diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab221d8..28ff7ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,15 +37,20 @@ jobs: timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up .NET SDK - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + # RestoreLockedMode: the committed packages.lock.json files are the restore + # graph; a stale lock file (a bump without 'dotnet restore --force-evaluate') + # fails here instead of silently restoring something else. - name: Build solution (x64, warnings as errors) - run: dotnet build gclo.slnx -p:Platform=x64 -warnaserror + run: dotnet build gclo.slnx -p:Platform=x64 -warnaserror -p:RestoreLockedMode=true # coverage.runsettings scopes collection to the three library assemblies and # excludes source-generated code and the [ExcludeFromCodeCoverage] native/ @@ -96,7 +101,7 @@ jobs: - name: Upload coverage artifact if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-cobertura path: TestResults/**/coverage.cobertura.xml @@ -115,20 +120,35 @@ jobs: continue-on-error: ${{ inputs.ui-tests-advisory == true }} steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up .NET SDK - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' # 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 + run: dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:RestoreLockedMode=true + # Diagnostics for the day this job fails on a hosted runner: a TRX log of + # the run, a screenshot of the screen at the end of every test session, and + # the app's own activity log, all uploaded only when something failed. - name: Run UI tests - run: dotnet test gclo.UiTests/gclo.UiTests.csproj + env: + GCLO_UITEST_ARTIFACTS: ${{ github.workspace }}\TestResults\ui + run: dotnet test gclo.UiTests/gclo.UiTests.csproj --logger "trx;LogFileName=uitests.trx" --results-directory TestResults + + - name: Upload UI test diagnostics + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ui-test-diagnostics + path: TestResults + if-no-files-found: warn format: name: Format (style gate) @@ -136,15 +156,17 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up .NET SDK - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Restore - run: dotnet restore gclo.slnx + run: dotnet restore gclo.slnx --locked-mode # Kept at --severity error so this gates real issues (analyzer errors and # error-severity style rules), not whitespace preferences. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 277a67d..3ef6231 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -35,23 +35,25 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up .NET SDK - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4 with: languages: csharp build-mode: manual - name: Build solution (x64) - run: dotnet build gclo.slnx -p:Platform=x64 + run: dotnet build gclo.slnx -p:Platform=x64 -p:RestoreLockedMode=true - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4 with: category: '/language:csharp' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index d488288..3747490 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -22,10 +22,10 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Dependency review - uses: actions/dependency-review-action@v5 + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: # Fail on ANY known vulnerability, even low severity. fail-on-severity: low diff --git a/.github/workflows/nuget-updates.yml b/.github/workflows/nuget-updates.yml index 65a2e77..299a369 100644 --- a/.github/workflows/nuget-updates.yml +++ b/.github/workflows/nuget-updates.yml @@ -50,14 +50,16 @@ jobs: pull-requests: write steps: - name: Checkout dev - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: dev - name: Set up .NET SDK - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' - name: Bump package versions id: bump @@ -67,6 +69,12 @@ jobs: if ('${{ inputs.include-major }}' -eq 'true') { $params.IncludeMajor = $true } ./.github/scripts/Update-NuGetPackages.ps1 @params + # The committed lock files must follow the bump, or CI's locked restore + # rejects the PR. + - name: Refresh package lock files + if: steps.bump.outputs.changed == 'true' + run: dotnet restore gclo.slnx --force-evaluate + # The gates CI applies, run against the bumped tree. They are allowed to fail # so the PR still opens and shows what broke; the outcome lands in the body. - name: Gate - build (x64, warnings as errors) @@ -136,6 +144,7 @@ jobs: .NET add-paths: | **/*.csproj + **/packages.lock.json - name: Nothing to update if: steps.bump.outputs.changed != 'true' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 421786f..c7dcc30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: channel: ${{ steps.version.outputs.channel }} steps: - name: Checkout (full history, so main's ancestry can be checked) - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -96,20 +96,30 @@ jobs: timeout-minutes: 90 permissions: contents: write + # Build provenance attestations for the published assets. + id-token: write + attestations: write env: - # Optional secrets. Empty when not configured; the steps that need them - # check for that in their `if:` and are skipped cleanly. - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} - WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} REPO_URL: https://github.com/KofTwentyTwo/gclo + # Pinned release tooling. vpk builds the installer and packages users + # receive and wingetcreate is a binary fetched at run time; neither may + # float. Bump deliberately, together with the Velopack package reference. + VPK_VERSION: 1.2.161 + WINGETCREATE_VERSION: v1.12.13.0 + WINGETCREATE_SHA256: 24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea + # The publishing secrets (NUGET_API_KEY, WINGET_TOKEN) are NOT job-level env: + # only the two steps that publish receive them, so nothing executed by the + # build, the tests, vpk, or wingetcreate's download can read them. steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up .NET SDK - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' # ---------------------------------------------------------------------- # (a) CLI: self-contained single-file publish, zipped as a release asset. @@ -132,7 +142,7 @@ jobs: run: dotnet publish gclo/gclo.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained -p:Version=${{ needs.verify.outputs.version }} -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:PublishTrimmed=false -o publish/app - name: Install Velopack CLI - run: dotnet tool install -g vpk + run: dotnet tool install -g vpk --version $env:VPK_VERSION - name: Download previous Velopack release (delta baseline) # The very first release on a channel has nothing to download; that is @@ -150,6 +160,19 @@ jobs: - name: Velopack pack run: vpk pack --packId gclo --packVersion ${{ needs.verify.outputs.version }} --packDir publish/app --mainExe gclo.exe --channel ${{ needs.verify.outputs.channel }} --icon gclo/Assets/Brand/gclo.ico + # Provenance: a signed statement that THIS workflow run built these exact + # bytes, verifiable with `gh attestation verify -R KofTwentyTwo/gclo`. + # Not a code signature (see #57); it makes a release attributable to the + # workflow rather than only to whoever holds the account. + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 + with: + subject-path: | + artifacts/gclo-cli-win-x64.zip + Releases/*.exe + Releases/*.zip + Releases/*.nupkg + # This repo has immutable releases: assets freeze the moment a release is # PUBLISHED. So vpk creates a DRAFT (no --publish), every asset and the # changelog land while it is still mutable, and publishing happens last. @@ -184,11 +207,29 @@ jobs: # (c) NuGet: pack gclo.Engine and push to nuget.org - only when the # NUGET_API_KEY secret is configured on the repository. # ---------------------------------------------------------------------- + # ContinuousIntegrationBuild normalizes the embedded source paths so the + # package is deterministic and SourceLink step-through works; the .snupkg + # beside it carries the symbols. - name: Pack gclo.Engine - run: dotnet pack gclo.Engine -c Release -p:Version=${{ needs.verify.outputs.version }} -o artifacts/nuget + run: dotnet pack gclo.Engine -c Release -p:Version=${{ needs.verify.outputs.version }} -p:ContinuousIntegrationBuild=true -o artifacts/nuget + + # The secrets context is not available in a step's `if`, so one step turns + # "is it configured?" into outputs; it is the only step besides the two + # publishers that sees the values, and it never prints them. + - name: Check which optional publishing secrets are configured + id: secrets + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + Add-Content -Path $env:GITHUB_OUTPUT -Value ("nuget=" + (-not [string]::IsNullOrEmpty($env:NUGET_API_KEY)).ToString().ToLower()) + Add-Content -Path $env:GITHUB_OUTPUT -Value ("winget=" + (-not [string]::IsNullOrEmpty($env:WINGET_TOKEN)).ToString().ToLower()) - name: Push gclo.Engine to nuget.org - if: env.NUGET_API_KEY != '' + if: steps.secrets.outputs.nuget == 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: dotnet nuget push "artifacts/nuget/*.nupkg" --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate # ---------------------------------------------------------------------- @@ -201,10 +242,20 @@ jobs: # docs/RELEASING.md), then this step keeps it current. # ---------------------------------------------------------------------- - name: Submit winget manifest update - if: needs.verify.outputs.prerelease == 'false' && env.WINGET_TOKEN != '' + if: needs.verify.outputs.prerelease == 'false' && steps.secrets.outputs.winget == 'true' shell: pwsh + env: + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} run: | - Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe + # A pinned release asset, verified against its committed SHA-256, instead + # of whatever https://aka.ms/wingetcreate/latest redirects to today. + $url = "https://github.com/microsoft/winget-create/releases/download/$env:WINGETCREATE_VERSION/wingetcreate.exe" + Invoke-WebRequest $url -OutFile wingetcreate.exe + $actual = (Get-FileHash wingetcreate.exe -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:WINGETCREATE_SHA256) { + Write-Error "wingetcreate.exe hash mismatch: expected $env:WINGETCREATE_SHA256, got $actual" + exit 1 + } # Velopack names the installer '--Setup.exe'. $setupUrl = "$env:REPO_URL/releases/download/$env:GITHUB_REF_NAME/gclo-stable-Setup.exe" .\wingetcreate.exe update KofTwentyTwo.gclo --urls $setupUrl --version ${{ needs.verify.outputs.version }} --submit --token $env:WINGET_TOKEN diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbd0681..993a5f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,8 +12,8 @@ Before starting on a feature, check the [development roadmap (#11)](https://gith ## Branch model -- **`dev`** is the integration branch. **Pull requests target `dev`**, not `main`. -- **`main`** is the stable branch. It only moves via reviewed PRs from `dev` and is protected by required status checks. Releases are tagged from `main`. +- **`dev`** is the integration branch. **Pull requests target `dev`**, not `main`. CI, CodeQL, and dependency review run on every push and PR to `dev` and their results are visible on the PR, but they are **not enforced** as required checks there — the maintainer merges on green. +- **`main`** is the stable branch. It only moves via PRs from `dev`, and those are enforced: the branch ruleset requires the build/test, format, dependency-review, and CodeQL checks to pass before a merge. Releases are tagged from `main`, and the release workflow refuses a tag that is not on it. Fork the repository, branch from `dev`, and open your PR against `dev`. @@ -26,6 +26,10 @@ All commands run from the repository root: # CI runs this with -warnaserror — the build must produce ZERO warnings. dotnet build gclo.slnx -p:Platform=x64 +# Changed a PackageReference? Refresh the committed lock files, or CI's +# locked restore rejects the change. +dotnet restore gclo.slnx --force-evaluate + # Unpackaged build — runs without MSIX deployment or package identity. # Verify this still works before opening a PR. dotnet build gclo.slnx -p:Platform=x64 -p:WindowsPackageType=None diff --git a/Directory.Build.props b/Directory.Build.props index e14ba8f..0c05c02 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,6 +8,11 @@ latest-recommended true true + + true + true + true + true + snupkg diff --git a/gclo.Engine/packages.lock.json b/gclo.Engine/packages.lock.json new file mode 100644 index 0000000..d3db6db --- /dev/null +++ b/gclo.Engine/packages.lock.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "LibGit2Sharp": { + "type": "Direct", + "requested": "[0.32.0, )", + "resolved": "0.32.0", + "contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==", + "dependencies": { + "LibGit2Sharp.NativeBinaries": "[2.0.324]" + } + }, + "Octokit": { + "type": "Direct", + "requested": "[14.0.0, )", + "resolved": "14.0.0", + "contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw==" + }, + "LibGit2Sharp.NativeBinaries": { + "type": "Transitive", + "resolved": "2.0.324", + "contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA==" + } + } + } +} \ No newline at end of file diff --git a/gclo.UiTests/AppSession.cs b/gclo.UiTests/AppSession.cs index 5ce9374..2c5b5e6 100644 --- a/gclo.UiTests/AppSession.cs +++ b/gclo.UiTests/AppSession.cs @@ -377,9 +377,91 @@ public sealed class AppSession : IDisposable + "Set GCLO_UITEST_EXE to the gclo.exe to test."); } + private static int _sessionCounter; + + /// + /// Waits until the app's activity log (under the session's data directory) + /// contains . The log is the one deterministic signal + /// for "the app handled that command" when the UI shows no change by design. + /// + public void WaitForLogLine(string fragment, TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); + string logs = Path.Combine(DataDirectory, "logs"); + while (DateTime.UtcNow < deadline) + { + if (Directory.Exists(logs)) + { + foreach (string file in Directory.EnumerateFiles(logs, "*.log")) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + if (reader.ReadToEnd().Contains(fragment, StringComparison.Ordinal)) + { + return; + } + } + catch (IOException) + { + // Mid-write; try again. + } + } + } + Thread.Sleep(50); + } + throw new TimeoutException($"Timed out waiting for the activity log to contain '{fragment}'."); + } + + /// + /// When GCLO_UITEST_ARTIFACTS names a directory (CI does), saves a screenshot of + /// the screen as the session ends and copies the app's activity log beside it, so + /// a failure on a hosted runner has something to diagnose with. Best effort. + /// + private void TryCaptureArtifacts() + { + string? root = Environment.GetEnvironmentVariable("GCLO_UITEST_ARTIFACTS"); + if (string.IsNullOrWhiteSpace(root)) + { + return; + } + + int number = Interlocked.Increment(ref _sessionCounter); + string stamp = DateTime.Now.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture); + try + { + Directory.CreateDirectory(root); + using var image = FlaUI.Core.Capturing.Capture.Screen(); + image.ToFile(Path.Combine(root, $"session-{number:D2}-{stamp}.png")); + } + catch + { + // A missing desktop session or a disposed automation must not fail the test. + } + + try + { + string logs = Path.Combine(DataDirectory, "logs"); + if (Directory.Exists(logs)) + { + foreach (string file in Directory.EnumerateFiles(logs)) + { + File.Copy(file, Path.Combine(root, $"session-{number:D2}-{Path.GetFileName(file)}"), overwrite: true); + } + } + } + catch + { + // Best effort only. + } + } + /// public void Dispose() { + TryCaptureArtifacts(); + // 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. diff --git a/gclo.UiTests/SmokeTests.cs b/gclo.UiTests/SmokeTests.cs index 39279b5..3fedbcc 100644 --- a/gclo.UiTests/SmokeTests.cs +++ b/gclo.UiTests/SmokeTests.cs @@ -112,8 +112,11 @@ public sealed partial class SmokeTests : UiTestBase Session.WaitForText("Step 1 of 4: Identity"); // right dialog, right step Session.ClickButton("Next"); - Thread.Sleep(PostInvokeSettle); // validation runs behind the dialog deferral + // Positive oracle: the step-1 validation message appearing proves the + // Next handler ran and rejected; only then is "still on step 1" meaningful. + Session.WaitForText("Enter a name for this account."); + Assert.NotNull(Session.FindInApp(cf => cf.ByName("Step 1 of 4: Identity"))); Assert.NotNull(Session.WaitForElement("WizardNameBox")); // still open Assert.False(Session.App.HasExited); @@ -153,7 +156,11 @@ public sealed partial class SmokeTests : UiTestBase Session.WaitForElement("AboutVersionText"); Session.InvokeNavItem("NavAddAccount"); // second dialog: must be a no-op - Thread.Sleep(PostInvokeSettle); + + // Positive oracle: the shell logs that it declined to open the wizard, so + // the negative assertions below are checked after the command was handled, + // not after an arbitrary pause. + Session.WaitForLogLine("Account wizard not opened: another dialog is open."); Assert.False(Session.App.HasExited); Assert.Null(Session.FindInApp(cf => cf.ByAutomationId("WizardNameBox"))); diff --git a/gclo.UiTests/packages.lock.json b/gclo.UiTests/packages.lock.json new file mode 100644 index 0000000..a6c08ee --- /dev/null +++ b/gclo.UiTests/packages.lock.json @@ -0,0 +1,184 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows10.0.19041": { + "FlaUI.UIA3": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "s1Yy1RsRJ0OXvmtxQlNJd0cu4sfvN3PFFu6skBGK8VlwPl/b38E0V5kCbo2gvkuhRIu4Itfxjdo7DUMF7qJw4Q==", + "dependencies": { + "FlaUI.Core": "5.0.0", + "Interop.UIAutomationClient": "10.19041.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.10.1, )", + "resolved": "18.10.1", + "contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==", + "dependencies": { + "Microsoft.CodeCoverage": "18.10.1", + "Microsoft.TestPlatform.TestHost": "18.10.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag==" + }, + "FlaUI.Core": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VWWYqwjsYkii2BrUsz5+bpXEp27NherKhLt19hTxHwJP4KrfyZoeo1FdBov4ZTh655wZAKDGIjR7NJLDesuzeg==", + "dependencies": { + "System.Diagnostics.PerformanceCounter": "8.0.1", + "System.Drawing.Common": "8.0.10", + "System.Management": "8.0.0", + "System.Security.Permissions": "8.0.0" + } + }, + "Interop.UIAutomationClient": { + "type": "Transitive", + "resolved": "10.19041.0", + "contentHash": "Me5vKNZ1JSg6txTsPQXaWx7fVNh+fmcUPPwtDKJtqvbMM0N8aoa/l/QPolkJIRnasCabCNt4v2zG4p1Q9HU98A==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.10.1", + "contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.10.1", + "contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.10.1", + "contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.10.1" + } + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "9opKRyOKMCi2xJ7Bj7kxtZ1r9vbzosMvRrdEhVhDz8j8MoBGgB+WmC94yH839NPH+BclAjtQ/pyagvi/8gDLkw==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "WTlRjL6KWIMr/pAaq3rYqh0TJlzpouaQ/W1eelssHgtlwHAH25jXTkUphTYx9HaIIf7XA6qs/0+YhtLEQRkJ+Q==" + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==", + "dependencies": { + "System.Diagnostics.EventLog": "8.0.1", + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "n1ZP7NM2Gkn/MgD8+eOT5MulMj6wfeQMNS2Pizvq5GHCZfjlFMXV2irQlQmJhwA2VABC57M0auudO89Iu2uRLg==" + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "9RfEDiEjlUADeThs8IPdDVTXSnPRSqjfgTQJALpmGFPKC0k2mbdufOXnb/9JZ4I0TkmxOfy3VTJxrHOJSs8cXg==", + "dependencies": { + "System.Configuration.ConfigurationManager": "8.0.1" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "8.0.10", + "contentHash": "MdajRp3P+FOlThgY6FBjAqnmLiVl5t2yWEC/2AsDMqx1zYbJG3G5TnscFBQ4obqcaGqvN5UnhQHSFaJFG2HftQ==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "8.0.0" + } + }, + "System.Management": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "jrK22i5LRzxZCfGb+tGmke2VH7oE0DvcDlJ1HAKYU8cPmD8XnpUT0bYn2Gy98GEhGjtfbR/sxKTVb+dE770pfA==", + "dependencies": { + "System.CodeDom": "8.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "v/BBylw7XevuAsHXoX9dDUUfmBIcUf7Lkz8K3ZXIKz3YRKpw8YftpSir4n4e/jDTKFoaK37AsC3xnk+GNFI1Ow==", + "dependencies": { + "System.Windows.Extensions": "8.0.0" + } + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "Obg3a90MkOw9mYKxrardLpY2u0axDMrSmy4JCdq2cYbelM2cUwmUir5Bomvd1yxmPL9h5LVHU1tuKBZpUjfASg==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + } + } + } +} \ No newline at end of file diff --git a/gclo.ViewModels/AccountWizardSeed.cs b/gclo.ViewModels/AccountWizardSeed.cs new file mode 100644 index 0000000..c827c4e --- /dev/null +++ b/gclo.ViewModels/AccountWizardSeed.cs @@ -0,0 +1,24 @@ +namespace gclo.ViewModels; + +/// +/// The connection values a Quick Sync workspace hands to the account wizard so a +/// working ad-hoc setup can be saved as an account without re-entering anything +/// (the seeded constructor). +/// +/// The token in effect; written to the vault on save. +/// The organization (or user) the workspace is connected to. +/// The workspace's target folder (not the org subfolder). +/// Whether clones go under TargetRoot\Organization. +/// The workspace's parallel clone count. +public sealed record AccountWizardSeed( + string Token, + string Organization, + string TargetRoot, + bool CreateOrgSubfolder, + int MaxConcurrency) +{ + /// Redacts the token: this record must never print it. + public override string ToString() => + $"AccountWizardSeed {{ Token = [redacted], Organization = {Organization}, TargetRoot = {TargetRoot}, " + + $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency} }}"; +} diff --git a/gclo.ViewModels/AccountWizardViewModel.cs b/gclo.ViewModels/AccountWizardViewModel.cs index 1a52c8a..4348d9b 100644 --- a/gclo.ViewModels/AccountWizardViewModel.cs +++ b/gclo.ViewModels/AccountWizardViewModel.cs @@ -15,36 +15,33 @@ public sealed partial class AccountWizardViewModel : ObservableObject { private readonly AccountsStore _store; private readonly IOrganizationLister _orgLister; + private readonly IActivityLog _log; private readonly Account? _existing; - /// What the token box was seeded with; unchanged means "leave the vault alone". - private readonly string _seededToken; + /// + /// For edits: fetches the account's stored token from the vault, called only when + /// a step actually needs to transmit it (validating on step 2 with the box left + /// empty). The token is never copied into , so the dialog never + /// holds or shows it; an empty box means "keep the stored token" (#32). + /// + private readonly Func? _storedToken; /// /// A wizard for a new account seeded from , or — when - /// is given — an edit wizard seeded from that account - /// and (the vault's current token, or null when - /// the vault has no entry for it). + /// is given — an edit wizard seeded from that account. + /// fetches the vault's current token on demand for + /// an edit (null, or returning null, when the vault has no entry). /// public AccountWizardViewModel( AccountsStore store, IOrganizationLister orgLister, AppSettings defaults, Account? existing = null, - string? existingToken = null) + Func? storedToken = null, + IActivityLog? log = null) + : this(store, orgLister, existing, storedToken, log) { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(orgLister); ArgumentNullException.ThrowIfNull(defaults); - _store = store; - _orgLister = orgLister; - _existing = existing; - _seededToken = existingToken ?? ""; - - Step = 1; - NameError = ""; - TokenError = ""; - Token = _seededToken; if (existing is null) { @@ -66,6 +63,50 @@ public sealed partial class AccountWizardViewModel : ObservableObject } } + /// + /// A wizard for a NEW account seeded from a working Quick Sync connection + /// (): token, organization, folder, subfolder preference, + /// and parallelism are carried over so "save this connection as an account" is a + /// matter of naming it (#30). The token is treated as freshly typed, so it is + /// always written to the vault on save. + /// + public AccountWizardViewModel( + AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null) + : this(store, orgLister, existing: null, storedToken: null, log) + { + ArgumentNullException.ThrowIfNull(seed); + Name = ""; + Description = ""; + Token = seed.Token; + Organization = seed.Organization; + TargetRoot = seed.TargetRoot; + CreateOrgSubfolder = seed.CreateOrgSubfolder; + MaxConcurrency = seed.MaxConcurrency; + } + + private AccountWizardViewModel( + AccountsStore store, IOrganizationLister orgLister, Account? existing, Func? storedToken, IActivityLog? log) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(orgLister); + _store = store; + _orgLister = orgLister; + _log = log ?? new NullActivityLog(); + _existing = existing; + _storedToken = existing is null ? null : storedToken; + + Step = 1; + NameError = ""; + TokenError = ""; + OrganizationError = ""; + TargetError = ""; + Token = ""; + Name = ""; + Description = ""; + Organization = ""; + TargetRoot = ""; + } + /// Current wizard step, 1 (identity) through 4 (destination). [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsFirstStep))] @@ -80,7 +121,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject [ObservableProperty] public partial string Description { get; set; } - /// GitHub personal access token; validated when leaving step 2. + /// + /// GitHub personal access token as typed; validated when leaving step 2. For an + /// edit it starts empty and stays empty unless the user types a replacement — + /// the stored token is never loaded into it. + /// [ObservableProperty] public partial string Token { get; set; } @@ -112,6 +157,14 @@ public sealed partial class AccountWizardViewModel : ObservableObject [ObservableProperty] public partial string TokenError { get; set; } + /// Step 3's validation message; empty once an organization is chosen. + [ObservableProperty] + public partial string OrganizationError { get; set; } + + /// Step 4's validation message; empty once a target folder is chosen. + [ObservableProperty] + public partial string TargetError { get; set; } + /// Organizations the validated token can see; feeds step 3's editable dropdown. public ObservableCollection Organizations { get; } = new(); @@ -139,8 +192,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject /// /// Validates the current step. Steps 1-3 advance and return true on success; step 4 /// returns true without advancing (the host then calls ). On - /// failure the wizard stays put, with the step's error message set where one exists - /// ( on step 1, on step 2). + /// failure the wizard stays put with the step's error message set + /// (, , , + /// ) — a silent no-op on Next reads as a broken button (#30). /// public async Task TryAdvanceAsync() { @@ -168,20 +222,36 @@ public sealed partial class AccountWizardViewModel : ObservableObject { // The lister is the validation: it fails on a rejected or rate-limited // token and returns the organizations the dropdown offers otherwise. + // An edit with the box left empty validates the STORED token, fetched + // here and used for this one call only. + string candidate = Token.Trim(); + if (candidate.Length == 0 && IsEditing) + { + candidate = _storedToken?.Invoke()?.Trim() ?? ""; + if (candidate.Length == 0) + { + TokenError = "This account has no stored token. Enter one to continue."; + return false; + } + } + IsValidatingToken = true; + _log.Info("Account wizard: validating the token."); try { - var organizations = await _orgLister.ListOrganizationsAsync(Token.Trim()); + var organizations = await _orgLister.ListOrganizationsAsync(candidate); Organizations.Clear(); foreach (string organization in organizations) { Organizations.Add(organization); } TokenError = ""; + _log.Info($"Account wizard: token accepted; {organizations.Count} organizations and accounts visible."); } catch (Exception ex) { TokenError = ex.Message; + _log.Error($"Account wizard: token rejected: {ex.Message}", ex); return false; } finally @@ -194,13 +264,22 @@ public sealed partial class AccountWizardViewModel : ObservableObject case 3: if (string.IsNullOrWhiteSpace(Organization)) { - return false; // the dropdown allows free text, but not nothing + // The dropdown allows free text, but not nothing. + OrganizationError = "Choose an organization from the list, or type one."; + return false; } + OrganizationError = ""; Step = 4; return true; default: // Step 4: valid means "ready to save"; the host closes via SaveAsync. - return !string.IsNullOrWhiteSpace(TargetRoot); + if (string.IsNullOrWhiteSpace(TargetRoot)) + { + TargetError = "Choose a target folder."; + return false; + } + TargetError = ""; + return true; } } @@ -224,9 +303,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject return Task.CompletedTask; } - /// New accounts always persist their token; edits only when it was altered. - private bool TokenChanged - => !IsEditing || !string.Equals(Token, _seededToken, StringComparison.Ordinal); + /// New accounts always persist their token; edits only when one was typed. + private bool TokenChanged => !IsEditing || Token.Trim().Length > 0; private Account BuildAccount() => new() { diff --git a/gclo.ViewModels/AccountsStore.cs b/gclo.ViewModels/AccountsStore.cs index 7a81a0b..277ee3c 100644 --- a/gclo.ViewModels/AccountsStore.cs +++ b/gclo.ViewModels/AccountsStore.cs @@ -117,10 +117,12 @@ public sealed class AccountsStore updated.Add(account); } + string verb = index >= 0 ? "updated" : "added"; if (token is null) { Persist(updated); // IO failures propagate; _accounts stays unchanged then. _accounts = updated; + _log.Info($"Account '{account.Name}' {verb} (token unchanged)."); return; } @@ -165,6 +167,7 @@ public sealed class AccountsStore } _accounts = updated; + _log.Info($"Account '{account.Name}' {verb} with a new token."); } } @@ -184,6 +187,7 @@ public sealed class AccountsStore { return; } + string name = _accounts.First(a => a.Id == id).Name; // Vault first: a failing Delete leaves both stores untouched. Keep the // token in memory only long enough to put it back if the file write fails. @@ -220,6 +224,7 @@ public sealed class AccountsStore } _accounts = remaining; + _log.Info($"Account '{name}' deleted" + (removedToken is null ? " (it had no token)." : " along with its token.")); } } @@ -245,6 +250,7 @@ public sealed class AccountsStore }; Persist(updated); _accounts = updated; + _log.Info($"Account '{updated[index].Name}': sync result recorded ({summary})."); } } diff --git a/gclo.ViewModels/PathRecoveryPlanner.cs b/gclo.ViewModels/PathRecoveryPlanner.cs new file mode 100644 index 0000000..703d84c --- /dev/null +++ b/gclo.ViewModels/PathRecoveryPlanner.cs @@ -0,0 +1,65 @@ +using gclo.Engine; + +namespace gclo.ViewModels; + +/// +/// Turns the per-row choices of the path-recovery UI (rename to this, or skip) into a +/// , and reports the choices that would make the apply fail +/// anyway — so the dialog can flag them inline instead of closing, failing in the +/// engine, and re-opening with a fresh list (#30). The engine still re-validates the +/// full effective tree on apply; this is the pre-flight for what the user can see. +/// +public static class PathRecoveryPlanner +{ + /// One row's choice. + /// The invalid repository path, as reported. + /// The full replacement path, or null when the name was left as is. + /// True to omit the file or folder from the checkout. + public sealed record Choice(string RepoPath, string? ReplacementPath, bool Skip); + + /// The recovery to apply plus the reasons it would still fail, if any. + /// Renames and skips, as the engine consumes them. + /// Human-readable problems, one per offending row; empty when the plan is sound. + public sealed record Plan(PathRecovery Recovery, IReadOnlyList Problems); + + /// + /// Builds the plan. A row that is neither skipped nor renamed is always a problem + /// (every listed path is invalid by definition), and replacement names go through + /// the same Windows rules as a checkout would, including two rows renamed onto one + /// destination. + /// + public static Plan Build(IReadOnlyList choices) + { + ArgumentNullException.ThrowIfNull(choices); + + // Git paths are case-sensitive, so both collections compare ordinally. The same + // repo path can appear in two rows (e.g. an invalid segment that also collides + // by case), which is why renames assign via the indexer instead of Add. + var renames = new Dictionary(StringComparer.Ordinal); + var skipped = new HashSet(StringComparer.Ordinal); + var problems = new List(); + + foreach (Choice choice in choices) + { + if (choice.Skip) + { + skipped.Add(choice.RepoPath); + } + else if (choice.ReplacementPath is { } replacement) + { + renames[choice.RepoPath] = replacement; + } + else + { + problems.Add($"'{choice.RepoPath}' is still invalid: rename it or mark it Skip."); + } + } + + foreach (InvalidPathInfo stillInvalid in WindowsPathValidator.ValidatePaths(renames.Values)) + { + problems.Add($"'{stillInvalid.RepoPath}' cannot be used: {stillInvalid.Reason}."); + } + + return new Plan(new PathRecovery(renames, skipped), problems); + } +} diff --git a/gclo.ViewModels/RepoItemViewModel.cs b/gclo.ViewModels/RepoItemViewModel.cs index 2dde423..15daed1 100644 --- a/gclo.ViewModels/RepoItemViewModel.cs +++ b/gclo.ViewModels/RepoItemViewModel.cs @@ -81,6 +81,13 @@ public sealed partial class RepoItemViewModel : ObservableObject OnPropertyChanged(nameof(IsIndeterminate)); } + /// + /// Re-raises the status change notification without a status change, so views + /// that derive theme-dependent values from (the row's status + /// brush) re-evaluate — used when the application theme changes mid-session. + /// + public void RefreshPresentation() => OnPropertyChanged(nameof(Status)); + partial void OnPercentChanged(double? value) { OnPropertyChanged(nameof(StatusText)); diff --git a/gclo.ViewModels/SyncAllCoordinator.cs b/gclo.ViewModels/SyncAllCoordinator.cs index 6f70776..5d2a706 100644 --- a/gclo.ViewModels/SyncAllCoordinator.cs +++ b/gclo.ViewModels/SyncAllCoordinator.cs @@ -65,6 +65,8 @@ public sealed class SyncAllCoordinator(IActivityLog log) { Announce(workspace, SyncAllAccountState.Queued); } + _log.Info($"Sync all: queued {accountWorkspaces.Count} accounts: " + + string.Join(", ", accountWorkspaces.Select(w => $"'{w.DisplayName}'")) + "."); int ran = 0; int skipped = 0; diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index cd43058..b93c14d 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -27,6 +27,16 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable private readonly Dictionary _itemsByName = new(StringComparer.OrdinalIgnoreCase); private CancellationTokenSource? _orgLoadCts; + /// + /// Position of every row in ; rebuilt on load and sort. Lets + /// progress-driven membership changes find a row's slot in + /// by binary search instead of rebuilding the whole bound list (#30). + /// + private readonly Dictionary _reposIndex = new(); + + /// Mirror of for O(1) membership checks. + private readonly HashSet _filteredSet = new(); + /// Canceled on dispose so an in-flight path recovery dies with the workspace. private readonly CancellationTokenSource _lifetimeCts = new(); @@ -45,6 +55,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// Breaks the AllSelected <-> item.IsSelected feedback loop while one side updates the other. private bool _syncingSelection; + /// False until the constructor finished seeding properties; gates user-action logging. + private readonly bool _constructed; + /// First repository name from the last load; makes concrete. private string? _sampleRepoName; @@ -91,6 +104,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable MaxConcurrency = AppSettings.DefaultConcurrency; StatusText = ""; ResultMessage = ""; + LoadErrorTitle = ""; + LoadErrorMessage = ""; AllSelected = true; CanEditInputs = true; @@ -103,6 +118,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable // The Token setter's existing org lookup fires naturally with the vault token. Token = tokenVault?.TryRetrieve(account.Id) ?? ""; } + _constructed = true; } /// Id of the account this workspace was created for, or null for Quick Sync. @@ -187,10 +203,56 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable [ObservableProperty] public partial bool CreateOrgSubfolder { get; set; } - /// Header checkbox state: setting it checks or unchecks every row. + /// + /// Header checkbox state: setting it checks or unchecks every VISIBLE row (the rows + /// in ), and it reads true when every visible row is + /// selected. With no filter active that is every row; with a filter it makes + /// "filter, then select what you see" the subset workflow (#30). Hidden rows keep + /// their selection and still sync. + /// [ObservableProperty] public partial bool AllSelected { get; set; } + /// + /// Organization whose repositories the table currently holds (trimmed, as loaded); + /// null before the first successful load. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTableStale))] + [NotifyPropertyChangedFor(nameof(StaleTableMessage))] + public partial string? LoadedOrganization { get; set; } + + /// + /// True when was edited after a load: the table still + /// shows the previous organization's rows while the chip and target path describe + /// the new one. Sync is blocked until a reload replaces the rows (#30). + /// + public bool IsTableStale + => LoadedOrganization is not null + && !string.Equals(Organization.Trim(), LoadedOrganization, StringComparison.OrdinalIgnoreCase); + + /// One-line explanation shown while ; empty otherwise. + public string StaleTableMessage + => IsTableStale + ? $"The table still shows '{LoadedOrganization}'. Reload to list '{Organization.Trim()}' before syncing." + : ""; + + /// Title of the error surface for a failed load or organization lookup. + [ObservableProperty] + public partial string LoadErrorTitle { get; set; } + + /// Message of the most recent failed load or organization lookup; empty when none. + [ObservableProperty] + public partial string LoadErrorMessage { get; set; } + + /// + /// True while the load-error surface is showing. Set by a failed load or + /// organization lookup, cleared when the next load starts or a lookup succeeds; the + /// page two-way binds it so the user can dismiss the bar (#30). + /// + [ObservableProperty] + public partial bool LoadErrorOpen { get; set; } + /// True once a sync run has finished (successfully, canceled, or faulted). [ObservableProperty] public partial bool HasCompletedRun { get; set; } @@ -314,7 +376,22 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable partial void OnTargetFolderChanged(string value) => NotifyTargetPathChanged(); - partial void OnOrganizationChanged(string value) => NotifyTargetPathChanged(); + partial void OnOrganizationChanged(string value) + { + NotifyTargetPathChanged(); + OnPropertyChanged(nameof(IsTableStale)); + OnPropertyChanged(nameof(StaleTableMessage)); + SyncCommand.NotifyCanExecuteChanged(); + } + + /// Records a failed load or lookup on the error surface (and the live region). + private void ShowLoadError(string title, string message) + { + StatusText = message; + LoadErrorTitle = title; + LoadErrorMessage = message; + LoadErrorOpen = true; + } partial void OnCreateOrgSubfolderChanged(bool value) => NotifyTargetPathChanged(); @@ -326,11 +403,46 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable // Runs on the UI thread (Token is only set from UI handlers), so the async // continuations below stay on the UI thread and may touch Organizations directly. - partial void OnTokenChanged(string value) => _ = RefreshOrganizationsAsync(); + partial void OnTokenChanged(string value) + { + // The value itself never reaches the log; its length is enough to tell a + // paste from a cleared box when reading back a session (#40). Construction + // seeds the property (empty, or the vault token) and is not a user action. + if (_constructed) + { + _log.Info(value.Length == 0 + ? $"Token cleared in workspace '{DisplayName}'." + : $"Token entered in workspace '{DisplayName}' ({value.Length} characters)."); + } + _ = RefreshOrganizationsAsync(); + } - /// Stops the in-flight org lookup and path recovery, if any. + /// Records that the user opened the target folder from the results bar (#40). + public void NoteFolderOpened() => _log.Info($"Opened folder '{EffectiveTargetRoot}'."); + + /// Asks every row to re-evaluate its theme-dependent presentation (see ). + public void RefreshPresentation() + { + foreach (RepoItemViewModel item in Repos) + { + item.RefreshPresentation(); + } + } + + private bool _disposed; + + /// + /// Stops the in-flight org lookup and path recovery, if any. Idempotent: the shell + /// disposes workspaces on eviction and again on window close, and a second call + /// used to throw ObjectDisposedException from the lifetime token source (#31). + /// public void Dispose() { + if (_disposed) + { + return; + } + _disposed = true; _orgLoadCts?.Cancel(); _orgLoadCts?.Dispose(); _orgLoadCts = null; @@ -361,8 +473,10 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { await Task.Delay(_orgLookupDebounce, lookupToken); // debounce keystrokes / rapid pastes IsLoadingOrgs = true; + _log.Info("Organization lookup started."); var orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken); lookupToken.ThrowIfCancellationRequested(); + _log.Info($"Organization lookup finished: {orgs.Count} organizations and accounts visible."); // An editable ComboBox resets its Text when its ItemsSource is mutated, // and the TwoWay binding would wipe a value that was already set — an @@ -377,6 +491,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable } Organization = organizationBeforeRefresh; OnPropertyChanged(nameof(Organization)); + LoadErrorOpen = false; // a lookup that works clears a stale lookup failure // The production lister always lists the token's own account first, so a // single entry means no organizations were visible. StatusText = orgs.Count <= 1 @@ -391,7 +506,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { if (_orgLoadCts == cts) { - StatusText = ex.Message; + _log.Error($"Organization lookup failed: {ex.Message}", ex); + ShowLoadError("Could not list organizations", ex.Message); } } finally @@ -442,7 +558,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable _syncingSelection = true; try { - foreach (RepoItemViewModel item in Repos) + foreach (RepoItemViewModel item in FilteredRepos) { item.IsSelected = value; } @@ -469,18 +585,21 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable } if (!_syncingSelection) { - // A header push recomputes once after its loop instead of per row. + // A header push (or any other batch) recomputes once after its loop + // instead of per row — including the command re-query, whose CanSync + // scans every row (#30). RecomputeSelectedCount(); UpdateAllSelectedFromItems(); + SyncCommand.NotifyCanExecuteChanged(); } - SyncCommand.NotifyCanExecuteChanged(); } private void RecomputeSelectedCount() => SelectedCount = Repos.Count(r => r.IsSelected); + /// Header state follows the VISIBLE rows; see . private void UpdateAllSelectedFromItems() { - bool all = Repos.All(r => r.IsSelected); + bool all = FilteredRepos.All(r => r.IsSelected); if (AllSelected != all) { _syncingSelection = true; @@ -510,6 +629,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { IsLoadingRepos = true; ResetRunResult(); + LoadErrorOpen = false; try { string organization = Organization.Trim(); @@ -544,14 +664,18 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable SortColumn = null; SortDescending = false; _sampleRepoName = Repos.Count > 0 ? Repos[0].Name : null; - UpdateAllSelectedFromItems(); + RebuildReposIndex(); + LoadedOrganization = organization; HasLoadedRepos = true; StatusText = $"{Repos.Count} repositories loaded."; _log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'."); } catch (Exception ex) { - StatusText = ex.Message; + // The message is good (the listers translate 401/404/rate limits); the + // surface must be too — an InfoBar with Retry, not caption text at the + // bottom of the window (#30). + ShowLoadError("Could not load repositories", ex.Message); _log.Error($"Loading repositories failed: {ex.Message}", ex); } finally @@ -559,7 +683,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable IsLoadingRepos = false; NotifyTargetPathChanged(); RecomputeSelectedCount(); - RebuildFilteredRepos(); + RebuildFilteredRepos(); // also re-derives AllSelected from the visible rows SyncCommand.NotifyCanExecuteChanged(); RetryFailedCommand.NotifyCanExecuteChanged(); RecomputeHasFailedRepos(); @@ -572,6 +696,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable !IsRunning && !IsLoadingRepos && !IsResolvingPaths + && !IsTableStale && !string.IsNullOrWhiteSpace(TargetFolder) && Repos.Any(r => r.IsSelected); @@ -597,6 +722,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable _runSet = selected; TotalCount = selected.Count; RecomputeCompletedCount(); + RebuildFilteredRepos(); // every selected row just went back to Queued string targetRoot = EffectiveTargetRoot; StatusText = $"Syncing {selected.Count} repositories..."; @@ -604,7 +730,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable // Constructed on the UI thread: Progress captures the WinUI // SynchronizationContext, so HandleProgress always runs on the UI thread. - var progress = _progressFactory(HandleProgress); + // Failure logging is layered in front of it so the per-entry file I/O + // happens on the engine's worker thread, never on the dispatcher (#30). + var progress = new FailureLoggingProgress(_progressFactory(HandleProgress), _log); var request = new SyncRequest( Organization.Trim(), Token.Trim(), targetRoot, MaxConcurrency); @@ -676,10 +804,24 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable [RelayCommand(CanExecute = nameof(CanRetryFailed))] private async Task RetryFailedAsync() { - foreach (RepoItemViewModel item in Repos) + _log.Info($"Retrying {Repos.Count(r => r.Status == SyncStatus.Failed)} failed repositories."); + // One batch: per-row recounts and command re-queries would be O(n^2) (#30). + _syncingSelection = true; + try { - item.IsSelected = item.Status == SyncStatus.Failed; + foreach (RepoItemViewModel item in Repos) + { + item.IsSelected = item.Status == SyncStatus.Failed; + } } + finally + { + _syncingSelection = false; + } + RecomputeSelectedCount(); + UpdateAllSelectedFromItems(); + SyncCommand.NotifyCanExecuteChanged(); + // Executing SyncCommand itself keeps SyncCancelCommand working for retry runs. await SyncCommand.ExecuteAsync(null); } @@ -708,9 +850,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable return; } + _log.Info($"{item.Name}: path recovery requested ({item.InvalidPaths?.Count ?? 0} invalid paths)."); PathRecovery? recovery = await interaction(item); if (recovery is null) { + _log.Info($"{item.Name}: path recovery canceled; the repository stays failed."); return; // user canceled; the row keeps its Failed state and payload } @@ -744,6 +888,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable IsResolvingPaths = false; } RecomputeCompletedCount(); + UpdateFilteredMembership(item); RetryFailedCommand.NotifyCanExecuteChanged(); } @@ -779,15 +924,15 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable _ => OrderRepos(r => r.IsArchived, Comparer.Default), }; - // Move (rather than clear + re-add) so list controls keep row containers stable. - for (int target = 0; target < sorted.Count; target++) + // Nothing binds Repos (the table binds FilteredRepos), so a plain reorder is + // enough: O(n) adds instead of the O(n^2) IndexOf/Move choreography that + // used to run here, and the one rebuild below refreshes the bound list (#30). + Repos.Clear(); + foreach (RepoItemViewModel item in sorted) { - int current = Repos.IndexOf(sorted[target]); - if (current != target) - { - Repos.Move(current, target); - } + Repos.Add(item); } + RebuildReposIndex(); RebuildFilteredRepos(); // the table binds FilteredRepos, which mirrors Repos' order } @@ -842,8 +987,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable if (report.Status == SyncStatus.Failed) { - _log.Error($"{report.RepoName} failed: {report.Error}"); - // Failures never reach StatusText (which has a live region), so assistive + // Logged already by FailureLoggingProgress on the worker thread. Failures + // never reach StatusText (which has a live region), so assistive // technology hears them only through this explicit channel. AnnouncementRequested?.Invoke($"{report.RepoName} failed. {report.Error}"); } @@ -852,20 +997,39 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { RecomputeCompletedCount(); } - else if (statusBefore != item.Status && Filter is RepoFilter.Active or RepoFilter.Pending) + + if (statusBefore != item.Status) { - // Status-dependent filters must follow NON-terminal transitions too (a row - // entering Cloning leaves Pending and joins Active immediately); terminal - // ones already rebuild via RecomputeCompletedCount, and the rebuild's - // sequence check keeps redundant calls cheap and scroll-stable. - RebuildFilteredRepos(); + // Status-dependent filters follow every transition (a row entering Cloning + // leaves Pending and joins Active immediately). Only this one row can have + // changed membership, so it is inserted or removed in place — no O(n) + // rebuild, no Reset notification, no scroll jump (#30). + UpdateFilteredMembership(item); + } + } + + /// + /// Logs failed reports on the thread that produced them (the engine's worker), then + /// forwards every report to the UI-marshaled progress. FileActivityLog opens, + /// appends, and closes the file per entry; a mass failure (revoked PAT, dropped + /// network) used to put a thousand of those on the UI thread (#30). + /// + private sealed class FailureLoggingProgress(IProgress inner, IActivityLog log) + : IProgress + { + public void Report(RepoProgress value) + { + if (value.Status == SyncStatus.Failed) + { + log.Error($"{value.RepoName} failed: {value.Error}"); + } + inner.Report(value); } } /// /// Recounts terminal rows within the captured run set (#22) — before the first run - /// the set is empty and the count stays 0. Also the hook for everything that follows - /// a terminal transition: the failed-rows flag and the filtered table. + /// the set is empty and the count stays 0. Also refreshes the failed-rows flag. /// private void RecomputeCompletedCount() { @@ -879,7 +1043,6 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable } CompletedCount = completed; RecomputeHasFailedRepos(); - RebuildFilteredRepos(); } private void RecomputeHasFailedRepos() @@ -887,24 +1050,91 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable // ---------------------------------------------------------------- filtering + /// Refreshes the row -> position map after was reloaded or reordered. + private void RebuildReposIndex() + { + _reposIndex.Clear(); + for (int i = 0; i < Repos.Count; i++) + { + _reposIndex[Repos[i]] = i; + } + } + /// /// Rebuilds from , preserving the - /// table's current sort order. A no-op when the visible set is already correct, so - /// list controls keep their scroll position across progress ticks. + /// table's current sort order, then re-derives the header checkbox from the + /// visible rows. Used when the filter, the table contents, or the sort order + /// change; single-row status transitions go through + /// instead. A no-op when the visible set + /// is already correct, so list controls keep their scroll position. /// private void RebuildFilteredRepos() { List desired = Repos.Where(MatchesFilter).ToList(); - if (desired.SequenceEqual(FilteredRepos)) + if (!desired.SequenceEqual(FilteredRepos)) + { + FilteredRepos.Clear(); + _filteredSet.Clear(); + foreach (RepoItemViewModel repo in desired) + { + FilteredRepos.Add(repo); + _filteredSet.Add(repo); + } + } + UpdateAllSelectedFromItems(); + } + + /// + /// Applies one row's current filter match to : inserts it + /// at its table position (binary search on ) when it became + /// visible, removes it when it became hidden, and does nothing when its membership + /// did not change. O(log n) instead of the former O(n) rebuild per progress report. + /// + private void UpdateFilteredMembership(RepoItemViewModel item) + { + bool visible = MatchesFilter(item); + bool wasVisible = _filteredSet.Contains(item); + if (visible == wasVisible) { return; } - FilteredRepos.Clear(); - foreach (RepoItemViewModel repo in desired) + int slot = FindFilteredSlot(item); + if (visible) { - FilteredRepos.Add(repo); + FilteredRepos.Insert(slot, item); + _filteredSet.Add(item); } + else + { + FilteredRepos.RemoveAt(slot); + _filteredSet.Remove(item); + } + UpdateAllSelectedFromItems(); + } + + /// + /// Index in where sits (when + /// present) or belongs (when absent), by its position in . + /// + private int FindFilteredSlot(RepoItemViewModel item) + { + int position = _reposIndex[item]; + int low = 0; + int high = FilteredRepos.Count; + while (low < high) + { + int mid = (low + high) / 2; + if (_reposIndex[FilteredRepos[mid]] < position) + { + low = mid + 1; + } + else + { + high = mid; + } + } + return low; } private bool MatchesFilter(RepoItemViewModel repo) diff --git a/gclo.ViewModels/packages.lock.json b/gclo.ViewModels/packages.lock.json new file mode 100644 index 0000000..9b9b55d --- /dev/null +++ b/gclo.ViewModels/packages.lock.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "CommunityToolkit.Mvvm": { + "type": "Direct", + "requested": "[8.4.2, )", + "resolved": "8.4.2", + "contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg==" + }, + "LibGit2Sharp": { + "type": "Transitive", + "resolved": "0.32.0", + "contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==", + "dependencies": { + "LibGit2Sharp.NativeBinaries": "[2.0.324]" + } + }, + "LibGit2Sharp.NativeBinaries": { + "type": "Transitive", + "resolved": "2.0.324", + "contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA==" + }, + "Octokit": { + "type": "Transitive", + "resolved": "14.0.0", + "contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw==" + }, + "gclo.engine": { + "type": "Project", + "dependencies": { + "LibGit2Sharp": "[0.32.0, )", + "Octokit": "[14.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/gclo/AboutDialog.xaml b/gclo/AboutDialog.xaml index 45f8c35..82dffdb 100644 --- a/gclo/AboutDialog.xaml +++ b/gclo/AboutDialog.xaml @@ -3,6 +3,7 @@ x:Class="gclo.AboutDialog" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="using:gclo" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" @@ -79,27 +80,21 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + One third-party component credited in the About dialog. + public sealed record Attribution(string Name, string License) + { + /// The license as rendered after the name. + public string LicenseText => " — " + License; + } + /// /// About dialog: app identity and version, project link, MIT license notice, /// and third-party attributions. The caller must set @@ -11,6 +19,21 @@ namespace gclo /// public sealed partial class AboutDialog : ContentDialog { + /// + /// The components gclo ships with. A dependency change is a one-line edit here; + /// the dialog renders them uniformly from a single template. + /// + public IReadOnlyList Attributions { get; } = + [ + new("Octokit", "MIT License"), + new("LibGit2Sharp", "MIT License"), + new("libgit2 (native library)", "GPLv2 with Linking Exception"), + new("CommunityToolkit.Mvvm", "MIT License"), + new("Velopack", "MIT License"), + new("Windows App SDK / WinUI 3", "MIT License, with Microsoft binary components"), + new(".NET Runtime", "MIT License"), + ]; + public AboutDialog() { InitializeComponent(); diff --git a/gclo/AccountWizardDialog.xaml b/gclo/AccountWizardDialog.xaml index 0f48f25..451bf34 100644 --- a/gclo/AccountWizardDialog.xaml +++ b/gclo/AccountWizardDialog.xaml @@ -17,8 +17,11 @@ PrimaryButtonClick="OnPrimaryButtonClick" SecondaryButtonClick="OnSecondaryButtonClick"> - - + + @@ -108,6 +111,14 @@ PlaceholderText="Choose from the list, or type a name" Loaded="OrgBox_Loaded" Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" /> + + + + + + + + + + + + + + + + + + +