No files matched your search
@@ -0,0 +1,80 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Points the Scoop manifest (bucket/gclo.json) and the Chocolatey package
|
||||
(packaging/chocolatey) at a stable release's CLI zip.
|
||||
|
||||
.DESCRIPTION
|
||||
Both package managers install the same asset, gclo-cli-win-x64.zip, by URL and
|
||||
SHA-256. This script rewrites the version, URL, checksum, and release-notes
|
||||
link in all three files from one source of truth: the version and the zip's
|
||||
hash (computed from -CliZipPath, or taken from -Sha256). The release workflow
|
||||
runs it on every stable tag before `choco pack` and opens a PR with the result
|
||||
so the committed manifests always describe the latest stable release.
|
||||
|
||||
.PARAMETER Version
|
||||
The stable semantic version, e.g. 1.2.3 (no leading 'v').
|
||||
|
||||
.PARAMETER CliZipPath
|
||||
Path to the built gclo-cli-win-x64.zip to hash. Either this or -Sha256.
|
||||
|
||||
.PARAMETER Sha256
|
||||
The zip's SHA-256 (lowercase hex) when it is already known.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Version,
|
||||
[string]$CliZipPath,
|
||||
[string]$Sha256
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
|
||||
throw "Version '$Version' is not a stable semantic version (prerelease builds are not published to package managers)."
|
||||
}
|
||||
if (-not $Sha256) {
|
||||
if (-not $CliZipPath) { throw 'Give -CliZipPath or -Sha256.' }
|
||||
$Sha256 = (Get-FileHash -LiteralPath $CliZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
if ($Sha256 -notmatch '^[0-9a-f]{64}$') {
|
||||
throw "'$Sha256' is not a lowercase SHA-256."
|
||||
}
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||
$url = "https://github.com/KofTwentyTwo/gclo/releases/download/v$Version/gclo-cli-win-x64.zip"
|
||||
|
||||
function Set-Content-Preserving([string]$path, [string]$text) {
|
||||
# Keep the file's encoding (no BOM) and line endings as they are.
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF
|
||||
[System.IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($hasBom)))
|
||||
}
|
||||
|
||||
# ---- Scoop: a real JSON edit, so the file stays valid whatever its layout.
|
||||
$scoopPath = Join-Path $repoRoot 'bucket\gclo.json'
|
||||
$scoop = Get-Content -LiteralPath $scoopPath -Raw | ConvertFrom-Json
|
||||
$scoop.version = $Version
|
||||
$scoop.architecture.'64bit'.url = $url
|
||||
$scoop.architecture.'64bit'.hash = $Sha256
|
||||
$scoopJson = ($scoop | ConvertTo-Json -Depth 10) -replace '\\u0026', '&'
|
||||
# ConvertTo-Json indents with four spaces and escapes '$'-free text; Scoop reads either way.
|
||||
Set-Content-Preserving $scoopPath ($scoopJson + "`n")
|
||||
Write-Host "Scoop manifest -> $Version"
|
||||
|
||||
# ---- Chocolatey: nuspec version + release notes link, install script URL + checksum.
|
||||
$nuspecPath = Join-Path $repoRoot 'packaging\chocolatey\gclo.nuspec'
|
||||
$nuspec = Get-Content -LiteralPath $nuspecPath -Raw
|
||||
$nuspec = $nuspec -replace '<version>[^<]+</version>', "<version>$Version</version>"
|
||||
$nuspec = $nuspec -replace '<releaseNotes>[^<]+</releaseNotes>', "<releaseNotes>https://github.com/KofTwentyTwo/gclo/releases/tag/v$Version</releaseNotes>"
|
||||
Set-Content-Preserving $nuspecPath $nuspec
|
||||
|
||||
$installPath = Join-Path $repoRoot 'packaging\chocolatey\tools\chocolateyinstall.ps1'
|
||||
$install = Get-Content -LiteralPath $installPath -Raw
|
||||
$install = $install -replace "url64bit\s*=\s*'[^']+'", "url64bit = '$url'"
|
||||
$install = $install -replace "checksum64\s*=\s*'[^']+'", "checksum64 = '$Sha256'"
|
||||
Set-Content-Preserving $installPath $install
|
||||
Write-Host "Chocolatey package -> $Version"
|
||||
|
||||
if ($env:GITHUB_OUTPUT) {
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value "sha256=$Sha256"
|
||||
}
|
||||
@@ -160,6 +160,16 @@ 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
|
||||
|
||||
# SHA256SUMS.txt: one line per asset ('<sha256> *<name>'), so users and the
|
||||
# Scoop autoupdate can verify downloads without computing hashes by hand.
|
||||
- name: Write SHA256SUMS.txt
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(Get-Item artifacts/gclo-cli-win-x64.zip) + @(Get-ChildItem Releases -File | Where-Object { $_.Extension -in '.exe', '.zip', '.nupkg' })
|
||||
$lines = $files | ForEach-Object { "{0} *{1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $_.Name }
|
||||
[System.IO.File]::WriteAllText("artifacts/SHA256SUMS.txt", ($lines -join "`n") + "`n", (New-Object System.Text.UTF8Encoding($false)))
|
||||
Get-Content artifacts/SHA256SUMS.txt
|
||||
|
||||
# Provenance: a signed statement that THIS workflow run built these exact
|
||||
# bytes, verifiable with `gh attestation verify <file> -R KofTwentyTwo/gclo`.
|
||||
# Not a code signature (see #57); it makes a release attributable to the
|
||||
@@ -169,6 +179,7 @@ jobs:
|
||||
with:
|
||||
subject-path: |
|
||||
artifacts/gclo-cli-win-x64.zip
|
||||
artifacts/SHA256SUMS.txt
|
||||
Releases/*.exe
|
||||
Releases/*.zip
|
||||
Releases/*.nupkg
|
||||
@@ -185,10 +196,10 @@ jobs:
|
||||
if ('${{ needs.verify.outputs.prerelease }}' -eq 'true') { $preFlag = @('--pre') }
|
||||
vpk upload github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel ${{ needs.verify.outputs.channel }} --releaseName "gclo ${{ needs.verify.outputs.version }}" --tag $env:GITHUB_REF_NAME @preFlag
|
||||
|
||||
- name: Attach CLI zip to the draft release
|
||||
- name: Attach CLI zip and checksums to the draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release upload $env:GITHUB_REF_NAME artifacts/gclo-cli-win-x64.zip --clobber
|
||||
run: gh release upload $env:GITHUB_REF_NAME artifacts/gclo-cli-win-x64.zip artifacts/SHA256SUMS.txt --clobber
|
||||
|
||||
# Auto-generated changelog (categorized by labels via .github/release.yml).
|
||||
- name: Generate release changelog
|
||||
@@ -222,9 +233,11 @@ jobs:
|
||||
env:
|
||||
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
|
||||
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())
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value ("choco=" + (-not [string]::IsNullOrEmpty($env:CHOCO_API_KEY)).ToString().ToLower())
|
||||
|
||||
- name: Push gclo.Engine to nuget.org
|
||||
if: steps.secrets.outputs.nuget == 'true'
|
||||
@@ -259,3 +272,64 @@ jobs:
|
||||
# Velopack names the installer '<packId>-<channel>-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
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# (e) Scoop + Chocolatey (#9), STABLE releases only. The repository is its
|
||||
# own Scoop bucket (bucket/gclo.json) and ships the Chocolatey package
|
||||
# source (packaging/chocolatey). Both are pointed at this release's CLI
|
||||
# zip; the Chocolatey package is pushed when CHOCO_API_KEY is configured,
|
||||
# and the updated manifests are proposed as a pull request against MAIN:
|
||||
# Scoop reads bucket/gclo.json from the default branch, so a PR against
|
||||
# dev would leave `scoop install` on the previous version until the next
|
||||
# dev->main merge. Merge the PR, then merge main back into dev. (With only
|
||||
# GITHUB_TOKEN the PR triggers no checks; DEPS_PAT fixes that, see #7.)
|
||||
# ----------------------------------------------------------------------
|
||||
- name: Point the Scoop and Chocolatey manifests at this release
|
||||
if: needs.verify.outputs.prerelease == 'false'
|
||||
id: manifests
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Update-PackageManifests.ps1 -Version ${{ needs.verify.outputs.version }} -CliZipPath artifacts/gclo-cli-win-x64.zip
|
||||
|
||||
- name: Pack the Chocolatey package
|
||||
if: needs.verify.outputs.prerelease == 'false'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts/chocolatey | Out-Null
|
||||
choco pack packaging/chocolatey/gclo.nuspec --outputdirectory artifacts/chocolatey
|
||||
Get-ChildItem artifacts/chocolatey
|
||||
|
||||
- name: Push the Chocolatey package
|
||||
if: needs.verify.outputs.prerelease == 'false' && steps.secrets.outputs.choco == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
|
||||
run: choco push (Get-ChildItem artifacts/chocolatey/*.nupkg | Select-Object -First 1).FullName --source https://push.chocolatey.org/ --api-key $env:CHOCO_API_KEY
|
||||
|
||||
- name: Open a PR with the updated package manifests
|
||||
if: needs.verify.outputs.prerelease == 'false'
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.DEPS_PAT || github.token }}
|
||||
base: main
|
||||
branch: packaging/v${{ needs.verify.outputs.version }}
|
||||
delete-branch: true
|
||||
commit-message: 'Packaging: point the Scoop and Chocolatey manifests at v${{ needs.verify.outputs.version }}'
|
||||
title: 'Packaging: Scoop and Chocolatey manifests for v${{ needs.verify.outputs.version }}'
|
||||
body: |
|
||||
Generated by the Release workflow for tag `${{ github.ref_name }}`.
|
||||
|
||||
- `bucket/gclo.json` (Scoop) and `packaging/chocolatey` now point at
|
||||
`gclo-cli-win-x64.zip` of v${{ needs.verify.outputs.version }}
|
||||
(SHA-256 `${{ steps.manifests.outputs.sha256 }}`).
|
||||
- `scoop update gclo` picks this up as soon as this PR is merged (Scoop
|
||||
reads the manifest from `main`); the Chocolatey package
|
||||
was ${{ steps.secrets.outputs.choco == 'true' && 'pushed to chocolatey.org' || 'packed but not pushed (CHOCO_API_KEY not configured)' }}.
|
||||
- After merging, merge `main` back into `dev` so the branches agree.
|
||||
|
||||
Relates to #9.
|
||||
labels: |
|
||||
release
|
||||
add-paths: |
|
||||
bucket/gclo.json
|
||||
packaging/chocolatey/gclo.nuspec
|
||||
packaging/chocolatey/tools/chocolateyinstall.ps1
|
||||
@@ -28,7 +28,18 @@ Plain version tags (`v1.2.3`) are **stable** releases; pre-release tags (`v1.2.3
|
||||
|
||||
> **Upgrading from a beta?** Channels don't cross: a dev/beta install never auto-updates to a stable release (its **Check for updates** only looks at the dev channel). To move to stable, install `gclo-stable-Setup.exe` once — it takes over the existing install and self-updates within the stable channel from then on.
|
||||
|
||||
`winget` support is planned (tracked in [#7](https://github.com/KofTwentyTwo/gclo/issues/7)); until then, install from the Releases page above.
|
||||
**Package managers (CLI):**
|
||||
|
||||
```powershell
|
||||
# Scoop: this repository is its own bucket
|
||||
scoop bucket add gclo https://github.com/KofTwentyTwo/gclo
|
||||
scoop install gclo
|
||||
|
||||
# Chocolatey (once the package has cleared moderation; see #7)
|
||||
choco install gclo
|
||||
```
|
||||
|
||||
Every release also attaches `SHA256SUMS.txt`, and every asset carries a build-provenance attestation you can check with `gh attestation verify <file> -R KofTwentyTwo/gclo`. `winget` support for the desktop app is tracked in [#7](https://github.com/KofTwentyTwo/gclo/issues/7).
|
||||
|
||||
## Getting a token
|
||||
|
||||
@@ -70,7 +81,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
|
||||
|
||||
8. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, **Retry failed** re-runs exactly the failed rows, and **Open folder** opens the target folder in Explorer.
|
||||
|
||||
**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`).
|
||||
**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). When WSL is installed with git in its default distribution, the same dialog offers **Clone in WSL instead**: the repository is cloned unchanged into `~/gclo/<org>/<repo>` inside WSL (reachable from Explorer as `\wsl.localhost\<distro>\home\...`), the token travels only through the environment, and the Windows copy is left as it is. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`).
|
||||
|
||||
**Settings** (File > Settings…) let you set a default GitHub token (stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration.
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "Git Clone Large Organizations: clone or update every repository of a GitHub organization or user account, in parallel (command-line edition)",
|
||||
"homepage": "https://github.com/KofTwentyTwo/gclo",
|
||||
"license": "MIT",
|
||||
"notes": [
|
||||
"gclo needs a GitHub personal access token: set GITHUB_TOKEN, or pass --token-env, --token-file, or --token-stdin.",
|
||||
"The desktop app is a separate download: https://github.com/KofTwentyTwo/gclo/releases"
|
||||
],
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-cli-win-x64.zip",
|
||||
"hash": "42805ca95a4f4d306df04f47a2b9aba54eee3df11cda8e3f201fe5a58311feee"
|
||||
}
|
||||
},
|
||||
"bin": "gclo.exe",
|
||||
"checkver": "github",
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/KofTwentyTwo/gclo/releases/download/v$version/gclo-cli-win-x64.zip",
|
||||
"hash": {
|
||||
"url": "$baseurl/SHA256SUMS.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ get a release without a delta package, which is normal.
|
||||
| GitHub Release (Setup, portable, full/delta packages, CLI zip) | always | none — the built-in `GITHUB_TOKEN` is enough |
|
||||
| nuget.org (`gclo.Engine` package) | `NUGET_API_KEY` secret is set | `NUGET_API_KEY` — an API key from nuget.org with push rights for `gclo.Engine` |
|
||||
| winget (`KofTwentyTwo.gclo`) | stable releases only, and `WINGET_TOKEN` secret is set | `WINGET_TOKEN` — a GitHub personal access token (classic, `public_repo` scope) used by `wingetcreate` to fork/PR [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) |
|
||||
| `SHA256SUMS.txt` on the GitHub Release | always | none |
|
||||
| Scoop (`bucket/gclo.json` in this repository) | stable releases only | none — the workflow opens a PR against `main` with the updated manifest; merge it (then `main` back into `dev`) and `scoop update gclo` sees the release |
|
||||
| Chocolatey (`gclo` on chocolatey.org) | stable releases only, and `CHOCO_API_KEY` secret is set | `CHOCO_API_KEY` — an API key from your chocolatey.org account; the first push creates the package (it then goes through Chocolatey moderation) |
|
||||
|
||||
If a secret is not configured, the corresponding step is skipped cleanly — the
|
||||
release still succeeds. Configure secrets under
|
||||
@@ -78,6 +81,28 @@ Once that first PR is merged and the package is live, set the `WINGET_TOKEN`
|
||||
secret and every subsequent stable release updates the manifest automatically
|
||||
via `wingetcreate update KofTwentyTwo.gclo ... --submit`.
|
||||
|
||||
## Scoop and Chocolatey
|
||||
|
||||
Both install the same asset, `gclo-cli-win-x64.zip`, by URL and SHA-256.
|
||||
|
||||
- **Scoop** needs no account: this repository is its own bucket. Users run
|
||||
`scoop bucket add gclo https://github.com/KofTwentyTwo/gclo` once, then
|
||||
`scoop install gclo`. The manifest (`bucket/gclo.json`) carries `checkver` and
|
||||
`autoupdate` entries keyed on `SHA256SUMS.txt`, so `scoop update` works as
|
||||
soon as the manifest on `main` names the new version. The release workflow
|
||||
therefore opens its "Packaging" pull request **against `main`** (the one
|
||||
exception to the dev-first branch model: three generated files, nothing else).
|
||||
Merge it, then merge `main` back into `dev`. Note that a PR opened with the
|
||||
default `GITHUB_TOKEN` triggers no status checks, so the required checks on
|
||||
`main` must be run by hand or `DEPS_PAT` must be configured (#7).
|
||||
- **Chocolatey** needs a one-time account on chocolatey.org and its API key as
|
||||
the `CHOCO_API_KEY` secret. The workflow packs `packaging/chocolatey` on every
|
||||
stable release and pushes it when the key exists; the first version goes
|
||||
through Chocolatey's moderation queue (expect a few days), later versions are
|
||||
usually auto-approved. Users then run `choco install gclo`.
|
||||
- To re-point the manifests by hand (for example after a missed release):
|
||||
`./.github/scripts/Update-PackageManifests.ps1 -Version 1.2.3 -CliZipPath <zip>`.
|
||||
|
||||
## Cutting a release, start to finish
|
||||
|
||||
1. **Make sure `main` is green.** The CI, CodeQL, and (for PRs) dependency
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the package-manager manifests the repository ships (#9): the Scoop bucket
|
||||
/// manifest and the Chocolatey package must stay valid, point at the same CLI
|
||||
/// asset of one stable release, and agree with each other — the release workflow
|
||||
/// rewrites them, and a drift would break `scoop install` / `choco install`.
|
||||
/// </summary>
|
||||
public sealed class PackagingManifestTests
|
||||
{
|
||||
private static readonly Regex Sha256 = new("^[0-9a-f]{64}$", RegexOptions.Compiled);
|
||||
|
||||
private static string RepoRoot
|
||||
{
|
||||
get
|
||||
{
|
||||
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||
while (current is not null && !File.Exists(Path.Combine(current.FullName, "gclo.slnx")))
|
||||
{
|
||||
current = current.Parent;
|
||||
}
|
||||
return current?.FullName ?? throw new FileNotFoundException("gclo.slnx not found above the test directory");
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Version, string Url, string Hash) ReadScoop()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepoRoot, "bucket", "gclo.json")));
|
||||
JsonElement root = doc.RootElement;
|
||||
JsonElement x64 = root.GetProperty("architecture").GetProperty("64bit");
|
||||
Assert.Equal("gclo.exe", root.GetProperty("bin").GetString());
|
||||
Assert.Equal("MIT", root.GetProperty("license").GetString());
|
||||
Assert.Equal("github", root.GetProperty("checkver").GetString());
|
||||
string autoupdateUrl = root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("url").GetString()!;
|
||||
Assert.Contains("v$version/gclo-cli-win-x64.zip", autoupdateUrl);
|
||||
Assert.Contains("SHA256SUMS.txt", root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("hash").GetProperty("url").GetString());
|
||||
return (root.GetProperty("version").GetString()!, x64.GetProperty("url").GetString()!, x64.GetProperty("hash").GetString()!);
|
||||
}
|
||||
|
||||
private static (string Version, string Url, string Hash) ReadChocolatey()
|
||||
{
|
||||
XDocument nuspec = XDocument.Load(Path.Combine(RepoRoot, "packaging", "chocolatey", "gclo.nuspec"));
|
||||
XNamespace ns = nuspec.Root!.Name.Namespace;
|
||||
XElement metadata = nuspec.Root.Element(ns + "metadata")!;
|
||||
Assert.Equal("gclo", metadata.Element(ns + "id")!.Value);
|
||||
string version = metadata.Element(ns + "version")!.Value;
|
||||
Assert.EndsWith($"/releases/tag/v{version}", metadata.Element(ns + "releaseNotes")!.Value);
|
||||
|
||||
string install = File.ReadAllText(Path.Combine(RepoRoot, "packaging", "chocolatey", "tools", "chocolateyinstall.ps1"));
|
||||
string url = Regex.Match(install, @"url64bit\s*=\s*'([^']+)'").Groups[1].Value;
|
||||
string hash = Regex.Match(install, @"checksum64\s*=\s*'([^']+)'").Groups[1].Value;
|
||||
Assert.Contains("Install-ChocolateyZipPackage", install);
|
||||
return (version, url, hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoopManifest_IsValid_AndPointsAtAStableCliRelease()
|
||||
{
|
||||
var (version, url, hash) = ReadScoop();
|
||||
|
||||
Assert.Matches(@"^\d+\.\d+\.\d+$", version); // package managers get stable releases only
|
||||
Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url);
|
||||
Assert.Matches(Sha256, hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChocolateyPackage_IsValid_AndPointsAtAStableCliRelease()
|
||||
{
|
||||
var (version, url, hash) = ReadChocolatey();
|
||||
|
||||
Assert.Matches(@"^\d+\.\d+\.\d+$", version);
|
||||
Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url);
|
||||
Assert.Matches(Sha256, hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoopAndChocolatey_DescribeTheSameRelease()
|
||||
{
|
||||
var scoop = ReadScoop();
|
||||
var choco = ReadChocolatey();
|
||||
|
||||
Assert.Equal(scoop.Version, choco.Version);
|
||||
Assert.Equal(scoop.Url, choco.Url);
|
||||
Assert.Equal(scoop.Hash, choco.Hash);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,10 @@ public sealed class ActionLoggingTests : IDisposable
|
||||
public void Report(RepoProgress value) => handler(value);
|
||||
}
|
||||
|
||||
private readonly FakeWslCloner _wsl = new();
|
||||
|
||||
private WorkspaceViewModel CreateViewModel()
|
||||
=> new(_lister, _git, _orgs, handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), _log);
|
||||
=> new(_lister, _git, _orgs, handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), _log, wsl: _wsl);
|
||||
|
||||
private void AssertNoTokenLogged()
|
||||
=> Assert.All(_log.Messages, m => Assert.DoesNotContain(Token, m));
|
||||
@@ -86,9 +88,11 @@ public sealed class ActionLoggingTests : IDisposable
|
||||
Assert.Contains("Retrying 1 failed repositories.", _log.Messages);
|
||||
|
||||
// The user opens recovery and cancels it.
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(null);
|
||||
_wsl.Availability = new WslAvailability(false, "wsl.exe is not installed");
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(null);
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
|
||||
Assert.Contains("alpha: path recovery requested (1 invalid paths).", _log.Messages);
|
||||
Assert.Contains("WSL clone not available: wsl.exe is not installed", _log.Messages);
|
||||
Assert.Contains("alpha: path recovery canceled; the repository stays failed.", _log.Messages);
|
||||
|
||||
vm.NoteFolderOpened();
|
||||
@@ -96,6 +100,35 @@ public sealed class ActionLoggingTests : IDisposable
|
||||
AssertNoTokenLogged();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Workspace_CloneInWsl_IsLogged_WithoutTheToken()
|
||||
{
|
||||
_lister.Repositories = Repos("alpha");
|
||||
var vm = CreateViewModel();
|
||||
vm.Organization = "acme";
|
||||
vm.Token = Token;
|
||||
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
|
||||
vm.TargetFolder = _root;
|
||||
await vm.LoadReposCommand.ExecuteAsync(null);
|
||||
var invalid = new List<InvalidPathInfo> { new("bad:name.txt", "invalid on Windows", "bad_name.txt") };
|
||||
_git.CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(invalid));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
_wsl.Availability = new WslAvailability(true, "git version 2.53.0");
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.CloneInWsl());
|
||||
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
|
||||
Assert.Contains("WSL clone available (git version 2.53.0).", _log.Messages);
|
||||
Assert.Contains("alpha: cloning in WSL instead.", _log.Messages);
|
||||
Assert.Contains("alpha: cloned in WSL (Ubuntu) at /home/me/gclo/acme/alpha.", _log.Messages);
|
||||
|
||||
_wsl.CloneHandler = (_, _) => Task.FromException<WslCloneResult>(new WslCloneException("Clone in WSL failed: boom"));
|
||||
vm.Repos[0].InvalidPaths = invalid;
|
||||
vm.Repos[0].Status = SyncStatus.Failed;
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
|
||||
Assert.Contains("alpha: clone in WSL failed: Clone in WSL failed: boom", _log.Messages);
|
||||
AssertNoTokenLogged();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- accounts
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -155,3 +155,69 @@ public sealed class RecordingProgress : IProgress<RepoProgress>
|
||||
public RepoProgress? LastFor(string repoName)
|
||||
=> _reports.LastOrDefault(r => r.RepoName == repoName);
|
||||
}
|
||||
|
||||
/// <summary>One scripted <see cref="IProcessRunner"/> invocation, as recorded by <see cref="FakeProcessRunner"/>.</summary>
|
||||
public sealed record ProcessCall(
|
||||
string FileName,
|
||||
IReadOnlyList<string> Arguments,
|
||||
IReadOnlyDictionary<string, string>? Environment,
|
||||
string? StandardInput);
|
||||
|
||||
/// <summary>An <see cref="IProcessRunner"/> that records calls and answers from a delegate.</summary>
|
||||
public sealed class FakeProcessRunner : IProcessRunner
|
||||
{
|
||||
private readonly ConcurrentQueue<ProcessCall> _calls = new();
|
||||
|
||||
/// <summary>Answers each call; default: exit 0 with empty output.</summary>
|
||||
public Func<ProcessCall, CancellationToken, Task<ProcessResult>> Handler { get; set; }
|
||||
= (_, _) => Task.FromResult(new ProcessResult(0, "", ""));
|
||||
|
||||
public IReadOnlyList<ProcessCall> Calls => _calls.ToArray();
|
||||
|
||||
public Task<ProcessResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string>? environment,
|
||||
string? standardInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var call = new ProcessCall(fileName, arguments, environment, standardInput);
|
||||
_calls.Enqueue(call);
|
||||
return Handler(call, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WslCloneCall(string Url, string Organization, string RepositoryName, string Token);
|
||||
|
||||
/// <summary>An <see cref="IWslCloner"/> with scripted availability and clone outcome.</summary>
|
||||
public sealed class FakeWslCloner : IWslCloner
|
||||
{
|
||||
private readonly ConcurrentQueue<WslCloneCall> _cloneCalls = new();
|
||||
private int _probes;
|
||||
|
||||
/// <summary>What <see cref="ProbeAsync"/> reports; default: not available.</summary>
|
||||
public WslAvailability Availability { get; set; } = new(false, "wsl.exe is not installed (test default)");
|
||||
|
||||
/// <summary>Body of <see cref="CloneAsync"/>; default: a successful clone into /home/me/gclo/org/repo.</summary>
|
||||
public Func<WslCloneCall, CancellationToken, Task<WslCloneResult>> CloneHandler { get; set; }
|
||||
= (call, _) => Task.FromResult(new WslCloneResult(
|
||||
"Ubuntu",
|
||||
$"/home/me/gclo/{call.Organization}/{call.RepositoryName}",
|
||||
$@"\\wsl.localhost\Ubuntu\home\me\gclo\{call.Organization}\{call.RepositoryName}"));
|
||||
|
||||
public int Probes => _probes;
|
||||
public IReadOnlyList<WslCloneCall> CloneCalls => _cloneCalls.ToArray();
|
||||
|
||||
public Task<WslAvailability> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _probes);
|
||||
return Task.FromResult(Availability);
|
||||
}
|
||||
|
||||
public Task<WslCloneResult> CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken)
|
||||
{
|
||||
var call = new WslCloneCall(url, organization, repositoryName, token);
|
||||
_cloneCalls.Enqueue(call);
|
||||
return CloneHandler(call, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
private readonly FakeRepositoryLister _lister = new();
|
||||
private readonly FakeGitClient _git = new();
|
||||
private readonly FakeOrganizationLister _orgs = new();
|
||||
private readonly FakeWslCloner _wsl = new();
|
||||
|
||||
/// <summary>Every view model the test created, disposed and cleaned up after it — pass or fail.</summary>
|
||||
private readonly List<WorkspaceViewModel> _created = new();
|
||||
@@ -56,7 +57,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
handler => new SyncProgress(handler),
|
||||
debounce ?? TimeSpan.FromMilliseconds(1),
|
||||
new NullActivityLog(),
|
||||
account, vault, store));
|
||||
account, vault, store, _wsl));
|
||||
|
||||
private WorkspaceViewModel Track(WorkspaceViewModel vm)
|
||||
{
|
||||
@@ -621,7 +622,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
vm.RecoveryInteraction = item =>
|
||||
{
|
||||
asked.Add(item);
|
||||
return Task.FromResult<PathRecovery?>(recovery);
|
||||
return Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.Apply(recovery));
|
||||
};
|
||||
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(row);
|
||||
@@ -651,8 +652,8 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
RepoItemViewModel row = vm.Repos[0];
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(
|
||||
new PathRecovery(new Dictionary<string, string>(), new HashSet<string>()));
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(
|
||||
new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary<string, string>(), new HashSet<string>())));
|
||||
|
||||
Task resolve = vm.ResolvePathsCommand.ExecuteAsync(row);
|
||||
await WaitUntilAsync(() => row.Status == SyncStatus.Pulling, "the in-flight recovery to show");
|
||||
@@ -674,7 +675,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
RepoItemViewModel row = vm.Repos[0];
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(null);
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(null);
|
||||
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(row);
|
||||
|
||||
@@ -698,8 +699,8 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
RepoItemViewModel row = vm.Repos[0];
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(
|
||||
new PathRecovery(new Dictionary<string, string>(), new HashSet<string>()));
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(
|
||||
new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary<string, string>(), new HashSet<string>())));
|
||||
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(row);
|
||||
|
||||
@@ -720,8 +721,8 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
RepoItemViewModel row = vm.Repos[0];
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(
|
||||
new PathRecovery(new Dictionary<string, string>(), new HashSet<string>()));
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(
|
||||
new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary<string, string>(), new HashSet<string>())));
|
||||
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(row);
|
||||
|
||||
@@ -731,6 +732,153 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- clone in WSL (#8)
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePaths_ProbesWslOnce_BeforeTheInteraction_AndExposesAvailability()
|
||||
{
|
||||
MakeAlphaFailWithInvalidPaths();
|
||||
_wsl.Availability = new WslAvailability(true, "git version 2.53.0");
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
Assert.False(vm.IsWslCloneAvailable); // not probed until recovery is requested
|
||||
Assert.Equal("", vm.WslAvailabilityDetail);
|
||||
|
||||
bool? seenDuringInteraction = null;
|
||||
vm.RecoveryInteraction = _ =>
|
||||
{
|
||||
seenDuringInteraction = vm.IsWslCloneAvailable;
|
||||
return Task.FromResult<PathRecoveryDecision?>(null);
|
||||
};
|
||||
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
|
||||
|
||||
Assert.True(seenDuringInteraction);
|
||||
Assert.True(vm.IsWslCloneAvailable);
|
||||
Assert.Equal("git version 2.53.0", vm.WslAvailabilityDetail);
|
||||
Assert.Equal(1, _wsl.Probes); // cached after the first request
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePaths_WslUnavailable_StaysUnavailable_WithTheReason()
|
||||
{
|
||||
MakeAlphaFailWithInvalidPaths();
|
||||
_wsl.Availability = new WslAvailability(false, "wsl.exe is not installed");
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(null);
|
||||
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
|
||||
|
||||
Assert.False(vm.IsWslCloneAvailable);
|
||||
Assert.Equal("wsl.exe is not installed", vm.WslAvailabilityDetail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePaths_CloneInWsl_ClonesWithTheRowsUrlAndTheWorkspaceToken_AndMarksRowDone()
|
||||
{
|
||||
MakeAlphaFailWithInvalidPaths();
|
||||
_wsl.Availability = new WslAvailability(true, "git version 2.53.0");
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
RepoItemViewModel row = vm.Repos[0];
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.CloneInWsl());
|
||||
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(row);
|
||||
|
||||
WslCloneCall call = Assert.Single(_wsl.CloneCalls);
|
||||
Assert.Equal(row.Descriptor.CloneUrl, call.Url);
|
||||
Assert.Equal(vm.Organization, call.Organization);
|
||||
Assert.Equal("alpha", call.RepositoryName);
|
||||
Assert.Equal(vm.Token, call.Token);
|
||||
Assert.Empty(_git.ApplyRecoveryCalls); // the Windows copy is left alone
|
||||
|
||||
Assert.Equal(SyncStatus.Done, row.Status);
|
||||
Assert.Null(row.Error);
|
||||
Assert.Null(row.InvalidPaths);
|
||||
Assert.False(row.HasPathIssue);
|
||||
Assert.True(row.HasNote);
|
||||
Assert.Equal(@"Cloned in WSL (Ubuntu): \\wsl.localhost\Ubuntu\home\me\gclo\" + vm.Organization + @"\alpha", row.Note);
|
||||
Assert.False(vm.ResolvePathsCommand.CanExecute(row));
|
||||
Assert.False(vm.RetryFailedCommand.CanExecute(null));
|
||||
Assert.False(vm.IsResolvingPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePaths_CloneInWsl_ShowsAnIndeterminateBar_AndLocksTheWorkspace_WhileInFlight()
|
||||
{
|
||||
MakeAlphaFailWithInvalidPaths();
|
||||
_wsl.Availability = new WslAvailability(true, "git");
|
||||
var gate = new TaskCompletionSource<WslCloneResult>();
|
||||
_wsl.CloneHandler = (_, _) => gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
RepoItemViewModel row = vm.Repos[0];
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.CloneInWsl());
|
||||
|
||||
Task resolve = vm.ResolvePathsCommand.ExecuteAsync(row);
|
||||
await WaitUntilAsync(() => row.Status == SyncStatus.Pulling, "the in-flight WSL clone to show");
|
||||
|
||||
Assert.True(row.IsIndeterminate);
|
||||
Assert.True(vm.IsResolvingPaths);
|
||||
Assert.False(vm.SyncCommand.CanExecute(null));
|
||||
|
||||
gate.SetResult(new WslCloneResult("Ubuntu", "/home/me/gclo/o/alpha", @"\\wsl.localhost\Ubuntu\home\me\gclo\o\alpha"));
|
||||
await resolve;
|
||||
|
||||
Assert.Equal(SyncStatus.Done, row.Status);
|
||||
Assert.False(vm.IsResolvingPaths);
|
||||
Assert.True(vm.SyncCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePaths_CloneInWslFails_KeepsRowFailed_WithPayload_ForAnotherAttempt()
|
||||
{
|
||||
MakeAlphaFailWithInvalidPaths();
|
||||
_wsl.Availability = new WslAvailability(true, "git");
|
||||
_wsl.CloneHandler = (_, _) => Task.FromException<WslCloneResult>(
|
||||
new WslCloneException("Clone in WSL failed: fatal: Authentication failed"));
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
RepoItemViewModel row = vm.Repos[0];
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.CloneInWsl());
|
||||
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(row);
|
||||
|
||||
Assert.Equal(SyncStatus.Failed, row.Status);
|
||||
Assert.Equal("Clone in WSL failed: fatal: Authentication failed", row.Error);
|
||||
Assert.True(row.HasPathIssue); // the rename/skip route is still open
|
||||
Assert.False(row.HasNote);
|
||||
Assert.True(vm.ResolvePathsCommand.CanExecute(row));
|
||||
Assert.True(vm.RetryFailedCommand.CanExecute(null));
|
||||
Assert.False(vm.IsResolvingPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepoItem_Note_RaisesHasNote()
|
||||
{
|
||||
var item = new RepoItemViewModel(Repo("alpha"));
|
||||
var changed = new List<string?>();
|
||||
item.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
Assert.False(item.HasNote);
|
||||
|
||||
item.Note = "Cloned in WSL";
|
||||
|
||||
Assert.True(item.HasNote);
|
||||
Assert.True(item.HasDetails); // the row-details strip shows for a note alone
|
||||
Assert.Contains(nameof(RepoItemViewModel.Note), changed);
|
||||
Assert.Contains(nameof(RepoItemViewModel.HasNote), changed);
|
||||
Assert.Contains(nameof(RepoItemViewModel.HasDetails), changed);
|
||||
|
||||
changed.Clear();
|
||||
item.Note = null;
|
||||
Assert.False(item.HasDetails);
|
||||
item.Error = "boom";
|
||||
Assert.True(item.HasDetails); // and for an error alone
|
||||
Assert.Contains(nameof(RepoItemViewModel.HasDetails), changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePaths_DisabledWhileARunIsInFlight()
|
||||
{
|
||||
@@ -1771,8 +1919,8 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
|
||||
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(
|
||||
new PathRecovery(new Dictionary<string, string>(), new HashSet<string>()));
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(
|
||||
new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary<string, string>(), new HashSet<string>())));
|
||||
|
||||
Task resolve = vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
|
||||
await WaitUntilAsync(() => vm.IsResolvingPaths, "the recovery to start");
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="WslCloner"/> (#8) through a fake process runner: no WSL needed. The
|
||||
/// contract under test is what wsl.exe is asked to run, that the token never reaches
|
||||
/// an argument vector or the script, and how output and failures are interpreted.
|
||||
/// </summary>
|
||||
public sealed class WslClonerTests
|
||||
{
|
||||
private const string WslExe = @"C:\Windows\System32\wsl.exe";
|
||||
private const string Token = "ghp_secret_token_value_1234567890";
|
||||
|
||||
private readonly FakeProcessRunner _runner = new();
|
||||
|
||||
private WslCloner Create(bool wslExists = true)
|
||||
=> new(_runner, _ => wslExists, WslExe);
|
||||
|
||||
// ---------------------------------------------------------------- probe
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_NoWslExe_IsUnavailable_WithoutRunningAnything()
|
||||
{
|
||||
WslAvailability result = await Create(wslExists: false).ProbeAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Contains("not installed", result.Detail);
|
||||
Assert.Empty(_runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_GitRunsInDistro_IsAvailable_WithTheGitVersion()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "git version 2.53.0\n", ""));
|
||||
|
||||
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Available);
|
||||
Assert.Equal("git version 2.53.0", result.Detail);
|
||||
ProcessCall call = Assert.Single(_runner.Calls);
|
||||
Assert.Equal(WslExe, call.FileName);
|
||||
Assert.Equal(["-e", "git", "--version"], call.Arguments);
|
||||
Assert.Null(call.Environment);
|
||||
Assert.Null(call.StandardInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_NoDistribution_IsUnavailable_WithWslsOwnMessage()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(
|
||||
-1, "", "Windows Subsystem for Linux has no installed distributions.\nUse 'wsl.exe --list --online'.\n"));
|
||||
|
||||
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Equal("the default WSL distribution did not run git: Windows Subsystem for Linux has no installed distributions.", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_ExitZeroButNotGit_IsUnavailable()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "sh: git: not found\n", ""));
|
||||
|
||||
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Contains("git is not installed in the default WSL distribution (sh: git: not found)", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_NonZeroWithNoOutput_ReportsTheExitCode()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(127, "", ""));
|
||||
|
||||
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.EndsWith("exit code 127", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_RunnerThrows_IsUnavailable_NotAnException()
|
||||
{
|
||||
_runner.Handler = (_, _) => throw new System.ComponentModel.Win32Exception("access denied");
|
||||
|
||||
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Contains("could not be started: access denied", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_Canceled_Propagates()
|
||||
{
|
||||
_runner.Handler = (_, ct) => Task.FromCanceled<ProcessResult>(new CancellationToken(canceled: true));
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Create().ProbeAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- clone
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_RunsTheScriptThroughStdin_WithTheTokenOnlyInTheEnvironment()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "Ubuntu\n/home/me/gclo/acme/repo\n", ""));
|
||||
|
||||
WslCloneResult result = await Create().CloneAsync(
|
||||
"https://github.com/acme/repo.git", "acme", "repo", Token, CancellationToken.None);
|
||||
|
||||
ProcessCall call = Assert.Single(_runner.Calls);
|
||||
Assert.Equal(WslExe, call.FileName);
|
||||
Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://github.com/acme/repo.git"], call.Arguments);
|
||||
Assert.Equal(WslCloner.CloneScript, call.StandardInput);
|
||||
Assert.DoesNotContain(Token, string.Join(" ", call.Arguments));
|
||||
Assert.DoesNotContain(Token, call.StandardInput);
|
||||
Assert.NotNull(call.Environment);
|
||||
Assert.Equal(Token, call.Environment![WslCloner.TokenVariable]);
|
||||
Assert.Equal("0", call.Environment["GIT_TERMINAL_PROMPT"]);
|
||||
Assert.Equal(WslCloner.TokenVariable + ":GIT_TERMINAL_PROMPT", call.Environment["WSLENV"]);
|
||||
|
||||
Assert.Equal("Ubuntu", result.Distribution);
|
||||
Assert.Equal("/home/me/gclo/acme/repo", result.LinuxPath);
|
||||
Assert.Equal(@"\\wsl.localhost\Ubuntu\home\me\gclo\acme\repo", result.WindowsPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloneScript_ReadsTheTokenThroughACredentialHelper_AndFastForwardsAnExistingClone()
|
||||
{
|
||||
string script = WslCloner.CloneScript;
|
||||
|
||||
Assert.Contains("password=$" + WslCloner.TokenVariable, script);
|
||||
Assert.Contains("credential.helper=", script);
|
||||
Assert.Contains("git clone", script.Replace("-c credential.helper= -c \"credential.helper=$helper\" ", ""));
|
||||
Assert.Contains("pull --ff-only", script);
|
||||
Assert.Contains("$HOME/gclo/$org/$repo", script);
|
||||
Assert.Contains("$WSL_DISTRO_NAME", script);
|
||||
Assert.StartsWith("set -e", script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_IgnoresChatterBeforeTheLastTwoLines()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(
|
||||
0, "Cloning into '/home/me/gclo/acme/repo'...\nwarning: something\nDebian\n/home/me/gclo/acme/repo\n", "noise on stderr\n"));
|
||||
|
||||
WslCloneResult result = await Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Debian", result.Distribution);
|
||||
Assert.Equal("/home/me/gclo/acme/repo", result.LinuxPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_GitFails_ThrowsWithTheLastStderrLine()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(
|
||||
128, "", "Cloning into '/home/me/gclo/acme/repo'...\nfatal: Authentication failed for 'https://github.com/acme/repo.git/'\n"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WslCloneException>(
|
||||
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
|
||||
|
||||
Assert.Equal("Clone in WSL failed: fatal: Authentication failed for 'https://github.com/acme/repo.git/'", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_FailsWithStdoutOnly_UsesStdout_ThenExitCode()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(1, "disk full\n", ""));
|
||||
var ex = await Assert.ThrowsAsync<WslCloneException>(
|
||||
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
|
||||
Assert.Equal("Clone in WSL failed: disk full", ex.Message);
|
||||
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(9, "", ""));
|
||||
ex = await Assert.ThrowsAsync<WslCloneException>(
|
||||
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
|
||||
Assert.Equal("Clone in WSL failed: exit code 9", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_SucceedsWithoutReportingAPath_Throws()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "only-one-line\n", ""));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WslCloneException>(
|
||||
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains("did not report where", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_RunnerThrows_IsWrapped()
|
||||
{
|
||||
_runner.Handler = (_, _) => throw new System.ComponentModel.Win32Exception("access denied");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WslCloneException>(
|
||||
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
|
||||
|
||||
Assert.Equal("wsl.exe could not be started: access denied", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_Canceled_Propagates()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromCanceled<ProcessResult>(new CancellationToken(canceled: true));
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "acme", "repo")]
|
||||
[InlineData("https://x/y.git", " ", "repo")]
|
||||
[InlineData("https://x/y.git", "acme", "")]
|
||||
public async Task Clone_RejectsBlankInputs(string url, string org, string repo)
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => Create().CloneAsync(url, org, repo, Token, CancellationToken.None));
|
||||
Assert.Empty(_runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_RejectsNullToken()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => Create().CloneAsync("https://x/y.git", "acme", "repo", null!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_PointAtSystem32Wsl()
|
||||
{
|
||||
// The default constructor must be usable by the app without arguments; its
|
||||
// probe answers "not installed" cleanly on a machine without wsl.exe.
|
||||
var cloner = new WslCloner();
|
||||
Assert.NotNull(cloner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
/// <summary>Outcome of one external process: exit code plus captured output.</summary>
|
||||
public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError);
|
||||
|
||||
/// <summary>
|
||||
/// Runs an external process to completion. The seam that keeps <see cref="WslCloner"/>
|
||||
/// testable without a WSL installation: the offline suite scripts results through a fake.
|
||||
/// </summary>
|
||||
public interface IProcessRunner
|
||||
{
|
||||
/// <param name="fileName">Executable path.</param>
|
||||
/// <param name="arguments">Argument vector (quoted by the runner, never by the caller).</param>
|
||||
/// <param name="environment">Extra environment variables for the child; the parent's are inherited.</param>
|
||||
/// <param name="standardInput">Text written to the child's stdin, then closed; null leaves stdin closed.</param>
|
||||
/// <param name="cancellationToken">Kills the child and throws <see cref="OperationCanceledException"/>.</param>
|
||||
Task<ProcessResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string>? environment,
|
||||
string? standardInput,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Whether a clone into WSL can be attempted on this machine, and why not when it cannot.</summary>
|
||||
/// <param name="Available">True when wsl.exe exists, a default distribution starts, and git runs inside it.</param>
|
||||
/// <param name="Detail">The git version when available; the reason otherwise (shown to the user).</param>
|
||||
public sealed record WslAvailability(bool Available, string Detail);
|
||||
|
||||
/// <summary>Where a repository ended up after a clone into WSL.</summary>
|
||||
/// <param name="Distribution">The WSL distribution that holds the clone (its default distribution).</param>
|
||||
/// <param name="LinuxPath">Path inside the distribution, e.g. <c>/home/me/gclo/acme/repo</c>.</param>
|
||||
/// <param name="WindowsPath">The same folder as Explorer reaches it: <c>\\wsl.localhost\<distro>\home\me\gclo\acme\repo</c>.</param>
|
||||
public sealed record WslCloneResult(string Distribution, string LinuxPath, string WindowsPath);
|
||||
|
||||
/// <summary>A clone into WSL did not complete; the message carries git's or wsl.exe's own explanation.</summary>
|
||||
public sealed class WslCloneException : Exception
|
||||
{
|
||||
/// <param name="message">What failed, in git's or wsl.exe's words.</param>
|
||||
public WslCloneException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The "Clone in WSL instead" recovery for repositories whose trees contain
|
||||
/// Windows-invalid paths (#8): a Linux file system has no such rule, so the
|
||||
/// repository is cloned by the distribution's own git into
|
||||
/// <c>~/gclo/<organization>/<repository></c> and left there.
|
||||
/// </summary>
|
||||
public interface IWslCloner
|
||||
{
|
||||
/// <summary>Checks, without side effects, whether a clone into WSL can be attempted.</summary>
|
||||
Task<WslAvailability> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Clones <paramref name="url"/> inside the default WSL distribution, or fast-forwards
|
||||
/// it when that folder is already a repository. Throws <see cref="WslCloneException"/>
|
||||
/// when git or wsl.exe fail.
|
||||
/// </summary>
|
||||
/// <param name="url">HTTPS clone URL.</param>
|
||||
/// <param name="organization">Organization (or user) name; the parent folder under ~/gclo.</param>
|
||||
/// <param name="repositoryName">Repository name; the clone's folder name.</param>
|
||||
/// <param name="token">GitHub token; handed to git through the environment only, never on a command line.</param>
|
||||
/// <param name="cancellationToken">Kills wsl.exe (and the clone) when canceled.</param>
|
||||
Task<WslCloneResult> CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives <c>wsl.exe</c> for <see cref="IWslCloner"/>. Everything that runs inside the
|
||||
/// distribution is a POSIX <c>sh</c> script fed through stdin (<c>sh -s</c>) with the
|
||||
/// organization, repository, and URL as positional parameters, so no value is ever
|
||||
/// re-parsed by wsl.exe's own command-line splitting and nothing needs quoting. The token
|
||||
/// travels as <c>GCLO_GIT_TOKEN</c>, forwarded by <c>WSLENV</c>, and is read by an inline
|
||||
/// credential helper: it appears in no argument vector and no file.
|
||||
/// </summary>
|
||||
public sealed class WslCloner : IWslCloner
|
||||
{
|
||||
/// <summary>Environment variable the inline credential helper reads the token from.</summary>
|
||||
public const string TokenVariable = "GCLO_GIT_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// The clone script. $1 organization, $2 repository, $3 clone URL. Prints the
|
||||
/// distribution name and the destination as its last two lines on success. A folder
|
||||
/// that already is a repository is fast-forwarded instead of failing, so the verb is
|
||||
/// safe to use twice.
|
||||
/// </summary>
|
||||
internal const string CloneScript =
|
||||
"set -e\n"
|
||||
+ "org=\"$1\"; repo=\"$2\"; url=\"$3\"\n"
|
||||
+ "dest=\"$HOME/gclo/$org/$repo\"\n"
|
||||
+ "helper='!f() { echo username=x-access-token; echo \"password=$" + TokenVariable + "\"; }; f'\n"
|
||||
+ "if [ -d \"$dest/.git\" ]; then\n"
|
||||
+ " git -C \"$dest\" -c credential.helper= -c \"credential.helper=$helper\" pull --ff-only --quiet\n"
|
||||
+ "else\n"
|
||||
+ " mkdir -p \"$HOME/gclo/$org\"\n"
|
||||
+ " git -c credential.helper= -c \"credential.helper=$helper\" clone --quiet \"$url\" \"$dest\"\n"
|
||||
+ "fi\n"
|
||||
+ "printf '%s\\n%s\\n' \"$WSL_DISTRO_NAME\" \"$dest\"\n";
|
||||
|
||||
private readonly IProcessRunner _runner;
|
||||
private readonly Func<string, bool> _fileExists;
|
||||
private readonly string _wslExe;
|
||||
|
||||
/// <param name="runner">Process seam; the real <see cref="ProcessRunner"/> by default.</param>
|
||||
/// <param name="fileExists">File-existence probe for wsl.exe; <see cref="File.Exists"/> by default.</param>
|
||||
/// <param name="wslExePath">Path of wsl.exe; <c>%SystemRoot%\System32\wsl.exe</c> by default.</param>
|
||||
public WslCloner(IProcessRunner? runner = null, Func<string, bool>? fileExists = null, string? wslExePath = null)
|
||||
{
|
||||
_runner = runner ?? new ProcessRunner();
|
||||
_fileExists = fileExists ?? File.Exists;
|
||||
_wslExe = wslExePath ?? Path.Combine(Environment.SystemDirectory, "wsl.exe");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WslAvailability> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_fileExists(_wslExe))
|
||||
{
|
||||
return new WslAvailability(false, "wsl.exe is not installed (Windows Subsystem for Linux is not enabled).");
|
||||
}
|
||||
|
||||
ProcessResult result;
|
||||
try
|
||||
{
|
||||
result = await _runner.RunAsync(_wslExe, ["-e", "git", "--version"], null, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new WslAvailability(false, $"wsl.exe could not be started: {ex.Message}");
|
||||
}
|
||||
|
||||
string stdout = result.StandardOutput.Trim();
|
||||
if (result.ExitCode == 0 && stdout.StartsWith("git version", StringComparison.Ordinal))
|
||||
{
|
||||
return new WslAvailability(true, stdout);
|
||||
}
|
||||
|
||||
string reason = FirstLine(result.StandardError) ?? FirstLine(stdout) ?? $"exit code {result.ExitCode}";
|
||||
return new WslAvailability(false, result.ExitCode == 0
|
||||
? $"git is not installed in the default WSL distribution ({reason})."
|
||||
: $"the default WSL distribution did not run git: {reason}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WslCloneResult> CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(url);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(organization);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[TokenVariable] = token,
|
||||
["GIT_TERMINAL_PROMPT"] = "0",
|
||||
// Forward both into the distribution (WSLENV is a colon-separated list).
|
||||
["WSLENV"] = TokenVariable + ":GIT_TERMINAL_PROMPT",
|
||||
};
|
||||
|
||||
ProcessResult result;
|
||||
try
|
||||
{
|
||||
result = await _runner.RunAsync(
|
||||
_wslExe,
|
||||
["-e", "sh", "-s", organization, repositoryName, url],
|
||||
environment,
|
||||
CloneScript,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WslCloneException($"wsl.exe could not be started: {ex.Message}");
|
||||
}
|
||||
|
||||
if (result.ExitCode != 0)
|
||||
{
|
||||
string reason = LastLine(result.StandardError) ?? LastLine(result.StandardOutput) ?? $"exit code {result.ExitCode}";
|
||||
throw new WslCloneException($"Clone in WSL failed: {reason}");
|
||||
}
|
||||
|
||||
string[] lines = result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (lines.Length < 2 || lines[^1].Length == 0 || lines[^2].Length == 0)
|
||||
{
|
||||
throw new WslCloneException("Clone in WSL finished but did not report where the repository was placed.");
|
||||
}
|
||||
|
||||
string distribution = lines[^2];
|
||||
string linuxPath = lines[^1];
|
||||
string windowsPath = @"\\wsl.localhost\" + distribution + linuxPath.Replace('/', '\\');
|
||||
return new WslCloneResult(distribution, linuxPath, windowsPath);
|
||||
}
|
||||
|
||||
private static string? FirstLine(string text)
|
||||
=> text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault();
|
||||
|
||||
private static string? LastLine(string text)
|
||||
=> text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).LastOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The real <see cref="IProcessRunner"/>. Output is read as bytes and decoded as UTF-16
|
||||
/// when it carries NULs (wsl.exe's own messages, e.g. "no distribution installed", are
|
||||
/// UTF-16) and as UTF-8 otherwise (everything a Linux process prints).
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage(Justification = "Spawns real processes; WslCloner is covered through a fake runner.")]
|
||||
public sealed class ProcessRunner : IProcessRunner
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ProcessResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string>? environment,
|
||||
string? standardInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var start = new ProcessStartInfo(fileName)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
if (environment is not null)
|
||||
{
|
||||
foreach (KeyValuePair<string, string> pair in environment)
|
||||
{
|
||||
start.Environment[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = start };
|
||||
process.Start();
|
||||
|
||||
using var stdout = new MemoryStream();
|
||||
using var stderr = new MemoryStream();
|
||||
Task copyOut = process.StandardOutput.BaseStream.CopyToAsync(stdout, cancellationToken);
|
||||
Task copyErr = process.StandardError.BaseStream.CopyToAsync(stderr, cancellationToken);
|
||||
if (standardInput is not null)
|
||||
{
|
||||
await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
process.StandardInput.Close();
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
await Task.WhenAll(copyOut, copyErr).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* already exited */ }
|
||||
throw;
|
||||
}
|
||||
|
||||
return new ProcessResult(process.ExitCode, Decode(stdout.ToArray()), Decode(stderr.ToArray()));
|
||||
}
|
||||
|
||||
private static string Decode(byte[] bytes)
|
||||
=> bytes.Length >= 2 && Array.IndexOf(bytes, (byte)0) >= 0
|
||||
? Encoding.Unicode.GetString(bytes).Replace("\0", "")
|
||||
: Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
@@ -40,6 +40,8 @@ public sealed class AppSession : IDisposable
|
||||
WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "",
|
||||
};
|
||||
startInfo.Environment["GCLO_DATA_DIR"] = DataDirectory;
|
||||
// Enables the offline fixture for the one magic token only (see UiTestFixture).
|
||||
startInfo.Environment["GCLO_UITEST_FIXTURE"] = "1";
|
||||
|
||||
Automation = new UIA3Automation();
|
||||
try
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using FlaUI.Core.AutomationElements;
|
||||
using FlaUI.Core.Input;
|
||||
using FlaUI.Core.WindowsAPI;
|
||||
|
||||
namespace gclo.UiTests;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the repository table (WinUI.TableView, #33) against the app's offline
|
||||
/// fixture: the magic token routes Quick Sync to a fixed organization with three
|
||||
/// repositories (see <c>UiTestFixture</c> in the app), so the table renders without
|
||||
/// GitHub. Its own collection, because loading repositories replaces the connect
|
||||
/// card the smoke tests expect to find.
|
||||
/// </summary>
|
||||
[Collection(nameof(UiTableTests))]
|
||||
public sealed class RepoTableTests : UiTestBase
|
||||
{
|
||||
private const string FixtureToken = "gclo-uitest-fixture-token";
|
||||
|
||||
public RepoTableTests(AppSession session)
|
||||
: base(session)
|
||||
{
|
||||
}
|
||||
|
||||
// (1) The fixture organization loads into the table: the TableView is reachable
|
||||
// by its automation id, the three repositories are rendered as cells, and
|
||||
// the header row (select-all, funnels) is in the UI Automation tree.
|
||||
[Fact]
|
||||
public void LoadRepositories_WithFixtureToken_RendersTheTable()
|
||||
{
|
||||
EnsureLoaded();
|
||||
|
||||
Assert.NotNull(Session.FindInApp(cf => cf.ByName("alpha")));
|
||||
Assert.NotNull(Session.FindInApp(cf => cf.ByName("bravo")));
|
||||
Assert.NotNull(Session.FindInApp(cf => cf.ByName("charlie")));
|
||||
Assert.NotNull(Session.WaitFor(() => Funnel("StatusFilterButton"), "the Status funnel"));
|
||||
Assert.NotNull(Session.WaitFor(() => ColumnHeader("Select"), "the select-all header"));
|
||||
}
|
||||
|
||||
// (2) Column headers carry composed sort names and the Invoke pattern; invoking
|
||||
// one sorts through the view model (ascending, then descending, then back to
|
||||
// ascending on the control's "clear" step), and other columns stay unsorted.
|
||||
[Fact]
|
||||
public void Headers_InvokeSortsThroughTheViewModel()
|
||||
{
|
||||
EnsureLoaded();
|
||||
|
||||
AutomationElement header = Session.WaitFor(() => ColumnHeader("Branch, not sorted"), "the Branch column header");
|
||||
Assert.True(header.Patterns.Invoke.IsSupported, "a sortable header exposes the Invoke pattern");
|
||||
|
||||
header.Patterns.Invoke.Pattern.Invoke();
|
||||
Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after one sort");
|
||||
Assert.NotNull(ColumnHeader("Name, not sorted"));
|
||||
|
||||
header.Patterns.Invoke.Pattern.Invoke();
|
||||
Session.WaitFor(() => ColumnHeader("Branch, sorted descending"), "the header after a second sort");
|
||||
|
||||
header.Patterns.Invoke.Pattern.Invoke(); // the control's third step is "clear"; the view model starts over
|
||||
Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after a third sort");
|
||||
}
|
||||
|
||||
// (3) The per-column funnel opens its flyout; its text box drives the view
|
||||
// model's filter (rows vanish, the empty-filter text appears, the funnel
|
||||
// names the active filter) and Clear restores every row.
|
||||
[Fact]
|
||||
public void NameFunnel_FiltersRows_AndClearRestoresThem()
|
||||
{
|
||||
EnsureLoaded();
|
||||
|
||||
try
|
||||
{
|
||||
Session.WaitFor(() => Funnel("NameFilterButton"), "the Name funnel").AsButton().Invoke();
|
||||
AutomationElement box = Session.WaitForElement("NameFilterBox");
|
||||
Session.EnterText(box, "zzz");
|
||||
|
||||
Session.WaitFor(
|
||||
() => Session.FindInApp(cf => cf.ByName("Filter by name, filtering on 'zzz'")),
|
||||
"the funnel to report the active filter");
|
||||
Session.WaitForText("No repositories match this filter.");
|
||||
AppSession.WaitUntilGone(() => Session.FindInApp(cf => cf.ByName("alpha")), "the filtered-out row");
|
||||
|
||||
Session.ClickButton("Clear");
|
||||
Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("Filter by name")), "the funnel after Clear");
|
||||
Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("alpha")), "the row to come back");
|
||||
Session.WaitForTextGone("No repositories match this filter.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Keyboard.Press(VirtualKeyShort.ESCAPE); // close the flyout if it is still open
|
||||
}
|
||||
}
|
||||
|
||||
// (4) Keyboard reachability of the funnels (Tab from the select-all checkbox) is
|
||||
// not asserted here: synthesized Tab keystrokes did not move focus inside the
|
||||
// TableView header under FlaUI on the development machine, so that remains a
|
||||
// manual Narrator/keyboard check (see the #33 acceptance notes).
|
||||
|
||||
/// <summary>A column header by its automation name; the header row is in the UIA tree thanks to AccessibleTableView.</summary>
|
||||
private AutomationElement? ColumnHeader(string name)
|
||||
=> Session.FindInApp(cf => cf.ByAutomationId("RepoListView"))?.FindFirstDescendant(cf => cf.ByName(name));
|
||||
|
||||
/// <summary>The funnel button carrying <paramref name="automationId"/>, inside its column header.</summary>
|
||||
private AutomationElement? Funnel(string automationId)
|
||||
=> Session.FindInApp(cf => cf.ByAutomationId("RepoListView"))?.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
|
||||
|
||||
/// <summary>
|
||||
/// Loads the fixture organization once per session: paste the magic token (the
|
||||
/// org lookup is answered by the fixture), pick the org, then Load repositories.
|
||||
/// A session that already shows the table skips straight through.
|
||||
/// </summary>
|
||||
private void EnsureLoaded()
|
||||
{
|
||||
if (Session.FindInApp(cf => cf.ByAutomationId("RepoListView")) is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox");
|
||||
Session.EnterText(tokenBox, FixtureToken);
|
||||
// The debounced org lookup is answered by the fixture (one organization); the
|
||||
// log line is the oracle, then the org is picked from the combo box's list so
|
||||
// the selection commits to the view model's Organization, which enables Load.
|
||||
Session.WaitForLogLine("Organization lookup finished: 1 organizations", TimeSpan.FromSeconds(20));
|
||||
ComboBox orgBox = Session.WaitForElement("ConnectOrgBox").AsComboBox();
|
||||
Session.WaitFor(
|
||||
() =>
|
||||
{
|
||||
orgBox.Expand();
|
||||
return orgBox.Items.FirstOrDefault(i => i.Name == "fixture-org");
|
||||
},
|
||||
"the fixture org in the organization list");
|
||||
orgBox.Select("fixture-org");
|
||||
orgBox.Collapse();
|
||||
|
||||
Session.WaitFor(
|
||||
() => Session.FindInApp(cf => cf.ByName("Load repositories")) is { IsEnabled: true } button ? button : null,
|
||||
"the Load repositories button to enable");
|
||||
Session.ClickButton("Load repositories");
|
||||
Session.WaitForElement("RepoListView", TimeSpan.FromSeconds(20));
|
||||
Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("charlie")), "the last fixture row");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A second collection (and therefore a second launched app) for the table tests;
|
||||
/// see <see cref="RepoTableTests"/> for why they cannot share the smoke session.
|
||||
/// </summary>
|
||||
[CollectionDefinition(nameof(UiTableTests), DisableParallelization = true)]
|
||||
public sealed class UiTableTests : ICollectionFixture<AppSession>
|
||||
{
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
// The table tests launch a second app instance in their own collection; two FlaUI
|
||||
// sessions must never drive input at the same time, so collections run one at a time.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace gclo.UiTests;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// What the user chose in the path-recovery UI for a repository whose tree has
|
||||
/// Windows-invalid paths. Null (no decision) means the dialog was dismissed.
|
||||
/// </summary>
|
||||
public abstract record PathRecoveryDecision
|
||||
{
|
||||
private PathRecoveryDecision() { }
|
||||
|
||||
/// <summary>Rename/skip the offending paths and check the repository out on Windows.</summary>
|
||||
public sealed record Apply(PathRecovery Recovery) : PathRecoveryDecision;
|
||||
|
||||
/// <summary>
|
||||
/// Leave the Windows copy alone and clone the repository inside WSL instead, where
|
||||
/// the paths are legal (#8). Only offered when <see cref="WorkspaceViewModel.IsWslCloneAvailable"/>.
|
||||
/// </summary>
|
||||
public sealed record CloneInWsl : PathRecoveryDecision;
|
||||
}
|
||||
@@ -74,6 +74,21 @@ public sealed partial class RepoItemViewModel : ObservableObject
|
||||
/// <summary>True when there is an error message to show inline.</summary>
|
||||
public bool HasError => !string.IsNullOrEmpty(Error);
|
||||
|
||||
/// <summary>
|
||||
/// A non-error remark shown under the row, e.g. where a repository was cloned when
|
||||
/// it lives outside the target folder (in WSL, #8). Cleared by the next run.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasNote))]
|
||||
[NotifyPropertyChangedFor(nameof(HasDetails))]
|
||||
public partial string? Note { get; set; }
|
||||
|
||||
/// <summary>True when there is a remark to show inline.</summary>
|
||||
public bool HasNote => !string.IsNullOrEmpty(Note);
|
||||
|
||||
/// <summary>True when the row has anything to show under its cells (error, note, or both).</summary>
|
||||
public bool HasDetails => HasError || HasNote;
|
||||
|
||||
partial void OnStatusChanged(SyncStatus value)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
@@ -94,7 +109,11 @@ public sealed partial class RepoItemViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(ProgressValue));
|
||||
}
|
||||
|
||||
partial void OnErrorChanged(string? value) => OnPropertyChanged(nameof(HasError));
|
||||
partial void OnErrorChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasError));
|
||||
OnPropertyChanged(nameof(HasDetails));
|
||||
}
|
||||
|
||||
partial void OnInvalidPathsChanged(IReadOnlyList<InvalidPathInfo>? value)
|
||||
=> OnPropertyChanged(nameof(HasPathIssue));
|
||||
|
||||
@@ -18,6 +18,10 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly IRepositoryLister _lister;
|
||||
private readonly IGitClient _git;
|
||||
private readonly IWslCloner _wsl;
|
||||
|
||||
/// <summary>Result of the one-time WSL probe; null until the first path recovery asks for it.</summary>
|
||||
private WslAvailability? _wslAvailability;
|
||||
private readonly IOrganizationLister _orgLister;
|
||||
private readonly Func<Action<RepoProgress>, IProgress<RepoProgress>> _progressFactory;
|
||||
private readonly TimeSpan _orgLookupDebounce;
|
||||
@@ -86,10 +90,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
IActivityLog? log = null,
|
||||
Account? account = null,
|
||||
ITokenVault? tokenVault = null,
|
||||
AccountsStore? accountsStore = null)
|
||||
AccountsStore? accountsStore = null,
|
||||
IWslCloner? wsl = null)
|
||||
{
|
||||
_lister = lister ?? new GitHubRepositoryLister();
|
||||
_git = git ?? new LibGit2GitClient();
|
||||
_wsl = wsl ?? new WslCloner();
|
||||
_orgLister = orgLister ?? new GitHubOrganizationLister();
|
||||
_progressFactory = progressFactory ?? (handler => new Progress<RepoProgress>(handler));
|
||||
_orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600);
|
||||
@@ -832,8 +838,35 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
/// Raised when the user asks to resolve a row's Windows-invalid paths. The view
|
||||
/// subscribes and shows recovery UI (the VM stays UI-free), returning the user's
|
||||
/// decision, or null when they cancel. With no subscriber the command does nothing.
|
||||
/// <see cref="IsWslCloneAvailable"/> is settled before the interaction runs, so the
|
||||
/// view can decide whether to offer "Clone in WSL instead".
|
||||
/// </summary>
|
||||
public Func<RepoItemViewModel, Task<PathRecovery?>>? RecoveryInteraction { get; set; }
|
||||
public Func<RepoItemViewModel, Task<PathRecoveryDecision?>>? RecoveryInteraction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when a clone into WSL can be offered as a recovery (#8): wsl.exe exists, the
|
||||
/// default distribution starts, and git runs inside it. Probed once, lazily, the
|
||||
/// first time path recovery is requested; false until then.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial bool IsWslCloneAvailable { get; private set; }
|
||||
|
||||
/// <summary>Why <see cref="IsWslCloneAvailable"/> is false (or the git version when true), for the log and the dialog.</summary>
|
||||
public string WslAvailabilityDetail => _wslAvailability?.Detail ?? "";
|
||||
|
||||
private async Task EnsureWslProbedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_wslAvailability is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
WslAvailability availability = await _wsl.ProbeAsync(cancellationToken);
|
||||
_wslAvailability = availability;
|
||||
IsWslCloneAvailable = availability.Available;
|
||||
_log.Info(availability.Available
|
||||
? $"WSL clone available ({availability.Detail})."
|
||||
: $"WSL clone not available: {availability.Detail}");
|
||||
}
|
||||
|
||||
private bool CanResolvePaths(RepoItemViewModel item)
|
||||
=> item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths;
|
||||
@@ -851,16 +884,39 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
|
||||
_log.Info($"{item.Name}: path recovery requested ({item.InvalidPaths?.Count ?? 0} invalid paths).");
|
||||
PathRecovery? recovery = await interaction(item);
|
||||
if (recovery is null)
|
||||
await EnsureWslProbedAsync(_lifetimeCts.Token);
|
||||
PathRecoveryDecision? decision = await interaction(item);
|
||||
if (decision is null)
|
||||
{
|
||||
_log.Info($"{item.Name}: path recovery canceled; the repository stays failed.");
|
||||
return; // user canceled; the row keeps its Failed state and payload
|
||||
}
|
||||
|
||||
string path = Path.Combine(EffectiveTargetRoot, item.Name);
|
||||
IsResolvingPaths = true;
|
||||
try
|
||||
{
|
||||
if (decision is PathRecoveryDecision.CloneInWsl)
|
||||
{
|
||||
await CloneInWslAsync(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ApplyRecoveryAsync(item, ((PathRecoveryDecision.Apply)decision).Recovery);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsResolvingPaths = false;
|
||||
}
|
||||
RecomputeCompletedCount();
|
||||
UpdateFilteredMembership(item);
|
||||
RetryFailedCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private async Task ApplyRecoveryAsync(RepoItemViewModel item, PathRecovery recovery)
|
||||
{
|
||||
string path = Path.Combine(EffectiveTargetRoot, item.Name);
|
||||
try
|
||||
{
|
||||
_log.Info($"{item.Name}: applying path recovery "
|
||||
+ $"({recovery.SegmentRenames.Count} renamed, {recovery.SkippedPaths.Count} skipped).");
|
||||
@@ -883,13 +939,37 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
item.InvalidPaths = (ex as InvalidRepositoryPathsException)?.Paths;
|
||||
_log.Error($"{item.Name}: path recovery failed: {ex.Message}", ex);
|
||||
}
|
||||
finally
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The "Clone in WSL instead" recovery (#8): the repository is cloned by the default
|
||||
/// WSL distribution's git into ~/gclo/<org>/<repo>, where its paths are
|
||||
/// legal. The fetched-but-unchecked-out Windows copy is left as it is (a later
|
||||
/// Windows sync reports the same invalid paths again, by design: nothing on the
|
||||
/// Windows side changed). On failure the row keeps its payload so the user can pick
|
||||
/// the other recovery.
|
||||
/// </summary>
|
||||
private async Task CloneInWslAsync(RepoItemViewModel item)
|
||||
{
|
||||
try
|
||||
{
|
||||
IsResolvingPaths = false;
|
||||
_log.Info($"{item.Name}: cloning in WSL instead.");
|
||||
item.Status = SyncStatus.Pulling; // indeterminate bar; git in WSL reports no percentage here
|
||||
WslCloneResult result = await _wsl.CloneAsync(
|
||||
item.Descriptor.CloneUrl, Organization.Trim(), item.Name, Token.Trim(), _lifetimeCts.Token);
|
||||
|
||||
item.Status = SyncStatus.Done;
|
||||
item.Error = null;
|
||||
item.InvalidPaths = null;
|
||||
item.Note = $"Cloned in WSL ({result.Distribution}): {result.WindowsPath}";
|
||||
_log.Info($"{item.Name}: cloned in WSL ({result.Distribution}) at {result.LinuxPath}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
item.Status = SyncStatus.Failed;
|
||||
item.Error = ex.Message;
|
||||
_log.Error($"{item.Name}: clone in WSL failed: {ex.Message}", ex);
|
||||
}
|
||||
RecomputeCompletedCount();
|
||||
UpdateFilteredMembership(item);
|
||||
RetryFailedCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sorting
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation.Peers;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using WinUI.TableView;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="TableView"/> whose UI Automation tree includes the column header row.
|
||||
/// WinUI.TableView 1.5.0 gives headers their own peers (Invoke sorts, the name
|
||||
/// composes the sort state) but inherits <c>ListViewAutomationPeer</c>'s children,
|
||||
/// which are the rows only: the header row, its headers, and anything hosted in
|
||||
/// them (the filter funnels here) are unreachable by tree walking, and the Table
|
||||
/// pattern's <c>ColumnHeaders</c> property fails for the same reason. This peer
|
||||
/// prepends the header row, so screen readers and the FlaUI suite see the table
|
||||
/// the way the hand-rolled header was seen before (#33).
|
||||
/// </summary>
|
||||
public sealed partial class AccessibleTableView : TableView
|
||||
{
|
||||
protected override AutomationPeer OnCreateAutomationPeer() => new AccessibleTableViewPeer(this);
|
||||
|
||||
/// <summary>The template's header row, once the template has been applied; null before.</summary>
|
||||
internal TableViewHeaderRow? FindHeaderRow() => FindDescendant<TableViewHeaderRow>(this);
|
||||
|
||||
private static T? FindDescendant<T>(DependencyObject root) where T : DependencyObject
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(root);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
DependencyObject child = VisualTreeHelper.GetChild(root, i);
|
||||
if (child is T match)
|
||||
{
|
||||
return match;
|
||||
}
|
||||
if (child is not ItemsPresenter && FindDescendant<T>(child) is { } nested)
|
||||
{
|
||||
return nested; // rows live under the ItemsPresenter; the header row does not
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>See <see cref="AccessibleTableView"/>.</summary>
|
||||
public sealed partial class AccessibleTableViewPeer : WinUI.TableView.AutomationPeers.TableViewAutomationPeer
|
||||
{
|
||||
private readonly AccessibleTableView _owner;
|
||||
|
||||
public AccessibleTableViewPeer(AccessibleTableView owner)
|
||||
: base(owner)
|
||||
{
|
||||
_owner = owner;
|
||||
}
|
||||
|
||||
protected override IList<AutomationPeer> GetChildrenCore()
|
||||
{
|
||||
IList<AutomationPeer> children = base.GetChildrenCore() ?? new List<AutomationPeer>();
|
||||
if (_owner.FindHeaderRow() is not { } headerRow)
|
||||
{
|
||||
return children;
|
||||
}
|
||||
|
||||
// TableViewHeaderRow is a plain Control with no peer of its own, so its
|
||||
// column headers (which do have peers) become direct children of the table.
|
||||
var withHeaders = new List<AutomationPeer>(children.Count + 8);
|
||||
foreach (TableViewColumnHeader header in FindAll<TableViewColumnHeader>(headerRow))
|
||||
{
|
||||
if (FrameworkElementAutomationPeer.CreatePeerForElement(header) is { } headerPeer)
|
||||
{
|
||||
withHeaders.Add(headerPeer);
|
||||
}
|
||||
}
|
||||
withHeaders.AddRange(children);
|
||||
return withHeaders;
|
||||
}
|
||||
|
||||
private static IEnumerable<T> FindAll<T>(DependencyObject root) where T : DependencyObject
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(root);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
DependencyObject child = VisualTreeHelper.GetChild(root, i);
|
||||
if (child is T match)
|
||||
{
|
||||
yield return match;
|
||||
continue;
|
||||
}
|
||||
foreach (T nested in FindAll<T>(child))
|
||||
{
|
||||
yield return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header content for a table column: the title text plus whatever the column adds
|
||||
/// beside it (the filter funnel). TableView names each cell from its column's
|
||||
/// header object, so the override makes that "Name, Row 3, alpha" rather than the
|
||||
/// Grid's type name.
|
||||
/// </summary>
|
||||
public sealed partial class ColumnHeaderContent : Grid
|
||||
{
|
||||
public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(
|
||||
nameof(Title), typeof(string), typeof(ColumnHeaderContent), new PropertyMetadata(""));
|
||||
|
||||
/// <summary>The column title, as read by assistive technology.</summary>
|
||||
public string Title
|
||||
{
|
||||
get => (string)GetValue(TitleProperty);
|
||||
set => SetValue(TitleProperty, value);
|
||||
}
|
||||
|
||||
public override string ToString() => Title;
|
||||
}
|
||||
}
|
||||
@@ -427,7 +427,11 @@ namespace gclo
|
||||
{
|
||||
// Quick Sync: no account behind it. A saved default token (Settings)
|
||||
// pre-fills the connect card; setting Token also starts the org lookup.
|
||||
var quickSync = new WorkspaceViewModel(log: _log);
|
||||
var quickSync = new WorkspaceViewModel(
|
||||
UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()),
|
||||
UiTestFixture.WrapGitClient(new LibGit2GitClient()),
|
||||
UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()),
|
||||
log: _log);
|
||||
if (_tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId) is { Length: > 0 } defaultToken)
|
||||
{
|
||||
quickSync.Token = defaultToken;
|
||||
@@ -442,6 +446,9 @@ namespace gclo
|
||||
return null;
|
||||
}
|
||||
return new WorkspaceViewModel(
|
||||
UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()),
|
||||
UiTestFixture.WrapGitClient(new LibGit2GitClient()),
|
||||
UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()),
|
||||
account: account,
|
||||
tokenVault: _tokenVault,
|
||||
accountsStore: _accountsStore,
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
PrimaryButtonText="Apply and check out"
|
||||
CloseButtonText="Skip this repo"
|
||||
DefaultButton="Primary"
|
||||
PrimaryButtonClick="OnPrimaryButtonClick">
|
||||
PrimaryButtonClick="OnPrimaryButtonClick"
|
||||
SecondaryButtonClick="OnSecondaryButtonClick">
|
||||
|
||||
<ContentDialog.Resources>
|
||||
<!-- Repository paths are wide; loosen the stock 548px dialog cap. -->
|
||||
|
||||
@@ -18,7 +18,9 @@ namespace gclo
|
||||
/// pre-flights the choices through <see cref="PathRecoveryPlanner"/> and keeps the
|
||||
/// dialog open with the problems listed when they would fail. <see cref="Result"/>
|
||||
/// carries the chosen <see cref="PathRecovery"/> after a sound apply and stays null
|
||||
/// when the dialog is dismissed ("Skip this repo").
|
||||
/// when the dialog is dismissed ("Skip this repo"). When WSL can run git on this
|
||||
/// machine the dialog also offers "Clone in WSL instead" (#8), which skips the
|
||||
/// Windows checkout entirely: <see cref="Decision"/> then reports that choice.
|
||||
///
|
||||
/// As with every ContentDialog, the caller must set <c>XamlRoot</c> before
|
||||
/// <c>ShowAsync</c>.
|
||||
@@ -28,9 +30,13 @@ namespace gclo
|
||||
private readonly List<PathRecoveryRow> _rows;
|
||||
|
||||
/// <summary>The user's recovery choice, or null when the dialog was dismissed.</summary>
|
||||
public PathRecovery? Result { get; private set; }
|
||||
public PathRecoveryDecision? Decision { get; private set; }
|
||||
|
||||
public PathRecoveryDialog(string repoName, IReadOnlyList<InvalidPathInfo> paths)
|
||||
/// <summary>The chosen renames/skips when <see cref="Decision"/> is an apply; null otherwise.</summary>
|
||||
public PathRecovery? Result => (Decision as PathRecoveryDecision.Apply)?.Recovery;
|
||||
|
||||
/// <param name="wslAvailable">Offers the "Clone in WSL instead" button (see <see cref="WorkspaceViewModel.IsWslCloneAvailable"/>).</param>
|
||||
public PathRecoveryDialog(string repoName, IReadOnlyList<InvalidPathInfo> paths, bool wslAvailable = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repoName);
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
@@ -38,7 +44,14 @@ namespace gclo
|
||||
|
||||
IntroText.Text =
|
||||
$"'{repoName}' contains paths that are legal in git but cannot be created on Windows. "
|
||||
+ "Edit the replacement name for each entry, or mark it Skip to leave it out of the checkout.";
|
||||
+ "Edit the replacement name for each entry, or mark it Skip to leave it out of the checkout."
|
||||
+ (wslAvailable
|
||||
? " Or clone the repository unchanged inside WSL (~/gclo/<org>/<repo> in your default distribution), where these paths are legal."
|
||||
: "");
|
||||
if (wslAvailable)
|
||||
{
|
||||
SecondaryButtonText = "Clone in WSL instead";
|
||||
}
|
||||
|
||||
_rows = new List<PathRecoveryRow>(paths.Count);
|
||||
foreach (InvalidPathInfo path in paths)
|
||||
@@ -64,8 +77,11 @@ namespace gclo
|
||||
}
|
||||
|
||||
ProblemsBar.IsOpen = false;
|
||||
Result = plan.Recovery;
|
||||
Decision = new PathRecoveryDecision.Apply(plan.Recovery);
|
||||
}
|
||||
|
||||
private void OnSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
=> Decision = new PathRecoveryDecision.CloneInWsl();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>
|
||||
/// Offline seam for the FlaUI suite (#54, #33): when the process runs with
|
||||
/// <c>GCLO_UITEST_FIXTURE=1</c> <b>and</b> a workspace's token is exactly
|
||||
/// <see cref="Token"/>, GitHub and git are answered by this fixture instead of the
|
||||
/// network - a fixed organization with three repositories, and a git client that
|
||||
/// succeeds without touching disk. Any other token (even with the variable set) goes
|
||||
/// to the real services, so the existing offline-input tests keep their meaning
|
||||
/// (a dummy token still gets GitHub's 401). Production never sets the variable.
|
||||
/// </summary>
|
||||
internal static class UiTestFixture
|
||||
{
|
||||
/// <summary>The magic token that routes a workspace to the fixture.</summary>
|
||||
public const string Token = "gclo-uitest-fixture-token";
|
||||
|
||||
/// <summary>The organization the fixture lists.</summary>
|
||||
public const string Organization = "fixture-org";
|
||||
|
||||
/// <summary>True when the process was started for the UI suite with the fixture enabled.</summary>
|
||||
public static bool IsEnabled { get; } =
|
||||
Environment.GetEnvironmentVariable("GCLO_UITEST_FIXTURE") == "1";
|
||||
|
||||
private static readonly IReadOnlyList<RepoDescriptor> Repositories =
|
||||
[
|
||||
new("alpha", "https://github.com/fixture-org/alpha.git", "main", IsArchived: false),
|
||||
new("bravo", "https://github.com/fixture-org/bravo.git", "develop", IsArchived: true),
|
||||
new("charlie", "https://github.com/fixture-org/charlie.git", "main", IsArchived: false),
|
||||
];
|
||||
|
||||
public static IRepositoryLister WrapRepositoryLister(IRepositoryLister real)
|
||||
=> IsEnabled ? new RepositoryLister(real) : real;
|
||||
|
||||
public static IOrganizationLister WrapOrganizationLister(IOrganizationLister real)
|
||||
=> IsEnabled ? new OrganizationLister(real) : real;
|
||||
|
||||
public static IGitClient WrapGitClient(IGitClient real)
|
||||
=> IsEnabled ? new GitClient(real) : real;
|
||||
|
||||
private sealed class RepositoryLister(IRepositoryLister real) : IRepositoryLister
|
||||
{
|
||||
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
|
||||
string organization, string token, CancellationToken cancellationToken = default)
|
||||
=> token == Token
|
||||
? Task.FromResult(Repositories)
|
||||
: real.ListOrganizationRepositoriesAsync(organization, token, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class OrganizationLister(IOrganizationLister real) : IOrganizationLister
|
||||
{
|
||||
public Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
|
||||
=> token == Token
|
||||
? Task.FromResult<IReadOnlyList<string>>([Organization])
|
||||
: real.ListOrganizationsAsync(token, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Succeeds without writing anything; the real client handles every other token.</summary>
|
||||
private sealed class GitClient(IGitClient real) : IGitClient
|
||||
{
|
||||
public bool IsValidRepository(string path) => false;
|
||||
|
||||
public Task CloneAsync(string url, string path, string token, Action<double>? onProgress, CancellationToken cancellationToken)
|
||||
=> token == Token ? Task.CompletedTask : real.CloneAsync(url, path, token, onProgress, cancellationToken);
|
||||
|
||||
public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
|
||||
=> token == Token ? Task.CompletedTask : real.FetchAndPullAsync(path, token, cancellationToken);
|
||||
|
||||
public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken)
|
||||
=> real.ApplyRecoveryAsync(path, recovery, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
+321
-321
@@ -1,40 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<UserControl
|
||||
x:Class="gclo.WorkspacePage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:gclo"
|
||||
xmlns:vm="using:gclo.ViewModels"
|
||||
xmlns:tv="using:WinUI.TableView"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.Resources>
|
||||
<!-- Sortable column header: quiet button, muted semibold label; left padding
|
||||
stays 0 so header text aligns exactly with the row cells below. -->
|
||||
<Style x:Key="ColumnHeaderButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0,4,4,4" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</Style>
|
||||
<!-- The per-column funnel that opens that column's filter flyout. -->
|
||||
<!-- The per-column funnel that opens that column's filter flyout; it lives
|
||||
inside the TableView column header, beside the header text. -->
|
||||
<Style x:Key="ColumnFilterButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="4" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<!-- Table column widths, declared once and referenced by both the header grid
|
||||
and the row template (WinUI has no SharedSizeGroup): editing a width here
|
||||
keeps header and cells aligned by construction. -->
|
||||
<!-- Column widths of the repository table, declared once. -->
|
||||
<GridLength x:Key="SelectColumnWidth">40</GridLength>
|
||||
<GridLength x:Key="NameColumnWidth">2*</GridLength>
|
||||
<GridLength x:Key="StatusColumnWidth">160</GridLength>
|
||||
<GridLength x:Key="BranchColumnWidth">120</GridLength>
|
||||
<GridLength x:Key="ArchivedColumnWidth">80</GridLength>
|
||||
<GridLength x:Key="ArchivedColumnWidth">96</GridLength>
|
||||
<!-- Header automation names are set from code (sort state is composed in), but
|
||||
every header needs a sensible name before the first sort; one style per
|
||||
column since Style setters cannot bind. -->
|
||||
<Style x:Key="SelectHeaderStyle" TargetType="tv:TableViewColumnHeader">
|
||||
<Setter Property="AutomationProperties.Name" Value="Select" />
|
||||
</Style>
|
||||
<Style x:Key="NameHeaderStyle" TargetType="tv:TableViewColumnHeader">
|
||||
<Setter Property="AutomationProperties.Name" Value="Name, not sorted" />
|
||||
</Style>
|
||||
<Style x:Key="StatusHeaderStyle" TargetType="tv:TableViewColumnHeader">
|
||||
<Setter Property="AutomationProperties.Name" Value="Status, not sorted" />
|
||||
</Style>
|
||||
<Style x:Key="BranchHeaderStyle" TargetType="tv:TableViewColumnHeader">
|
||||
<Setter Property="AutomationProperties.Name" Value="Branch, not sorted" />
|
||||
</Style>
|
||||
<Style x:Key="ArchivedHeaderStyle" TargetType="tv:TableViewColumnHeader">
|
||||
<Setter Property="AutomationProperties.Name" Value="Archived, not sorted" />
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid Padding="20" RowSpacing="12">
|
||||
@@ -487,334 +495,326 @@
|
||||
</StackPanel>
|
||||
</InfoBar>
|
||||
|
||||
<!-- Table header: fixed column widths mirrored by the row template below.
|
||||
Every column sorts (button, composed automation name like
|
||||
"Name, sorted ascending") and filters (funnel flyout; the funnel
|
||||
turns accent-colored while its filter is active). The header row
|
||||
itself gets subtle chrome to read as a header, not a data row. -->
|
||||
<Border
|
||||
Grid.Row="3"
|
||||
Margin="0,8,0,0"
|
||||
Padding="12,2,12,2"
|
||||
Background="{ThemeResource SubtleFillColorSecondaryBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
CornerRadius="4,4,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="{StaticResource SelectColumnWidth}" />
|
||||
<ColumnDefinition Width="{StaticResource NameColumnWidth}" />
|
||||
<ColumnDefinition Width="{StaticResource StatusColumnWidth}" />
|
||||
<ColumnDefinition Width="{StaticResource BranchColumnWidth}" />
|
||||
<ColumnDefinition Width="{StaticResource ArchivedColumnWidth}" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<CheckBox
|
||||
Grid.Column="0"
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTipService.ToolTip="Select or clear every visible row (hidden rows keep their selection)"
|
||||
AutomationProperties.Name="Select all visible repositories"
|
||||
IsChecked="{x:Bind ViewModel.AllSelected, Mode=TwoWay}" />
|
||||
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
Style="{StaticResource ColumnHeaderButtonStyle}"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Name">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Name" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Name', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="NameFilterButton"
|
||||
AutomationProperties.Name="{x:Bind TextFilterAutomationName('name', ViewModel.NameFilter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by name">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind TextFilterIndicatorBrush(ViewModel.NameFilter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<StackPanel Width="220" Spacing="8">
|
||||
<TextBox
|
||||
Header="Name contains"
|
||||
AutomationProperties.AutomationId="NameFilterBox"
|
||||
Text="{x:Bind ViewModel.NameFilter, Mode=OneWay}"
|
||||
TextChanged="NameFilterBox_TextChanged" />
|
||||
<Button
|
||||
HorizontalAlignment="Right"
|
||||
Content="Clear"
|
||||
Click="ClearNameFilter_Click" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
Style="{StaticResource ColumnHeaderButtonStyle}"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Status">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Status" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Status', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="StatusFilterButton"
|
||||
AutomationProperties.Name="{x:Bind StatusFilterAutomationName(ViewModel.Filter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by status">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind StatusFilterIndicatorBrush(ViewModel.Filter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<RadioButtons
|
||||
Header="Show"
|
||||
AutomationProperties.AutomationId="StatusFilterChoices"
|
||||
SelectedIndex="{x:Bind StatusFilterIndex(ViewModel.Filter), Mode=OneWay}"
|
||||
SelectionChanged="StatusFilterChoices_SelectionChanged">
|
||||
<x:String>All</x:String>
|
||||
<x:String>Active</x:String>
|
||||
<x:String>Failed</x:String>
|
||||
<x:String>Pending</x:String>
|
||||
</RadioButtons>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="3">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
Style="{StaticResource ColumnHeaderButtonStyle}"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Branch">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Branch" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Branch', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="BranchFilterButton"
|
||||
AutomationProperties.Name="{x:Bind TextFilterAutomationName('branch', ViewModel.BranchFilter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by branch">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind TextFilterIndicatorBrush(ViewModel.BranchFilter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<StackPanel Width="220" Spacing="8">
|
||||
<TextBox
|
||||
Header="Branch contains"
|
||||
AutomationProperties.AutomationId="BranchFilterBox"
|
||||
Text="{x:Bind ViewModel.BranchFilter, Mode=OneWay}"
|
||||
TextChanged="BranchFilterBox_TextChanged" />
|
||||
<Button
|
||||
HorizontalAlignment="Right"
|
||||
Content="Clear"
|
||||
Click="ClearBranchFilter_Click" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
Style="{StaticResource ColumnHeaderButtonStyle}"
|
||||
AutomationProperties.Name="{x:Bind SortAutomationName('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Archived">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock FontWeight="SemiBold" Text="Archived" />
|
||||
<FontIcon
|
||||
FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Glyph="{x:Bind SortGlyph('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Visibility="{x:Bind SortGlyphVisibility('Archived', ViewModel.SortColumn), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="ArchivedFilterButton"
|
||||
AutomationProperties.Name="{x:Bind ArchivedFilterAutomationName(ViewModel.ArchivedFilter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by archived">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind ArchivedFilterIndicatorBrush(ViewModel.ArchivedFilter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<RadioButtons
|
||||
Header="Show"
|
||||
AutomationProperties.AutomationId="ArchivedFilterChoices"
|
||||
SelectedIndex="{x:Bind ArchivedFilterIndex(ViewModel.ArchivedFilter), Mode=OneWay}"
|
||||
SelectionChanged="ArchivedFilterChoices_SelectionChanged">
|
||||
<x:String>All</x:String>
|
||||
<x:String>Archived only</x:String>
|
||||
<x:String>Not archived</x:String>
|
||||
</RadioButtons>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Per-repo table body. The ListView carries the view model as DataContext so
|
||||
the row template's Resolve link can reach WorkspaceViewModel.ResolvePathsCommand
|
||||
via ElementName (x:Bind inside the template is scoped to the row item). -->
|
||||
<ListView
|
||||
x:Name="RepoListView"
|
||||
<!-- Repository table: WinUI.TableView (#33). The view model owns sort and
|
||||
filter state - FilteredRepos is the source, already sorted and
|
||||
filtered - so the control's own sorting is intercepted (Sorting /
|
||||
ClearSorting -> SortCommand; the indicator and the header's
|
||||
automation name follow SortColumn from code) and its Excel-style
|
||||
column filters are off in favor of the funnel flyouts in each
|
||||
header, which bind the same view-model filters as before. Selection
|
||||
is model state (the IsSelected column), never the control's. The
|
||||
TableView carries the view model as DataContext so the row-details
|
||||
template's Resolve link can reach ResolvePathsCommand by ElementName. -->
|
||||
<local:AccessibleTableView
|
||||
x:Name="RepoTable"
|
||||
AutomationProperties.AutomationId="RepoListView"
|
||||
Grid.Row="4"
|
||||
AutomationProperties.Name="Repositories"
|
||||
Grid.Row="3"
|
||||
Grid.RowSpan="2"
|
||||
Margin="0,8,0,0"
|
||||
DataContext="{x:Bind ViewModel, Mode=OneTime}"
|
||||
ItemsSource="{x:Bind ViewModel.FilteredRepos, Mode=OneTime}"
|
||||
SelectionMode="None">
|
||||
<ListView.ItemContainerStyle>
|
||||
<Style TargetType="ListViewItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Padding" Value="12,0,12,0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
</ListView.ItemContainerStyle>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RepoItemViewModel">
|
||||
<Grid Padding="0,6,0,6">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="{StaticResource SelectColumnWidth}" />
|
||||
<ColumnDefinition Width="{StaticResource NameColumnWidth}" />
|
||||
<ColumnDefinition Width="{StaticResource StatusColumnWidth}" />
|
||||
<ColumnDefinition Width="{StaticResource BranchColumnWidth}" />
|
||||
<ColumnDefinition Width="{StaticResource ArchivedColumnWidth}" />
|
||||
</Grid.ColumnDefinitions>
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
SelectionMode="None"
|
||||
CornerButtonMode="None"
|
||||
HeadersVisibility="Columns"
|
||||
CanFilterColumns="False"
|
||||
CanSortColumns="True"
|
||||
CanReorderColumns="False"
|
||||
CanResizeColumns="True"
|
||||
ShowExportOptions="False"
|
||||
GridLinesVisibility="Horizontal"
|
||||
HeaderGridLinesVisibility="Horizontal"
|
||||
RowDetailsVisibilityMode="Visible"
|
||||
Sorting="RepoTable_Sorting"
|
||||
ClearSorting="RepoTable_ClearSorting"
|
||||
Loaded="RepoTable_Loaded">
|
||||
<tv:TableView.Columns>
|
||||
|
||||
<!-- Select: header checkbox acts on the VISIBLE rows (AllSelected). -->
|
||||
<tv:TableViewTemplateColumn
|
||||
Tag="Select"
|
||||
Width="{StaticResource SelectColumnWidth}"
|
||||
CanSort="False"
|
||||
CanResize="False"
|
||||
HeaderStyle="{StaticResource SelectHeaderStyle}">
|
||||
<tv:TableViewTemplateColumn.Header>
|
||||
<CheckBox
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.Name="{x:Bind Name, Mode=OneTime}"
|
||||
IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{x:Bind Name, Mode=OneTime}" />
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Margin="0,0,12,0"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
ToolTipService.ToolTip="Select or clear every visible row (hidden rows keep their selection)"
|
||||
AutomationProperties.Name="Select all visible repositories"
|
||||
IsChecked="{x:Bind ViewModel.AllSelected, Mode=TwoWay}" />
|
||||
</tv:TableViewTemplateColumn.Header>
|
||||
<tv:TableViewTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:RepoItemViewModel">
|
||||
<CheckBox
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.Name="{x:Bind Name, Mode=OneTime}"
|
||||
IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />
|
||||
</DataTemplate>
|
||||
</tv:TableViewTemplateColumn.CellTemplate>
|
||||
</tv:TableViewTemplateColumn>
|
||||
|
||||
<!-- Name -->
|
||||
<tv:TableViewTextColumn
|
||||
Tag="Name"
|
||||
Width="{StaticResource NameColumnWidth}"
|
||||
MinWidth="140"
|
||||
Binding="{Binding Name, Mode=OneWay}"
|
||||
HeaderStyle="{StaticResource NameHeaderStyle}">
|
||||
<tv:TableViewTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</tv:TableViewTextColumn.ElementStyle>
|
||||
<tv:TableViewTextColumn.Header>
|
||||
<local:ColumnHeaderContent Title="Name">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock VerticalAlignment="Center" FontWeight="SemiBold" Text="Name" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="NameFilterButton"
|
||||
AutomationProperties.Name="{x:Bind TextFilterAutomationName('name', ViewModel.NameFilter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by name">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
Glyph="{x:Bind local:StatusFormat.GlyphFor(Status), Mode=OneWay}"
|
||||
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
|
||||
<TextBlock
|
||||
Text="{x:Bind StatusText, Mode=OneWay}"
|
||||
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
|
||||
Glyph=""
|
||||
Foreground="{x:Bind TextFilterIndicatorBrush(ViewModel.NameFilter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<StackPanel Width="220" Spacing="8">
|
||||
<TextBox
|
||||
Header="Name contains"
|
||||
AutomationProperties.AutomationId="NameFilterBox"
|
||||
Text="{x:Bind ViewModel.NameFilter, Mode=OneWay}"
|
||||
TextChanged="NameFilterBox_TextChanged" />
|
||||
<Button
|
||||
HorizontalAlignment="Right"
|
||||
Content="Clear"
|
||||
Click="ClearNameFilter_Click" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</local:ColumnHeaderContent>
|
||||
</tv:TableViewTextColumn.Header>
|
||||
</tv:TableViewTextColumn>
|
||||
|
||||
<!-- Status: icon + text, plus the row's progress bar while it runs. -->
|
||||
<tv:TableViewTemplateColumn
|
||||
Tag="Status"
|
||||
Width="{StaticResource StatusColumnWidth}"
|
||||
MinWidth="120"
|
||||
CanSort="True"
|
||||
HeaderStyle="{StaticResource StatusHeaderStyle}">
|
||||
<tv:TableViewTemplateColumn.Header>
|
||||
<local:ColumnHeaderContent Title="Status">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock VerticalAlignment="Center" FontWeight="SemiBold" Text="Status" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="StatusFilterButton"
|
||||
AutomationProperties.Name="{x:Bind StatusFilterAutomationName(ViewModel.Filter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by status">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind StatusFilterIndicatorBrush(ViewModel.Filter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<RadioButtons
|
||||
Header="Show"
|
||||
AutomationProperties.AutomationId="StatusFilterChoices"
|
||||
SelectedIndex="{x:Bind StatusFilterIndex(ViewModel.Filter), Mode=OneWay}"
|
||||
SelectionChanged="StatusFilterChoices_SelectionChanged">
|
||||
<x:String>All</x:String>
|
||||
<x:String>Active</x:String>
|
||||
<x:String>Failed</x:String>
|
||||
<x:String>Pending</x:String>
|
||||
</RadioButtons>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</local:ColumnHeaderContent>
|
||||
</tv:TableViewTemplateColumn.Header>
|
||||
<tv:TableViewTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:RepoItemViewModel">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
Glyph="{x:Bind local:StatusFormat.GlyphFor(Status), Mode=OneWay}"
|
||||
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
|
||||
<TextBlock
|
||||
Text="{x:Bind StatusText, Mode=OneWay}"
|
||||
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
<ProgressBar
|
||||
Maximum="1"
|
||||
AutomationProperties.Name="{x:Bind local:StatusFormat.ProgressAutomationName(Name), Mode=OneTime}"
|
||||
Value="{x:Bind ProgressValue, Mode=OneWay}"
|
||||
IsIndeterminate="{x:Bind IsIndeterminate, Mode=OneWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ShowProgress), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
<ProgressBar
|
||||
Maximum="1"
|
||||
Value="{x:Bind ProgressValue, Mode=OneWay}"
|
||||
IsIndeterminate="{x:Bind IsIndeterminate, Mode=OneWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ShowProgress), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</tv:TableViewTemplateColumn.CellTemplate>
|
||||
</tv:TableViewTemplateColumn>
|
||||
|
||||
<!-- Branch -->
|
||||
<tv:TableViewTextColumn
|
||||
Tag="Branch"
|
||||
Width="{StaticResource BranchColumnWidth}"
|
||||
MinWidth="80"
|
||||
Binding="{Binding BranchText, Mode=OneWay}"
|
||||
HeaderStyle="{StaticResource BranchHeaderStyle}">
|
||||
<tv:TableViewTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</Style>
|
||||
</tv:TableViewTextColumn.ElementStyle>
|
||||
<tv:TableViewTextColumn.Header>
|
||||
<local:ColumnHeaderContent Title="Branch">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock VerticalAlignment="Center" FontWeight="SemiBold" Text="Branch" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="BranchFilterButton"
|
||||
AutomationProperties.Name="{x:Bind TextFilterAutomationName('branch', ViewModel.BranchFilter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by branch">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind TextFilterIndicatorBrush(ViewModel.BranchFilter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<StackPanel Width="220" Spacing="8">
|
||||
<TextBox
|
||||
Header="Branch contains"
|
||||
AutomationProperties.AutomationId="BranchFilterBox"
|
||||
Text="{x:Bind ViewModel.BranchFilter, Mode=OneWay}"
|
||||
TextChanged="BranchFilterBox_TextChanged" />
|
||||
<Button
|
||||
HorizontalAlignment="Right"
|
||||
Content="Clear"
|
||||
Click="ClearBranchFilter_Click" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</local:ColumnHeaderContent>
|
||||
</tv:TableViewTextColumn.Header>
|
||||
</tv:TableViewTextColumn>
|
||||
|
||||
<!-- Archived -->
|
||||
<tv:TableViewTemplateColumn
|
||||
Tag="Archived"
|
||||
Width="{StaticResource ArchivedColumnWidth}"
|
||||
MinWidth="80"
|
||||
CanSort="True"
|
||||
HeaderStyle="{StaticResource ArchivedHeaderStyle}">
|
||||
<tv:TableViewTemplateColumn.Header>
|
||||
<local:ColumnHeaderContent Title="Archived">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock VerticalAlignment="Center" FontWeight="SemiBold" Text="Archived" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ColumnFilterButtonStyle}"
|
||||
AutomationProperties.AutomationId="ArchivedFilterButton"
|
||||
AutomationProperties.Name="{x:Bind ArchivedFilterAutomationName(ViewModel.ArchivedFilter), Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Filter by archived">
|
||||
<FontIcon
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Foreground="{x:Bind ArchivedFilterIndicatorBrush(ViewModel.ArchivedFilter), Mode=OneWay}" />
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<RadioButtons
|
||||
Header="Show"
|
||||
AutomationProperties.AutomationId="ArchivedFilterChoices"
|
||||
SelectedIndex="{x:Bind ArchivedFilterIndex(ViewModel.ArchivedFilter), Mode=OneWay}"
|
||||
SelectionChanged="ArchivedFilterChoices_SelectionChanged">
|
||||
<x:String>All</x:String>
|
||||
<x:String>Archived only</x:String>
|
||||
<x:String>Not archived</x:String>
|
||||
</RadioButtons>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</local:ColumnHeaderContent>
|
||||
</tv:TableViewTemplateColumn.Header>
|
||||
<tv:TableViewTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:RepoItemViewModel">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Archived"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(IsArchived), Mode=OneTime}" />
|
||||
</DataTemplate>
|
||||
</tv:TableViewTemplateColumn.CellTemplate>
|
||||
</tv:TableViewTemplateColumn>
|
||||
</tv:TableView.Columns>
|
||||
|
||||
<!-- Row details (always-on mode, collapsed when empty): the failure
|
||||
message, the WSL note, and the Resolve link for Windows-invalid
|
||||
paths - the full-width lines the old row template had under the
|
||||
cells, which a strictly columnar grid cannot host in a cell. -->
|
||||
<tv:TableView.RowDetailsTemplate>
|
||||
<DataTemplate x:DataType="vm:RepoItemViewModel">
|
||||
<StackPanel
|
||||
Padding="52,0,12,6"
|
||||
Spacing="2"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasDetails), Mode=OneWay}">
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="3"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind BranchText, Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="4"
|
||||
VerticalAlignment="Center"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Archived"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(IsArchived), Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="5"
|
||||
TextWrapping="Wrap"
|
||||
IsTextSelectionEnabled="True"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
Text="{x:Bind Error, Mode=OneWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasError), Mode=OneWay}" />
|
||||
<!-- Non-error remark, e.g. where a repository was cloned in WSL (#8). -->
|
||||
<TextBlock
|
||||
IsTextSelectionEnabled="True"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind Note, Mode=OneWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasNote), Mode=OneWay}" />
|
||||
<HyperlinkButton
|
||||
Grid.Row="2"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="5"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Left"
|
||||
AutomationProperties.Name="{x:Bind local:StatusFormat.ResolveAutomationName(Name), Mode=OneTime}"
|
||||
Content="Resolve…"
|
||||
Command="{Binding DataContext.ResolvePathsCommand, ElementName=RepoListView}"
|
||||
Command="{Binding DataContext.ResolvePathsCommand, ElementName=RepoTable}"
|
||||
CommandParameter="{x:Bind}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasPathIssue), Mode=OneWay}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</tv:TableView.RowDetailsTemplate>
|
||||
</local:AccessibleTableView>
|
||||
|
||||
<!-- Shown when the current filter hides everything the table has. -->
|
||||
<TextBlock
|
||||
|
||||
+64
-18
@@ -5,8 +5,10 @@ using System.Threading.Tasks;
|
||||
using gclo.Engine;
|
||||
using gclo.ViewModels;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
using WinUI.TableView;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
@@ -110,31 +112,75 @@ namespace gclo
|
||||
ViewModel.RecoveryInteraction = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort-direction arrow (visual only; the header button's automation name
|
||||
/// carries the state) for the active sort column: chevron up = ascending.
|
||||
/// </summary>
|
||||
public string SortGlyph(string column, string? sortColumn, bool sortDescending)
|
||||
=> column == sortColumn ? (sortDescending ? "" : "") : "";
|
||||
|
||||
/// <summary>Shows the sort arrow only on the column the table is sorted by.</summary>
|
||||
public Visibility SortGlyphVisibility(string column, string? sortColumn)
|
||||
=> column == sortColumn ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
/// <summary>
|
||||
/// Composed automation name for a sortable column header, e.g.
|
||||
/// "Name, sorted ascending" or "Branch, not sorted".
|
||||
/// </summary>
|
||||
public string SortAutomationName(string column, string? sortColumn, bool sortDescending)
|
||||
public static string SortAutomationName(string column, string? sortColumn, bool sortDescending)
|
||||
=> column != sortColumn
|
||||
? $"{column}, not sorted"
|
||||
: sortDescending
|
||||
? $"{column}, sorted descending"
|
||||
: $"{column}, sorted ascending";
|
||||
|
||||
// ---- TableView bridge (#33): the control raises sort requests, the view model
|
||||
// decides. Sorting (first and second click) and ClearSorting (the control's
|
||||
// third-click "unsorted" step) both hand the column to SortCommand, which
|
||||
// cycles ascending <-> descending; the indicator and the header's automation
|
||||
// name are then written back from SortColumn/SortDescending.
|
||||
|
||||
private void RepoTable_Sorting(object sender, TableViewSortingEventArgs e)
|
||||
{
|
||||
e.Handled = true; // never let the control re-sort its own collection view
|
||||
if (e.Column?.Tag is string column)
|
||||
{
|
||||
ViewModel.SortCommand.Execute(column);
|
||||
}
|
||||
}
|
||||
|
||||
private void RepoTable_ClearSorting(object sender, TableViewClearSortingEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
if (e.Column?.Tag is string column)
|
||||
{
|
||||
ViewModel.SortCommand.Execute(column);
|
||||
}
|
||||
}
|
||||
|
||||
private void RepoTable_Loaded(object sender, RoutedEventArgs e) => SyncSortIndicators();
|
||||
|
||||
/// <summary>
|
||||
/// Writes the view model's sort state onto the columns: the header's sort
|
||||
/// indicator and its composed automation name ("Name, sorted ascending").
|
||||
/// Columns whose header control is not realized yet keep the static name from
|
||||
/// their HeaderStyle until the next call.
|
||||
/// </summary>
|
||||
private void SyncSortIndicators()
|
||||
{
|
||||
foreach (TableViewColumn column in RepoTable.Columns)
|
||||
{
|
||||
if (column.Tag is not string key || !column.CanSort)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
bool active = key == ViewModel.SortColumn;
|
||||
column.SortDirection = active
|
||||
? (ViewModel.SortDescending ? SortDirection.Descending : SortDirection.Ascending)
|
||||
: null;
|
||||
if (column.HeaderControl is { } header)
|
||||
{
|
||||
AutomationProperties.SetName(header, SortAutomationName(key, ViewModel.SortColumn, ViewModel.SortDescending));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(WorkspaceViewModel.StatusText))
|
||||
if (e.PropertyName is nameof(WorkspaceViewModel.SortColumn) or nameof(WorkspaceViewModel.SortDescending))
|
||||
{
|
||||
SyncSortIndicators();
|
||||
}
|
||||
else if (e.PropertyName == nameof(WorkspaceViewModel.StatusText))
|
||||
{
|
||||
// LiveSetting alone does not announce: XAML never raises
|
||||
// LiveRegionChanged automatically, so each StatusText update must raise
|
||||
@@ -344,8 +390,8 @@ namespace gclo
|
||||
return;
|
||||
}
|
||||
|
||||
var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(RepoListView)
|
||||
?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(RepoListView);
|
||||
var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(RepoTable)
|
||||
?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(RepoTable);
|
||||
peer?.RaiseNotificationEvent(
|
||||
Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationKind.ActionCompleted,
|
||||
Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationProcessing.ImportantMostRecent,
|
||||
@@ -358,16 +404,16 @@ namespace gclo
|
||||
/// blocked by Windows-invalid paths. Returns the user's recovery choice, or null
|
||||
/// when the dialog was dismissed (or the row carries no path details).
|
||||
/// </summary>
|
||||
private async Task<PathRecovery?> ShowPathRecoveryDialogAsync(RepoItemViewModel item)
|
||||
private async Task<PathRecoveryDecision?> ShowPathRecoveryDialogAsync(RepoItemViewModel item)
|
||||
{
|
||||
if (item.InvalidPaths is not { Count: > 0 } paths)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dialog = new PathRecoveryDialog(item.Name, paths) { XamlRoot = XamlRoot };
|
||||
var dialog = new PathRecoveryDialog(item.Name, paths, ViewModel.IsWslCloneAvailable) { XamlRoot = XamlRoot };
|
||||
await DialogGuard.ShowAsync(dialog);
|
||||
return dialog.Result; // blocked by another open dialog reads as dismissed
|
||||
return dialog.Decision; // blocked by another open dialog reads as dismissed
|
||||
}
|
||||
|
||||
private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
|
||||
<PackageReference Include="Velopack" Version="1.2.161" />
|
||||
<PackageReference Include="WinUI.TableView" Version="1.5.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\gclo.Engine\gclo.Engine.csproj" />
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
"resolved": "1.2.161",
|
||||
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
|
||||
},
|
||||
"WinUI.TableView": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.5.0, )",
|
||||
"resolved": "1.5.0",
|
||||
"contentHash": "YtZtk/JHhL668jfAhjlCm9u8kchajg5FJQVbeYDNKjjl3JqmQbcpCY5McHwaTrlADVwmoDn/x794Px16ZtyRRQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.WindowsAppSDK.WinUI": "1.8.260528001"
|
||||
}
|
||||
},
|
||||
"LibGit2Sharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "0.32.0",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Chocolatey package for the gclo CLI. The version, download URL, and checksum
|
||||
are kept at the latest STABLE release by .github/scripts/Update-PackageManifests.ps1,
|
||||
which the release workflow runs before `choco pack`; the same script keeps
|
||||
bucket/gclo.json (Scoop) in step. -->
|
||||
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>gclo</id>
|
||||
<version>1.0.0</version>
|
||||
<title>gclo (Git Clone Large Organizations)</title>
|
||||
<authors>James Maes</authors>
|
||||
<owners>KofTwentyTwo</owners>
|
||||
<projectUrl>https://github.com/KofTwentyTwo/gclo</projectUrl>
|
||||
<projectSourceUrl>https://github.com/KofTwentyTwo/gclo</projectSourceUrl>
|
||||
<packageSourceUrl>https://github.com/KofTwentyTwo/gclo/tree/main/packaging/chocolatey</packageSourceUrl>
|
||||
<docsUrl>https://github.com/KofTwentyTwo/gclo/blob/main/docs/CLI.md</docsUrl>
|
||||
<bugTrackerUrl>https://github.com/KofTwentyTwo/gclo/issues</bugTrackerUrl>
|
||||
<licenseUrl>https://github.com/KofTwentyTwo/gclo/blob/main/LICENSE</licenseUrl>
|
||||
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
||||
<iconUrl>https://raw.githubusercontent.com/KofTwentyTwo/gclo/main/gclo/Assets/Brand/gclo-icon.svg</iconUrl>
|
||||
<tags>git github clone mirror organization cli</tags>
|
||||
<summary>Clone or update every repository of a GitHub organization in one pass (CLI).</summary>
|
||||
<description><![CDATA[
|
||||
**gclo** (Git Clone Large Organizations) mirrors every repository of a GitHub organization or user account into a local folder in one pass: it clones the repositories that are missing and fast-forwards the ones that already exist, with bounded parallelism and per-repository failure isolation.
|
||||
|
||||
This package installs the self-contained command-line edition (`gclo.exe`, no .NET runtime required).
|
||||
|
||||
- `gclo sync --org <name> --target <folder>` — mirror an organization
|
||||
- `gclo repos --org <name>` — list what a sync would see
|
||||
- `gclo orgs` — list the organizations a token can see
|
||||
- `gclo accounts` — saved profiles with tokens in Windows Credential Manager
|
||||
- `--json` / `--json-lines` for scripts; exit codes distinguish auth, transient, and usage errors
|
||||
|
||||
A GitHub personal access token is required: set `GITHUB_TOKEN`, or use `--token-env`, `--token-file`, or `--token-stdin` (there is deliberately no `--token <value>` option). Full reference: https://github.com/KofTwentyTwo/gclo/blob/main/docs/CLI.md
|
||||
|
||||
The desktop app (WinUI 3) is a separate download from the GitHub Releases page.
|
||||
]]></description>
|
||||
<releaseNotes>https://github.com/KofTwentyTwo/gclo/releases/tag/v1.0.0</releaseNotes>
|
||||
</metadata>
|
||||
<files>
|
||||
<file src="tools\**" target="tools" />
|
||||
</files>
|
||||
</package>
|
||||
@@ -0,0 +1,16 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Installs the self-contained gclo CLI zip into the package's tools folder;
|
||||
# Chocolatey shims gclo.exe onto PATH automatically. The URL and checksum are
|
||||
# updated per stable release by .github/scripts/Update-PackageManifests.ps1.
|
||||
$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
$packageArgs = @{
|
||||
packageName = 'gclo'
|
||||
unzipLocation = $toolsDir
|
||||
url64bit = 'https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-cli-win-x64.zip'
|
||||
checksum64 = '42805ca95a4f4d306df04f47a2b9aba54eee3df11cda8e3f201fe5a58311feee'
|
||||
checksumType64 = 'sha256'
|
||||
}
|
||||
|
||||
Install-ChocolateyZipPackage @packageArgs
|
||||
Reference in new issue
Block a user