Dialog-collision crash fix, FlaUI e2e suite, branded splash (beta.10) #29

Merged
KofTwentyTwo merged 1 commits from dev into main 2026-07-04 23:03:49 +00:00
20 changed files with 991 additions and 34 deletions

No files matched your search

+24
View File
@@ -78,6 +78,30 @@ jobs:
path: TestResults/**/coverage.cobertura.xml
if-no-files-found: warn
ui-tests:
# End-to-end FlaUI/UIA tests against the real gclo.exe. Not in the branch
# ruleset's required checks yet: hosted-runner UIA is new here, so this job
# earns required status by proving itself stable first.
name: UI end-to-end tests (x64)
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
# Self-contained Windows App SDK: hosted runners do not have the runtime
# installed. The output path is the same one AppSession resolves by default.
- name: Build app (unpackaged, self-contained Windows App SDK)
run: dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true
- name: Run UI tests
run: dotnet test gclo.UiTests/gclo.UiTests.csproj
format:
name: Format (style gate)
runs-on: windows-latest
+6
View File
@@ -0,0 +1,6 @@
# Analyzer tuning local to gclo.UiTests (inherits the repo root .editorconfig).
# Mirrors the gclo.Engine.Tests suppressions, which are keyed to that project's
# path in the root file and therefore do not reach this one.
[*.cs]
# CA1707: xunit's Method_Scenario_Expectation naming uses underscores by convention.
dotnet_diagnostic.CA1707.severity = none
+418
View File
@@ -0,0 +1,418 @@
using System.Diagnostics;
using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Conditions;
using FlaUI.Core.Definitions;
using FlaUI.Core.Input;
using FlaUI.Core.Tools;
using FlaUI.UIA3;
namespace gclo.UiTests;
/// <summary>
/// One launched gclo.exe plus the UIA3 automation that drives it, shared by every
/// UI test in the collection (see <see cref="UiSessionTests"/>).
///
/// The exe comes from the GCLO_UITEST_EXE environment variable when set, otherwise
/// from the repo-relative Debug/x64 output (found by walking up from the test's
/// base directory to the folder containing gclo.slnx). Each session gets a unique
/// temp directory passed to the app as GCLO_DATA_DIR, so tests always start with
/// no settings, no accounts, and no logs — and never touch the real user profile.
///
/// Dispose closes the app (kill as fallback), disposes the automation only after
/// the app is gone, and best-effort deletes the temp data directory.
/// </summary>
public sealed class AppSession : IDisposable
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan LaunchTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250);
/// <summary>Launches gclo against a fresh temp GCLO_DATA_DIR and waits for its main window.</summary>
public AppSession()
{
ExePath = ResolveExePath();
DataDirectory = Path.Combine(Path.GetTempPath(), "gclo-uitests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(DataDirectory);
var startInfo = new ProcessStartInfo(ExePath)
{
WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "",
};
startInfo.Environment["GCLO_DATA_DIR"] = DataDirectory;
Automation = new UIA3Automation();
try
{
App = Application.Launch(startInfo);
MainWindow = WaitForMainWindow();
}
catch
{
// A half-launched session must not leak the process, the automation,
// or the temp directory; xunit will surface the original exception.
Dispose();
throw;
}
}
/// <summary>The gclo.exe under test.</summary>
public string ExePath { get; }
/// <summary>The temp directory the app sees as its data root (via GCLO_DATA_DIR).</summary>
public string DataDirectory { get; }
/// <summary>The UIA3 automation used for every lookup and pattern call.</summary>
public UIA3Automation Automation { get; }
/// <summary>The launched application (never null after construction).</summary>
public Application App { get; }
/// <summary>The app's main window, resolved once at launch.</summary>
public Window MainWindow { get; }
// ---------------------------------------------------------------- lookups
/// <summary>
/// Finds the first element matching <paramref name="condition"/> in the main
/// window — or in any other top-level window of the app's process. The second
/// leg matters because WinAppSDK menu flyouts open in windowed popups (separate
/// top-level HWNDs), so their items are not descendants of the main window.
/// </summary>
public AutomationElement? FindInApp(Func<ConditionFactory, ConditionBase> condition)
{
AutomationElement? found = MainWindow.FindFirstDescendant(condition);
if (found is not null)
{
return found;
}
foreach (AutomationElement topLevel in Automation.GetDesktop()
.FindAllChildren(cf => cf.ByProcessId(App.ProcessId)))
{
if (topLevel.Equals(MainWindow))
{
continue;
}
found = topLevel.FindFirstDescendant(condition);
if (found is not null)
{
return found;
}
}
return null;
}
/// <summary>
/// Retries <paramref name="lookup"/> until it returns an element, throwing a
/// <see cref="TimeoutException"/> naming <paramref name="description"/> when the
/// timeout (default 10s) elapses first. Exceptions inside the lookup (stale
/// elements mid-transition) count as "not found yet" and are retried.
/// </summary>
public AutomationElement WaitFor(
Func<AutomationElement?> lookup, string description, TimeSpan? timeout = null)
{
ArgumentNullException.ThrowIfNull(lookup);
TimeSpan effective = timeout ?? DefaultTimeout;
RetryResult<AutomationElement?> result = Retry.WhileNull(
lookup, timeout: effective, interval: PollInterval, ignoreException: true);
if (result.Result is { } element)
{
return element;
}
throw new TimeoutException(
$"Timed out after {effective.TotalSeconds:0}s waiting for {description}. "
+ $"(app exited: {App.HasExited})");
}
/// <summary>
/// Retries until <paramref name="lookup"/> returns null (the element left the
/// tree), throwing a <see cref="TimeoutException"/> naming
/// <paramref name="description"/> when it is still present at the timeout.
/// </summary>
public static void WaitUntilGone(
Func<AutomationElement?> lookup, string description, TimeSpan? timeout = null)
{
ArgumentNullException.ThrowIfNull(lookup);
TimeSpan effective = timeout ?? DefaultTimeout;
RetryResult<bool> result = Retry.WhileTrue(
() => lookup() is not null, timeout: effective, interval: PollInterval, ignoreException: true);
if (!result.Success)
{
throw new TimeoutException(
$"Timed out after {effective.TotalSeconds:0}s waiting for {description} to go away.");
}
}
/// <summary>Waits for the element carrying <paramref name="automationId"/>.</summary>
public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null)
=> WaitFor(
() => FindInApp(cf => cf.ByAutomationId(automationId)),
$"element '{automationId}'",
timeout);
/// <summary>Waits until no element carries <paramref name="automationId"/> anymore.</summary>
public void WaitForElementGone(string automationId, TimeSpan? timeout = null)
=> WaitUntilGone(
() => FindInApp(cf => cf.ByAutomationId(automationId)),
$"element '{automationId}'",
timeout);
/// <summary>Waits for a text element whose name is exactly <paramref name="text"/>.</summary>
public AutomationElement WaitForText(string text, TimeSpan? timeout = null)
=> WaitFor(
() => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))),
$"text '{text}'",
timeout);
/// <summary>Waits until no text element named <paramref name="text"/> remains.</summary>
public void WaitForTextGone(string text, TimeSpan? timeout = null)
=> WaitUntilGone(
() => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))),
$"text '{text}'",
timeout);
// ---------------------------------------------------------------- actions
/// <summary>
/// Opens the menu bar item named <paramref name="menuBarItemName"/> (e.g. "File")
/// and invokes the flyout item carrying <paramref name="itemAutomationId"/>.
/// The bar item is opened via its ExpandCollapse pattern (WinUI's MenuBarItem
/// exposes it) with Invoke as fallback; the flyout item is then looked up across
/// the app's windows because menu popups are windowed in WinAppSDK.
/// </summary>
public void InvokeMenuItem(string menuBarItemName, string itemAutomationId)
{
AutomationElement barItem = WaitFor(
() => MainWindow.FindFirstDescendant(cf =>
cf.ByControlType(ControlType.MenuItem).And(cf.ByName(menuBarItemName)))
?? MainWindow.FindFirstDescendant(cf => cf.ByName(menuBarItemName)),
$"menu bar item '{menuBarItemName}'");
if (barItem.Patterns.ExpandCollapse.TryGetPattern(out var expandCollapse))
{
expandCollapse.Expand();
Wait.UntilInputIsProcessed();
}
else
{
InvokeElement(barItem);
}
AutomationElement item = WaitFor(
() => FindInApp(cf => cf.ByAutomationId(itemAutomationId)),
$"menu item '{itemAutomationId}' under '{menuBarItemName}'");
InvokeElement(item);
}
/// <summary>
/// Activates a NavigationView item by AutomationId. NavigationViewItem exposes
/// Invoke (preferred, it routes through the pane's selection logic) and
/// SelectionItem; a raw click is the last resort.
/// </summary>
public void InvokeNavItem(string automationId)
=> InvokeElement(WaitForElement(automationId));
/// <summary>
/// Invokes the XAML button named <paramref name="name"/>. The ClassName filter
/// ("Button") keeps ContentDialog buttons (real XAML buttons named by their
/// *ButtonText) from colliding with the window's non-client caption buttons,
/// whose UIA names ("Close", ...) overlap dialog button captions.
/// </summary>
public void ClickButton(string name, TimeSpan? timeout = null)
{
AutomationElement button = WaitFor(
() => FindInApp(cf => cf.ByControlType(ControlType.Button)
.And(cf.ByName(name))
.And(cf.ByClassName("Button"))),
$"button '{name}'",
timeout);
InvokeElement(button);
}
/// <summary>
/// Best-effort variant of <see cref="ClickButton"/> for <c>finally</c> cleanup:
/// clicks the button when it is currently present and swallows every failure,
/// so closing a leftover dialog can never mask the test's own exception.
/// </summary>
public void TryClickButton(string name)
{
try
{
AutomationElement? button = FindInApp(cf => cf.ByControlType(ControlType.Button)
.And(cf.ByName(name))
.And(cf.ByClassName("Button")));
if (button is not null)
{
InvokeElement(button);
}
}
catch
{
// Cleanup only — the test outcome was decided in the try body.
}
}
/// <summary>
/// Writes <paramref name="text"/> into <paramref name="element"/>: the UIA Value
/// pattern when available and writable (works without window focus), otherwise
/// focus plus real keystrokes via <see cref="Keyboard"/>.
/// </summary>
public void EnterText(AutomationElement element, string text)
{
ArgumentNullException.ThrowIfNull(element);
try
{
if (element.Patterns.Value.TryGetPattern(out var value)
&& !value.IsReadOnly.ValueOrDefault)
{
value.SetValue(text);
return;
}
}
catch
{
// Some boxes reject SetValue (or the element re-rendered); type instead.
}
MainWindow.Focus();
element.Focus();
Keyboard.Type(text);
Wait.UntilInputIsProcessed();
}
/// <summary>Invoke pattern first, SelectionItem second, mouse click as last resort.</summary>
private static void InvokeElement(AutomationElement element)
{
if (element.Patterns.Invoke.TryGetPattern(out var invoke))
{
invoke.Invoke();
}
else if (element.Patterns.SelectionItem.TryGetPattern(out var selectionItem))
{
selectionItem.Select();
}
else
{
element.Click();
}
Wait.UntilInputIsProcessed();
}
// ---------------------------------------------------------------- lifecycle
private Window WaitForMainWindow()
{
RetryResult<Window?> result = Retry.WhileNull(
() =>
{
if (App.HasExited)
{
throw new InvalidOperationException(
$"gclo exited while waiting for its main window (exit code {App.ExitCode}). "
+ $"Exe: {ExePath}");
}
try
{
return App.GetMainWindow(Automation, TimeSpan.FromMilliseconds(500));
}
catch
{
return null; // not ready yet; retried until the outer timeout
}
},
timeout: LaunchTimeout,
interval: PollInterval);
if (result.Result is { } window)
{
return window;
}
throw new TimeoutException(
$"gclo's main window did not appear within {LaunchTimeout.TotalSeconds:0}s. Exe: {ExePath}");
}
/// <summary>
/// The exe to drive: GCLO_UITEST_EXE when set, else the repo's Debug/x64 output,
/// located by walking up from the test assembly to the folder holding gclo.slnx.
/// </summary>
private static string ResolveExePath()
{
string? fromEnv = Environment.GetEnvironmentVariable("GCLO_UITEST_EXE");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
if (!File.Exists(fromEnv))
{
throw new FileNotFoundException(
$"GCLO_UITEST_EXE points at '{fromEnv}', which does not exist.", fromEnv);
}
return fromEnv;
}
for (DirectoryInfo? current = new(AppContext.BaseDirectory);
current is not null;
current = current.Parent)
{
if (!File.Exists(Path.Combine(current.FullName, "gclo.slnx")))
{
continue;
}
string exePath = Path.Combine(
current.FullName, "gclo", "bin", "x64", "Debug",
"net10.0-windows10.0.19041.0", "win-x64", "gclo.exe");
if (!File.Exists(exePath))
{
throw new FileNotFoundException(
$"gclo.exe not found at '{exePath}'. Build the app first: "
+ "dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None "
+ "— or set GCLO_UITEST_EXE to the exe to test.",
exePath);
}
return exePath;
}
throw new FileNotFoundException(
$"Could not find the repo root (no gclo.slnx above '{AppContext.BaseDirectory}'). "
+ "Set GCLO_UITEST_EXE to the gclo.exe to test.");
}
/// <inheritdoc/>
public void Dispose()
{
// Order matters: take the app down first, dispose the automation only after
// the process is gone, then sweep the temp data directory. Every step is
// best-effort so one failure cannot mask the test outcome.
try
{
App?.Close(); // FlaUI kills the process itself if closing stalls
}
catch
{
// The process may already be gone; Kill below double-checks.
}
try
{
if (App is { HasExited: false })
{
App.Kill();
}
}
catch
{
// Best effort only.
}
App?.Dispose();
Automation?.Dispose();
try
{
Directory.Delete(DataDirectory, recursive: true);
}
catch
{
// A straggling handle keeps the directory; it lives under %TEMP% anyway.
}
GC.SuppressFinalize(this);
}
}
+177
View File
@@ -0,0 +1,177 @@
using System.Text.RegularExpressions;
using FlaUI.Core.AutomationElements;
namespace gclo.UiTests;
/// <summary>
/// Token-free, deterministic smoke tests against the launched app: the shell and
/// its navigation render, the core dialogs open and close, and offline input does
/// not crash anything. No test talks to GitHub — the app runs on an isolated
/// GCLO_DATA_DIR with no accounts and no stored settings (see <see cref="AppSession"/>).
/// Dialog tests close what they open on the happy path and fall back to a
/// best-effort <see cref="AppSession.TryClickButton"/> in <c>finally</c>, so one
/// failure cannot strand a dialog in front of the next test.
/// </summary>
[Collection(nameof(UiSessionTests))]
public sealed partial class SmokeTests : UiTestBase
{
/// <summary>Small pause after a button invoke whose handler runs behind a dialog deferral.</summary>
private static readonly TimeSpan PostInvokeSettle = TimeSpan.FromMilliseconds(300);
public SmokeTests(AppSession session)
: base(session)
{
}
// (1) The app launches and puts up its shell window.
[Fact]
public void MainWindow_AfterLaunch_TitleContainsGclo()
=> Assert.Contains("gclo", Session.MainWindow.Title, StringComparison.Ordinal);
// (2) The navigation pane carries the pinned Quick Sync plus both footer commands.
[Fact]
public void NavigationPane_AfterLaunch_ShowsQuickSyncAddAccountAndSyncAll()
{
Assert.NotNull(Session.WaitForElement("NavQuickSync"));
Assert.NotNull(Session.WaitForElement("NavAddAccount"));
Assert.NotNull(Session.WaitForElement("NavSyncAll"));
}
// (3) With no accounts and no prior load, the Quick Sync workspace shows the
// connect card, and the 'Sync all' footer command has nothing to run.
[Fact]
public void ConnectCard_FreshDataDir_ShowsTokenAndLoadAndDisablesSyncAll()
{
Assert.NotNull(Session.WaitForElement("ConnectTokenBox"));
Assert.NotNull(Session.WaitForElement("LoadReposButton"));
AutomationElement syncAll = Session.WaitForElement("NavSyncAll");
Assert.False(syncAll.IsEnabled, "'Sync all' should be disabled while no accounts exist.");
}
// (4) File -> Settings opens the Settings dialog; Cancel closes it.
[Fact]
public void FileMenu_Settings_OpensDialogAndCancelCloses()
{
Session.InvokeMenuItem("File", "MenuSettings");
try
{
Session.WaitForText("Settings"); // the ContentDialog's title
Session.ClickButton("Cancel");
Session.WaitForTextGone("Settings");
}
finally
{
Session.TryClickButton("Cancel"); // no-op when the happy path closed it
}
}
// (5) Help -> About shows the semantic version plus the parenthesized git hash.
[Fact]
public void HelpMenu_About_ShowsSemanticVersionWithCommitHash()
{
Session.InvokeMenuItem("Help", "MenuAbout");
try
{
// The TextBlock exists as soon as the dialog opens; retry until its
// text has been populated by the AboutDialog constructor.
AutomationElement version = Session.WaitFor(
() =>
{
AutomationElement? element =
Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText"));
return element is not null
&& element.Name.StartsWith("Version ", StringComparison.Ordinal)
? element
: null;
},
"the About dialog's version text");
string text = version.Name;
Assert.Matches(VersionPattern(), text);
Assert.Contains("(", text, StringComparison.Ordinal); // "(<short git hash>)"
Session.ClickButton("Close");
Session.WaitForElementGone("AboutVersionText");
}
finally
{
Session.TryClickButton("Close"); // no-op when the happy path closed it
}
}
// (6) The Add account wizard appears; Next with an empty name keeps it open
// (step 1 refuses to advance); Cancel closes it.
[Fact]
public void AddAccountWizard_NextWithEmptyName_StaysOpenAndCancelCloses()
{
Session.InvokeNavItem("NavAddAccount");
try
{
Session.WaitForElement("WizardNameBox"); // wizard step 1 is on screen
Session.WaitForText("Step 1 of 4: Identity"); // right dialog, right step
Session.ClickButton("Next");
Thread.Sleep(PostInvokeSettle); // validation runs behind the dialog deferral
Assert.NotNull(Session.WaitForElement("WizardNameBox")); // still open
Assert.False(Session.App.HasExited);
Session.ClickButton("Cancel");
Session.WaitForElementGone("WizardNameBox");
}
finally
{
Session.TryClickButton("Cancel"); // no-op when the happy path closed it
}
}
// (7) Typing into the token box (which kicks the offline org-listing path) does
// not crash the app, and the org combo stays present. No token validation
// is asserted — these tests run without network access or credentials.
[Fact]
public void ConnectTokenBox_TypingText_KeepsAppAliveAndOrgBoxPresent()
{
AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox");
Session.EnterText(tokenBox, "ghp_uitests_offline_dummy_token");
Assert.NotNull(Session.WaitForElement("ConnectOrgBox"));
Assert.False(Session.App.HasExited);
}
// (8) Regression for the dialog-collision crash: WinUI allows one open
// ContentDialog per XamlRoot, and a second ShowAsync throws a COMException
// that — escaping an async void handler — used to kill the whole process
// (0xc000027b). Commanding 'Add account' while About is open must now be
// ignored: the app stays alive, About stays up, and no wizard appears.
[Fact]
public void AddAccountWhileAboutDialogOpen_IsIgnoredAndAppSurvives()
{
Session.InvokeMenuItem("Help", "MenuAbout");
try
{
Session.WaitForElement("AboutVersionText");
Session.InvokeNavItem("NavAddAccount"); // second dialog: must be a no-op
Thread.Sleep(PostInvokeSettle);
Assert.False(Session.App.HasExited);
Assert.Null(Session.FindInApp(cf => cf.ByAutomationId("WizardNameBox")));
Assert.NotNull(Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText")));
Session.ClickButton("Close");
Session.WaitForElementGone("AboutVersionText");
}
finally
{
Session.TryClickButton("Close"); // no-op when the happy path closed it
}
}
// (9) Resizing below the minimum window size is intentionally not covered:
// driving OS-level resize through UIA transforms is flaky across DPI
// scales, and the minimum is already enforced by OverlappedPresenter.
[GeneratedRegex(@"^Version \d+\.\d+\.\d+")]
private static partial Regex VersionPattern();
}
+31
View File
@@ -0,0 +1,31 @@
namespace gclo.UiTests;
/// <summary>
/// The single xunit collection every UI test belongs to (referenced via
/// <c>nameof(UiSessionTests)</c>). All UI tests share one launched gclo instance
/// (the <see cref="AppSession"/> collection fixture) and run sequentially
/// (<c>DisableParallelization</c>), so two tests never drive two overlapping app
/// instances or interleave input on the same one.
/// </summary>
[CollectionDefinition(nameof(UiSessionTests), DisableParallelization = true)]
public sealed class UiSessionTests : ICollectionFixture<AppSession>
{
}
/// <summary>
/// Base class for UI tests: holds the shared <see cref="AppSession"/> so test
/// bodies read as session-relative steps. Derived classes must carry
/// <c>[Collection(nameof(UiSessionTests))]</c> — xunit does not inherit it.
/// </summary>
public abstract class UiTestBase
{
/// <summary>Captures the collection fixture xunit injects per test class.</summary>
protected UiTestBase(AppSession session)
{
ArgumentNullException.ThrowIfNull(session);
Session = session;
}
/// <summary>The app instance shared by every test in the collection.</summary>
protected AppSession Session { get; }
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Out-of-process UI smoke tests: FlaUI drives the built gclo.exe over UI
Automation, so this project deliberately has NO ProjectReference to the app.
Build the app first (dotnet build gclo/gclo.csproj -p:Platform=x64
-p:WindowsPackageType=None) or point GCLO_UITEST_EXE at an exe to test.
CI builds this project but only runs gclo.Engine.Tests; the UI suite is run
locally where an interactive desktop is available. -->
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FlaUI.UIA3" Version="5.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
+6 -6
View File
@@ -5,7 +5,8 @@ using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// Persists account profiles as JSON at %LOCALAPPDATA%\gclo\accounts.json and their
/// Persists account profiles as JSON at accounts.json under
/// <see cref="GcloPaths.DataRoot"/> (default %LOCALAPPDATA%\gclo) and their
/// tokens in an <see cref="ITokenVault"/>; the file holds metadata only and never a
/// token. Unlike <see cref="AppSettings"/>, accounts are primary user data, so write
/// failures in <see cref="Save"/>, <see cref="Delete"/>, and
@@ -22,17 +23,16 @@ public sealed class AccountsStore
/// <summary>
/// Loads existing accounts from <paramref name="directory"/> (default:
/// %LOCALAPPDATA%\gclo). A missing file simply means no accounts yet; a corrupt
/// or unreadable one is logged (pass the app's <paramref name="log"/> to surface
/// that) and treated as empty rather than blocking startup.
/// <see cref="GcloPaths.DataRoot"/>). A missing file simply means no accounts yet;
/// a corrupt or unreadable one is logged (pass the app's <paramref name="log"/> to
/// surface that) and treated as empty rather than blocking startup.
/// </summary>
public AccountsStore(ITokenVault vault, string? directory = null, IActivityLog? log = null)
{
ArgumentNullException.ThrowIfNull(vault);
_vault = vault;
_log = log ?? new NullActivityLog();
directory ??= Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
directory ??= GcloPaths.DataRoot;
_filePath = Path.Combine(directory, "accounts.json");
_accounts = Load();
}
+5 -7
View File
@@ -4,9 +4,10 @@ using System.Text.Json.Serialization;
namespace gclo.ViewModels;
/// <summary>
/// User preferences persisted as JSON at %LOCALAPPDATA%\gclo\settings.json.
/// Uses Environment.GetFolderPath (not Windows.Storage.ApplicationData) so the same
/// path works whether the app runs packaged or unpackaged.
/// User preferences persisted as JSON at settings.json under
/// <see cref="GcloPaths.DataRoot"/> (default %LOCALAPPDATA%\gclo, overridable via
/// GCLO_DATA_DIR). Uses Environment.GetFolderPath (not Windows.Storage.ApplicationData)
/// so the same path works whether the app runs packaged or unpackaged.
/// </summary>
public sealed class AppSettings
{
@@ -23,10 +24,7 @@ public sealed class AppSettings
/// <summary>App theme: "System", "Light", or "Dark". Unknown values fall back to "System".</summary>
public string Theme { get; set; } = "System";
private static string SettingsPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"gclo",
"settings.json");
private static string SettingsPath => Path.Combine(GcloPaths.DataRoot, "settings.json");
/// <summary>
/// Loads settings from disk. Never throws: a missing, corrupt, or unreadable file
+22
View File
@@ -0,0 +1,22 @@
namespace gclo.ViewModels;
/// <summary>
/// The single seam for where gclo keeps its per-user data (settings, accounts,
/// logs). Defaults to %LOCALAPPDATA%\gclo; the GCLO_DATA_DIR environment variable
/// overrides it so UI tests (and portable setups) can point the whole app at an
/// isolated directory without touching the real profile. Components that accept
/// an explicit directory (test seams) are unaffected — this only feeds defaults.
/// </summary>
public static class GcloPaths
{
/// <summary>
/// Root directory for gclo's per-user data: GCLO_DATA_DIR when set and
/// non-empty, otherwise %LOCALAPPDATA%\gclo. Read on every access so a
/// value set before launch always wins over any cached default.
/// </summary>
public static string DataRoot =>
Environment.GetEnvironmentVariable("GCLO_DATA_DIR") is { Length: > 0 } dir
? dir
: Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
}
+1
View File
@@ -7,6 +7,7 @@
<Project Path="gclo.Cli/gclo.Cli.csproj" />
<Project Path="gclo.Engine.Tests/gclo.Engine.Tests.csproj" />
<Project Path="gclo.Engine/gclo.Engine.csproj" />
<Project Path="gclo.UiTests/gclo.UiTests.csproj" />
<Project Path="gclo.ViewModels/gclo.ViewModels.csproj" />
<Project Path="gclo/gclo.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
+1
View File
@@ -21,6 +21,7 @@
TextWrapping="Wrap" />
<TextBlock
x:Name="VersionText"
AutomationProperties.AutomationId="AboutVersionText"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
+4
View File
@@ -28,6 +28,7 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 1 of 4: Identity" />
<TextBox
AutomationProperties.AutomationId="WizardNameBox"
Header="Account name"
PlaceholderText="e.g. Work – Acme"
Text="{x:Bind ViewModel.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
@@ -53,6 +54,7 @@
Text="Step 2 of 4: Access token" />
<PasswordBox
x:Name="TokenBox"
AutomationProperties.AutomationId="WizardTokenBox"
Header="Personal access token"
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
@@ -94,6 +96,7 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 3 of 4: Organization" />
<ComboBox
AutomationProperties.AutomationId="WizardOrgBox"
HorizontalAlignment="Stretch"
Header="Organization or account"
IsEditable="True"
@@ -116,6 +119,7 @@
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
AutomationProperties.AutomationId="WizardTargetBox"
Header="Target folder"
PlaceholderText="C:\src\my-org"
Text="{x:Bind ViewModel.TargetRoot, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+30 -1
View File
@@ -1,3 +1,4 @@
using gclo.Engine;
using gclo.ViewModels;
using Microsoft.UI.Xaml;
@@ -10,6 +11,12 @@ namespace gclo
{
private Window? _window;
/// <summary>
/// Where the unhandled-exception net writes; set by MainWindow once the shared
/// activity log exists. Null only during the first instants of startup.
/// </summary>
internal static IActivityLog? CrashLog { get; set; }
/// <summary>
/// Constructed by Program.Main inside Application.Start after VelopackApp handled
/// install/update hooks — see Program.cs.
@@ -18,6 +25,26 @@ namespace gclo
{
InitializeComponent();
// Last-resort net: an exception escaping an async void event handler is
// rethrown on the dispatcher with no frame above it, and the process dies
// as a stowed exception (0xc000027b) with nothing in the log. The known
// collision (two ContentDialogs) is prevented at the source by
// DialogGuard; this catches whatever else slips through, records it, and
// keeps the app running.
UnhandledException += (_, e) =>
{
e.Handled = true;
try
{
CrashLog?.Error($"Unhandled UI exception: {e.Message}", e.Exception);
}
catch
{
// Logging must never turn a survivable error fatal; the stowed
// Exception property itself can throw when type info was lost.
}
};
// App-level so Application.Current.Resources theme lookups (the status
// brushes) agree with the visible theme. Only settable here, before any
// window exists; mid-session theme changes still use the root element's
@@ -34,7 +61,9 @@ namespace gclo
}
/// <summary>
/// Invoked when the application is launched.
/// Invoked when the application is launched. The branded splash is an overlay
/// inside <see cref="MainWindow"/> (see <see cref="SplashOverlay"/> for why a
/// separate splash window is a UIA crash hazard).
/// </summary>
/// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
+43
View File
@@ -0,0 +1,43 @@
using System; // the GetAwaiter extension for IAsyncOperation lives here (CsWinRT)
using System.Threading.Tasks;
using Microsoft.UI.Xaml.Controls;
namespace gclo
{
/// <summary>
/// Serializes ContentDialog display. WinUI allows a single open ContentDialog
/// per XamlRoot, and a second ShowAsync does not queue — it throws a
/// COMException which, escaping an async void event handler, kills the process
/// with a stowed exception (0xc000027b). Every dialog in the app opens through
/// <see cref="ShowAsync"/>, which ignores the request while another dialog is
/// on screen — the same no-op a user expects from commands behind a modal.
/// All calls run on the single UI thread, so the flag needs no locking.
/// </summary>
internal static class DialogGuard
{
private static bool _dialogOpen;
/// <summary>
/// Shows <paramref name="dialog"/> unless another dialog is on screen.
/// Returns the dialog result, or null when the request was ignored —
/// callers treat null like a dismissal.
/// </summary>
public static async Task<ContentDialogResult?> ShowAsync(ContentDialog dialog)
{
if (_dialogOpen)
{
return null;
}
_dialogOpen = true;
try
{
return await dialog.ShowAsync();
}
finally
{
_dialogOpen = false;
}
}
}
}
+22 -7
View File
@@ -21,18 +21,18 @@
<MenuBar Grid.Row="0">
<MenuBarItem Title="File" AccessKey="F">
<MenuFlyoutItem Text="Settings…" AccessKey="S" Click="SettingsMenuItem_Click" />
<MenuFlyoutItem Text="Settings…" AccessKey="S" AutomationProperties.AutomationId="MenuSettings" Click="SettingsMenuItem_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem Text="Exit" AccessKey="X" Click="ExitMenuItem_Click" />
<MenuFlyoutItem Text="Exit" AccessKey="X" AutomationProperties.AutomationId="MenuExit" Click="ExitMenuItem_Click" />
</MenuBarItem>
<MenuBarItem Title="View" AccessKey="V">
<MenuFlyoutItem Text="Activity log…" AccessKey="A" Click="ActivityLogMenuItem_Click" />
<MenuFlyoutItem Text="Activity log…" AccessKey="A" AutomationProperties.AutomationId="MenuActivityLog" Click="ActivityLogMenuItem_Click" />
</MenuBarItem>
<MenuBarItem Title="Help" AccessKey="H">
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" Click="GitHubMenuItem_Click" />
<MenuFlyoutItem Text="Check for updates…" AccessKey="C" Click="CheckForUpdatesMenuItem_Click" />
<MenuFlyoutItem Text="Check for updates…" AccessKey="C" AutomationProperties.AutomationId="MenuCheckUpdates" Click="CheckForUpdatesMenuItem_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem Text="About gclo…" AccessKey="A" Click="AboutMenuItem_Click">
<MenuFlyoutItem Text="About gclo…" AccessKey="A" AutomationProperties.AutomationId="MenuAbout" Click="AboutMenuItem_Click">
<MenuFlyoutItem.KeyboardAccelerators>
<KeyboardAccelerator Key="F1" />
</MenuFlyoutItem.KeyboardAccelerators>
@@ -45,6 +45,7 @@
Content hosts the selected workspace's cached WorkspacePage. -->
<NavigationView
x:Name="WorkspaceNav"
AutomationProperties.AutomationId="WorkspaceNav"
Grid.Row="1"
PaneDisplayMode="Left"
IsBackButtonVisible="Collapsed"
@@ -52,7 +53,10 @@
OpenPaneLength="240"
SelectionChanged="WorkspaceNav_SelectionChanged">
<NavigationView.MenuItems>
<NavigationViewItem x:Name="QuickSyncNavItem" Content="Quick Sync">
<NavigationViewItem
x:Name="QuickSyncNavItem"
AutomationProperties.AutomationId="NavQuickSync"
Content="Quick Sync">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE945;" />
</NavigationViewItem.Icon>
@@ -62,6 +66,7 @@
<NavigationView.FooterMenuItems>
<NavigationViewItem
x:Name="SyncAllNavItem"
AutomationProperties.AutomationId="NavSyncAll"
Content="Sync all"
Tag="syncall"
IsEnabled="False">
@@ -69,12 +74,22 @@
<FontIcon Glyph="&#xE895;" />
</NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="Add account" Tag="add">
<NavigationViewItem
AutomationProperties.AutomationId="NavAddAccount"
Content="Add account"
Tag="add">
<NavigationViewItem.Icon>
<FontIcon Glyph="&#xE710;" />
</NavigationViewItem.Icon>
</NavigationViewItem>
</NavigationView.FooterMenuItems>
</NavigationView>
<!-- Branded startup splash: covers the whole window until MainWindow dismisses
it (fade + removal). An overlay, not a second Window — see SplashOverlay. -->
<local:SplashOverlay
x:Name="StartupSplash"
Grid.Row="0"
Grid.RowSpan="2" />
</Grid>
</Window>
+23 -11
View File
@@ -66,7 +66,8 @@ namespace gclo
public MainWindow()
{
_log = new FileActivityLog();
_log = new FileActivityLog(System.IO.Path.Combine(GcloPaths.DataRoot, "logs"));
App.CrashLog = _log; // the unhandled-exception net now reaches the activity log
_tokenVault = new CredentialManagerVault();
_accountsStore = new AccountsStore(_tokenVault, log: _log);
@@ -109,8 +110,19 @@ namespace gclo
}
Closed += (_, _) => DisposeWorkspaces();
// Dismiss the startup splash overlay once it has been visible long enough
// to read as an intentional brand moment rather than a flicker.
DispatcherQueue.TryEnqueue(async () =>
{
await Task.Delay(MinimumSplashMilliseconds);
await StartupSplash.DismissAsync();
});
}
/// <summary>Minimum splash display, so a fast startup never shows a flicker.</summary>
private const int MinimumSplashMilliseconds = 800;
private void ApplySettings()
{
if (Content is FrameworkElement root)
@@ -389,7 +401,7 @@ namespace gclo
{
XamlRoot = Content.XamlRoot,
};
await dialog.ShowAsync();
await DialogGuard.ShowAsync(dialog);
if (!dialog.Saved)
{
return;
@@ -486,7 +498,7 @@ namespace gclo
DefaultButton = ContentDialogButton.Close,
XamlRoot = Content.XamlRoot,
};
if (await confirm.ShowAsync() != ContentDialogResult.Primary)
if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary)
{
return;
}
@@ -598,9 +610,9 @@ namespace gclo
catch (Exception ex)
{
// Unexpected only: the coordinator reports per-account trouble by
// skipping. (Also lands here when the summary dialog cannot open
// because another ContentDialog is showing; the activity log carries
// the per-account outcomes either way.)
// skipping, and DialogGuard turns a blocked summary dialog into a
// no-op instead of a throw; the activity log carries the
// per-account outcomes either way.
_log.Error($"Sync all did not finish cleanly: {ex.Message}", ex);
}
finally
@@ -651,7 +663,7 @@ namespace gclo
private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new SettingsDialog(_settings) { XamlRoot = Content.XamlRoot };
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
if (await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary)
{
dialog.ApplyAndSave();
ApplySettings();
@@ -663,7 +675,7 @@ namespace gclo
private async void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new LogViewerDialog(_log) { XamlRoot = Content.XamlRoot };
await dialog.ShowAsync();
await DialogGuard.ShowAsync(dialog);
}
private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e)
@@ -674,7 +686,7 @@ namespace gclo
private async void AboutMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new AboutDialog { XamlRoot = Content.XamlRoot };
await dialog.ShowAsync();
await DialogGuard.ShowAsync(dialog);
}
private async void CheckForUpdatesMenuItem_Click(object sender, RoutedEventArgs e)
@@ -713,7 +725,7 @@ namespace gclo
DefaultButton = ContentDialogButton.Primary,
XamlRoot = Content.XamlRoot,
};
if (await confirm.ShowAsync() != ContentDialogResult.Primary)
if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary)
{
return;
}
@@ -736,7 +748,7 @@ namespace gclo
CloseButtonText = "OK",
XamlRoot = Content.XamlRoot,
};
await dialog.ShowAsync();
await DialogGuard.ShowAsync(dialog);
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
+86
View File
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="gclo.SplashOverlay"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<!-- Brand splash, rendered as a full-window overlay INSIDE the main window: a
separate splash Window is a UIA crash hazard (assistive tech or UI automation
querying it mid-close takes down the process with a stowed exception), while
an overlay is ordinary XAML churn. Deliberately single-theme: the gradient and
white type ARE the brand look in both OS themes; the root requests Dark so
theme-resource-driven control parts (the ProgressBar) match the palette. -->
<Grid
x:Name="SplashRoot"
RequestedTheme="Dark"
AutomationProperties.Name="gclo is starting">
<Grid.Background>
<!-- The app icon's palette, run diagonally for a little depth. -->
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="#3B4CCA" />
<GradientStop Offset="1" Color="#2A2F8F" />
</LinearGradientBrush>
</Grid.Background>
<Grid.Resources>
<Storyboard x:Name="FadeOut">
<DoubleAnimation
Storyboard.TargetName="SplashRoot"
Storyboard.TargetProperty="Opacity"
To="0"
Duration="0:0:0.25" />
</Storyboard>
</Grid.Resources>
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center">
<FontIcon
Glyph="&#xE895;"
FontSize="56"
Foreground="White"
HorizontalAlignment="Center"
Margin="0,0,0,16" />
<TextBlock
Text="gclo"
FontFamily="Segoe UI Variable Display"
FontWeight="SemiBold"
FontSize="40"
Foreground="White"
HorizontalAlignment="Center" />
<TextBlock
Text="Git Clone Large Organizations"
FontSize="14"
Foreground="White"
Opacity="0.8"
HorizontalAlignment="Center"
Margin="0,4,0,0" />
<TextBlock
x:Name="VersionText"
FontSize="12"
Foreground="White"
Opacity="0.6"
HorizontalAlignment="Center"
Margin="0,8,0,0" />
</StackPanel>
<!-- Thin activity strip: white bar over a 20% white track; the track-height
resource is overridden so the track matches the 3px bar. -->
<ProgressBar
IsIndeterminate="True"
Height="3"
MinHeight="3"
Foreground="White"
Background="#33FFFFFF"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Margin="0,0,0,20">
<ProgressBar.Resources>
<x:Double x:Key="ProgressBarTrackHeight">3</x:Double>
</ProgressBar.Resources>
</ProgressBar>
</Grid>
</UserControl>
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.UI.Xaml.Controls;
namespace gclo
{
/// <summary>
/// Branded startup splash rendered as a full-window overlay inside
/// <see cref="MainWindow"/>. An overlay instead of a separate splash window by
/// design: closing a real window while assistive technology or UI automation is
/// querying it crashes the process with a stowed XAML exception, whereas fading
/// out and removing an element is ordinary, race-free XAML work.
/// </summary>
public sealed partial class SplashOverlay : UserControl
{
private bool _dismissed;
/// <summary>Builds the overlay and stamps the build identity line.</summary>
public SplashOverlay()
{
InitializeComponent();
VersionText.Text = gclo.Engine.BuildVersion.Describe(Assembly.GetExecutingAssembly());
}
/// <summary>
/// Fades the overlay out and removes it from its parent panel. Safe to call
/// more than once; only the first call does anything.
/// </summary>
public async Task DismissAsync()
{
if (_dismissed)
{
return;
}
_dismissed = true;
// Input must fall through to the app the moment dismissal starts.
IsHitTestVisible = false;
var completion = new TaskCompletionSource();
FadeOut.Completed += (_, _) => completion.TrySetResult();
FadeOut.Begin();
await completion.Task;
(Parent as Panel)?.Children.Remove(this);
}
}
}
+14
View File
@@ -41,6 +41,7 @@
<StackPanel Spacing="12">
<PasswordBox
x:Name="TokenBox"
AutomationProperties.AutomationId="ConnectTokenBox"
Header="Personal access token"
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
@@ -55,6 +56,7 @@
</Grid.ColumnDefinitions>
<ComboBox
Grid.Column="0"
AutomationProperties.AutomationId="ConnectOrgBox"
HorizontalAlignment="Stretch"
Header="Organization or account"
IsEditable="True"
@@ -77,11 +79,13 @@
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
AutomationProperties.AutomationId="ConnectTargetBox"
Header="Target folder"
PlaceholderText="C:\src\my-org"
Text="{x:Bind ViewModel.TargetFolder, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="1"
AutomationProperties.AutomationId="ConnectBrowseButton"
VerticalAlignment="Bottom"
Content="Browse…"
Click="BrowseButton_Click" />
@@ -103,6 +107,7 @@
<StackPanel Orientation="Horizontal" Spacing="12">
<Button
MinWidth="140"
AutomationProperties.AutomationId="LoadReposButton"
Style="{StaticResource AccentButtonStyle}"
Content="Load repositories"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}">
@@ -175,6 +180,7 @@
<HyperlinkButton
Padding="4,0"
VerticalAlignment="Center"
AutomationProperties.AutomationId="ChipEditButton"
AutomationProperties.Name="Edit connection"
Content="Edit"
Click="EditButton_Click">
@@ -286,6 +292,7 @@
</Grid.ColumnDefinitions>
<SelectorBar
x:Name="FilterSelectorBar"
AutomationProperties.AutomationId="RepoFilterBar"
Grid.Column="0"
VerticalAlignment="Center"
SelectionChanged="FilterSelectorBar_SelectionChanged">
@@ -306,6 +313,7 @@
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(ViewModel.IsRunning), Mode=OneWay}">
<Button
x:Name="OptionsButton"
AutomationProperties.AutomationId="SyncOptionsButton"
AutomationProperties.Name="Sync options"
ToolTipService.ToolTip="Sync options">
<FontIcon FontSize="16" Glyph="&#xE713;" />
@@ -340,6 +348,7 @@
</Button.Flyout>
</Button>
<Button
AutomationProperties.AutomationId="RefreshButton"
Content="Refresh"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}">
<Button.KeyboardAccelerators>
@@ -348,6 +357,7 @@
</Button>
<Button
MinWidth="120"
AutomationProperties.AutomationId="SyncButton"
Style="{StaticResource AccentButtonStyle}"
Content="{x:Bind ViewModel.SyncButtonLabel, Mode=OneWay}"
Command="{x:Bind ViewModel.SyncCommand, Mode=OneTime}">
@@ -371,6 +381,7 @@
VerticalAlignment="Center"
Text="{x:Bind local:StatusFormat.RunProgressText(ViewModel.CompletedCount, ViewModel.TotalCount), Mode=OneWay}" />
<Button
AutomationProperties.AutomationId="CancelSyncButton"
Content="Cancel"
Command="{x:Bind ViewModel.SyncCancelCommand, Mode=OneTime}">
<Button.KeyboardAccelerators>
@@ -432,6 +443,7 @@
<!-- Run results: opens when a run ends, user-dismissable (two-way IsOpen). -->
<InfoBar
Grid.Row="2"
AutomationProperties.AutomationId="ResultInfoBar"
Margin="0,8,0,0"
IsOpen="{x:Bind ViewModel.ResultOpen, Mode=TwoWay}"
Severity="{x:Bind local:StatusFormat.SeverityFor(ViewModel.ResultKind), Mode=OneWay}"
@@ -555,6 +567,7 @@
via ElementName (x:Bind inside the template is scoped to the row item). -->
<ListView
x:Name="RepoListView"
AutomationProperties.AutomationId="RepoListView"
Grid.Row="4"
DataContext="{x:Bind ViewModel, Mode=OneTime}"
ItemsSource="{x:Bind ViewModel.FilteredRepos, Mode=OneTime}"
@@ -673,6 +686,7 @@
the tree (with its name) so announcements keep working in both states. -->
<TextBlock
x:Name="StatusTextBlock"
AutomationProperties.AutomationId="StatusTextBlock"
Grid.Row="1"
AutomationProperties.LiveSetting="Polite"
Style="{ThemeResource CaptionTextBlockStyle}"
+2 -2
View File
@@ -206,8 +206,8 @@ namespace gclo
}
var dialog = new PathRecoveryDialog(item.Name, paths) { XamlRoot = XamlRoot };
await dialog.ShowAsync();
return dialog.Result;
await DialogGuard.ShowAsync(dialog);
return dialog.Result; // blocked by another open dialog reads as dismissed
}
// PasswordBox does not support reliable two-way x:Bind on Password;