From ddffb4d588aee23afe5c25b945acae4c9b2305ca Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 15:51:27 +0000 Subject: [PATCH 1/3] Phase 1 foundation + repo conventions Lands the UI-free Core (domain, substitution engine, JSON store, settings, backup, first-run seed) with 63 unit tests, plus the project-wide ground rules that the rest of the build will follow. Core (Snipdeck.Core, net10.0): - Models: Cli, Snip, Parameter (Text / Choice), AppConfig, HotkeyBinding (values matching Win32 MOD_*), ThemePreference, CloseBehaviour, BackupInfo, versioned SnipStoreDocument. - Engine: SubstitutionEngine with a strict {placeholder} regex (so JSON braces pass through), returning resolved text plus the unresolved-token list in first-appearance order for live previews. - Abstractions: ISnipStore, ISettingsStore, IBackupService, IClock. - Services: JsonSnipStore / JsonSettingsStore with temp-file-then-rename atomic writes and schema-version guards; BackupService with default retention 20 + collision-safe filenames + prune-by-pattern-only; SystemClock; ExamplesSeed for the first-run "Examples" CLI. Repo conventions: - Apache 2.0 LICENSE. - README and Keep-a-Changelog CHANGELOG. - CONTRIBUTING.md covering prerequisites, the Core/App boundary discipline, package policy, brace rule, British English, the tag-driven release flow. - .editorconfig taken verbatim from StuartMeeks/NextIteration.SpectreConsole.SelfUpdate. - Directory.Build.props with Authors / Company / dynamic copyright, nullable, implicit usings, LangVersion=latest, TreatWarningsAsErrors=true, EnforceCodeStyleInBuild=true. - Directory.Packages.props for Central Package Management (ManagePackageVersionsCentrally + CentralPackageTransitivePinningEnabled). - GitHub Actions: ci.yml (Core on ubuntu, full solution on windows) and release.yml (tag-driven, stable vs pre-release detected from the tag, publishes Snipdeck.App and packs with Velopack). CLAUDE.md updated with the four confirmed product decisions (configurable hide-to-tray default, every-write backups keeping 20, default Ctrl+Alt+S hotkey, single Examples CLI on first run) plus the latest-NuGet, README/CHANGELOG-as-you-go, and Apache 2.0 conventions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .editorconfig | 103 ++++++++ .github/workflows/ci.yml | 85 +++++++ .github/workflows/release.yml | 103 ++++++++ CHANGELOG.md | 61 +++++ CLAUDE.md | 25 +- CONTRIBUTING.md | 124 ++++++++++ Directory.Build.props | 18 ++ Directory.Packages.props | 31 +++ LICENSE | 201 +++++++++++++++ README.md | 70 ++++++ src/Snipdeck.App/Snipdeck.App.csproj | 11 +- .../Abstractions/IBackupService.cs | 13 + src/Snipdeck.Core/Abstractions/IClock.cs | 7 + .../Abstractions/ISettingsStore.cs | 13 + src/Snipdeck.Core/Abstractions/ISnipStore.cs | 13 + .../Engine/SubstitutionEngine.cs | 64 +++++ .../Engine/SubstitutionResult.cs | 7 + src/Snipdeck.Core/Models/AppConfig.cs | 19 ++ src/Snipdeck.Core/Models/BackupInfo.cs | 4 + src/Snipdeck.Core/Models/Cli.cs | 11 + src/Snipdeck.Core/Models/CloseBehaviour.cs | 8 + src/Snipdeck.Core/Models/HotkeyBinding.cs | 28 +++ src/Snipdeck.Core/Models/Parameter.cs | 13 + src/Snipdeck.Core/Models/ParameterType.cs | 8 + src/Snipdeck.Core/Models/Snip.cs | 27 ++ src/Snipdeck.Core/Models/SnipStoreDocument.cs | 13 + src/Snipdeck.Core/Models/ThemePreference.cs | 9 + src/Snipdeck.Core/Services/BackupService.cs | 160 ++++++++++++ src/Snipdeck.Core/Services/ExamplesSeed.cs | 107 ++++++++ .../Services/JsonSettingsStore.cs | 113 +++++++++ src/Snipdeck.Core/Services/JsonSnipStore.cs | 101 ++++++++ src/Snipdeck.Core/Services/SystemClock.cs | 9 + src/Snipdeck.Core/Snipdeck.Core.csproj | 8 +- .../Engine/SubstitutionEngineTests.cs | 231 +++++++++++++++++ .../Services/BackupServiceTests.cs | 187 ++++++++++++++ .../Services/ExamplesSeedTests.cs | 112 +++++++++ .../Services/JsonSettingsStoreTests.cs | 132 ++++++++++ .../Services/JsonSnipStoreTests.cs | 233 ++++++++++++++++++ .../Snipdeck.Core.Tests.csproj | 22 +- .../Snipdeck.Core.Tests/Support/FakeClock.cs | 19 ++ tests/Snipdeck.Core.Tests/UnitTest1.cs | 10 - 41 files changed, 2501 insertions(+), 32 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/Snipdeck.Core/Abstractions/IBackupService.cs create mode 100644 src/Snipdeck.Core/Abstractions/IClock.cs create mode 100644 src/Snipdeck.Core/Abstractions/ISettingsStore.cs create mode 100644 src/Snipdeck.Core/Abstractions/ISnipStore.cs create mode 100644 src/Snipdeck.Core/Engine/SubstitutionEngine.cs create mode 100644 src/Snipdeck.Core/Engine/SubstitutionResult.cs create mode 100644 src/Snipdeck.Core/Models/AppConfig.cs create mode 100644 src/Snipdeck.Core/Models/BackupInfo.cs create mode 100644 src/Snipdeck.Core/Models/Cli.cs create mode 100644 src/Snipdeck.Core/Models/CloseBehaviour.cs create mode 100644 src/Snipdeck.Core/Models/HotkeyBinding.cs create mode 100644 src/Snipdeck.Core/Models/Parameter.cs create mode 100644 src/Snipdeck.Core/Models/ParameterType.cs create mode 100644 src/Snipdeck.Core/Models/Snip.cs create mode 100644 src/Snipdeck.Core/Models/SnipStoreDocument.cs create mode 100644 src/Snipdeck.Core/Models/ThemePreference.cs create mode 100644 src/Snipdeck.Core/Services/BackupService.cs create mode 100644 src/Snipdeck.Core/Services/ExamplesSeed.cs create mode 100644 src/Snipdeck.Core/Services/JsonSettingsStore.cs create mode 100644 src/Snipdeck.Core/Services/JsonSnipStore.cs create mode 100644 src/Snipdeck.Core/Services/SystemClock.cs create mode 100644 tests/Snipdeck.Core.Tests/Engine/SubstitutionEngineTests.cs create mode 100644 tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs create mode 100644 tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs create mode 100644 tests/Snipdeck.Core.Tests/Services/JsonSettingsStoreTests.cs create mode 100644 tests/Snipdeck.Core.Tests/Services/JsonSnipStoreTests.cs create mode 100644 tests/Snipdeck.Core.Tests/Support/FakeClock.cs delete mode 100644 tests/Snipdeck.Core.Tests/UnitTest1.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..41f2e91 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,103 @@ +root = true + +# ------------------------------- +# General +# ------------------------------- +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +# ------------------------------- +# C# files +# ------------------------------- +[*.cs] + +indent_size = 4 + +# New lines & braces +csharp_new_line_before_open_brace = all +csharp_prefer_braces = true:warning + +# Using directives +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = true + +# var usage (Spectre-style: pragmatic) +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +# Expression-bodied members (used where clean) +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion + +# Pattern matching / modern C# +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Nullability helpers +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion + +# Readonly fields +dotnet_style_readonly_field = true:suggestion + +# ------------------------------- +# Naming +# ------------------------------- + +# Private fields: _camelCase +dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_with_underscore + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case +dotnet_naming_style.camel_case_with_underscore.required_prefix = _ + +# Interfaces: IMyInterface +dotnet_naming_rule.interfaces_should_start_with_i.severity = suggestion +dotnet_naming_rule.interfaces_should_start_with_i.symbols = interfaces +dotnet_naming_rule.interfaces_should_start_with_i.style = interface_prefix + +dotnet_naming_symbols.interfaces.applicable_kinds = interface + +dotnet_naming_style.interface_prefix.required_prefix = I +dotnet_naming_style.interface_prefix.capitalization = pascal_case + +# ------------------------------- +# Analyzers +# ------------------------------- + +# Keep warnings visible but not painful +dotnet_analyzer_diagnostic.severity = warning + +# Unused usings +dotnet_diagnostic.IDE0005.severity = warning + +# Simplification +dotnet_diagnostic.IDE0007.severity = suggestion +dotnet_diagnostic.IDE0008.severity = suggestion + +# Documentation (Spectre.Console is pragmatic here) +dotnet_diagnostic.CS1591.severity = silent + +# ------------------------------- +# JSON / YAML +# ------------------------------- +[*.json] +indent_size = 2 + +[*.yml] +indent_size = 2 + +[*.yaml] +indent_size = 2 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c991d26 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: ci + +on: + push: + branches: [master] + pull_request: + branches: [master] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_VERSION: "10.0.x" + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + +jobs: + core-tests: + name: Core build + tests (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + cache: true + cache-dependency-path: | + **/packages.lock.json + **/*.csproj + + - name: Restore Core projects + run: | + dotnet restore src/Snipdeck.Core/Snipdeck.Core.csproj + dotnet restore tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj + + - name: Build Core + run: dotnet build src/Snipdeck.Core/Snipdeck.Core.csproj --configuration Release --no-restore + + - name: Build + run Core tests + run: dotnet test tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=core-tests.trx" --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: core-test-results + path: TestResults/ + + app-build: + name: App build (windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + cache: true + cache-dependency-path: | + **/packages.lock.json + **/*.csproj + + - name: Restore + run: dotnet restore + + - name: Build solution + run: dotnet build --configuration Release --no-restore + + - name: Run Core tests (sanity check on Windows) + run: dotnet test tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj --configuration Release --no-build --logger "trx;LogFileName=core-tests-windows.trx" --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-test-results + path: TestResults/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..901c974 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,103 @@ +name: release + +# Triggered by tags of the form vMAJOR.MINOR.PATCH (stable) or +# vMAJOR.MINOR.PATCH-suffix (pre-release; the hyphen marks it as a pre-release +# on GitHub and feeds Velopack's channel). +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" + +permissions: + contents: write + +env: + DOTNET_VERSION: "10.0.x" + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + PACK_ID: "Snipdeck" + PACK_AUTHORS: "Stuart Meeks" + PACK_TITLE: "Snipdeck" + +jobs: + release: + name: Build + publish Velopack release + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + cache: true + cache-dependency-path: | + **/packages.lock.json + **/*.csproj + + - name: Derive version + channel from tag + id: meta + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + $version = $tag.TrimStart('v') + $isPrerelease = $version.Contains('-') + if ($isPrerelease) { + $channel = ($version -split '-', 2)[1] -replace '\..*$', '' + if ([string]::IsNullOrWhiteSpace($channel)) { $channel = 'beta' } + } else { + $channel = 'stable' + } + "version=$version" >> $env:GITHUB_OUTPUT + "isPrerelease=$isPrerelease" >> $env:GITHUB_OUTPUT + "channel=$channel" >> $env:GITHUB_OUTPUT + Write-Host "Tag : $tag" + Write-Host "Version : $version" + Write-Host "Pre-release : $isPrerelease" + Write-Host "Channel : $channel" + + - name: Restore + run: dotnet restore + + - name: Run Core tests + run: dotnet test tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj --configuration Release --no-restore + + - name: Publish Snipdeck.App (win-x64) + run: dotnet publish src/Snipdeck.App/Snipdeck.App.csproj --configuration Release --runtime win-x64 --self-contained true --output publish + + - name: Install Velopack CLI + run: dotnet tool install -g vpk + + - name: Pack with Velopack + shell: pwsh + run: | + $version = "${{ steps.meta.outputs.version }}" + $channel = "${{ steps.meta.outputs.channel }}" + vpk pack ` + --packId "${{ env.PACK_ID }}" ` + --packVersion $version ` + --packDir publish ` + --mainExe Snipdeck.App.exe ` + --packTitle "${{ env.PACK_TITLE }}" ` + --packAuthors "${{ env.PACK_AUTHORS }}" ` + --channel $channel ` + --outputDir Releases + + - name: Upload Velopack artifacts + uses: actions/upload-artifact@v4 + with: + name: snipdeck-${{ steps.meta.outputs.version }} + path: Releases/ + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + prerelease: ${{ steps.meta.outputs.isPrerelease }} + generate_release_notes: true + files: | + Releases/* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bb317dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +All notable changes to Snipdeck are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Repository scaffold: `Snipdeck.Core` (net10.0, UI-free), `Snipdeck.App` (WinUI 3, + net10.0-windows), `Snipdeck.Core.Tests` (xUnit). +- Apache 2.0 licence. +- README and changelog. +- Core domain models: `Cli`, `Snip`, `Parameter` (Text / Choice), `Tag` as a + string list, root `SnipStoreDocument` with `SchemaVersion`. +- `SubstitutionEngine` — replaces `{placeholder}` tokens against a value + dictionary and returns both the resolved text and the list of unresolved tokens + in first-appearance order. Only `[A-Za-z_][A-Za-z0-9_]*` is treated as a token, + so JSON braces and other literal braces pass through. +- `ISnipStore` / `JsonSnipStore` — `System.Text.Json`-backed store with + temp-file-then-rename atomic writes, schema-version guarding, and a single + semaphore protecting concurrent access. +- `ISettingsStore` / `JsonSettingsStore` — application settings stored separately + from the snip store, with defaults (`Theme = System`, `CloseBehaviour = + HideToTray`, hotkey = Ctrl+Alt+S) applied when the file is missing. +- `IBackupService` / `BackupService` — copies the snip store to a timestamped + filename on demand, prunes to the configured retention (default 20), and + exposes a newest-first listing. +- `IClock` / `SystemClock` for testable time. +- `ExamplesSeed` — first-run seed producing one "Examples" CLI with a handful of + representative Snips (Text + Choice parameters, tags, a favourite). +- GitHub Actions CI workflow: builds Core on Ubuntu, builds the full solution on + Windows, runs Core tests on both. +- GitHub Actions release workflow: tag-triggered (`v*.*.*` stable, `v*.*.*-*` + pre-release), publishes the app, packs with Velopack, and attaches the + artefacts to a GitHub Release. +- `.editorconfig` taken verbatim from + `StuartMeeks/NextIteration.SpectreConsole.SelfUpdate`. Enforces brace style, + `using` ordering, naming conventions, and analyser severities. +- `Directory.Build.props` at the repo root: shared `Authors` ("Stuart Meeks"), + `Company` ("Next Iteration"), copyright, nullability, implicit usings, latest + `LangVersion`, and **`TreatWarningsAsErrors=true`** + `EnforceCodeStyleInBuild`. +- `Directory.Packages.props` at the repo root for Central Package Management + (`ManagePackageVersionsCentrally=true`, + `CentralPackageTransitivePinningEnabled=true`). +- `CONTRIBUTING.md` covering prerequisites, the non-negotiables, the + Core / App boundary discipline, the release process, and how to ask for + direction when something's genuinely ambiguous. + +### Changed +- All Core source converted to block-scoped namespaces, collection expressions + (`[]`), and a `GeneratedRegex`-backed token regex to comply with the + editorconfig under `TreatWarningsAsErrors`. +- `JsonSnipStore`, `JsonSettingsStore`, and `BackupService` now implement + `IDisposable` to release their internal semaphores (CA1001). +- `Snipdeck.App.csproj` no longer carries `` (now inherited from + `Directory.Build.props`). +- Bumped test-project dependencies to latest stable: `coverlet.collector` 6.0.4 → + 10.0.1, `Microsoft.NET.Test.Sdk` 17.14.1 → 18.6.0, `xunit.runner.visualstudio` + 3.1.4 → 3.1.5. diff --git a/CLAUDE.md b/CLAUDE.md index 22e28fe..deeade4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,11 @@ Conventions: - One JSON document (the "store") via `System.Text.Json`. - The storage directory and the backups directory are both user-configurable. +- **First run** — if the store does not yet exist when the app launches, the app + seeds it with a single demo CLI called "Examples", containing a handful of + representative Snips (mix of Text and Choice parameters, tags, a favourite). + The user can delete it once they're oriented. Do **not** seed the store again + on subsequent launches. - **App config is stored separately from the store**, in `LocalAppData`. The store's own location cannot live inside the store (chicken-and-egg). The storage path, backup settings and theme choice all live in app config. @@ -93,8 +98,9 @@ Conventions: overwrite the store in place — a crash mid-write would corrupt it. - When the storage path changes at runtime, decide deliberately: move the existing store, adopt an existing store already at the new path, or warn on conflict. -- Backup policy (provisional default — confirm with Stu): timestamped copy of the - store on launch and immediately before every Velopack update; retain the last N. +- Backup policy: timestamped snapshot of the store on **every successful write** + and immediately before every Velopack update; retain the last 20, pruning the + oldest. Backups live in the user-configurable backup directory. ## Shell & UX @@ -137,7 +143,11 @@ Conventions: - **Single instance only** (and therefore a single tray icon). Use the Windows App SDK `AppInstance` keyed registration + activation redirection — **not** a named mutex. -- The global hotkey summons / foregrounds the single running instance. +- The global hotkey summons / foregrounds the single running instance. Default + binding is **Ctrl+Alt+S**, user-rebindable from Settings. +- **Close-button behaviour** is configurable, defaulting to **hide-to-tray** (the + process keeps running so the hotkey stays live; explicit Exit lives on the tray + menu). The opt-out flips it to a hard exit on close. - **Boot order is strict**: Velopack hook (`VelopackApp.Build().Run()`) → single-instance check / redirect → DI container + UI. Velopack must run first so it can intercept install/update/uninstall invocations and handle the post-update relaunch. @@ -149,6 +159,15 @@ Conventions: - British English spelling in all user-facing copy and documentation. - Always use curly braces in C#, even for single-line `if` / `else` / loops. - Enterprise-professional tone in product copy. No emojis in UI copy. +- Licensed under **Apache 2.0** (`LICENSE` at the repo root). New source files do + not need per-file licence headers; the root licence covers the project. +- When adding or updating a NuGet reference, pull the **latest stable** version + unless there is a documented reason not to. Don't quietly pin to an older + patch. +- Every user-visible change — new feature, behaviour change, bug fix, removed + capability — gets a line under `## [Unreleased]` in `CHANGELOG.md` (Keep a + Changelog format). `README.md` is the public-facing intro and stays in sync + with what the app actually does today, not aspirational. ## Out of scope for v1 — do not build speculatively diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6508d99 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +# Contributing to Snipdeck + +Thanks for your interest. This guide covers the working agreement, the +non-negotiables, and how to get a change to "green". + +## Prerequisites + +- **.NET 10 SDK** (`dotnet --list-sdks` should show a `10.0.x` SDK). +- **Windows 11** to build and run the WinUI 3 head (`Snipdeck.App`). The + `Snipdeck.Core` project and its tests are pure `net10.0` and build/run on any + platform the SDK supports. +- For releases: Visual Studio 2022 17.x with the Windows App SDK workload, or + its CLI equivalents. + +## Build and test + +```bash +# Restore + build everything +dotnet build + +# Build Core only (works on Linux / macOS) +dotnet build src/Snipdeck.Core + +# Run Core tests +dotnet test tests/Snipdeck.Core.Tests +``` + +On a non-Windows machine, restoring the `Snipdeck.App` project requires +`EnableWindowsTargeting=true`: + +```bash +EnableWindowsTargeting=true dotnet restore +``` + +You will not be able to *build* the App project off Windows; the XAML compiler +is a Windows-only binary. CI (`.github/workflows/ci.yml`) builds Core on Ubuntu +and the full solution on Windows. + +## Non-negotiables + +These are checked into project configuration and will fail the build, not just +politely warn: + +- **`TreatWarningsAsErrors=true`** — every compiler warning, analyser warning, + and IDE-style violation is a build error. Fix the underlying issue rather than + adding `#pragma warning disable`. If you genuinely need to suppress something, + do it narrowly (a single line, with a comment explaining why) — not + project-wide. +- **`.editorconfig`** — committed at the repo root, taken verbatim from + `StuartMeeks/NextIteration.SpectreConsole.SelfUpdate`. Format your code to + match (`dotnet format` is your friend). Brace style, `using` ordering, naming + (private fields are `_camelCase`), and analyser severities are all set there. +- **Curly braces, always.** Even single-line `if` / `else` / `for` / `foreach` / + `while` / `using` bodies use braces. The editorconfig enforces this + (`csharp_prefer_braces = true:warning`), but treat it as a rule of thumb you + follow without needing the analyser to tell you. +- **British English** in all user-facing copy, documentation, and + `// these comments`. (Code identifiers follow .NET API conventions, which are + American — leave `Colour` out of API surfaces and use `Color` if it's an SDK + type.) +- **Enterprise-professional tone** in product copy. No emojis in UI strings. + +## Architecture rules + +These come from `CLAUDE.md` — read it before writing code that crosses the +project boundary. + +- `Snipdeck.Core` is UI-free (`net10.0`, not `net10.0-windows`). It contains the + domain, the substitution engine, JSON store, settings, backup, view models, + and every service interface. +- `Snipdeck.App` is the WinUI 3 head and the only project allowed to depend on + WinUI / Windows / Win32 / Windows App SDK types. +- The dependency direction is one-way: **`App → Core`**. Never the reverse. +- View models reference only Core abstractions — no `Frame`, `NavigationView`, + `DispatcherQueue`, etc. If you find yourself reaching for a WinUI type inside + a view model, route it through an interface in `Snipdeck.Core/Abstractions/` + and add the implementation in `Snipdeck.App`. + +## Packages + +- Versions are centralised in `Directory.Packages.props` (Central Package + Management). Add new packages with `` there and reference them + in the consuming csproj with `` (no + `Version` attribute). +- Use the **latest stable** version unless there is a documented reason + otherwise. Don't quietly pin to an older patch. + +## Tests + +- Tests live in `tests/Snipdeck.Core.Tests/` and use xUnit. The naming + convention is `Method_or_subject_then_expected_behaviour()` — underscores are + fine, the test-only `NoWarn` block in the csproj accepts it. +- Cover the substitution engine and the JSON store heavily; these are the bits + where a silent bug hurts most. +- Use `FakeClock` for any service that depends on `IClock`. + +## Commits and pull requests + +- One logical change per commit. The body explains *why*, not *what*. +- Every user-visible change (feature, behaviour change, bug fix, removed + capability) gets a line under `## [Unreleased]` in `CHANGELOG.md` + (Keep-a-Changelog format). +- Keep `README.md` in sync with what the app actually does today — not what we + intend to ship later. +- PR titles are short, present-tense imperative + (e.g. `Add tray icon implementation`). + +## Releases + +Releases are driven by git tags: + +- `v1.2.3` — stable release. +- `v1.2.3-rc.1`, `v1.2.3-beta.4`, etc. — pre-release. The hyphen in the tag + marks the GitHub release as a pre-release and feeds Velopack's channel name. + +Pushing a matching tag triggers `.github/workflows/release.yml`, which builds +`Snipdeck.App`, packs it with Velopack, and attaches the artefacts to a new +GitHub Release. Don't push tags from feature branches — release from `master`. + +## Asking for direction + +When a product decision is genuinely ambiguous, ask rather than guess. Open an +issue or raise it in the PR description — it's cheaper than building the wrong +thing. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..13c208b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,18 @@ + + + + Stuart Meeks + Next Iteration + Copyright © $([System.DateTime]::UtcNow.Year) Stuart Meeks + + + + enable + enable + latest + true + true + true + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..036aa81 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,31 @@ + + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9cded64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing the Work or + Derivative Works thereof, You may choose to offer, and charge a + fee for, acceptance of support, warranty, indemnity, or other + liability obligations and/or rights consistent with this License. + However, in accepting such obligations, You may act only on Your + own behalf and on Your sole responsibility, not on behalf of any + other Contributor, and only if You agree to indemnify, defend, + and hold each Contributor harmless for any liability incurred by, + or claims asserted against, such Contributor by reason of your + accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Stuart Meeks + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing + permissions and limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..32cf461 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Snipdeck + +A native Windows desktop app for managing parameterised CLI command snippets +("Snips"), organised by the CLI they belong to. Browse a CLI, pick a Snip, fill +its arguments, and copy the resolved command to the clipboard. + +Conceptually inspired by SnipCommand, with one defining difference: **the CLI is +the top-level organising axis** — every Snip belongs to exactly one CLI (e.g. +`pl-app`, `mpt-app`, `inv-app`). + +> Snipdeck is in early development. The list below describes what's actually +> implemented today, not what's planned. + +## Status + +In active development. The data model, JSON store, substitution engine, settings, +backup service, and first-run seed live in `Snipdeck.Core` and are covered by +unit tests. The WinUI 3 UI shell, platform services, and Velopack updater are +being layered on next. + +## Stack + +- .NET 10, C# +- WinUI 3 (Windows App SDK) — unpackaged +- MVVM via [`CommunityToolkit.Mvvm`](https://github.com/CommunityToolkit/dotnet) +- DI via `Microsoft.Extensions.DependencyInjection` +- JSON persistence via `System.Text.Json` +- Identicons via [`Jdenticon-net`](https://github.com/dmester/jdenticon-net) +- Tray icon via [`H.NotifyIcon`](https://github.com/HavenDV/H.NotifyIcon) +- Install + self-update via [Velopack](https://velopack.io/) +- Tests via xUnit + +## Repository layout + +``` +src/ + Snipdeck.Core/ net10.0 — UI-free domain, engine, store, services + Snipdeck.App/ net10.0-windows — WinUI 3 head, platform implementations +tests/ + Snipdeck.Core.Tests/ net10.0 — xUnit coverage for Core +``` + +The dependency direction is one-way: `App → Core`. The view models live in Core +and never touch WinUI types directly — every platform-bound capability is an +interface defined in Core and implemented in App. + +## Building + +Requirements: + +- Windows 11 (Windows 10 1809+ may work but is not the target) +- .NET 10 SDK +- Visual Studio 2022 17.x with the **Windows App SDK** workload, *or* the + command-line equivalents + +```powershell +# Restore + build everything +dotnet build + +# Run Core unit tests +dotnet test tests/Snipdeck.Core.Tests +``` + +The `Snipdeck.Core` project targets `net10.0` and is fully portable, so +`dotnet build` / `dotnet test` for Core also work on Linux and macOS. The +`Snipdeck.App` project is Windows-only. + +## Licence + +Licensed under the Apache Licence, Version 2.0. See [`LICENSE`](LICENSE). diff --git a/src/Snipdeck.App/Snipdeck.App.csproj b/src/Snipdeck.App/Snipdeck.App.csproj index eb64bfa..41cabc9 100644 --- a/src/Snipdeck.App/Snipdeck.App.csproj +++ b/src/Snipdeck.App/Snipdeck.App.csproj @@ -10,7 +10,6 @@ true false true - enable None true x64 @@ -39,11 +38,11 @@ - - - - - + + + + + diff --git a/src/Snipdeck.Core/Abstractions/IBackupService.cs b/src/Snipdeck.Core/Abstractions/IBackupService.cs new file mode 100644 index 0000000..0cb1fcd --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IBackupService.cs @@ -0,0 +1,13 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Abstractions +{ + public interface IBackupService + { + string BackupDirectory { get; } + + Task CreateBackupAsync(CancellationToken cancellationToken = default); + + Task> ListBackupsAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Snipdeck.Core/Abstractions/IClock.cs b/src/Snipdeck.Core/Abstractions/IClock.cs new file mode 100644 index 0000000..e56cbf8 --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IClock.cs @@ -0,0 +1,7 @@ +namespace Snipdeck.Core.Abstractions +{ + public interface IClock + { + DateTimeOffset UtcNow { get; } + } +} diff --git a/src/Snipdeck.Core/Abstractions/ISettingsStore.cs b/src/Snipdeck.Core/Abstractions/ISettingsStore.cs new file mode 100644 index 0000000..1beebec --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/ISettingsStore.cs @@ -0,0 +1,13 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Abstractions +{ + public interface ISettingsStore + { + string FilePath { get; } + + Task LoadAsync(CancellationToken cancellationToken = default); + + Task SaveAsync(AppConfig config, CancellationToken cancellationToken = default); + } +} diff --git a/src/Snipdeck.Core/Abstractions/ISnipStore.cs b/src/Snipdeck.Core/Abstractions/ISnipStore.cs new file mode 100644 index 0000000..bcb5caa --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/ISnipStore.cs @@ -0,0 +1,13 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Abstractions +{ + public interface ISnipStore + { + string FilePath { get; } + + Task LoadAsync(CancellationToken cancellationToken = default); + + Task SaveAsync(SnipStoreDocument document, CancellationToken cancellationToken = default); + } +} diff --git a/src/Snipdeck.Core/Engine/SubstitutionEngine.cs b/src/Snipdeck.Core/Engine/SubstitutionEngine.cs new file mode 100644 index 0000000..5fb7345 --- /dev/null +++ b/src/Snipdeck.Core/Engine/SubstitutionEngine.cs @@ -0,0 +1,64 @@ +using System.Text.RegularExpressions; + +namespace Snipdeck.Core.Engine +{ + public static partial class SubstitutionEngine + { + [GeneratedRegex(@"\{([A-Za-z_][A-Za-z0-9_]*)\}", RegexOptions.CultureInvariant)] + private static partial Regex TokenRegex(); + + public static SubstitutionResult Substitute( + string template, + IReadOnlyDictionary values) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(values); + + if (template.Length == 0) + { + return new SubstitutionResult(string.Empty, []); + } + + var seen = new HashSet(StringComparer.Ordinal); + var unresolved = new List(); + + var resolved = TokenRegex().Replace(template, match => + { + var name = match.Groups[1].Value; + if (values.TryGetValue(name, out var value) && value is not null) + { + return value; + } + if (seen.Add(name)) + { + unresolved.Add(name); + } + return match.Value; + }); + + return new SubstitutionResult(resolved, unresolved); + } + + public static IReadOnlyList ExtractTokens(string template) + { + ArgumentNullException.ThrowIfNull(template); + + if (template.Length == 0) + { + return []; + } + + var seen = new HashSet(StringComparer.Ordinal); + var tokens = new List(); + foreach (Match match in TokenRegex().Matches(template)) + { + var name = match.Groups[1].Value; + if (seen.Add(name)) + { + tokens.Add(name); + } + } + return tokens; + } + } +} diff --git a/src/Snipdeck.Core/Engine/SubstitutionResult.cs b/src/Snipdeck.Core/Engine/SubstitutionResult.cs new file mode 100644 index 0000000..ba22d19 --- /dev/null +++ b/src/Snipdeck.Core/Engine/SubstitutionResult.cs @@ -0,0 +1,7 @@ +namespace Snipdeck.Core.Engine +{ + public sealed record SubstitutionResult(string Text, IReadOnlyList UnresolvedTokens) + { + public bool IsFullyResolved => UnresolvedTokens.Count == 0; + } +} diff --git a/src/Snipdeck.Core/Models/AppConfig.cs b/src/Snipdeck.Core/Models/AppConfig.cs new file mode 100644 index 0000000..db90cd5 --- /dev/null +++ b/src/Snipdeck.Core/Models/AppConfig.cs @@ -0,0 +1,19 @@ +namespace Snipdeck.Core.Models +{ + public sealed class AppConfig + { + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; set; } = CurrentSchemaVersion; + + public string? StoragePath { get; set; } + + public string? BackupDirectory { get; set; } + + public ThemePreference Theme { get; set; } = ThemePreference.System; + + public HotkeyBinding Hotkey { get; set; } = HotkeyBinding.Default; + + public CloseBehaviour CloseBehaviour { get; set; } = CloseBehaviour.HideToTray; + } +} diff --git a/src/Snipdeck.Core/Models/BackupInfo.cs b/src/Snipdeck.Core/Models/BackupInfo.cs new file mode 100644 index 0000000..19c3a4b --- /dev/null +++ b/src/Snipdeck.Core/Models/BackupInfo.cs @@ -0,0 +1,4 @@ +namespace Snipdeck.Core.Models +{ + public sealed record BackupInfo(string FilePath, DateTimeOffset CreatedAtUtc, long SizeBytes); +} diff --git a/src/Snipdeck.Core/Models/Cli.cs b/src/Snipdeck.Core/Models/Cli.cs new file mode 100644 index 0000000..e34307c --- /dev/null +++ b/src/Snipdeck.Core/Models/Cli.cs @@ -0,0 +1,11 @@ +namespace Snipdeck.Core.Models +{ + public sealed class Cli + { + public Guid Id { get; init; } = Guid.NewGuid(); + + public string Name { get; set; } = string.Empty; + + public string? IconRef { get; set; } + } +} diff --git a/src/Snipdeck.Core/Models/CloseBehaviour.cs b/src/Snipdeck.Core/Models/CloseBehaviour.cs new file mode 100644 index 0000000..0f9487a --- /dev/null +++ b/src/Snipdeck.Core/Models/CloseBehaviour.cs @@ -0,0 +1,8 @@ +namespace Snipdeck.Core.Models +{ + public enum CloseBehaviour + { + HideToTray = 0, + Exit = 1, + } +} diff --git a/src/Snipdeck.Core/Models/HotkeyBinding.cs b/src/Snipdeck.Core/Models/HotkeyBinding.cs new file mode 100644 index 0000000..774a7af --- /dev/null +++ b/src/Snipdeck.Core/Models/HotkeyBinding.cs @@ -0,0 +1,28 @@ +namespace Snipdeck.Core.Models +{ + [Flags] + public enum HotkeyModifiers + { + None = 0, + Alt = 1, + Control = 2, + Shift = 4, + Windows = 8, + } + + public sealed class HotkeyBinding + { + public HotkeyModifiers Modifiers { get; set; } = HotkeyModifiers.None; + + public string Key { get; set; } = string.Empty; + + public bool IsEmpty => + Modifiers == HotkeyModifiers.None && string.IsNullOrWhiteSpace(Key); + + public static HotkeyBinding Default => new() + { + Modifiers = HotkeyModifiers.Control | HotkeyModifiers.Alt, + Key = "S", + }; + } +} diff --git a/src/Snipdeck.Core/Models/Parameter.cs b/src/Snipdeck.Core/Models/Parameter.cs new file mode 100644 index 0000000..20a0f22 --- /dev/null +++ b/src/Snipdeck.Core/Models/Parameter.cs @@ -0,0 +1,13 @@ +namespace Snipdeck.Core.Models +{ + public sealed class Parameter + { + public string Name { get; set; } = string.Empty; + + public ParameterType Type { get; set; } = ParameterType.Text; + + public List Options { get; set; } = []; + + public string? Default { get; set; } + } +} diff --git a/src/Snipdeck.Core/Models/ParameterType.cs b/src/Snipdeck.Core/Models/ParameterType.cs new file mode 100644 index 0000000..eaa3aeb --- /dev/null +++ b/src/Snipdeck.Core/Models/ParameterType.cs @@ -0,0 +1,8 @@ +namespace Snipdeck.Core.Models +{ + public enum ParameterType + { + Text = 0, + Choice = 1, + } +} diff --git a/src/Snipdeck.Core/Models/Snip.cs b/src/Snipdeck.Core/Models/Snip.cs new file mode 100644 index 0000000..30e6258 --- /dev/null +++ b/src/Snipdeck.Core/Models/Snip.cs @@ -0,0 +1,27 @@ +namespace Snipdeck.Core.Models +{ + public sealed class Snip + { + public Guid Id { get; init; } = Guid.NewGuid(); + + public Guid CliId { get; set; } + + public string Title { get; set; } = string.Empty; + + public string CommandTemplate { get; set; } = string.Empty; + + public string? Description { get; set; } + + public List Parameters { get; set; } = []; + + public List Tags { get; set; } = []; + + public bool IsFavourite { get; set; } + + public bool IsTrash { get; set; } + + public int UsageCount { get; set; } + + public DateTimeOffset? LastUsedAt { get; set; } + } +} diff --git a/src/Snipdeck.Core/Models/SnipStoreDocument.cs b/src/Snipdeck.Core/Models/SnipStoreDocument.cs new file mode 100644 index 0000000..6631c8f --- /dev/null +++ b/src/Snipdeck.Core/Models/SnipStoreDocument.cs @@ -0,0 +1,13 @@ +namespace Snipdeck.Core.Models +{ + public sealed class SnipStoreDocument + { + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; set; } = CurrentSchemaVersion; + + public List Clis { get; set; } = []; + + public List Snips { get; set; } = []; + } +} diff --git a/src/Snipdeck.Core/Models/ThemePreference.cs b/src/Snipdeck.Core/Models/ThemePreference.cs new file mode 100644 index 0000000..0e0f3ec --- /dev/null +++ b/src/Snipdeck.Core/Models/ThemePreference.cs @@ -0,0 +1,9 @@ +namespace Snipdeck.Core.Models +{ + public enum ThemePreference + { + System = 0, + Light = 1, + Dark = 2, + } +} diff --git a/src/Snipdeck.Core/Services/BackupService.cs b/src/Snipdeck.Core/Services/BackupService.cs new file mode 100644 index 0000000..c49f61e --- /dev/null +++ b/src/Snipdeck.Core/Services/BackupService.cs @@ -0,0 +1,160 @@ +using System.Globalization; + +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Services +{ + public sealed class BackupService : IBackupService, IDisposable + { + public const int DefaultRetention = 20; + private const string _filenamePrefix = "snipstore_"; + private const string _filenameSuffix = ".json"; + private const string _timestampFormat = "yyyyMMdd_HHmmssfff"; + + private readonly string _sourceFilePath; + private readonly IClock _clock; + private readonly int _retention; + private readonly SemaphoreSlim _gate = new(1, 1); + + public BackupService(string sourceFilePath, string backupDirectory, IClock clock, int retention = DefaultRetention) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(backupDirectory); + ArgumentNullException.ThrowIfNull(clock); + if (retention < 1) + { + throw new ArgumentOutOfRangeException(nameof(retention), retention, "Retention must be at least 1."); + } + + _sourceFilePath = sourceFilePath; + BackupDirectory = backupDirectory; + _clock = clock; + _retention = retention; + } + + public string BackupDirectory { get; } + + public async Task CreateBackupAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!File.Exists(_sourceFilePath)) + { + return null; + } + + _ = Directory.CreateDirectory(BackupDirectory); + + var now = _clock.UtcNow; + var timestamp = now.UtcDateTime.ToString(_timestampFormat, CultureInfo.InvariantCulture); + + var destinationPath = Path.Combine(BackupDirectory, _filenamePrefix + timestamp + _filenameSuffix); + var collisionIndex = 1; + while (File.Exists(destinationPath)) + { + destinationPath = Path.Combine( + BackupDirectory, + $"{_filenamePrefix}{timestamp}-{collisionIndex}{_filenameSuffix}"); + collisionIndex++; + } + + File.Copy(_sourceFilePath, destinationPath); + + PruneStaleBackups(); + + var size = new FileInfo(destinationPath).Length; + return new BackupInfo(destinationPath, now, size); + } + finally + { + _ = _gate.Release(); + } + } + + public Task> ListBackupsAsync(CancellationToken cancellationToken = default) + { + if (!Directory.Exists(BackupDirectory)) + { + return Task.FromResult>([]); + } + + var infos = EnumerateBackupFiles() + .OrderByDescending(name => name, StringComparer.Ordinal) + .Select(path => + { + var info = new FileInfo(path); + var createdAt = TryParseTimestamp(info.Name, out var ts) + ? ts + : new DateTimeOffset(info.CreationTimeUtc, TimeSpan.Zero); + return new BackupInfo(path, createdAt, info.Length); + }) + .ToList(); + + return Task.FromResult>(infos); + } + + public void Dispose() + { + _gate.Dispose(); + } + + private IEnumerable EnumerateBackupFiles() + { + return Directory.EnumerateFiles(BackupDirectory, _filenamePrefix + "*" + _filenameSuffix); + } + + private void PruneStaleBackups() + { + var ordered = EnumerateBackupFiles() + .OrderByDescending(name => name, StringComparer.Ordinal) + .ToList(); + + for (var i = _retention; i < ordered.Count; i++) + { + try + { + File.Delete(ordered[i]); + } + catch (IOException) + { + // Best-effort: a backup file may be in use. Skip and retry next time. + } + } + } + + private static bool TryParseTimestamp(string fileName, out DateTimeOffset result) + { + result = default; + if (!fileName.StartsWith(_filenamePrefix, StringComparison.Ordinal) + || !fileName.EndsWith(_filenameSuffix, StringComparison.Ordinal)) + { + return false; + } + + var core = fileName.Substring( + _filenamePrefix.Length, + fileName.Length - _filenamePrefix.Length - _filenameSuffix.Length); + + var dashIndex = core.IndexOf('-', _timestampFormat.Length - 1); + if (dashIndex > 0) + { + core = core[..dashIndex]; + } + + if (DateTime.TryParseExact( + core, + _timestampFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + result = new DateTimeOffset(parsed, TimeSpan.Zero); + return true; + } + + return false; + } + } +} diff --git a/src/Snipdeck.Core/Services/ExamplesSeed.cs b/src/Snipdeck.Core/Services/ExamplesSeed.cs new file mode 100644 index 0000000..33ecc7b --- /dev/null +++ b/src/Snipdeck.Core/Services/ExamplesSeed.cs @@ -0,0 +1,107 @@ +using Snipdeck.Core.Engine; +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Services +{ + public static class ExamplesSeed + { + public const string CliName = "Examples"; + + public static bool IsEmpty(SnipStoreDocument document) + { + ArgumentNullException.ThrowIfNull(document); + return document.Clis.Count == 0 && document.Snips.Count == 0; + } + + public static SnipStoreDocument Build() + { + var cli = new Cli { Name = CliName }; + var document = new SnipStoreDocument(); + document.Clis.Add(cli); + + document.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Echo a greeting", + CommandTemplate = "echo Hello, {name}!", + Description = "Prints a greeting to the console. A minimal one-parameter example.", + Tags = { "demo" }, + Parameters = + { + new Parameter + { + Name = "name", + Type = ParameterType.Text, + Default = "world", + }, + }, + }); + + document.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Deploy to an environment", + CommandTemplate = "myapp deploy --env {env}", + Description = "Triggers a deployment in the named environment. Shows a Choice parameter.", + Tags = { "demo", "deploy" }, + IsFavourite = true, + Parameters = + { + new Parameter + { + Name = "env", + Type = ParameterType.Choice, + Options = { "dev", "staging", "prod" }, + Default = "dev", + }, + }, + }); + + document.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Create an annotated git tag", + CommandTemplate = "git tag -a {tag} -m \"{message}\"", + Description = "Tags the current HEAD with a name and a message. Shows two parameters in one template.", + Tags = { "demo", "git" }, + Parameters = + { + new Parameter + { + Name = "tag", + Type = ParameterType.Text, + Default = "v1.0.0", + }, + new Parameter + { + Name = "message", + Type = ParameterType.Text, + Default = "Release", + }, + }, + }); + + ValidateInternalConsistency(document); + return document; + } + + private static void ValidateInternalConsistency(SnipStoreDocument document) + { + foreach (var snip in document.Snips) + { + var definedNames = new HashSet( + snip.Parameters.Select(p => p.Name), + StringComparer.Ordinal); + + foreach (var token in SubstitutionEngine.ExtractTokens(snip.CommandTemplate)) + { + if (!definedNames.Contains(token)) + { + throw new InvalidOperationException( + $"Seed Snip '{snip.Title}' references token '{{{token}}}' with no matching parameter definition."); + } + } + } + } + } +} diff --git a/src/Snipdeck.Core/Services/JsonSettingsStore.cs b/src/Snipdeck.Core/Services/JsonSettingsStore.cs new file mode 100644 index 0000000..53cc04b --- /dev/null +++ b/src/Snipdeck.Core/Services/JsonSettingsStore.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Services +{ + public sealed class JsonSettingsStore : ISettingsStore, IDisposable + { + private static readonly JsonSerializerOptions _serializerOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), + }, + }; + + private readonly SemaphoreSlim _gate = new(1, 1); + + public JsonSettingsStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + FilePath = filePath; + } + + public string FilePath { get; } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!File.Exists(FilePath)) + { + return new AppConfig(); + } + + await using var stream = new FileStream( + FilePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + + var config = await JsonSerializer + .DeserializeAsync(stream, _serializerOptions, cancellationToken) + .ConfigureAwait(false); + + if (config is null) + { + return new AppConfig(); + } + + if (config.SchemaVersion > AppConfig.CurrentSchemaVersion) + { + throw new InvalidOperationException( + $"Settings schema version {config.SchemaVersion} is newer than the supported version " + + $"{AppConfig.CurrentSchemaVersion}. Update the application to read these settings."); + } + + config.Hotkey ??= HotkeyBinding.Default; + + return config; + } + finally + { + _ = _gate.Release(); + } + } + + public async Task SaveAsync(AppConfig config, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(config); + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var directory = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(directory)) + { + _ = Directory.CreateDirectory(directory); + } + + var tempPath = FilePath + ".tmp"; + + await using (var stream = new FileStream( + tempPath, + FileMode.Create, + FileAccess.Write, + FileShare.None)) + { + await JsonSerializer + .SerializeAsync(stream, config, _serializerOptions, cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(tempPath, FilePath, overwrite: true); + } + finally + { + _ = _gate.Release(); + } + } + + public void Dispose() + { + _gate.Dispose(); + } + } +} diff --git a/src/Snipdeck.Core/Services/JsonSnipStore.cs b/src/Snipdeck.Core/Services/JsonSnipStore.cs new file mode 100644 index 0000000..4ac79a8 --- /dev/null +++ b/src/Snipdeck.Core/Services/JsonSnipStore.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Services +{ + public sealed class JsonSnipStore : ISnipStore, IDisposable + { + private static readonly JsonSerializerOptions _serializerOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + private readonly SemaphoreSlim _gate = new(1, 1); + + public JsonSnipStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + FilePath = filePath; + } + + public string FilePath { get; } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!File.Exists(FilePath)) + { + return new SnipStoreDocument(); + } + + await using var stream = new FileStream( + FilePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + + var document = await JsonSerializer + .DeserializeAsync(stream, _serializerOptions, cancellationToken) + .ConfigureAwait(false) + ?? new SnipStoreDocument(); + + return document.SchemaVersion > SnipStoreDocument.CurrentSchemaVersion + ? throw new InvalidOperationException( + $"Store schema version {document.SchemaVersion} is newer than the supported version " + + $"{SnipStoreDocument.CurrentSchemaVersion}. Update the application to read this store.") + : document; + } + finally + { + _ = _gate.Release(); + } + } + + public async Task SaveAsync(SnipStoreDocument document, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(document); + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var directory = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(directory)) + { + _ = Directory.CreateDirectory(directory); + } + + var tempPath = FilePath + ".tmp"; + + await using (var stream = new FileStream( + tempPath, + FileMode.Create, + FileAccess.Write, + FileShare.None)) + { + await JsonSerializer + .SerializeAsync(stream, document, _serializerOptions, cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(tempPath, FilePath, overwrite: true); + } + finally + { + _ = _gate.Release(); + } + } + + public void Dispose() + { + _gate.Dispose(); + } + } +} diff --git a/src/Snipdeck.Core/Services/SystemClock.cs b/src/Snipdeck.Core/Services/SystemClock.cs new file mode 100644 index 0000000..bc74771 --- /dev/null +++ b/src/Snipdeck.Core/Services/SystemClock.cs @@ -0,0 +1,9 @@ +using Snipdeck.Core.Abstractions; + +namespace Snipdeck.Core.Services +{ + public sealed class SystemClock : IClock + { + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + } +} diff --git a/src/Snipdeck.Core/Snipdeck.Core.csproj b/src/Snipdeck.Core/Snipdeck.Core.csproj index ca3e94a..9df329e 100644 --- a/src/Snipdeck.Core/Snipdeck.Core.csproj +++ b/src/Snipdeck.Core/Snipdeck.Core.csproj @@ -1,14 +1,12 @@ - + net10.0 - enable - enable - - + + diff --git a/tests/Snipdeck.Core.Tests/Engine/SubstitutionEngineTests.cs b/tests/Snipdeck.Core.Tests/Engine/SubstitutionEngineTests.cs new file mode 100644 index 0000000..cb48a52 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Engine/SubstitutionEngineTests.cs @@ -0,0 +1,231 @@ +using Snipdeck.Core.Engine; + +namespace Snipdeck.Core.Tests.Engine +{ + + public class SubstitutionEngineTests + { + [Fact] + public void Empty_template_returns_empty_and_no_unresolved() + { + var result = SubstitutionEngine.Substitute(string.Empty, new Dictionary()); + + Assert.Equal(string.Empty, result.Text); + Assert.Empty(result.UnresolvedTokens); + Assert.True(result.IsFullyResolved); + } + + [Fact] + public void Template_with_no_tokens_is_returned_verbatim() + { + var template = "echo hello world"; + + var result = SubstitutionEngine.Substitute(template, new Dictionary()); + + Assert.Equal(template, result.Text); + Assert.Empty(result.UnresolvedTokens); + } + + [Fact] + public void Single_token_is_substituted() + { + var values = new Dictionary { ["name"] = "Stuart" }; + + var result = SubstitutionEngine.Substitute("hello {name}", values); + + Assert.Equal("hello Stuart", result.Text); + Assert.True(result.IsFullyResolved); + } + + [Fact] + public void Multiple_distinct_tokens_are_substituted() + { + var values = new Dictionary + { + ["env"] = "prod", + ["region"] = "eu-west-1", + }; + + var result = SubstitutionEngine.Substitute("deploy --env {env} --region {region}", values); + + Assert.Equal("deploy --env prod --region eu-west-1", result.Text); + Assert.True(result.IsFullyResolved); + } + + [Fact] + public void Repeated_token_is_substituted_each_occurrence() + { + var values = new Dictionary { ["x"] = "42" }; + + var result = SubstitutionEngine.Substitute("{x}-{x}-{x}", values); + + Assert.Equal("42-42-42", result.Text); + Assert.True(result.IsFullyResolved); + } + + [Fact] + public void Missing_token_leaves_literal_in_place_and_lists_it_as_unresolved() + { + var result = SubstitutionEngine.Substitute("hello {name}", new Dictionary()); + + Assert.Equal("hello {name}", result.Text); + Assert.Equal(new[] { "name" }, result.UnresolvedTokens); + Assert.False(result.IsFullyResolved); + } + + [Fact] + public void Repeated_missing_token_is_listed_once_only() + { + var result = SubstitutionEngine.Substitute( + "{missing} and {missing} again", + new Dictionary()); + + Assert.Equal("{missing} and {missing} again", result.Text); + Assert.Single(result.UnresolvedTokens); + Assert.Equal("missing", result.UnresolvedTokens[0]); + } + + [Fact] + public void Mix_of_resolved_and_unresolved_handles_both() + { + var values = new Dictionary { ["a"] = "1" }; + + var result = SubstitutionEngine.Substitute("{a} {b} {a} {c}", values); + + Assert.Equal("1 {b} 1 {c}", result.Text); + Assert.Equal(new[] { "b", "c" }, result.UnresolvedTokens); + } + + [Fact] + public void Unresolved_tokens_preserve_first_appearance_order() + { + var result = SubstitutionEngine.Substitute( + "{z} {a} {m} {a} {z}", + new Dictionary()); + + Assert.Equal(new[] { "z", "a", "m" }, result.UnresolvedTokens); + } + + [Fact] + public void Empty_string_value_substitutes_to_empty_string_and_is_considered_resolved() + { + var values = new Dictionary { ["flag"] = string.Empty }; + + var result = SubstitutionEngine.Substitute("cmd {flag} end", values); + + Assert.Equal("cmd end", result.Text); + Assert.True(result.IsFullyResolved); + } + + [Fact] + public void Null_value_in_dictionary_is_treated_as_unresolved() + { + var values = new Dictionary { ["flag"] = null }; + + var result = SubstitutionEngine.Substitute("cmd {flag}", values); + + Assert.Equal("cmd {flag}", result.Text); + Assert.Equal(new[] { "flag" }, result.UnresolvedTokens); + } + + [Fact] + public void Token_matching_is_case_sensitive() + { + var values = new Dictionary { ["Env"] = "prod" }; + + var result = SubstitutionEngine.Substitute("{env} vs {Env}", values); + + Assert.Equal("{env} vs prod", result.Text); + Assert.Equal(new[] { "env" }, result.UnresolvedTokens); + } + + [Theory] + [InlineData("{a-b}")] // hyphen is not allowed in identifiers + [InlineData("{ name }")] // whitespace is not allowed + [InlineData("{}")] // empty token name + [InlineData("{1name}")] // leading digit + [InlineData("{a.b}")] // dot not allowed + public void Invalid_token_shapes_are_left_as_literal_text(string template) + { + var values = new Dictionary { ["name"] = "x" }; + + var result = SubstitutionEngine.Substitute(template, values); + + Assert.Equal(template, result.Text); + Assert.Empty(result.UnresolvedTokens); + } + + [Fact] + public void Json_payload_with_one_placeholder_only_substitutes_the_placeholder() + { + var template = "aws ec2 describe-instances --filters '{\"Name\":\"tag:Env\",\"Values\":[\"{env}\"]}'"; + var values = new Dictionary { ["env"] = "prod" }; + + var result = SubstitutionEngine.Substitute(template, values); + + Assert.Equal( + "aws ec2 describe-instances --filters '{\"Name\":\"tag:Env\",\"Values\":[\"prod\"]}'", + result.Text); + Assert.True(result.IsFullyResolved); + } + + [Fact] + public void Adjacent_tokens_with_no_separator_are_substituted_independently() + { + var values = new Dictionary + { + ["a"] = "Foo", + ["b"] = "Bar", + }; + + var result = SubstitutionEngine.Substitute("{a}{b}", values); + + Assert.Equal("FooBar", result.Text); + } + + [Theory] + [InlineData("_hidden")] + [InlineData("snake_case")] + [InlineData("camelCase")] + [InlineData("PascalCase")] + [InlineData("with9digits")] + public void Valid_identifier_shapes_are_substituted(string name) + { + var values = new Dictionary { [name] = "ok" }; + + var result = SubstitutionEngine.Substitute("[{" + name + "}]", values); + + Assert.Equal("[ok]", result.Text); + } + + [Fact] + public void Substitute_throws_on_null_template() + { + Assert.Throws(() => + SubstitutionEngine.Substitute(null!, new Dictionary())); + } + + [Fact] + public void Substitute_throws_on_null_values_dictionary() + { + Assert.Throws(() => + SubstitutionEngine.Substitute("template", null!)); + } + + [Fact] + public void ExtractTokens_returns_unique_tokens_in_first_appearance_order() + { + var tokens = SubstitutionEngine.ExtractTokens("{b} {a} {b} {c} {a}"); + + Assert.Equal(new[] { "b", "a", "c" }, tokens); + } + + [Fact] + public void ExtractTokens_on_template_with_no_tokens_returns_empty() + { + var tokens = SubstitutionEngine.ExtractTokens("echo hello"); + + Assert.Empty(tokens); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs b/tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs new file mode 100644 index 0000000..fc7b965 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs @@ -0,0 +1,187 @@ +using Snipdeck.Core.Services; +using Snipdeck.Core.Tests.Support; + +namespace Snipdeck.Core.Tests.Services +{ + + public sealed class BackupServiceTests : IDisposable + { + private readonly string _tempDirectory; + private readonly string _sourcePath; + private readonly string _backupDirectory; + + public BackupServiceTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), "snipdeck-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDirectory); + _sourcePath = Path.Combine(_tempDirectory, "store.json"); + _backupDirectory = Path.Combine(_tempDirectory, "backups"); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + GC.SuppressFinalize(this); + } + + private BackupService BuildService(FakeClock clock, int retention = BackupService.DefaultRetention) + { + return new BackupService(_sourcePath, _backupDirectory, clock, retention); + } + + private void WriteSource(string content = "{\"schemaVersion\":1,\"clis\":[],\"snips\":[]}") + { + File.WriteAllText(_sourcePath, content); + } + + [Fact] + public void Constructor_validates_arguments() + { + var clock = new FakeClock(DateTimeOffset.UtcNow); + + Assert.Throws(() => new BackupService(null!, _backupDirectory, clock)); + Assert.Throws(() => new BackupService(_sourcePath, null!, clock)); + Assert.Throws(() => new BackupService(_sourcePath, _backupDirectory, null!)); + Assert.Throws(() => new BackupService(_sourcePath, _backupDirectory, clock, retention: 0)); + } + + [Fact] + public async Task CreateBackupAsync_returns_null_when_source_missing() + { + var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero)); + var service = BuildService(clock); + + var info = await service.CreateBackupAsync(); + + Assert.Null(info); + Assert.False(Directory.Exists(_backupDirectory) && Directory.EnumerateFiles(_backupDirectory).Any()); + } + + [Fact] + public async Task CreateBackupAsync_copies_source_to_timestamped_destination() + { + WriteSource("hello"); + var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 34, 56, 789, TimeSpan.Zero)); + var service = BuildService(clock); + + var info = await service.CreateBackupAsync(); + + Assert.NotNull(info); + Assert.True(File.Exists(info!.FilePath)); + Assert.EndsWith("snipstore_20260529_123456789.json", info.FilePath); + Assert.Equal("hello", await File.ReadAllTextAsync(info.FilePath)); + Assert.Equal(5, info.SizeBytes); + Assert.Equal(clock.UtcNow, info.CreatedAtUtc); + } + + [Fact] + public async Task CreateBackupAsync_creates_missing_backup_directory() + { + WriteSource(); + Assert.False(Directory.Exists(_backupDirectory)); + + var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero)); + await BuildService(clock).CreateBackupAsync(); + + Assert.True(Directory.Exists(_backupDirectory)); + } + + [Fact] + public async Task CreateBackupAsync_appends_collision_suffix_when_clock_repeats() + { + WriteSource(); + var fixedTime = new DateTimeOffset(2026, 5, 29, 12, 0, 0, 0, TimeSpan.Zero); + var clock = new FakeClock(fixedTime); + var service = BuildService(clock); + + var first = await service.CreateBackupAsync(); + var second = await service.CreateBackupAsync(); + var third = await service.CreateBackupAsync(); + + Assert.NotEqual(first!.FilePath, second!.FilePath); + Assert.NotEqual(second.FilePath, third!.FilePath); + Assert.EndsWith("snipstore_20260529_120000000.json", first.FilePath); + Assert.EndsWith("snipstore_20260529_120000000-1.json", second.FilePath); + Assert.EndsWith("snipstore_20260529_120000000-2.json", third.FilePath); + } + + [Fact] + public async Task CreateBackupAsync_prunes_backups_beyond_retention() + { + WriteSource(); + var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero)); + var service = BuildService(clock, retention: 3); + + for (var i = 0; i < 5; i++) + { + await service.CreateBackupAsync(); + clock.Advance(TimeSpan.FromSeconds(1)); + } + + var remaining = Directory.GetFiles(_backupDirectory, "snipstore_*.json") + .OrderBy(f => f, StringComparer.Ordinal) + .Select(Path.GetFileName) + .ToArray(); + + Assert.Equal(3, remaining.Length); + Assert.Equal("snipstore_20260529_120002000.json", remaining[0]); + Assert.Equal("snipstore_20260529_120003000.json", remaining[1]); + Assert.Equal("snipstore_20260529_120004000.json", remaining[2]); + } + + [Fact] + public async Task PruneStep_does_not_touch_unrelated_files_in_backup_directory() + { + WriteSource(); + Directory.CreateDirectory(_backupDirectory); + var sibling = Path.Combine(_backupDirectory, "not-a-backup.txt"); + await File.WriteAllTextAsync(sibling, "untouched"); + + var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero)); + var service = BuildService(clock, retention: 1); + + for (var i = 0; i < 5; i++) + { + await service.CreateBackupAsync(); + clock.Advance(TimeSpan.FromSeconds(1)); + } + + Assert.True(File.Exists(sibling)); + Assert.Equal("untouched", await File.ReadAllTextAsync(sibling)); + } + + [Fact] + public async Task ListBackupsAsync_returns_newest_first_with_parsed_timestamps() + { + WriteSource(); + var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero)); + var service = BuildService(clock); + + await service.CreateBackupAsync(); + clock.Advance(TimeSpan.FromSeconds(5)); + await service.CreateBackupAsync(); + clock.Advance(TimeSpan.FromSeconds(5)); + await service.CreateBackupAsync(); + + var list = await service.ListBackupsAsync(); + + Assert.Equal(3, list.Count); + Assert.True(list[0].CreatedAtUtc > list[1].CreatedAtUtc); + Assert.True(list[1].CreatedAtUtc > list[2].CreatedAtUtc); + } + + [Fact] + public async Task ListBackupsAsync_returns_empty_when_directory_missing() + { + var clock = new FakeClock(DateTimeOffset.UtcNow); + var service = BuildService(clock); + + var list = await service.ListBackupsAsync(); + + Assert.Empty(list); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs b/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs new file mode 100644 index 0000000..591462e --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs @@ -0,0 +1,112 @@ +using Snipdeck.Core.Engine; +using Snipdeck.Core.Models; +using Snipdeck.Core.Services; + +namespace Snipdeck.Core.Tests.Services +{ + + public class ExamplesSeedTests + { + [Fact] + public void IsEmpty_is_true_for_default_document() + { + Assert.True(ExamplesSeed.IsEmpty(new SnipStoreDocument())); + } + + [Fact] + public void IsEmpty_is_false_after_Build() + { + Assert.False(ExamplesSeed.IsEmpty(ExamplesSeed.Build())); + } + + [Fact] + public void Build_produces_a_single_cli_named_Examples() + { + var doc = ExamplesSeed.Build(); + + var cli = Assert.Single(doc.Clis); + Assert.Equal(ExamplesSeed.CliName, cli.Name); + Assert.NotEqual(Guid.Empty, cli.Id); + } + + [Fact] + public void Build_produces_multiple_snips_all_belonging_to_the_examples_cli() + { + var doc = ExamplesSeed.Build(); + var cliId = doc.Clis.Single().Id; + + Assert.NotEmpty(doc.Snips); + Assert.All(doc.Snips, snip => Assert.Equal(cliId, snip.CliId)); + } + + [Fact] + public void Every_seed_snip_has_a_unique_id() + { + var doc = ExamplesSeed.Build(); + var ids = doc.Snips.Select(s => s.Id).ToList(); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + + [Fact] + public void Every_template_token_is_backed_by_a_parameter_definition() + { + var doc = ExamplesSeed.Build(); + + foreach (var snip in doc.Snips) + { + var defined = snip.Parameters.Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + var referenced = SubstitutionEngine.ExtractTokens(snip.CommandTemplate); + foreach (var token in referenced) + { + Assert.Contains(token, defined); + } + } + } + + [Fact] + public void Seed_contains_at_least_one_choice_parameter_and_one_text_parameter() + { + var doc = ExamplesSeed.Build(); + var allParams = doc.Snips.SelectMany(s => s.Parameters).ToList(); + + Assert.Contains(allParams, p => p.Type == ParameterType.Choice); + Assert.Contains(allParams, p => p.Type == ParameterType.Text); + } + + [Fact] + public void Seed_contains_at_least_one_favourite_snip() + { + var doc = ExamplesSeed.Build(); + Assert.Contains(doc.Snips, s => s.IsFavourite); + } + + [Fact] + public async Task Seed_round_trips_through_the_json_store() + { + var tempDir = Path.Combine(Path.GetTempPath(), "snipdeck-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var store = new JsonSnipStore(Path.Combine(tempDir, "store.json")); + var original = ExamplesSeed.Build(); + + await store.SaveAsync(original); + var loaded = await store.LoadAsync(); + + Assert.Equal(original.Clis.Count, loaded.Clis.Count); + Assert.Equal(original.Snips.Count, loaded.Snips.Count); + Assert.Equal(original.Clis[0].Name, loaded.Clis[0].Name); + for (var i = 0; i < original.Snips.Count; i++) + { + Assert.Equal(original.Snips[i].Title, loaded.Snips[i].Title); + Assert.Equal(original.Snips[i].CommandTemplate, loaded.Snips[i].CommandTemplate); + Assert.Equal(original.Snips[i].Parameters.Count, loaded.Snips[i].Parameters.Count); + } + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + } +} diff --git a/tests/Snipdeck.Core.Tests/Services/JsonSettingsStoreTests.cs b/tests/Snipdeck.Core.Tests/Services/JsonSettingsStoreTests.cs new file mode 100644 index 0000000..3486b78 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Services/JsonSettingsStoreTests.cs @@ -0,0 +1,132 @@ +using Snipdeck.Core.Models; +using Snipdeck.Core.Services; + +namespace Snipdeck.Core.Tests.Services +{ + + public sealed class JsonSettingsStoreTests : IDisposable + { + private readonly string _tempDirectory; + + public JsonSettingsStoreTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), "snipdeck-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDirectory); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + GC.SuppressFinalize(this); + } + + private string PathIn(string name) => Path.Combine(_tempDirectory, name); + + [Fact] + public async Task LoadAsync_returns_defaults_when_file_missing() + { + var store = new JsonSettingsStore(PathIn("settings.json")); + + var config = await store.LoadAsync(); + + Assert.Equal(AppConfig.CurrentSchemaVersion, config.SchemaVersion); + Assert.Null(config.StoragePath); + Assert.Null(config.BackupDirectory); + Assert.Equal(ThemePreference.System, config.Theme); + Assert.Equal(CloseBehaviour.HideToTray, config.CloseBehaviour); + Assert.NotNull(config.Hotkey); + Assert.Equal(HotkeyModifiers.Control | HotkeyModifiers.Alt, config.Hotkey.Modifiers); + Assert.Equal("S", config.Hotkey.Key); + } + + [Fact] + public async Task SaveAsync_then_LoadAsync_round_trips_all_fields() + { + var store = new JsonSettingsStore(PathIn("settings.json")); + + await store.SaveAsync(new AppConfig + { + StoragePath = "/data/store", + BackupDirectory = "/data/backups", + Theme = ThemePreference.Dark, + CloseBehaviour = CloseBehaviour.Exit, + Hotkey = new HotkeyBinding + { + Modifiers = HotkeyModifiers.Control | HotkeyModifiers.Shift, + Key = "Space", + }, + }); + + var loaded = await store.LoadAsync(); + + Assert.Equal("/data/store", loaded.StoragePath); + Assert.Equal("/data/backups", loaded.BackupDirectory); + Assert.Equal(ThemePreference.Dark, loaded.Theme); + Assert.Equal(CloseBehaviour.Exit, loaded.CloseBehaviour); + Assert.Equal(HotkeyModifiers.Control | HotkeyModifiers.Shift, loaded.Hotkey.Modifiers); + Assert.Equal("Space", loaded.Hotkey.Key); + } + + [Fact] + public async Task SaveAsync_creates_missing_parent_directory() + { + var nested = PathIn("a/b/settings.json"); + var store = new JsonSettingsStore(nested); + + await store.SaveAsync(new AppConfig()); + + Assert.True(File.Exists(nested)); + } + + [Fact] + public async Task SaveAsync_does_not_leave_tmp_file_behind_on_success() + { + var path = PathIn("settings.json"); + var store = new JsonSettingsStore(path); + + await store.SaveAsync(new AppConfig()); + + Assert.False(File.Exists(path + ".tmp")); + Assert.True(File.Exists(path)); + } + + [Fact] + public async Task LoadAsync_throws_when_schema_version_is_newer_than_supported() + { + var path = PathIn("settings.json"); + var futureJson = $$""" + { "schemaVersion": {{AppConfig.CurrentSchemaVersion + 1}} } + """; + await File.WriteAllTextAsync(path, futureJson); + + var store = new JsonSettingsStore(path); + + await Assert.ThrowsAsync(() => store.LoadAsync()); + } + + [Fact] + public async Task LoadAsync_repairs_a_missing_hotkey_with_the_default() + { + var path = PathIn("settings.json"); + await File.WriteAllTextAsync(path, "{}"); + + var store = new JsonSettingsStore(path); + var loaded = await store.LoadAsync(); + + Assert.NotNull(loaded.Hotkey); + Assert.Equal(HotkeyModifiers.Control | HotkeyModifiers.Alt, loaded.Hotkey.Modifiers); + Assert.Equal("S", loaded.Hotkey.Key); + } + + [Fact] + public async Task SaveAsync_throws_on_null_config() + { + var store = new JsonSettingsStore(PathIn("settings.json")); + + await Assert.ThrowsAsync(() => store.SaveAsync(null!)); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/Services/JsonSnipStoreTests.cs b/tests/Snipdeck.Core.Tests/Services/JsonSnipStoreTests.cs new file mode 100644 index 0000000..39aa2fa --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Services/JsonSnipStoreTests.cs @@ -0,0 +1,233 @@ +using Snipdeck.Core.Models; +using Snipdeck.Core.Services; + +namespace Snipdeck.Core.Tests.Services +{ + + public sealed class JsonSnipStoreTests : IDisposable + { + private readonly string _tempDirectory; + + public JsonSnipStoreTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), "snipdeck-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDirectory); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + GC.SuppressFinalize(this); + } + + private string PathIn(string name) => System.IO.Path.Combine(_tempDirectory, name); + + [Fact] + public void Throws_when_file_path_is_null_or_whitespace() + { + Assert.Throws(() => new JsonSnipStore(null!)); + Assert.Throws(() => new JsonSnipStore("")); + Assert.Throws(() => new JsonSnipStore(" ")); + } + + [Fact] + public async Task LoadAsync_returns_empty_document_when_file_missing() + { + var store = new JsonSnipStore(PathIn("store.json")); + + var document = await store.LoadAsync(); + + Assert.Equal(SnipStoreDocument.CurrentSchemaVersion, document.SchemaVersion); + Assert.Empty(document.Clis); + Assert.Empty(document.Snips); + } + + [Fact] + public async Task SaveAsync_then_LoadAsync_round_trips_full_document() + { + var path = PathIn("store.json"); + var store = new JsonSnipStore(path); + + var cliId = Guid.NewGuid(); + var snipId = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + + var original = new SnipStoreDocument + { + Clis = + { + new Cli { Id = cliId, Name = "pl-app", IconRef = "pl-app.png" }, + }, + Snips = + { + new Snip + { + Id = snipId, + CliId = cliId, + Title = "List orgs", + CommandTemplate = "pl-app orgs list --env {env}", + Description = "Lists every organisation visible to the caller.", + Tags = { "orgs", "read" }, + IsFavourite = true, + UsageCount = 3, + LastUsedAt = now, + Parameters = + { + new Parameter + { + Name = "env", + Type = ParameterType.Choice, + Options = { "dev", "prod" }, + Default = "dev", + }, + }, + }, + }, + }; + + await store.SaveAsync(original); + var loaded = await store.LoadAsync(); + + Assert.Equal(SnipStoreDocument.CurrentSchemaVersion, loaded.SchemaVersion); + + var cli = Assert.Single(loaded.Clis); + Assert.Equal(cliId, cli.Id); + Assert.Equal("pl-app", cli.Name); + Assert.Equal("pl-app.png", cli.IconRef); + + var snip = Assert.Single(loaded.Snips); + Assert.Equal(snipId, snip.Id); + Assert.Equal(cliId, snip.CliId); + Assert.Equal("List orgs", snip.Title); + Assert.Equal("pl-app orgs list --env {env}", snip.CommandTemplate); + Assert.Equal("Lists every organisation visible to the caller.", snip.Description); + Assert.Equal(new[] { "orgs", "read" }, snip.Tags); + Assert.True(snip.IsFavourite); + Assert.False(snip.IsTrash); + Assert.Equal(3, snip.UsageCount); + Assert.Equal(now, snip.LastUsedAt); + + var param = Assert.Single(snip.Parameters); + Assert.Equal("env", param.Name); + Assert.Equal(ParameterType.Choice, param.Type); + Assert.Equal(new[] { "dev", "prod" }, param.Options); + Assert.Equal("dev", param.Default); + } + + [Fact] + public async Task SaveAsync_creates_missing_parent_directory() + { + var nested = PathIn("a/b/c/store.json"); + var store = new JsonSnipStore(nested); + + await store.SaveAsync(new SnipStoreDocument()); + + Assert.True(File.Exists(nested)); + } + + [Fact] + public async Task SaveAsync_does_not_leave_tmp_file_behind_on_success() + { + var path = PathIn("store.json"); + var store = new JsonSnipStore(path); + + await store.SaveAsync(new SnipStoreDocument()); + + Assert.False(File.Exists(path + ".tmp")); + Assert.True(File.Exists(path)); + } + + [Fact] + public async Task SaveAsync_overwrites_existing_file() + { + var path = PathIn("store.json"); + var store = new JsonSnipStore(path); + + await store.SaveAsync(new SnipStoreDocument + { + Clis = { new Cli { Name = "first" } }, + }); + + await store.SaveAsync(new SnipStoreDocument + { + Clis = { new Cli { Name = "second" } }, + }); + + var loaded = await store.LoadAsync(); + var cli = Assert.Single(loaded.Clis); + Assert.Equal("second", cli.Name); + } + + [Fact] + public async Task LoadAsync_throws_when_schema_version_is_newer_than_supported() + { + var path = PathIn("store.json"); + var futureJson = $$""" + { + "schemaVersion": {{SnipStoreDocument.CurrentSchemaVersion + 1}}, + "clis": [], + "snips": [] + } + """; + await File.WriteAllTextAsync(path, futureJson); + + var store = new JsonSnipStore(path); + + await Assert.ThrowsAsync(() => store.LoadAsync()); + } + + [Fact] + public async Task SaveAsync_throws_on_null_document() + { + var store = new JsonSnipStore(PathIn("store.json")); + + await Assert.ThrowsAsync(() => store.SaveAsync(null!)); + } + + [Fact] + public async Task Concurrent_saves_leave_a_consistent_well_formed_file() + { + var path = PathIn("store.json"); + var store = new JsonSnipStore(path); + + var tasks = Enumerable.Range(0, 20).Select(i => store.SaveAsync(new SnipStoreDocument + { + Clis = { new Cli { Name = $"cli-{i}" } }, + })).ToArray(); + + await Task.WhenAll(tasks); + + var loaded = await store.LoadAsync(); + var cli = Assert.Single(loaded.Clis); + Assert.StartsWith("cli-", cli.Name); + Assert.False(File.Exists(path + ".tmp")); + } + + [Fact] + public async Task Parameter_type_is_serialised_as_camel_case_string() + { + var path = PathIn("store.json"); + var store = new JsonSnipStore(path); + + await store.SaveAsync(new SnipStoreDocument + { + Snips = + { + new Snip + { + Parameters = + { + new Parameter { Name = "p", Type = ParameterType.Choice }, + }, + }, + }, + }); + + var json = await File.ReadAllTextAsync(path); + Assert.Contains("\"type\": \"choice\"", json); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj b/tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj index bd3b87c..9276705 100644 --- a/tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj +++ b/tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj @@ -1,17 +1,23 @@ - + net10.0 - enable - enable false + + $(NoWarn);CA1707;CA1861;IDE0300;IDE0058 - - - - + + + + @@ -22,4 +28,4 @@ - \ No newline at end of file + diff --git a/tests/Snipdeck.Core.Tests/Support/FakeClock.cs b/tests/Snipdeck.Core.Tests/Support/FakeClock.cs new file mode 100644 index 0000000..6313b18 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Support/FakeClock.cs @@ -0,0 +1,19 @@ +using Snipdeck.Core.Abstractions; + +namespace Snipdeck.Core.Tests.Support +{ + public sealed class FakeClock(DateTimeOffset initial) : IClock + { + public DateTimeOffset UtcNow { get; private set; } = initial; + + public void Advance(TimeSpan delta) + { + UtcNow = UtcNow.Add(delta); + } + + public void Set(DateTimeOffset value) + { + UtcNow = value; + } + } +} diff --git a/tests/Snipdeck.Core.Tests/UnitTest1.cs b/tests/Snipdeck.Core.Tests/UnitTest1.cs deleted file mode 100644 index 5c2dbd4..0000000 --- a/tests/Snipdeck.Core.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Snipdeck.Core.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} From 8cbaaa7d6e06eecc5d06afedb1dda60d4cbce741 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 15:54:39 +0000 Subject: [PATCH 2/3] Add CODEOWNERS * maps to Stuart so any change automatically requests his review under the branch ruleset. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b50bf01 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* stuart.meeks@stuartmeeks.net From 29ac60aa84f3a2da52a554201f8d31e709ea06df Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 15:59:34 +0000 Subject: [PATCH 3/3] Trim WinUI scaffold usings to satisfy TreatWarningsAsErrors App.xaml.cs and MainWindow.xaml.cs shipped with the Visual Studio template's cargo-culted using list (System / Windows / Microsoft.UI.Xaml in the wrong group order, with most entries unused). Under TreatWarningsAsErrors this trips IDE0005 (unused using) and IDE0055 (formatting / group ordering). Reduce each file to the single using actually needed (Microsoft.UI.Xaml) and drop the boilerplate comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Snipdeck.App/App.xaml.cs | 32 +---------------------------- src/Snipdeck.App/MainWindow.xaml.cs | 19 ----------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/src/Snipdeck.App/App.xaml.cs b/src/Snipdeck.App/App.xaml.cs index 667336d..188f05b 100644 --- a/src/Snipdeck.App/App.xaml.cs +++ b/src/Snipdeck.App/App.xaml.cs @@ -1,46 +1,16 @@ -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; -using Microsoft.UI.Xaml.Shapes; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; -using Windows.ApplicationModel; -using Windows.ApplicationModel.Activation; -using Windows.Foundation; -using Windows.Foundation.Collections; - -// To learn more about WinUI, the WinUI project structure, -// and more about our project templates, see: http://aka.ms/winui-project-info. +using Microsoft.UI.Xaml; namespace Snipdeck.App { - /// - /// Provides application-specific behavior to supplement the default Application class. - /// public partial class App : Application { private Window? _window; - /// - /// Initializes the singleton application object. This is the first line of authored code - /// executed, and as such is the logical equivalent of main() or WinMain(). - /// public App() { InitializeComponent(); } - /// - /// Invoked when the application is launched. - /// - /// Details about the launch request and process. protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) { _window = new MainWindow(); diff --git a/src/Snipdeck.App/MainWindow.xaml.cs b/src/Snipdeck.App/MainWindow.xaml.cs index db0c41b..a573380 100644 --- a/src/Snipdeck.App/MainWindow.xaml.cs +++ b/src/Snipdeck.App/MainWindow.xaml.cs @@ -1,26 +1,7 @@ using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; -using Windows.Foundation; -using Windows.Foundation.Collections; - -// To learn more about WinUI, the WinUI project structure, -// and more about our project templates, see: http://aka.ms/winui-project-info. namespace Snipdeck.App { - /// - /// An empty window that can be used on its own or navigated to within a Frame. - /// public sealed partial class MainWindow : Window { public MainWindow()