From b9456b7f14b8a00ba45a18626f83ceab0538ebfd Mon Sep 17 00:00:00 2001 From: ksj09406 Date: Thu, 10 Sep 2026 20:43:14 +0900 Subject: [PATCH] =?UTF-8?q?4=EC=B0=A8=20B-1:=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일 짝과 폴더 짝을 따로 15개까지 기억한다. 탭으로 나눈 글로 설정 폴더에 둔다. 없는 경로는 흐리게 보여 주고 지우지 않는다. 검사 33건 추가. --- doc/history/2026-09-10-4차.md | 42 ++++- doc/history/changelog.md | 2 + doc/진행상황.md | 2 +- src/MergeHub/Services/GitWorkspace.cs | 20 +++ src/MergeHub/Services/RecentPairs.cs | 195 +++++++++++++++++++++ src/MergeHub/ViewModels/FolderViewModel.cs | 35 ++++ src/MergeHub/ViewModels/HomeViewModel.cs | 38 ++++ src/MergeHub/Views/FolderView.xaml | 29 ++- src/MergeHub/Views/FolderView.xaml.cs | 12 ++ src/MergeHub/Views/HomeView.xaml | 26 +++ src/MergeHub/Views/HomeView.xaml.cs | 14 ++ 11 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 src/MergeHub/Services/RecentPairs.cs 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 @@ + +