Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.1.0] - 2026-07-01

### Added
- Settings now has an **Enabled** checkbox for the global hotkey. Clearing it
turns the hotkey off immediately and disables the shortcut-capture box; the
choice persists across restarts, and the stored chord is kept so re-enabling
restores it. The Reset button turns the hotkey back on and restores the
Ctrl+Alt+S default.

## [1.0.4] - 2026-06-10

### Changed
Expand Down
11 changes: 11 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,15 @@
<PackageReference Include="Nerdbank.GitVersioning" PrivateAssets="all" />
</ItemGroup>

<!-- Suppress the SQLitePCLRaw.lib.e_sqlite3 advisory pulled in transitively by
Microsoft.Data.Sqlite (history.db). The fix is a SQLitePCLRaw 2.x->3.x major
jump that Microsoft.Data.Sqlite 10.x doesn't yet bind, and TreatWarningsAsErrors
turns the NuGetAudit warning into a hard restore failure. The advisory concerns
native SQLite parsing of untrusted database files; Snipdeck only ever opens its
own local history.db, so the practical exposure is minimal. Remove this once a
Microsoft.Data.Sqlite release ships a patched native library. -->
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-2m69-gcr7-jv3q" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ The v1 feature set:
colours, spinners, progress bars and interactive prompts all work — watch it live,
and keep a clean, searchable plain-text run history you can replay. Per-CLI shell,
executable path and working directory configure how runs launch.
- Global hotkey (default **Ctrl+Alt+S**, rebindable), system tray with
- Global hotkey (default **Ctrl+Alt+S**, rebindable, and switchable off), system tray with
configurable close-to-tray, Light/Dark/System theme, and per-write plus
pre-update store backups.
- Migrate from SnipCommand with the [`snipdeck-importer`](tools/Snipdeck.Importer)
Expand Down
5 changes: 4 additions & 1 deletion src/Snipdeck.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ private void InitialiseHotkey(AppConfig config)
{
_hotkey = Services.GetRequiredService<IHotkeyService>();
_hotkey.Pressed += OnHotkeyPressed;
_ = _hotkey.TryRegister(config.Hotkey);
if (config.HotkeyEnabled)
{
_ = _hotkey.TryRegister(config.Hotkey);
}
}

private void OnTrayShowRequested(object? sender, EventArgs e) => BringToFront();
Expand Down
9 changes: 7 additions & 2 deletions src/Snipdeck.App/Views/ShellPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,17 @@
TextWrapping="Wrap"
MaxWidth="200"
Visibility="{x:Bind HotkeyError, Mode=OneWay, Converter={StaticResource EmptyStringToVisibility}}" />
<CheckBox Content="Enabled"
IsChecked="{x:Bind HotkeyEnabled, Mode=TwoWay}"
VerticalAlignment="Center"
ToolTipService.ToolTip="Turn the global hotkey on or off" />
<controls:HotkeyCaptureBox
CurrentText="{x:Bind HotkeyDisplay, Mode=OneWay}"
Command="{x:Bind RebindHotkeyCommand}" />
Command="{x:Bind RebindHotkeyCommand}"
IsEnabled="{x:Bind HotkeyEnabled, Mode=OneWay}" />
<Button Content="Reset"
Command="{x:Bind ResetHotkeyCommand}"
ToolTipService.ToolTip="Reset to Ctrl+Alt+S" />
ToolTipService.ToolTip="Turn the hotkey on and reset to Ctrl+Alt+S" />
</StackPanel>
</tk:SettingsCard>

Expand Down
8 changes: 8 additions & 0 deletions src/Snipdeck.Core/Models/AppConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ public sealed class AppConfig

public HotkeyBinding Hotkey { get; set; } = HotkeyBinding.Default;

/// <summary>
/// Whether the global hotkey is registered. When false the app runs
/// without a system-wide shortcut; the stored <see cref="Hotkey"/> chord
/// is retained so re-enabling restores it. Defaults to true so existing
/// settings files (written before this option existed) keep their hotkey.
/// </summary>
public bool HotkeyEnabled { get; set; } = true;

public CloseBehaviour CloseBehaviour { get; set; } = CloseBehaviour.HideToTray;

/// <summary>
Expand Down
42 changes: 42 additions & 0 deletions src/Snipdeck.Core/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public SettingsViewModel(
CloseBehaviour = config.CloseBehaviour;
CloseBehaviourIndex = CloseBehaviourIndexFor(config.CloseBehaviour);
HotkeyDisplay = config.Hotkey.ToDisplayString();
HotkeyEnabled = config.HotkeyEnabled;
StorageDirectory = config.StoragePath ?? pathProvider.DefaultStorageDirectory;
BackupDirectory = config.BackupDirectory ?? pathProvider.DefaultBackupDirectory;
BackupRetention = config.BackupRetention;
Expand Down Expand Up @@ -130,6 +131,14 @@ public SettingsViewModel(
[ObservableProperty]
public partial string HotkeyError { get; set; } = string.Empty;

/// <summary>
/// Whether the global hotkey is on. Toggling this registers or
/// unregisters the hotkey immediately and persists the choice. When
/// false the hotkey-capture box is disabled in the UI.
/// </summary>
[ObservableProperty]
public partial bool HotkeyEnabled { get; set; }

[ObservableProperty]
public partial ThemePreference Theme { get; set; }

Expand Down Expand Up @@ -209,6 +218,33 @@ partial void OnCloseBehaviourIndexChanged(int value)
_ = PersistAsync();
}

partial void OnHotkeyEnabledChanged(bool value)
{
// During construction the hotkey is already in the state App set at
// startup; only react to genuine user toggles.
if (_suppressPersist)
{
return;
}
if (value)
{
if (_hotkeyService.TryRegister(_config.Hotkey))
{
HotkeyError = string.Empty;
}
else
{
HotkeyError = $"Couldn't set {_config.Hotkey.ToDisplayString()} — it may already be in use by another app.";
}
}
else
{
_hotkeyService.Unregister();
HotkeyError = string.Empty;
}
_ = PersistAsync();
}

[RelayCommand]
private async Task CheckForUpdatesAsync()
{
Expand Down Expand Up @@ -267,6 +303,11 @@ private void RebindHotkey(HotkeyBinding? binding)
[RelayCommand]
private void ResetHotkey()
{
// Reset also re-enables the hotkey if it was switched off. Setting the
// property first flips the UI and (when it was off) registers the
// stored chord; RebindHotkey then swaps in the default and persists,
// so the final state is enabled + Ctrl+Alt+S regardless of the start.
HotkeyEnabled = true;
RebindHotkey(HotkeyBinding.Default);
}

Expand Down Expand Up @@ -371,6 +412,7 @@ private async Task PersistAsync()
_config.BackupRetention = BackupRetention;
_config.HistoryRetentionPerSnip = HistoryRetentionPerSnip;
_config.MaxCapturedOutputBytes = (long)MaxCapturedOutputMb * 1024 * 1024;
_config.HotkeyEnabled = HotkeyEnabled;
// _config.Hotkey is set directly by RebindHotkey before this runs.
await _settingsStore.SaveAsync(_config).ConfigureAwait(true);
}
Expand Down
76 changes: 76 additions & 0 deletions tests/Snipdeck.Core.Tests/ViewModels/SettingsViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,82 @@ public void ResetHotkey_rebinds_to_the_default_chord()
Assert.Equal("S", hotkey.LastRegistered.Key);
}

[Fact]
public void Initial_hotkey_enabled_reflects_config()
{
var enabled = Build(out _, out _, new AppConfig { HotkeyEnabled = true });
Assert.True(enabled.HotkeyEnabled);

var disabled = Build(out _, out _, new AppConfig { HotkeyEnabled = false });
Assert.False(disabled.HotkeyEnabled);
}

[Fact]
public void Disabling_the_hotkey_unregisters_and_persists_the_off_state()
{
var vm = Build(out var hotkey, out var store, new AppConfig { HotkeyEnabled = true });

vm.HotkeyEnabled = false;

Assert.Equal(1, hotkey.UnregisterCount);
Assert.Equal(string.Empty, vm.HotkeyError);
Assert.Equal(1, store.SaveCount);
Assert.False(store.Current.HotkeyEnabled);
}

[Fact]
public void Enabling_the_hotkey_registers_the_stored_chord_and_persists()
{
var vm = Build(out var hotkey, out var store, new AppConfig
{
HotkeyEnabled = false,
Hotkey = HotkeyBinding.Default,
});

vm.HotkeyEnabled = true;

Assert.Equal(HotkeyBinding.Default.Key, hotkey.LastRegistered!.Key);
Assert.Equal(1, hotkey.RegisterCount);
Assert.Equal(string.Empty, vm.HotkeyError);
Assert.Equal(1, store.SaveCount);
Assert.True(store.Current.HotkeyEnabled);
}

[Fact]
public void Enabling_the_hotkey_reports_an_error_when_registration_fails()
{
var vm = Build(out var hotkey, out var store, new AppConfig
{
HotkeyEnabled = false,
Hotkey = HotkeyBinding.Default,
});
hotkey.NextRegisterResult = false;

vm.HotkeyEnabled = true;

Assert.Contains("in use", vm.HotkeyError);
// The choice is still persisted even though registration failed.
Assert.True(store.Current.HotkeyEnabled);
}

[Fact]
public void ResetHotkey_reenables_the_hotkey_when_it_was_switched_off()
{
var vm = Build(out var hotkey, out var store, new AppConfig
{
HotkeyEnabled = false,
Hotkey = new HotkeyBinding { Modifiers = HotkeyModifiers.Control | HotkeyModifiers.Shift, Key = "K" },
});

vm.ResetHotkeyCommand.Execute(null);

Assert.True(vm.HotkeyEnabled);
Assert.True(store.Current.HotkeyEnabled);
Assert.Equal("Ctrl+Alt+S", vm.HotkeyDisplay);
Assert.Equal(HotkeyBinding.Default.Modifiers, hotkey.LastRegistered!.Modifiers);
Assert.Equal("S", hotkey.LastRegistered.Key);
}

[Fact]
public async Task ChangeStoragePath_moves_the_store_to_an_empty_target_then_restarts()
{
Expand Down
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "1.0.4",
"version": "1.1.0",
"publicReleaseRefSpec": [
"^refs/tags/v\\d+\\.\\d+\\.\\d+"
]
Expand Down
Loading