Account-org hardening, settings expansion, live log window, tooltips (beta.12) #35

Merged
KofTwentyTwo merged 3 commits from dev into main 2026-07-05 13:09:09 +00:00
18 changed files with 705 additions and 171 deletions

No files matched your search

+49
View File
@@ -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()
{
+18
View File
@@ -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";
+8
View File
@@ -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
View File
@@ -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="&#xE895;"
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"
+8 -1
View File
@@ -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"
+6
View File
@@ -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)
{
+60
View File
@@ -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;
}
}
}
-34
View File
@@ -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>
-82
View File
@@ -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);
}
}
}
}
+90
View File
@@ -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>
+175
View File
@@ -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);
}
}
+5 -2
View File
@@ -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="&#xE945;" />
</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="&#xE710;" />
+44 -17
View File
@@ -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)
+62 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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" />
+7
View File
@@ -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();