diff --git a/ProjectDirector.Test/BrowserDirectoryTests.cs b/ProjectDirector.Test/BrowserDirectoryTests.cs
new file mode 100644
index 0000000..b211603
--- /dev/null
+++ b/ProjectDirector.Test/BrowserDirectoryTests.cs
@@ -0,0 +1,83 @@
+// 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 static readonly string[] NestedDirectories = [Path.Join("src", "Inner")];
+
+ private string root = string.Empty;
+
+ [TestInitialize]
+ public void CreateRepositories()
+ {
+ 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]
+ 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, Path.Join(root, "A"), Path.Join(root, "B"));
+
+ Assert.AreSequenceEqual(ExpectedDirectories, directories.Select(x => x.ToString()));
+ }
+
+ [TestMethod]
+ public void ANestedEntryIsFoundFromTheRepositoryRoot()
+ {
+ _ = 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.Join(root, "A"));
+
+ Assert.AreSequenceEqual(NestedDirectories, directories.Select(x => x.ToString()));
+ }
+
+ [TestMethod]
+ public void AFileIsNeverADirectory()
+ {
+ RelativePath[] entries = [Entry("README.md")];
+
+ 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()));
+}
diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs
index 138a267..6c0d724 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;
@@ -2067,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.Repos[Options.BaseRepo].LocalPath, Options.Repos[Options.CompareRepo].LocalPath);
Collection files = allFilesystemEntries.Except(directories).ToCollection();
if (ImGui.BeginTable("CompareBrowser", 3, ImGuiTableFlags.Borders))
@@ -2238,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, baseRepo.LocalPath);
Collection files = allFilesystemEntries.Except(directories).ToCollection();
bool shouldOpenPopup = false;
@@ -2421,6 +2428,20 @@ 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, 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.
+ ///
+ /// 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, 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)
{
Options.BrowsePath = newPath;
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.
+[](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);