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 @@ -17,6 +17,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
IL2104 on the WinAppSDK/WinRT/Jdenticon assemblies, which aren't trim-safe.

### Added
- **Change the storage location.** A "Change…" button on Settings → Storage
location lets you pick a new folder for your snips. If the folder already
contains a Snipdeck store it's adopted (your current snips are left where
they are); otherwise your store and icons are copied there (the old folder
is kept as a backup). The choice is confirmed first, and Snipdeck restarts
to apply it — the storage path is read at startup, so restarting keeps
everything consistent and avoids writing to the old location after the
switch. If the automatic restart can't run, you're prompted to restart
manually.
- **Rebindable global hotkey.** The global hotkey is now editable from
Settings: click the capture box and press a shortcut (at least one of
Ctrl/Alt/Shift plus a key). The new binding registers and persists
Expand Down
3 changes: 3 additions & 0 deletions src/Snipdeck.App/Bootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public static IServiceProvider Build()
.AddSingleton<IClipboardService, WindowsClipboardService>()
.AddSingleton<IIconNormaliser, WindowsIconNormaliser>()
.AddSingleton<IFilePickerService, WindowsFilePickerService>()
.AddSingleton<IFolderPickerService, WindowsFolderPickerService>()
.AddSingleton<IAppRestartService, WindowsAppRestartService>()
.AddSingleton<IStorageRelocationService>(new StorageRelocationService(_snipStoreFileName))
.AddSingleton<IHotkeyService, WindowsHotkeyService>()
.AddSingleton<ITrayService, HNotifyIconTrayService>()
.AddSingleton<IShellInteractions, WindowsShellInteractions>()
Expand Down
18 changes: 18 additions & 0 deletions src/Snipdeck.App/Services/WindowsAppRestartService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Snipdeck.Core.Abstractions;

using Microsoft.Windows.AppLifecycle;

namespace Snipdeck.App.Services
{
internal sealed class WindowsAppRestartService : IAppRestartService
{
public bool Restart()
{
// On success Windows App SDK terminates the process and this never
// returns. If it returns, it failed (it yields a failure reason
// rather than throwing), so report that to the caller.
_ = AppInstance.Restart(string.Empty);
return false;
}
}
}
33 changes: 33 additions & 0 deletions src/Snipdeck.App/Services/WindowsFolderPickerService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Snipdeck.Core.Abstractions;

using Windows.Storage.Pickers;

namespace Snipdeck.App.Services
{
internal sealed class WindowsFolderPickerService : IFolderPickerService
{
private readonly IServiceProvider _services;

public WindowsFolderPickerService(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
_services = services;
}

public async Task<string?> PickFolderAsync()
{
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.DocumentsLibrary,
};
picker.FileTypeFilter.Add("*");

var mainWindow = (MainWindow)_services.GetService(typeof(MainWindow))!;
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(mainWindow);
WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);

var folder = await picker.PickSingleFolderAsync();
return folder?.Path;
}
}
}
15 changes: 10 additions & 5 deletions src/Snipdeck.App/Views/ShellPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,16 @@
</tk:SettingsCard>

<tk:SettingsCard Header="Storage location"
Description="Where snips and backups live. Move support lands in a future phase.">
<TextBlock Text="{x:Bind StorageDirectory}"
Style="{ThemeResource CaptionTextBlockStyle}"
TextTrimming="CharacterEllipsis"
MaxWidth="320" />
Description="Where your snips live. Changing it restarts Snipdeck to load the new location.">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="{x:Bind StorageDirectory, Mode=OneWay}"
Style="{ThemeResource CaptionTextBlockStyle}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="260" />
<Button Content="Change…"
Command="{x:Bind ChangeStoragePathCommand}" />
</StackPanel>
</tk:SettingsCard>

<tk:SettingsCard Header="Backups to keep"
Expand Down
18 changes: 18 additions & 0 deletions src/Snipdeck.Core/Abstractions/IAppRestartService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Snipdeck.Core.Abstractions
{
/// <summary>
/// Restarts the running application. Used to apply changes that are only
/// read at startup (e.g. the storage location), so the app reloads cleanly
/// rather than continuing against stale, immutable startup state.
/// </summary>
public interface IAppRestartService
{
/// <summary>
/// Requests an application restart. On success the process is terminated
/// and this does not return; it returns <c>false</c> if the restart
/// could not be initiated, so the caller can fall back (e.g. ask the
/// user to restart manually).
/// </summary>
bool Restart();
}
}
11 changes: 11 additions & 0 deletions src/Snipdeck.Core/Abstractions/IFolderPickerService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Snipdeck.Core.Abstractions
{
/// <summary>
/// Lets the user pick a directory (e.g. a new storage location). Returns the
/// absolute path, or null if the picker was cancelled.
/// </summary>
public interface IFolderPickerService
{
Task<string?> PickFolderAsync();
}
}
51 changes: 51 additions & 0 deletions src/Snipdeck.Core/Abstractions/IStorageRelocationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace Snipdeck.Core.Abstractions
{
/// <summary>
/// What changing the storage directory to a given target implies.
/// </summary>
public enum StorageChangeOutcome
{
/// <summary>Target is the current directory — nothing to do.</summary>
NoChange,

/// <summary>
/// Target is nested inside the current storage directory (or vice
/// versa) — relocating would copy a directory into itself / delete the
/// copy. Reject it.
/// </summary>
Invalid,

/// <summary>Neither current nor target holds a store — just adopt the empty target.</summary>
SetEmptyTarget,

/// <summary>Current holds a store and the target is empty — move the store there.</summary>
MoveToTarget,

/// <summary>The target already holds a store — adopt it (the current store is left in place).</summary>
AdoptTarget,
}

/// <summary>
/// Decides what a storage-directory change means and performs the on-disk
/// relocation. UI-free and filesystem-only so it can be unit-tested against
/// real temp directories.
/// </summary>
public interface IStorageRelocationService
{
/// <summary>The store file name within a storage directory (e.g. "store.json").</summary>
string StoreFileName { get; }

/// <summary>Classifies a proposed change from <paramref name="currentDirectory"/> to <paramref name="targetDirectory"/>.</summary>
StorageChangeOutcome Inspect(string currentDirectory, string targetDirectory);

/// <summary>
/// Copies the store file and the icons subdirectory from the current
/// directory to the target. Non-destructive: the originals are left in
/// place as a safety copy. We never delete the old store from the
/// running process — the storage path is only re-read after the restart,
/// and a failed restart would otherwise leave the app writing to a
/// directory we'd already emptied.
/// </summary>
void CopyStore(string currentDirectory, string targetDirectory);
}
}
84 changes: 84 additions & 0 deletions src/Snipdeck.Core/Services/StorageRelocationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using Snipdeck.Core.Abstractions;

namespace Snipdeck.Core.Services
{
/// <summary>
/// Filesystem implementation of <see cref="IStorageRelocationService"/>.
/// Relocates the store file plus the icons subdirectory and leaves the
/// settings/backups alone (those have their own locations).
/// </summary>
public sealed class StorageRelocationService : IStorageRelocationService
{
private const string _iconsSubdirectory = "icons";

public StorageRelocationService(string storeFileName = "store.json")
{
ArgumentException.ThrowIfNullOrWhiteSpace(storeFileName);
StoreFileName = storeFileName;
}

public string StoreFileName { get; }

public StorageChangeOutcome Inspect(string currentDirectory, string targetDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(currentDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory);

return PathsEqual(currentDirectory, targetDirectory) ? StorageChangeOutcome.NoChange
: IsNested(targetDirectory, currentDirectory) || IsNested(currentDirectory, targetDirectory) ? StorageChangeOutcome.Invalid
: File.Exists(StorePath(targetDirectory)) ? StorageChangeOutcome.AdoptTarget
: File.Exists(StorePath(currentDirectory)) ? StorageChangeOutcome.MoveToTarget
: StorageChangeOutcome.SetEmptyTarget;
}

public void CopyStore(string currentDirectory, string targetDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(currentDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory);

_ = Directory.CreateDirectory(targetDirectory);

var sourceStore = StorePath(currentDirectory);
if (File.Exists(sourceStore))
{
File.Copy(sourceStore, StorePath(targetDirectory), overwrite: true);
}

var sourceIcons = Path.Combine(currentDirectory, _iconsSubdirectory);
if (Directory.Exists(sourceIcons))
{
CopyDirectory(sourceIcons, Path.Combine(targetDirectory, _iconsSubdirectory));
}
}

private string StorePath(string directory) => Path.Combine(directory, StoreFileName);

private static bool PathsEqual(string a, string b) =>
string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase);

// True when "descendant" is a proper subdirectory of "ancestor".
private static bool IsNested(string descendant, string ancestor)
{
var d = Normalize(descendant);
var a = Normalize(ancestor);
return d.Length > a.Length
&& d.StartsWith(a + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}

private static string Normalize(string path) =>
Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));

private static void CopyDirectory(string source, string destination)
{
_ = Directory.CreateDirectory(destination);
foreach (var file in Directory.EnumerateFiles(source))
{
File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true);
}
foreach (var directory in Directory.EnumerateDirectories(source))
{
CopyDirectory(directory, Path.Combine(destination, Path.GetFileName(directory)));
}
}
}
}
Loading
Loading