Account-org hardening, settings expansion, live log window, tooltips (beta.12) #35
No files matched your search
@@ -0,0 +1,49 @@
|
||||
using gclo.ViewModels;
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
/// <summary>Pins defaults and load-time sanitizing of <see cref="AppSettings"/>.</summary>
|
||||
public sealed class AppSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_SplashOnAtFiveSeconds()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
|
||||
Assert.True(settings.ShowSplashScreen);
|
||||
Assert.Equal(5_000, settings.SplashMilliseconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_ClampsSplashDuration()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
|
||||
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir);
|
||||
try
|
||||
{
|
||||
var settings = new AppSettings { SplashMilliseconds = 999_999 };
|
||||
settings.Save();
|
||||
Assert.Equal(AppSettings.MaxSplashMilliseconds, settings.SplashMilliseconds);
|
||||
|
||||
settings.SplashMilliseconds = 1;
|
||||
settings.Save();
|
||||
Assert.Equal(AppSettings.MinSplashMilliseconds, settings.SplashMilliseconds);
|
||||
|
||||
// Round trip: the persisted values load back as saved.
|
||||
var loaded = AppSettings.Load();
|
||||
Assert.Equal(AppSettings.MinSplashMilliseconds, loaded.SplashMilliseconds);
|
||||
Assert.True(loaded.ShowSplashScreen);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null);
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1150,6 +1150,42 @@ public sealed class WorkspaceViewModelTests
|
||||
MaxConcurrency = 1, // serialize progress: the sync fake reports inline across threads
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task OrgRefresh_AccountSeededOrganization_SurvivesAndResyncsTheView()
|
||||
{
|
||||
// Regression: the view's editable ComboBox resets its Text when its
|
||||
// ItemsSource is mutated, blanking the org an account workspace seeded in
|
||||
// the constructor. The VM must keep the value across the refresh AND raise
|
||||
// Organization again after the list mutation so the view resyncs its Text.
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Kof",
|
||||
Organization = "KofTwentyTwo",
|
||||
TargetRoot = @"C:\repos\kof",
|
||||
};
|
||||
var vault = new InMemoryVault();
|
||||
vault.Store(account.Id, "vault-token-1234567890");
|
||||
_orgs.Handler = (_, _) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(["KofTwentyTwo", "acme", "other"]);
|
||||
|
||||
var vm = CreateViewModel(account: account, vault: vault);
|
||||
bool resyncAfterListArrived = false;
|
||||
vm.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(WorkspaceViewModel.Organization)
|
||||
&& vm.Organizations.Count > 0)
|
||||
{
|
||||
resyncAfterListArrived = true;
|
||||
}
|
||||
};
|
||||
|
||||
await WaitUntilAsync(() => vm.Organizations.Count == 3, "org list refresh");
|
||||
|
||||
Assert.Equal("KofTwentyTwo", vm.Organization);
|
||||
Assert.True(resyncAfterListArrived, "Organization must be re-raised after the list mutation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,17 @@ public sealed class AppSettings
|
||||
public const int MaxConcurrency = 64;
|
||||
public const int DefaultConcurrency = 8;
|
||||
|
||||
public const int MinSplashMilliseconds = 500;
|
||||
public const int MaxSplashMilliseconds = 30_000;
|
||||
public const int DefaultSplashMilliseconds = 5_000;
|
||||
|
||||
/// <summary>
|
||||
/// Vault id under which the optional default GitHub token is stored. The token
|
||||
/// itself lives only in the <see cref="ITokenVault"/> (Credential Manager) —
|
||||
/// never in this settings file.
|
||||
/// </summary>
|
||||
public static readonly Guid DefaultTokenVaultId = new("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11");
|
||||
|
||||
/// <summary>Folder pre-filled as the sync target. Empty means "no default".</summary>
|
||||
public string DefaultTargetFolder { get; set; } = "";
|
||||
|
||||
@@ -24,6 +35,12 @@ public sealed class AppSettings
|
||||
/// <summary>App theme: "System", "Light", or "Dark". Unknown values fall back to "System".</summary>
|
||||
public string Theme { get; set; } = "System";
|
||||
|
||||
/// <summary>Whether the branded splash overlay shows at startup.</summary>
|
||||
public bool ShowSplashScreen { get; set; } = true;
|
||||
|
||||
/// <summary>How long the splash stays up before fading, clamped to 500..30000 ms.</summary>
|
||||
public int SplashMilliseconds { get; set; } = DefaultSplashMilliseconds;
|
||||
|
||||
private static string SettingsPath => Path.Combine(GcloPaths.DataRoot, "settings.json");
|
||||
|
||||
/// <summary>
|
||||
@@ -84,6 +101,7 @@ public sealed class AppSettings
|
||||
// The JSON deserializer can assign null despite the non-nullable annotation.
|
||||
DefaultTargetFolder ??= "";
|
||||
DefaultMaxConcurrency = Math.Clamp(DefaultMaxConcurrency, MinConcurrency, MaxConcurrency);
|
||||
SplashMilliseconds = Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds);
|
||||
if (Theme is not ("System" or "Light" or "Dark"))
|
||||
{
|
||||
Theme = "System";
|
||||
|
||||
@@ -352,11 +352,19 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
var orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken);
|
||||
lookupToken.ThrowIfCancellationRequested();
|
||||
|
||||
// An editable ComboBox resets its Text when its ItemsSource is mutated,
|
||||
// and the TwoWay binding would wipe a value that was already set — an
|
||||
// account workspace seeds Organization in the constructor, and this
|
||||
// refresh lands ~a second later. Capture, restore, and force a binding
|
||||
// resync so the seeded (or typed) organization survives the refresh.
|
||||
string organizationBeforeRefresh = Organization;
|
||||
Organizations.Clear();
|
||||
foreach (string org in orgs)
|
||||
{
|
||||
Organizations.Add(org);
|
||||
}
|
||||
Organization = organizationBeforeRefresh;
|
||||
OnPropertyChanged(nameof(Organization));
|
||||
// The production lister always lists the token's own account first, so a
|
||||
// single entry means no organizations were visible.
|
||||
StatusText = orgs.Count <= 1
|
||||
|
||||
+42
-12
@@ -10,20 +10,50 @@
|
||||
CloseButtonText="Close"
|
||||
DefaultButton="Close">
|
||||
|
||||
<ScrollViewer MaxHeight="420" VerticalScrollBarVisibility="Auto">
|
||||
<ScrollViewer MaxHeight="460" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="12" Padding="0,0,12,0">
|
||||
|
||||
<!-- App identity -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="gclo — Git Clone Large Organizations" Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock
|
||||
Text="Clone and update every repository in a GitHub organization."
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock
|
||||
x:Name="VersionText"
|
||||
AutomationProperties.AutomationId="AboutVersionText"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<!-- Brand header: the splash's look in miniature (same gradient, glyph,
|
||||
wordmark, and white type — deliberately single-theme, like the splash). -->
|
||||
<Border CornerRadius="8" RequestedTheme="Dark">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Offset="0" Color="#3B4CCA" />
|
||||
<GradientStop Offset="1" Color="#2A2F8F" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<StackPanel Padding="20,16" Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<FontIcon
|
||||
Glyph=""
|
||||
FontSize="28"
|
||||
Foreground="White"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock
|
||||
Text="gclo"
|
||||
FontFamily="Segoe UI Variable Display"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="28"
|
||||
Foreground="White"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Text="Git Clone Large Organizations"
|
||||
FontSize="13"
|
||||
Foreground="White"
|
||||
Opacity="0.8" />
|
||||
<TextBlock
|
||||
x:Name="VersionText"
|
||||
AutomationProperties.AutomationId="AboutVersionText"
|
||||
FontSize="12"
|
||||
Foreground="White"
|
||||
Opacity="0.7" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock
|
||||
Text="Clone and update every repository in a GitHub organization."
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<HyperlinkButton
|
||||
Padding="0"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ContentDialog
|
||||
x:Class="gclo.AccountWizardDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
@@ -30,6 +30,7 @@
|
||||
<TextBox
|
||||
AutomationProperties.AutomationId="WizardNameBox"
|
||||
Header="Account name"
|
||||
ToolTipService.ToolTip="Shown in the navigation pane; must be unique"
|
||||
PlaceholderText="e.g. Work – Acme"
|
||||
Text="{x:Bind ViewModel.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
@@ -41,6 +42,7 @@
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.NameError), Mode=OneWay}" />
|
||||
<TextBox
|
||||
Header="Description (optional)"
|
||||
ToolTipService.ToolTip="A note to future you about what this account is for"
|
||||
PlaceholderText="What this account is for"
|
||||
Text="{x:Bind ViewModel.Description, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
</StackPanel>
|
||||
@@ -56,6 +58,7 @@
|
||||
x:Name="TokenBox"
|
||||
AutomationProperties.AutomationId="WizardTokenBox"
|
||||
Header="Personal access token"
|
||||
ToolTipService.ToolTip="Stored in Windows Credential Manager, never in a file; needs repository read access"
|
||||
PlaceholderText="ghp_…"
|
||||
PasswordChanged="TokenBox_PasswordChanged" />
|
||||
<HyperlinkButton
|
||||
@@ -99,9 +102,11 @@
|
||||
AutomationProperties.AutomationId="WizardOrgBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
Header="Organization or account"
|
||||
ToolTipService.ToolTip="The GitHub organization or user account this saved account syncs"
|
||||
IsEditable="True"
|
||||
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
|
||||
PlaceholderText="Choose from the list, or type a name"
|
||||
Loaded="OrgBox_Loaded"
|
||||
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -121,6 +126,7 @@
|
||||
Grid.Column="0"
|
||||
AutomationProperties.AutomationId="WizardTargetBox"
|
||||
Header="Target folder"
|
||||
ToolTipService.ToolTip="Local folder each repository is cloned into"
|
||||
PlaceholderText="C:\src\my-org"
|
||||
Text="{x:Bind ViewModel.TargetRoot, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button
|
||||
@@ -136,6 +142,7 @@
|
||||
<NumberBox
|
||||
x:Name="ConcurrencyBox"
|
||||
Header="Parallel clones and pulls"
|
||||
ToolTipService.ToolTip="How many repositories run at the same time (1-64)"
|
||||
Minimum="1"
|
||||
Maximum="64"
|
||||
SmallChange="1"
|
||||
|
||||
@@ -149,6 +149,12 @@ namespace gclo
|
||||
private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
|
||||
=> ViewModel.Token = ((PasswordBox)sender).Password;
|
||||
|
||||
// An editable ComboBox does not render Text set before its template loaded;
|
||||
// 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);
|
||||
|
||||
/// <summary>Mirrors the NumberBox's double into the view model's int.</summary>
|
||||
private void ConcurrencyBox_ValueChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>
|
||||
/// Workaround for a WinUI platform quirk: an editable ComboBox never renders
|
||||
/// Text that was bound before its template loaded — the value sits in the Text
|
||||
/// property but the template's inner TextBox stays empty, so a seeded value
|
||||
/// (an account workspace's organization) looks blank. Re-assigning Text is an
|
||||
/// identical-value no-op, so the inner TextBox must be written directly.
|
||||
/// </summary>
|
||||
internal static class EditableComboBox
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes <paramref name="text"/> into the combo's template TextBox (and the
|
||||
/// Text property, for good measure). Call from the control's Loaded handler;
|
||||
/// enqueued once when the template has not been applied yet at that point.
|
||||
/// </summary>
|
||||
public static void ReapplyText(ComboBox combo, string text)
|
||||
{
|
||||
if (FindInnerTextBox(combo) is TextBox inner)
|
||||
{
|
||||
inner.Text = text;
|
||||
inner.SelectionStart = text.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Template not applied yet: try once more after this layout pass.
|
||||
combo.DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if (FindInnerTextBox(combo) is TextBox late)
|
||||
{
|
||||
late.Text = text;
|
||||
late.SelectionStart = text.Length;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static TextBox? FindInnerTextBox(DependencyObject root)
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(root);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
DependencyObject child = VisualTreeHelper.GetChild(root, i);
|
||||
if (child is TextBox box)
|
||||
{
|
||||
return box;
|
||||
}
|
||||
if (FindInnerTextBox(child) is TextBox nested)
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ContentDialog
|
||||
x:Class="gclo.LogViewerDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
Title="Activity log"
|
||||
CloseButtonText="Close"
|
||||
SecondaryButtonText="Open logs folder"
|
||||
SecondaryButtonClick="OpenLogsFolder_Click"
|
||||
DefaultButton="Close">
|
||||
|
||||
<ContentDialog.Resources>
|
||||
<!-- Log lines are wide; loosen the stock 548px dialog cap. -->
|
||||
<x:Double x:Key="ContentDialogMaxWidth">800</x:Double>
|
||||
</ContentDialog.Resources>
|
||||
|
||||
<ScrollViewer
|
||||
x:Name="LogScroll"
|
||||
MinWidth="480"
|
||||
MaxHeight="420"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
HorizontalScrollMode="Auto"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock
|
||||
x:Name="LogText"
|
||||
FontFamily="Consolas"
|
||||
FontSize="12"
|
||||
IsTextSelectionEnabled="True"
|
||||
TextWrapping="NoWrap" />
|
||||
</ScrollViewer>
|
||||
</ContentDialog>
|
||||
@@ -1,82 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using gclo.Engine;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only viewer for the tail of today's activity log, with a shortcut to the
|
||||
/// logs folder. The caller must set <see cref="ContentDialog.XamlRoot"/> before
|
||||
/// calling ShowAsync.
|
||||
/// </summary>
|
||||
public sealed partial class LogViewerDialog : ContentDialog
|
||||
{
|
||||
private const int TailLineCount = 400;
|
||||
|
||||
private readonly IActivityLog _log;
|
||||
|
||||
public LogViewerDialog(IActivityLog log)
|
||||
{
|
||||
_log = log;
|
||||
InitializeComponent();
|
||||
LogText.Text = ReadTail(log.CurrentLogFilePath);
|
||||
|
||||
// Recent entries matter most: start scrolled to the end of the tail.
|
||||
Opened += (_, _) =>
|
||||
{
|
||||
LogScroll.UpdateLayout();
|
||||
LogScroll.ChangeView(null, LogScroll.ScrollableHeight, null, true);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last <see cref="TailLineCount"/> lines of <paramref name="path"/>,
|
||||
/// or a placeholder when the file is missing or unreadable. Never throws: the
|
||||
/// viewer is diagnostics UI and must not crash the app over a bad log file.
|
||||
/// </summary>
|
||||
private static string ReadTail(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return "No log entries yet.";
|
||||
}
|
||||
|
||||
// Share ReadWrite: the activity log may append while the dialog reads.
|
||||
using var stream = new FileStream(
|
||||
path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var tail = new Queue<string>(TailLineCount);
|
||||
while (reader.ReadLine() is string line)
|
||||
{
|
||||
if (tail.Count == TailLineCount)
|
||||
{
|
||||
tail.Dequeue();
|
||||
}
|
||||
tail.Enqueue(line);
|
||||
}
|
||||
|
||||
return tail.Count == 0
|
||||
? "No log entries yet."
|
||||
: string.Join(Environment.NewLine, tail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return "Could not read the log file." + Environment.NewLine + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenLogsFolder_Click(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
{
|
||||
string directory = _log.LogDirectory;
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
await Windows.System.Launcher.LaunchFolderPathAsync(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Window
|
||||
x:Class="gclo.LogWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
Title="gclo — Activity log">
|
||||
|
||||
<Window.SystemBackdrop>
|
||||
<MicaBackdrop />
|
||||
</Window.SystemBackdrop>
|
||||
|
||||
<Grid Padding="16" RowSpacing="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Toolbar: filter and actions. The window is non-modal by design — it can
|
||||
stay open beside the main window while syncs run. -->
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ToggleButton
|
||||
x:Name="ErrorsOnlyToggle"
|
||||
Grid.Column="0"
|
||||
Content="Errors only"
|
||||
AutomationProperties.AutomationId="LogErrorsOnlyToggle"
|
||||
ToolTipService.ToolTip="Show only [ERROR] entries (with their exception details)"
|
||||
Click="ErrorsOnlyToggle_Click" />
|
||||
<ToggleButton
|
||||
x:Name="FollowToggle"
|
||||
Grid.Column="1"
|
||||
Content="Follow"
|
||||
IsChecked="True"
|
||||
AutomationProperties.AutomationId="LogFollowToggle"
|
||||
ToolTipService.ToolTip="Refresh automatically and keep the newest entries in view" />
|
||||
<TextBlock
|
||||
x:Name="LogPathText"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTipService.ToolTip="The file new entries are appended to" />
|
||||
<Button
|
||||
Grid.Column="3"
|
||||
Content="Copy"
|
||||
AutomationProperties.AutomationId="LogCopyButton"
|
||||
ToolTipService.ToolTip="Copy the visible log text to the clipboard"
|
||||
Click="CopyButton_Click" />
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
Content="Open logs folder"
|
||||
AutomationProperties.AutomationId="LogOpenFolderButton"
|
||||
ToolTipService.ToolTip="Open the folder that holds all gclo log files"
|
||||
Click="OpenLogsFolder_Click" />
|
||||
</Grid>
|
||||
|
||||
<!-- The raw log tail: timestamps, levels, and full exception text, in a
|
||||
monospace face for scanability. Selection enabled for copy/paste. -->
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Background="{ThemeResource LayerFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<ScrollViewer
|
||||
x:Name="LogScroll"
|
||||
Padding="12"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock
|
||||
x:Name="LogText"
|
||||
AutomationProperties.AutomationId="LogWindowText"
|
||||
FontFamily="Cascadia Mono, Consolas"
|
||||
FontSize="12"
|
||||
IsTextSelectionEnabled="True"
|
||||
TextWrapping="NoWrap" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using gclo.Engine;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>
|
||||
/// Non-modal activity-log window: stays open beside the main window while syncs
|
||||
/// run, tails the current log file live (timer + follow mode), and shows the raw
|
||||
/// technical text — timestamps, levels, full exception stacks — in monospace.
|
||||
/// <see cref="MainWindow"/> keeps at most one instance and re-activates it.
|
||||
/// </summary>
|
||||
public sealed partial class LogWindow : Window
|
||||
{
|
||||
private const int TailLineCount = 2000;
|
||||
private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly IActivityLog _log;
|
||||
private readonly DispatcherTimer _refresh = new() { Interval = RefreshInterval };
|
||||
|
||||
private long _lastLength = -1;
|
||||
private bool _errorsOnly;
|
||||
|
||||
public LogWindow(IActivityLog log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(log);
|
||||
_log = log;
|
||||
InitializeComponent();
|
||||
|
||||
LogPathText.Text = _log.CurrentLogFilePath;
|
||||
|
||||
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
double scale = GetDpiForWindow(hwnd) / 96.0;
|
||||
AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(680 * scale)));
|
||||
|
||||
_refresh.Tick += (_, _) => RefreshIfChanged();
|
||||
_refresh.Start();
|
||||
Closed += (_, _) => _refresh.Stop();
|
||||
|
||||
Reload(scrollToEnd: true);
|
||||
}
|
||||
|
||||
private void ErrorsOnlyToggle_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_errorsOnly = ErrorsOnlyToggle.IsChecked == true;
|
||||
Reload(scrollToEnd: true);
|
||||
}
|
||||
|
||||
private void CopyButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var package = new Windows.ApplicationModel.DataTransfer.DataPackage();
|
||||
package.SetText(LogText.Text);
|
||||
Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package);
|
||||
}
|
||||
|
||||
private async void OpenLogsFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string directory = _log.LogDirectory;
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
await Windows.System.Launcher.LaunchFolderPathAsync(directory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Timer tick: reload only when the file grew (or rolled) and Follow is on.</summary>
|
||||
private void RefreshIfChanged()
|
||||
{
|
||||
if (FollowToggle.IsChecked != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string path = _log.CurrentLogFilePath;
|
||||
long length = File.Exists(path) ? new FileInfo(path).Length : 0;
|
||||
if (length != _lastLength)
|
||||
{
|
||||
LogPathText.Text = path; // the day can roll over while the window is open
|
||||
Reload(scrollToEnd: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Diagnostics UI must never take the app down over an unreadable file.
|
||||
}
|
||||
}
|
||||
|
||||
private void Reload(bool scrollToEnd)
|
||||
{
|
||||
IReadOnlyList<string> lines = ReadTail(_log.CurrentLogFilePath, out _lastLength);
|
||||
if (_errorsOnly)
|
||||
{
|
||||
lines = FilterToErrorEntries(lines);
|
||||
}
|
||||
|
||||
LogText.Text = lines.Count == 0
|
||||
? (_errorsOnly ? "No errors logged. Good." : "No log entries yet.")
|
||||
: string.Join(Environment.NewLine, lines);
|
||||
|
||||
if (scrollToEnd)
|
||||
{
|
||||
LogScroll.UpdateLayout();
|
||||
LogScroll.ChangeView(null, LogScroll.ScrollableHeight, null, disableAnimation: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Last <see cref="TailLineCount"/> lines of the file (shared for concurrent
|
||||
/// appends); never throws — this is diagnostics UI.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> ReadTail(string path, out long length)
|
||||
{
|
||||
length = 0;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var stream = new FileStream(
|
||||
path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
length = stream.Length;
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var tail = new Queue<string>(TailLineCount);
|
||||
while (reader.ReadLine() is string line)
|
||||
{
|
||||
if (tail.Count == TailLineCount)
|
||||
{
|
||||
tail.Dequeue();
|
||||
}
|
||||
tail.Enqueue(line);
|
||||
}
|
||||
return [.. tail];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ["Could not read the log file.", ex.Message];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps [ERROR] entries including their continuation lines (exception text
|
||||
/// spans lines until the next timestamped entry starts).
|
||||
/// </summary>
|
||||
private static List<string> FilterToErrorEntries(IReadOnlyList<string> lines)
|
||||
{
|
||||
var kept = new List<string>();
|
||||
bool inError = false;
|
||||
foreach (string line in lines)
|
||||
{
|
||||
if (EntryStart().IsMatch(line))
|
||||
{
|
||||
inError = line.Contains("[ERROR]", StringComparison.Ordinal);
|
||||
}
|
||||
if (inError)
|
||||
{
|
||||
kept.Add(line);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")]
|
||||
private static partial Regex EntryStart();
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern uint GetDpiForWindow(IntPtr hwnd);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Window
|
||||
x:Class="gclo.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
@@ -56,7 +56,8 @@
|
||||
<NavigationViewItem
|
||||
x:Name="QuickSyncNavItem"
|
||||
AutomationProperties.AutomationId="NavQuickSync"
|
||||
Content="Quick Sync">
|
||||
Content="Quick Sync"
|
||||
ToolTipService.ToolTip="Ad-hoc sync: paste a token and go, nothing is saved">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</NavigationViewItem.Icon>
|
||||
@@ -68,6 +69,7 @@
|
||||
x:Name="SyncAllNavItem"
|
||||
AutomationProperties.AutomationId="NavSyncAll"
|
||||
Content="Sync all"
|
||||
ToolTipService.ToolTip="Run every saved account in order, one after another"
|
||||
Tag="syncall"
|
||||
IsEnabled="False">
|
||||
<NavigationViewItem.Icon>
|
||||
@@ -77,6 +79,7 @@
|
||||
<NavigationViewItem
|
||||
AutomationProperties.AutomationId="NavAddAccount"
|
||||
Content="Add account"
|
||||
ToolTipService.ToolTip="Save an organization, token, and target as a reusable account"
|
||||
Tag="add">
|
||||
<NavigationViewItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
|
||||
+44
-17
@@ -109,19 +109,27 @@ namespace gclo
|
||||
presenter.PreferredMinimumHeight = (int)(MinWindowHeight * scale);
|
||||
}
|
||||
|
||||
Closed += (_, _) => DisposeWorkspaces();
|
||||
|
||||
// Dismiss the startup splash overlay once it has been visible long enough
|
||||
// to read as an intentional brand moment rather than a flicker.
|
||||
DispatcherQueue.TryEnqueue(async () =>
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
await Task.Delay(MinimumSplashMilliseconds);
|
||||
await StartupSplash.DismissAsync();
|
||||
});
|
||||
}
|
||||
_logWindow?.Close(); // a log-only process would linger otherwise
|
||||
DisposeWorkspaces();
|
||||
};
|
||||
|
||||
/// <summary>Minimum splash display, so a fast startup never shows a flicker.</summary>
|
||||
private const int MinimumSplashMilliseconds = 800;
|
||||
// The splash overlay honors Settings → Advanced: skipped entirely when
|
||||
// disabled, otherwise dismissed after the configured display time.
|
||||
if (_settings.ShowSplashScreen)
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(async () =>
|
||||
{
|
||||
await Task.Delay(_settings.SplashMilliseconds);
|
||||
await StartupSplash.DismissAsync();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
StartupSplash.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySettings()
|
||||
{
|
||||
@@ -253,8 +261,14 @@ namespace gclo
|
||||
{
|
||||
if (id == Guid.Empty)
|
||||
{
|
||||
// Quick Sync: no account behind it — exactly the pre-accounts behavior.
|
||||
return new WorkspaceViewModel(log: _log);
|
||||
// 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);
|
||||
if (_tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId) is { Length: > 0 } defaultToken)
|
||||
{
|
||||
quickSync.Token = defaultToken;
|
||||
}
|
||||
return quickSync;
|
||||
}
|
||||
|
||||
Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id);
|
||||
@@ -662,7 +676,11 @@ namespace gclo
|
||||
|
||||
private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new SettingsDialog(_settings) { XamlRoot = Content.XamlRoot };
|
||||
var dialog = new SettingsDialog(
|
||||
_settings, _tokenVault, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
|
||||
{
|
||||
XamlRoot = Content.XamlRoot,
|
||||
};
|
||||
if (await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary)
|
||||
{
|
||||
dialog.ApplyAndSave();
|
||||
@@ -672,10 +690,19 @@ namespace gclo
|
||||
|
||||
private void ExitMenuItem_Click(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private async void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
/// <summary>The one log window, re-activated while open; null when closed.</summary>
|
||||
private LogWindow? _logWindow;
|
||||
|
||||
private void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new LogViewerDialog(_log) { XamlRoot = Content.XamlRoot };
|
||||
await DialogGuard.ShowAsync(dialog);
|
||||
// Non-modal by design: the log stays open and live-updating beside the
|
||||
// main window while syncs run.
|
||||
if (_logWindow is null)
|
||||
{
|
||||
_logWindow = new LogWindow(_log);
|
||||
_logWindow.Closed += (_, _) => _logWindow = null;
|
||||
}
|
||||
_logWindow.Activate();
|
||||
}
|
||||
|
||||
private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
|
||||
@@ -11,25 +11,80 @@
|
||||
CloseButtonText="Cancel"
|
||||
DefaultButton="Primary">
|
||||
|
||||
<StackPanel MinWidth="380" Spacing="12">
|
||||
<TextBox
|
||||
x:Name="TargetFolderBox"
|
||||
Header="Default target folder"
|
||||
PlaceholderText="C:\src\my-org" />
|
||||
<StackPanel MinWidth="420" Spacing="12">
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox
|
||||
x:Name="TargetFolderBox"
|
||||
Header="Default target folder"
|
||||
PlaceholderText="C:\src\my-org"
|
||||
ToolTipService.ToolTip="Pre-filled as the sync target for new workspaces" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Bottom"
|
||||
Content="Browse…"
|
||||
AutomationProperties.AutomationId="SettingsBrowseButton"
|
||||
ToolTipService.ToolTip="Choose the default target folder"
|
||||
Click="BrowseButton_Click" />
|
||||
</Grid>
|
||||
<PasswordBox
|
||||
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">
|
||||
<TextBlock
|
||||
x:Name="TokenStateText"
|
||||
VerticalAlignment="Center"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
<HyperlinkButton
|
||||
x:Name="RemoveTokenButton"
|
||||
Padding="0"
|
||||
Content="Remove saved token"
|
||||
ToolTipService.ToolTip="Deletes the saved default token from Windows Credential Manager when you press Save"
|
||||
Click="RemoveTokenButton_Click" />
|
||||
</StackPanel>
|
||||
<NumberBox
|
||||
x:Name="ConcurrencyBox"
|
||||
Header="Default parallelism"
|
||||
Minimum="1"
|
||||
Maximum="64"
|
||||
SmallChange="1"
|
||||
SpinButtonPlacementMode="Inline" />
|
||||
SpinButtonPlacementMode="Inline"
|
||||
ToolTipService.ToolTip="How many repositories new workspaces clone or pull at the same time" />
|
||||
<ComboBox
|
||||
x:Name="ThemeBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
Header="Theme">
|
||||
Header="Theme"
|
||||
ToolTipService.ToolTip="Light, dark, or follow the Windows setting">
|
||||
<ComboBoxItem Content="System" />
|
||||
<ComboBoxItem Content="Light" />
|
||||
<ComboBoxItem Content="Dark" />
|
||||
</ComboBox>
|
||||
<Expander
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Header="Advanced">
|
||||
<StackPanel Spacing="12">
|
||||
<ToggleSwitch
|
||||
x:Name="SplashToggle"
|
||||
Header="Show splash screen at startup"
|
||||
AutomationProperties.AutomationId="SettingsSplashToggle"
|
||||
ToolTipService.ToolTip="Turns the branded startup splash on or off" />
|
||||
<NumberBox
|
||||
x:Name="SplashDurationBox"
|
||||
Header="Splash duration (milliseconds)"
|
||||
Minimum="500"
|
||||
Maximum="30000"
|
||||
SmallChange="250"
|
||||
SpinButtonPlacementMode="Inline"
|
||||
AutomationProperties.AutomationId="SettingsSplashDurationBox"
|
||||
ToolTipService.ToolTip="How long the splash stays on screen before fading (500–30000 ms)" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
+80
-11
@@ -1,33 +1,45 @@
|
||||
using System;
|
||||
using gclo.ViewModels;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>
|
||||
/// Modal editor for <see cref="AppSettings"/>. The dialog only edits and persists the
|
||||
/// values; it does not apply the theme or touch any window — the caller owns side effects.
|
||||
/// Modal editor for <see cref="AppSettings"/> plus the optional default GitHub
|
||||
/// token (kept in the <see cref="ITokenVault"/>, never in settings.json). The
|
||||
/// dialog only edits and persists values; the caller owns side effects like
|
||||
/// applying the theme.
|
||||
///
|
||||
/// WinUI 3 requires <c>XamlRoot</c> to be set before <c>ShowAsync</c> (a ContentDialog
|
||||
/// created in code has no visual tree to attach to and throws otherwise). Typical usage
|
||||
/// from a Window:
|
||||
/// WinUI 3 requires <c>XamlRoot</c> to be set before <c>ShowAsync</c>. Typical
|
||||
/// usage from a Window:
|
||||
/// <code>
|
||||
/// var dialog = new SettingsDialog(settings) { XamlRoot = Content.XamlRoot };
|
||||
/// var dialog = new SettingsDialog(settings, vault, () => hwnd) { XamlRoot = Content.XamlRoot };
|
||||
/// if (await dialog.ShowAsync() == ContentDialogResult.Primary)
|
||||
/// {
|
||||
/// dialog.ApplyAndSave(); // writes edits into the AppSettings passed above, then Save()
|
||||
/// // caller applies theme / defaults as it sees fit
|
||||
/// dialog.ApplyAndSave();
|
||||
/// }
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public sealed partial class SettingsDialog : ContentDialog
|
||||
{
|
||||
private readonly AppSettings _settings;
|
||||
private readonly ITokenVault _vault;
|
||||
// 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>Creates the dialog and populates the controls from <paramref name="settings"/>.</summary>
|
||||
public SettingsDialog(AppSettings settings)
|
||||
public SettingsDialog(AppSettings settings, ITokenVault vault, Func<nint> windowHandleProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
ArgumentNullException.ThrowIfNull(windowHandleProvider);
|
||||
_settings = settings;
|
||||
_vault = vault;
|
||||
_windowHandleProvider = windowHandleProvider;
|
||||
InitializeComponent();
|
||||
|
||||
TargetFolderBox.Text = settings.DefaultTargetFolder;
|
||||
@@ -38,11 +50,18 @@ namespace gclo
|
||||
"Dark" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the edited values back into the <see cref="AppSettings"/> instance the dialog
|
||||
/// was constructed with and persists them. Call this after <c>ShowAsync</c> returned
|
||||
/// Copies the edited values back into the <see cref="AppSettings"/> instance the
|
||||
/// dialog was constructed with, persists them, and applies the default-token
|
||||
/// change (store or remove) to the vault. Call after <c>ShowAsync</c> returned
|
||||
/// <see cref="ContentDialogResult.Primary"/>.
|
||||
/// </summary>
|
||||
public void ApplyAndSave()
|
||||
@@ -64,7 +83,57 @@ namespace gclo
|
||||
_ => "System",
|
||||
};
|
||||
|
||||
_settings.ShowSplashScreen = SplashToggle.IsOn;
|
||||
double splash = SplashDurationBox.Value;
|
||||
if (!double.IsNaN(splash))
|
||||
{
|
||||
_settings.SplashMilliseconds = Math.Clamp(
|
||||
(int)Math.Round(splash),
|
||||
AppSettings.MinSplashMilliseconds,
|
||||
AppSettings.MaxSplashMilliseconds);
|
||||
}
|
||||
|
||||
_settings.Save();
|
||||
|
||||
string typed = DefaultTokenBox.Password;
|
||||
if (typed.Length > 0)
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, typed);
|
||||
}
|
||||
else if (_removeSavedToken)
|
||||
{
|
||||
_vault.Delete(AppSettings.DefaultTokenVaultId);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveTokenButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
private async void BrowseButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var picker = new Windows.Storage.Pickers.FolderPicker();
|
||||
picker.FileTypeFilter.Add("*"); // required in packaged apps
|
||||
|
||||
WinRT.Interop.InitializeWithWindow.Initialize(picker, _windowHandleProvider());
|
||||
|
||||
var folder = await picker.PickSingleFolderAsync();
|
||||
if (folder is not null)
|
||||
{
|
||||
TargetFolderBox.Text = folder.Path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -1,4 +1,4 @@
|
||||
<?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"
|
||||
@@ -85,6 +85,7 @@
|
||||
x:Name="TokenBox"
|
||||
AutomationProperties.AutomationId="ConnectTokenBox"
|
||||
Header="Personal access token"
|
||||
ToolTipService.ToolTip="Fine-grained or classic PAT with repository read access; used for the GitHub API and git over HTTPS. Never written to disk or logs."
|
||||
PlaceholderText="ghp_…"
|
||||
PasswordChanged="TokenBox_PasswordChanged" />
|
||||
<HyperlinkButton
|
||||
@@ -101,9 +102,11 @@
|
||||
AutomationProperties.AutomationId="ConnectOrgBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
Header="Organization or account"
|
||||
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}"
|
||||
PlaceholderText="Paste a token first, or type an org"
|
||||
Loaded="OrgBox_Loaded"
|
||||
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
|
||||
<ProgressRing
|
||||
Grid.Column="1"
|
||||
@@ -122,12 +125,14 @@
|
||||
<TextBox
|
||||
Grid.Column="0"
|
||||
AutomationProperties.AutomationId="ConnectTargetBox"
|
||||
ToolTipService.ToolTip="Local folder each repository is cloned into (one subfolder per repository)"
|
||||
Header="Target folder"
|
||||
PlaceholderText="C:\src\my-org"
|
||||
Text="{x:Bind ViewModel.TargetFolder, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
AutomationProperties.AutomationId="ConnectBrowseButton"
|
||||
ToolTipService.ToolTip="Choose the target folder"
|
||||
VerticalAlignment="Bottom"
|
||||
Content="Browse…"
|
||||
Click="BrowseButton_Click" />
|
||||
@@ -150,6 +155,7 @@
|
||||
<Button
|
||||
MinWidth="140"
|
||||
AutomationProperties.AutomationId="LoadReposButton"
|
||||
ToolTipService.ToolTip="List every repository the token can see in this organization"
|
||||
Style="{StaticResource AccentButtonStyle}"
|
||||
Content="Load repositories"
|
||||
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}">
|
||||
@@ -261,6 +267,7 @@
|
||||
IsEditable="True"
|
||||
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
|
||||
PlaceholderText="Paste a token first, or type an org"
|
||||
Loaded="OrgBox_Loaded"
|
||||
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
|
||||
<ProgressRing
|
||||
Grid.Column="1"
|
||||
@@ -349,10 +356,10 @@
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
SelectionChanged="FilterSelectorBar_SelectionChanged">
|
||||
<SelectorBarItem x:Name="FilterAllItem" Text="All" Tag="All" />
|
||||
<SelectorBarItem Text="Active" Tag="Active" />
|
||||
<SelectorBarItem Text="Failed" Tag="Failed" />
|
||||
<SelectorBarItem Text="Pending" Tag="Pending" />
|
||||
<SelectorBarItem x:Name="FilterAllItem" Text="All" Tag="All" ToolTipService.ToolTip="Show every loaded repository" />
|
||||
<SelectorBarItem Text="Active" Tag="Active" ToolTipService.ToolTip="Show repositories cloning or pulling right now" />
|
||||
<SelectorBarItem Text="Failed" Tag="Failed" ToolTipService.ToolTip="Show repositories whose last run failed" />
|
||||
<SelectorBarItem Text="Pending" Tag="Pending" ToolTipService.ToolTip="Show repositories still waiting to run" />
|
||||
</SelectorBar>
|
||||
<TextBlock
|
||||
x:Name="SelectionSummaryText"
|
||||
@@ -410,6 +417,7 @@
|
||||
<Button
|
||||
AutomationProperties.AutomationId="RefreshButton"
|
||||
Content="Refresh"
|
||||
ToolTipService.ToolTip="Reload the repository list from GitHub (Ctrl+R)"
|
||||
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}">
|
||||
<Button.KeyboardAccelerators>
|
||||
<KeyboardAccelerator Key="R" Modifiers="Control" />
|
||||
@@ -419,6 +427,7 @@
|
||||
MinWidth="120"
|
||||
AutomationProperties.AutomationId="SyncButton"
|
||||
Style="{StaticResource AccentButtonStyle}"
|
||||
ToolTipService.ToolTip="Clone new repositories and update existing ones, for the selected rows (Ctrl+Enter)"
|
||||
Content="{x:Bind ViewModel.SyncButtonLabel, Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SyncCommand, Mode=OneTime}">
|
||||
<Button.KeyboardAccelerators>
|
||||
@@ -449,6 +458,7 @@
|
||||
<Button
|
||||
AutomationProperties.AutomationId="CancelSyncButton"
|
||||
Content="Cancel"
|
||||
ToolTipService.ToolTip="Stop scheduling new repositories; operations already running finish cleanly (Esc)"
|
||||
Command="{x:Bind ViewModel.SyncCancelCommand, Mode=OneTime}">
|
||||
<Button.KeyboardAccelerators>
|
||||
<KeyboardAccelerator Key="Escape" />
|
||||
|
||||
@@ -242,6 +242,13 @@ namespace gclo
|
||||
null => "Filter by archived",
|
||||
};
|
||||
|
||||
// An editable ComboBox does not render Text that was set before its template
|
||||
// loaded, so an account workspace's seeded org shows as blank even though
|
||||
// the view model holds it — see EditableComboBox. Runs on every Loaded:
|
||||
// the connect card's box on page creation, the chip flyout's on each open.
|
||||
private void OrgBox_Loaded(object sender, RoutedEventArgs e)
|
||||
=> EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization);
|
||||
|
||||
private void Toolbar_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||
=> UpdateToolbarLayout();
|
||||
|
||||
|
||||
Reference in new issue
Block a user