From 936d957fb45c4b5456d6de4f28103c5477c191c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:00:25 +0000 Subject: [PATCH 1/4] Tidy the app gallery: no splitter strip, no empty panel, no stray glyph Crop every picture at the bottom of the top zone, which the app now marks as a probe region, instead of one pixel above the log, which left the splitter and the log's border as a strip along the bottom. Open the two File menu dialogs over a selected repository so they are not seen against an empty panel, and fold the git actions away before propagating so the centred popup no longer lands across the similar repositories table with the tops of its digits showing above the title bar. Collapsing headers are marked so a stage can click them. The README now shows a preview from the gallery. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WWRBkw7bXdWGzuLpqnCvrB --- ProjectDirector/ProjectDirector.cs | 9 ++++++++- README.md | 2 ++ tests/ProjectDirector.UITests/Gallery/GalleryApp.cs | 8 ++++++-- .../Gallery/GalleryCatalog.cs | 12 +++++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 138a267..1ad6811 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -717,6 +717,11 @@ private void Tick(float dt) private void ShowTopPanel(float dt) { + // The divider draws each zone as its own child window, so this window is the whole top zone. + // Marking it lets a picture of the application stop above the splitter and the log. + Vector2 topPanelPosition = ImGui.GetWindowPos(); + ImGuiProbes.MarkRegion("TopPanel", topPanelPosition, topPanelPosition + ImGui.GetWindowSize()); + if (Options.Repos.TryGetValue(Options.BaseRepo, out GitRepository? repo)) { ImGui.TextUnformatted($"Selected Repo: {Options.BaseRepo}"); @@ -885,7 +890,9 @@ private void ShowCollapsiblePanel(string name, Action contentDelegate) } bool wasOpen = open; - if (ImGui.CollapsingHeader(name, flags)) + bool expanded = ImGui.CollapsingHeader(name, flags); + ImGuiProbes.MarkItem("panel", name); + if (expanded) { contentDelegate?.Invoke(); open = true; diff --git a/README.md b/README.md index 4b1b873..e571c8c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ canonical version out to the rest. The [app gallery](docs/gallery/README.md) shows the main views and dialogs as the application draws them today. +[![Two copies of a file compared side by side](docs/gallery/comparing-a-file.png)](docs/gallery/README.md) + ## Requirements - .NET 9.0 diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs index c8276b6..b85c9d1 100644 --- a/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs +++ b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs @@ -92,11 +92,15 @@ internal void RightClick(string name) /// Gets everything above the log panel. The log prefixes each line with the wall-clock time it /// was written, so it can never be photographed the same way twice. /// + /// + /// The crop ends at the bottom of the top zone rather than at the log itself, so neither the + /// splitter between the two nor the log's own border is left as a strip along the bottom. + /// /// The part of the window worth keeping. internal Rectangle AboveTheLog() { - Rectangle log = Harness.Probe.Rect("Log") ?? throw new InvalidOperationException("The log panel has not been drawn."); - return new Rectangle(0, 0, Harness.Options.Width, log.MinY - 1); + Rectangle top = Harness.Probe.Rect("TopPanel") ?? throw new InvalidOperationException("The top panel has not been drawn."); + return new Rectangle(0, 0, Harness.Options.Width, top.MaxY); } /// diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs b/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs index 28bdc71..12f7c05 100644 --- a/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs +++ b/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs @@ -55,6 +55,12 @@ internal static class GalleryCatalog app => { app.SelectRepository(GalleryRepositories.Alpha); + + // The popup opens in the middle of the window, which with every panel open is across + // the similar repositories table, leaving the tops of its glyphs showing above the + // title bar. Folding the git actions away lifts the table clear of it. + app.Harness.Click("panel/Git Actions"); + app.Harness.Step(2); app.RightClick("browse/README.md"); app.Harness.Click("browse-menu/Propagate"); app.Harness.Step(3); @@ -80,9 +86,13 @@ internal static class GalleryCatalog private static Rectangle? AboveTheLog(GalleryApp app) => app.AboveTheLog(); - /// Opens a popup from the File menu. The popup is only ever shown, never confirmed. + /// + /// Opens a popup from the File menu over a selected repository, so the popup is seen against the + /// application rather than an empty panel. The popup is only ever shown, never confirmed. + /// private static void OpenMenuItem(GalleryApp app, string item) { + app.SelectRepository(GalleryRepositories.Alpha); app.Harness.Click("menu/File"); app.Harness.Step(2); app.Harness.Click(item); From 8e311260b243826af4ddc100f4709d774781b90f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:13:38 +0000 Subject: [PATCH 2/4] Fix the compare browser listing directories as files The compare browser told directories apart by a trailing separator on each entry, but a RelativePath drops it, so no entry ever matched: every directory was listed among the files and could not be opened. Both browsers now ask the file system through ListBrowserDirectories, covered by BrowserDirectoryTests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WWRBkw7bXdWGzuLpqnCvrB --- ProjectDirector.Test/BrowserDirectoryTests.cs | 69 +++++++++++++++++++ ProjectDirector/ProjectDirector.cs | 19 ++++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 ProjectDirector.Test/BrowserDirectoryTests.cs diff --git a/ProjectDirector.Test/BrowserDirectoryTests.cs b/ProjectDirector.Test/BrowserDirectoryTests.cs new file mode 100644 index 0000000..09afc78 --- /dev/null +++ b/ProjectDirector.Test/BrowserDirectoryTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.Test; + +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Semantics.Paths; + +/// +/// Covers how the repository browsers tell a directory from a file. +/// +/// +/// The compare browser used to look for a trailing separator on each entry. The entries are +/// s, which do not keep one, so it found no directories at all and listed +/// every directory among the files, where clicking it could not open it. +/// +[TestClass] +public sealed class BrowserDirectoryTests +{ + private static readonly string[] ExpectedDirectories = ["docs", "src"]; + + private string root = string.Empty; + + [TestInitialize] + public void CreateRepositories() + { + root = Path.Combine(Path.GetTempPath(), $"browser-dirs-{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(Path.Combine(root, "A", "src")); + _ = Directory.CreateDirectory(Path.Combine(root, "B", "docs")); + File.WriteAllText(Path.Combine(root, "A", "README.md"), "a"); + File.WriteAllText(Path.Combine(root, "B", "README.md"), "b"); + } + + [TestCleanup] + public void DeleteRepositories() => Directory.Delete(root, recursive: true); + + [TestMethod] + public void ARelativePathDoesNotKeepItsTrailingSeparator() + { + RelativePath entry = RelativePath.Create("src" + Path.DirectorySeparatorChar); + + Assert.IsFalse(entry.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal), "If this starts passing, the spelling of an entry says whether it is a directory again."); + } + + [TestMethod] + public void DirectoriesAreFoundInWhicheverRepositoryHasThem() + { + RelativePath[] entries = [Entry("docs"), Entry("README.md"), Entry("src")]; + + Collection directories = ProjectDirector.ListBrowserDirectories(entries, string.Empty, Path.Combine(root, "A"), Path.Combine(root, "B")); + + Assert.AreSequenceEqual(ExpectedDirectories, directories.Select(x => x.ToString())); + } + + [TestMethod] + public void AFileIsNeverADirectory() + { + RelativePath[] entries = [Entry("README.md")]; + + Assert.IsEmpty(ProjectDirector.ListBrowserDirectories(entries, string.Empty, Path.Combine(root, "A"))); + } + + private static RelativePath Entry(string name) => RelativePath.Create(name + (name.Contains('.', StringComparison.Ordinal) ? string.Empty : Path.DirectorySeparatorChar.ToString())); +} diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 1ad6811..b186b7d 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -2074,7 +2074,7 @@ private static void ShowDiffSummaryText(int linesDeleted, int linesAdded) private void ShowCompareBrowser() { IEnumerable allFilesystemEntries = BrowserContentsBase.Union(BrowserContentsCompare); - Collection directories = allFilesystemEntries.Where(x => x.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)).ToCollection(); + Collection directories = ListBrowserDirectories(allFilesystemEntries, Options.BrowsePath, Options.Repos[Options.BaseRepo].LocalPath, Options.Repos[Options.CompareRepo].LocalPath); Collection files = allFilesystemEntries.Except(directories).ToCollection(); if (ImGui.BeginTable("CompareBrowser", 3, ImGuiTableFlags.Borders)) @@ -2245,7 +2245,7 @@ private void ShowRepoBrowser() { Collection allFilesystemEntries = BrowserContentsBase; GitRepository baseRepo = Options.Repos[Options.BaseRepo]; - Collection directories = allFilesystemEntries.Where(x => Directory.Exists(Path.Combine(baseRepo.LocalPath, Options.BrowsePath, x))).ToCollection(); + Collection directories = ListBrowserDirectories(allFilesystemEntries, Options.BrowsePath, baseRepo.LocalPath); Collection files = allFilesystemEntries.Except(directories).ToCollection(); bool shouldOpenPopup = false; @@ -2428,6 +2428,21 @@ private void ShowRepoBrowser() internal static IEnumerable ListBrowserEntries(string directory) => Directory.EnumerateFileSystemEntries(directory).Order(StringComparer.OrdinalIgnoreCase); + /// + /// Picks out the entries of a browser listing that are directories in any of the given repositories. + /// + /// The listing, relative to . + /// The directory being browsed, relative to each repository. + /// The working trees the listing was taken from. + /// The entries that are directories, in listing order. + /// + /// Asked of the file system rather than read off the entry's spelling: a + /// does not keep a trailing separator, so a test for one finds no directories at all, and the + /// compare browser used to list every directory as a file that could not be opened. + /// + internal static Collection ListBrowserDirectories(IEnumerable entries, string browsePath, params string[] repoRoots) => + entries.Where(entry => repoRoots.Any(root => Directory.Exists(Path.Join(root, browsePath, entry)))).ToCollection(); + private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, FullyQualifiedGitHubRepoName compareRepo, RelativeDirectoryPath newPath) { Options.BrowsePath = newPath; From c0de9d906b71131eae82aa026ef8508d58247e09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:15:23 +0000 Subject: [PATCH 3/4] Classify browser entries from the repository root The listing's entries already include the browsed directory (see #440), so joining the browse path again looked for src/src/Inner and missed every nested directory. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WWRBkw7bXdWGzuLpqnCvrB --- ProjectDirector.Test/BrowserDirectoryTests.cs | 20 ++++++++++++++++--- ProjectDirector/ProjectDirector.cs | 11 +++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/ProjectDirector.Test/BrowserDirectoryTests.cs b/ProjectDirector.Test/BrowserDirectoryTests.cs index 09afc78..9aaaec4 100644 --- a/ProjectDirector.Test/BrowserDirectoryTests.cs +++ b/ProjectDirector.Test/BrowserDirectoryTests.cs @@ -24,6 +24,8 @@ public sealed class BrowserDirectoryTests { private static readonly string[] ExpectedDirectories = ["docs", "src"]; + private static readonly string[] NestedDirectories = [Path.Combine("src", "Inner")]; + private string root = string.Empty; [TestInitialize] @@ -52,18 +54,30 @@ public void DirectoriesAreFoundInWhicheverRepositoryHasThem() { RelativePath[] entries = [Entry("docs"), Entry("README.md"), Entry("src")]; - Collection directories = ProjectDirector.ListBrowserDirectories(entries, string.Empty, Path.Combine(root, "A"), Path.Combine(root, "B")); + Collection directories = ProjectDirector.ListBrowserDirectories(entries, Path.Combine(root, "A"), Path.Combine(root, "B")); Assert.AreSequenceEqual(ExpectedDirectories, directories.Select(x => x.ToString())); } + [TestMethod] + public void ANestedEntryIsFoundFromTheRepositoryRoot() + { + _ = Directory.CreateDirectory(Path.Combine(root, "A", "src", "Inner")); + File.WriteAllText(Path.Combine(root, "A", "src", "a.cs"), "a"); + RelativePath[] entries = [Entry(Path.Combine("src", "Inner")), Entry(Path.Combine("src", "a.cs"))]; + + Collection directories = ProjectDirector.ListBrowserDirectories(entries, Path.Combine(root, "A")); + + Assert.AreSequenceEqual(NestedDirectories, directories.Select(x => x.ToString())); + } + [TestMethod] public void AFileIsNeverADirectory() { RelativePath[] entries = [Entry("README.md")]; - Assert.IsEmpty(ProjectDirector.ListBrowserDirectories(entries, string.Empty, Path.Combine(root, "A"))); + Assert.IsEmpty(ProjectDirector.ListBrowserDirectories(entries, Path.Combine(root, "A"))); } - private static RelativePath Entry(string name) => RelativePath.Create(name + (name.Contains('.', StringComparison.Ordinal) ? string.Empty : Path.DirectorySeparatorChar.ToString())); + private static RelativePath Entry(string name) => RelativePath.Create(name + (Path.GetFileName(name).Contains('.', StringComparison.Ordinal) ? string.Empty : Path.DirectorySeparatorChar.ToString())); } diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index b186b7d..6c0d724 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -2074,7 +2074,7 @@ private static void ShowDiffSummaryText(int linesDeleted, int linesAdded) private void ShowCompareBrowser() { IEnumerable allFilesystemEntries = BrowserContentsBase.Union(BrowserContentsCompare); - Collection directories = ListBrowserDirectories(allFilesystemEntries, Options.BrowsePath, Options.Repos[Options.BaseRepo].LocalPath, Options.Repos[Options.CompareRepo].LocalPath); + Collection directories = ListBrowserDirectories(allFilesystemEntries, Options.Repos[Options.BaseRepo].LocalPath, Options.Repos[Options.CompareRepo].LocalPath); Collection files = allFilesystemEntries.Except(directories).ToCollection(); if (ImGui.BeginTable("CompareBrowser", 3, ImGuiTableFlags.Borders)) @@ -2245,7 +2245,7 @@ private void ShowRepoBrowser() { Collection allFilesystemEntries = BrowserContentsBase; GitRepository baseRepo = Options.Repos[Options.BaseRepo]; - Collection directories = ListBrowserDirectories(allFilesystemEntries, Options.BrowsePath, baseRepo.LocalPath); + Collection directories = ListBrowserDirectories(allFilesystemEntries, baseRepo.LocalPath); Collection files = allFilesystemEntries.Except(directories).ToCollection(); bool shouldOpenPopup = false; @@ -2431,8 +2431,7 @@ internal static IEnumerable ListBrowserEntries(string directory) => /// /// Picks out the entries of a browser listing that are directories in any of the given repositories. /// - /// The listing, relative to . - /// The directory being browsed, relative to each repository. + /// The listing, each entry relative to the repository root (so already including the browsed directory). /// The working trees the listing was taken from. /// The entries that are directories, in listing order. /// @@ -2440,8 +2439,8 @@ internal static IEnumerable ListBrowserEntries(string directory) => /// does not keep a trailing separator, so a test for one finds no directories at all, and the /// compare browser used to list every directory as a file that could not be opened. /// - internal static Collection ListBrowserDirectories(IEnumerable entries, string browsePath, params string[] repoRoots) => - entries.Where(entry => repoRoots.Any(root => Directory.Exists(Path.Join(root, browsePath, entry)))).ToCollection(); + internal static Collection ListBrowserDirectories(IEnumerable entries, params string[] repoRoots) => + entries.Where(entry => repoRoots.Any(root => Directory.Exists(Path.Join(root, entry)))).ToCollection(); private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, FullyQualifiedGitHubRepoName compareRepo, RelativeDirectoryPath newPath) { From d742c43f1e3df57ad54ac315824ecfc05383f1ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:22:53 +0000 Subject: [PATCH 4/4] Join test paths with Path.Join Path.Combine drops every earlier argument when a later one is rooted, which CodeQL flagged on each call in BrowserDirectoryTests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WWRBkw7bXdWGzuLpqnCvrB --- ProjectDirector.Test/BrowserDirectoryTests.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/ProjectDirector.Test/BrowserDirectoryTests.cs b/ProjectDirector.Test/BrowserDirectoryTests.cs index 9aaaec4..b211603 100644 --- a/ProjectDirector.Test/BrowserDirectoryTests.cs +++ b/ProjectDirector.Test/BrowserDirectoryTests.cs @@ -24,18 +24,18 @@ public sealed class BrowserDirectoryTests { private static readonly string[] ExpectedDirectories = ["docs", "src"]; - private static readonly string[] NestedDirectories = [Path.Combine("src", "Inner")]; + private static readonly string[] NestedDirectories = [Path.Join("src", "Inner")]; private string root = string.Empty; [TestInitialize] public void CreateRepositories() { - root = Path.Combine(Path.GetTempPath(), $"browser-dirs-{Guid.NewGuid():N}"); - _ = Directory.CreateDirectory(Path.Combine(root, "A", "src")); - _ = Directory.CreateDirectory(Path.Combine(root, "B", "docs")); - File.WriteAllText(Path.Combine(root, "A", "README.md"), "a"); - File.WriteAllText(Path.Combine(root, "B", "README.md"), "b"); + root = Path.Join(Path.GetTempPath(), $"browser-dirs-{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(Path.Join(root, "A", "src")); + _ = Directory.CreateDirectory(Path.Join(root, "B", "docs")); + File.WriteAllText(Path.Join(root, "A", "README.md"), "a"); + File.WriteAllText(Path.Join(root, "B", "README.md"), "b"); } [TestCleanup] @@ -54,7 +54,7 @@ public void DirectoriesAreFoundInWhicheverRepositoryHasThem() { RelativePath[] entries = [Entry("docs"), Entry("README.md"), Entry("src")]; - Collection directories = ProjectDirector.ListBrowserDirectories(entries, Path.Combine(root, "A"), Path.Combine(root, "B")); + Collection directories = ProjectDirector.ListBrowserDirectories(entries, Path.Join(root, "A"), Path.Join(root, "B")); Assert.AreSequenceEqual(ExpectedDirectories, directories.Select(x => x.ToString())); } @@ -62,11 +62,11 @@ public void DirectoriesAreFoundInWhicheverRepositoryHasThem() [TestMethod] public void ANestedEntryIsFoundFromTheRepositoryRoot() { - _ = Directory.CreateDirectory(Path.Combine(root, "A", "src", "Inner")); - File.WriteAllText(Path.Combine(root, "A", "src", "a.cs"), "a"); - RelativePath[] entries = [Entry(Path.Combine("src", "Inner")), Entry(Path.Combine("src", "a.cs"))]; + _ = Directory.CreateDirectory(Path.Join(root, "A", "src", "Inner")); + File.WriteAllText(Path.Join(root, "A", "src", "a.cs"), "a"); + RelativePath[] entries = [Entry(Path.Join("src", "Inner")), Entry(Path.Join("src", "a.cs"))]; - Collection directories = ProjectDirector.ListBrowserDirectories(entries, Path.Combine(root, "A")); + Collection directories = ProjectDirector.ListBrowserDirectories(entries, Path.Join(root, "A")); Assert.AreSequenceEqual(NestedDirectories, directories.Select(x => x.ToString())); } @@ -76,7 +76,7 @@ public void AFileIsNeverADirectory() { RelativePath[] entries = [Entry("README.md")]; - Assert.IsEmpty(ProjectDirector.ListBrowserDirectories(entries, Path.Combine(root, "A"))); + Assert.IsEmpty(ProjectDirector.ListBrowserDirectories(entries, Path.Join(root, "A"))); } private static RelativePath Entry(string name) => RelativePath.Create(name + (Path.GetFileName(name).Contains('.', StringComparison.Ordinal) ? string.Empty : Path.DirectorySeparatorChar.ToString()));