diff --git a/doc/history/2026-09-10-4차.md b/doc/history/2026-09-10-4차.md
index dbd31f1..47278d1 100644
--- a/doc/history/2026-09-10-4차.md
+++ b/doc/history/2026-09-10-4차.md
@@ -156,4 +156,44 @@ MergeHub 파일 비교 결과
화면의 머지 주석 미리보기가 `// [김승준] 2026-09-10` 이었으니 **두 이름이 같다.**
-검사 8건 추가(`ReportTest` 23 → 31). 전체 **522건 통과** · 빌드 경고 0 · 오류 0.
\ No newline at end of file
+검사 8건 추가(`ReportTest` 23 → 31). 전체 **522건 통과** · 빌드 경고 0 · 오류 0.
+---
+
+## B-1. 최근 비교 목록 — **완료** (2026-09-10)
+
+**왜** - 같은 짝을 반복해서 여는데 그때마다 경로를 다시 골라야 했다. 매일 쓰는 사람에게 가장 잦은 마찰이다.
+
+`Services/RecentPairs.cs` 신설. 화면 없이 검사할 수 있게 **기억하는 일만** 여기서 한다.
+
+**파일 짝과 폴더 짝을 따로 둔다.** 한 목록에 섞으면 파일 비교 화면에서 폴더가 나온다.
+상한은 15개 - 목록이 길면 고르는 것이 더 느려진다.
+
+**저장 형식은 탭으로 나눈 글이다.** JSON 을 안 쓴 까닭 - 줄 몇 개가 전부라 라이브러리를 하나 더 매달 값이 없고,
+글이면 사람이 열어 보고 손으로 고칠 수도 있다. 윈도우 경로에는 탭이 못 들어가서 나누는 데 문제가 없다.
+자리는 `D:\UbisamConfig\Settings\MergeHub.Recent.txt` 다. **설정 화면에는 안 내놓는다** -
+사람이 손으로 고칠 값이 아니고 설정 목록만 어지럽힌다.
+
+**켤 때 한 번 읽고 바뀔 때마다 바로 쓴다.** 프로그램이 갑자기 꺼져도 목록이 남게 하려는 것이다.
+
+**없는 경로를 자동으로 지우지 않는다.** 지시서가 그렇게 못박았다 - 네트워크 드라이브나 USB 는
+잠깐 안 보일 뿐인데 지워 버리면 다시 찾아 들어가야 한다. 흐리게 보여 주고 열려고 할 때 알린다.
+
+**비교가 끝난 짝만 담는다.** 읽다 실패했거나 취소한 짝을 남기면 다음에 골랐을 때 또 실패한다.
+
+**목록 이름은 양쪽 끝 이름만** 쓴다. 전체 경로는 길어서 목록에서 읽히지 않는다.
+두 이름이 같으면 하나만 적고(`Other.cpp`), 다르면 둘 다 적는다(`기준.cs ↔ 내작업.cs`). 전체 경로는 툴팁에 있다.
+
+여는 길은 다른 데와 같은 `RequestPlainCompare` 다. 그래서 **저장하지 않은 편집도 A-1 에서 함께 걸린다.**
+
+**화면에서 확인한 것**
+
+| 한 일 | 결과 |
+|---|---|
+| 파일 비교 · 폴더 비교를 한 번씩 | `MergeHub.Recent.txt` 에 `F` 줄과 `D` 줄이 하나씩 |
+| 프로그램을 껐다 켜기 | 두 목록이 그대로 남았다 |
+| 폴더 최근에서 고르기 | 두 칸이 채워지고 비교가 바로 돌았다 (`전체 2개 · 다름 2`) |
+| 파일 최근에서 고르기 | `BASE · Weld.cpp` / `LOCAL · Weld.cpp` 로 열렸다 |
+| 짝의 두 파일을 지운 뒤 | 목록에 그대로 남고 **흐리게** 보였다 |
+| 그 죽은 짝을 고르기 | `두 파일 모두 찾을 수 없습니다` 토스트, 화면은 그대로 |
+
+검사 33건 추가(`RecentTest`). 전체 **555건 통과** · 빌드 경고 0 · 오류 0.
\ No newline at end of file
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index ce347fa..3fa711f 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 2026-09-10 | **4차 B-1** — 최근에 견준 파일 짝·폴더 짝을 기억해 위 줄에서 바로 연다. 없는 경로는 흐리게만 하고 지우지 않는다 | 아래 |
| 2026-09-10 | **4차 A-2** — 내보낸 보고서 머리글에 만든 사람을 넣음. 이름 판단을 머지 주석과 한 함수로 모았다 | 아래 |
| 2026-09-10 | **4차 A-1** — 저장하지 않은 편집이 말없이 사라지던 길 여덟 군데를 막음. 플랫폼에 세 갈래 확인 창을 새로 넣었다 | 아래 |
| 2026-09-10 | **4차 D-2** — 항목을 끝내면 상세·상태·한 줄을 각각 어디에 적을지 공통 규칙으로 못박음 | 아래 |
@@ -29,6 +30,7 @@
| 날짜 | 문서 | 내용 |
|---|---|---|
+| 2026-09-10 | **4차 B-1** — 최근에 견준 파일 짝·폴더 짝을 기억해 위 줄에서 바로 연다. 없는 경로는 흐리게만 하고 지우지 않는다 | 아래 |
| 2026-09-10 | **4차 A-2** — 내보낸 보고서 머리글에 만든 사람을 넣음. 이름 판단을 머지 주석과 한 함수로 모았다 | 아래 |
| 2026-09-10 | **4차 A-1** — 저장하지 않은 편집이 말없이 사라지던 길 여덟 군데를 막음. 플랫폼에 세 갈래 확인 창을 새로 넣었다 | 아래 |
| 2026-09-10 | **4차 D-2** — 항목을 끝내면 상세·상태·한 줄을 각각 어디에 적을지 공통 규칙으로 못박음 | 아래 |
diff --git a/doc/진행상황.md b/doc/진행상황.md
index 660669d..b863029 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -29,7 +29,7 @@
| 상태 | 항목 |
|---|---|
-| 대기 | B-1 최근 비교 목록 |
+| **완료** | B-1 최근 비교 목록 |
| 대기 | B-2 같은 줄 접기 |
| 대기 | B-3 폴더 비교에서 다음 다른 파일로 |
| 대기 | B-4 외부 편집기로 열기 |
diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs
index d220445..f4f9658 100644
--- a/src/MergeHub/Services/GitWorkspace.cs
+++ b/src/MergeHub/Services/GitWorkspace.cs
@@ -48,6 +48,11 @@ namespace MergeHub.Services
// 그러면 주석에 등록한 이름 대신 윈도우 계정이 찍힌다.
AuthorName = ResolveAuthorName();
+ // 최근 목록은 켤 때 한 번 읽고, 바뀔 때마다 바로 쓴다.
+ // 프로그램이 갑자기 꺼져도 목록이 남게 하려는 것이다 - 몇 줄짜리 글이라 쓰는 값이 싸다.
+ Recent.Load(RecentPath);
+ Recent.Changed += (_, _) => Recent.Save(RecentPath);
+
// `git mergetool` · 탐색기 우클릭이 넘긴 것을 화면으로 보낸다.
// 여기서 하는 이유 - 이 객체는 첫 화면이 만들어질 때 함께 만들어지므로
// 요청을 남겨 둘 자리로 가장 이르다.
@@ -71,6 +76,21 @@ namespace MergeHub.Services
public IGitService Git { get; }
+ ///
+ /// 최근에 견준 짝. 파일 비교와 폴더 비교가 함께 본다.
+ ///
+ /// 여기에 두는 까닭 - 두 화면이 서로를 모르는 채로 같은 목록을 봐야 하고,
+ /// 이 객체가 이미 그 통로 노릇을 하고 있다.
+ ///
+ public RecentPairs Recent { get; } = new RecentPairs();
+
+ ///
+ /// 최근 목록을 두는 자리. 플랫폼 설정 폴더 아래에 둔다 - 장비마다 한 자리에 모인다.
+ /// 설정 화면에는 내놓지 않는다. 사람이 손으로 고칠 값이 아니고, 설정 목록만 어지럽힌다.
+ ///
+ private static string RecentPath => Path.Combine(
+ UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot, "Settings", "MergeHub.Recent.txt");
+
public MergeHubSettings Settings { get; }
/// 저장소 상태가 다시 읽힐 때마다 발생. 화면들이 자기 패널을 갱신한다.
diff --git a/src/MergeHub/Services/RecentPairs.cs b/src/MergeHub/Services/RecentPairs.cs
new file mode 100644
index 0000000..c2671a7
--- /dev/null
+++ b/src/MergeHub/Services/RecentPairs.cs
@@ -0,0 +1,195 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+
+namespace MergeHub.Services
+{
+ /// 최근 목록에 담기는 것이 파일 짝인지 폴더 짝인지.
+ public enum RecentKind
+ {
+ Files,
+ Folders
+ }
+
+ /// 최근에 견준 짝 하나.
+ public sealed class RecentPair
+ {
+ public RecentPair(RecentKind kind, string left, string right, DateTime when)
+ {
+ Kind = kind;
+ Left = left ?? string.Empty;
+ Right = right ?? string.Empty;
+ When = when;
+ }
+
+ public RecentKind Kind { get; }
+ public string Left { get; }
+ public string Right { get; }
+ public DateTime When { get; }
+
+ ///
+ /// 목록에 뿌릴 이름. 양쪽 끝 이름만 보여 준다 - 전체 경로는 길어서 목록에서 읽히지 않고,
+ /// 어느 짝인지 가르는 것은 대개 파일 이름이다. 전체 경로는 툴팁에 있다.
+ ///
+ public string Label
+ {
+ get
+ {
+ var l = Tail(Left);
+ var r = Tail(Right);
+ return string.Equals(l, r, StringComparison.OrdinalIgnoreCase) ? l : $"{l} ↔ {r}";
+ }
+ }
+
+ public string Tip => $"{Left}{Environment.NewLine}{Right}";
+
+ ///
+ /// 양쪽이 다 있는지. 없다고 목록에서 지우지는 않는다 -
+ /// 네트워크 드라이브나 USB 는 잠깐 안 보일 뿐인데 지워 버리면 다시 찾아 들어가야 한다.
+ /// 흐리게 보여 주고, 열려고 할 때 알린다.
+ ///
+ public bool Exists => Kind == RecentKind.Folders
+ ? Directory.Exists(Left) && Directory.Exists(Right)
+ : File.Exists(Left) || File.Exists(Right);
+
+ private static string Tail(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return "-";
+
+ var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var name = Path.GetFileName(trimmed);
+
+ // 드라이브 뿌리(C:\)는 이름이 비어 나온다. 그때는 경로를 그대로 쓴다.
+ return name.Length > 0 ? name : trimmed;
+ }
+ }
+
+ ///
+ /// 최근에 견준 짝을 기억한다.
+ ///
+ /// 같은 짝을 반복해서 여는 일이 잦은데 그때마다 경로를 다시 골라야 했다.
+ /// 파일 짝과 폴더 짝을 따로 둔다 - 한 목록에 섞으면 파일 비교 화면에서 폴더가 나온다.
+ ///
+ /// 저장 형식은 탭으로 나눈 글이다. JSON 을 쓰지 않는 까닭 - 이 목록은 줄 몇 개가 전부라
+ /// 라이브러리를 하나 더 매달 값이 없고, 글이면 사람이 열어 보고 손으로 고칠 수도 있다.
+ /// 윈도우 경로에는 탭이 들어갈 수 없어 나누는 데 문제가 없다.
+ ///
+ public sealed class RecentPairs
+ {
+ /// 이보다 많이 쌓지 않는다. 목록이 길면 고르는 것이 더 느려진다.
+ public const int Max = 15;
+
+ private readonly List _files = new List();
+ private readonly List _folders = new List();
+
+ public IReadOnlyList Files => _files;
+ public IReadOnlyList Folders => _folders;
+
+ /// 목록이 바뀌었다. 화면이 다시 그리도록.
+ public event EventHandler? Changed;
+
+ public void Add(RecentKind kind, string left, string right, DateTime when)
+ {
+ if (string.IsNullOrWhiteSpace(left) && string.IsNullOrWhiteSpace(right)) return;
+
+ var list = kind == RecentKind.Folders ? _folders : _files;
+
+ // 같은 짝이 이미 있으면 지우고 맨 위에 다시 넣는다. 최근 것이 위여야 한다.
+ list.RemoveAll(p => Same(p, left, right));
+ list.Insert(0, new RecentPair(kind, left ?? string.Empty, right ?? string.Empty, when));
+
+ while (list.Count > Max) list.RemoveAt(list.Count - 1);
+
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void Add(RecentKind kind, string left, string right) => Add(kind, left, right, DateTime.Now);
+
+ private static bool Same(RecentPair pair, string? left, string? right) =>
+ string.Equals(pair.Left, left ?? string.Empty, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(pair.Right, right ?? string.Empty, StringComparison.OrdinalIgnoreCase);
+
+ // ================= 글로 옮기기 =================
+
+ private const string Header = "# MergeHub 최근 비교 목록";
+
+ public string Serialize()
+ {
+ var text = new StringBuilder();
+ text.AppendLine(Header);
+
+ foreach (var pair in _files) Write(text, pair);
+ foreach (var pair in _folders) Write(text, pair);
+
+ return text.ToString();
+ }
+
+ private static void Write(StringBuilder text, RecentPair pair) =>
+ text.AppendLine(string.Join("\t",
+ pair.Kind == RecentKind.Folders ? "D" : "F",
+ pair.When.ToString("yyyy-MM-ddTHH:mm:ss"),
+ pair.Left,
+ pair.Right));
+
+ ///
+ /// 글에서 되읽는다. 알아볼 수 없는 줄은 조용히 건너뛴다 -
+ /// 최근 목록 하나 때문에 프로그램이 안 켜지면 안 된다.
+ ///
+ public void Parse(string? content)
+ {
+ _files.Clear();
+ _folders.Clear();
+
+ if (string.IsNullOrWhiteSpace(content)) { Changed?.Invoke(this, EventArgs.Empty); return; }
+
+ foreach (var raw in content!.Replace("\r\n", "\n").Split('\n'))
+ {
+ var line = raw.TrimEnd();
+ if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue;
+
+ var parts = line.Split('\t');
+ if (parts.Length < 4) continue;
+
+ var kind = parts[0] == "D" ? RecentKind.Folders : RecentKind.Files;
+ if (!DateTime.TryParse(parts[1], out var when)) when = DateTime.MinValue;
+
+ var list = kind == RecentKind.Folders ? _folders : _files;
+ if (list.Count >= Max) continue;
+
+ list.Add(new RecentPair(kind, parts[2], parts[3], when));
+ }
+
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ // ================= 파일 =================
+
+ public void Load(string path)
+ {
+ try
+ {
+ Parse(File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : null);
+ }
+ catch (IOException) { Parse(null); }
+ catch (UnauthorizedAccessException) { Parse(null); }
+ }
+
+ ///
+ /// 저장에 실패해도 조용히 넘어간다. 최근 목록은 편의 기능이라
+ /// 이것 때문에 하던 일이 막히면 안 된다.
+ ///
+ public void Save(string path)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir!);
+
+ File.WriteAllText(path, Serialize(), new UTF8Encoding(true));
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/MergeHub/ViewModels/FolderViewModel.cs b/src/MergeHub/ViewModels/FolderViewModel.cs
index ef73643..13f8dd4 100644
--- a/src/MergeHub/ViewModels/FolderViewModel.cs
+++ b/src/MergeHub/ViewModels/FolderViewModel.cs
@@ -121,6 +121,8 @@ namespace MergeHub.ViewModels
// 탐색기 우클릭으로 켰으면 그 폴더가 왼쪽에 들어온다.
_workspace.FolderCompareRequested += OnFolderCompareRequested;
+ _workspace.Recent.Changed += (_, _) => _workspace.OnUi(RefreshRecent);
+ RefreshRecent();
_workspace.DeliverPendingFolderCompare();
// 저장소를 열어 뒀으면 왼쪽 기본값으로 삼는다 - 대개 그 폴더를 견주게 된다.
@@ -151,6 +153,36 @@ namespace MergeHub.ViewModels
public ObservableCollection Nodes { get; } = new ObservableCollection();
+ // ================= 최근 비교 목록 =================
+
+ /// 최근에 견준 폴더 짝. 파일 짝과 따로 둔다.
+ public ObservableCollection RecentFolders { get; } = new ObservableCollection();
+
+ private void RefreshRecent()
+ {
+ RecentFolders.Clear();
+ foreach (var pair in _workspace.Recent.Folders) RecentFolders.Add(pair);
+ }
+
+ ///
+ /// 최근 목록에서 고른 짝으로 곧바로 견준다.
+ /// 자리를 옮겼거나 지워진 짝이라도 목록에서 지우지 않는다 - 왜 못 여는지만 알려 준다.
+ ///
+ public void OpenRecent(RecentPair? pair)
+ {
+ if (pair is null) return;
+
+ if (!Directory.Exists(pair.Left) || !Directory.Exists(pair.Right))
+ {
+ MessageUtil.ShowWarningToast("폴더를 찾을 수 없습니다. 자리를 옮겼거나 지워진 것 같습니다.");
+ return;
+ }
+
+ LeftFolder = pair.Left;
+ RightFolder = pair.Right;
+ _ = CompareAsync();
+ }
+
[ObservableProperty] private string _leftFolder = string.Empty;
[ObservableProperty] private string _rightFolder = string.Empty;
@@ -325,6 +357,9 @@ namespace MergeHub.ViewModels
Rebuild();
Summary = result.Summary;
_workspace.AppendLog($"폴더 비교: {left} ↔ {right} · {result.Summary}");
+
+ // 비교가 끝난 짝만 담는다. 취소했거나 실패한 짝은 최근에 남길 값이 없다.
+ _workspace.Recent.Add(RecentKind.Folders, left, right);
}
catch (OperationCanceledException)
{
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index baa0488..d4736d4 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -37,8 +37,11 @@ namespace MergeHub.ViewModels
_workspace.Refreshed += OnWorkspaceRefreshed;
_workspace.StateChanged += RaiseCanExecuteChanged;
_workspace.PlainCompareRequested += OnPlainCompareRequested;
+ _workspace.Recent.Changed += (_, _) => _workspace.OnUi(RefreshRecent);
_reflowTimer.Tick += (_, _) => Reflow();
+ RefreshRecent();
+
LoadRules();
IsGitMode = _workspace.IsRepositoryOpen;
RefreshRemoteBranches();
@@ -187,6 +190,37 @@ namespace MergeHub.ViewModels
/// 비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일).
public ObservableCollection Files { get; } = new ObservableCollection();
+ // ================= 최근 비교 목록 =================
+
+ /// 최근에 견준 파일 짝. 위 줄의 목록에 뿌린다.
+ public ObservableCollection RecentFiles { get; } = new ObservableCollection();
+
+ private void RefreshRecent()
+ {
+ RecentFiles.Clear();
+ foreach (var pair in _workspace.Recent.Files) RecentFiles.Add(pair);
+ }
+
+ ///
+ /// 최근 목록에서 고른 짝을 연다.
+ ///
+ /// 자리를 옮겼거나 지워진 짝이라도 목록에서 지우지 않는다 - 네트워크 드라이브나 USB 는
+ /// 잠깐 안 보일 뿐이다. 대신 왜 못 여는지 알려 준다.
+ /// 여는 길은 다른 데와 같다(RequestPlainCompare) - 저장하지 않은 편집도 그 길에서 함께 걸린다.
+ ///
+ public void OpenRecent(RecentPair? pair)
+ {
+ if (pair is null) return;
+
+ if (!File.Exists(pair.Left) && !File.Exists(pair.Right))
+ {
+ MessageUtil.ShowWarningToast("두 파일 모두 찾을 수 없습니다. 자리를 옮겼거나 지워진 것 같습니다.");
+ return;
+ }
+
+ _workspace.RequestPlainCompare(pair.Left, pair.Right);
+ }
+
[ObservableProperty] private string? _selectedFile;
[ObservableProperty] private MergeLine? _selectedLine;
[ObservableProperty] private string _baseHeader = "BASE";
@@ -1728,6 +1762,10 @@ namespace MergeHub.ViewModels
RefreshDiffPosition();
RaiseCanExecuteChanged();
+ // 다 읽고 나서 최근 목록에 담는다. 읽다 실패한 짝을 목록에 남기면
+ // 다음에 골랐을 때 또 실패한다.
+ _workspace.Recent.Add(RecentKind.Files, LeftFile, RightFile);
+
// 줄을 고르는 것만으로는 화면이 따라오지 않는다. 캐럿까지 옮겨야 굴러간다.
if (asked >= 0) GoToRow(asked);
});
diff --git a/src/MergeHub/Views/FolderView.xaml b/src/MergeHub/Views/FolderView.xaml
index 487d386..9d1bffa 100644
--- a/src/MergeHub/Views/FolderView.xaml
+++ b/src/MergeHub/Views/FolderView.xaml
@@ -97,6 +97,8 @@
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MergeHub/Views/FolderView.xaml.cs b/src/MergeHub/Views/FolderView.xaml.cs
index 015db85..530795a 100644
--- a/src/MergeHub/Views/FolderView.xaml.cs
+++ b/src/MergeHub/Views/FolderView.xaml.cs
@@ -52,6 +52,18 @@ namespace MergeHub.Views
e.Handled = true;
}
+ ///
+ /// 최근 목록에서 하나를 고르면 그 짝으로 곧바로 견준다.
+ /// 고른 뒤 바로 선택을 지운다 - 같은 짝을 다시 고를 수 있어야 한다.
+ ///
+ private void Recent_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (sender is not ComboBox box || box.SelectedItem is not Services.RecentPair pair) return;
+
+ box.SelectedIndex = -1;
+ if (DataContext is FolderViewModel vm) vm.OpenRecent(pair);
+ }
+
/// 찾기 칸에서 Enter 를 누르면 바로 찾는다. 단추까지 가는 걸음을 줄인다.
private void SearchBox_KeyDown(object sender, KeyEventArgs e)
{
diff --git a/src/MergeHub/Views/HomeView.xaml b/src/MergeHub/Views/HomeView.xaml
index f037f14..8b93dbc 100644
--- a/src/MergeHub/Views/HomeView.xaml
+++ b/src/MergeHub/Views/HomeView.xaml
@@ -396,6 +396,32 @@
ToolTip="{Binding RightFile}"
Background="Transparent"
AllowDrop="True" DragOver="View_DragOver" Drop="RightFile_Drop"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+