Skip to content

Commit 9533088

Browse files
Geeven SinghCopilot
andcommitted
Phase 2.2: AppSettings.AutoUpdate / UpdateCheckCadence / IncludePreReleases + Updates dialog section
Adds the three persisted settings the Phase 2.3 banner UI and Phase 3 release pipeline will read, and a 'Updates' section to the Settings dialog so users can opt in or out before the auto-update channel goes live for real users in Phase 3. Schema v6 -> v7 (no-op migration; all three fields have safe defaults). Defaults diverge from the master plan: AutoUpdate defaults to NotifyOnly, not Automatic. NotifyOnly is the safer choice for a tool that's used by developers who deserve to know when their binary changes under them. The runtime distinction between NotifyOnly and Disabled (NotifyOnly = check + show banner, don't auto-apply) requires the banner UI from Phase 2.3 — until then, both modes short-circuit the App.xaml.cs startup check identically. The user opt-in path through the new Settings dialog section means Phase 3 can ship safely without users being surprise-auto-updated. Phase 2.2 also doesn't wire UpdateCheckCadence (no periodic timer exists yet — that's Phase 2.3) or IncludePreReleases (VelopackUpdateService hardcodes prerelease: false — also Phase 2.3). Inert settings are flagged in their XML docs so the gap is discoverable. App.xaml.cs gains one branch around the fire-and-forget background check: AutoUpdate==Automatic still does Phase 2.1's silent download + WaitExitThenApplyUpdates; anything else (NotifyOnly, Disabled) is a no-op until Phase 2.3 differentiates them. Settings dialog new section follows the existing PR-review section visual conventions (SectionHeader + FieldLabel + ComboBox / CheckBox). ComboBoxes display the raw enum name (StartupOnly, EverySixHours, etc.) — adding a value converter for friendlier display is a Phase 2.3 polish task. Tests: 1371 passing (+2: v6 -> v7 migration test follows the v4 -> v5 / v5 -> v6 pattern; round-trip test confirms serializer reads back what it writes). 0 warnings, 0 errors in dotnet build -c Release. CHANGELOG [Unreleased] documents the new dialog section. AI-Local-Session: 4519f6b6-393a-4476-8efa-410e5396c3a9 AI-Cloud-Session: 72f9e474-60ab-42c2-b2a0-28fee827cbbb Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1ac2fc9 commit 9533088

10 files changed

Lines changed: 264 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,29 @@ body. Keep section headings exact and write notes in Markdown.
1212

1313
## [Unreleased]
1414

15+
### Added
16+
17+
- **Settings → Updates section.** Three new settings control how
18+
DiffViewer handles available updates from GitHub Releases. The
19+
settings are inert on portable / from-source builds (those have
20+
never checked for updates) and become live when the upcoming
21+
Velopack-installed channel ships:
22+
- **Auto-update behavior**`NotifyOnly` (default for new and
23+
migrated installs), `Automatic`, or `Disabled`. `NotifyOnly`
24+
is the safe default: silent auto-updates are opt-in. The
25+
banner UX that distinguishes `NotifyOnly` from `Disabled` lands
26+
in a follow-up.
27+
- **Check frequency**`StartupOnly` / `Hourly` / `EverySixHours`
28+
/ `Daily` (default) / `Weekly`. Currently only the startup
29+
check fires; the periodic timer that honors this setting lands
30+
in a follow-up.
31+
- **Include pre-release versions** — off by default. Currently
32+
ignored (the update check is hardcoded stable-only); will be
33+
wired through to the Velopack source in a follow-up.
34+
- `settings.json` schema bump v6 → v7. The migration is a no-op:
35+
all three new fields default to safe values, so pre-v7 files load
36+
with the same effective behaviour they had before.
37+
1538
## [1.4.0] - 2026-05-29
1639

1740
### Changed

DiffViewer.Tests/Services/SettingsServiceTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,56 @@ public void Load_V5File_MigratesToV6_RenderSvgImageDefaultsToTrue()
498498
svc.Current.RenderSvgImage.Should().BeTrue();
499499
}
500500

501+
[Fact]
502+
public void Load_V6File_MigratesToV7_AutoUpdateFieldsHydrateToDefaults()
503+
{
504+
// v6 schema (current minus 1) had no auto-update fields.
505+
// After v6->v7 migration the three fields should hydrate to
506+
// their safe defaults (NotifyOnly / Daily / false) and other
507+
// fields should be preserved. Phase 2.2 deliberately defaults
508+
// AutoUpdate to NotifyOnly so existing installs do not opt
509+
// into silent updates without explicit user consent.
510+
var v6 = new JsonObject
511+
{
512+
["schemaVersion"] = 6,
513+
["fontSize"] = 19,
514+
["tabWidth"] = 7,
515+
["isSideBySide"] = false,
516+
["renderSvgImage"] = false,
517+
};
518+
File.WriteAllText(_settingsPath, v6.ToJsonString());
519+
520+
var svc = new SettingsService(_settingsPath);
521+
522+
svc.LastLoadOutcome.Should().Be(SettingsLoadOutcome.Migrated);
523+
svc.Current.FontSize.Should().Be(19);
524+
svc.Current.TabWidth.Should().Be(7);
525+
svc.Current.IsSideBySide.Should().BeFalse();
526+
svc.Current.RenderSvgImage.Should().BeFalse();
527+
svc.Current.AutoUpdate.Should().Be(AutoUpdateMode.NotifyOnly);
528+
svc.Current.UpdateCheckCadence.Should().Be(UpdateCheckCadence.Daily);
529+
svc.Current.IncludePreReleases.Should().BeFalse();
530+
}
531+
532+
[Fact]
533+
public void Save_Then_Load_RoundTripsAutoUpdateFields()
534+
{
535+
var svc1 = new SettingsService(_settingsPath);
536+
svc1.Save(svc1.Current with
537+
{
538+
AutoUpdate = AutoUpdateMode.Disabled,
539+
UpdateCheckCadence = UpdateCheckCadence.Weekly,
540+
IncludePreReleases = true,
541+
});
542+
543+
var svc2 = new SettingsService(_settingsPath);
544+
545+
svc2.LastLoadOutcome.Should().Be(SettingsLoadOutcome.Loaded);
546+
svc2.Current.AutoUpdate.Should().Be(AutoUpdateMode.Disabled);
547+
svc2.Current.UpdateCheckCadence.Should().Be(UpdateCheckCadence.Weekly);
548+
svc2.Current.IncludePreReleases.Should().BeTrue();
549+
}
550+
501551
[Fact]
502552
public void RepoUrlMappings_StableOrderingOnDisk_AcrossSaves()
503553
{

DiffViewer/App.xaml.cs

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System;
2+
using System.IO;
23
using System.Net.Http;
34
using System.Threading;
45
using System.Threading.Tasks;
56
using System.Windows;
7+
using DiffViewer.Models;
68
using DiffViewer.Rendering;
79
using DiffViewer.Services;
810
using DiffViewer.Utility;
@@ -168,19 +170,25 @@ protected override async void OnStartup(StartupEventArgs e)
168170

169171
window.Show();
170172

171-
// Auto-update lifecycle (Phase 2.1). Fires a single background
172-
// check at startup; if an update is available it downloads
173-
// and queues to apply silently on the next clean exit.
174-
// Velopack-installed copies get the real service; portable /
175-
// dev launches get a no-op. Phase 2.2 will add periodic
176-
// re-checks driven by a configurable interval; Phase 2.3 will
177-
// add the in-app notification banner that turns this silent
178-
// path into a user-visible one.
179-
IUpdateService updateService =
180-
VelopackUpdateService.TryCreateForInstalled()
181-
?? (IUpdateService)new NullUpdateService();
182-
var updateCt = _shutdownCts.Token;
183-
_ = Task.Run(() => updateService.CheckAndQueueUpdateAsync(updateCt));
173+
// Auto-update lifecycle (Phase 2.1 + 2.2). Fires a single
174+
// background check at startup IF the user has opted in via
175+
// AppSettings.AutoUpdate; Phase 2.2 defaults to NotifyOnly so
176+
// no check fires for new installs until either the user flips
177+
// the setting or Phase 2.3 redefines NotifyOnly to mean
178+
// "check + show banner, don't apply silently." For now,
179+
// anything other than AutoUpdateMode.Automatic short-circuits
180+
// here. Velopack-installed copies get the real service;
181+
// portable / dev launches get a no-op. Phase 2.3 will add the
182+
// banner UI; Phase 3 will start producing Velopack-installed
183+
// copies in the wild.
184+
if (settingsService.Current.AutoUpdate == AutoUpdateMode.Automatic)
185+
{
186+
IUpdateService updateService =
187+
VelopackUpdateService.TryCreateForInstalled()
188+
?? (IUpdateService)new NullUpdateService();
189+
var updateCt = _shutdownCts.Token;
190+
_ = Task.Run(() => updateService.CheckAndQueueUpdateAsync(updateCt));
191+
}
184192
}
185193

186194
protected override void OnExit(ExitEventArgs e)

DiffViewer/Models/AppSettings.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ namespace DiffViewer.Models;
1616
public sealed record AppSettings
1717
{
1818
/// <summary>Current schema version; bump every time the shape changes.</summary>
19-
public const int CurrentSchemaVersion = 6;
19+
public const int CurrentSchemaVersion = 7;
2020

2121
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
2222

@@ -111,6 +111,34 @@ public sealed record AppSettings
111111
/// </summary>
112112
public IReadOnlyDictionary<RepoUrlKey, string> RepoUrlMappings { get; init; }
113113
= new Dictionary<RepoUrlKey, string>();
114+
115+
// ---- Auto-update (added in v7) ----
116+
/// <summary>
117+
/// How DiffViewer handles available updates from the configured
118+
/// update source. Default <see cref="AutoUpdateMode.NotifyOnly"/>
119+
/// — silent auto-updates are opt-in. See
120+
/// <see cref="AutoUpdateMode"/> for the per-value semantics and
121+
/// the cross-phase rollout story.
122+
/// </summary>
123+
public AutoUpdateMode AutoUpdate { get; init; } = AutoUpdateMode.NotifyOnly;
124+
125+
/// <summary>
126+
/// How often the update check fires. Default
127+
/// <see cref="UpdateCheckCadence.Daily"/>. Inert in Phase 2.2
128+
/// (only the startup check exists); Phase 2.3 wires a periodic
129+
/// timer that honors this value.
130+
/// </summary>
131+
public UpdateCheckCadence UpdateCheckCadence { get; init; } = UpdateCheckCadence.Daily;
132+
133+
/// <summary>
134+
/// When <c>true</c>, the update source includes pre-release tags
135+
/// (e.g. <c>v1.5.0-rc1</c>). Default <c>false</c> — stable
136+
/// releases only. Inert in Phase 2.2 (the Velopack adapter
137+
/// hardcodes <c>prerelease: false</c>); Phase 2.3 will read this
138+
/// and pass it through to
139+
/// <see cref="Velopack.Sources.GithubSource"/>.
140+
/// </summary>
141+
public bool IncludePreReleases { get; init; }
114142
}
115143

116144
/// <summary>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
namespace DiffViewer.Models;
2+
3+
/// <summary>
4+
/// How DiffViewer handles available updates from the configured update
5+
/// source. Persisted as <c>AppSettings.AutoUpdate</c> in
6+
/// <c>settings.json</c>; added in schema v7. The runtime effect of
7+
/// each value evolves across phases of the auto-update rollout:
8+
///
9+
/// <list type="bullet">
10+
/// <item><see cref="Automatic"/>: download new releases in the
11+
/// background and apply them silently on the next clean exit.
12+
/// Phase 2.3 will add a banner to surface "update available";
13+
/// until then, this mode is fully silent.</item>
14+
/// <item><see cref="NotifyOnly"/>: <b>Phase 2.2</b> — effectively
15+
/// the same as <see cref="Disabled"/> (no banner UI exists yet
16+
/// to surface notifications); <b>Phase 2.3 onward</b> — check,
17+
/// download, but only apply when the user clicks the banner's
18+
/// "install" action. The default for new installs.</item>
19+
/// <item><see cref="Disabled"/>: skip the update check entirely.
20+
/// Useful for tightly-controlled environments where updates are
21+
/// managed externally, or users who prefer to drive their own
22+
/// upgrade cadence by downloading new installers from the
23+
/// Releases page.</item>
24+
/// </list>
25+
/// </summary>
26+
public enum AutoUpdateMode
27+
{
28+
Automatic,
29+
NotifyOnly,
30+
Disabled,
31+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace DiffViewer.Models;
2+
3+
/// <summary>
4+
/// How often DiffViewer should poll the configured update source for
5+
/// a newer release. Persisted as
6+
/// <c>AppSettings.UpdateCheckCadence</c> in <c>settings.json</c>;
7+
/// added in schema v7. Inert in Phase 2.2 — only the startup-time
8+
/// check fires today, regardless of this setting. Phase 2.3 will add
9+
/// a periodic re-check timer that honors the configured cadence.
10+
///
11+
/// <para>The five values cover the documented choices from the auto-update
12+
/// design ("startup-only / 1h / 6h / 24h / 7d"). DiffViewer launches
13+
/// are deliberate (it's not a chat client), so anything more frequent
14+
/// than <see cref="Hourly"/> would be wasted polling against GitHub
15+
/// rate limits.</para>
16+
/// </summary>
17+
public enum UpdateCheckCadence
18+
{
19+
/// <summary>Only check at startup; no periodic re-check.</summary>
20+
StartupOnly,
21+
22+
Hourly,
23+
EverySixHours,
24+
Daily,
25+
Weekly,
26+
}

DiffViewer/Services/SettingsJsonSerializer.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ public static string Serialize(AppSettings s)
5555
["repoRoots"] = SerializeStringList(s.RepoRoots),
5656
["defaultCloneDestination"] = s.DefaultCloneDestination,
5757
["repoUrlMappings"] = SerializeRepoUrlMappings(s.RepoUrlMappings),
58+
["autoUpdate"] = s.AutoUpdate.ToString(),
59+
["updateCheckCadence"] = s.UpdateCheckCadence.ToString(),
60+
["includePreReleases"] = s.IncludePreReleases,
5861
};
5962
return obj.ToJsonString(WriteOptions);
6063
}
@@ -89,6 +92,9 @@ public static AppSettings Deserialize(JsonObject obj)
8992
RepoRoots = DeserializeStringList(obj["repoRoots"]) ?? defaults.RepoRoots,
9093
DefaultCloneDestination = TryString(obj, "defaultCloneDestination"),
9194
RepoUrlMappings = DeserializeRepoUrlMappings(obj["repoUrlMappings"]) ?? defaults.RepoUrlMappings,
95+
AutoUpdate = TryEnum<AutoUpdateMode>(obj, "autoUpdate") ?? defaults.AutoUpdate,
96+
UpdateCheckCadence = TryEnum<UpdateCheckCadence>(obj, "updateCheckCadence") ?? defaults.UpdateCheckCadence,
97+
IncludePreReleases = TryBool(obj, "includePreReleases") ?? defaults.IncludePreReleases,
9298
};
9399
}
94100

DiffViewer/Services/SettingsMigrations.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ namespace DiffViewer.Services;
3737
/// because the field has a sensible default (<c>true</c>) — pre-v6
3838
/// files deserialize with the rasterised view enabled, matching the
3939
/// headline behaviour the feature was designed to deliver.</para>
40+
///
41+
/// <para>v7 introduced the three auto-update settings:
42+
/// <c>autoUpdate</c> (<see cref="AutoUpdateMode"/>),
43+
/// <c>updateCheckCadence</c> (<see cref="UpdateCheckCadence"/>), and
44+
/// <c>includePreReleases</c>. The migration is a no-op because every
45+
/// field has a safe default — pre-v7 files deserialize to
46+
/// <see cref="AutoUpdateMode.NotifyOnly"/> / <see cref="UpdateCheckCadence.Daily"/> /
47+
/// <c>false</c>, which exactly reflects the "no auto-update opted into yet"
48+
/// posture Phase 2.2 ships with.</para>
4049
/// </summary>
4150
internal static class SettingsMigrations
4251
{
@@ -58,6 +67,7 @@ public static JsonObject MigrateUpTo(JsonObject obj, int fromVersion, int toVers
5867
3 => MigrateV3ToV4, // adds fileListPaneWidthPixels (double, default 320)
5968
4 => MigrateV4ToV5, // adds repoRoots, defaultCloneDestination, repoUrlMappings (all defaultable)
6069
5 => MigrateV5ToV6, // adds renderSvgImage (bool, default true)
70+
6 => MigrateV6ToV7, // adds autoUpdate / updateCheckCadence / includePreReleases (all defaultable)
6171
_ => throw new InvalidOperationException($"No migration registered from version {v} to {v + 1}."),
6272
};
6373
current = step(current);
@@ -116,4 +126,14 @@ public static JsonObject MigrateUpTo(JsonObject obj, int fromVersion, int toVers
116126
/// <see cref="AppSettings.RenderSvgImage"/> as <c>true</c>.
117127
/// </summary>
118128
private static JsonObject MigrateV5ToV6(JsonObject obj) => obj;
129+
130+
/// <summary>
131+
/// v7 adds the three auto-update settings (<c>autoUpdate</c>,
132+
/// <c>updateCheckCadence</c>, <c>includePreReleases</c>). All have
133+
/// safe defaults that map to "no auto-update opted into yet" so
134+
/// the migration is a no-op; the deserializer fills in
135+
/// <see cref="AutoUpdateMode.NotifyOnly"/> /
136+
/// <see cref="UpdateCheckCadence.Daily"/> / <c>false</c>.
137+
/// </summary>
138+
private static JsonObject MigrateV6ToV7(JsonObject obj) => obj;
119139
}

DiffViewer/ViewModels/SettingsViewModel.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ public SettingsViewModel(
120120
partial void OnDefaultCloneDestinationChanged(string value) =>
121121
OnPropertyChanged(nameof(HasDefaultCloneDestination));
122122

123+
// Auto-update settings (v7)
124+
[ObservableProperty] private AutoUpdateMode _autoUpdate = AutoUpdateMode.NotifyOnly;
125+
[ObservableProperty] private UpdateCheckCadence _updateCheckCadence = UpdateCheckCadence.Daily;
126+
[ObservableProperty] private bool _includePreReleases;
127+
128+
/// <summary>Option list for the AutoUpdate dropdown in the dialog.</summary>
129+
public IReadOnlyList<AutoUpdateMode> AutoUpdateOptions { get; } = Enum.GetValues<AutoUpdateMode>();
130+
131+
/// <summary>Option list for the UpdateCheckCadence dropdown in the dialog.</summary>
132+
public IReadOnlyList<UpdateCheckCadence> UpdateCheckCadenceOptions { get; } = Enum.GetValues<UpdateCheckCadence>();
133+
123134
// Status line
124135
[ObservableProperty] private string _statusMessage = string.Empty;
125136

@@ -225,6 +236,15 @@ partial void OnConfirmRevertHunkChanged(bool value) =>
225236
partial void OnConfirmDeleteFileChanged(bool value) =>
226237
SaveIfNotSuppressed(s => s with { SuppressDeleteFileConfirmation = !value });
227238

239+
partial void OnAutoUpdateChanged(AutoUpdateMode value) =>
240+
SaveIfNotSuppressed(s => s with { AutoUpdate = value });
241+
242+
partial void OnUpdateCheckCadenceChanged(UpdateCheckCadence value) =>
243+
SaveIfNotSuppressed(s => s with { UpdateCheckCadence = value });
244+
245+
partial void OnIncludePreReleasesChanged(bool value) =>
246+
SaveIfNotSuppressed(s => s with { IncludePreReleases = value });
247+
228248
partial void OnSelectedColorPresetChanged(ColorSchemePresetName value)
229249
{
230250
if (_suppress) return;
@@ -411,6 +431,10 @@ private void LoadFromSettings()
411431
ConfirmRevertHunk = !s.SuppressRevertHunkConfirmation;
412432
ConfirmDeleteFile = !s.SuppressDeleteFileConfirmation;
413433

434+
AutoUpdate = s.AutoUpdate;
435+
UpdateCheckCadence = s.UpdateCheckCadence;
436+
IncludePreReleases = s.IncludePreReleases;
437+
414438
RepoRoots.Clear();
415439
foreach (var root in s.RepoRoots) RepoRoots.Add(root);
416440

DiffViewer/Views/SettingsDialog.xaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,40 @@
272272
Visibility="{Binding RepoUrlMappings.Count,
273273
Converter={x:Static vm:ZeroToVisibleConverter.Instance}}"
274274
Text="No remembered clones yet."/>
275+
276+
<!-- Updates (added in v7) -->
277+
<TextBlock Text="Updates" Style="{StaticResource SectionHeader}"/>
278+
<TextBlock Margin="0,0,0,6" Foreground="#666" TextWrapping="Wrap" FontStyle="Italic">
279+
DiffViewer can check GitHub Releases for a newer version. The check
280+
only runs when launched from a Velopack-installed copy; portable
281+
builds ignore these settings.
282+
</TextBlock>
283+
284+
<Grid Margin="0,2">
285+
<Grid.ColumnDefinitions>
286+
<ColumnDefinition Width="160"/>
287+
<ColumnDefinition Width="*"/>
288+
</Grid.ColumnDefinitions>
289+
<Grid.RowDefinitions>
290+
<RowDefinition/><RowDefinition/><RowDefinition/>
291+
</Grid.RowDefinitions>
292+
293+
<TextBlock Grid.Row="0" Grid.Column="0" Style="{StaticResource FieldLabel}"
294+
Text="Auto-update behavior"/>
295+
<ComboBox Grid.Row="0" Grid.Column="1" Margin="0,4"
296+
ItemsSource="{Binding AutoUpdateOptions}"
297+
SelectedItem="{Binding AutoUpdate}"/>
298+
299+
<TextBlock Grid.Row="1" Grid.Column="0" Style="{StaticResource FieldLabel}"
300+
Text="Check frequency"/>
301+
<ComboBox Grid.Row="1" Grid.Column="1" Margin="0,4"
302+
ItemsSource="{Binding UpdateCheckCadenceOptions}"
303+
SelectedItem="{Binding UpdateCheckCadence}"/>
304+
305+
<CheckBox Grid.Row="2" Grid.Column="1" Margin="0,6,0,0"
306+
Content="Include pre-release versions"
307+
IsChecked="{Binding IncludePreReleases}"/>
308+
</Grid>
275309
</StackPanel>
276310
</ScrollViewer>
277311
</DockPanel>

0 commit comments

Comments
 (0)