From d06c74aba5db6f225236ff7249206eb07ad13f6f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:27:15 +0000 Subject: [PATCH] [patch] Give each owner's panel its own ImGui ID scope CollapsingHeader opens no ID scope, so a repository name shared by two owners (every org's .github) gave both rows the same ImGui ID and a click on the second owner's row was swallowed by the first. Each collapsible panel now pushes its name as an ID around its content. The repository rows are probed by their owner-qualified name, so the UI tests can tell the two rows apart. Fixes #522 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5JsJWSEGD7D4Jhs1d --- ProjectDirector/ProjectDirector.cs | 7 +- .../Gallery/GalleryApp.cs | 12 +++- .../ProjectDirector.UITests/RepoPanelTests.cs | 64 +++++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/ProjectDirector.UITests/RepoPanelTests.cs diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 5c400ef..1b26be5 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -894,7 +894,12 @@ private void ShowCollapsiblePanel(string name, Action contentDelegate) ImGuiProbes.MarkItem("panel", name); if (expanded) { + // CollapsingHeader opens no ID scope of its own, so without this two panels drawing a widget + // with the same label (each owner's ".github" row) would give both the same ID, and ImGui would + // hand a click on the second to the first. + ImGui.PushID(name); contentDelegate?.Invoke(); + ImGui.PopID(); open = true; } else @@ -1020,7 +1025,7 @@ private void ShowRepos(GitHubOwnerName owner) ImGui.SameLine(); bool isSelected = Options.BaseRepo == repoName; bool clicked = ImGui.Selectable(gitHubRepo.RepoName, ref isSelected); - ImGuiProbes.MarkItem("repo", gitHubRepo.RepoName); + ImGuiProbes.MarkItem("repo", repoName); if (clicked) { SwitchPage(repoName); diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs index b85c9d1..3540794 100644 --- a/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs +++ b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs @@ -39,7 +39,13 @@ private GalleryApp(ImGuiAppHarness harness, ProjectDirectorOptions options, Cred /// Starts the application at the given display size. /// The size of the window the application draws into. /// The running application. - internal static GalleryApp Start((int Width, int Height) display) + internal static GalleryApp Start((int Width, int Height) display) => Start(display, GalleryRepositories.CreateOptions); + + /// Starts the application at the given display size over settings the caller builds. + /// The size of the window the application draws into. + /// Builds the settings the application runs on, after the settings file and secret store are redirected. + /// The running application. + internal static GalleryApp Start((int Width, int Height) display, Func createOptions) { AppData.ConfigureForTesting(() => new MockFileSystem()); CredentialCache credentials = new(new InMemoryCredentialStore()); @@ -47,7 +53,7 @@ internal static GalleryApp Start((int Width, int Height) display) try { - ProjectDirectorOptions options = GalleryRepositories.CreateOptions(); + ProjectDirectorOptions options = createOptions(); ProjectDirector director = new(options); ImGuiAppHarness harness = ImGuiAppHarness.Start(director.BuildConfig(), new HarnessOptions { @@ -68,7 +74,7 @@ internal static GalleryApp Start((int Width, int Height) display) /// The repository's own name. internal void SelectRepository(string repo) { - Harness.Click($"repo/{repo}"); + Harness.Click($"repo/{GalleryRepositories.FullName(repo)}"); GitRepository selected = Options.Repos[GalleryRepositories.FullName(repo)]; // The comparison runs git on a background task, which publishes whenever it finishes rather diff --git a/tests/ProjectDirector.UITests/RepoPanelTests.cs b/tests/ProjectDirector.UITests/RepoPanelTests.cs new file mode 100644 index 0000000..0230a2a --- /dev/null +++ b/tests/ProjectDirector.UITests/RepoPanelTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests; + +using ktsu.ProjectDirector.UITests.Gallery; +using ktsu.Semantics.Paths; + +/// +/// Drives the left panel's repository list, where every owner's repositories are drawn as rows of +/// the same kind of widget. +/// +[TestClass] +public sealed class RepoPanelTests +{ + private const string SharedName = ".github"; + + private static readonly string[] Owners = ["ktsu-dev", "matt-edmondson"]; + + [TestMethod] + public void ClickingASharedRepoName_SelectsThatOwnersRepository() + { + using GalleryApp app = GalleryApp.Start(AppGallery.Display, CreateOptions); + app.Harness.Step(2); + + // Click the later owner first: it is the row an ID clash with the earlier owner's row swallows. + foreach (string owner in Owners.Reverse()) + { + FullyQualifiedGitHubRepoName expected = FullName(owner); + app.Harness.Click($"repo/{expected}"); + app.Harness.Step(2); + + Assert.AreEqual(expected, app.Options.BaseRepo, $"Clicking {owner}'s {SharedName} did not select it."); + } + } + + private static FullyQualifiedGitHubRepoName FullName(string owner) => + FullyQualifiedGitHubRepoName.Create($"{owner}.{SharedName}"); + + private static ProjectDirectorOptions CreateOptions() + { + // Nothing is cloned, so the paths are never created; they only have to differ per owner. + string root = Path.Combine(Path.GetTempPath(), "projectdirector-repo-panel"); + ProjectDirectorOptions options = new() + { + DevDirectory = AbsoluteDirectoryPath.Create(root), + }; + + foreach (string ownerName in Owners) + { + GitHubOwnerName owner = GitHubOwnerName.Create(ownerName); + _ = options.GitHubOwners.Add(owner); + options.Repos[FullName(ownerName)] = new GitHubRepository() + { + OwnerName = owner, + RepoName = GitHubRepoName.Create(SharedName), + LocalPath = FullyQualifiedLocalRepoPath.Create(Path.Combine(root, ownerName, SharedName)), + RemotePath = GitRemotePath.Create($"https://github.com/{ownerName}/{SharedName}"), + MinFetchIntervalSeconds = 0, + }; + } + + return options; + } +}