diff --git a/doc/history/2026-09-10-4차.md b/doc/history/2026-09-10-4차.md
index 47278d1..2c45a48 100644
--- a/doc/history/2026-09-10-4차.md
+++ b/doc/history/2026-09-10-4차.md
@@ -196,4 +196,40 @@ MergeHub 파일 비교 결과
| 짝의 두 파일을 지운 뒤 | 목록에 그대로 남고 **흐리게** 보였다 |
| 그 죽은 짝을 고르기 | `두 파일 모두 찾을 수 없습니다` 토스트, 화면은 그대로 |
-검사 33건 추가(`RecentTest`). 전체 **555건 통과** · 빌드 경고 0 · 오류 0.
\ No newline at end of file
+검사 33건 추가(`RecentTest`). 전체 **555건 통과** · 빌드 경고 0 · 오류 0.
+---
+
+## B-3. 폴더 비교에서 다음 다른 파일로 — **완료** (2026-09-10)
+
+**왜** - 파일 하나를 보고 **폴더 탭으로 돌아갔다가** 다음 파일을 다시 열어야 했다.
+다른 파일이 스무 개면 이 왕복을 스무 번 한다.
+
+`GitWorkspace` 에 훑는 중인 목록(`Queue`)과 자리(`QueueIndex`)를 뒀다.
+폴더 비교가 채우고 파일 비교가 딛는다 - 두 화면이 서로를 모르는 채로 같은 목록을 봐야 해서
+이미 통로 노릇을 하는 이 객체에 뒀다.
+
+**원본 결과가 아니라 지금 트리를 훑어 목록을 만든다.** 필터로 "코드가 다른 것만" 을 걸어 뒀으면
+그 안에서만 움직여야 한다 - 목록에 없는 파일로 넘어가면 왜 거기로 갔는지 알 수 없다.
+
+**찾은 줄 목록도 훑을 목록이 된다.** 같은 파일이 여러 줄 걸렸으면 한 번만 담는다 -
+다음 파일을 눌렀는데 같은 파일이 다시 열리면 멈춘 것처럼 보인다.
+
+**최근 목록으로 연 파일은 목록을 비운다.** 남아 있던 목록을 그대로 두면 다음 파일이 엉뚱한 데로 간다.
+
+**먼저 묻고 그 다음에 자리를 옮긴다.** 확인 창에서 취소하면 자리도 그대로여야 하는데,
+자리를 먼저 옮기면 취소했을 때 목록 위치만 앞서 나간다. 물은 뒤에는 두 번 묻지 않도록 막는다.
+
+단축키는 `Ctrl+PageUp` · `Ctrl+PageDown` 이다. `Alt+PageDown`(북마크)은 그대로 두었고
+검사로 빼앗지 않았음을 확인한다.
+
+**화면에서 확인한 것** (파일 네 개 · 하나는 하위 폴더)
+
+| 한 일 | 자리 | 열린 파일 | 이전/다음 |
+|---|---|---|---|
+| 검색 결과로 열기 | 1 / 4 | A.cpp | 끔 / 켬 |
+| 다음 파일 ×3 | 2 → 3 → 4 | B.cpp → C.cpp → D.cpp | 마지막에서 다음이 꺼짐 |
+| 이전 파일 ×2 | 3 → 2 | C.cpp → B.cpp | 둘 다 켬 |
+| 고친 뒤 다음 파일 → 취소 | **2 / 4 그대로** | B.cpp 그대로 | 편집 내용도 그대로 |
+| 다시 눌러 버리고 계속 | 3 / 4 | C.cpp | B.cpp 파일은 손대지 않음 |
+
+검사 5건 추가(`ShortcutTest` 63 → 68). 전체 **560건 통과** · 빌드 경고 0 · 오류 0.
\ No newline at end of file
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index 3fa711f..03e8a74 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 2026-09-10 | **4차 B-3** — 폴더 비교 목록을 파일 비교 화면에서 이전/다음으로 딛는다 (Ctrl+PageUp/PageDown) | 아래 |
| 2026-09-10 | **4차 B-1** — 최근에 견준 파일 짝·폴더 짝을 기억해 위 줄에서 바로 연다. 없는 경로는 흐리게만 하고 지우지 않는다 | 아래 |
| 2026-09-10 | **4차 A-2** — 내보낸 보고서 머리글에 만든 사람을 넣음. 이름 판단을 머지 주석과 한 함수로 모았다 | 아래 |
| 2026-09-10 | **4차 A-1** — 저장하지 않은 편집이 말없이 사라지던 길 여덟 군데를 막음. 플랫폼에 세 갈래 확인 창을 새로 넣었다 | 아래 |
@@ -30,6 +31,7 @@
| 날짜 | 문서 | 내용 |
|---|---|---|
+| 2026-09-10 | **4차 B-3** — 폴더 비교 목록을 파일 비교 화면에서 이전/다음으로 딛는다 (Ctrl+PageUp/PageDown) | 아래 |
| 2026-09-10 | **4차 B-1** — 최근에 견준 파일 짝·폴더 짝을 기억해 위 줄에서 바로 연다. 없는 경로는 흐리게만 하고 지우지 않는다 | 아래 |
| 2026-09-10 | **4차 A-2** — 내보낸 보고서 머리글에 만든 사람을 넣음. 이름 판단을 머지 주석과 한 함수로 모았다 | 아래 |
| 2026-09-10 | **4차 A-1** — 저장하지 않은 편집이 말없이 사라지던 길 여덟 군데를 막음. 플랫폼에 세 갈래 확인 창을 새로 넣었다 | 아래 |
diff --git a/doc/진행상황.md b/doc/진행상황.md
index b863029..571fd99 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -31,7 +31,7 @@
|---|---|
| **완료** | B-1 최근 비교 목록 |
| 대기 | B-2 같은 줄 접기 |
-| 대기 | B-3 폴더 비교에서 다음 다른 파일로 |
+| **완료** | B-3 폴더 비교에서 다음 다른 파일로 |
| 대기 | B-4 외부 편집기로 열기 |
### C. 화면 구성 정리
diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs
index f4f9658..89f77f6 100644
--- a/src/MergeHub/Services/GitWorkspace.cs
+++ b/src/MergeHub/Services/GitWorkspace.cs
@@ -13,6 +13,24 @@ using UbisamBase.Core.SettingItems;
namespace MergeHub.Services
{
+ ///
+ /// 줄 단위로 견줄 파일 한 짝. 폴더 비교가 파일 비교로 넘기는 목록의 단위다.
+ ///
+ public sealed class ComparePair
+ {
+ public ComparePair(string left, string right, string name)
+ {
+ Left = left ?? string.Empty;
+ Right = right ?? string.Empty;
+ Name = name ?? string.Empty;
+ }
+
+ public string Left { get; }
+ public string Right { get; }
+
+ /// 화면에 적을 이름. 대개 상대 경로다.
+ public string Name { get; }
+ }
///
/// 홈(머지툴) 탭과 Git 탭이 함께 쓰는 저장소 상태.
/// IGitService 는 스레드 안전하지 않으므로 모든 호출을 여기서 한 줄로 직렬화한다.
@@ -408,6 +426,34 @@ namespace MergeHub.Services
OnUi(DeliverPendingPlainCompare);
}
+ // ================= 폴더 비교에서 훑는 중인 파일 목록 =================
+
+ ///
+ /// 폴더 비교가 넘긴 "지금 훑는 중인 파일들". 파일 비교 화면의 이전/다음 파일이 이걸 딛는다.
+ ///
+ /// 화면에 보이는 순서 그대로 담는다 - 필터로 "코드가 다른 것만" 을 걸어 뒀으면
+ /// 그 안에서만 움직여야 한다. 목록에 없는 파일로 넘어가면 왜 거기로 갔는지 알 수 없다.
+ ///
+ public IReadOnlyList Queue { get; private set; } = Array.Empty();
+
+ /// 지금 몇 번째를 보고 있는지. 목록이 없으면 -1.
+ public int QueueIndex { get; set; } = -1;
+
+ /// 목록이나 자리가 바뀌었다.
+ public event EventHandler? QueueChanged;
+
+ public void SetQueue(IReadOnlyList queue, int index)
+ {
+ Queue = queue ?? Array.Empty();
+ QueueIndex = index;
+ OnUi(() => QueueChanged?.Invoke(this, EventArgs.Empty));
+ }
+
+ /// 목록을 비운다. 폴더를 거치지 않고 연 파일은 딛고 갈 자리가 없다.
+ public void ClearQueue() => SetQueue(Array.Empty(), -1);
+
+ public void RaiseQueueChanged() => OnUi(() => QueueChanged?.Invoke(this, EventArgs.Empty));
+
/// 넘긴 파일에서 짚어 줄 자리. 없으면 0.
public int RequestedLine { get; private set; }
diff --git a/src/MergeHub/Services/ShortcutMap.cs b/src/MergeHub/Services/ShortcutMap.cs
index fae5dd4..c8232d5 100644
--- a/src/MergeHub/Services/ShortcutMap.cs
+++ b/src/MergeHub/Services/ShortcutMap.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Input;
@@ -20,6 +20,8 @@ namespace MergeHub.Services
Recompare,
GoToLine,
NextBookmark,
+ PrevFile,
+ NextFile,
ToggleBookmark,
AcceptBlock,
NextConflict,
@@ -133,6 +135,12 @@ namespace MergeHub.Services
new ShortcutEntry(ShortcutAction.NextBookmark, Key.F2, ModifierKeys.None, "이동·북마크", "다음 북마크 (대체 키)"),
new ShortcutEntry(ShortcutAction.GoToLine, Key.G, ModifierKeys.Control, "이동·북마크", "줄 번호로 이동"),
+ new ShortcutEntry(ShortcutAction.PrevFile, Key.PageUp, ModifierKeys.Control, "이동·북마크",
+ "폴더 비교 목록에서 앞 파일로"),
+ new ShortcutEntry(ShortcutAction.NextFile, Key.PageDown, ModifierKeys.Control, "이동·북마크",
+ "폴더 비교 목록에서 다음 파일로",
+ "폴더 비교에서 파일을 열었을 때만 동작한다. 파일 하나를 보고 폴더 탭으로 돌아갔다 다시 여는 왕복을 없애려는 것이다."),
+
new ShortcutEntry(ShortcutAction.OpenFolderCompare, Key.D, ModifierKeys.Control, "파일·보기", "폴더 비교 열기",
"WinMerge 는 새 비교 창을 띄우지만 여기서는 탭 하나로 돌아간다 - 창을 여러 개 띄우는 도구가 아니다."),
new ShortcutEntry(ShortcutAction.Recompare, Key.F5, ModifierKeys.None, "파일·보기", "다시 비교(새로고침)"),
diff --git a/src/MergeHub/ViewModels/FolderViewModel.cs b/src/MergeHub/ViewModels/FolderViewModel.cs
index 13f8dd4..6730795 100644
--- a/src/MergeHub/ViewModels/FolderViewModel.cs
+++ b/src/MergeHub/ViewModels/FolderViewModel.cs
@@ -540,6 +540,19 @@ namespace MergeHub.ViewModels
return;
}
+ // 찾은 줄 목록도 훑을 목록이 된다. 같은 파일이 여러 줄 걸렸으면 한 번만 담는다 -
+ // 다음 파일을 눌렀는데 같은 파일이 다시 열리면 멈춘 것처럼 보인다.
+ var files = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var one in SearchHits)
+ {
+ if (seen.Add(one.RelativePath))
+ files.Add(new ComparePair(one.LeftPath, one.RightPath, one.RelativePath));
+ }
+
+ _workspace.SetQueue(files, files.FindIndex(p =>
+ string.Equals(p.Name, hit.RelativePath, StringComparison.OrdinalIgnoreCase)));
+
OpenInDiffRequested?.Invoke(this, (hit.LeftPath, hit.RightPath));
// 오른쪽에서만 나온 줄이면 오른쪽 파일 기준 번호다.
@@ -629,6 +642,9 @@ namespace MergeHub.ViewModels
return;
}
+ // 지금 화면에 보이는 순서대로 목록을 넘긴다. 파일 비교 화면이 이걸로 이전/다음 파일을 딛는다.
+ SendQueue(node);
+
OpenInDiffRequested?.Invoke(this, (node.LeftPath, node.RightPath));
// 요청을 먼저 걸고 탭을 연다. 파일 비교 화면이 아직 없으면 그 화면이 만들어지면서
@@ -637,6 +653,31 @@ namespace MergeHub.ViewModels
UbisamBase.Core.Modules.TabManager.Activate(ViewIds.Home);
}
+ ///
+ /// 지금 목록에서 열 수 있는 파일을 보이는 차례대로 모아 넘긴다.
+ ///
+ /// 필터를 걸어 뒀으면 그 안에서만 움직여야 한다 - 목록에 없는 파일로 넘어가면
+ /// 왜 거기로 갔는지 알 수 없다. 그래서 원본 결과가 아니라 지금 트리를 훑는다.
+ ///
+ private void SendQueue(FolderNode opened)
+ {
+ var list = new List();
+ foreach (var root in Nodes) Collect(root, list);
+
+ var at = list.FindIndex(p =>
+ string.Equals(p.Left, opened.LeftPath, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(p.Right, opened.RightPath, StringComparison.OrdinalIgnoreCase));
+
+ _workspace.SetQueue(list, at);
+ }
+
+ private static void Collect(FolderNode node, List into)
+ {
+ if (node.CanOpenInDiff) into.Add(new ComparePair(node.LeftPath, node.RightPath, node.RelativePath));
+
+ foreach (var child in node.Children) Collect(child, into);
+ }
+
private bool CanCopyToRight() => SelectedNode is { } node && node.Item.LeftExists;
[RelayCommand(CanExecute = nameof(CanCopyToRight))]
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index d4736d4..770bea9 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -38,6 +38,7 @@ namespace MergeHub.ViewModels
_workspace.StateChanged += RaiseCanExecuteChanged;
_workspace.PlainCompareRequested += OnPlainCompareRequested;
_workspace.Recent.Changed += (_, _) => _workspace.OnUi(RefreshRecent);
+ _workspace.QueueChanged += (_, _) => RefreshQueue();
_reflowTimer.Tick += (_, _) => Reflow();
RefreshRecent();
@@ -190,6 +191,57 @@ namespace MergeHub.ViewModels
/// 비교 대상 파일 목록(충돌 중이면 충돌 파일, 아니면 선택 커밋의 파일).
public ObservableCollection Files { get; } = new ObservableCollection();
+ // ================= 폴더에서 훑는 중인 파일 이동 =================
+
+ /// 훑을 목록이 있는지. 폴더를 거치지 않고 연 파일에는 없다.
+ public bool HasFileQueue => _workspace.Queue.Count > 0 && _workspace.QueueIndex >= 0;
+
+ /// 지금 몇 번째인지. 예: 3 / 27.
+ public string FileQueuePosition =>
+ HasFileQueue ? $"{_workspace.QueueIndex + 1} / {_workspace.Queue.Count}" : string.Empty;
+
+ private bool CanPrevFile() => HasFileQueue && _workspace.QueueIndex > 0;
+ private bool CanNextFile() => HasFileQueue && _workspace.QueueIndex < _workspace.Queue.Count - 1;
+
+ [RelayCommand(CanExecute = nameof(CanPrevFile))]
+ private void PrevFile() => MoveFile(-1);
+
+ [RelayCommand(CanExecute = nameof(CanNextFile))]
+ private void NextFile() => MoveFile(1);
+
+ ///
+ /// 폴더 비교 목록에서 앞뒤 파일로 옮긴다.
+ ///
+ /// 여기서 먼저 묻고 그 다음에 자리를 옮긴다. 확인 창에서 취소하면 자리도 그대로여야 하는데,
+ /// 자리를 먼저 옮기면 취소했을 때 목록 위치만 앞서 나간다.
+ /// 물은 뒤에는 _suspendGuard 로 막는다 - 여는 길(RequestPlainCompare)이
+ /// 같은 것을 한 번 더 묻기 때문이다.
+ ///
+ private void MoveFile(int delta)
+ {
+ var next = _workspace.QueueIndex + delta;
+ if (next < 0 || next >= _workspace.Queue.Count) return;
+
+ var pair = _workspace.Queue[next];
+ if (!ConfirmDiscard($"다른 파일을 엽니다: {pair.Name}")) return;
+
+ _workspace.QueueIndex = next;
+
+ _suspendGuard = true;
+ try { _workspace.RequestPlainCompare(pair.Left, pair.Right); }
+ finally { _suspendGuard = false; }
+
+ RefreshQueue();
+ }
+
+ private void RefreshQueue()
+ {
+ OnPropertyChanged(nameof(HasFileQueue));
+ OnPropertyChanged(nameof(FileQueuePosition));
+ PrevFileCommand.NotifyCanExecuteChanged();
+ NextFileCommand.NotifyCanExecuteChanged();
+ }
+
// ================= 최근 비교 목록 =================
/// 최근에 견준 파일 짝. 위 줄의 목록에 뿌린다.
@@ -218,6 +270,9 @@ namespace MergeHub.ViewModels
return;
}
+ // 최근 목록으로 연 파일은 딛고 갈 목록이 없다. 남아 있던 목록을 그대로 두면
+ // 다음 파일이 엉뚱한 데로 간다.
+ _workspace.ClearQueue();
_workspace.RequestPlainCompare(pair.Left, pair.Right);
}
diff --git a/src/MergeHub/Views/HomeView.xaml b/src/MergeHub/Views/HomeView.xaml
index 8b93dbc..9441940 100644
--- a/src/MergeHub/Views/HomeView.xaml
+++ b/src/MergeHub/Views/HomeView.xaml
@@ -397,6 +397,17 @@
Background="Transparent"
AllowDrop="True" DragOver="View_DragOver" Drop="RightFile_Drop"/>
+
+
+
+
+