Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
198a56213b | ||
|
|
d77049eb3c | ||
|
|
dda0a22287 | ||
|
|
f4014ff5c9 | ||
|
|
472201f0cf | ||
|
|
b9c0faa41c | ||
|
|
935430c84a |
No files matched your search
@@ -153,7 +153,10 @@ jobs:
|
||||
with:
|
||||
solution: gclo.slnx
|
||||
app-project: gclo/gclo.csproj
|
||||
app-id: gclo
|
||||
# Prereleases pack as their own Velopack app (gclo-dev): separate install
|
||||
# folder, Start menu entry, update feed and data root, so a dev build can be
|
||||
# installed beside the stable one (#114). Asset names follow the app id.
|
||||
app-id: ${{ contains(github.ref_name, '-') && 'gclo-dev' || 'gclo' }}
|
||||
main-exe: gclo.exe
|
||||
icon: gclo/Assets/Brand/gclo.ico
|
||||
platform: x64
|
||||
@@ -184,12 +187,16 @@ jobs:
|
||||
needs: release
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
# TRUSTED_SIGNING_SUBJECT is a `release`-environment variable, so the job must
|
||||
# run in that environment to read it (v1.0.2-beta.3 failed here with it unset).
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
K22_VERSION: ${{ needs.release.outputs.version }}
|
||||
K22_CHANNEL: ${{ needs.release.outputs.prerelease == 'true' && 'dev' || 'stable' }}
|
||||
K22_APP_ID: ${{ needs.release.outputs.prerelease == 'true' && 'gclo-dev' || 'gclo' }}
|
||||
EXPECTED_SUBJECT: ${{ vars.TRUSTED_SIGNING_SUBJECT }}
|
||||
steps:
|
||||
- name: Download the published assets
|
||||
@@ -199,10 +206,10 @@ jobs:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force assets | Out-Null
|
||||
gh release download $env:TAG --dir assets --pattern "gclo-$env:K22_CHANNEL-Setup.exe" --pattern "gclo-$env:K22_CHANNEL-Portable.zip" --pattern "gclo-cli-win-x64-$env:K22_VERSION.zip"
|
||||
gh release download $env:TAG --dir assets --pattern "$env:K22_APP_ID-$env:K22_CHANNEL-Setup.exe" --pattern "$env:K22_APP_ID-$env:K22_CHANNEL-Portable.zip" --pattern "$env:K22_APP_ID-cli-win-x64-$env:K22_VERSION.zip"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Expand-Archive assets/gclo-$env:K22_CHANNEL-Portable.zip -DestinationPath assets/portable
|
||||
Expand-Archive assets/gclo-cli-win-x64-$env:K22_VERSION.zip -DestinationPath assets/cli
|
||||
Expand-Archive assets/$env:K22_APP_ID-$env:K22_CHANNEL-Portable.zip -DestinationPath assets/portable
|
||||
Expand-Archive assets/$env:K22_APP_ID-cli-win-x64-$env:K22_VERSION.zip -DestinationPath assets/cli
|
||||
|
||||
- name: signtool verify /pa /all on every shipped executable
|
||||
shell: pwsh
|
||||
@@ -214,7 +221,7 @@ jobs:
|
||||
}
|
||||
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
|
||||
Where-Object { $_.FullName -match '\\x64\\' } | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
$files = @(Get-Item assets/gclo-$env:K22_CHANNEL-Setup.exe) + @(Get-ChildItem assets/portable, assets/cli -Recurse -Include *.exe, *.dll |
|
||||
$files = @(Get-Item assets/$env:K22_APP_ID-$env:K22_CHANNEL-Setup.exe) + @(Get-ChildItem assets/portable, assets/cli -Recurse -Include *.exe, *.dll |
|
||||
Where-Object { $_.Name -like 'gclo*' -or $_.Name -in 'Update.exe' })
|
||||
$failed = 0
|
||||
foreach ($f in $files)
|
||||
|
||||
@@ -23,6 +23,24 @@ Any prerelease identifier works (`-alpha`, `-beta.2`, `-rc.1`, ...) — the rule
|
||||
is simply: **a `-` in the version means prerelease, which means the `dev`
|
||||
channel**.
|
||||
|
||||
### The dev edition is a separate app
|
||||
|
||||
A prerelease is packed as its own Velopack app, id **`gclo-dev`**, while stable
|
||||
releases use **`gclo`**. The two install side by side: separate install folder
|
||||
(`%LOCALAPPDATA%\gclo-dev` vs `%LOCALAPPDATA%\gclo`), Start menu entry, update
|
||||
feed and data root (`%LOCALAPPDATA%\KofTwentyTwo\gclo-dev` vs `…\gclo`), and
|
||||
the dev edition's window title reads "gclo (dev)". A machine can therefore keep
|
||||
the stable app for real work and a dev build for trying the next release; each
|
||||
only ever updates within its own channel. The dev edition's assets follow its
|
||||
app id: `gclo-dev-dev-Setup.exe`, `gclo-dev-dev-Portable.zip`,
|
||||
`gclo-dev-1.2.3-beta.1-dev-full.nupkg`, `gclo-dev-cli-win-x64-<version>.zip`,
|
||||
`releases.dev.json`. Both editions read the same Windows Credential Manager
|
||||
entries (the default token saved in one is available in the other; account
|
||||
tokens are keyed by account id, and each edition has its own accounts), so a
|
||||
dev build needs no token re-entry. Dev installs made before this split (app id
|
||||
`gclo` on the dev channel, up to 1.0.2-beta.3) cannot update into `gclo-dev`:
|
||||
uninstall them, or install the stable app over them.
|
||||
|
||||
### How self-update channels map
|
||||
|
||||
The desktop app updates itself with [Velopack](https://velopack.io). Each
|
||||
|
||||
@@ -46,9 +46,12 @@
|
||||
| User cleared the box, or pressed Remove | empty | "The default token will be removed on Save. *N* account(s) use it and will stop syncing until a new one is saved." (plain "…will be removed on Save." when N = 0) | hidden |
|
||||
|
||||
Save semantics: mask unchanged → keep; non-empty and not the mask → store; empty
|
||||
→ remove (only if one was saved). Removing while `Default` accounts exist shows a
|
||||
confirmation ("Remove the default token? These accounts use it: A, B.") before the
|
||||
dialog closes; Cancel returns to the dialog with the mask restored.
|
||||
→ remove (only if one was saved). Removing while `Default` accounts exist is
|
||||
confirmed **inline** rather than with a second dialog (a `ContentDialog` cannot
|
||||
open another one, and the dialog guard allows one at a time): the caption turns
|
||||
critical, names the accounts ("2 accounts use it and will stop syncing until a new
|
||||
one is saved: A, B."), and Save is the confirmation; typing a token or Cancel
|
||||
backs out.
|
||||
|
||||
Accessibility: the box keeps its automation id `SettingsDefaultTokenBox`; the
|
||||
mask is announced as the field's value by the password pattern (dots), the
|
||||
|
||||
@@ -566,6 +566,24 @@ public sealed class AccountWizardViewModelTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task EditWizard_OrganizationSurvivesTheStepTwoListRefresh()
|
||||
{
|
||||
// The editable ComboBox on step 3 writes "" into Organization when its
|
||||
// ItemsSource is replaced; the view model must restore the seeded value.
|
||||
Account existing = MakeAccount("Work") with { Organization = "KofTwentyTwo" };
|
||||
_store.Save(existing, "ghp_own");
|
||||
AccountWizardViewModel wizard = NewWizard(existing, "ghp_own");
|
||||
wizard.Organizations.CollectionChanged += (_, _) => wizard.Organization = "";
|
||||
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["KofTwentyTwo", "other"]);
|
||||
|
||||
await AdvanceToStepAsync(wizard, 3);
|
||||
|
||||
Assert.Equal("KofTwentyTwo", wizard.Organization);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DefaultTokenCaption_CountsOtherAccounts()
|
||||
{
|
||||
|
||||
@@ -30,6 +30,21 @@ public sealed class GcloPathsTests
|
||||
|
||||
|
||||
|
||||
/// <summary>The dev edition keeps its own data root beside the stable one (#114).</summary>
|
||||
[Fact]
|
||||
public void DataFolderNameFor_DevEdition_UsesItsOwnFolder()
|
||||
{
|
||||
// Edition itself is process-wide state set once by the app's entry point; the
|
||||
// tests exercise the pure mapping so they cannot race the vault's scoping.
|
||||
Assert.Equal("gclo", GcloPaths.DataFolderNameFor(""));
|
||||
Assert.Equal("gclo-dev", GcloPaths.DataFolderNameFor("dev"));
|
||||
Assert.Throws<ArgumentNullException>(() => GcloPaths.DataFolderNameFor(null!));
|
||||
Assert.Equal("", GcloPaths.Edition);
|
||||
Assert.Equal("gclo", GcloPaths.DataFolderName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The default root is never the install root nor anything under it.</summary>
|
||||
[Fact]
|
||||
public void DefaultDataRoot_IsNotTheVelopackInstallRoot()
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SettingsTokenState"/>: the mask stands for a saved token, Save's action
|
||||
/// follows from what the box holds, and the captions name the consequence (#101).
|
||||
/// </summary>
|
||||
public sealed class SettingsTokenStateTests
|
||||
{
|
||||
private static SettingsTokenState Saved(params string[] dependents) => new(hasSavedToken: true, dependents);
|
||||
|
||||
private static SettingsTokenState Empty() => new(hasSavedToken: false, []);
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void NullDependents_Throws()
|
||||
=> Assert.Throws<ArgumentNullException>(() => new SettingsTokenState(true, null!));
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void InitialBoxValue_IsTheMaskOnlyWhenATokenIsSaved()
|
||||
{
|
||||
Assert.Equal(SettingsTokenState.Mask, Saved().InitialBoxValue);
|
||||
Assert.Equal("", Empty().InitialBoxValue);
|
||||
Assert.DoesNotContain("ghp", SettingsTokenState.Mask);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, SettingsTokenState.Mask, SettingsTokenAction.Keep)]
|
||||
[InlineData(true, "ghp_new", SettingsTokenAction.Store)]
|
||||
[InlineData(true, "", SettingsTokenAction.Remove)]
|
||||
[InlineData(false, "", SettingsTokenAction.Keep)]
|
||||
[InlineData(false, "ghp_new", SettingsTokenAction.Store)]
|
||||
[InlineData(false, SettingsTokenState.Mask, SettingsTokenAction.Store)]
|
||||
public void Plan_FollowsFromTheBox(bool hasSaved, string box, SettingsTokenAction expected)
|
||||
{
|
||||
var state = new SettingsTokenState(hasSaved, []);
|
||||
|
||||
Assert.Equal(expected, state.Plan(box));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Plan_NullBox_Throws()
|
||||
=> Assert.Throws<ArgumentNullException>(() => Saved().Plan(null!));
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void CanOfferRemoval_OnlyWhileTheMaskIsUntouched()
|
||||
{
|
||||
Assert.True(Saved().CanOfferRemoval(SettingsTokenState.Mask));
|
||||
Assert.False(Saved().CanOfferRemoval("ghp_new"));
|
||||
Assert.False(Saved().CanOfferRemoval(""));
|
||||
Assert.False(Empty().CanOfferRemoval(SettingsTokenState.Mask));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Caption_SavedAndUntouched_SaysWhoUsesIt()
|
||||
{
|
||||
Assert.Equal(
|
||||
"A default token is saved and used by Quick Sync. Type to replace it; clear the box to remove it.",
|
||||
Saved().Caption(SettingsTokenState.Mask));
|
||||
Assert.Contains("Quick Sync and 1 account.", Saved("Work").Caption(SettingsTokenState.Mask));
|
||||
Assert.Contains("Quick Sync and 2 accounts.", Saved("Work", "Home").Caption(SettingsTokenState.Mask));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Caption_NothingSaved_SaysSo()
|
||||
{
|
||||
Assert.StartsWith("No default token saved.", Empty().Caption(""), StringComparison.Ordinal);
|
||||
Assert.StartsWith("The default token will be saved on Save", Empty().Caption("ghp_new"), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Caption_Replacing_MentionsTheAccountsThatRotateWithIt()
|
||||
{
|
||||
Assert.Equal("The default token will be replaced on Save.", Saved().Caption("ghp_new"));
|
||||
Assert.Contains("The 1 account that uses it switches", Saved("Work").Caption("ghp_new"));
|
||||
Assert.Contains("The 2 accounts that use it switch", Saved("Work", "Home").Caption("ghp_new"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Caption_Removing_WarnsAndNamesTheDependentAccounts()
|
||||
{
|
||||
Assert.Equal("The default token will be removed on Save.", Saved().Caption(""));
|
||||
Assert.False(Saved().RemovalBreaksAccounts(""));
|
||||
|
||||
string one = Saved("Work").Caption("");
|
||||
Assert.Contains("1 account uses it and will stop syncing", one);
|
||||
Assert.Contains("Work", one);
|
||||
Assert.True(Saved("Work").RemovalBreaksAccounts(""));
|
||||
|
||||
string two = Saved("Work", "Home").Caption("");
|
||||
Assert.Contains("2 accounts use it and will stop syncing", two);
|
||||
Assert.Contains("Work, Home", two);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void RemovalBreaksAccounts_IsFalseUnlessSaveWouldRemove()
|
||||
{
|
||||
Assert.False(Saved("Work").RemovalBreaksAccounts(SettingsTokenState.Mask));
|
||||
Assert.False(Saved("Work").RemovalBreaksAccounts("ghp_new"));
|
||||
Assert.False(Empty().RemovalBreaksAccounts(""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Captions_NeverContainTheMaskOrATokenValue()
|
||||
{
|
||||
foreach(string box in new[] { SettingsTokenState.Mask, "ghp_secret", "" })
|
||||
{
|
||||
string caption = Saved("Work").Caption(box);
|
||||
Assert.DoesNotContain("ghp_secret", caption);
|
||||
Assert.DoesNotContain(SettingsTokenState.Mask, caption);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1823,6 +1823,66 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- shown: saved accounts load themselves
|
||||
|
||||
[Fact]
|
||||
public async Task LoadRepositoriesIfSaved_Account_LoadsOnce_WithTheResolvedToken()
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Work",
|
||||
Organization = "acme",
|
||||
TargetRoot = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")),
|
||||
};
|
||||
var vault = new InMemoryVault();
|
||||
vault.Store(account.Id, "vault-token-1234567890");
|
||||
_lister.Repositories = [new RepoDescriptor("alpha", "https://example.test/acme/alpha.git", "main", false)];
|
||||
WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault);
|
||||
|
||||
Assert.True(vm.LoadRepositoriesIfSaved());
|
||||
await WaitUntilAsync(() => vm.HasLoadedRepos, "repositories to load");
|
||||
|
||||
Assert.Contains(_lister.Calls, c =>
|
||||
string.Equals(c.Organization, "acme", StringComparison.Ordinal)
|
||||
&& string.Equals(c.Token, "vault-token-1234567890", StringComparison.Ordinal));
|
||||
Assert.False(vm.LoadRepositoriesIfSaved()); // already loaded: showing it again is a no-op
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void LoadRepositoriesIfSaved_QuickSync_DoesNothing()
|
||||
{
|
||||
WorkspaceViewModel vm = CreateViewModel();
|
||||
vm.Token = "typed-token-1234567890";
|
||||
vm.Organization = "acme";
|
||||
|
||||
Assert.False(vm.LoadRepositoriesIfSaved());
|
||||
Assert.Empty(_lister.Calls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void LoadRepositoriesIfSaved_AccountWithoutAToken_LeavesTheCardUp()
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Shared",
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos\shared",
|
||||
TokenSource = TokenSource.Default,
|
||||
};
|
||||
WorkspaceViewModel vm = CreateViewModel(account: account, vault: new InMemoryVault());
|
||||
|
||||
Assert.False(vm.LoadRepositoriesIfSaved());
|
||||
Assert.Empty(_lister.Calls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountConstruction_DefaultTokenAccount_WithoutADefaultToken_StartsEmpty()
|
||||
{
|
||||
|
||||
@@ -363,11 +363,18 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> organizations = await _orgLister.ListOrganizationsAsync(candidate);
|
||||
// An editable ComboBox resets its text when its ItemsSource is
|
||||
// mutated, and the TwoWay binding writes that empty text back here,
|
||||
// wiping the organization an edit (or a seed) started with. Capture,
|
||||
// restore, and re-raise so step 3 shows what the account has.
|
||||
string organizationBeforeRefresh = Organization;
|
||||
Organizations.Clear();
|
||||
foreach(string organization in organizations)
|
||||
{
|
||||
Organizations.Add(organization);
|
||||
}
|
||||
Organization = organizationBeforeRefresh;
|
||||
OnPropertyChanged(nameof(Organization));
|
||||
TokenError = "";
|
||||
_log.Info($"Account wizard: token accepted; {organizations.Count} organizations and accounts visible.");
|
||||
}
|
||||
|
||||
@@ -34,11 +34,36 @@ public static class GcloPaths
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// %LOCALAPPDATA%\KofTwentyTwo\gclo: the root used when GCLO_DATA_DIR is not
|
||||
/// set. Outside the Velopack install root on purpose (see the type remarks).
|
||||
/// The installed edition: "" for the stable app (Velopack app id <c>gclo</c>) and
|
||||
/// "dev" for the dev-channel app (<c>gclo-dev</c>), which installs beside the
|
||||
/// stable one and keeps its own data root so both can run on one machine (#114).
|
||||
/// Set once by the app's entry point before anything reads <see cref="DataRoot"/>;
|
||||
/// local and CLI runs leave it empty.
|
||||
/// </summary>
|
||||
public static string Edition { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
/// <summary>The folder name under %LOCALAPPDATA%\KofTwentyTwo: "gclo", or "gclo-dev" for the dev edition.</summary>
|
||||
public static string DataFolderName => DataFolderNameFor(Edition);
|
||||
|
||||
|
||||
|
||||
/// <summary>"gclo" for the stable edition (empty <paramref name="edition"/>), "gclo-<edition>" otherwise.</summary>
|
||||
public static string DataFolderNameFor(string edition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edition);
|
||||
return edition.Length == 0 ? "gclo" : $"gclo-{edition}";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// %LOCALAPPDATA%\KofTwentyTwo\gclo (or gclo-dev): the root used when GCLO_DATA_DIR
|
||||
/// is not set. Outside the Velopack install root on purpose (see the type remarks).
|
||||
/// </summary>
|
||||
public static string DefaultDataRoot =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"KofTwentyTwo", "gclo");
|
||||
"KofTwentyTwo", DataFolderName);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>What Save does with the default-token box (<see cref="SettingsTokenState.Plan"/>).</summary>
|
||||
public enum SettingsTokenAction
|
||||
{
|
||||
/// <summary>Leave the vault as it is: the mask is untouched, or nothing was saved and nothing typed.</summary>
|
||||
Keep = 0,
|
||||
|
||||
/// <summary>Store what was typed as the default token (replacing a saved one).</summary>
|
||||
Store = 1,
|
||||
|
||||
/// <summary>Delete the saved default token: the box was cleared or Remove was pressed.</summary>
|
||||
Remove = 2,
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The Settings dialog's default-token box, as state the dialog only renders: what
|
||||
/// the box shows, what the caption says, and what Save does with what the user left
|
||||
/// in the box (#101). A saved token is represented by a fixed <see cref="Mask"/>,
|
||||
/// never by the token itself, so the box reads as "something is saved" and a reveal
|
||||
/// button can only ever reveal the mask or what the user typed this session.
|
||||
/// </summary>
|
||||
public sealed class SettingsTokenState
|
||||
{
|
||||
/// <summary>What the box shows while a saved token is untouched. A constant, not the token.</summary>
|
||||
public const string Mask = "••••••••••••••••";
|
||||
|
||||
private readonly IReadOnlyList<string> _dependentAccounts;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="hasSavedToken"/>: whether the vault holds a default token;
|
||||
/// <paramref name="dependentAccounts"/>: the names of the accounts that resolve
|
||||
/// to it (<see cref="TokenSource.Default"/>), which a removal would break.
|
||||
/// </summary>
|
||||
public SettingsTokenState(bool hasSavedToken, IReadOnlyList<string> dependentAccounts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dependentAccounts);
|
||||
HasSavedToken = hasSavedToken;
|
||||
_dependentAccounts = dependentAccounts;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the vault held a default token when the dialog opened.</summary>
|
||||
public bool HasSavedToken { get; }
|
||||
|
||||
/// <summary>How many accounts resolve to the default token.</summary>
|
||||
public int DependentCount => _dependentAccounts.Count;
|
||||
|
||||
/// <summary>The initial box value: the mask when a token is saved, empty otherwise.</summary>
|
||||
public string InitialBoxValue => HasSavedToken ? Mask : "";
|
||||
|
||||
/// <summary>True when "Remove saved token" applies: a token is saved and the box still shows the mask.</summary>
|
||||
public bool CanOfferRemoval(string boxValue) => HasSavedToken && string.Equals(boxValue, Mask, StringComparison.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>What Save will do with <paramref name="boxValue"/>, the box's current content.</summary>
|
||||
public SettingsTokenAction Plan(string boxValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(boxValue);
|
||||
if(string.Equals(boxValue, Mask, StringComparison.Ordinal))
|
||||
{
|
||||
return HasSavedToken ? SettingsTokenAction.Keep : SettingsTokenAction.Store;
|
||||
}
|
||||
if(boxValue.Length > 0)
|
||||
{
|
||||
return SettingsTokenAction.Store;
|
||||
}
|
||||
return HasSavedToken ? SettingsTokenAction.Remove : SettingsTokenAction.Keep;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The caption under the box for <paramref name="boxValue"/>; names what Save will do.</summary>
|
||||
public string Caption(string boxValue)
|
||||
{
|
||||
switch(Plan(boxValue))
|
||||
{
|
||||
case SettingsTokenAction.Store:
|
||||
return HasSavedToken
|
||||
? "The default token will be replaced on Save." + RotationNote()
|
||||
: "The default token will be saved on Save; Quick Sync uses it on every launch.";
|
||||
case SettingsTokenAction.Remove:
|
||||
return "The default token will be removed on Save." + RemovalWarning();
|
||||
default:
|
||||
return HasSavedToken
|
||||
? "A default token is saved" + UsedBy() + ". Type to replace it; clear the box to remove it."
|
||||
: "No default token saved. Quick Sync will ask for a token each launch.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when Save would remove a token that accounts depend on; the caption then carries the warning.</summary>
|
||||
public bool RemovalBreaksAccounts(string boxValue)
|
||||
=> Plan(boxValue) == SettingsTokenAction.Remove && DependentCount > 0;
|
||||
|
||||
|
||||
|
||||
private string UsedBy() => DependentCount switch
|
||||
{
|
||||
0 => " and used by Quick Sync",
|
||||
1 => " and used by Quick Sync and 1 account",
|
||||
_ => $" and used by Quick Sync and {DependentCount} accounts",
|
||||
};
|
||||
|
||||
|
||||
|
||||
private string RotationNote() => DependentCount switch
|
||||
{
|
||||
0 => "",
|
||||
1 => " The 1 account that uses it switches to the new token too.",
|
||||
_ => $" The {DependentCount} accounts that use it switch to the new token too.",
|
||||
};
|
||||
|
||||
|
||||
|
||||
private string RemovalWarning()
|
||||
{
|
||||
if(DependentCount == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
string names = string.Join(", ", _dependentAccounts);
|
||||
return DependentCount == 1
|
||||
? $" 1 account uses it and will stop syncing until a new one is saved: {names}."
|
||||
: $" {DependentCount} accounts use it and will stop syncing until a new one is saved: {names}.";
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,26 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Called by the shell when the workspace is shown. A saved account knows its
|
||||
/// organization, folder and token, so it loads its repositories straight away
|
||||
/// instead of landing on the connect card; Quick Sync, a workspace that already
|
||||
/// loaded, or an account whose token is missing (the card shows why) do nothing.
|
||||
/// Returns true when a load was started.
|
||||
/// </summary>
|
||||
public bool LoadRepositoriesIfSaved()
|
||||
{
|
||||
if(_account is null || HasLoadedRepos || !LoadReposCommand.CanExecute(null))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_log.Info($"Opening account '{DisplayName}': loading its repositories.");
|
||||
_ = LoadReposCommand.ExecuteAsync(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public ObservableCollection<RepoItemViewModel> Repos { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Step 3 of 4: Organization" />
|
||||
<ComboBox
|
||||
x:Name="OrgBox"
|
||||
AutomationProperties.AutomationId="WizardOrgBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
Header="Organization or account"
|
||||
|
||||
@@ -63,6 +63,27 @@ public sealed partial class AccountWizardDialog : ContentDialog
|
||||
|
||||
// NumberBox.Value is a double; the int is mirrored by hand (see ValueChanged).
|
||||
ConcurrencyBox.Value = ViewModel.MaxConcurrency;
|
||||
|
||||
// Step 3's panel is collapsed while step 2's validation replaces the
|
||||
// organization list, so the editable ComboBox shows up blank even though the
|
||||
// view model still holds Organization (an edit seeds it): put the text back
|
||||
// after the panel has laid out, every time the wizard arrives on step 3.
|
||||
ViewModel.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if(string.Equals(e.PropertyName, nameof(AccountWizardViewModel.Step), StringComparison.Ordinal) && ViewModel.Step == 3)
|
||||
{
|
||||
// The panel was collapsed, so the ComboBox has no template (and no inner
|
||||
// TextBox) until it is measured: reapply on its first size change, and
|
||||
// once more after the dispatcher drains, whichever comes first.
|
||||
void OnSized(object sender, SizeChangedEventArgs args)
|
||||
{
|
||||
OrgBox.SizeChanged -= OnSized;
|
||||
EditableComboBox.ReapplyText(OrgBox, ViewModel.Organization);
|
||||
}
|
||||
OrgBox.SizeChanged += OnSized;
|
||||
DispatcherQueue.TryEnqueue(() => EditableComboBox.ReapplyText(OrgBox, ViewModel.Organization));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +203,23 @@ public sealed partial class AccountWizardDialog : ContentDialog
|
||||
// when editing an account, step 3 would show blank despite the seeded
|
||||
// organization — see EditableComboBox.
|
||||
private void OrgBox_Loaded(object sender, RoutedEventArgs e)
|
||||
=> EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization);
|
||||
{
|
||||
var combo = (ComboBox)sender;
|
||||
EditableComboBox.ReapplyText(combo, ViewModel.Organization);
|
||||
// Step 2's validation replaces the organization list, which resets the
|
||||
// editable ComboBox's text; the view model keeps Organization, so put it
|
||||
// back whenever the list changes (an edit otherwise shows step 3 blank).
|
||||
if(!_organizationsHooked)
|
||||
{
|
||||
_organizationsHooked = true;
|
||||
ViewModel.Organizations.CollectionChanged += (_, _) => EditableComboBox.ReapplyText(combo, ViewModel.Organization);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Whether <see cref="OrgBox_Loaded"/> already follows the organization list.</summary>
|
||||
private bool _organizationsHooked;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
AutomationProperties.AutomationId="{x:Bind Id('OrgBox')}"
|
||||
HorizontalAlignment="Stretch"
|
||||
Header="Organization or account"
|
||||
x:Name="OrgBox"
|
||||
ToolTipService.ToolTip="The GitHub organization or user account whose repositories are synced. Populated from the token; typing a name also works."
|
||||
IsEditable="True"
|
||||
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
|
||||
|
||||
@@ -79,28 +79,35 @@ public sealed partial class ConnectionFormControl : UserControl
|
||||
|
||||
|
||||
// Runs on every host load (the card once, the flyout on each open). A token the
|
||||
// view model already holds (an account's vault token, or the saved default
|
||||
// token) is deliberately NOT mirrored into the box: the placeholder says what
|
||||
// is in effect, and an untouched box keeps it. Only typing replaces (#32).
|
||||
// view model already holds (an account's token, or the saved default token) is
|
||||
// deliberately NOT mirrored into the box: the box shows the same fixed mask as
|
||||
// Settings, so it reads as "a token is in effect" (an empty box read as "no
|
||||
// token", #101), and an untouched mask keeps it. Only typing replaces (#32).
|
||||
private void OnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
TokenBox.Password = "";
|
||||
TokenBox.PlaceholderText = ViewModel is null || ViewModel.Token.Length == 0
|
||||
? "ghp_…"
|
||||
: ViewModel.AccountId is null
|
||||
? "Using the saved default token — type here to replace it"
|
||||
: "Using the account's stored token — type here to replace it";
|
||||
bool hasToken = ViewModel is not null && ViewModel.Token.Length > 0;
|
||||
TokenBox.Password = hasToken ? SettingsTokenState.Mask : "";
|
||||
TokenBox.PlaceholderText = "ghp_…";
|
||||
// The organization placeholder must not ask for a token the box already has.
|
||||
OrgBox.PlaceholderText = hasToken ? "Choose an organization, or type one" : "Paste a token first, or type an org";
|
||||
ToolTipService.SetToolTip(TokenBox, !hasToken
|
||||
? "Fine-grained or classic PAT with repository read access; used for the GitHub API and git over HTTPS. Never written to disk or logs."
|
||||
: ViewModel!.AccountId is null
|
||||
? "Using the saved default token from Settings. Type here to use a different one for this session."
|
||||
: "Using this account's token. Type here to use a different one for this session.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// PasswordBox does not support reliable two-way x:Bind on Password; mirror it
|
||||
// into the view model by hand.
|
||||
// into the view model by hand. The mask is not a token: while the box shows it,
|
||||
// the view model keeps the token it already holds.
|
||||
private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(ViewModel is not null)
|
||||
string typed = ((PasswordBox)sender).Password;
|
||||
if(ViewModel is not null && !string.Equals(typed, SettingsTokenState.Mask, StringComparison.Ordinal))
|
||||
{
|
||||
ViewModel.Token = ((PasswordBox)sender).Password;
|
||||
ViewModel.Token = typed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,11 +121,26 @@ public sealed partial class ConnectionFormControl : UserControl
|
||||
if(ViewModel is not null)
|
||||
{
|
||||
EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization);
|
||||
// The organization lookup replaces the list about a second after an
|
||||
// account workspace opens, and an editable ComboBox drops its text when
|
||||
// its ItemsSource changes; the view model restores Organization, so put
|
||||
// it back into the box each time the list changes.
|
||||
if(!ReferenceEquals(_organizationsHooked, ViewModel))
|
||||
{
|
||||
_organizationsHooked = ViewModel;
|
||||
ViewModel.Organizations.CollectionChanged += (_, _) =>
|
||||
EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The view model whose organization list this control already follows.</summary>
|
||||
private WorkspaceViewModel? _organizationsHooked;
|
||||
|
||||
|
||||
|
||||
private async void BrowseButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(ViewModel is null || WindowHandleProvider is not { } handle)
|
||||
|
||||
@@ -27,20 +27,26 @@ internal static class EditableComboBox
|
||||
/// </summary>
|
||||
public static void ReapplyText(ComboBox combo, string text)
|
||||
{
|
||||
// When the value is one of the items, selecting it is the clean path: the
|
||||
// combo renders it like a user's pick, with no placeholder or dropdown side
|
||||
// effects (writing text into a focused editable combo opens its list, and
|
||||
// closing that list throws the text away). Free text still goes to the box.
|
||||
if(SelectItem(combo, text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(FindInnerTextBox(combo) is TextBox inner)
|
||||
{
|
||||
inner.Text = text;
|
||||
inner.SelectionStart = text.Length;
|
||||
Apply(inner, text);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Template not applied yet: try once more after this layout pass.
|
||||
combo.DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if(FindInnerTextBox(combo) is TextBox late)
|
||||
if(!SelectItem(combo, text) && FindInnerTextBox(combo) is TextBox late)
|
||||
{
|
||||
late.Text = text;
|
||||
late.SelectionStart = text.Length;
|
||||
Apply(late, text);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -48,6 +54,38 @@ internal static class EditableComboBox
|
||||
|
||||
|
||||
|
||||
private static bool SelectItem(ComboBox combo, string text)
|
||||
{
|
||||
foreach(object item in combo.Items)
|
||||
{
|
||||
if(item is string candidate && string.Equals(candidate, text, System.StringComparison.Ordinal))
|
||||
{
|
||||
combo.SelectedItem = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes the text and re-evaluates the placeholder: a TextBox whose Text is set
|
||||
/// from code while it was collapsed keeps painting its placeholder over the text
|
||||
/// (the wizard's step 3 box showed "Choose from the list" over a filled value);
|
||||
/// touching PlaceholderText makes the control recompute that visual state.
|
||||
/// </summary>
|
||||
private static void Apply(TextBox box, string text)
|
||||
{
|
||||
box.Text = text;
|
||||
box.SelectionStart = text.Length;
|
||||
string placeholder = box.PlaceholderText;
|
||||
box.PlaceholderText = "";
|
||||
box.PlaceholderText = placeholder;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static TextBox? FindInnerTextBox(DependencyObject root)
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(root);
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
<MenuFlyoutItem Text="Activity log…" AccessKey="A" AutomationProperties.AutomationId="MenuActivityLog" Click="ActivityLogMenuItem_Click" />
|
||||
</MenuBarItem>
|
||||
<MenuBarItem Title="Help" AccessKey="H">
|
||||
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" Click="GitHubMenuItem_Click" />
|
||||
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" AutomationProperties.AutomationId="MenuGitHub" Click="GitHubMenuItem_Click" />
|
||||
<MenuFlyoutItem Text="Submit an issue…" AccessKey="I" AutomationProperties.AutomationId="MenuSubmitIssue" ToolTipService.ToolTip="Report a bug or request a feature on GitHub" Click="SubmitIssueMenuItem_Click" />
|
||||
<MenuFlyoutItem Text="Community support…" AccessKey="S" AutomationProperties.AutomationId="MenuCommunitySupport" ToolTipService.ToolTip="Ask a question or share tips in GitHub Discussions" Click="CommunitySupportMenuItem_Click" />
|
||||
<MenuFlyoutSeparator />
|
||||
<MenuFlyoutItem x:Name="CheckUpdatesMenuItem" Text="Check for updates…" AccessKey="C" AutomationProperties.AutomationId="MenuCheckUpdates" Click="CheckForUpdatesMenuItem_Click" />
|
||||
<MenuFlyoutSeparator />
|
||||
<MenuFlyoutItem Text="About gclo…" AccessKey="A" AutomationProperties.AutomationId="MenuAbout" Click="AboutMenuItem_Click">
|
||||
|
||||
+62
-9
@@ -43,6 +43,11 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
private const int MinWindowHeight = 520;
|
||||
|
||||
/// <summary>Largest default window (logical pixels); the user can still resize beyond it.</summary>
|
||||
private const int MaxDefaultWindowWidth = 1600;
|
||||
|
||||
private const int MaxDefaultWindowHeight = 1100;
|
||||
|
||||
/// <summary>Set once the root's theme-change subscription exists.</summary>
|
||||
private bool _themeHooked;
|
||||
|
||||
@@ -116,7 +121,10 @@ public sealed partial class MainWindow : Window
|
||||
InitializeComponent();
|
||||
ApplyBackdrop();
|
||||
|
||||
Title = "gclo — Git Clone Large Organizations";
|
||||
// The dev edition installs beside the stable app, so the title says which one this is.
|
||||
Title = GcloPaths.Edition.Length == 0
|
||||
? "gclo — Git Clone Large Organizations"
|
||||
: $"gclo ({GcloPaths.Edition}) — Git Clone Large Organizations";
|
||||
|
||||
// Quick Sync is declared in XAML; its Guid tag and the account items are
|
||||
// runtime data, so they are filled in here.
|
||||
@@ -137,10 +145,20 @@ public sealed partial class MainWindow : Window
|
||||
WorkspaceNav.SelectedItem = QuickSyncNavItem;
|
||||
|
||||
// AppWindow.Resize takes physical pixels; scale by the monitor DPI so the
|
||||
// window is the same visual size at 150%/200% display scaling.
|
||||
// window is the same visual size at 150%/200% display scaling. The default
|
||||
// size follows the monitor: about 70% of the work area in each direction,
|
||||
// never smaller than the minimum layout and never larger than a sensible
|
||||
// desktop-app maximum, centred on that monitor (a fixed 1000x750 looked
|
||||
// small on large displays and did not fit small ones).
|
||||
nint hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
double scale = GetDpiForWindow(hwnd) / 96.0;
|
||||
AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(750 * scale)));
|
||||
Windows.Graphics.RectInt32 work = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest).WorkArea;
|
||||
int width = (int)Math.Clamp(work.Width * 0.7, MinWindowWidth * scale, MaxDefaultWindowWidth * scale);
|
||||
int height = (int)Math.Clamp(work.Height * 0.7, MinWindowHeight * scale, MaxDefaultWindowHeight * scale);
|
||||
width = Math.Min(width, work.Width);
|
||||
height = Math.Min(height, work.Height);
|
||||
AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(
|
||||
work.X + (work.Width - width) / 2, work.Y + (work.Height - height) / 2, width, height));
|
||||
|
||||
// The brand icon in the title bar (and alt-tab / taskbar for unpackaged runs).
|
||||
AppWindow.SetIcon(System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "Brand", "gclo.ico"));
|
||||
@@ -395,11 +413,18 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
workspace.Page ??= CreatePage(workspace.ViewModel);
|
||||
WorkspaceNav.Content = workspace.Page;
|
||||
AutoLoadAccountRepositories(workspace.ViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The view model decides whether showing this workspace loads it (saved accounts do).</summary>
|
||||
private static void AutoLoadAccountRepositories(WorkspaceViewModel viewModel)
|
||||
=> viewModel.LoadRepositoriesIfSaved();
|
||||
|
||||
|
||||
|
||||
private WorkspacePage CreatePage(WorkspaceViewModel viewModel)
|
||||
=> new(viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
|
||||
{
|
||||
@@ -639,15 +664,24 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
return account.Name;
|
||||
}
|
||||
// The pane is narrow: a short tag with the full meaning in the tooltip (and
|
||||
// in the item's automation name), and the name trims before the tag does.
|
||||
var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
|
||||
panel.Children.Add(new TextBlock { Text = account.Name, VerticalAlignment = VerticalAlignment.Center });
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "Default token",
|
||||
Text = account.Name,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
});
|
||||
var tag = new TextBlock
|
||||
{
|
||||
Text = "Default",
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
|
||||
Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
|
||||
});
|
||||
};
|
||||
ToolTipService.SetToolTip(tag, "Uses the default token from Settings");
|
||||
panel.Children.Add(tag);
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -1057,7 +1091,7 @@ public sealed partial class MainWindow : Window
|
||||
private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new SettingsDialog(
|
||||
_settings, _tokenVault, () => WinRT.Interop.WindowNative.GetWindowHandle(this), _log)
|
||||
_settings, _tokenVault, _accountsStore, () => WinRT.Interop.WindowNative.GetWindowHandle(this), _log)
|
||||
{
|
||||
XamlRoot = Content.XamlRoot,
|
||||
};
|
||||
@@ -1101,9 +1135,28 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
|
||||
private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
=> await OpenRepositoryPageAsync("");
|
||||
|
||||
|
||||
|
||||
/// <summary>Help > Submit an issue: GitHub's new-issue chooser (bug report / feature request templates).</summary>
|
||||
private async void SubmitIssueMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
=> await OpenRepositoryPageAsync("/issues/new/choose");
|
||||
|
||||
|
||||
|
||||
/// <summary>Help > Community support: the repository's GitHub Discussions.</summary>
|
||||
private async void CommunitySupportMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
=> await OpenRepositoryPageAsync("/discussions");
|
||||
|
||||
|
||||
|
||||
/// <summary>Opens a page of the repository in the default browser; the log names the page, never a token.</summary>
|
||||
private async Task OpenRepositoryPageAsync(string path)
|
||||
{
|
||||
_log.Info($"Opening {RepoUrl} in the browser.");
|
||||
await Windows.System.Launcher.LaunchUriAsync(new Uri(RepoUrl));
|
||||
string url = RepoUrl + path;
|
||||
_log.Info($"Opening {url} in the browser.");
|
||||
await Windows.System.Launcher.LaunchUriAsync(new Uri(url));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
using System;
|
||||
using gclo.ViewModels;
|
||||
using Velopack;
|
||||
|
||||
|
||||
@@ -25,6 +26,14 @@ public static class Program
|
||||
// Must be first: handles Velopack install/update/uninstall hooks and may exit.
|
||||
VelopackApp.Build().Run();
|
||||
|
||||
// The dev channel is packed as its own app (id gclo-dev) so it installs beside
|
||||
// the stable one; it keeps its own data root and says so in the title (#114).
|
||||
// Local and CLI runs have no Velopack app id and stay on the stable paths.
|
||||
if(string.Equals(Velopack.Locators.VelopackLocator.Current?.AppId, "gclo-dev", StringComparison.Ordinal))
|
||||
{
|
||||
GcloPaths.Edition = "dev";
|
||||
}
|
||||
|
||||
// The remainder mirrors the XAML-generated Main (obj\...\App.g.i.cs) verbatim.
|
||||
global::WinRT.ComWrappersSupport.InitializeComWrappers();
|
||||
global::Microsoft.UI.Xaml.Application.Start((p) =>
|
||||
|
||||
@@ -34,20 +34,33 @@
|
||||
x:Name="DefaultTokenBox"
|
||||
Header="Default GitHub token"
|
||||
AutomationProperties.AutomationId="SettingsDefaultTokenBox"
|
||||
ToolTipService.ToolTip="Pre-fills Quick Sync so you do not paste the token every launch. Stored in Windows Credential Manager, never in a file." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
PlaceholderText="ghp_…"
|
||||
ToolTipService.ToolTip="Pre-fills Quick Sync and serves every account set to use it. Stored in Windows Credential Manager, never in a file."
|
||||
PasswordChanged="DefaultTokenBox_PasswordChanged" />
|
||||
<!-- Caption takes the width, the Remove link keeps its own: a horizontal
|
||||
StackPanel clipped the link once the caption grew (#101). -->
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
x:Name="TokenStateText"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
AutomationProperties.LiveSetting="Polite"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
<HyperlinkButton
|
||||
x:Name="RemoveTokenButton"
|
||||
Grid.Column="1"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Content="Remove saved token"
|
||||
ToolTipService.ToolTip="Deletes the saved default token from Windows Credential Manager when you press Save"
|
||||
ToolTipService.ToolTip="Clears the box; the saved default token is deleted from Windows Credential Manager when you press Save"
|
||||
Click="RemoveTokenButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<NumberBox
|
||||
x:Name="ConcurrencyBox"
|
||||
Header="Default parallelism"
|
||||
|
||||
+53
-28
@@ -5,6 +5,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using gclo.Engine;
|
||||
using gclo.ViewModels;
|
||||
using Microsoft.UI.Xaml;
|
||||
@@ -42,8 +43,12 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
// A ContentDialog has no HWND of its own; the host window supplies one for pickers.
|
||||
private readonly Func<nint> _windowHandleProvider;
|
||||
|
||||
/// <summary>Set by the Remove link; the deletion happens on Save.</summary>
|
||||
private bool _removeSavedToken;
|
||||
/// <summary>
|
||||
/// What the default-token box shows and what Save does with it: a saved token
|
||||
/// shows as a fixed mask (never the token), typing replaces it, clearing removes
|
||||
/// it, and the caption names the accounts a removal would break (#101).
|
||||
/// </summary>
|
||||
private readonly SettingsTokenState _tokenState;
|
||||
|
||||
|
||||
|
||||
@@ -52,10 +57,15 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
/// <paramref name="log"/> records what a save changed (field names and values,
|
||||
/// never the token).
|
||||
/// </summary>
|
||||
public SettingsDialog(AppSettings settings, ITokenVault vault, Func<nint> windowHandleProvider, IActivityLog? log = null)
|
||||
public SettingsDialog(
|
||||
AppSettings settings, ITokenVault vault, AccountsStore accounts, Func<nint> windowHandleProvider, IActivityLog? log = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
ArgumentNullException.ThrowIfNull(accounts);
|
||||
_tokenState = new SettingsTokenState(
|
||||
hasSavedToken: !string.IsNullOrEmpty(vault.TryRetrieve(AppSettings.DefaultTokenVaultId)),
|
||||
dependentAccounts: accounts.GetAll().Where(a => a.UsesDefaultToken).Select(a => a.Name).ToList());
|
||||
ArgumentNullException.ThrowIfNull(windowHandleProvider);
|
||||
_settings = settings;
|
||||
_vault = vault;
|
||||
@@ -74,9 +84,11 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
SplashToggle.IsOn = settings.ShowSplashScreen;
|
||||
SplashDurationBox.Value = settings.SplashMilliseconds;
|
||||
|
||||
// The saved token is never re-materialized into the box — the box stays
|
||||
// empty and means "unchanged"; only typing a new value replaces it.
|
||||
RefreshTokenState(hasSaved: _vault.TryRetrieve(AppSettings.DefaultTokenVaultId) is not null);
|
||||
// The saved token is never re-materialized into the box: a saved token shows
|
||||
// as the fixed mask, which means "unchanged"; typing replaces it, clearing
|
||||
// removes it. The caption follows every edit (PasswordChanged).
|
||||
DefaultTokenBox.Password = _tokenState.InitialBoxValue;
|
||||
UpdateTokenCaption();
|
||||
}
|
||||
|
||||
|
||||
@@ -145,15 +157,20 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
_settings.Save();
|
||||
|
||||
string typed = DefaultTokenBox.Password;
|
||||
if(typed.Length > 0)
|
||||
switch(_tokenState.Plan(typed))
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, typed);
|
||||
changes.Add("default token stored");
|
||||
}
|
||||
else if(_removeSavedToken)
|
||||
{
|
||||
_vault.Delete(AppSettings.DefaultTokenVaultId);
|
||||
changes.Add("default token removed");
|
||||
case SettingsTokenAction.Store:
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, typed);
|
||||
changes.Add(_tokenState.HasSavedToken ? "default token replaced" : "default token stored");
|
||||
break;
|
||||
case SettingsTokenAction.Remove:
|
||||
_vault.Delete(AppSettings.DefaultTokenVaultId);
|
||||
changes.Add(_tokenState.DependentCount == 0
|
||||
? "default token removed"
|
||||
: $"default token removed ({_tokenState.DependentCount} account(s) depend on it)");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
_log.Info(changes.Count == 0
|
||||
@@ -163,22 +180,30 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
|
||||
|
||||
|
||||
// Clearing the box is the removal; the caption (via PasswordChanged) says what
|
||||
// Save will do and which accounts it affects.
|
||||
private void RemoveTokenButton_Click(object sender, RoutedEventArgs e)
|
||||
=> DefaultTokenBox.Password = "";
|
||||
|
||||
|
||||
|
||||
private void DefaultTokenBox_PasswordChanged(object sender, RoutedEventArgs e)
|
||||
=> UpdateTokenCaption();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The caption and the Remove link follow the box: the state object words the
|
||||
/// consequence of Save (keep, replace, remove), and a removal that breaks accounts
|
||||
/// is shown in the critical colour so it reads as the warning it is.
|
||||
/// </summary>
|
||||
private void UpdateTokenCaption()
|
||||
{
|
||||
_removeSavedToken = true;
|
||||
DefaultTokenBox.Password = "";
|
||||
TokenStateText.Text = "Saved token will be removed on Save.";
|
||||
RemoveTokenButton.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void RefreshTokenState(bool hasSaved)
|
||||
{
|
||||
TokenStateText.Text = hasSaved
|
||||
? "A default token is saved. Leave the box empty to keep it."
|
||||
: "No default token saved.";
|
||||
RemoveTokenButton.Visibility = hasSaved ? Visibility.Visible : Visibility.Collapsed;
|
||||
string box = DefaultTokenBox.Password;
|
||||
TokenStateText.Text = _tokenState.Caption(box);
|
||||
TokenStateText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources[
|
||||
_tokenState.RemovalBreaksAccounts(box) ? "SystemFillColorCriticalBrush" : "TextFillColorSecondaryBrush"];
|
||||
RemoveTokenButton.Visibility = _tokenState.CanOfferRemoval(box) ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,12 +19,18 @@
|
||||
<Setter Property="Padding" Value="4" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<!-- The row-details presenter is shown for every row (RowDetailsVisibilityMode
|
||||
Visible, so error/note/Resolve lines can appear without a toggle) and
|
||||
the TableView theme pads it 8px on every side even when the details
|
||||
content is collapsed, which left an empty band under each row. The
|
||||
RowDetailsTemplate pads its own content, so the theme padding is 0 here. -->
|
||||
<Thickness x:Key="TableViewDetailsPadding">0</Thickness>
|
||||
<!-- 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">96</GridLength>
|
||||
<GridLength x:Key="ArchivedColumnWidth">112</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. -->
|
||||
@@ -543,6 +549,7 @@
|
||||
<CheckBox
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
ToolTipService.ToolTip="Select or clear every visible row (hidden rows keep their selection)"
|
||||
AutomationProperties.Name="Select all visible repositories"
|
||||
@@ -553,6 +560,7 @@
|
||||
<CheckBox
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.Name="{x:Bind Name, Mode=OneTime}"
|
||||
IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />
|
||||
|
||||
Reference in new issue
Block a user