diff --git a/.github/skills/ToolStripIcons/SKILL.md b/.github/skills/ToolStripIcons/SKILL.md deleted file mode 100644 index dad0101..0000000 --- a/.github/skills/ToolStripIcons/SKILL.md +++ /dev/null @@ -1,218 +0,0 @@ ---- -name: warp-toolstrip-icons -description: How to give ToolStrip / MenuStrip / ContextMenuStrip items crisp, DPI-aware glyph icons via the `WarpToolkit.WinForms.Extensions.UI.ToolStripExtensions.ConfigureItem` extension method, using symbol-font enums (e.g. `FluentSymbols.AllSymbols`) instead of bitmap resources. ---- - -# WARP ToolStrip Icons via `ConfigureItem` - -This skill explains how to populate a `ToolStripItem` (menu item, button, -drop-down, etc.) with a vector glyph from a symbol font — no `.png`/`.ico` -resources, no manual `ImageList` plumbing — using the -`ConfigureItem` extension method from `WarpToolkit.WinForms.Extensions.UI`. - -For the broader API map see `../ApiDecisionGuide/SKILL.md`. - -The current preview version of every WARP NuGet is: -`0.9.7-preview.g32895b766b`. - -## When to use this skill - -Use this skill whenever the user wants to: - -- Has explicitly instructed before to use or introduce the WarpToolkit library for a specific project. -- Add or change an icon on a `ToolStripMenuItem`, `ToolStripButton`, - `ToolStripSplitButton`, `ToolStripDropDownButton`, or `ContextMenuStrip` - entry. -- Wire up the `Click` handler **and** the icon **and** the tooltip in one - place (typical "command setup" code-behind). -- Get glyphs that scale cleanly on high-DPI / per-monitor-v2 displays - without shipping bitmap assets. -- Theme icons to the current `ForeColor` (dark-mode aware). - -If the user just wants a built-in WinForms `Image` from disk, this skill -does not apply. - -## Required packages and usings - -| Need | Package | -|------|---------| -| `ConfigureItem` / `GetSymbolImage` | `WarpToolkit.WinForms.Extensions` | -| A symbol enum (e.g. `FluentSymbols.AllSymbols`) | `WarpToolkit.WinForms` (namespace `WarpToolkit.WinForms.Symbols`) | - -```csharp -using WarpToolkit.WinForms.Extensions.UI; // ConfigureItem, GetSymbolImage -using WarpToolkit.WinForms.Symbols; // FluentSymbols, etc. -``` - -## The signature you target - -```csharp -public static void ConfigureItem( - this ToolStripItem toolStripItem, - Enum symbol, - (EventHandler clickHandler, bool removeBeforeAdd)? eventHandler = null, - string? tooltipText = default, - int size = 0, - Color? foreColor = null, - Color? backColor = null); -``` - -Key behaviors: - -- `symbol` is **any enum** whose declaring type carries a - `[SourceFontName(...)]` attribute. The enum value's integer is treated as - a Unicode code point and rendered with that font. `FluentSymbols.AllSymbols` - (Segoe Fluent Icons) is the WARP default. -- `eventHandler` is an **optional tuple**. Set `removeBeforeAdd: true` if - `ConfigureItem` may run more than once for the same item (e.g. a "rebuild - commands" routine) — it prevents the handler from being subscribed twice. -- `tooltipText` is assigned straight to `ToolTipText`. -- `size = 0` means: use the owning ToolStrip's `ImageScalingSize.Width`. - Leave it at `0` in the common case so DPI scaling stays consistent. -- `foreColor = null` defaults to the owner's `ForeColor` (so dark-mode - themed strips get light glyphs automatically). -- `backColor = null` defaults to `Color.Transparent` inside `ConfigureItem` - (note: `GetSymbolImage` itself would default to the owner's `BackColor`, - but `ConfigureItem` overrides that to transparent so the strip's - background paints through). - -## Preconditions (will throw otherwise) - -`ConfigureItem` requires the item's `Owner` to be set — i.e. the item must -already be added to its parent `ToolStrip`/`MenuStrip`/`ContextMenuStrip`. - -- ✅ Call `ConfigureItem` from `Form.Load`, a `SetupCommands()` method - invoked from the constructor **after** `InitializeComponent()`, or any - later moment. -- ❌ Do **not** call it from inside `InitializeComponent` or from a - control's constructor before it is parented — `toolStripItem.Owner` will - be `null` and the method throws `NullReferenceException`. - -## Canonical usage - -The Chatty sample's `FrmMain_Commands.cs` is the reference pattern: - -```csharp -public partial class FrmMain : Form -{ - private void SetupCommands() - { - _tsmStartNewChat.ConfigureItem( - symbol: FluentSymbols.AllSymbols.NewWindow, - eventHandler: (clickHandler: StartNewChatCommand, removeBeforeAdd: true), - tooltipText: "Begin new chat"); - - _tsmDeleteChat.ConfigureItem( - symbol: FluentSymbols.AllSymbols.DeleteWord, - eventHandler: (clickHandler: DeleteChatCommand, removeBeforeAdd: true), - tooltipText: "Delete chat"); - - // …repeat per command. - } - - private void StartNewChatCommand(object? sender, EventArgs e) { /* … */ } - private void DeleteChatCommand(object? sender, EventArgs e) { /* … */ } -} -``` - -Call `SetupCommands()` once after `InitializeComponent()` (e.g. from the -constructor or `OnLoad`). - -## Recipe: icon only, no click handler - -If the click is already wired in the Designer (`Click += …` in -`InitializeComponent`), pass only what you need: - -```csharp -_tsbSave.ConfigureItem( - symbol: FluentSymbols.AllSymbols.Save, - tooltipText: "Save"); -``` - -## Recipe: re-skinning on theme change - -`ConfigureItem` rebuilds the glyph bitmap on each call. To re-tint icons -after a theme switch, simply call `ConfigureItem` again with -`removeBeforeAdd: true` so the click handler is not duplicated: - -```csharp -private void ApplyTheme() -{ - Color fg = Application.IsDarkModeEnabled ? Color.Gainsboro : Color.Black; - - _tsmStartNewChat.ConfigureItem( - symbol: FluentSymbols.AllSymbols.NewWindow, - eventHandler: (StartNewChatCommand, removeBeforeAdd: true), - tooltipText: "Begin new chat", - foreColor: fg); -} -``` - -> In most apps you don't need to pass `foreColor` at all — let it default -> to the owner's `ForeColor` and let the ToolStrip renderer handle it. - -## Using your own symbol font - -`ConfigureItem` works with any enum whose declaring type is annotated with -`[SourceFontName("Your Font Name")]` (from `WarpToolkit.WinForms.Symbols`). -The enum values must be the Unicode code points of the desired glyphs: - -```csharp -[SourceFontName("Segoe MDL2 Assets")] -public enum MyIcons -{ - Play = 0xE768, - Pause = 0xE769, - Stop = 0xE71A, -} - -_tsbPlay.ConfigureItem(symbol: MyIcons.Play, tooltipText: "Play"); -``` - -If the attribute is missing, `Generic.GetFont` throws -`InvalidOperationException` ("…does not have a SourceFontNameAttribute"). - -## Sizing notes (DPI) - -- Prefer `size: 0` (the default). The icon is drawn at - `toolStrip.ImageScalingSize.Width` and font size is `80%` of that — - tuned to look right in the standard ToolStrip metrics. -- Only override `size` when you have a non-standard `ImageScalingSize` or - want oversized glyphs (e.g. a launcher tile). The bitmap is created from - the owning ToolStrip's HDC, so it picks up the correct DPI automatically. - -## Lower-level alternative: `GetSymbolImage` - -If you need just the `Image` (e.g. to feed an `ImageList`, a -`NotifyIcon`, or a custom-drawn cell), call `GetSymbolImage` on the -`ToolStrip` directly: - -```csharp -Image img = _myToolStrip.GetSymbolImage( - FluentSymbols.AllSymbols.Save, - size: 24, - foreColor: SystemColors.ControlText); -``` - -`ConfigureItem` is a thin wrapper over this plus event/tooltip wiring. - -## Anti-patterns - -- **Do not** call `ConfigureItem` before the item is added to a - ToolStrip — `Owner` will be `null` and it throws. -- **Do not** call it from `InitializeComponent` (Designer code must stay - serializer-friendly; see the WinForms Designer rules). -- **Do not** subscribe the same handler twice. Either set - `removeBeforeAdd: true` or guarantee `ConfigureItem` runs exactly once - per item. -- **Do not** pass a non-transparent `backColor` unless you really want a - colored tile behind the glyph — it will visually clash with the - ToolStrip renderer's own background. -- **Do not** invent integer values for a symbol enum that don't correspond - to real code points in the declared font — you'll get "tofu" boxes. - -## Hand-off - -- For broader package selection: `../ApiDecisionGuide/SKILL.md`. -- For ToolStrip layout/design rules: the WinForms design guidelines in the - repo's `.github` instructions. diff --git a/.github/skills/winforms-async-apis/SKILL.md b/.github/skills/winforms-async-apis/SKILL.md new file mode 100644 index 0000000..9a87166 --- /dev/null +++ b/.github/skills/winforms-async-apis/SKILL.md @@ -0,0 +1,321 @@ +--- +name: winforms-async-apis +description: Guidance for using the WinForms async APIs (.NET 9/10) — Control.InvokeAsync, Form.ShowAsync/ShowDialogAsync, and TaskDialog.ShowDialogAsync — including correct overload selection and patterns for kicking off async work from synchronous UI code. +--- + +# WinForms Async APIs (.NET 9/10) + +## When to use this skill + +Use this skill when generating or reviewing WinForms code that: + +- Marshals work to the UI thread from a worker thread or `Task.Run`. +- Updates controls, reads control state, or runs async UI-bound operations. +- Shows forms or dialogs asynchronously, especially in multi-form, MVVM, or DI scenarios. +- Triggers async work from a synchronous context (event handlers, `OnLoad`). +- Needs to defer work until after the message queue drains (post-show kickoff, + reacting after a Windows message completes). +- Hits `InvokeAsync` overload-resolution errors/warnings (e.g. `WFO2001`). + +Do **not** reach for these APIs to replace plain `await` of background work that +never touches the UI — that needs no marshalling. + +## The APIs at a glance + +| API | Status | Purpose | +| --- | --- | --- | +| `Control.InvokeAsync` | Stable (.NET 9) | Marshal sync/async callbacks to the UI thread, non-blocking. | +| `Form.ShowAsync` | Stable (.NET 10) | Show a modeless form asynchronously. | +| `Form.ShowDialogAsync` | Stable (.NET 10) | Show a modal dialog asynchronously. | +| `TaskDialog.ShowDialogAsync` | Stable (.NET 10) | Show a Task Dialog asynchronously. | + +The form/dialog APIs were experimental in .NET 9 (required suppressing +`WFO5002`). As of .NET 10 they are stable — `WFO5002` is no longer raised, and no +opt-in is needed. + +## Control.InvokeAsync + +`InvokeAsync` *posts* the delegate to the WinForms message queue and returns +immediately — the calling thread is not blocked. Contrast with `Control.Invoke`, +which *sends* the delegate and blocks until the UI thread finishes it. + +| Operation | Method | Blocking | +| --- | --- | --- | +| Send | `Control.Invoke` | Yes — waits for completion. | +| Post | `Control.InvokeAsync` | No — queues and returns. | + +Posting keeps the message loop free to repaint, handle clicks, and process input, +so the UI stays responsive even under many UI-bound tasks. + +### Overloads + +```csharp +public async Task InvokeAsync(Action callback, CancellationToken cancellationToken = default) +public async Task InvokeAsync(Func callback, CancellationToken cancellationToken = default) +public async Task InvokeAsync(Func callback, CancellationToken cancellationToken = default) +public async Task InvokeAsync(Func> callback, CancellationToken cancellationToken = default) +``` + +### Choosing the right overload + +- Sync, no return value → `Action`. +- Sync, returns `T` → `Func`. +- Async, no result → `Func`. +- Async, returns `T` → `Func>`. + +The two async overloads receive a `CancellationToken` and return a `ValueTask`, +which `InvokeAsync` awaits internally. + +### Examples + +```csharp +// Sync, no return value. +await control.InvokeAsync(() => control.Text = "Updated Text"); + +// Sync, returns a value. +int itemCount = await control.InvokeAsync(() => comboBox.Items.Count); + +// Async, no result. +await control.InvokeAsync(async (ct) => +{ + await Task.Delay(1000, ct); + control.Text = "Data Loaded"; +}); + +// Async, returns a value. +int count = await control.InvokeAsync(async (ct) => +{ + await Task.Delay(500, ct); + + return comboBox.Items.Count; +}); +``` + +### Avoiding accidental fire-and-forget + +Passing an async (`Task`-returning) method to a *synchronous* overload without a +`CancellationToken` produces a fire-and-forget call that cannot be awaited +internally. The WinForms analyzer flags this: + +```text +warning WFO2001: Task is being passed to InvokeAsync without a cancellation token. +``` + +When generating code, ensure async callbacks return `ValueTask` (not `Task`), +accept a `CancellationToken`, and resolve to the async overload. + +### Overload resolution with Task.Run + +`InvokeAsync` returns a `Task` — that `Task` cannot be passed to `Task.Run`, +which needs an `Action` or a `Func`. Wrap the call in a local function: + +```csharp +// Local function -> calls the loop on the UI thread. +Task InvokeTask() => this.InvokeAsync(ActualDisplayLoopAsync, CancellationToken.None); + +await Task.Run(InvokeTask); + +async ValueTask ActualDisplayLoopAsync(CancellationToken cancellation = default) +{ + // ... +} +``` + +## Deferred execution: posting work behind the message queue + +`InvokeAsync` isn't only for cross-thread marshalling. Calling it from code that +is *already* on the UI thread is a legitimate pattern: the delegate is posted +**behind** all currently queued messages, so it runs only after the message loop +drains what's pending. + +**Letting a Windows message finish before reacting to it.** Inside a `KeyDown` +handler, calling `SelectAll` directly can be defeated by the still-in-flight key +message. Posting it sidesteps that — and works whether or not `e.Handled` was set: + +```csharp +private async void TextBox_KeyDown(object sender, KeyEventArgs e) +{ + if (e.KeyCode is Keys.Down) + { + _textBox.Text = "Some text to pick from"; + + await InvokeAsync(() => _textBox.SelectAll()); + } +} +``` + +**Kicking off work once the form is fully shown.** As the last line of `OnLoad`, +a posted delegate runs after the framework's own show/activate/paint messages +drain — i.e. once the form is not just constructed but visible and active. It +keeps `OnLoad` synchronous (no `async void` on the override) and the kickoff +non-blocking, with its own `try`/`catch`: + +```csharp +protected override void OnLoad(EventArgs e) +{ + base.OnLoad(e); + + // Synchronous infra setup runs here and completes before OnLoad returns. + + // Posted last: runs after the form is shown and the message queue drains. + _ = InvokeAsync(StartUpWorkAsync, CancellationToken.None); +} +``` + +Note: this defers the kickoff past the *framework's* remaining init/paint +messages — not past your own `OnLoad` code, which has already run synchronously +by this point. + +## Form and dialog async APIs + +`Form.ShowAsync` and `Form.ShowDialogAsync` show forms asynchronously without +blocking the UI thread — handy when juggling multiple instances of the same +form type (e.g. one window per document). `ShowAsync` returns a plain `Task` +that completes when the form is closed or disposed; it returns immediately even +for a large, slow-to-initialize form. + +As of .NET 10 the async task's state machine holds only a **weak reference** to +the form (not a strong one), so a long-lived task does not keep the form alive +and form lifetime stays decoupled from how long the caller retains the task. + +```csharp +MyForm myForm = new(); +await myForm.ShowAsync(); +``` + +```csharp +DialogResult result = await myForm.ShowDialogAsync(); + +if (result is DialogResult.OK) +{ + // Act on the dialog result. +} +``` + +`TaskDialog.ShowDialogAsync` shows a Task Dialog asynchronously: + +```csharp +TaskDialogPage taskDialogPage = new() +{ + Heading = "Processing...", + Text = "Please wait while we complete the task." +}; + +TaskDialogButton buttonClicked = await TaskDialog.ShowDialogAsync(taskDialogPage); +``` + +Notes: implausible calls are expected to throw (e.g. calling `ShowAsync` twice on +the same instance). Forms are shown on the UI thread. + +## Starting async work from synchronous code + +Avoid `async void` — the caller cannot await or observe completion, and +exceptions escape normal `Task` error handling. + +**Exception:** event handlers (and methods with event-handler signatures) cannot +return `Task`, so `async void` is unavoidable there. Wrap the awaited body in +`try`/`catch` so exceptions are still handled: + +```csharp +private async void Button_Click(object sender, EventArgs e) +{ + try + { + await PerformLongRunningOperationAsync(); + } + catch (Exception ex) + { + MessageBox.Show( + $"An error occurred: {ex.Message}", + "Error", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } +} +``` + +Kicking an async loop off from `OnLoad` is the standard pattern. `OnLoad` +completes at the first `await`; the message loop stays free, and the runtime +resumes the method after each awaited task — so an infinite async loop does not +freeze the UI: + +```csharp +protected override async void OnLoad(EventArgs e) +{ + base.OnLoad(e); + await RunDisplayLoopAsync(); +} +``` + +This is cooperative, not parallel — like a relay race passing a baton — until the +loop is explicitly moved onto another thread via `Task.Run`. At that point any UI +access inside it must go through `InvokeAsync`, or a cross-thread exception is +thrown. + +## Parallelizing UI-bound work + +To run two UI-bound operations concurrently inside a loop, start both, await +`Task.WhenAny` so the faster one isn't blocked by the slower, then reset the +completed task to `null` so the next iteration restarts it. Use a +`CancellationTokenSource` cancelled in `OnFormClosing` to break the loop cleanly: + +```csharp +private async Task RunDisplayLoopAsync() +{ + Task? uiUpdateTask = null; + Task? separatorFadingTask = null; + + while (true) + { + async Task FadeInFadeOutAsync(CancellationToken cancellation) + { + await _sevenSegmentTimer.FadeSeparatorsInAsync(cancellation).ConfigureAwait(false); + await _sevenSegmentTimer.FadeSeparatorsOutAsync(cancellation).ConfigureAwait(false); + } + + uiUpdateTask ??= _sevenSegmentTimer.UpdateTimeAndDelayAsync( + time: TimeOnly.FromDateTime(DateTime.Now), + cancellation: _formCloseCancellation.Token); + + separatorFadingTask ??= FadeInFadeOutAsync(_formCloseCancellation.Token); + + Task completed = await Task.WhenAny(separatorFadingTask, uiUpdateTask); + + if (completed.IsCanceled) + { + break; + } + + if (completed == uiUpdateTask) + { + uiUpdateTask = null; + } + else + { + separatorFadingTask = null; + } + } +} + +protected override void OnFormClosing(FormClosingEventArgs e) +{ + base.OnFormClosing(e); + _formCloseCancellation.Cancel(); +} +``` + +Use `ConfigureAwait(false)` for operations that are safe from any thread (e.g. +setting a label color); skip it where the continuation must stay on the UI thread. + +## Quick checklist + +- Marshalling to the UI thread, non-blocking → `InvokeAsync`, not `Invoke`. +- Match the overload to sync/async and return-value needs. +- Async callbacks: return `ValueTask`, take a `CancellationToken` — avoid `WFO2001`. +- Don't pass an `InvokeAsync` `Task` into `Task.Run`; wrap it in a local function. +- `async void` only for event handlers, always with `try`/`catch`. +- To run work after the message queue drains, post it with `InvokeAsync` from the + UI thread (e.g. last line of `OnLoad`) — keeps `OnLoad` sync and non-blocking. +- Form/dialog async APIs are stable in .NET 10 (no `WFO5002` suppression needed). + +Sample code: diff --git a/.github/skills/winforms-custom-and-usercontrols/SKILL.md b/.github/skills/winforms-custom-and-usercontrols/SKILL.md new file mode 100644 index 0000000..03ca390 --- /dev/null +++ b/.github/skills/winforms-custom-and-usercontrols/SKILL.md @@ -0,0 +1,255 @@ +--- +name: winforms-custom-and-usercontrols +description: Author custom controls and UserControls for modern WinForms (.NET 6+). Use this skill whenever the user asks to create, derive, or modernize a WinForms control, UserControl, owner-drawn control, themed/dark-mode-aware control, scrollable Control-derived control, or a List/Grid-like control — even if they only say "I need a control that..." without naming it. Also use when wiring up Designer serialization for new control properties. If the user's project targets .NET Framework (4.x), inform them that Application.IsDarkModeEnabled, ApplyThemingImplicitly, and the WinForms Designer SDK are unavailable—offer .NET Framework equivalents or recommend migration. +--- + +# When to use this skill + +Authoring custom controls and UserControls for modern WinForms (.NET 6+, C# 13/14): base-class selection, clip-proof layout/sizing, owner-draw, dark-mode theming, Designer serialization, and List/Grid controls. +Use this skill when the user asks to create, derive, or modernize a WinForms control, UserControl, owner-drawn control, themed/dark-mode-aware control, scrollable Control-derived control, or a List/Grid-like control — even if they only say "I need a control that..." without naming it. Also use when wiring up Designer serialization for new control properties. + +Use it for designing UserControl to offload complex UI from forms, or when a reusable control is needed across multiple forms/projects. Also use it for modernizing legacy controls to be dark-mode aware and DPI-aware. + +**Tenets:** Assume the project's house style: + +- **No `using` statements** — globally-imported namespaces only. +- **NRTs enabled** — `#nullable enable` / `nullable` mode. +- **Modern C#** — pattern matching, `is`/`and`/`or`, switch expressions, collection initializers (`[]`). +- **XML doc comments** — for all public members. +- **`var` discipline** — use `var` only when the right-hand side is a constructor call or cast that makes the type obvious (e.g., `var cp = new CreateParams()`). Use explicit types for `int`, `bool`, `string`, `float`, `double`, `Color`, `Size`, `Point`, and similar short type names. +- **Expression-bodied members** — use them for single-expression getters, simple one-liner methods, and read-only properties. Use block bodies when the logic has side effects or exceeds ~100 characters. +- **Blank line before `return`** — improves readability in multi-statement methods. +- **Be brief** — the code carries the intent. + +## 1. Pick the right base class + +Decide *generic reusable control* vs *composite/LOB control* first. + +- **Generic, no domain (a slider, badge, gauge, custom button):** derive from `Control`, or the closest specialized base (`ButtonBase`, `ScrollableControl`, `ListControl`, `Panel`, `Label`, …). `Control` gives you a blank canvas with full paint/input control. `TextBoxBase` is **not available** — it is `internal`; if you need text-box-like behavior, derive from `TextBox` or compose one. +- **Composite / LOB / domain-specific:** derive from `UserControl` and assemble constituent controls (see §5). +- **List- or grid-shaped data:** derive from `DataGridView`, not `Control` from scratch (see §6). + +Prefer the most specific base that already solves the hard parts — don't reimplement `ButtonBase`'s focus/click mechanics on a raw `Control`. + +## 2. Layout & sizing — make it clip-proof + +A control that looks fine at 100% DPI with the default font but clips at 200% / large fonts is broken. Treat sizing as first-class wherever content needs a *minimum real estate* to render fonts/glyphs/images without clipping. + +Implement these as a coherent set: + +- **`GetPreferredSize(Size proposedSize)`** — return the size genuinely needed for current content, font, padding, and DPI. Measure text with `TextRenderer.MeasureText` (it matches `TextRenderer.DrawText`). Always add `Padding.Size`. This is the single source of truth for "how big do I need to be." +- **`AutoSize`** — expose and honor it. When `true`, the layout engine calls `GetPreferredSize`; your job is to return an honest number. Re-raise layout when size-affecting content changes (`PerformLayout()` / `Invalidate()`). +- **`SetBounds(...)`** — override when you must clamp or adjust the bounds you're given (e.g. enforce a minimum). Respect the `BoundsSpecified` flags so you don't clobber a dimension the caller didn't set. +- **`Padding`** — honor it in painting *and* `GetPreferredSize`. Content draws inside the padded rectangle. + +Rule of thumb: an honest `GetPreferredSize` plus honored `AutoSize`/`Padding` survives DPI and font scaling automatically. + +A typical `GetPreferredSize` for a text-bearing `Control`-derived control: + +```csharp +public override Size GetPreferredSize(Size proposedSize) +{ + Size textSize = TextRenderer.MeasureText(Text, Font); + Size content = new( + textSize.Width + Padding.Horizontal, + textSize.Height + Padding.Vertical); + + return new Size( + Math.Max(content.Width, MinimumSize.Width), + Math.Max(content.Height, MinimumSize.Height)); +} +``` + +`OnTextChanged` / `OnFontChanged` overrides should call `PerformLayout()` (and `Invalidate()` if owner-drawn) so a new preferred size is picked up while `AutoSize` is on. + +## 3. Owner-draw → always double-buffer + +When you take over painting (`OnPaint`, owner-draw cells, custom rendering), enable double buffering to eliminate flicker: + +```csharp +SetStyle( + ControlStyles.UserPaint + | ControlStyles.AllPaintingInWmPaint + | ControlStyles.OptimizedDoubleBuffer, + true); +``` + +Do all painting in `OnPaint` — don't scatter logic into `OnPaintBackground`. Call `Invalidate()` (not `Refresh()`) to request a repaint so it coalesces with other invalidations rather than forcing a synchronous redraw. + +## 4. Dark mode & theming + +Modern WinForms supports dark mode. Two things bite custom controls: + +### 4.1 Win32 scrollbars on a `Control`-derived class are not themed automatically + +If you add scrollbars by calling Win32 (`WS_HSCROLL` / `WS_VSCROLL`, `ShowScrollBar`, etc.) on a `Control` subclass, those scrollbars render in the classic (light) theme even in dark mode. To get them themed, override **`CreateParams`** and opt in: + +```csharp +protected override CreateParams CreateParams +{ + get + { + SetStyle(ControlStyles.ApplyThemingImplicitly, true); // opt-in; false = opt-out + CreateParams cp = base.CreateParams; + // add WS_HSCROLL / WS_VSCROLL here as needed + return cp; + } +} +``` + +This **cannot** be done in the constructor: the base constructor reads `CreateParams` *before* your constructor body runs. It must live in the `CreateParams` getter, with the `SetStyle` call placed *before* `base.CreateParams` is read. + +### 4.2 `SystemColors` flip in dark mode — names become counter-intuitive + +In dark mode, `SystemColors` are remapped to roughly complementary values. The **name no longer matches the apparent brightness, and that is intentional**: `ControlLightLight` is very bright in classic mode and correspondingly dark in dark mode. The remap is *not* always a strict mathematical complement — some colors are nudged to keep contrast adequate. + +Consequences: + +- If you use `SystemColors`, accept that the name describes its *classic-mode* role, not its color. +- For **guaranteed contrast in both modes**, don't use `SystemColors` for your control's palette. Define your own colors as explicit `#AARRGGBB` values with **separate defaults for classic and dark mode**. +- Pick the active set at runtime via `Application.IsDarkModeEnabled`, which reflects the *current* mode: + +```csharp +Color faceColor = Application.IsDarkModeEnabled + ? Color.FromArgb(unchecked((int)0xFF2D2D30)) + : Color.FromArgb(unchecked((int)0xFFF0F0F0)); +``` + +## 5. UserControls & composite custom controls + +For a `UserControl` (or any control composed of constituent controls): + +- **No design-time `Font` definitions.** Don't set `Font` on the UserControl or its children at design time. Design-time fonts pollute the HighDPI `AutoScaleDimensions` code generation, so when the user later re-opens the control in the Designer the generated scaling code is wrong. Adjust font *size* only at **runtime**. +- **Layout in a `TableLayoutPanel`** with `AutoSize` rows/columns. Let the table compute geometry. +- **The control itself exposes `AutoSize`** and an honest **`GetPreferredSize`** big enough to render every child unclipped across all HighDPI and large-font scenarios (§2). The `TableLayoutPanel` with AutoSize cells does most of this for you, but verify the outer control reports it. +- **Very large UserControls** with many containers/controls where a single clip-proof preferred size is impractical: host the content in a scrolling `Panel` (`AutoScroll = true`) rather than forcing an unreasonable minimum size. + +## 6. List- / Grid-like controls + +When asked for anything list- or grid-shaped, **derive from `DataGridView`** rather than building from scratch. + +For image-rich or graphically rich data records, the best outcome is usually to **render the entire data item in one cell** via a custom `DataGridViewCell` — symbol fonts, multiple colors, multiple font sizes, multi-line layout, all in a single cell. Skip column headers in that case. + +Keep column headers (and therefore per-field columns) only when the context genuinely needs a schema overview or per-column sort/filter. Even then, prefer **custom cell rendering** to get the rich graphical UI; don't fall back to plain text just because headers exist. + +Whenever cells vary in height, **compute row height from the tallest cell in the data row** — measure each cell's required height and set the row to the max, or the content clips. + +## 7. Designer serialization for new properties + +Every public property you add must tell the Designer how to serialize it. Pick **exactly one** of these three mechanisms (they conflict if combined): + +1. **`[DesignerSerializationVisibility(...)]`** — `Hidden` for runtime-only/derived properties the Designer must not write to `InitializeComponent`; `Content` for collections whose items serialize individually. +2. **`[DefaultValue(...)]`** — the property serializes only when its value differs from this constant. Use for simple properties with a fixed, compile-time-constant default. +3. **`ResetXxx()` + `ShouldSerializeXxx()` pair** — `ShouldSerializeXxx` returns whether the value should be written; `ResetXxx` restores the default. **Use this** for ambient properties, and whenever a default is *not constant* (depends on another property or the theme) — `[DefaultValue]` can't express that. + +The method pair, for a property whose default depends on the current theme: + +```csharp +/// Gets or sets the color of the control's face. +public Color FaceColor { get; set; } = DefaultFaceColor; + +private static Color DefaultFaceColor + => Application.IsDarkModeEnabled + ? Color.FromArgb(unchecked((int)0xFF2D2D30)) + : Color.FromArgb(unchecked((int)0xFFF0F0F0)); + +// The Designer discovers these by naming convention — keep them private. +private bool ShouldSerializeFaceColor() + => FaceColor != DefaultFaceColor; + +private void ResetFaceColor() + => FaceColor = DefaultFaceColor; +``` + +Note the convention: the methods are `private`, named exactly `ShouldSerialize` / `Reset`, and take no parameters. Don't combine them with `[DefaultValue]` on the same property — the two mechanisms conflict. + +Don't leave a property with none of these — the Designer will either over-serialize or fail to round-trip. + +### 7.1 Property-window attributes + +Beyond serialization, decorate every new public property so it behaves well in the Properties window and IntelliSense. These are four *distinct* concerns: + +- **`[Category("Appearance")]`** — the Properties-window group. Reuse standard names (`Appearance`, `Behavior`, `Layout`, `Data`, …) so your properties merge with the built-in ones. +- **`[Description("...")]`** — help text in the Properties window's description pane. Write it for a control *consumer*. +- **`[Browsable(false)]`** — hides the property from the Properties window. Use for runtime-only state. It almost always also needs `[DesignerSerializationVisibility(Hidden)]` — hiding from the grid does *not* stop serialization. +- **`[EditorBrowsable(EditorBrowsableState.Never | Advanced)]`** — controls IntelliSense visibility, independent of the grid. `Never` hides from completion; `Advanced` shows only when "Hide advanced members" is off. + +To fully suppress an inherited property that's meaningless on your control (e.g. shadowing `Text`), combine all three on the `new`/`override` member: + +```csharp +[Browsable(false)] +[EditorBrowsable(EditorBrowsableState.Never)] +[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] +public override string Text +{ + get => base.Text; + set => base.Text = value; +} +``` + +A normal, visible property reads like this: + +```csharp +[Category("Appearance")] +[Description("The color used to paint the control's face.")] +public Color FaceColor { get; set; } = DefaultFaceColor; +``` + +## 8. Construction order — `ISupportInitialize` + +During `InitializeComponent`, the Designer sets your properties one at a time in an order *you don't control*. If two properties are interdependent — or a setter does expensive work (re-layout, allocation, a Win32 round-trip) — the control repeats that work against a half-configured state, and may even throw because property B isn't set yet when property A's setter runs. + +Implement **`ISupportInitialize`** to defer it. The Designer emits `BeginInit()` before the property block and `EndInit()` after; do nothing expensive in between: + +```csharp +public class GaugeControl : Control, ISupportInitialize +{ + private bool _initializing; + + public void BeginInit() + => _initializing = true; + + public void EndInit() + { + _initializing = false; + RecalculateLayout(); // the deferred work, done once, fully configured + Invalidate(); + } + + public int Minimum + { + get; + set + { + field = value; + + if (!_initializing) + { + RecalculateLayout(); + Invalidate(); + } + } + } +} +``` + +Every interdependent setter checks `_initializing` and skips the expensive path while it's `true`. `EndInit` runs that path exactly once. At runtime (no Designer), code that sets properties directly without calling `BeginInit`/`EndInit` still works — `_initializing` is just `false`, so each setter does its work immediately. + +## 9. Designer support code (.NET 6+) + +Designer functionality in .NET 6+ requires the **WinForms Designer SDK** NuGet package. + +- Use the current preview: **`Microsoft.WinForms.Designer.SDK 1.13.0-preview.2.24575.3`**. The latest stable (`1.6.0`) is missing features you'll likely need — prefer the preview. +- Without shipping a separate Designer NuGet package, you **can** put the following in the *same .NET assembly as the control*: custom `CodeDomSerializer`s, custom `TypeConverter`s, and custom `DesignerActionList` (smart-tag) actions. +- **Out of scope for this skill:** Designers with a *custom design-time UI*. Those can't live in the control assembly — they need a dedicated multi-assembly NuGet package (client side on .NET Framework 4.7.2, server-side process on .NET, and a multi-targeted communication layer). If the user needs that, say so and stop. + +## Quick checklist + +- Most specific base class (`Control` generic, `UserControl` composite, `DataGridView` list/grid). +- `GetPreferredSize` honest; `AutoSize` + `Padding` honored; survives 200% DPI / large fonts. +- Owner-draw → `OptimizedDoubleBuffer | AllPaintingInWmPaint | UserPaint`; repaint via `Invalidate()`. +- Dark mode: own `#AARRGGBB` palette per mode, switched on `Application.IsDarkModeEnabled`; Win32 scrollbars themed via `CreateParams` + `ApplyThemingImplicitly`. +- UserControl: no design-time fonts, `TableLayoutPanel` with AutoSize cells, scrolling `Panel` if too large. +- Every new property: `[DefaultValue]`, `[DesignerSerializationVisibility]`, or `ShouldSerializeXxx`/`ResetXxx`; plus `[Category]`/`[Description]`. +- Interdependent or expensive property setters → `ISupportInitialize` with a `_initializing` guard. +- Designer SDK preview `1.13.0-preview.2.24575.3` if design-time code is involved. diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 0000000..e69de29 diff --git a/src/WinForms/NET10/WinBaas/WinBaas.slnx b/src/WinForms/NET10/WinBaas/WinBaas.slnx new file mode 100644 index 0000000..75ef488 --- /dev/null +++ b/src/WinForms/NET10/WinBaas/WinBaas.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor.slnx b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor.slnx new file mode 100644 index 0000000..f262692 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/AddAppDialog.Designer.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/AddAppDialog.Designer.cs new file mode 100644 index 0000000..a279509 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/AddAppDialog.Designer.cs @@ -0,0 +1,215 @@ +namespace Winget_Package_Editor; + +partial class AddAppDialog +{ + private System.ComponentModel.IContainer components = null!; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + _layoutPanel = new TableLayoutPanel(); + _appLabel = new Label(); + _appComboBox = new ComboBox(); + _actionLabel = new Label(); + _actionComboBox = new ComboBox(); + _sourceLabel = new Label(); + _sourceComboBox = new ComboBox(); + _scopeLabel = new Label(); + _scopeComboBox = new ComboBox(); + _versionLabel = new Label(); + _versionTextBox = new TextBox(); + _allowPrereleaseCheckBox = new CheckBox(); + _buttonPanel = new FlowLayoutPanel(); + _okButton = new Button(); + _cancelButton = new Button(); + _layoutPanel.SuspendLayout(); + _buttonPanel.SuspendLayout(); + SuspendLayout(); + // + // _layoutPanel + // + _layoutPanel.ColumnCount = 2; + _layoutPanel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + _layoutPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + _layoutPanel.Controls.Add(_appLabel, 0, 0); + _layoutPanel.Controls.Add(_appComboBox, 1, 0); + _layoutPanel.Controls.Add(_actionLabel, 0, 1); + _layoutPanel.Controls.Add(_actionComboBox, 1, 1); + _layoutPanel.Controls.Add(_sourceLabel, 0, 2); + _layoutPanel.Controls.Add(_sourceComboBox, 1, 2); + _layoutPanel.Controls.Add(_scopeLabel, 0, 3); + _layoutPanel.Controls.Add(_scopeComboBox, 1, 3); + _layoutPanel.Controls.Add(_versionLabel, 0, 4); + _layoutPanel.Controls.Add(_versionTextBox, 1, 4); + _layoutPanel.Controls.Add(_allowPrereleaseCheckBox, 1, 5); + _layoutPanel.Controls.Add(_buttonPanel, 1, 6); + _layoutPanel.Dock = DockStyle.Fill; + _layoutPanel.Location = new Point(0, 0); + _layoutPanel.Name = "_layoutPanel"; + _layoutPanel.Padding = new Padding(12); + _layoutPanel.RowCount = 7; + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + _layoutPanel.Size = new Size(458, 320); + _layoutPanel.TabIndex = 0; + // + // _appLabel + // + _appLabel.Anchor = AnchorStyles.Left; + _appLabel.AutoSize = true; + _appLabel.Name = "_appLabel"; + _appLabel.Text = "App:"; + // + // _appComboBox + // + _appComboBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _appComboBox.DropDownStyle = ComboBoxStyle.DropDownList; + _appComboBox.Name = "_appComboBox"; + _appComboBox.TabIndex = 0; + // + // _actionLabel + // + _actionLabel.Anchor = AnchorStyles.Left; + _actionLabel.AutoSize = true; + _actionLabel.Name = "_actionLabel"; + _actionLabel.Text = "Action:"; + // + // _actionComboBox + // + _actionComboBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _actionComboBox.DropDownStyle = ComboBoxStyle.DropDownList; + _actionComboBox.Name = "_actionComboBox"; + _actionComboBox.TabIndex = 1; + // + // _sourceLabel + // + _sourceLabel.Anchor = AnchorStyles.Left; + _sourceLabel.AutoSize = true; + _sourceLabel.Name = "_sourceLabel"; + _sourceLabel.Text = "Source:"; + // + // _sourceComboBox + // + _sourceComboBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _sourceComboBox.DropDownStyle = ComboBoxStyle.DropDownList; + _sourceComboBox.Name = "_sourceComboBox"; + _sourceComboBox.TabIndex = 2; + // + // _scopeLabel + // + _scopeLabel.Anchor = AnchorStyles.Left; + _scopeLabel.AutoSize = true; + _scopeLabel.Name = "_scopeLabel"; + _scopeLabel.Text = "Scope:"; + // + // _scopeComboBox + // + _scopeComboBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _scopeComboBox.DropDownStyle = ComboBoxStyle.DropDownList; + _scopeComboBox.Name = "_scopeComboBox"; + _scopeComboBox.TabIndex = 3; + // + // _versionLabel + // + _versionLabel.Anchor = AnchorStyles.Left; + _versionLabel.AutoSize = true; + _versionLabel.Name = "_versionLabel"; + _versionLabel.Text = "Version (optional):"; + // + // _versionTextBox + // + _versionTextBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _versionTextBox.Name = "_versionTextBox"; + _versionTextBox.TabIndex = 4; + // + // _allowPrereleaseCheckBox + // + _allowPrereleaseCheckBox.Anchor = AnchorStyles.Left; + _allowPrereleaseCheckBox.AutoSize = true; + _allowPrereleaseCheckBox.Name = "_allowPrereleaseCheckBox"; + _allowPrereleaseCheckBox.TabIndex = 5; + _allowPrereleaseCheckBox.Text = "Allow prerelease"; + _allowPrereleaseCheckBox.UseVisualStyleBackColor = true; + // + // _buttonPanel + // + _buttonPanel.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; + _buttonPanel.AutoSize = true; + _buttonPanel.Controls.Add(_okButton); + _buttonPanel.Controls.Add(_cancelButton); + _buttonPanel.FlowDirection = FlowDirection.LeftToRight; + _buttonPanel.Name = "_buttonPanel"; + _buttonPanel.TabIndex = 6; + // + // _okButton + // + _okButton.DialogResult = DialogResult.OK; + _okButton.Name = "_okButton"; + _okButton.Size = new Size(90, 27); + _okButton.TabIndex = 0; + _okButton.Text = "Add"; + _okButton.UseVisualStyleBackColor = true; + _okButton.Click += OkButton_Click; + // + // _cancelButton + // + _cancelButton.DialogResult = DialogResult.Cancel; + _cancelButton.Name = "_cancelButton"; + _cancelButton.Size = new Size(90, 27); + _cancelButton.TabIndex = 1; + _cancelButton.Text = "Cancel"; + _cancelButton.UseVisualStyleBackColor = true; + // + // AddAppDialog + // + AcceptButton = _okButton; + AutoScaleDimensions = new SizeF(8F, 20F); + AutoScaleMode = AutoScaleMode.Font; + CancelButton = _cancelButton; + ClientSize = new Size(458, 320); + Controls.Add(_layoutPanel); + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Name = "AddAppDialog"; + StartPosition = FormStartPosition.CenterParent; + Text = "Add App"; + _layoutPanel.ResumeLayout(false); + _layoutPanel.PerformLayout(); + _buttonPanel.ResumeLayout(false); + ResumeLayout(false); + } + + #endregion + + private TableLayoutPanel _layoutPanel = null!; + private Label _appLabel = null!; + private ComboBox _appComboBox = null!; + private Label _actionLabel = null!; + private ComboBox _actionComboBox = null!; + private Label _sourceLabel = null!; + private ComboBox _sourceComboBox = null!; + private Label _scopeLabel = null!; + private ComboBox _scopeComboBox = null!; + private Label _versionLabel = null!; + private TextBox _versionTextBox = null!; + private CheckBox _allowPrereleaseCheckBox = null!; + private FlowLayoutPanel _buttonPanel = null!; + private Button _okButton = null!; + private Button _cancelButton = null!; +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/AddAppDialog.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/AddAppDialog.cs new file mode 100644 index 0000000..bbb7d49 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/AddAppDialog.cs @@ -0,0 +1,47 @@ +using WingetPackageEditor.Core.Models; +using WingetPackageEditor.Core.Services; + +namespace Winget_Package_Editor; + +internal partial class AddAppDialog : Form +{ + public AddAppDialog(IReadOnlyList wellKnownApps) + { + ArgumentNullException.ThrowIfNull(wellKnownApps); + InitializeComponent(); + + _appComboBox.DisplayMember = nameof(AppEntry.DisplayName); + _appComboBox.DataSource = wellKnownApps.ToList(); + _actionComboBox.DataSource = Enum.GetValues(); + _sourceComboBox.DataSource = Enum.GetValues(); + _scopeComboBox.DataSource = Enum.GetValues(); + + if (wellKnownApps.Count > 0) + { + _appComboBox.SelectedIndex = 0; + } + } + + public AppEntry? Result { get; private set; } + + private void OkButton_Click(object? sender, EventArgs e) + { + if (_appComboBox.SelectedItem is not AppEntry template) + { + MessageBox.Show(this, "Please select an app.", "Add App", + MessageBoxButtons.OK, MessageBoxIcon.Information); + DialogResult = DialogResult.None; + return; + } + + // Clone the template so the catalog entry is never mutated. + AppEntry configured = WingetPackage.Clone(new WingetPackage { Apps = [template] }).Apps[0]; + configured.Action = (AppAction)_actionComboBox.SelectedItem!; + configured.Source = (AppSource)_sourceComboBox.SelectedItem!; + configured.Scope = (AppScope)_scopeComboBox.SelectedItem!; + configured.AllowPrerelease = _allowPrereleaseCheckBox.Checked; + configured.Version = string.IsNullOrWhiteSpace(_versionTextBox.Text) ? null : _versionTextBox.Text.Trim(); + + Result = configured; + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Feature-Prompts/ApplicationPrompt.md b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Feature-Prompts/ApplicationPrompt.md new file mode 100644 index 0000000..78204ee --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Feature-Prompts/ApplicationPrompt.md @@ -0,0 +1,431 @@ +# WinGet Package Editor — Implementation Prompt for Copilot + +## Project Summary + +Build a Windows Forms application targeting **.NET 10** that acts as a visual editor for **WinGet Configuration packages** (DSC YAML consumed by `winget configure`). It is a single-user personal tool for managing ~8 development machines from one curated, repeatable provisioning surface. + +The app uses the **WARP.Toolkit** WinForms library. WARP and WinForms conventions are already understood by the agent; follow standard WARP patterns and WinForms idioms. Be precise where this document is precise (WinGet YAML, data model, process behavior) and conventional everywhere else. + +--- + +## Scope — v1 only + +**In scope:** + +- Create / edit / delete / save / load Package documents (JSON on disk). +- Edit polymorphic app entries via a master-detail UI. +- Emit a `config.yaml` + `install.ps1` pair from a Package. +- "Run Now" — shell out to `winget configure`, stream output to a console pane. +- Settings file in `%APPDATA%`, designed to roam via OneDrive Known-Folder Move. +- Hardcoded curated catalog. + +**Explicitly out of scope (v2 candidates — do not implement):** + +- Scheduled Task creation. +- Self-contained .NET exe bundle generation. +- Catalog editor / user-extensible catalog. + +--- + +## Build Order — Engine First + +The interesting risk in this project is not the UI. Build in this order: + +1. **Data model** (POCOs with `System.Text.Json` polymorphism). +2. **YAML emitter** (model → `winget configure` YAML). +3. **Process runner** for `winget configure` with async stdout/stderr streaming. +4. **One hardcoded test package** that round-trips through #1–#3 from a **console host** (a small `Program.cs` or xUnit test). +5. **WinForms editor** wrapping the engine. + +**Do not start on the WinForms UI until step 4 produces YAML that `winget configure` accepts and runs end-to-end.** The engine is where the real risk lives; the UI is the last 30% of the work, not the first. + +--- + +## Data Model + +```csharp +public sealed class WingetPackage +{ + public string Name { get; set; } = ""; + public string? Description { get; set; } + public string? Author { get; set; } + public string Version { get; set; } = "1.0.0"; + public List Apps { get; set; } = []; +} + +public enum AppAction { Ensure, Install, Upgrade } +public enum AppScope { User, Machine } +public enum AppSource { Winget, MSStore } + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(GenericAppEntry), "generic")] +[JsonDerivedType(typeof(VisualStudioEntry), "vs")] +[JsonDerivedType(typeof(VSCodeEntry), "vscode")] +public abstract class AppEntry +{ + public string Id { get; set; } = ""; // WinGet package id + public string DisplayName { get; set; } = ""; + public AppAction Action { get; set; } = AppAction.Ensure; + public AppSource Source { get; set; } = AppSource.Winget; + public string? Version { get; set; } // null = latest + public AppScope Scope { get; set; } = AppScope.Machine; + public bool AllowPrerelease { get; set; } + public Dictionary ExtraSettings { get; set; } = []; +} + +public sealed class GenericAppEntry : AppEntry { } + +public sealed class VisualStudioEntry : AppEntry +{ + public VSEdition Edition { get; set; } // Community | Professional | Enterprise | BuildTools + public VSChannel Channel { get; set; } // Release | Preview + public string? VSConfigPath { get; set; } + public string? VSConfigInline { get; set; } // alternative to path + public string? InstanceNickname { get; set; } + public List Extensions { get; set; } = []; +} + +public sealed class VsixReference +{ + public string Identifier { get; set; } = ""; // marketplace ItemName, .vsix URL, or local path + public bool Admin { get; set; } = true; +} + +public sealed class VSCodeEntry : AppEntry +{ + public List Extensions { get; set; } = []; // publisher.name format +} + +public enum VSEdition { Community, Professional, Enterprise, BuildTools } +public enum VSChannel { Release, Preview } +``` + +Serialize Packages to JSON as the document format. + +--- + +## Curated Catalog (v1, hardcoded) + +Each catalog item provides defaults (Id, DisplayName, default action, polymorphic entry type). Adding a catalog item to a package creates a fresh editable copy. + +| DisplayName | WinGet Id | Entry Type | Notes | +|---|---|---|---| +| PowerShell 7 | `Microsoft.PowerShell` | Generic | | +| Visual Studio Code | `Microsoft.VisualStudioCode` | VSCode | Has Extensions list | +| Visual Studio | (see VS section) | VS | Custom flow | +| Clink | `chrisant996.Clink` | Generic | | +| Oh My Posh | `JanDeDobbeleer.OhMyPosh` | Generic | | +| Sysinternals Suite | `Microsoft.Sysinternals.Suite` | Generic | Verify id | +| 7-Zip | `7zip.7zip` | Generic | | +| ScreenToGif | `NickeManarin.ScreenToGif` | Generic | | +| WinMerge | `WinMerge.WinMerge` | Generic | | +| Windows App | `Microsoft.WindowsApp` | Generic | Cloud-PC client, formerly Remote Desktop | +| GitHub CLI | `GitHub.cli` | Generic | | +| GitHub Copilot CLI | `GitHub.cli` + extension | Generic | Post-step: `gh extension install github/gh-copilot` | +| Paint.NET | `dotPDN.PaintDotNet` | Generic | | +| PowerToys | `Microsoft.PowerToys` | Generic | | +| .NET 8 SDK | `Microsoft.DotNet.SDK.8` | Generic | | +| .NET 10 SDK | `Microsoft.DotNet.SDK.10` | Generic | | +| .NET 11 SDK (Preview) | `Microsoft.DotNet.SDK.Preview` | Generic | Set `AllowPrerelease = true`; verify id | + +**Important:** WinGet IDs in this table are best-effort. Several may be stale. Before locking the catalog, run `winget search ` for each entry and capture the canonical id. Do **not** ship the catalog with unverified IDs. + +Add a `CatalogValidator` that, in DEBUG builds, calls `winget show --id --exact` for each catalog entry at startup and logs any misses. + +--- + +## WinGet Configuration YAML — Precise Spec + +**This is the section to be exact about. Read carefully.** + +The output format is **WinGet Configuration**, which uses **DSC v3** semantics over a **WinGet Configuration v0.2** schema. + +### File header + +```yaml +# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 +properties: + configurationVersion: 0.2 + assertions: [] + resources: + # ... resource entries +``` + +Verify the current canonical schema URL before locking it in (Microsoft has occasionally moved DSC schema endpoints). + +### The two `id` fields — DO NOT CONFUSE + +Each resource block has two distinct `id` values: + +```yaml +- resource: Microsoft.WinGet.DSC/WinGetPackage + id: microsoft-powershell # ← YAML-level anchor, for dependsOn references + settings: + id: Microsoft.PowerShell # ← WinGet package id (the "real" id) +``` + +- The **YAML-level `id`** is a stable anchor for `dependsOn`. Derive from the package id: lowercase, dots → hyphens, no other special chars. Must be unique within the document. +- The **`settings.id`** is the literal WinGet package id passed to the package manager. + +### Generic app — `Microsoft.WinGet.DSC/WinGetPackage` + +```yaml +- resource: Microsoft.WinGet.DSC/WinGetPackage + id: + directives: + description: + allowPrerelease: + settings: + id: + source: winget # or "msstore" + Ensure: Present # DSC convention; capitalized; values are Present | Absent + UseLatest: true # when entry.Version is null + # OR — never both: + Version: "1.2.3" # when entry.Version is pinned +``` + +Critical: + +- `Ensure` is **capitalized**, values `Present` | `Absent`. Do not lowercase. +- `UseLatest` and `Version` are **mutually exclusive**. Emit exactly one. +- v1 always emits `Ensure: Present` (Install/Upgrade/Ensure on the model side all collapse to `Present`; the model's distinction matters only for documentation and a future Absent verb). +- Omit `allowPrerelease` when false; include only when true. + +### VS Code + +VSCode itself uses `WinGetPackage`. Extensions install via a follow-up Script resource that depends on the VSCode resource: + +```yaml +- resource: PSDscResources/Script + id: vscode-extensions + dependsOn: + - microsoft-visualstudiocode # the WinGetPackage YAML anchor + directives: + description: Install VS Code extensions + settings: + GetScript: "return @{ Result = '' }" + TestScript: "return $false" # always re-run; idempotent because --install-extension no-ops if present + SetScript: | + $exts = @('ms-dotnettools.csharp', 'github.copilot') + foreach ($e in $exts) { + & code --install-extension $e --force + } +``` + +`dependsOn` references the **YAML anchor**, not the WinGet id. + +### Visual Studio + +VS has its own DSC module: **`Microsoft.VisualStudio.DSC`**. Resource surface: `VSSetup` (install / edition / channel / vsconfig), `VSComponents` (workloads + components after install). **Module evolves quickly — verify resource names and supported settings at implementation time** via: + +```powershell +Get-DscResource -Module Microsoft.VisualStudio.DSC +``` + +Minimal VS install via VSConfig: + +```yaml +- resource: Microsoft.VisualStudio.DSC/VSSetup + id: vs-pro-2022 + directives: + description: Visual Studio 2022 Professional + settings: + productId: Microsoft.VisualStudio.Product.Professional + channelId: VisualStudio.17.Release # or VisualStudio.17.Preview + configFile: C:\path\to\package.vsconfig # required when configuring components + # nickname / instance: as supported by the current module version +``` + +If the model has `VSConfigInline` set, the emitter writes that content to a temp `.vsconfig` next to the YAML and points `configFile` at it. + +#### VSIX — reliable v1 approach + +DSC resource coverage for arbitrary marketplace VSIXes is incomplete. Use a `PSDscResources/Script` resource that discovers VS via `vswhere` and loops through `VSIXInstaller.exe`: + +```yaml +- resource: PSDscResources/Script + id: vs-extensions + dependsOn: + - vs-pro-2022 + directives: + description: Install VSIX extensions + settings: + GetScript: "return @{ Result = '' }" + TestScript: "return $false" + SetScript: | + $vsRoot = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" ` + -latest -property installationPath + $installer = Join-Path $vsRoot 'Common7\IDE\VSIXInstaller.exe' + $vsixes = @('', '') + foreach ($v in $vsixes) { + $p = Start-Process $installer -ArgumentList "/quiet /admin `"$v`"" -Wait -PassThru + # exit 1001 means already installed — treat as success + if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 1001) { + throw "VSIXInstaller failed for $v with code $($p.ExitCode)" + } + } +``` + +### Sources + +- `winget` — public community repo (default). +- `msstore` — Microsoft Store source. Different license-acceptance flow; `winget configure` handles it but the user must accept agreements on first contact. + +### Emitter hygiene + +- No BOM in the YAML file. Write UTF-8 without BOM. +- Do not emit empty `directives:` or `settings:` blocks — omit the key. +- Do not emit `dependsOn` arrays for resources with no real dependency. +- YAML keys are case-sensitive in DSC. `Ensure`, `UseLatest`, `Version`, `Present`, `Absent` — preserve case exactly as documented above. +- Stable ordering: sort entries deterministically (by YAML anchor) so identical models produce byte-identical YAML — important for diffing and source control. + +--- + +## `install.ps1` Companion Script + +Emit alongside the YAML in the same output folder: + +```powershell +#Requires -Version 7.0 +[CmdletBinding()] param() + +$ErrorActionPreference = 'Stop' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$config = Join-Path $here 'config.yaml' + +# Verify winget +if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + throw "winget not found. Install App Installer from the Microsoft Store and retry." +} + +# Ensure required DSC modules +$modules = @('Microsoft.WinGet.DSC', 'PSDscResources') +# Emitter appends 'Microsoft.VisualStudio.DSC' if any VS resource is present in the YAML. + +foreach ($m in $modules) { + if (-not (Get-Module -ListAvailable -Name $m)) { + Install-Module -Name $m -Scope CurrentUser -Force -AcceptLicense + } +} + +winget configure --file $config --accept-configuration-agreements --verbose +exit $LASTEXITCODE +``` + +The emitter mutates the `$modules` list at emit-time based on which resources are present in the YAML. + +--- + +## Process Runner — Console Pane Specifics + +The console pane streams output from `winget configure`. Required behavior: + +1. **Async, line-buffered.** Use `Process.StandardOutput.ReadLineAsync` in a loop. **Never** `ReadToEnd()` — installs take minutes and the UI must remain responsive. +2. **UI thread marshaling.** Output writes must go through `Control.Invoke` / `BeginInvoke` or a captured `SynchronizationContext`. The reader runs on a `Task`, not the UI thread. +3. **ANSI escape handling.** `winget configure --verbose` emits cursor-control escapes for progress bars: `\x1B[2K`, `\x1B[?25l`, `\x1B[;H`, color escapes, etc. Strip with this regex before display: + ``` + \x1B\[[\d;?]*[a-zA-Z] + ``` + Or, optionally, parse colors into spans if colored output is wanted. v1 = strip is fine. +4. **Unbounded growth defense.** Do **not** use a plain `RichTextBox` that grows without bound — long installs produce thousands of lines and the control will stall. Use either a virtualized list/grid or a capped ring buffer (e.g. last 5000 lines, drop oldest). +5. **stderr handling.** Capture stderr separately. Tag stderr lines visually (dim red foreground / italic / "[err]" prefix — agent's call). + +Process invocation: + +``` +winget configure --file "" --accept-configuration-agreements --verbose +``` + +Smoke-test the YAML before running. `winget configure` supports subcommands like `show` and `test` for inspection / Test-phase dry-runs — check `winget configure --help` and use the appropriate one as the emitter's validation step. Do not assume `validate` exists; verify. + +--- + +## Elevation + +`winget configure` needs elevation for machine-scope installs (most of the catalog). + +**v1: use `requireAdministrator` in the app manifest.** The whole app runs elevated. This is a single-user personal tool; do not over-engineer split-privilege models. + +```xml + +``` + +--- + +## MVVM Architecture + +- Create a dedicated NET10 class library which holds the ViewModels and the Business Logic with every App related WinGet Feature. +- Create Unit Tests for the ViewModels with fake WinGet runners. +- Use Warp.Toolkits DI and Warp.Toolkit UI Services for Dialog control. + +### UI-Helper classes + +- Not every Control in WinForms is suited for Binding in MVVM Fashion. For those cases, create adapter classes in the UI Project. +- Call the Forms/UserControls 'View'. E.g. FrmMainView (Form), DetailsView (UserControl) + +## Layout (brief — WARP conventions apply) + +- Top: MenuStrip +- Below: ToolStrip - use WARP ToolStrip skill for Icons. +- Below: Nested SplitContainer -- Left->TreeView node of packages->node of Apps->node of Extensions/Plug-ins, where it applies. Right->SplitContainer -- Top:WarpDataGridView with list of Apps of package/List of Extensions/Plug-ins where it applies->List of properties/settings of App/Extensions/Plug-in. Bottom (Panel2): Console control for debug and procces stdinout. +- `StatusStrip`. Status strip surfaces details of the current selection. +- VS and VSCode are the apps currently needing extension support. +- Menus: **File** (New, Open, Save, Save As, Export YAML+Script, Quit), **Edit** (Add App, Remove App, Properties), **Action** (Apply Now, Generate Bundle Folder), **Tools** (Options), **Help**. + +--- + +## Settings File + +`%APPDATA%\WingetPackageEditor\settings.json` — single JSON file, roamable via OneDrive Known-Folder Move: + +```json +{ + "PackageStorePath": "%OneDrive%\\WingetPackages", + "LastOpenedPackage": "...", + "WindowState": { "Width": 1400, "Height": 900, "Maximized": true } +} +``` + +Resolve `%OneDrive%` and other environment variables at load time via `Environment.ExpandEnvironmentVariables`. + +--- +## Definition of Done - V0 + +WinForms and MVVM is still a work-approach to optimize. For that reason: +* We start building a Solution skeleton, with a minimum ViewModel wired up to + - A few MenuItems and ToolStripButtons + - The TreeView + - The WarpDataGridView + - The Console + - The StatusStrip + +The ViewModel should +* contain the real base structure and already include the data model. +* Ensure the correct Command roundtripping (MenuItems, ToolStripButtons, Buttons) +* Ensure the proper functioning of Relay Commands. +* Correct propergation of ViewModels via the Control's DataContext Property +* Ensure the correct rountripping of the TreView +* Ensure the correct roundtripping of the WarpDataGridView +* Introduce a system to communicate (Messages?) with the Console control. +* Proof, that the ViewModel build for V0 scope is unit testable. + +In AutoPilot, the V0 build task is done after those features have been created. +Further continuation with V1 needs exploritory testing by human interaction, and a manual new triggering for building the next V1 milestone. + +## Definition of Done — v1 + +1. Create a new package, add 5 mixed entries (including a Visual Studio Professional entry with a VSConfig and 2 VSIXes, plus a VS Code entry with 3 extensions). Save as JSON. +2. Reopen the saved package — round-trips losslessly (assert via golden-file test). +3. Emit `config.yaml` + `install.ps1` to a folder; YAML is deterministic, BOM-free, UTF-8. +4. The emitted YAML loads without error via `winget configure show --file config.yaml` (or whichever inspection subcommand exists at implementation time). +5. "Run Now" executes `winget configure` end-to-end with live output streamed to the console pane; UI remains responsive; ANSI escapes are stripped; long output does not stall the UI. +6. App runs elevated via manifest; no per-action UAC prompts during normal use. + +--- + +## Final Notes for the Implementing Agent + +- **Verify before locking.** WinGet IDs, DSC module resource surfaces (`Microsoft.WinGet.DSC`, `Microsoft.VisualStudio.DSC`, `PSDscResources`), and the canonical schema URL change. Validate at implementation time against the actual installed environment; do not trust this document over a freshly-run `winget search` or `Get-DscResource`. +- **Engine-first build order is non-negotiable.** A working YAML emitter + process runner driven by a console host must exist before any WinForms code is written. +- **Determinism matters.** Two runs of the emitter on the same model must produce byte-identical YAML. Source control will thank the user. +- **No invented features.** v2 items (schedule, exe bundle, catalog editor) are not v1. If a v1 feature pulls in v2-shaped scope, stop and ask. diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/GridSelectionBinder.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/GridSelectionBinder.cs new file mode 100644 index 0000000..634360e --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/GridSelectionBinder.cs @@ -0,0 +1,61 @@ +using System.ComponentModel; +using WingetPackageEditor.Core.ViewModels; + +namespace Winget_Package_Editor; + +internal sealed class GridSelectionBinder : IDisposable +{ + private readonly DataGridView _gridView; + private readonly MainViewModel _viewModel; + private bool _updating; + + public GridSelectionBinder(DataGridView gridView, MainViewModel viewModel) + { + _gridView = gridView ?? throw new ArgumentNullException(nameof(gridView)); + _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + _gridView.SelectionChanged += OnSelectionChanged; + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + } + + public void Dispose() + { + _gridView.SelectionChanged -= OnSelectionChanged; + _viewModel.PropertyChanged -= OnViewModelPropertyChanged; + } + + private void OnSelectionChanged(object? sender, EventArgs e) + { + if (_updating || _gridView.CurrentRow?.DataBoundItem is not AppEntryViewModel app) + { + return; + } + + _viewModel.SelectedApp = app; + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(MainViewModel.SelectedApp)) + { + return; + } + + _updating = true; + try + { + foreach (DataGridViewRow row in _gridView.Rows) + { + if (ReferenceEquals(row.DataBoundItem, _viewModel.SelectedApp)) + { + row.Selected = true; + _gridView.CurrentCell = row.Cells[0]; + return; + } + } + } + finally + { + _updating = false; + } + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/MainForm.Designer.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/MainForm.Designer.cs new file mode 100644 index 0000000..52a4852 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/MainForm.Designer.cs @@ -0,0 +1,543 @@ +using Microsoft.Extensions.DependencyInjection; +using WarpToolkit.ComponentModel; +using WarpToolkit.WinForms.Specialized; +using WingetPackageEditor.Core.ViewModels; + +namespace Winget_Package_Editor; + +public partial class MainForm : Form, IServiceProvider +{ + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null!; + +#pragma warning disable WFOWARP9901 + private sealed class DeferredServiceProvider : IServiceProvider + { + private readonly IServiceProvider _serviceProvider; + + public DeferredServiceProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public object GetService(Type serviceType) => _serviceProvider.GetService(serviceType); + } +#pragma warning restore WFOWARP9901 + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + if (_viewModel is not null) + { + _viewModel.PropertyChanged -= ViewModel_PropertyChanged; + _viewModel.ConsoleMessages.CollectionChanged -= ConsoleMessages_CollectionChanged; + _viewModel.ViewCommandRequested -= ViewModel_ViewCommandRequested; + } + + _appsBindingList?.Dispose(); + _treeViewBinder?.Dispose(); + _gridSelectionBinder?.Dispose(); + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + DataGridViewCellStyle dataGridViewCellStyle1 = new DataGridViewCellStyle(); + DataGridViewCellStyle dataGridViewCellStyle2 = new DataGridViewCellStyle(); + DataGridViewCellStyle dataGridViewCellStyle3 = new DataGridViewCellStyle(); + DataGridViewCellStyle dataGridViewCellStyle4 = new DataGridViewCellStyle(); + _mainMenuStrip = new MenuStrip(); + _fileMenuItem = new ToolStripMenuItem(); + _newMenuItem = new ToolStripMenuItem(); + _newFromExistingMenuItem = new ToolStripMenuItem(); + toolStripSeparator1 = new ToolStripSeparator(); + _removePackageMenuItem = new ToolStripMenuItem(); + toolStripSeparator4 = new ToolStripSeparator(); + _openMenuItem = new ToolStripMenuItem(); + toolStripSeparator2 = new ToolStripSeparator(); + _exportMenuItem = new ToolStripMenuItem(); + _quitMenuItem = new ToolStripMenuItem(); + _editMenuItem = new ToolStripMenuItem(); + _addAppMenuItem = new ToolStripMenuItem(); + _removeAppMenuItem = new ToolStripMenuItem(); + toolStripSeparator3 = new ToolStripSeparator(); + _propertiesMenuItem = new ToolStripMenuItem(); + _viewMenuItem = new ToolStripMenuItem(); + _expandNodesMenuItem = new ToolStripMenuItem(); + _collapseNodeMenuItem = new ToolStripMenuItem(); + _expandSelectedMenuItem = new ToolStripMenuItem(); + _actionMenuItem = new ToolStripMenuItem(); + _updatePackageMenuItem = new ToolStripMenuItem(); + _applyNowMenuItem = new ToolStripMenuItem(); + _generateBundleFolderMenuItem = new ToolStripMenuItem(); + _toolsMenuItem = new ToolStripMenuItem(); + _optionsMenuItem = new ToolStripMenuItem(); + _helpMenuItem = new ToolStripMenuItem(); + _mainToolStrip = new ToolStrip(); + _newToolStripButton = new ToolStripButton(); + _addAppToolStripButton = new ToolStripButton(); + _removeAppToolStripButton = new ToolStripButton(); + _exportToolStripButton = new ToolStripButton(); + _applyNowToolStripButton = new ToolStripButton(); + _mainSplitContainer = new SplitContainer(); + _packageTreeView = new TreeView(); + _rightSplitContainer = new SplitContainer(); + _gridHostPanel = new Panel(); + _appsWarpDataGridView = new WarpDataGridView(); + _consoleControl = new ConsoleControl(); + _statusStrip = new StatusStrip(); + _statusLabel = new ToolStripStatusLabel(); + _mainMenuStrip.SuspendLayout(); + _mainToolStrip.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)_mainSplitContainer).BeginInit(); + _mainSplitContainer.Panel1.SuspendLayout(); + _mainSplitContainer.Panel2.SuspendLayout(); + _mainSplitContainer.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)_rightSplitContainer).BeginInit(); + _rightSplitContainer.Panel1.SuspendLayout(); + _rightSplitContainer.Panel2.SuspendLayout(); + _rightSplitContainer.SuspendLayout(); + _gridHostPanel.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)_appsWarpDataGridView).BeginInit(); + _statusStrip.SuspendLayout(); + SuspendLayout(); + // + // _mainMenuStrip + // + _mainMenuStrip.ImageScalingSize = new Size(24, 24); + _mainMenuStrip.Items.AddRange(new ToolStripItem[] { _fileMenuItem, _editMenuItem, _viewMenuItem, _actionMenuItem, _toolsMenuItem, _helpMenuItem }); + _mainMenuStrip.Location = new Point(0, 0); + _mainMenuStrip.Margin = new Padding(0, 2, 0, 0); + _mainMenuStrip.Name = "_mainMenuStrip"; + _mainMenuStrip.Padding = new Padding(8, 2, 0, 2); + _mainMenuStrip.Size = new Size(1348, 33); + _mainMenuStrip.TabIndex = 0; + // + // _fileMenuItem + // + _fileMenuItem.DropDownItems.AddRange(new ToolStripItem[] { _newMenuItem, _newFromExistingMenuItem, toolStripSeparator1, _removePackageMenuItem, toolStripSeparator4, _openMenuItem, toolStripSeparator2, _exportMenuItem, _quitMenuItem }); + _fileMenuItem.Name = "_fileMenuItem"; + _fileMenuItem.Size = new Size(54, 29); + _fileMenuItem.Text = "&File"; + // + // _newMenuItem + // + _newMenuItem.Name = "_newMenuItem"; + _newMenuItem.Size = new Size(341, 34); + _newMenuItem.Text = "&New empty package..."; + // + // _newFromExistingMenuItem + // + _newFromExistingMenuItem.Name = "_newFromExistingMenuItem"; + _newFromExistingMenuItem.Size = new Size(341, 34); + _newFromExistingMenuItem.Text = "New from existing package..."; + // + // toolStripSeparator1 + // + toolStripSeparator1.Name = "toolStripSeparator1"; + toolStripSeparator1.Size = new Size(338, 6); + // + // _removePackageMenuItem + // + _removePackageMenuItem.Name = "_removePackageMenuItem"; + _removePackageMenuItem.Size = new Size(341, 34); + _removePackageMenuItem.Text = "Remove package"; + // + // toolStripSeparator4 + // + toolStripSeparator4.Name = "toolStripSeparator4"; + toolStripSeparator4.Size = new Size(338, 6); + // + // _openMenuItem + // + _openMenuItem.Name = "_openMenuItem"; + _openMenuItem.Size = new Size(341, 34); + _openMenuItem.Text = "&Import package from file..."; + // + // toolStripSeparator2 + // + toolStripSeparator2.Name = "toolStripSeparator2"; + toolStripSeparator2.Size = new Size(338, 6); + // + // _exportMenuItem + // + _exportMenuItem.Name = "_exportMenuItem"; + _exportMenuItem.Size = new Size(341, 34); + _exportMenuItem.Text = "&Export YAML+Script..."; + // + // _quitMenuItem + // + _quitMenuItem.Name = "_quitMenuItem"; + _quitMenuItem.Size = new Size(341, 34); + _quitMenuItem.Text = "&Quit"; + // + // _editMenuItem + // + _editMenuItem.DropDownItems.AddRange(new ToolStripItem[] { _addAppMenuItem, _removeAppMenuItem, toolStripSeparator3, _propertiesMenuItem }); + _editMenuItem.Name = "_editMenuItem"; + _editMenuItem.Size = new Size(58, 29); + _editMenuItem.Text = "&Edit"; + // + // _addAppMenuItem + // + _addAppMenuItem.Name = "_addAppMenuItem"; + _addAppMenuItem.Size = new Size(217, 34); + _addAppMenuItem.Text = "&Add App..."; + // + // _removeAppMenuItem + // + _removeAppMenuItem.Name = "_removeAppMenuItem"; + _removeAppMenuItem.Size = new Size(217, 34); + _removeAppMenuItem.Text = "&Remove App"; + // + // toolStripSeparator3 + // + toolStripSeparator3.Name = "toolStripSeparator3"; + toolStripSeparator3.Size = new Size(214, 6); + // + // _propertiesMenuItem + // + _propertiesMenuItem.Name = "_propertiesMenuItem"; + _propertiesMenuItem.Size = new Size(217, 34); + _propertiesMenuItem.Text = "&Properties"; + // + // _viewMenuItem + // + _viewMenuItem.DropDownItems.AddRange(new ToolStripItem[] { _expandNodesMenuItem, _collapseNodeMenuItem, _expandSelectedMenuItem }); + _viewMenuItem.Name = "_viewMenuItem"; + _viewMenuItem.Size = new Size(65, 29); + _viewMenuItem.Text = "&View"; + // + // _expandNodesMenuItem + // + _expandNodesMenuItem.Name = "_expandNodesMenuItem"; + _expandNodesMenuItem.Size = new Size(241, 34); + _expandNodesMenuItem.Text = "Expand &nodes"; + // + // _collapseNodeMenuItem + // + _collapseNodeMenuItem.Name = "_collapseNodeMenuItem"; + _collapseNodeMenuItem.Size = new Size(241, 34); + _collapseNodeMenuItem.Text = "&Collapse node"; + // + // _expandSelectedMenuItem + // + _expandSelectedMenuItem.Name = "_expandSelectedMenuItem"; + _expandSelectedMenuItem.Size = new Size(241, 34); + _expandSelectedMenuItem.Text = "Expand &selected"; + // + // _actionMenuItem + // + _actionMenuItem.DropDownItems.AddRange(new ToolStripItem[] { _updatePackageMenuItem, _applyNowMenuItem, _generateBundleFolderMenuItem }); + _actionMenuItem.Name = "_actionMenuItem"; + _actionMenuItem.Size = new Size(79, 29); + _actionMenuItem.Text = "&Action"; + // + // _updatePackageMenuItem + // + _updatePackageMenuItem.Name = "_updatePackageMenuItem"; + _updatePackageMenuItem.Size = new Size(315, 34); + _updatePackageMenuItem.Text = "Update current package..."; + // + // _applyNowMenuItem + // + _applyNowMenuItem.Name = "_applyNowMenuItem"; + _applyNowMenuItem.Size = new Size(315, 34); + _applyNowMenuItem.Text = "&Apply package now..."; + // + // _generateBundleFolderMenuItem + // + _generateBundleFolderMenuItem.Name = "_generateBundleFolderMenuItem"; + _generateBundleFolderMenuItem.Size = new Size(315, 34); + _generateBundleFolderMenuItem.Text = "&Generate Bundle Folder..."; + // + // _toolsMenuItem + // + _toolsMenuItem.DropDownItems.AddRange(new ToolStripItem[] { _optionsMenuItem }); + _toolsMenuItem.Name = "_toolsMenuItem"; + _toolsMenuItem.Size = new Size(69, 29); + _toolsMenuItem.Text = "&Tools"; + // + // _optionsMenuItem + // + _optionsMenuItem.Name = "_optionsMenuItem"; + _optionsMenuItem.Size = new Size(178, 34); + _optionsMenuItem.Text = "&Options"; + // + // _helpMenuItem + // + _helpMenuItem.Name = "_helpMenuItem"; + _helpMenuItem.Size = new Size(65, 29); + _helpMenuItem.Text = "&Help"; + // + // _mainToolStrip + // + _mainToolStrip.ImageScalingSize = new Size(36, 36); + _mainToolStrip.Items.AddRange(new ToolStripItem[] { _newToolStripButton, _addAppToolStripButton, _removeAppToolStripButton, _exportToolStripButton, _applyNowToolStripButton }); + _mainToolStrip.Location = new Point(0, 33); + _mainToolStrip.Margin = new Padding(0, 2, 0, 2); + _mainToolStrip.Name = "_mainToolStrip"; + _mainToolStrip.Size = new Size(1348, 34); + _mainToolStrip.TabIndex = 1; + // + // _newToolStripButton + // + _newToolStripButton.Name = "_newToolStripButton"; + _newToolStripButton.Size = new Size(51, 29); + _newToolStripButton.Text = "New"; + _newToolStripButton.TextImageRelation = TextImageRelation.ImageAboveText; + // + // _addAppToolStripButton + // + _addAppToolStripButton.Name = "_addAppToolStripButton"; + _addAppToolStripButton.Size = new Size(89, 29); + _addAppToolStripButton.Text = "Add App"; + _addAppToolStripButton.TextImageRelation = TextImageRelation.ImageAboveText; + // + // _removeAppToolStripButton + // + _removeAppToolStripButton.Name = "_removeAppToolStripButton"; + _removeAppToolStripButton.Size = new Size(119, 29); + _removeAppToolStripButton.Text = "Remove App"; + _removeAppToolStripButton.TextImageRelation = TextImageRelation.ImageAboveText; + // + // _exportToolStripButton + // + _exportToolStripButton.Name = "_exportToolStripButton"; + _exportToolStripButton.Size = new Size(67, 29); + _exportToolStripButton.Text = "Export"; + _exportToolStripButton.TextImageRelation = TextImageRelation.ImageAboveText; + // + // _applyNowToolStripButton + // + _applyNowToolStripButton.Name = "_applyNowToolStripButton"; + _applyNowToolStripButton.Size = new Size(63, 29); + _applyNowToolStripButton.Text = "Apply"; + _applyNowToolStripButton.TextImageRelation = TextImageRelation.ImageAboveText; + // + // _mainSplitContainer + // + _mainSplitContainer.Dock = DockStyle.Fill; + _mainSplitContainer.Location = new Point(0, 67); + _mainSplitContainer.Margin = new Padding(4); + _mainSplitContainer.Name = "_mainSplitContainer"; + // + // _mainSplitContainer.Panel1 + // + _mainSplitContainer.Panel1.Controls.Add(_packageTreeView); + // + // _mainSplitContainer.Panel2 + // + _mainSplitContainer.Panel2.Controls.Add(_rightSplitContainer); + _mainSplitContainer.Size = new Size(1348, 735); + _mainSplitContainer.SplitterDistance = 375; + _mainSplitContainer.SplitterWidth = 5; + _mainSplitContainer.TabIndex = 2; + // + // _packageTreeView + // + _packageTreeView.Dock = DockStyle.Fill; + _packageTreeView.HideSelection = false; + _packageTreeView.Location = new Point(0, 0); + _packageTreeView.Margin = new Padding(4); + _packageTreeView.Name = "_packageTreeView"; + _packageTreeView.Size = new Size(375, 735); + _packageTreeView.TabIndex = 0; + // + // _rightSplitContainer + // + _rightSplitContainer.Dock = DockStyle.Fill; + _rightSplitContainer.Location = new Point(0, 0); + _rightSplitContainer.Margin = new Padding(4); + _rightSplitContainer.Name = "_rightSplitContainer"; + _rightSplitContainer.Orientation = Orientation.Horizontal; + // + // _rightSplitContainer.Panel1 + // + _rightSplitContainer.Panel1.Controls.Add(_gridHostPanel); + // + // _rightSplitContainer.Panel2 + // + _rightSplitContainer.Panel2.Controls.Add(_consoleControl); + _rightSplitContainer.Size = new Size(968, 735); + _rightSplitContainer.SplitterDistance = 444; + _rightSplitContainer.SplitterWidth = 5; + _rightSplitContainer.TabIndex = 0; + // + // _gridHostPanel + // + _gridHostPanel.Controls.Add(_appsWarpDataGridView); + _gridHostPanel.Dock = DockStyle.Fill; + _gridHostPanel.Location = new Point(0, 0); + _gridHostPanel.Margin = new Padding(4); + _gridHostPanel.Name = "_gridHostPanel"; + _gridHostPanel.Size = new Size(968, 444); + _gridHostPanel.TabIndex = 0; + // + // _appsWarpDataGridView + // + _appsWarpDataGridView.AllowUserToAddRows = false; + _appsWarpDataGridView.AllowUserToDeleteRows = false; + dataGridViewCellStyle1.BackColor = Color.FromArgb(245, 245, 245); + dataGridViewCellStyle1.ForeColor = SystemColors.WindowText; + dataGridViewCellStyle1.SelectionBackColor = SystemColors.Highlight; + dataGridViewCellStyle1.SelectionForeColor = SystemColors.HighlightText; + _appsWarpDataGridView.AlternatingRowsDefaultCellStyle = dataGridViewCellStyle1; + _appsWarpDataGridView.BackgroundColor = SystemColors.Window; + dataGridViewCellStyle2.Alignment = DataGridViewContentAlignment.MiddleLeft; + dataGridViewCellStyle2.BackColor = SystemColors.Control; + dataGridViewCellStyle2.Font = new Font("Segoe UI", 9F); + dataGridViewCellStyle2.ForeColor = SystemColors.ControlText; + dataGridViewCellStyle2.SelectionBackColor = SystemColors.Control; + dataGridViewCellStyle2.SelectionForeColor = SystemColors.ControlText; + dataGridViewCellStyle2.WrapMode = DataGridViewTriState.True; + _appsWarpDataGridView.ColumnHeadersDefaultCellStyle = dataGridViewCellStyle2; + _appsWarpDataGridView.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; + dataGridViewCellStyle3.Alignment = DataGridViewContentAlignment.MiddleLeft; + dataGridViewCellStyle3.BackColor = SystemColors.Window; + dataGridViewCellStyle3.Font = new Font("Segoe UI", 9F); + dataGridViewCellStyle3.ForeColor = SystemColors.WindowText; + dataGridViewCellStyle3.SelectionBackColor = SystemColors.Highlight; + dataGridViewCellStyle3.SelectionForeColor = SystemColors.HighlightText; + dataGridViewCellStyle3.WrapMode = DataGridViewTriState.False; + _appsWarpDataGridView.DefaultCellStyle = dataGridViewCellStyle3; + _appsWarpDataGridView.Dock = DockStyle.Fill; + _appsWarpDataGridView.EnableHeadersVisualStyles = false; + _appsWarpDataGridView.GridColor = SystemColors.ControlDark; + _appsWarpDataGridView.Location = new Point(0, 0); + _appsWarpDataGridView.Margin = new Padding(4); + _appsWarpDataGridView.MultiSelect = false; + _appsWarpDataGridView.Name = "_appsWarpDataGridView"; + dataGridViewCellStyle4.Alignment = DataGridViewContentAlignment.MiddleLeft; + dataGridViewCellStyle4.BackColor = SystemColors.Control; + dataGridViewCellStyle4.Font = new Font("Segoe UI", 9F); + dataGridViewCellStyle4.ForeColor = SystemColors.ControlText; + dataGridViewCellStyle4.SelectionBackColor = SystemColors.Highlight; + dataGridViewCellStyle4.SelectionForeColor = SystemColors.HighlightText; + dataGridViewCellStyle4.WrapMode = DataGridViewTriState.True; + _appsWarpDataGridView.RowHeadersDefaultCellStyle = dataGridViewCellStyle4; + _appsWarpDataGridView.RowHeadersWidth = 62; + _appsWarpDataGridView.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + _appsWarpDataGridView.Size = new Size(968, 444); + _appsWarpDataGridView.TabIndex = 1; + // + // _consoleControl + // + _consoleControl.Dock = DockStyle.Fill; + _consoleControl.Location = new Point(0, 0); + _consoleControl.Margin = new Padding(4); + _consoleControl.Name = "_consoleControl"; + _consoleControl.ReadOnly = true; + _consoleControl.Size = new Size(968, 286); + _consoleControl.TabIndex = 0; + _consoleControl.Text = ""; + // + // _statusStrip + // + _statusStrip.ImageScalingSize = new Size(24, 24); + _statusStrip.Items.AddRange(new ToolStripItem[] { _statusLabel }); + _statusStrip.Location = new Point(0, 802); + _statusStrip.Margin = new Padding(0, 2, 0, 2); + _statusStrip.Name = "_statusStrip"; + _statusStrip.Padding = new Padding(1, 0, 18, 0); + _statusStrip.Size = new Size(1348, 32); + _statusStrip.TabIndex = 3; + // + // _statusLabel + // + _statusLabel.Name = "_statusLabel"; + _statusLabel.Size = new Size(60, 25); + _statusLabel.Text = "Ready"; + // + // MainForm + // + AutoScaleDimensions = new SizeF(10F, 25F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(1348, 834); + Controls.Add(_mainSplitContainer); + Controls.Add(_statusStrip); + Controls.Add(_mainToolStrip); + Controls.Add(_mainMenuStrip); + MainMenuStrip = _mainMenuStrip; + Margin = new Padding(4); + Name = "MainForm"; + Text = "WinGet Package Editor"; + _mainMenuStrip.ResumeLayout(false); + _mainMenuStrip.PerformLayout(); + _mainToolStrip.ResumeLayout(false); + _mainToolStrip.PerformLayout(); + _mainSplitContainer.Panel1.ResumeLayout(false); + _mainSplitContainer.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)_mainSplitContainer).EndInit(); + _mainSplitContainer.ResumeLayout(false); + _rightSplitContainer.Panel1.ResumeLayout(false); + _rightSplitContainer.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)_rightSplitContainer).EndInit(); + _rightSplitContainer.ResumeLayout(false); + _gridHostPanel.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)_appsWarpDataGridView).EndInit(); + _statusStrip.ResumeLayout(false); + _statusStrip.PerformLayout(); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private MenuStrip _mainMenuStrip = null!; + private ToolStripMenuItem _fileMenuItem = null!; + private ToolStripMenuItem _newMenuItem = null!; + private ToolStripMenuItem _openMenuItem = null!; + private ToolStripMenuItem _exportMenuItem = null!; + private ToolStripMenuItem _quitMenuItem = null!; + private ToolStripMenuItem _editMenuItem = null!; + private ToolStripMenuItem _addAppMenuItem = null!; + private ToolStripMenuItem _removeAppMenuItem = null!; + private ToolStripMenuItem _propertiesMenuItem = null!; + private ToolStripMenuItem _viewMenuItem = null!; + private ToolStripMenuItem _expandNodesMenuItem = null!; + private ToolStripMenuItem _collapseNodeMenuItem = null!; + private ToolStripMenuItem _expandSelectedMenuItem = null!; + private ToolStripMenuItem _actionMenuItem = null!; + private ToolStripMenuItem _applyNowMenuItem = null!; + private ToolStripMenuItem _generateBundleFolderMenuItem = null!; + private ToolStripMenuItem _toolsMenuItem = null!; + private ToolStripMenuItem _optionsMenuItem = null!; + private ToolStripMenuItem _helpMenuItem = null!; + private ToolStrip _mainToolStrip = null!; + private ToolStripButton _newToolStripButton = null!; + private ToolStripButton _addAppToolStripButton = null!; + private ToolStripButton _removeAppToolStripButton = null!; + private ToolStripButton _exportToolStripButton = null!; + private ToolStripButton _applyNowToolStripButton = null!; + private SplitContainer _mainSplitContainer = null!; + private TreeView _packageTreeView = null!; + private SplitContainer _rightSplitContainer = null!; + private Panel _gridHostPanel = null!; + private WarpDataGridView _appsWarpDataGridView = null!; + private ConsoleControl _consoleControl = null!; + private StatusStrip _statusStrip = null!; + private ToolStripStatusLabel _statusLabel = null!; + private ToolStripMenuItem _newFromExistingMenuItem = null!; + private ToolStripMenuItem _updatePackageMenuItem = null!; + private ToolStripSeparator toolStripSeparator1; + private ToolStripMenuItem _removePackageMenuItem = null!; + private ToolStripSeparator toolStripSeparator4; + private ToolStripSeparator toolStripSeparator2; + private ToolStripSeparator toolStripSeparator3; +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/MainForm.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/MainForm.cs new file mode 100644 index 0000000..6a2b0d6 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/MainForm.cs @@ -0,0 +1,707 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Drawing; +using System.IO; +using System.Windows.Input; +using WarpToolkit.ComponentModel; +using WarpToolkit.WinForms.Extensions.UI; +using WarpToolkit.WinForms.Specialized; +using WarpToolkit.WinForms.Symbols; +using WingetPackageEditor.Core.Services; +using WingetPackageEditor.Core.ViewModels; +using CoreConsoleMessageKind = WingetPackageEditor.Core.Services.ConsoleMessageKind; + +namespace Winget_Package_Editor; + +public partial class MainForm : Form, IServiceProvider +{ + private static readonly string SettingsKey_MainFormBounds + = nameof(SettingsKey_MainFormBounds); + + private const string SettingsKey_MainFormWindowState = "MainForm.WindowState"; + private const string SettingsKey_MainSplitter = "MainForm.MainSplitter"; + private const string SettingsKey_RightSplitter = "MainForm.RightSplitter"; + private const string SettingsKey_AppGridColumns = "MainForm.AppGrid.Columns"; + private const string SettingsKey_VisualStudioInstanceGridColumns = "MainForm.VisualStudioInstanceGrid.Columns"; + private const string VisualStudioDataPathColumnName = "_visualStudioDataPathColumn"; + private const string SettingsKey_TreeExpansion = "MainForm.TreeExpansion"; + private const string SettingsKey_FontFamily = "MainForm.FontFamily"; + private const string SettingsKey_MenuStripFontSize = "MainForm.MenuStripFontSize"; + private const string SettingsKey_StandardFontSize = "MainForm.StandardFontSize"; + private const string SettingsKey_TreeMainNodeDelta = "MainForm.TreeMainNodeDelta"; + private const string SettingsKey_TreeMainNodeBold = "MainForm.TreeMainNodeBold"; + private const string SettingsKey_StatusStripFontSize = "MainForm.StatusStripFontSize"; + + private UiFontSettings _fontSettings = new(); + private string? _currentGridColumnSettingsKey; + private bool _layoutStateRestored; + + private readonly MainViewModel? _viewModel; + private ObservableBindingList? _appsBindingList; + private TreeViewBinder? _treeViewBinder; + private GridSelectionBinder? _gridSelectionBinder; + + private readonly IUserSettingsService? _userSettingsService; + private readonly IServiceProvider? _serviceProvider; + + public MainForm() + { + InitializeComponent(); + } + + /// + /// Initializes a new instance of the class with dependency injection support. + /// + /// + /// The service provider that contains all registered services for dependency injection. + /// This parameter is used to resolve dependencies and configure the form with the required services. + /// + /// + /// Thrown when is . + /// + /// + /// Thrown when the required is not registered in the service provider. + /// + /// + /// This constructor overload is specifically designed to be used when the Form is instantiated + /// through Dependency Injection (DI) using the WinFormsApplication class and the + /// WinFormsApplicationBuilder. This approach provides the same infrastructure pattern + /// as ASP.NET Core applications, enabling familiar service registration, configuration, + /// and dependency injection patterns in WinForms applications. + /// + /// When using this constructor, the Form acts as a ServiceProvider-aware component, + /// allowing it to resolve and utilize services that have been registered in the + /// application's service container. This enables loose coupling, testability, + /// and modern application architecture patterns in WinForms development. + /// + /// + /// The constructor automatically assigns the service provider to the form using the + /// AssignServiceProvider extension method and resolves the required + /// from the container. + /// + /// + public MainForm(IServiceProvider serviceProvider) : this() + { + ArgumentNullException.ThrowIfNull(serviceProvider, nameof(serviceProvider)); + _serviceProvider = new DeferredServiceProvider(serviceProvider); + + _userSettingsService = serviceProvider.GetRequiredService(); + _viewModel = serviceProvider.GetRequiredService(); + + if (_userSettingsService is null) + { + throw new NullReferenceException($"The service '{nameof(IUserSettingsService)}' is not registered."); + } + } + + protected override void OnLoad(EventArgs e) + { + base.OnLoad(e); + + if (_viewModel is not null) + { + InitializeViewModel(_viewModel); + } + + // Bounds must be restored AFTER fonts have been applied (in InitializeViewModel). + // With AutoScaleMode.Font, applying a font rescales the form and would otherwise + // clobber any previously restored size/position. + RestoreWindowBounds(); + } + + object IServiceProvider.GetService(Type serviceType) + { + ArgumentNullException.ThrowIfNull(serviceType, nameof(serviceType)); + + if (_serviceProvider is null) + { + throw new InvalidOperationException("Service provider is not initialized."); + } + + return _serviceProvider.GetService(serviceType) + ?? throw new InvalidOperationException($"Service of type '{serviceType.Name}' is not registered."); + } + + protected override void OnShown(EventArgs e) + { + base.OnShown(e); + + // Splitter distances depend on the final laid-out size of the (possibly nested) + // SplitContainers, which is only settled once the form has been shown. Restoring + // earlier lets the clamp logic silently reject otherwise-valid saved distances. + RestoreLayoutState(); + } + + private void RestoreWindowBounds() + { + if (_userSettingsService is null) + { + return; + } + + if (!_userSettingsService.TryApplyFormBounds(this, SettingsKey_MainFormBounds)) + { + Bounds = this.CenterToScreen( + horizontalFillGrade: 70, + verticalFillGrade: 70); + } + + if (_userSettingsService.TryGet(SettingsKey_MainFormWindowState, out FormWindowState windowState) + && windowState == FormWindowState.Maximized) + { + WindowState = FormWindowState.Maximized; + } + } + + private void RestoreLayoutState() + { + if (_layoutStateRestored || _userSettingsService is null) + { + return; + } + + _layoutStateRestored = true; + _userSettingsService.TryApplySplitterDistance(_mainSplitContainer, SettingsKey_MainSplitter); + _userSettingsService.TryApplySplitterDistance(_rightSplitContainer, SettingsKey_RightSplitter); + _treeViewBinder?.RestoreExpandedNodeKeys(_userSettingsService.Get(SettingsKey_TreeExpansion, [])); + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + base.OnFormClosing(e); + + if (_userSettingsService is null) + { + return; + } + + _userSettingsService.SaveFormBounds(this, SettingsKey_MainFormBounds); + _userSettingsService.Set(SettingsKey_MainFormWindowState, WindowState); + _userSettingsService.SaveSplitterDistance(_mainSplitContainer, SettingsKey_MainSplitter); + _userSettingsService.SaveSplitterDistance(_rightSplitContainer, SettingsKey_RightSplitter); + SaveCurrentGridColumnWidths(); + _userSettingsService.Set(SettingsKey_TreeExpansion, _treeViewBinder?.GetExpandedNodeKeys() ?? []); + _userSettingsService.Flush(); + } + + private void InitializeViewModel(MainViewModel viewModel) + { + DataContext = viewModel; + SetupCommands(viewModel); + SetupGrid(viewModel); + SetupTree(viewModel); + SetupConsole(viewModel); + viewModel.ViewCommandRequested += ViewModel_ViewCommandRequested; + viewModel.PropertyChanged += ViewModel_PropertyChanged; + _fontSettings = LoadFontSettings(); + ApplyFontSettings(_fontSettings); + _statusLabel.Text = viewModel.StatusText; + } + + private void SetupCommands(MainViewModel viewModel) + { + ConfigureCommand(_newMenuItem, viewModel.NewPackageCommand); + ConfigureCommand(_newFromExistingMenuItem, viewModel.NewFromExistingPackageCommand); + ConfigureCommand(_removePackageMenuItem, viewModel.RemovePackageCommand); + ConfigureCommand(_openMenuItem, viewModel.OpenPackageCommand); + ConfigureCommand(_exportMenuItem, viewModel.ExportCommand); + ConfigureCommand(_quitMenuItem, viewModel.QuitCommand); + ConfigureCommand(_addAppMenuItem, viewModel.AddAppCommand); + ConfigureCommand(_removeAppMenuItem, viewModel.RemoveAppCommand); + ConfigureCommand(_propertiesMenuItem, viewModel.PropertiesCommand); + ConfigureCommand(_expandNodesMenuItem, viewModel.ExpandAllNodesCommand); + ConfigureCommand(_collapseNodeMenuItem, viewModel.CollapseSelectedNodeCommand); + ConfigureCommand(_expandSelectedMenuItem, viewModel.ExpandSelectedNodeCommand); + ConfigureCommand(_updatePackageMenuItem, viewModel.UpdateCurrentPackageCommand); + ConfigureCommand(_applyNowMenuItem, viewModel.ApplyNowCommand); + ConfigureCommand(_generateBundleFolderMenuItem, viewModel.GenerateBundleFolderCommand); + ConfigureCommand(_optionsMenuItem, viewModel.OptionsCommand); + + ConfigureCommand(_newToolStripButton, viewModel.NewPackageCommand); + ConfigureCommand(_addAppToolStripButton, viewModel.AddAppCommand); + ConfigureCommand(_removeAppToolStripButton, viewModel.RemoveAppCommand); + ConfigureCommand(_exportToolStripButton, viewModel.ExportCommand); + ConfigureCommand(_applyNowToolStripButton, viewModel.ApplyNowCommand); + + _newToolStripButton.ConfigureItem(FluentSymbols.CommonToolStripSymbols.New, tooltipText: "New package", size: 36); + _addAppToolStripButton.ConfigureItem(FluentSymbols.CommonToolStripSymbols.AddBold, tooltipText: "Add app", size: 36); + _removeAppToolStripButton.ConfigureItem(FluentSymbols.CommonToolStripSymbols.Delete, tooltipText: "Remove app", size: 36); + _exportToolStripButton.ConfigureItem(FluentSymbols.AllSymbols.Export, tooltipText: "Export YAML+Script", size: 36); + _applyNowToolStripButton.ConfigureItem(FluentSymbols.AllSymbols.Play, tooltipText: "Apply now", size: 36); + } + + private static void ConfigureCommand(ToolStripItem item, ICommand command) + { + item.Command = command; + } + + private void SetupGrid(MainViewModel viewModel) + { + _appsBindingList = new ObservableBindingList(viewModel.CurrentApps); + _gridSelectionBinder = new GridSelectionBinder(_appsWarpDataGridView, viewModel); + _appsWarpDataGridView.CellFormatting += AppsGrid_CellFormatting; + _appsWarpDataGridView.CellContentClick += AppsGrid_CellContentClick; + _appsWarpDataGridView.CellMouseDown += AppsGrid_CellMouseDown; + ConfigureGridForSelectedNode(viewModel); + } + + private static DataGridViewTextBoxColumn CreateTextColumn(string propertyName, string headerText, int width, bool readOnly = false) + { + return new DataGridViewTextBoxColumn + { + DataPropertyName = propertyName, + HeaderText = headerText, + Name = $"_{propertyName}Column", + ReadOnly = readOnly, + Width = width + }; + } + + private void SetupTree(MainViewModel viewModel) + { + _treeViewBinder = new TreeViewBinder(_packageTreeView, viewModel.NavigationRoots); + _treeViewBinder.SelectedNodeChanged += (_, selectedNode) => viewModel.SelectedNavigationNode = selectedNode; + _treeViewBinder.SelectNode(viewModel.SelectedNavigationNode); + } + + private void SetupConsole(MainViewModel viewModel) + { + viewModel.ConsoleMessages.CollectionChanged += ConsoleMessages_CollectionChanged; + + foreach (ConsoleMessage message in viewModel.ConsoleMessages) + { + _ = AppendConsoleMessageAsync(message); + } + } + + private void ConsoleMessages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems is null) + { + return; + } + + foreach (ConsoleMessage message in e.NewItems) + { + _ = AppendConsoleMessageAsync(message); + } + } + + private async Task AppendConsoleMessageAsync(ConsoleMessage message) + { + Color color = message.Kind switch + { + CoreConsoleMessageKind.Error => Color.IndianRed, + CoreConsoleMessageKind.Warning => Color.Goldenrod, + CoreConsoleMessageKind.Command => Color.LightSkyBlue, + CoreConsoleMessageKind.Debug => Color.Gray, + _ => Color.Empty + }; + + string line = $"[{message.Timestamp:HH:mm:ss}] [{message.Kind}] {message.Text}"; + if (IsHandleCreated && InvokeRequired) + { + BeginInvoke(new Action(() => _ = AppendConsoleMessageAsync(message))); + return; + } + + await _consoleControl.WriteLineAsync(line, color == Color.Empty ? null : color); + } + + private void ViewModel_ViewCommandRequested(object? sender, ViewCommandKind e) + { + switch (e) + { + case ViewCommandKind.ExpandAllNodes: + _treeViewBinder?.ExpandAll(); + break; + case ViewCommandKind.CollapseSelectedNode: + _treeViewBinder?.CollapseSelected(); + break; + case ViewCommandKind.ExpandSelectedNode: + _treeViewBinder?.ExpandSelected(); + break; + case ViewCommandKind.ShowOptions: + ShowOptionsDialog(); + break; + } + } + + private void ShowOptionsDialog() + { + using OptionsDialog dialog = new(_fontSettings); + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + _fontSettings = dialog.Settings; + SaveFontSettings(_fontSettings); + ApplyFontSettings(_fontSettings); + } + + private UiFontSettings LoadFontSettings() + { + if (_userSettingsService is null) + { + return new UiFontSettings(); + } + + return new UiFontSettings + { + FontFamily = _userSettingsService.Get(SettingsKey_FontFamily, "Segoe UI"), + MenuStripSize = _userSettingsService.Get(SettingsKey_MenuStripFontSize, 11F), + StandardSize = _userSettingsService.Get(SettingsKey_StandardFontSize, 10F), + TreeMainNodeDelta = _userSettingsService.Get(SettingsKey_TreeMainNodeDelta, 1F), + TreeMainNodeBold = _userSettingsService.Get(SettingsKey_TreeMainNodeBold, true), + StatusStripSize = _userSettingsService.Get(SettingsKey_StatusStripFontSize, 10F) + }; + } + + private void SaveFontSettings(UiFontSettings settings) + { + if (_userSettingsService is null) + { + return; + } + + _userSettingsService.Set(SettingsKey_FontFamily, settings.FontFamily); + _userSettingsService.Set(SettingsKey_MenuStripFontSize, settings.MenuStripSize); + _userSettingsService.Set(SettingsKey_StandardFontSize, settings.StandardSize); + _userSettingsService.Set(SettingsKey_TreeMainNodeDelta, settings.TreeMainNodeDelta); + _userSettingsService.Set(SettingsKey_TreeMainNodeBold, settings.TreeMainNodeBold); + _userSettingsService.Set(SettingsKey_StatusStripFontSize, settings.StatusStripSize); + _userSettingsService.Flush(); + } + + private void ApplyFontSettings(UiFontSettings settings) + { + Font standardFont = new(settings.FontFamily, settings.StandardSize, FontStyle.Regular, GraphicsUnit.Point); + Font menuFont = new(settings.FontFamily, settings.MenuStripSize, FontStyle.Regular, GraphicsUnit.Point); + Font statusFont = new(settings.FontFamily, settings.StatusStripSize, FontStyle.Regular, GraphicsUnit.Point); + Font treeRootFont = new( + settings.FontFamily, + settings.StandardSize + settings.TreeMainNodeDelta, + settings.TreeMainNodeBold ? FontStyle.Bold : FontStyle.Regular, + GraphicsUnit.Point); + + Font = standardFont; + _mainMenuStrip.Font = menuFont; + _mainToolStrip.Font = standardFont; + _packageTreeView.Font = standardFont; + _appsWarpDataGridView.Font = standardFont; + _consoleControl.Font = standardFont; + _statusStrip.Font = statusFont; + _treeViewBinder?.SetRootNodeFont(treeRootFont); + } + + private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MainViewModel.StatusText) && sender is MainViewModel viewModel) + { + _statusLabel.Text = viewModel.StatusText; + } + else if (e.PropertyName == nameof(MainViewModel.SelectedNavigationNode) && sender is MainViewModel vm) + { + ConfigureGridForSelectedNode(vm); + _treeViewBinder?.SelectNode(vm.SelectedNavigationNode); + } + } + + private void ConfigureGridForSelectedNode(MainViewModel viewModel) + { + IReadOnlyList? rows = viewModel.SelectedNavigationNode?.Value switch + { + VisualStudioBranchViewModel branch => branch.Rows, + VisualStudioVersionViewModel version => version.Rows, + VisualStudioSkuComboViewModel combo => combo.Rows, + VisualStudioInstanceViewModel instance => instance.Rows, + _ => null + }; + + if (rows is not null) + { + ConfigureVisualStudioInstanceGrid(rows); + return; + } + + ConfigureAppGrid(); + } + + private void ConfigureAppGrid() + { + ConfigureGrid( + SettingsKey_AppGridColumns, + _appsBindingList, + () => + { + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(AppEntryViewModel.DisplayName), "Display Name", width: 220)); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(AppEntryViewModel.Id), "WinGet Id", width: 260)); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(AppEntryViewModel.EntryType), "Type", width: 120, readOnly: true)); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(AppEntryViewModel.Source), "Source", width: 100)); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(AppEntryViewModel.Scope), "Scope", width: 100)); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(AppEntryViewModel.Version), "Version", width: 120)); + _appsWarpDataGridView.Columns.Add(new DataGridViewCheckBoxColumn + { + DataPropertyName = nameof(AppEntryViewModel.AllowPrerelease), + HeaderText = "Prerelease", + Name = "_allowPrereleaseColumn", + Width = 90 + }); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(AppEntryViewModel.ExtensionsSummary), "Extensions", width: 170, readOnly: true)); + }); + } + + private void ConfigureVisualStudioInstanceGrid(IReadOnlyList rows) + { + ConfigureGrid( + SettingsKey_VisualStudioInstanceGridColumns, + rows, + () => + { + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(VisualStudioInstallationRowViewModel.SkuName), "SKU Name", width: 240, readOnly: true)); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(VisualStudioInstallationRowViewModel.Version), "Version", width: 130, readOnly: true)); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(VisualStudioInstallationRowViewModel.InstallDateDisplay), "Install Date", width: 150, readOnly: true)); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(VisualStudioInstallationRowViewModel.InstanceId), "Instance ID", width: 110, readOnly: true)); + _appsWarpDataGridView.Columns.Add(CreateTextColumn(nameof(VisualStudioInstallationRowViewModel.InstallationPathDisplay), "Install Path", width: 200, readOnly: true)); + _appsWarpDataGridView.Columns.Add(new DataGridViewButtonColumn + { + DataPropertyName = nameof(VisualStudioInstallationRowViewModel.DataPathDisplay), + HeaderText = "Path to Data", + Name = VisualStudioDataPathColumnName, + Width = 200, + UseColumnTextForButtonValue = false + }); + }); + } + + private VisualStudioInstallationRowViewModel? GetVisualStudioRow(int rowIndex) + { + if (rowIndex < 0 || rowIndex >= _appsWarpDataGridView.Rows.Count) + { + return null; + } + + return _appsWarpDataGridView.Rows[rowIndex].DataBoundItem as VisualStudioInstallationRowViewModel; + } + + private void AppsGrid_CellFormatting(object? sender, DataGridViewCellFormattingEventArgs e) + { + if (GetVisualStudioRow(e.RowIndex) is not { IsExperimental: true }) + { + return; + } + + // Experimental-hive rows are rendered in a muted gray: light-light-gray over the + // dark theme background, dark-dark-gray over the classic (light) background. + e.CellStyle.ForeColor = Application.IsDarkModeEnabled + ? Color.FromArgb(190, 190, 190) + : Color.FromArgb(90, 90, 90); + } + + private void AppsGrid_CellContentClick(object? sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex < 0 || e.ColumnIndex < 0) + { + return; + } + + if (!string.Equals(_appsWarpDataGridView.Columns[e.ColumnIndex].Name, VisualStudioDataPathColumnName, StringComparison.Ordinal)) + { + return; + } + + if (GetVisualStudioRow(e.RowIndex) is { } row) + { + OpenInExplorer(row.DataPath); + } + } + + private void AppsGrid_CellMouseDown(object? sender, DataGridViewCellMouseEventArgs e) + { + if (e.Button != MouseButtons.Right || e.RowIndex < 0) + { + return; + } + + if (GetVisualStudioRow(e.RowIndex) is not { } row) + { + return; + } + + _appsWarpDataGridView.ClearSelection(); + _appsWarpDataGridView.Rows[e.RowIndex].Selected = true; + _appsWarpDataGridView.CurrentCell = _appsWarpDataGridView.Rows[e.RowIndex].Cells[Math.Max(0, e.ColumnIndex)]; + ShowVisualStudioContextMenu(row); + } + + private void ShowVisualStudioContextMenu(VisualStudioInstallationRowViewModel row) + { + ContextMenuStrip menu = new(); + menu.Closed += (_, _) => menu.Dispose(); + + bool hasDataPath = !string.IsNullOrEmpty(row.DataPath); + + ToolStripMenuItem openInstall = (ToolStripMenuItem)menu.Items.Add("Open Explorer Install Path"); + openInstall.ConfigureItem(FluentSymbols.AllSymbols.FolderOpen, + (clickHandler: (_, _) => OpenInExplorer(row.InstallationPath), removeBeforeAdd: false)); + + ToolStripMenuItem openData = (ToolStripMenuItem)menu.Items.Add("Open Explorer Data Path"); + openData.Enabled = hasDataPath; + openData.ConfigureItem(FluentSymbols.AllSymbols.Folder, + (clickHandler: (_, _) => OpenInExplorer(row.DataPath), removeBeforeAdd: false)); + + menu.Items.Add(new ToolStripSeparator()); + + ToolStripMenuItem copyVersion = (ToolStripMenuItem)menu.Items.Add("Copy Version Info to Clipboard"); + copyVersion.ConfigureItem(FluentSymbols.AllSymbols.ClipboardList, + (clickHandler: (_, _) => SetClipboardText($"{row.SkuName} {row.Version} ({row.InstanceId})"), removeBeforeAdd: false)); + + ToolStripMenuItem copyInstall = (ToolStripMenuItem)menu.Items.Add("Copy Install Path to Clipboard"); + copyInstall.ConfigureItem(FluentSymbols.AllSymbols.Copy, + (clickHandler: (_, _) => SetClipboardText(row.InstallationPath), removeBeforeAdd: false)); + + ToolStripMenuItem copyData = (ToolStripMenuItem)menu.Items.Add("Copy Data Path to Clipboard"); + copyData.Enabled = hasDataPath; + copyData.ConfigureItem(FluentSymbols.AllSymbols.Copy, + (clickHandler: (_, _) => SetClipboardText(row.DataPath), removeBeforeAdd: false)); + + menu.Items.Add(new ToolStripSeparator()); + + ToolStripMenuItem enableUnsigned = (ToolStripMenuItem)menu.Items.Add("Enable running unsigned .NET Runtimes"); + enableUnsigned.Click += (_, _) => EnableUnsignedDotnetRuntimes(row); + + menu.Show(Cursor.Position); + } + + private void OpenInExplorer(string path) + { + if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path)) + { + _viewModel?.WriteConsole(CoreConsoleMessageKind.Error, $"Path not found: {path}"); + return; + } + + try + { + using System.Diagnostics.Process? _ = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = path, + UseShellExecute = true + }); + } + catch (Exception ex) + { + _viewModel?.WriteConsole(CoreConsoleMessageKind.Error, $"Could not open '{path}': {ex.Message}"); + } + } + + private void SetClipboardText(string text) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + try + { + Clipboard.SetText(text); + } + catch (Exception ex) + { + _viewModel?.WriteConsole(CoreConsoleMessageKind.Error, $"Clipboard error: {ex.Message}"); + } + } + + private void EnableUnsignedDotnetRuntimes(VisualStudioInstallationRowViewModel row) + { + string vsRegEdit = Path.Combine(row.InstallationPath, "Common7", "IDE", "VsRegEdit.exe"); + if (!File.Exists(vsRegEdit)) + { + _viewModel?.WriteConsole(CoreConsoleMessageKind.Error, $"VsRegEdit.exe not found at: {vsRegEdit}"); + return; + } + + System.Diagnostics.ProcessStartInfo startInfo = new() + { + FileName = vsRegEdit, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + foreach (string argument in new[] + { + "set", "local", row.HiveName, "HKCU", + @"Debugger\EngineSwitches", "ValidateDotnetDebugLibSignatures", "dword", "0" + }) + { + startInfo.ArgumentList.Add(argument); + } + + _viewModel?.WriteConsole(CoreConsoleMessageKind.Info, + $"Enabling unsigned .NET runtimes for hive {row.HiveName}..."); + + try + { + using System.Diagnostics.Process process = new() { StartInfo = startInfo }; + process.OutputDataReceived += (_, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + _viewModel?.WriteConsole(CoreConsoleMessageKind.Info, args.Data); + } + }; + process.ErrorDataReceived += (_, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + _viewModel?.WriteConsole(CoreConsoleMessageKind.Error, args.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + + _viewModel?.WriteConsole( + process.ExitCode == 0 ? CoreConsoleMessageKind.Info : CoreConsoleMessageKind.Error, + $"VsRegEdit.exe exited with code {process.ExitCode}."); + } + catch (Exception ex) + { + _viewModel?.WriteConsole(CoreConsoleMessageKind.Error, $"VsRegEdit.exe failed: {ex.Message}"); + } + } + + private void ConfigureGrid(string settingsKey, object? dataSource, Action configureColumns) + { + if (string.Equals(_currentGridColumnSettingsKey, settingsKey, StringComparison.Ordinal) + && ReferenceEquals(_appsWarpDataGridView.DataSource, dataSource)) + { + return; + } + + SaveCurrentGridColumnWidths(); + _appsWarpDataGridView.DataSource = null; + _appsWarpDataGridView.AutoGenerateColumns = false; + _appsWarpDataGridView.Columns.Clear(); + configureColumns(); + _appsWarpDataGridView.DataSource = dataSource; + _currentGridColumnSettingsKey = settingsKey; + _userSettingsService?.TryApplyDataGridViewColumnWidths(_appsWarpDataGridView, settingsKey); + } + + private void SaveCurrentGridColumnWidths() + { + if (_userSettingsService is null || string.IsNullOrWhiteSpace(_currentGridColumnSettingsKey)) + { + return; + } + + _userSettingsService.SaveDataGridViewColumnWidths(_appsWarpDataGridView, _currentGridColumnSettingsKey); + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/MainForm.resx b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/MainForm.resx new file mode 100644 index 0000000..052cfc2 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/MainForm.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + 226, 17 + + + 422, 17 + + + 78 + + \ No newline at end of file diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/NewFromExistingDialog.Designer.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/NewFromExistingDialog.Designer.cs new file mode 100644 index 0000000..11ca6e4 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/NewFromExistingDialog.Designer.cs @@ -0,0 +1,139 @@ +namespace Winget_Package_Editor; + +partial class NewFromExistingDialog +{ + private System.ComponentModel.IContainer components = null!; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + _layoutPanel = new TableLayoutPanel(); + _nameLabel = new Label(); + _nameTextBox = new TextBox(); + _sourceLabel = new Label(); + _sourceComboBox = new ComboBox(); + _buttonPanel = new FlowLayoutPanel(); + _okButton = new Button(); + _cancelButton = new Button(); + _layoutPanel.SuspendLayout(); + _buttonPanel.SuspendLayout(); + SuspendLayout(); + // + // _layoutPanel + // + _layoutPanel.ColumnCount = 2; + _layoutPanel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + _layoutPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + _layoutPanel.Controls.Add(_nameLabel, 0, 0); + _layoutPanel.Controls.Add(_nameTextBox, 1, 0); + _layoutPanel.Controls.Add(_sourceLabel, 0, 1); + _layoutPanel.Controls.Add(_sourceComboBox, 1, 1); + _layoutPanel.Controls.Add(_buttonPanel, 1, 2); + _layoutPanel.Dock = DockStyle.Fill; + _layoutPanel.Location = new Point(0, 0); + _layoutPanel.Name = "_layoutPanel"; + _layoutPanel.Padding = new Padding(12); + _layoutPanel.RowCount = 3; + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + _layoutPanel.Size = new Size(458, 160); + _layoutPanel.TabIndex = 0; + // + // _nameLabel + // + _nameLabel.Anchor = AnchorStyles.Left; + _nameLabel.AutoSize = true; + _nameLabel.Name = "_nameLabel"; + _nameLabel.Text = "New package name:"; + // + // _nameTextBox + // + _nameTextBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _nameTextBox.Name = "_nameTextBox"; + _nameTextBox.TabIndex = 0; + // + // _sourceLabel + // + _sourceLabel.Anchor = AnchorStyles.Left; + _sourceLabel.AutoSize = true; + _sourceLabel.Name = "_sourceLabel"; + _sourceLabel.Text = "Copy definition from:"; + // + // _sourceComboBox + // + _sourceComboBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _sourceComboBox.DropDownStyle = ComboBoxStyle.DropDownList; + _sourceComboBox.Name = "_sourceComboBox"; + _sourceComboBox.TabIndex = 1; + // + // _buttonPanel + // + _buttonPanel.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; + _buttonPanel.AutoSize = true; + _buttonPanel.Controls.Add(_okButton); + _buttonPanel.Controls.Add(_cancelButton); + _buttonPanel.FlowDirection = FlowDirection.LeftToRight; + _buttonPanel.Name = "_buttonPanel"; + _buttonPanel.TabIndex = 2; + // + // _okButton + // + _okButton.DialogResult = DialogResult.OK; + _okButton.Name = "_okButton"; + _okButton.Size = new Size(90, 27); + _okButton.TabIndex = 0; + _okButton.Text = "Create"; + _okButton.UseVisualStyleBackColor = true; + _okButton.Click += OkButton_Click; + // + // _cancelButton + // + _cancelButton.DialogResult = DialogResult.Cancel; + _cancelButton.Name = "_cancelButton"; + _cancelButton.Size = new Size(90, 27); + _cancelButton.TabIndex = 1; + _cancelButton.Text = "Cancel"; + _cancelButton.UseVisualStyleBackColor = true; + // + // NewFromExistingDialog + // + AcceptButton = _okButton; + AutoScaleDimensions = new SizeF(8F, 20F); + AutoScaleMode = AutoScaleMode.Font; + CancelButton = _cancelButton; + ClientSize = new Size(458, 160); + Controls.Add(_layoutPanel); + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Name = "NewFromExistingDialog"; + StartPosition = FormStartPosition.CenterParent; + Text = "New from existing package"; + _layoutPanel.ResumeLayout(false); + _layoutPanel.PerformLayout(); + _buttonPanel.ResumeLayout(false); + ResumeLayout(false); + } + + #endregion + + private TableLayoutPanel _layoutPanel = null!; + private Label _nameLabel = null!; + private TextBox _nameTextBox = null!; + private Label _sourceLabel = null!; + private ComboBox _sourceComboBox = null!; + private FlowLayoutPanel _buttonPanel = null!; + private Button _okButton = null!; + private Button _cancelButton = null!; +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/NewFromExistingDialog.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/NewFromExistingDialog.cs new file mode 100644 index 0000000..a945d3d --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/NewFromExistingDialog.cs @@ -0,0 +1,41 @@ +using WingetPackageEditor.Core.Models; + +namespace Winget_Package_Editor; + +internal partial class NewFromExistingDialog : Form +{ + public NewFromExistingDialog(IReadOnlyList existingPackages) + { + ArgumentNullException.ThrowIfNull(existingPackages); + InitializeComponent(); + + _sourceComboBox.DisplayMember = nameof(WingetPackage.Name); + _sourceComboBox.DataSource = existingPackages.ToList(); + if (existingPackages.Count > 0) + { + _sourceComboBox.SelectedIndex = 0; + } + } + + public string NewName => _nameTextBox.Text.Trim(); + + public WingetPackage? SourcePackage => _sourceComboBox.SelectedItem as WingetPackage; + + private void OkButton_Click(object? sender, EventArgs e) + { + if (string.IsNullOrWhiteSpace(NewName)) + { + MessageBox.Show(this, "Please enter a name for the new package.", "New from existing package", + MessageBoxButtons.OK, MessageBoxIcon.Information); + DialogResult = DialogResult.None; + return; + } + + if (SourcePackage is null) + { + MessageBox.Show(this, "Please select a source package to copy.", "New from existing package", + MessageBoxButtons.OK, MessageBoxIcon.Information); + DialogResult = DialogResult.None; + } + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/ObservableBindingList.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/ObservableBindingList.cs new file mode 100644 index 0000000..768f2e4 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/ObservableBindingList.cs @@ -0,0 +1,83 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; + +namespace Winget_Package_Editor; + +internal sealed class ObservableBindingList : BindingList, IDisposable +{ + private ObservableCollection? _source; + + public ObservableBindingList(ObservableCollection source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + foreach (T item in source) + { + Add(item); + } + + source.CollectionChanged += OnCollectionChanged; + } + + public void Dispose() + { + if (_source is not null) + { + _source.CollectionChanged -= OnCollectionChanged; + _source = null; + } + } + + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + if (e.NewItems is not null) + { + foreach (T item in e.NewItems) + { + Add(item); + } + } + break; + case NotifyCollectionChangedAction.Remove: + if (e.OldItems is not null) + { + foreach (T item in e.OldItems) + { + Remove(item); + } + } + break; + case NotifyCollectionChangedAction.Replace: + case NotifyCollectionChangedAction.Move: + case NotifyCollectionChangedAction.Reset: + Reload(); + break; + } + } + + private void Reload() + { + RaiseListChangedEvents = false; + try + { + Clear(); + if (_source is null) + { + return; + } + + foreach (T item in _source) + { + Add(item); + } + } + finally + { + RaiseListChangedEvents = true; + ResetBindings(); + } + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/OptionsDialog.Designer.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/OptionsDialog.Designer.cs new file mode 100644 index 0000000..1a5fae1 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/OptionsDialog.Designer.cs @@ -0,0 +1,267 @@ +namespace Winget_Package_Editor; + +partial class OptionsDialog +{ + private System.ComponentModel.IContainer components = null!; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + _layoutPanel = new TableLayoutPanel(); + _fontFamilyLabel = new Label(); + _fontFamilyTextBox = new TextBox(); + _menuFontSizeLabel = new Label(); + _menuFontSizeUpDown = new NumericUpDown(); + _standardFontSizeLabel = new Label(); + _standardFontSizeUpDown = new NumericUpDown(); + _treeDeltaLabel = new Label(); + _treeDeltaUpDown = new NumericUpDown(); + _treeBoldCheckBox = new CheckBox(); + _statusFontSizeLabel = new Label(); + _statusFontSizeUpDown = new NumericUpDown(); + _buttonPanel = new FlowLayoutPanel(); + _okButton = new Button(); + _cancelButton = new Button(); + ((System.ComponentModel.ISupportInitialize)_menuFontSizeUpDown).BeginInit(); + ((System.ComponentModel.ISupportInitialize)_standardFontSizeUpDown).BeginInit(); + ((System.ComponentModel.ISupportInitialize)_treeDeltaUpDown).BeginInit(); + ((System.ComponentModel.ISupportInitialize)_statusFontSizeUpDown).BeginInit(); + _layoutPanel.SuspendLayout(); + _buttonPanel.SuspendLayout(); + SuspendLayout(); + // + // _layoutPanel + // + _layoutPanel.ColumnCount = 2; + _layoutPanel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + _layoutPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + _layoutPanel.Controls.Add(_fontFamilyLabel, 0, 0); + _layoutPanel.Controls.Add(_fontFamilyTextBox, 1, 0); + _layoutPanel.Controls.Add(_menuFontSizeLabel, 0, 1); + _layoutPanel.Controls.Add(_menuFontSizeUpDown, 1, 1); + _layoutPanel.Controls.Add(_standardFontSizeLabel, 0, 2); + _layoutPanel.Controls.Add(_standardFontSizeUpDown, 1, 2); + _layoutPanel.Controls.Add(_treeDeltaLabel, 0, 3); + _layoutPanel.Controls.Add(_treeDeltaUpDown, 1, 3); + _layoutPanel.Controls.Add(_treeBoldCheckBox, 1, 4); + _layoutPanel.Controls.Add(_statusFontSizeLabel, 0, 5); + _layoutPanel.Controls.Add(_statusFontSizeUpDown, 1, 5); + _layoutPanel.Controls.Add(_buttonPanel, 1, 6); + _layoutPanel.Dock = DockStyle.Fill; + _layoutPanel.Location = new Point(0, 0); + _layoutPanel.Name = "_layoutPanel"; + _layoutPanel.Padding = new Padding(12); + _layoutPanel.RowCount = 7; + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + _layoutPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + _layoutPanel.Size = new Size(458, 274); + _layoutPanel.TabIndex = 0; + // + // _fontFamilyLabel + // + _fontFamilyLabel.Anchor = AnchorStyles.Left; + _fontFamilyLabel.AutoSize = true; + _fontFamilyLabel.Location = new Point(15, 18); + _fontFamilyLabel.Name = "_fontFamilyLabel"; + _fontFamilyLabel.Size = new Size(90, 20); + _fontFamilyLabel.TabIndex = 0; + _fontFamilyLabel.Text = "Font family:"; + // + // _fontFamilyTextBox + // + _fontFamilyTextBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _fontFamilyTextBox.Location = new Point(185, 15); + _fontFamilyTextBox.Name = "_fontFamilyTextBox"; + _fontFamilyTextBox.Size = new Size(258, 27); + _fontFamilyTextBox.TabIndex = 1; + // + // _menuFontSizeLabel + // + _menuFontSizeLabel.Anchor = AnchorStyles.Left; + _menuFontSizeLabel.AutoSize = true; + _menuFontSizeLabel.Location = new Point(15, 50); + _menuFontSizeLabel.Name = "_menuFontSizeLabel"; + _menuFontSizeLabel.Size = new Size(139, 20); + _menuFontSizeLabel.TabIndex = 2; + _menuFontSizeLabel.Text = "MenuStrip size (pt):"; + // + // _menuFontSizeUpDown + // + _menuFontSizeUpDown.DecimalPlaces = 1; + _menuFontSizeUpDown.Increment = 0.5M; + _menuFontSizeUpDown.Location = new Point(185, 48); + _menuFontSizeUpDown.Maximum = 24M; + _menuFontSizeUpDown.Minimum = 6M; + _menuFontSizeUpDown.Name = "_menuFontSizeUpDown"; + _menuFontSizeUpDown.Size = new Size(90, 27); + _menuFontSizeUpDown.TabIndex = 3; + _menuFontSizeUpDown.Value = 11M; + // + // _standardFontSizeLabel + // + _standardFontSizeLabel.Anchor = AnchorStyles.Left; + _standardFontSizeLabel.AutoSize = true; + _standardFontSizeLabel.Location = new Point(15, 83); + _standardFontSizeLabel.Name = "_standardFontSizeLabel"; + _standardFontSizeLabel.Size = new Size(151, 20); + _standardFontSizeLabel.TabIndex = 4; + _standardFontSizeLabel.Text = "Standard UI size (pt):"; + // + // _standardFontSizeUpDown + // + _standardFontSizeUpDown.DecimalPlaces = 1; + _standardFontSizeUpDown.Increment = 0.5M; + _standardFontSizeUpDown.Location = new Point(185, 81); + _standardFontSizeUpDown.Maximum = 24M; + _standardFontSizeUpDown.Minimum = 6M; + _standardFontSizeUpDown.Name = "_standardFontSizeUpDown"; + _standardFontSizeUpDown.Size = new Size(90, 27); + _standardFontSizeUpDown.TabIndex = 5; + _standardFontSizeUpDown.Value = 10M; + // + // _treeDeltaLabel + // + _treeDeltaLabel.Anchor = AnchorStyles.Left; + _treeDeltaLabel.AutoSize = true; + _treeDeltaLabel.Location = new Point(15, 116); + _treeDeltaLabel.Name = "_treeDeltaLabel"; + _treeDeltaLabel.Size = new Size(164, 20); + _treeDeltaLabel.TabIndex = 6; + _treeDeltaLabel.Text = "Tree root size delta (pt):"; + // + // _treeDeltaUpDown + // + _treeDeltaUpDown.DecimalPlaces = 1; + _treeDeltaUpDown.Increment = 0.5M; + _treeDeltaUpDown.Location = new Point(185, 114); + _treeDeltaUpDown.Maximum = 6M; + _treeDeltaUpDown.Name = "_treeDeltaUpDown"; + _treeDeltaUpDown.Size = new Size(90, 27); + _treeDeltaUpDown.TabIndex = 7; + _treeDeltaUpDown.Value = 1M; + // + // _treeBoldCheckBox + // + _treeBoldCheckBox.AutoSize = true; + _treeBoldCheckBox.Checked = true; + _treeBoldCheckBox.CheckState = CheckState.Checked; + _treeBoldCheckBox.Location = new Point(185, 147); + _treeBoldCheckBox.Name = "_treeBoldCheckBox"; + _treeBoldCheckBox.Size = new Size(181, 24); + _treeBoldCheckBox.TabIndex = 8; + _treeBoldCheckBox.Text = "Tree root nodes bold"; + _treeBoldCheckBox.UseVisualStyleBackColor = true; + // + // _statusFontSizeLabel + // + _statusFontSizeLabel.Anchor = AnchorStyles.Left; + _statusFontSizeLabel.AutoSize = true; + _statusFontSizeLabel.Location = new Point(15, 181); + _statusFontSizeLabel.Name = "_statusFontSizeLabel"; + _statusFontSizeLabel.Size = new Size(141, 20); + _statusFontSizeLabel.TabIndex = 9; + _statusFontSizeLabel.Text = "StatusStrip size (pt):"; + // + // _statusFontSizeUpDown + // + _statusFontSizeUpDown.DecimalPlaces = 1; + _statusFontSizeUpDown.Increment = 0.5M; + _statusFontSizeUpDown.Location = new Point(185, 178); + _statusFontSizeUpDown.Maximum = 24M; + _statusFontSizeUpDown.Minimum = 6M; + _statusFontSizeUpDown.Name = "_statusFontSizeUpDown"; + _statusFontSizeUpDown.Size = new Size(90, 27); + _statusFontSizeUpDown.TabIndex = 10; + _statusFontSizeUpDown.Value = 10M; + // + // _buttonPanel + // + _buttonPanel.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; + _buttonPanel.AutoSize = true; + _buttonPanel.Controls.Add(_okButton); + _buttonPanel.Controls.Add(_cancelButton); + _buttonPanel.FlowDirection = FlowDirection.LeftToRight; + _buttonPanel.Location = new Point(281, 230); + _buttonPanel.Name = "_buttonPanel"; + _buttonPanel.Size = new Size(162, 29); + _buttonPanel.TabIndex = 11; + // + // _okButton + // + _okButton.DialogResult = DialogResult.OK; + _okButton.Location = new Point(3, 3); + _okButton.Name = "_okButton"; + _okButton.Size = new Size(75, 23); + _okButton.TabIndex = 0; + _okButton.Text = "OK"; + _okButton.UseVisualStyleBackColor = true; + _okButton.Click += OkButton_Click; + // + // _cancelButton + // + _cancelButton.DialogResult = DialogResult.Cancel; + _cancelButton.Location = new Point(84, 3); + _cancelButton.Name = "_cancelButton"; + _cancelButton.Size = new Size(75, 23); + _cancelButton.TabIndex = 1; + _cancelButton.Text = "Cancel"; + _cancelButton.UseVisualStyleBackColor = true; + // + // OptionsDialog + // + AcceptButton = _okButton; + AutoScaleDimensions = new SizeF(8F, 20F); + AutoScaleMode = AutoScaleMode.Font; + CancelButton = _cancelButton; + ClientSize = new Size(458, 274); + Controls.Add(_layoutPanel); + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Name = "OptionsDialog"; + StartPosition = FormStartPosition.CenterParent; + Text = "Options"; + ((System.ComponentModel.ISupportInitialize)_menuFontSizeUpDown).EndInit(); + ((System.ComponentModel.ISupportInitialize)_standardFontSizeUpDown).EndInit(); + ((System.ComponentModel.ISupportInitialize)_treeDeltaUpDown).EndInit(); + ((System.ComponentModel.ISupportInitialize)_statusFontSizeUpDown).EndInit(); + _layoutPanel.ResumeLayout(false); + _layoutPanel.PerformLayout(); + _buttonPanel.ResumeLayout(false); + ResumeLayout(false); + } + + #endregion + + private TableLayoutPanel _layoutPanel = null!; + private Label _fontFamilyLabel = null!; + private TextBox _fontFamilyTextBox = null!; + private Label _menuFontSizeLabel = null!; + private NumericUpDown _menuFontSizeUpDown = null!; + private Label _standardFontSizeLabel = null!; + private NumericUpDown _standardFontSizeUpDown = null!; + private Label _treeDeltaLabel = null!; + private NumericUpDown _treeDeltaUpDown = null!; + private CheckBox _treeBoldCheckBox = null!; + private Label _statusFontSizeLabel = null!; + private NumericUpDown _statusFontSizeUpDown = null!; + private FlowLayoutPanel _buttonPanel = null!; + private Button _okButton = null!; + private Button _cancelButton = null!; +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/OptionsDialog.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/OptionsDialog.cs new file mode 100644 index 0000000..7a6980f --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/OptionsDialog.cs @@ -0,0 +1,37 @@ +namespace Winget_Package_Editor; + +internal partial class OptionsDialog : Form +{ + private readonly UiFontSettings _settings; + + public OptionsDialog(UiFontSettings settings) + { + _settings = settings.Clone(); + InitializeComponent(); + LoadSettings(); + } + + public UiFontSettings Settings => _settings.Clone(); + + private void LoadSettings() + { + _fontFamilyTextBox.Text = _settings.FontFamily; + _menuFontSizeUpDown.Value = (decimal)_settings.MenuStripSize; + _standardFontSizeUpDown.Value = (decimal)_settings.StandardSize; + _treeDeltaUpDown.Value = (decimal)_settings.TreeMainNodeDelta; + _statusFontSizeUpDown.Value = (decimal)_settings.StatusStripSize; + _treeBoldCheckBox.Checked = _settings.TreeMainNodeBold; + } + + private void OkButton_Click(object? sender, EventArgs e) + { + _settings.FontFamily = string.IsNullOrWhiteSpace(_fontFamilyTextBox.Text) + ? "Segoe UI" + : _fontFamilyTextBox.Text.Trim(); + _settings.MenuStripSize = (float)_menuFontSizeUpDown.Value; + _settings.StandardSize = (float)_standardFontSizeUpDown.Value; + _settings.TreeMainNodeDelta = (float)_treeDeltaUpDown.Value; + _settings.StatusStripSize = (float)_statusFontSizeUpDown.Value; + _settings.TreeMainNodeBold = _treeBoldCheckBox.Checked; + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Program.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Program.cs new file mode 100644 index 0000000..af04d93 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Program.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.WinForms; +using System.Diagnostics; +using WarpToolkit.Microsoft.Extensions.Logging; +using WarpToolkit.WinForms.AppServices.ServiceExtensions; +using WingetPackageEditor.Core.Services; +using WingetPackageEditor.Core.ViewModels; + +namespace Winget_Package_Editor; + +internal static class Program +{ + /// + /// The main entry point for the application. + /// + [STAThread] + public static void Main() + { + WinFormsApplicationBuilder builder = WinFormsApplication.CreateBuilder(); + + // We want to use the UserSettings service, for a convenient + // way to store user settings in a file. + builder.Services.AddWinFormsUserSettingsService(); + + // We want to use the Exception service, so we can handle + // unhandled exceptions in a consistent way. + builder.Services.AddWinFormsExceptionService(); + builder.Services.AddWinFormsDialogService(); + + // One we setup this service, compatible component can use the + // service to either get the AI-Provider key via this default local + // key, or can pass a different EnvironmentVariable key once they got + // the service, to get the actual key from the environment variable. + builder.Services.AddLocalKeyRetrievalService(); + + // We want to use the BlazorWebView service, so we can + // so we can use the ChatView control, which is based + // on the BlazorWebView control. + // builder.Services.AddWindowsFormsBlazorWebView(); + + Debug.Assert(Thread.CurrentThread.GetApartmentState() == ApartmentState.STA); + + // Configure logging + builder.Logging.AddTimeStampedDebug(); + + // Register the main form as a scoped service. + // This is not only convenient, but also allows us to use dependency injection, + // and particularly to provide the Form the ServiceProvider, which it itself can + // distribute by calling the Form Extension method `AssignServices(serviceProvider)`. + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(provider => + new JsonPackageStore(provider.GetRequiredService())); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Configure WinForms-specific options + + // Variant 1: loading configuration from an appsettings.json file. + // builder.UseStartupForm() + // // We are using an appsettings.json file for configuration. + // .AllowWinFormsJsonAppSettings(); + + // Variant 2: Setting up configuration through code. + builder.UseStartupForm() + .UseHighDpiMode(HighDpiMode.SystemAware) + .UseColorMode(SystemColorMode.System) + .UseTextRenderingV2() + .UseVisualStyles(); + + // Build and run the application + WinFormsApplication app = builder.Build(); + + app.Run(); + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Properties/DataSources/MainViewModel.datasource b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Properties/DataSources/MainViewModel.datasource new file mode 100644 index 0000000..d69110c --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Properties/DataSources/MainViewModel.datasource @@ -0,0 +1,5 @@ + + + WingetPackageEditor.Core.ViewModels.MainViewModel, WingetPackageEditor.Core, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null + diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/TreeViewBinder.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/TreeViewBinder.cs new file mode 100644 index 0000000..6ff3692 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/TreeViewBinder.cs @@ -0,0 +1,193 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using WingetPackageEditor.Core.ViewModels; + +namespace Winget_Package_Editor; + +internal sealed class TreeViewBinder : IDisposable +{ + private readonly TreeView _treeView; + private readonly ObservableCollection _roots; + private Font? _rootNodeFont; + private bool _updatingTree; + + public TreeViewBinder(TreeView treeView, ObservableCollection roots) + { + _treeView = treeView ?? throw new ArgumentNullException(nameof(treeView)); + _roots = roots ?? throw new ArgumentNullException(nameof(roots)); + _roots.CollectionChanged += OnRootsChanged; + _treeView.AfterSelect += OnAfterSelect; + Rebuild(); + } + + public event EventHandler? SelectedNodeChanged; + + public void SetRootNodeFont(Font? font) + { + _rootNodeFont = font; + foreach (TreeNode node in _treeView.Nodes) + { + if (node.Tag is NavigationNodeViewModel { Kind: NavigationNodeKind.Package }) + { + node.NodeFont = _rootNodeFont; + } + } + } + + public void ExpandAll() => _treeView.ExpandAll(); + + public void CollapseSelected() + { + _treeView.SelectedNode?.Collapse(); + } + + public void ExpandSelected() + { + _treeView.SelectedNode?.ExpandAll(); + } + + public string[] GetExpandedNodeKeys() + { + List keys = []; + CollectExpandedNodeKeys(_treeView.Nodes, keys); + return [.. keys]; + } + + public void RestoreExpandedNodeKeys(IEnumerable keys) + { + HashSet keySet = new(keys); + RestoreExpandedNodeKeys(_treeView.Nodes, keySet); + } + + public void SelectNode(NavigationNodeViewModel? selectedNode) + { + if (selectedNode is null) + { + return; + } + + TreeNode? node = FindNode(_treeView.Nodes, selectedNode); + if (node is null) + { + return; + } + + _updatingTree = true; + try + { + _treeView.SelectedNode = node; + node.EnsureVisible(); + } + finally + { + _updatingTree = false; + } + } + + public void Dispose() + { + _roots.CollectionChanged -= OnRootsChanged; + _treeView.AfterSelect -= OnAfterSelect; + } + + private void OnRootsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild(); + + private void Rebuild() + { + _updatingTree = true; + try + { + _treeView.BeginUpdate(); + _treeView.Nodes.Clear(); + foreach (NavigationNodeViewModel root in _roots) + { + _treeView.Nodes.Add(CreateNode(root, _rootNodeFont)); + } + + if (_treeView.Nodes.Count > 0 && _treeView.SelectedNode is null) + { + _treeView.SelectedNode = _treeView.Nodes[0]; + } + } + finally + { + _treeView.EndUpdate(); + _updatingTree = false; + } + } + + private static TreeNode CreateNode(NavigationNodeViewModel viewModel, Font? rootNodeFont) + { + TreeNode node = new(viewModel.Text) + { + Tag = viewModel + }; + + if (viewModel.Kind == NavigationNodeKind.Package) + { + node.NodeFont = rootNodeFont; + } + + foreach (NavigationNodeViewModel child in viewModel.Children) + { + node.Nodes.Add(CreateNode(child, rootNodeFont)); + } + + return node; + } + + private void OnAfterSelect(object? sender, TreeViewEventArgs e) + { + if (_updatingTree) + { + return; + } + + SelectedNodeChanged?.Invoke(this, e.Node?.Tag as NavigationNodeViewModel); + } + + private static TreeNode? FindNode(TreeNodeCollection nodes, NavigationNodeViewModel selectedNode) + { + foreach (TreeNode node in nodes) + { + if (ReferenceEquals(node.Tag, selectedNode)) + { + return node; + } + + TreeNode? childNode = FindNode(node.Nodes, selectedNode); + if (childNode is not null) + { + return childNode; + } + } + + return null; + } + + private static void CollectExpandedNodeKeys(TreeNodeCollection nodes, List keys) + { + foreach (TreeNode node in nodes) + { + if (node.IsExpanded && node.Tag is NavigationNodeViewModel viewModel) + { + keys.Add(viewModel.Key); + } + + CollectExpandedNodeKeys(node.Nodes, keys); + } + } + + private static void RestoreExpandedNodeKeys(TreeNodeCollection nodes, HashSet keys) + { + foreach (TreeNode node in nodes) + { + if (node.Tag is NavigationNodeViewModel viewModel && keys.Contains(viewModel.Key)) + { + node.Expand(); + } + + RestoreExpandedNodeKeys(node.Nodes, keys); + } + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/UiFontSettings.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/UiFontSettings.cs new file mode 100644 index 0000000..0cb8b0e --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/UiFontSettings.cs @@ -0,0 +1,29 @@ +namespace Winget_Package_Editor; + +internal sealed class UiFontSettings +{ + public string FontFamily { get; set; } = "Segoe UI"; + + public float MenuStripSize { get; set; } = 11F; + + public float StandardSize { get; set; } = 10F; + + public float TreeMainNodeDelta { get; set; } = 1F; + + public float StatusStripSize { get; set; } = 10F; + + public bool TreeMainNodeBold { get; set; } = true; + + public UiFontSettings Clone() + { + return new UiFontSettings + { + FontFamily = FontFamily, + MenuStripSize = MenuStripSize, + StandardSize = StandardSize, + TreeMainNodeDelta = TreeMainNodeDelta, + StatusStripSize = StatusStripSize, + TreeMainNodeBold = TreeMainNodeBold + }; + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/WinFormsPackageEditorDialogService.cs b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/WinFormsPackageEditorDialogService.cs new file mode 100644 index 0000000..05d1760 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/WinFormsPackageEditorDialogService.cs @@ -0,0 +1,43 @@ +using WingetPackageEditor.Core.Models; +using WingetPackageEditor.Core.Services; + +namespace Winget_Package_Editor; + +/// +/// WinForms implementation of . Dialogs are owned by the +/// currently active form so they center over the main window. +/// +internal sealed class WinFormsPackageEditorDialogService : IPackageEditorDialogService +{ + public NewFromExistingResult? AskNewFromExisting(IReadOnlyList existingPackages) + { + ArgumentNullException.ThrowIfNull(existingPackages); + + using NewFromExistingDialog dialog = new(existingPackages); + if (dialog.ShowDialog(Form.ActiveForm) != DialogResult.OK || dialog.SourcePackage is null) + { + return null; + } + + return new NewFromExistingResult(dialog.NewName, dialog.SourcePackage); + } + + public bool ConfirmRemovePackage(string packageName) + { + return MessageBox.Show( + Form.ActiveForm, + $"Remove package '{packageName}'?\r\n\r\nA backup will be written to the AppData backups folder.", + "Remove package", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2) == DialogResult.Yes; + } + + public AppEntry? PickAndConfigureApp(IReadOnlyList wellKnownApps) + { + ArgumentNullException.ThrowIfNull(wellKnownApps); + + using AddAppDialog dialog = new(wellKnownApps); + return dialog.ShowDialog(Form.ActiveForm) == DialogResult.OK ? dialog.Result : null; + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Winget-Package-Editor.csproj b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Winget-Package-Editor.csproj new file mode 100644 index 0000000..c78dce9 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/Winget-Package-Editor.csproj @@ -0,0 +1,26 @@ + + + + WinExe + net10.0-windows10.0.22000.0 + enable + true + enable + + + [0.*-preview*,) + + + + + + + + + + + + + + + diff --git a/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/appsettings.json b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/appsettings.json new file mode 100644 index 0000000..82b3486 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/Winget-Package-Editor/appsettings.json @@ -0,0 +1,8 @@ +{ + "WinForms": { + "HighDpiMode": "SystemAware", + "ColorMode": "System", + "UseTextRenderingV2": true, + "UseVisualStyles": true + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Models/VisualStudioDiscoveryModels.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Models/VisualStudioDiscoveryModels.cs new file mode 100644 index 0000000..beb6878 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Models/VisualStudioDiscoveryModels.cs @@ -0,0 +1,71 @@ +namespace WingetPackageEditor.Core.Models; + +/// +/// Identifies the update channel a Visual Studio installation was acquired from. +/// +public enum VisualStudioChannel +{ + Unknown, + Release, + Preview, + Canary, + Main +} + +/// +/// Describes a single Visual Studio data/registry hive folder under +/// %LocalAppData%\Microsoft\VisualStudio. +/// +public sealed record VisualStudioHiveInfo( + string Name, + string Path, + string SettingsFilePath, + bool IsExperimental); + +/// +/// Represents a raw Visual Studio hive folder discovered on disk before correlation. +/// +public sealed record VisualStudioHiveFolder(string Name, string Path); + +/// +/// Describes a concrete Visual Studio installation as reported by vswhere.exe, +/// together with the local data hives correlated to it. +/// +public sealed record VisualStudioInstanceInfo( + string InstanceId, + string DisplayName, + string Year, + string Edition, + VisualStudioChannel Channel, + string ChannelId, + string Version, + string ShortVersion, + DateTimeOffset? InstallDate, + string InstallationPath, + string ProductId, + bool IsPrerelease, + IReadOnlyList Hives) +{ + /// + /// Gets a human-readable channel label, falling back to the raw channel id segment + /// when the channel could not be classified. + /// + public string ChannelLabel + { + get + { + if (Channel != VisualStudioChannel.Unknown) + { + return Channel.ToString(); + } + + string segment = ChannelId.Split('.').LastOrDefault() ?? string.Empty; + return segment.Length > 0 ? segment : "Unknown"; + } + } + + /// + /// Gets the "Channel-Edition" combination label used as a grouping node. + /// + public string SkuComboLabel => $"{ChannelLabel}-{Edition}"; +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Models/WingetPackage.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Models/WingetPackage.cs new file mode 100644 index 0000000..5817137 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Models/WingetPackage.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization; +using WingetPackageEditor.Core.Services; + +namespace WingetPackageEditor.Core.Models; + +public sealed class WingetPackage +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + + public string Name { get; set; } = ""; + + public string? Description { get; set; } + + public string? Author { get; set; } + + public string Version { get; set; } = "1.0.0"; + + public List Apps { get; set; } = []; + + /// + /// Creates a deep copy of , preserving polymorphic app entries. + /// + public static WingetPackage Clone(WingetPackage package) => PackageJsonSerializer.Clone(package); +} + +public enum AppAction { Ensure, Install, Upgrade } + +public enum AppScope { User, Machine } + +public enum AppSource { Winget, MSStore } + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(GenericAppEntry), "generic")] +[JsonDerivedType(typeof(VisualStudioEntry), "vs")] +[JsonDerivedType(typeof(VSCodeEntry), "vscode")] +public abstract class AppEntry +{ + public string Id { get; set; } = ""; + + public string DisplayName { get; set; } = ""; + + public AppAction Action { get; set; } = AppAction.Ensure; + + public AppSource Source { get; set; } = AppSource.Winget; + + public string? Version { get; set; } + + public AppScope Scope { get; set; } = AppScope.Machine; + + public bool AllowPrerelease { get; set; } + + public Dictionary ExtraSettings { get; set; } = []; +} + +public sealed class GenericAppEntry : AppEntry; + +public sealed class VisualStudioEntry : AppEntry +{ + public VSEdition Edition { get; set; } + + public VSChannel Channel { get; set; } + + public string? VSConfigPath { get; set; } + + public string? VSConfigInline { get; set; } + + public string? InstanceNickname { get; set; } + + public List Extensions { get; set; } = []; +} + +public sealed class VsixReference +{ + public string Identifier { get; set; } = ""; + + public bool Admin { get; set; } = true; +} + +public sealed class VSCodeEntry : AppEntry +{ + public List Extensions { get; set; } = []; +} + +public enum VSEdition { Community, Professional, Enterprise, BuildTools } + +public enum VSChannel { Release, Preview } diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/ConsoleMessage.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/ConsoleMessage.cs new file mode 100644 index 0000000..ff9ce98 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/ConsoleMessage.cs @@ -0,0 +1,15 @@ +namespace WingetPackageEditor.Core.Services; + +public sealed record ConsoleMessage( + DateTimeOffset Timestamp, + ConsoleMessageKind Kind, + string Text); + +public enum ConsoleMessageKind +{ + Info, + Warning, + Error, + Command, + Debug +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/ConsoleService.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/ConsoleService.cs new file mode 100644 index 0000000..06fa578 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/ConsoleService.cs @@ -0,0 +1,14 @@ +using System.Collections.ObjectModel; + +namespace WingetPackageEditor.Core.Services; + +public sealed class ConsoleService : IConsoleService +{ + public ObservableCollection Messages { get; } = []; + + public void Write(ConsoleMessageKind kind, string? text) + { + text ??= string.Empty; + Messages.Add(new ConsoleMessage(DateTimeOffset.Now, kind, text)); + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/HardcodedCatalogService.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/HardcodedCatalogService.cs new file mode 100644 index 0000000..d6525c4 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/HardcodedCatalogService.cs @@ -0,0 +1,99 @@ +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.Services; + +public sealed class HardcodedCatalogService : ICatalogService +{ + public AppEntry CreateDefaultApp() + { + return new GenericAppEntry + { + Id = "Microsoft.PowerShell", + DisplayName = "PowerShell 7", + Action = AppAction.Ensure, + Source = AppSource.Winget, + Scope = AppScope.Machine + }; + } + + public IReadOnlyList GetWellKnownApps() + { + return + [ + Generic("Git.Git", "Git"), + Generic("Microsoft.PowerShell", "PowerShell 7"), + Generic("Microsoft.WindowsTerminal", "Windows Terminal"), + Generic("7zip.7zip", "7-Zip"), + Generic("Notepad++.Notepad++", "Notepad++"), + Generic("Python.Python.3.12", "Python 3.12"), + Generic("OpenJS.NodeJS.LTS", "Node.js LTS"), + Generic("Microsoft.PowerToys", "PowerToys"), + Generic("Docker.DockerDesktop", "Docker Desktop"), + Generic("GitHub.cli", "GitHub CLI"), + new VSCodeEntry + { + Id = "Microsoft.VisualStudioCode", + DisplayName = "Visual Studio Code" + }, + new VisualStudioEntry + { + Id = "Microsoft.VisualStudio.2022.Professional", + DisplayName = "Visual Studio 2022 Professional", + Edition = VSEdition.Professional, + Channel = VSChannel.Release + } + ]; + + static GenericAppEntry Generic(string id, string displayName) => new() + { + Id = id, + DisplayName = displayName, + Action = AppAction.Ensure, + Source = AppSource.Winget, + Scope = AppScope.Machine + }; + } + + public WingetPackage CreateDemoPackage() + { + return new WingetPackage + { + Name = "Developer Workstation", + Description = "V0 demo package for exercising MVVM bindings.", + Author = Environment.UserName, + Apps = + [ + CreateDefaultApp(), + new VSCodeEntry + { + Id = "Microsoft.VisualStudioCode", + DisplayName = "Visual Studio Code", + Extensions = + [ + "ms-dotnettools.csharp", + "github.copilot", + "github.vscode-github-actions" + ] + }, + new VisualStudioEntry + { + Id = "Microsoft.VisualStudio.2022.Professional", + DisplayName = "Visual Studio 2022 Professional", + Edition = VSEdition.Professional, + Channel = VSChannel.Release, + VSConfigInline = """ + { + "version": "1.0", + "components": [] + } + """, + Extensions = + [ + new VsixReference { Identifier = "GitHub.copilotvs" }, + new VsixReference { Identifier = "VisualStudioExptTeam.VSColorOutput64" } + ] + } + ] + }; + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/ICatalogService.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/ICatalogService.cs new file mode 100644 index 0000000..bbd5335 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/ICatalogService.cs @@ -0,0 +1,16 @@ +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.Services; + +public interface ICatalogService +{ + AppEntry CreateDefaultApp(); + + WingetPackage CreateDemoPackage(); + + /// + /// Returns curated template entries for commonly installed developer apps. Each entry carries + /// its winget Id so installed apps can be matched against the catalog. + /// + IReadOnlyList GetWellKnownApps(); +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IConsoleService.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IConsoleService.cs new file mode 100644 index 0000000..5a4b55f --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IConsoleService.cs @@ -0,0 +1,10 @@ +using System.Collections.ObjectModel; + +namespace WingetPackageEditor.Core.Services; + +public interface IConsoleService +{ + ObservableCollection Messages { get; } + + void Write(ConsoleMessageKind kind, string text); +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IInstalledAppScanner.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IInstalledAppScanner.cs new file mode 100644 index 0000000..d6cc26a --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IInstalledAppScanner.cs @@ -0,0 +1,13 @@ +namespace WingetPackageEditor.Core.Services; + +/// +/// Scans the machine for installed applications and reports their winget package Ids. +/// +public interface IInstalledAppScanner +{ + /// + /// Returns the winget Ids of installed apps. Implementations stream tool output to the console + /// and never throw when winget is unavailable. + /// + IReadOnlyList GetInstalledWingetIds(); +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IPackageEditorDialogService.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IPackageEditorDialogService.cs new file mode 100644 index 0000000..6088bf7 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IPackageEditorDialogService.cs @@ -0,0 +1,32 @@ +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.Services; + +/// +/// Result of the "New from existing package" dialog: the chosen new name and the source package +/// whose definition should be cloned. +/// +public sealed record NewFromExistingResult(string NewName, WingetPackage SourcePackage); + +/// +/// Abstracts the modal dialogs the editor needs so view-model logic stays UI-free and testable. +/// +public interface IPackageEditorDialogService +{ + /// + /// Prompts for a new package name and a source package to clone. Returns + /// when the user cancels. + /// + NewFromExistingResult? AskNewFromExisting(IReadOnlyList existingPackages); + + /// + /// Asks the user to confirm removal of the named package. + /// + bool ConfirmRemovePackage(string packageName); + + /// + /// Lets the user pick a well-known app and configure how it installs. Returns a configured + /// , or when cancelled. + /// + AppEntry? PickAndConfigureApp(IReadOnlyList wellKnownApps); +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IPackageStore.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IPackageStore.cs new file mode 100644 index 0000000..731183a --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IPackageStore.cs @@ -0,0 +1,25 @@ +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.Services; + +/// +/// Persists winget package definitions to disk, one JSON file per package. +/// +public interface IPackageStore +{ + /// + /// Loads every persisted package. Returns an empty list when no packages exist yet. + /// Implementations are best-effort and skip files that fail to deserialize. + /// + IReadOnlyList LoadAll(); + + /// + /// Creates or overwrites the on-disk definition for . + /// + void Save(WingetPackage package); + + /// + /// Writes a timestamped backup of and deletes its on-disk definition. + /// + void Delete(WingetPackage package); +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IVisualStudioDiscoveryService.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IVisualStudioDiscoveryService.cs new file mode 100644 index 0000000..8c0aa7d --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/IVisualStudioDiscoveryService.cs @@ -0,0 +1,16 @@ +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.Services; + +/// +/// Discovers Visual Studio installations and their associated data hives. +/// +public interface IVisualStudioDiscoveryService +{ + /// + /// Discovers all installed Visual Studio instances (including prerelease), correlating + /// each with its local data and experimental hives. Implementations route any external + /// tool output to the console and never throw for the absence of Visual Studio. + /// + IReadOnlyList DiscoverInstances(); +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/JsonPackageStore.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/JsonPackageStore.cs new file mode 100644 index 0000000..63fe038 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/JsonPackageStore.cs @@ -0,0 +1,103 @@ +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.Services; + +/// +/// Stores each package as {Id}.json under a packages folder, with deletions backed up to a +/// sibling backups folder as WPE{yyMMddHHmmss}.bak. By default the root lives under +/// %AppData%\Winget-Package-Editor. +/// +public sealed class JsonPackageStore : IPackageStore +{ + private readonly string _packagesDirectory; + private readonly string _backupsDirectory; + private readonly IConsoleService? _consoleService; + + public JsonPackageStore(IConsoleService? consoleService = null) + : this(DefaultRootDirectory(), consoleService) + { + } + + public JsonPackageStore(string rootDirectory, IConsoleService? consoleService = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + _packagesDirectory = Path.Combine(rootDirectory, "Packages"); + _backupsDirectory = Path.Combine(rootDirectory, "Backups"); + _consoleService = consoleService; + } + + public static string DefaultRootDirectory() + => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Winget-Package-Editor"); + + public IReadOnlyList LoadAll() + { + List packages = []; + if (!Directory.Exists(_packagesDirectory)) + { + return packages; + } + + foreach (string file in Directory.EnumerateFiles(_packagesDirectory, "*.json")) + { + try + { + if (PackageJsonSerializer.Deserialize(File.ReadAllText(file)) is { } package) + { + packages.Add(package); + } + } + catch (Exception ex) + { + _consoleService?.Write(ConsoleMessageKind.Warning, $"Skipped unreadable package '{file}': {ex.Message}"); + } + } + + return packages; + } + + public void Save(WingetPackage package) + { + ArgumentNullException.ThrowIfNull(package); + + try + { + Directory.CreateDirectory(_packagesDirectory); + File.WriteAllText(GetPackagePath(package), PackageJsonSerializer.Serialize(package)); + } + catch (Exception ex) + { + _consoleService?.Write(ConsoleMessageKind.Error, $"Could not save package '{package.Name}': {ex.Message}"); + } + } + + public void Delete(WingetPackage package) + { + ArgumentNullException.ThrowIfNull(package); + + try + { + Directory.CreateDirectory(_backupsDirectory); + string backupName = $"WPE{DateTime.Now:yyMMddHHmmss}.bak"; + File.WriteAllText(Path.Combine(_backupsDirectory, backupName), PackageJsonSerializer.Serialize(package)); + + string path = GetPackagePath(package); + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) + { + _consoleService?.Write(ConsoleMessageKind.Error, $"Could not remove package '{package.Name}': {ex.Message}"); + } + } + + private string GetPackagePath(WingetPackage package) + { + string id = string.IsNullOrWhiteSpace(package.Id) ? Guid.NewGuid().ToString("N") : package.Id; + package.Id = id; + return Path.Combine(_packagesDirectory, $"{id}.json"); + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/LocalVisualStudioDiscoveryService.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/LocalVisualStudioDiscoveryService.cs new file mode 100644 index 0000000..fe6171c --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/LocalVisualStudioDiscoveryService.cs @@ -0,0 +1,183 @@ +using System.Diagnostics; +using System.Text; +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.Services; + +/// +/// Discovers Visual Studio installations by running vswhere.exe and correlating the +/// reported instances with the local data/experimental hives on disk. +/// +public sealed class LocalVisualStudioDiscoveryService : IVisualStudioDiscoveryService +{ + private readonly IConsoleService _consoleService; + private readonly string _visualStudioLocalAppDataPath; + private readonly string? _vsWherePathOverride; + private readonly Func? _vsWhereOutputProvider; + + public LocalVisualStudioDiscoveryService(IConsoleService consoleService) + : this( + consoleService, + DefaultLocalAppDataPath(), + DefaultVsWherePath(), + vsWhereOutputProvider: null) + { + } + + /// + /// Initializes a new instance for testing, allowing the hive folder location and the + /// raw vswhere output to be supplied directly. + /// + public LocalVisualStudioDiscoveryService( + IConsoleService consoleService, + string visualStudioLocalAppDataPath, + string? vsWherePathOverride, + Func? vsWhereOutputProvider) + { + _consoleService = consoleService ?? throw new ArgumentNullException(nameof(consoleService)); + ArgumentException.ThrowIfNullOrWhiteSpace(visualStudioLocalAppDataPath); + _visualStudioLocalAppDataPath = visualStudioLocalAppDataPath; + _vsWherePathOverride = vsWherePathOverride; + _vsWhereOutputProvider = vsWhereOutputProvider; + } + + public IReadOnlyList DiscoverInstances() + { + string? output = _vsWhereOutputProvider is not null + ? _vsWhereOutputProvider() + : RunVsWhere(); + + if (string.IsNullOrWhiteSpace(output)) + { + _consoleService.Write(ConsoleMessageKind.Warning, "No Visual Studio installations were reported by vswhere."); + return []; + } + + IReadOnlyList hiveFolders = EnumerateHiveFolders(); + IReadOnlyList> blocks = VisualStudioDiscoveryParser.ParseBlocks(output); + + List instances = []; + foreach (IReadOnlyDictionary block in blocks) + { + VisualStudioInstanceInfo? instance = VisualStudioDiscoveryParser.MapInstance(block, hiveFolders); + if (instance is not null) + { + instances.Add(instance); + } + } + + _consoleService.Write( + instances.Count == 0 ? ConsoleMessageKind.Warning : ConsoleMessageKind.Info, + instances.Count == 0 + ? "No Visual Studio installations were discovered." + : $"Discovered {instances.Count} Visual Studio installation(s)."); + + return instances + .OrderByDescending(instance => instance.Year, StringComparer.OrdinalIgnoreCase) + .ThenBy(instance => instance.SkuComboLabel, StringComparer.OrdinalIgnoreCase) + .ThenBy(instance => instance.Version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private string? RunVsWhere() + { + string vsWherePath = _vsWherePathOverride ?? DefaultVsWherePath(); + if (!File.Exists(vsWherePath)) + { + _consoleService.Write(ConsoleMessageKind.Warning, $"vswhere.exe was not found at '{vsWherePath}'."); + return null; + } + + try + { + ProcessStartInfo startInfo = new() + { + FileName = vsWherePath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8 + }; + startInfo.ArgumentList.Add("-all"); + startInfo.ArgumentList.Add("-prerelease"); + + _consoleService.Write(ConsoleMessageKind.Command, $"\"{vsWherePath}\" -all -prerelease"); + + using Process process = new() { StartInfo = startInfo }; + if (!process.Start()) + { + _consoleService.Write(ConsoleMessageKind.Error, "Failed to start vswhere.exe."); + return null; + } + + StringBuilder output = new(); + string? line; + while ((line = process.StandardOutput.ReadLine()) is not null) + { + output.AppendLine(line); + + // Blank lines separate vswhere instance blocks; they are preserved in the + // captured output for the parser but skipped here because the console service + // rejects empty/whitespace text. + if (!string.IsNullOrWhiteSpace(line)) + { + _consoleService.Write(ConsoleMessageKind.Info, line); + } + } + + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (!string.IsNullOrWhiteSpace(error)) + { + _consoleService.Write(ConsoleMessageKind.Error, error.Trim()); + } + + if (process.ExitCode != 0) + { + _consoleService.Write(ConsoleMessageKind.Warning, $"vswhere.exe exited with code {process.ExitCode}."); + } + + return output.ToString(); + } + catch (Exception ex) when (ex is IOException or System.ComponentModel.Win32Exception or UnauthorizedAccessException) + { + _consoleService.Write(ConsoleMessageKind.Error, $"Failed to run vswhere.exe: {ex.Message}"); + return null; + } + } + + private IReadOnlyList EnumerateHiveFolders() + { + if (!Directory.Exists(_visualStudioLocalAppDataPath)) + { + return []; + } + + try + { + return Directory + .EnumerateDirectories(_visualStudioLocalAppDataPath) + .Select(path => new VisualStudioHiveFolder(Path.GetFileName(path), path)) + .ToArray(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException) + { + _consoleService.Write(ConsoleMessageKind.Warning, $"Failed to enumerate Visual Studio hive folders: {ex.Message}"); + return []; + } + } + + private static string DefaultLocalAppDataPath() + => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "VisualStudio"); + + private static string DefaultVsWherePath() + { + string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + return Path.Combine(programFilesX86, "Microsoft Visual Studio", "Installer", "vswhere.exe"); + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/PackageJsonSerializer.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/PackageJsonSerializer.cs new file mode 100644 index 0000000..cc108b0 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/PackageJsonSerializer.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.Services; + +public static class PackageJsonSerializer +{ + public static JsonSerializerOptions DefaultOptions { get; } = new(JsonSerializerDefaults.General) + { + WriteIndented = true + }; + + public static string Serialize(WingetPackage package) + { + ArgumentNullException.ThrowIfNull(package); + return JsonSerializer.Serialize(package, DefaultOptions); + } + + public static WingetPackage? Deserialize(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + return JsonSerializer.Deserialize(json, DefaultOptions); + } + + /// + /// Creates a deep copy of by round-tripping it through JSON. + /// Polymorphic app entries are preserved. + /// + public static WingetPackage Clone(WingetPackage package) + { + ArgumentNullException.ThrowIfNull(package); + return Deserialize(Serialize(package)) + ?? throw new InvalidOperationException("Failed to clone package."); + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/PathShortener.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/PathShortener.cs new file mode 100644 index 0000000..af62512 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/PathShortener.cs @@ -0,0 +1,37 @@ +namespace WingetPackageEditor.Core.Services; + +/// +/// Produces compact, middle-elided representations of long file-system paths for display. +/// +public static class PathShortener +{ + /// + /// Shortens a path by eliding its middle, e.g. + /// C:\Program Files\Microsoft Visual Studio\18\Insiders becomes + /// C:\Pro ... io\18\Insiders. + /// + /// The path to shorten. + /// The maximum length of the returned string. + /// The shortened path, or the original when it already fits. + public static string Shorten(string? path, int maxLength = 28) + { + if (string.IsNullOrEmpty(path) || path.Length <= maxLength) + { + return path ?? string.Empty; + } + + const string ellipsis = " ... "; + int budget = maxLength - ellipsis.Length; + if (budget <= 2) + { + return path[..maxLength]; + } + + int headLength = Math.Max(3, budget / 3); + int tailLength = budget - headLength; + + string head = path[..headLength]; + string tail = path[^tailLength..]; + return $"{head}{ellipsis}{tail}"; + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/VisualStudioDiscoveryParser.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/VisualStudioDiscoveryParser.cs new file mode 100644 index 0000000..c2cdae3 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/VisualStudioDiscoveryParser.cs @@ -0,0 +1,265 @@ +using System.Globalization; +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.Services; + +/// +/// Parses vswhere.exe output and maps it onto +/// records, correlating Visual Studio data hives discovered on disk. +/// +public static class VisualStudioDiscoveryParser +{ + /// + /// Splits raw vswhere output into per-instance key/value blocks. + /// + /// The raw standard-output text emitted by vswhere. + /// One dictionary per discovered instance block. + public static IReadOnlyList> ParseBlocks(string? output) + { + List> blocks = []; + if (string.IsNullOrWhiteSpace(output)) + { + return blocks; + } + + Dictionary current = new(StringComparer.OrdinalIgnoreCase); + + void FlushBlock() + { + if (current.ContainsKey("instanceId")) + { + blocks.Add(current); + } + + current = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + foreach (string rawLine in output.Replace("\r\n", "\n").Split('\n')) + { + string line = rawLine.TrimEnd(); + if (line.Length == 0) + { + FlushBlock(); + continue; + } + + int separator = line.IndexOf(':'); + if (separator <= 0) + { + // Header/banner lines (no "key: value" shape) are ignored. + continue; + } + + string key = line[..separator].Trim(); + string value = line[(separator + 1)..].Trim(); + if (key.Length == 0) + { + continue; + } + + current[key] = value; + } + + FlushBlock(); + return blocks; + } + + /// + /// Maps a parsed vswhere block onto a , + /// attaching any hive folders that belong to the instance. + /// + /// A parsed key/value block. + /// All Visual Studio hive folders discovered on disk. + /// The mapped instance, or when the block has no instance id. + public static VisualStudioInstanceInfo? MapInstance( + IReadOnlyDictionary block, + IReadOnlyList hiveFolders) + { + ArgumentNullException.ThrowIfNull(block); + ArgumentNullException.ThrowIfNull(hiveFolders); + + if (!block.TryGetValue("instanceId", out string? instanceId) || string.IsNullOrWhiteSpace(instanceId)) + { + return null; + } + + string version = GetValue(block, "installationVersion"); + string shortVersion = ToShortVersion(version); + string productId = GetValue(block, "productId"); + string displayName = GetValue(block, "displayName"); + string edition = MapEdition(productId, displayName); + string channelId = GetValue(block, "channelId"); + VisualStudioChannel channel = MapChannel(channelId); + string year = MapYear(block, shortVersion); + string installationPath = GetValue(block, "installationPath"); + bool isPrerelease = string.Equals(GetValue(block, "isPrerelease"), "1", StringComparison.Ordinal); + DateTimeOffset? installDate = ParseDate(GetValue(block, "installDate")); + + IReadOnlyList hives = CorrelateHives(hiveFolders, shortVersion, instanceId); + + return new VisualStudioInstanceInfo( + InstanceId: instanceId, + DisplayName: string.IsNullOrWhiteSpace(displayName) ? $"Visual Studio {edition} {year}".Trim() : displayName, + Year: year, + Edition: edition, + Channel: channel, + ChannelId: channelId, + Version: version, + ShortVersion: shortVersion, + InstallDate: installDate, + InstallationPath: installationPath, + ProductId: productId, + IsPrerelease: isPrerelease, + Hives: hives); + } + + /// + /// Selects the hive folders that belong to the instance identified by + /// and . + /// + public static IReadOnlyList CorrelateHives( + IReadOnlyList hiveFolders, + string shortVersion, + string instanceId) + { + ArgumentNullException.ThrowIfNull(hiveFolders); + + string prefix = $"{shortVersion}_{instanceId}"; + List hives = []; + + foreach (VisualStudioHiveFolder folder in hiveFolders) + { + if (!folder.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string remainder = folder.Name[prefix.Length..]; + bool isExperimental = remainder.Equals("Exp", StringComparison.OrdinalIgnoreCase); + + // Accept the main hive (no suffix) and the experimental hive. + if (remainder.Length != 0 && !isExperimental) + { + continue; + } + + string settingsFilePath = System.IO.Path.Combine(folder.Path, "Settings", "CurrentSettings.vssettings"); + hives.Add(new VisualStudioHiveInfo(folder.Name, folder.Path, settingsFilePath, isExperimental)); + } + + return hives; + } + + /// + /// Classifies a raw channelId into a . + /// + public static VisualStudioChannel MapChannel(string? channelId) + { + if (string.IsNullOrWhiteSpace(channelId)) + { + return VisualStudioChannel.Unknown; + } + + string lowered = channelId.ToLowerInvariant(); + if (lowered.Contains("canary", StringComparison.Ordinal)) + { + return VisualStudioChannel.Canary; + } + + if (lowered.Contains("main", StringComparison.Ordinal)) + { + return VisualStudioChannel.Main; + } + + if (lowered.Contains("preview", StringComparison.Ordinal)) + { + return VisualStudioChannel.Preview; + } + + if (lowered.Contains("release", StringComparison.Ordinal)) + { + return VisualStudioChannel.Release; + } + + return VisualStudioChannel.Unknown; + } + + /// + /// Derives the release year (2019/2022/2026) from the product line version. + /// + public static string MapYear(IReadOnlyDictionary block, string shortVersion) + { + ArgumentNullException.ThrowIfNull(block); + + string lineVersion = GetValue(block, "catalog_productLineVersion"); + if (string.IsNullOrWhiteSpace(lineVersion)) + { + lineVersion = shortVersion.Split('.').FirstOrDefault() ?? string.Empty; + } + + return lineVersion switch + { + "16" => "2019", + "17" => "2022", + "18" => "2026", + _ => string.IsNullOrWhiteSpace(lineVersion) ? "Unknown" : lineVersion + }; + } + + /// + /// Derives the edition (Community/Professional/Enterprise/...) from the product id. + /// + public static string MapEdition(string? productId, string? displayName) + { + string? edition = productId?.Split('.').LastOrDefault(); + if (!string.IsNullOrWhiteSpace(edition)) + { + return edition; + } + + foreach (string candidate in new[] { "Enterprise", "Professional", "Community", "BuildTools" }) + { + if (!string.IsNullOrEmpty(displayName) + && displayName.Contains(candidate, StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } + + return "Unknown"; + } + + /// + /// Converts a full installation version (e.g. 18.7.11822.327) into the short + /// major.0 form used by Visual Studio hive folder names (e.g. 18.0). + /// + public static string ToShortVersion(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + { + return string.Empty; + } + + string major = version.Split('.').FirstOrDefault() ?? string.Empty; + return major.Length == 0 ? string.Empty : $"{major}.0"; + } + + private static DateTimeOffset? ParseDate(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (DateTimeOffset.TryParse(value, CultureInfo.CurrentCulture, DateTimeStyles.None, out DateTimeOffset parsed) + || DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + { + return parsed; + } + + return null; + } + + private static string GetValue(IReadOnlyDictionary block, string key) + => block.TryGetValue(key, out string? value) ? value : string.Empty; +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/WingetListScanner.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/WingetListScanner.cs new file mode 100644 index 0000000..305e245 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/Services/WingetListScanner.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using System.Text; + +namespace WingetPackageEditor.Core.Services; + +/// +/// Discovers installed winget package Ids by running winget list and parsing its tabular +/// output. All output is streamed to the console; failures are non-fatal and yield an empty result. +/// +public sealed class WingetListScanner(IConsoleService consoleService) + : IInstalledAppScanner +{ + private readonly IConsoleService _consoleService = consoleService ?? throw new ArgumentNullException(nameof(consoleService)); + + public IReadOnlyList GetInstalledWingetIds() + { + ProcessStartInfo startInfo = new() + { + FileName = "winget", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8 + }; + + foreach (string argument in new[] { "list", "--disable-interactivity" }) + { + startInfo.ArgumentList.Add(argument); + } + + StringBuilder output = new(); + + try + { + _consoleService.Write(ConsoleMessageKind.Command, "Running 'winget list'..."); + using Process process = new() { StartInfo = startInfo }; + process.OutputDataReceived += (_, args) => + { + if (args.Data is not null) + { + output.AppendLine(args.Data); + _consoleService.Write(ConsoleMessageKind.Info, args.Data); + } + }; + process.ErrorDataReceived += (_, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + _consoleService.Write(ConsoleMessageKind.Error, args.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + } + catch (Exception ex) + { + _consoleService.Write(ConsoleMessageKind.Error, $"'winget list' failed: {ex.Message}"); + return []; + } + + return ParseIds(output.ToString()); + } + + /// + /// Parses the Id column out of winget list output. The Id column spans from the start of + /// the "Id" header to the start of the following header. + /// + public static IReadOnlyList ParseIds(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return []; + } + + string[] lines = output.Replace("\r\n", "\n").Split('\n'); + + int headerIndex = Array.FindIndex(lines, IsHeader); + if (headerIndex < 0) + { + return []; + } + + string header = lines[headerIndex]; + int idStart = header.IndexOf("Id", StringComparison.Ordinal); + if (idStart < 0) + { + return []; + } + + int idEnd = FindNextColumnStart(header, idStart); + + List ids = []; + for (int i = headerIndex + 1; i < lines.Length; i++) + { + string line = lines[i]; + if (string.IsNullOrWhiteSpace(line) || line.StartsWith('-')) + { + continue; + } + + if (idStart >= line.Length) + { + continue; + } + + int length = Math.Min(idEnd, line.Length) - idStart; + string id = line.Substring(idStart, Math.Max(0, length)).Trim(); + if (id.Length > 0) + { + ids.Add(id); + } + } + + return ids; + } + + private static bool IsHeader(string line) + => line.Contains("Id", StringComparison.Ordinal) + && line.Contains("Version", StringComparison.Ordinal) + && line.Contains("Name", StringComparison.Ordinal); + + private static int FindNextColumnStart(string header, int fromColumn) + { + // Columns are separated by two-or-more spaces; the next column starts after the gap. + int gap = header.IndexOf(" ", fromColumn, StringComparison.Ordinal); + if (gap < 0) + { + return header.Length; + } + + int next = gap; + while (next < header.Length && header[next] == ' ') + { + next++; + } + + return next < header.Length ? next : header.Length; + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/AppEntryViewModel.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/AppEntryViewModel.cs new file mode 100644 index 0000000..292945b --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/AppEntryViewModel.cs @@ -0,0 +1,79 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.ViewModels; + +public sealed partial class AppEntryViewModel : ObservableObject +{ + public AppEntryViewModel(AppEntry model) + { + Model = model ?? throw new ArgumentNullException(nameof(model)); + } + + public AppEntry Model { get; } + + public string EntryType => Model switch + { + VisualStudioEntry => "Visual Studio", + VSCodeEntry => "VS Code", + GenericAppEntry => "Generic", + _ => Model.GetType().Name + }; + + public string Id + { + get => Model.Id; + set => SetProperty(Model.Id, value, Model, static (model, newValue) => model.Id = newValue); + } + + public string DisplayName + { + get => Model.DisplayName; + set + { + if (SetProperty(Model.DisplayName, value, Model, static (model, newValue) => model.DisplayName = newValue)) + { + OnPropertyChanged(nameof(TreeText)); + } + } + } + + public AppAction Action + { + get => Model.Action; + set => SetProperty(Model.Action, value, Model, static (model, newValue) => model.Action = newValue); + } + + public AppSource Source + { + get => Model.Source; + set => SetProperty(Model.Source, value, Model, static (model, newValue) => model.Source = newValue); + } + + public string? Version + { + get => Model.Version; + set => SetProperty(Model.Version, value, Model, static (model, newValue) => model.Version = newValue); + } + + public AppScope Scope + { + get => Model.Scope; + set => SetProperty(Model.Scope, value, Model, static (model, newValue) => model.Scope = newValue); + } + + public bool AllowPrerelease + { + get => Model.AllowPrerelease; + set => SetProperty(Model.AllowPrerelease, value, Model, static (model, newValue) => model.AllowPrerelease = newValue); + } + + public string TreeText => string.IsNullOrWhiteSpace(DisplayName) ? Id : DisplayName; + + public string ExtensionsSummary => Model switch + { + VSCodeEntry code => $"{code.Extensions.Count} VS Code extension(s)", + VisualStudioEntry visualStudio => $"{visualStudio.Extensions.Count} VSIX extension(s)", + _ => "" + }; +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/MainViewModel.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..aca86f8 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/MainViewModel.cs @@ -0,0 +1,561 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using WingetPackageEditor.Core.Models; +using WingetPackageEditor.Core.Services; + +namespace WingetPackageEditor.Core.ViewModels; + +public sealed partial class MainViewModel : ObservableObject +{ + private readonly ICatalogService _catalogService; + private readonly IConsoleService _consoleService; + private readonly IVisualStudioDiscoveryService _visualStudioDiscoveryService; + private readonly IPackageStore _packageStore; + private readonly IInstalledAppScanner _installedAppScanner; + private readonly IPackageEditorDialogService _dialogService; + private bool _suppressAutoSave; + + [ObservableProperty] + private PackageViewModel? _selectedPackage; + + [ObservableProperty] + private AppEntryViewModel? _selectedApp; + + [ObservableProperty] + private NavigationNodeViewModel? _selectedNavigationNode; + + [ObservableProperty] + private string _statusText = "Ready"; + + public MainViewModel( + ICatalogService catalogService, + IConsoleService consoleService, + IVisualStudioDiscoveryService visualStudioDiscoveryService, + IPackageStore packageStore, + IInstalledAppScanner installedAppScanner, + IPackageEditorDialogService dialogService) + { + _catalogService = catalogService ?? throw new ArgumentNullException(nameof(catalogService)); + _consoleService = consoleService ?? throw new ArgumentNullException(nameof(consoleService)); + _visualStudioDiscoveryService = visualStudioDiscoveryService ?? throw new ArgumentNullException(nameof(visualStudioDiscoveryService)); + _packageStore = packageStore ?? throw new ArgumentNullException(nameof(packageStore)); + _installedAppScanner = installedAppScanner ?? throw new ArgumentNullException(nameof(installedAppScanner)); + _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); + + ConsoleMessages = _consoleService.Messages; + VisualStudioBranch = new VisualStudioBranchViewModel(_visualStudioDiscoveryService.DiscoverInstances()); + LoadPackages(); + } + + public ObservableCollection Packages { get; } = []; + + public ObservableCollection CurrentApps { get; } = []; + + public ObservableCollection NavigationRoots { get; } = []; + + public ObservableCollection ConsoleMessages { get; } + + public VisualStudioBranchViewModel VisualStudioBranch { get; } + + public void WriteConsole(ConsoleMessageKind kind, string text) => _consoleService.Write(kind, text); + + public event EventHandler? ViewCommandRequested; + + partial void OnSelectedPackageChanged(PackageViewModel? value) + { + RefreshCurrentApps(); + AddAppCommand.NotifyCanExecuteChanged(); + ExportCommand.NotifyCanExecuteChanged(); + RemovePackageCommand.NotifyCanExecuteChanged(); + UpdateCurrentPackageCommand.NotifyCanExecuteChanged(); + ApplyNowCommand.NotifyCanExecuteChanged(); + GenerateBundleFolderCommand.NotifyCanExecuteChanged(); + UpdateStatus(); + } + + partial void OnSelectedAppChanged(AppEntryViewModel? value) + { + RemoveAppCommand.NotifyCanExecuteChanged(); + PropertiesCommand.NotifyCanExecuteChanged(); + UpdateStatus(); + } + + partial void OnSelectedNavigationNodeChanged(NavigationNodeViewModel? value) + { + switch (value?.Value) + { + case PackageViewModel package: + SelectedPackage = package; + SelectedApp = null; + break; + case AppEntryViewModel app: + SelectedPackage = Packages.FirstOrDefault(package => package.Apps.Contains(app)); + SelectedApp = app; + break; + case VisualStudioBranchViewModel branch: + SelectedPackage = null; + SelectedApp = null; + StatusText = $"Visual Studio: {branch.Rows.Count} installation/hive item(s)"; + break; + case VisualStudioVersionViewModel version: + SelectedPackage = null; + SelectedApp = null; + StatusText = $"Visual Studio {version.Year}: {version.Rows.Count} installation/hive item(s)"; + break; + case VisualStudioSkuComboViewModel combo: + SelectedPackage = null; + SelectedApp = null; + StatusText = $"Visual Studio {combo.ComboLabel}: {combo.Rows.Count} installation/hive item(s)"; + break; + case VisualStudioInstanceViewModel instance: + SelectedPackage = null; + SelectedApp = null; + StatusText = $"Visual Studio instance: {instance.Model.DisplayName} ({instance.Model.Version})"; + break; + default: + SelectedApp = null; + break; + } + } + + [RelayCommand] + private void NewPackage() + { + PackageViewModel package = new(new WingetPackage + { + Name = $"New Package {Packages.Count + 1}", + Author = Environment.UserName + }); + + AddPackage(package); + SelectedPackage = package; + SelectedApp = null; + RebuildNavigation(); + SavePackage(package); + _consoleService.Write(ConsoleMessageKind.Command, $"Created package '{package.Name}'."); + } + + [RelayCommand] + private void NewFromExistingPackage() + { + if (Packages.Count == 0) + { + _consoleService.Write(ConsoleMessageKind.Warning, "No existing packages to copy from."); + return; + } + + NewFromExistingResult? result = _dialogService.AskNewFromExisting( + Packages.Select(package => package.Model).ToList()); + + if (result is null) + { + return; + } + + WingetPackage clone = WingetPackage.Clone(result.SourcePackage); + clone.Id = Guid.NewGuid().ToString("N"); + clone.Name = result.NewName; + + PackageViewModel package = new(clone); + AddPackage(package); + SelectedPackage = package; + SelectedApp = null; + RebuildNavigation(); + SavePackage(package); + _consoleService.Write(ConsoleMessageKind.Command, $"Created package '{package.Name}' from '{result.SourcePackage.Name}'."); + } + + [RelayCommand(CanExecute = nameof(HasSelectedPackage))] + private void RemovePackage() + { + PackageViewModel package = SelectedPackage!; + if (!_dialogService.ConfirmRemovePackage(package.Name)) + { + return; + } + + _packageStore.Delete(package.Model); + Packages.Remove(package); + SelectedApp = null; + SelectedPackage = Packages.FirstOrDefault(); + RebuildNavigation(); + _consoleService.Write(ConsoleMessageKind.Command, $"Removed package '{package.Name}' (backup written)."); + } + + [RelayCommand(CanExecute = nameof(HasSelectedPackage))] + private void UpdateCurrentPackage() + { + PackageViewModel package = SelectedPackage!; + IReadOnlyList installedIds = _installedAppScanner.GetInstalledWingetIds(); + HashSet installed = new(installedIds, StringComparer.OrdinalIgnoreCase); + HashSet existing = new( + package.Model.Apps.Select(app => app.Id), + StringComparer.OrdinalIgnoreCase); + + int added = 0; + _suppressAutoSave = true; + try + { + foreach (AppEntry template in _catalogService.GetWellKnownApps()) + { + if (!installed.Contains(template.Id) || existing.Contains(template.Id)) + { + continue; + } + + package.AddApp(WingetPackage.Clone(new WingetPackage { Apps = [template] }).Apps[0]); + existing.Add(template.Id); + added++; + } + } + finally + { + _suppressAutoSave = false; + } + + if (added > 0) + { + RefreshCurrentApps(); + RebuildNavigation(); + SavePackage(package); + } + + _consoleService.Write(ConsoleMessageKind.Command, + $"Update current package: added {added} installed app(s) to '{package.Name}'."); + } + + [RelayCommand] + private void OpenPackage() + { + _consoleService.Write(ConsoleMessageKind.Command, "Import package command executed (V0 placeholder)."); + } + + [RelayCommand(CanExecute = nameof(HasSelectedPackage))] + private void Export() + { + _consoleService.Write(ConsoleMessageKind.Command, $"Export YAML+Script command executed for '{SelectedPackage!.Name}' (V0 placeholder)."); + } + + [RelayCommand(CanExecute = nameof(HasSelectedPackage))] + private void AddApp() + { + AppEntry? configured = _dialogService.PickAndConfigureApp(_catalogService.GetWellKnownApps()); + if (configured is null) + { + return; + } + + AppEntryViewModel app = SelectedPackage!.AddApp(configured); + SelectedApp = app; + RefreshCurrentApps(); + RebuildNavigation(); + SavePackage(SelectedPackage); + _consoleService.Write(ConsoleMessageKind.Command, $"Added app '{app.DisplayName}' to '{SelectedPackage.Name}'."); + } + + [RelayCommand(CanExecute = nameof(HasSelectedApp))] + private void RemoveApp() + { + AppEntryViewModel app = SelectedApp!; + PackageViewModel? package = SelectedPackage; + if (package is null) + { + return; + } + + package.RemoveApp(app); + SelectedApp = null; + RefreshCurrentApps(); + RebuildNavigation(); + SavePackage(package); + _consoleService.Write(ConsoleMessageKind.Command, $"Removed app '{app.DisplayName}' from '{package.Name}'."); + } + + [RelayCommand(CanExecute = nameof(HasSelectedApp))] + private void Properties() + { + _consoleService.Write(ConsoleMessageKind.Command, $"Properties command executed for '{SelectedApp!.DisplayName}' (V0 placeholder)."); + } + + [RelayCommand(CanExecute = nameof(HasSelectedPackage))] + private void ApplyNow() + { + _consoleService.Write(ConsoleMessageKind.Command, $"Apply Now command executed for '{SelectedPackage!.Name}' (V0 placeholder)."); + } + + [RelayCommand(CanExecute = nameof(HasSelectedPackage))] + private void GenerateBundleFolder() + { + _consoleService.Write(ConsoleMessageKind.Command, $"Generate Bundle Folder command executed for '{SelectedPackage!.Name}' (V0 placeholder)."); + } + + [RelayCommand] + private void Options() + { + RequestViewCommand(ViewCommandKind.ShowOptions, "Options dialog requested."); + } + + [RelayCommand] + private void ExpandAllNodes() + { + RequestViewCommand(ViewCommandKind.ExpandAllNodes, "Expand all tree nodes requested."); + } + + [RelayCommand] + private void CollapseSelectedNode() + { + RequestViewCommand(ViewCommandKind.CollapseSelectedNode, "Collapse selected tree node requested."); + } + + [RelayCommand] + private void ExpandSelectedNode() + { + RequestViewCommand(ViewCommandKind.ExpandSelectedNode, "Expand selected tree node requested."); + } + + [RelayCommand] + private void Quit() + { + _consoleService.Write(ConsoleMessageKind.Command, "Quit command executed."); + } + + private bool HasSelectedPackage() => SelectedPackage is not null; + + private bool HasSelectedApp() => SelectedApp is not null; + + private void LoadPackages() + { + IReadOnlyList stored = _packageStore.LoadAll(); + + if (stored.Count == 0) + { + PackageViewModel demo = new(_catalogService.CreateDemoPackage()); + AddPackage(demo); + SavePackage(demo); + SelectedPackage = demo; + RebuildNavigation(); + _consoleService.Write(ConsoleMessageKind.Info, "Loaded V0 demo package."); + return; + } + + foreach (WingetPackage model in stored.OrderBy(package => package.Name, StringComparer.OrdinalIgnoreCase)) + { + AddPackage(new PackageViewModel(model)); + } + + SelectedPackage = Packages.FirstOrDefault(); + RebuildNavigation(); + _consoleService.Write(ConsoleMessageKind.Info, $"Loaded {Packages.Count} package(s) from disk."); + } + + private void AddPackage(PackageViewModel package) + { + Packages.Add(package); + AttachAutoSave(package); + } + + private void AttachAutoSave(PackageViewModel package) + { + package.PropertyChanged += (_, _) => SavePackage(package); + package.Apps.CollectionChanged += (_, args) => + { + if (args.NewItems is not null) + { + foreach (AppEntryViewModel app in args.NewItems.OfType()) + { + app.PropertyChanged += (_, _) => SavePackage(package); + } + } + }; + + foreach (AppEntryViewModel app in package.Apps) + { + app.PropertyChanged += (_, _) => SavePackage(package); + } + } + + private void SavePackage(PackageViewModel package) + { + if (_suppressAutoSave) + { + return; + } + + _packageStore.Save(package.Model); + } + + private void RefreshCurrentApps() + { + CurrentApps.Clear(); + if (SelectedPackage is null) + { + return; + } + + foreach (AppEntryViewModel app in SelectedPackage.Apps) + { + CurrentApps.Add(app); + } + } + + private void RebuildNavigation() + { + NavigationRoots.Clear(); + + foreach (PackageViewModel package in Packages) + { + string packageKey = $"package:{package.Name}"; + NavigationNodeViewModel packageNode = new( + package.TreeText, + NavigationNodeKind.Package, + package, + packageKey); + + foreach (AppEntryViewModel app in package.Apps) + { + NavigationNodeViewModel appNode = new( + app.TreeText, + NavigationNodeKind.App, + app, + $"{packageKey}/app:{app.Id}"); + AddExtensionNodes(appNode, app.Model); + packageNode.Children.Add(appNode); + } + + packageNode.Children.Add(CreateVisualStudioNode(packageKey)); + NavigationRoots.Add(packageNode); + } + + SelectedNavigationNode = FindSelectedNavigationNode(); + } + + private NavigationNodeViewModel CreateVisualStudioNode(string packageKey) + { + string visualStudioKey = $"{packageKey}/vs"; + NavigationNodeViewModel root = new( + VisualStudioBranch.TreeText, + NavigationNodeKind.VisualStudioRoot, + VisualStudioBranch, + visualStudioKey); + + foreach (VisualStudioVersionViewModel version in VisualStudioBranch.Versions) + { + string versionKey = $"{visualStudioKey}/year:{version.Year}"; + NavigationNodeViewModel versionNode = new( + version.TreeText, + NavigationNodeKind.VisualStudioVersion, + version, + versionKey); + + foreach (VisualStudioSkuComboViewModel combo in version.SkuCombos) + { + string comboKey = $"{versionKey}/sku:{combo.ComboLabel}"; + NavigationNodeViewModel comboNode = new( + combo.TreeText, + NavigationNodeKind.VisualStudioSkuCombo, + combo, + comboKey); + + foreach (VisualStudioInstanceViewModel instance in combo.Instances) + { + comboNode.Children.Add(new NavigationNodeViewModel( + instance.TreeText, + NavigationNodeKind.VisualStudioInstance, + instance, + $"{comboKey}/instance:{instance.Id}")); + } + + versionNode.Children.Add(comboNode); + } + + root.Children.Add(versionNode); + } + + return root; + } + + private static void AddExtensionNodes(NavigationNodeViewModel appNode, AppEntry app) + { + switch (app) + { + case VSCodeEntry code: + foreach (string extension in code.Extensions) + { + appNode.Children.Add(new NavigationNodeViewModel( + extension, + NavigationNodeKind.Extension, + extension, + $"{appNode.Key}/extension:{extension}")); + } + break; + case VisualStudioEntry visualStudio: + foreach (VsixReference extension in visualStudio.Extensions) + { + appNode.Children.Add(new NavigationNodeViewModel( + extension.Identifier, + NavigationNodeKind.Extension, + extension, + $"{appNode.Key}/extension:{extension.Identifier}")); + } + break; + } + } + + private void UpdateStatus() + { + StatusText = (SelectedPackage, SelectedApp) switch + { + (_, { } app) => $"Selected app: {app.DisplayName} ({app.Id})", + ({ } package, _) => $"Selected package: {package.Name} ({package.Apps.Count} app(s))", + _ => "Ready" + }; + } + + private NavigationNodeViewModel? FindSelectedNavigationNode() + { + if (SelectedApp is not null) + { + return NavigationRoots + .SelectMany(packageNode => packageNode.Children) + .FirstOrDefault(appNode => ReferenceEquals(appNode.Value, SelectedApp)); + } + + if (SelectedPackage is not null) + { + return NavigationRoots.FirstOrDefault(packageNode => ReferenceEquals(packageNode.Value, SelectedPackage)); + } + + if (SelectedNavigationNode is not null) + { + return FindNodeByKey(NavigationRoots, SelectedNavigationNode.Key); + } + + return null; + } + + private static NavigationNodeViewModel? FindNodeByKey(IEnumerable nodes, string key) + { + foreach (NavigationNodeViewModel node in nodes) + { + if (StringComparer.Ordinal.Equals(node.Key, key)) + { + return node; + } + + NavigationNodeViewModel? child = FindNodeByKey(node.Children, key); + if (child is not null) + { + return child; + } + } + + return null; + } + + private void RequestViewCommand(ViewCommandKind kind, string message) + { + _consoleService.Write(ConsoleMessageKind.Command, message); + ViewCommandRequested?.Invoke(this, kind); + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/NavigationNodeViewModel.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/NavigationNodeViewModel.cs new file mode 100644 index 0000000..bf3ebd7 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/NavigationNodeViewModel.cs @@ -0,0 +1,37 @@ +using System.Collections.ObjectModel; + +namespace WingetPackageEditor.Core.ViewModels; + +public sealed class NavigationNodeViewModel +{ + public NavigationNodeViewModel(string text, NavigationNodeKind kind, object value, string key) + { + Text = text; + Kind = kind; + Value = value; + Key = key; + } + + public string Text { get; } + + public NavigationNodeKind Kind { get; } + + public object Value { get; } + + public string Key { get; } + + public ObservableCollection Children { get; } = []; + + public override string ToString() => Text; +} + +public enum NavigationNodeKind +{ + Package, + App, + Extension, + VisualStudioRoot, + VisualStudioVersion, + VisualStudioSkuCombo, + VisualStudioInstance +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/PackageViewModel.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/PackageViewModel.cs new file mode 100644 index 0000000..81be5a6 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/PackageViewModel.cs @@ -0,0 +1,68 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.ViewModels; + +public sealed partial class PackageViewModel : ObservableObject +{ + public PackageViewModel(WingetPackage model) + { + Model = model ?? throw new ArgumentNullException(nameof(model)); + Apps = new ObservableCollection( + model.Apps.Select(app => new AppEntryViewModel(app))); + } + + public WingetPackage Model { get; } + + public ObservableCollection Apps { get; } + + public string Name + { + get => Model.Name; + set + { + if (SetProperty(Model.Name, value, Model, static (model, newValue) => model.Name = newValue)) + { + OnPropertyChanged(nameof(TreeText)); + } + } + } + + public string? Description + { + get => Model.Description; + set => SetProperty(Model.Description, value, Model, static (model, newValue) => model.Description = newValue); + } + + public string? Author + { + get => Model.Author; + set => SetProperty(Model.Author, value, Model, static (model, newValue) => model.Author = newValue); + } + + public string Version + { + get => Model.Version; + set => SetProperty(Model.Version, value, Model, static (model, newValue) => model.Version = newValue); + } + + public string TreeText => string.IsNullOrWhiteSpace(Name) ? "(Untitled package)" : Name; + + public AppEntryViewModel AddApp(AppEntry app) + { + ArgumentNullException.ThrowIfNull(app); + Model.Apps.Add(app); + AppEntryViewModel viewModel = new(app); + Apps.Add(viewModel); + return viewModel; + } + + public bool RemoveApp(AppEntryViewModel app) + { + ArgumentNullException.ThrowIfNull(app); + bool removedFromModel = Model.Apps.Remove(app.Model); + bool removedFromView = Apps.Remove(app); + return removedFromModel || removedFromView; + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/ViewCommandKind.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/ViewCommandKind.cs new file mode 100644 index 0000000..78a7272 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/ViewCommandKind.cs @@ -0,0 +1,9 @@ +namespace WingetPackageEditor.Core.ViewModels; + +public enum ViewCommandKind +{ + ExpandAllNodes, + CollapseSelectedNode, + ExpandSelectedNode, + ShowOptions +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/VisualStudioBranchViewModel.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/VisualStudioBranchViewModel.cs new file mode 100644 index 0000000..d2a6050 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/VisualStudioBranchViewModel.cs @@ -0,0 +1,109 @@ +using System.Collections.ObjectModel; +using WingetPackageEditor.Core.Models; + +namespace WingetPackageEditor.Core.ViewModels; + +/// +/// A concrete Visual Studio installation node (leaf of the navigation tree). +/// +public sealed class VisualStudioInstanceViewModel +{ + public VisualStudioInstanceViewModel(VisualStudioInstanceInfo model) + { + Model = model ?? throw new ArgumentNullException(nameof(model)); + Rows = [.. VisualStudioInstallationRowViewModel.CreateRows(model)]; + } + + public VisualStudioInstanceInfo Model { get; } + + public IReadOnlyList Rows { get; } + + public string Id => Model.InstanceId; + + public string TreeText => string.IsNullOrWhiteSpace(Model.Version) + ? Model.InstanceId + : $"{Model.Version} ({Model.InstanceId})"; +} + +/// +/// A Channel-Edition grouping node (e.g. Preview-Enterprise). +/// +public sealed class VisualStudioSkuComboViewModel +{ + public VisualStudioSkuComboViewModel(string comboLabel, IReadOnlyList instances) + { + ComboLabel = comboLabel ?? throw new ArgumentNullException(nameof(comboLabel)); + ArgumentNullException.ThrowIfNull(instances); + + foreach (VisualStudioInstanceInfo instance in instances) + { + Instances.Add(new VisualStudioInstanceViewModel(instance)); + } + + Rows = [.. Instances.SelectMany(instance => instance.Rows)]; + } + + public string ComboLabel { get; } + + public string TreeText => ComboLabel; + + public ObservableCollection Instances { get; } = []; + + public IReadOnlyList Rows { get; } +} + +/// +/// A Visual Studio version grouping node (2019/2022/2026). +/// +public sealed class VisualStudioVersionViewModel +{ + public VisualStudioVersionViewModel(string year, IReadOnlyList instances) + { + Year = year ?? throw new ArgumentNullException(nameof(year)); + ArgumentNullException.ThrowIfNull(instances); + + foreach (IGrouping combo in instances + .GroupBy(instance => instance.SkuComboLabel, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) + { + SkuCombos.Add(new VisualStudioSkuComboViewModel(combo.Key, [.. combo])); + } + + Rows = [.. SkuCombos.SelectMany(combo => combo.Rows)]; + } + + public string Year { get; } + + public string TreeText => Year; + + public ObservableCollection SkuCombos { get; } = []; + + public IReadOnlyList Rows { get; } +} + +/// +/// The root "Visual Studio" node nested under each winget package. Holds the discovered +/// installation tree (Version -> SKU-combo -> instance) and a flattened overview. +/// +public sealed class VisualStudioBranchViewModel +{ + public VisualStudioBranchViewModel(IReadOnlyList instances) + { + ArgumentNullException.ThrowIfNull(instances); + + foreach (IGrouping versionGroup in instances + .GroupBy(instance => instance.Year, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(group => group.Key, StringComparer.OrdinalIgnoreCase)) + { + Versions.Add(new VisualStudioVersionViewModel(versionGroup.Key, [.. versionGroup])); + } + + Rows = [.. Versions.SelectMany(version => version.Rows)]; + } + + public string TreeText => "Visual Studio"; + + public ObservableCollection Versions { get; } = []; + + public IReadOnlyList Rows { get; } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/VisualStudioInstallationRowViewModel.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/VisualStudioInstallationRowViewModel.cs new file mode 100644 index 0000000..9d13ffd --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/ViewModels/VisualStudioInstallationRowViewModel.cs @@ -0,0 +1,77 @@ +using WingetPackageEditor.Core.Models; +using WingetPackageEditor.Core.Services; + +namespace WingetPackageEditor.Core.ViewModels; + +/// +/// A single Visual Studio installation/hive row displayed in the overview grid. A main +/// installation produces one row; each experimental hive produces an additional row. +/// +public sealed class VisualStudioInstallationRowViewModel +{ + public VisualStudioInstallationRowViewModel( + VisualStudioInstanceInfo instance, + string hiveName, + string dataPath, + bool isExperimental) + { + ArgumentNullException.ThrowIfNull(instance); + Instance = instance; + HiveName = hiveName; + DataPath = dataPath ?? string.Empty; + IsExperimental = isExperimental; + } + + public VisualStudioInstanceInfo Instance { get; } + + public string SkuName => Instance.DisplayName; + + public string Version => Instance.Version; + + public DateTimeOffset? InstallDate => Instance.InstallDate; + + public string InstallDateDisplay => InstallDate is { } date + ? date.LocalDateTime.ToString("yyyy-MM-dd") + : string.Empty; + + public string InstanceId => Instance.InstanceId; + + public string InstallationPath => Instance.InstallationPath; + + public string DataPath { get; } + + public string HiveName { get; } + + public bool IsExperimental { get; } + + public string InstallationPathDisplay => PathShortener.Shorten(InstallationPath); + + public string DataPathDisplay => string.IsNullOrEmpty(DataPath) + ? "(not created)" + : PathShortener.Shorten(DataPath); + + /// + /// Builds the rows for a single instance: one main row plus one per experimental hive. + /// + public static IEnumerable CreateRows(VisualStudioInstanceInfo instance) + { + ArgumentNullException.ThrowIfNull(instance); + + VisualStudioHiveInfo? main = instance.Hives.FirstOrDefault(hive => !hive.IsExperimental); + string mainHiveName = main?.Name ?? $"{instance.ShortVersion}_{instance.InstanceId}"; + yield return new VisualStudioInstallationRowViewModel( + instance, + mainHiveName, + main?.Path ?? string.Empty, + isExperimental: false); + + foreach (VisualStudioHiveInfo hive in instance.Hives.Where(hive => hive.IsExperimental)) + { + yield return new VisualStudioInstallationRowViewModel( + instance, + hive.Name, + hive.Path, + isExperimental: true); + } + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/WingetPackageEditor.Core.csproj b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/WingetPackageEditor.Core.csproj new file mode 100644 index 0000000..07714b4 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Core/WingetPackageEditor.Core.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/JsonPackageStoreTests.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/JsonPackageStoreTests.cs new file mode 100644 index 0000000..2acf201 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/JsonPackageStoreTests.cs @@ -0,0 +1,74 @@ +using WingetPackageEditor.Core.Models; +using WingetPackageEditor.Core.Services; + +namespace WingetPackageEditor.Tests; + +public sealed class JsonPackageStoreTests +{ + [Fact] + public void SaveAndLoadAll_RoundtripsPackages() + { + using TempDirectory root = new(); + JsonPackageStore store = new(root.Path); + WingetPackage package = new() + { + Name = "Roundtrip", + Apps = [new GenericAppEntry { Id = "Git.Git", DisplayName = "Git" }] + }; + + store.Save(package); + IReadOnlyList loaded = store.LoadAll(); + + WingetPackage restored = Assert.Single(loaded); + Assert.Equal("Roundtrip", restored.Name); + Assert.Equal(package.Id, restored.Id); + Assert.Single(restored.Apps); + } + + [Fact] + public void LoadAll_ReturnsEmpty_WhenNothingSaved() + { + using TempDirectory root = new(); + JsonPackageStore store = new(root.Path); + + Assert.Empty(store.LoadAll()); + } + + [Fact] + public void Delete_WritesBackupAndRemovesPackageFile() + { + using TempDirectory root = new(); + JsonPackageStore store = new(root.Path); + WingetPackage package = new() { Name = "ToRemove" }; + store.Save(package); + + store.Delete(package); + + Assert.Empty(store.LoadAll()); + string[] backups = Directory.GetFiles(Path.Combine(root.Path, "Backups"), "WPE*.bak"); + Assert.Single(backups); + } + + private sealed class TempDirectory : IDisposable + { + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "wpe-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/MainViewModelTests.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/MainViewModelTests.cs new file mode 100644 index 0000000..4eed38e --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/MainViewModelTests.cs @@ -0,0 +1,418 @@ +using WingetPackageEditor.Core.Models; +using WingetPackageEditor.Core.Services; +using WingetPackageEditor.Core.ViewModels; + +namespace WingetPackageEditor.Tests; + +public sealed class MainViewModelTests +{ + [Fact] + public void Constructor_LoadsDemoPackageAndWritesConsoleMessage() + { + ConsoleService console = new(); + MainViewModel viewModel = new( + new HardcodedCatalogService(), + console, + new FakeVisualStudioDiscoveryService(), + new FakePackageStore(), + new FakeInstalledAppScanner(), + new FakeDialogService()); + + Assert.Single(viewModel.Packages); + Assert.NotNull(viewModel.SelectedPackage); + Assert.NotEmpty(viewModel.CurrentApps); + Assert.NotEmpty(viewModel.NavigationRoots); + Assert.Contains(console.Messages, message => message.Text.Contains("Loaded V0 demo package", StringComparison.Ordinal)); + } + + [Fact] + public void NewPackageCommand_CreatesAndSelectsPackage() + { + MainViewModel viewModel = CreateViewModel(); + int originalCount = viewModel.Packages.Count; + + viewModel.NewPackageCommand.Execute(null); + + Assert.Equal(originalCount + 1, viewModel.Packages.Count); + Assert.Same(viewModel.Packages.Last(), viewModel.SelectedPackage); + Assert.Contains("Selected package", viewModel.StatusText, StringComparison.Ordinal); + } + + [Fact] + public void AddAndRemoveAppCommands_RoundtripSelectedPackage() + { + MainViewModel viewModel = CreateViewModel(); + int originalCount = viewModel.SelectedPackage!.Apps.Count; + + Assert.True(viewModel.AddAppCommand.CanExecute(null)); + viewModel.AddAppCommand.Execute(null); + + Assert.Equal(originalCount + 1, viewModel.SelectedPackage.Apps.Count); + Assert.NotNull(viewModel.SelectedApp); + Assert.True(viewModel.RemoveAppCommand.CanExecute(null)); + + viewModel.RemoveAppCommand.Execute(null); + + Assert.Equal(originalCount, viewModel.SelectedPackage.Apps.Count); + Assert.Null(viewModel.SelectedApp); + } + + [Fact] + public void RemoveAppCommand_TracksSelectionCanExecute() + { + MainViewModel viewModel = CreateViewModel(); + viewModel.SelectedApp = null; + + Assert.False(viewModel.RemoveAppCommand.CanExecute(null)); + + viewModel.SelectedApp = viewModel.CurrentApps[0]; + + Assert.True(viewModel.RemoveAppCommand.CanExecute(null)); + } + + [Fact] + public void SelectingNavigationNode_UpdatesSelectedPackageAndApp() + { + MainViewModel viewModel = CreateViewModel(); + NavigationNodeViewModel appNode = viewModel.NavigationRoots[0].Children[0]; + + viewModel.SelectedNavigationNode = appNode; + + Assert.Same(appNode.Value, viewModel.SelectedApp); + Assert.Same(viewModel.Packages[0], viewModel.SelectedPackage); + Assert.Contains("Selected app", viewModel.StatusText, StringComparison.Ordinal); + } + + [Fact] + public void Commands_WriteConsoleMessages() + { + ConsoleService console = new(); + MainViewModel viewModel = new( + new HardcodedCatalogService(), + console, + new FakeVisualStudioDiscoveryService(), + new FakePackageStore(), + new FakeInstalledAppScanner(), + new FakeDialogService()); + int originalCount = console.Messages.Count; + + viewModel.OptionsCommand.Execute(null); + + Assert.Equal(originalCount + 1, console.Messages.Count); + Assert.Equal(ConsoleMessageKind.Command, console.Messages.Last().Kind); + } + + [Theory] + [InlineData(nameof(MainViewModel.ExpandAllNodesCommand), ViewCommandKind.ExpandAllNodes)] + [InlineData(nameof(MainViewModel.CollapseSelectedNodeCommand), ViewCommandKind.CollapseSelectedNode)] + [InlineData(nameof(MainViewModel.ExpandSelectedNodeCommand), ViewCommandKind.ExpandSelectedNode)] + [InlineData(nameof(MainViewModel.OptionsCommand), ViewCommandKind.ShowOptions)] + public void ViewCommands_RaiseViewCommandRequests(string commandPropertyName, ViewCommandKind expectedKind) + { + MainViewModel viewModel = CreateViewModel(); + ViewCommandKind? requestedKind = null; + viewModel.ViewCommandRequested += (_, kind) => requestedKind = kind; + + System.Windows.Input.ICommand command = (System.Windows.Input.ICommand)typeof(MainViewModel) + .GetProperty(commandPropertyName)! + .GetValue(viewModel)!; + command.Execute(null); + + Assert.Equal(expectedKind, requestedKind); + } + + [Fact] + public void NavigationNodes_UseStableKeysForPersistence() + { + MainViewModel viewModel = CreateViewModel(); + + NavigationNodeViewModel packageNode = viewModel.NavigationRoots[0]; + NavigationNodeViewModel appNode = packageNode.Children[0]; + + Assert.StartsWith("package:", packageNode.Key, StringComparison.Ordinal); + Assert.Contains("/app:", appNode.Key, StringComparison.Ordinal); + Assert.EndsWith("/vs", packageNode.Children.Last().Key, StringComparison.Ordinal); + } + + [Fact] + public void VisualStudioBranch_IsNestedUnderEachPackageWithVersionSkuInstanceHierarchy() + { + MainViewModel viewModel = CreateViewModel(); + + NavigationNodeViewModel packageNode = viewModel.NavigationRoots[0]; + NavigationNodeViewModel visualStudioRoot = packageNode.Children.Last(); + + Assert.Equal(NavigationNodeKind.VisualStudioRoot, visualStudioRoot.Kind); + Assert.Equal("Visual Studio", visualStudioRoot.Text); + Assert.EndsWith("/vs", visualStudioRoot.Key, StringComparison.Ordinal); + + NavigationNodeViewModel versionNode = visualStudioRoot.Children[0]; + Assert.Equal(NavigationNodeKind.VisualStudioVersion, versionNode.Kind); + Assert.Equal("2026", versionNode.Text); + + NavigationNodeViewModel comboNode = versionNode.Children[0]; + Assert.Equal(NavigationNodeKind.VisualStudioSkuCombo, comboNode.Kind); + Assert.Equal("Preview-Enterprise", comboNode.Text); + + NavigationNodeViewModel instanceNode = comboNode.Children[0]; + Assert.Equal(NavigationNodeKind.VisualStudioInstance, instanceNode.Kind); + Assert.IsType(instanceNode.Value); + } + + [Fact] + public void SelectingVisualStudioRoot_ClearsPackageSelectionAndReportsRowCount() + { + MainViewModel viewModel = CreateViewModel(); + NavigationNodeViewModel visualStudioRoot = viewModel.NavigationRoots[0].Children.Last(); + + viewModel.SelectedNavigationNode = visualStudioRoot; + + Assert.Null(viewModel.SelectedPackage); + Assert.Null(viewModel.SelectedApp); + // One main installation row plus one experimental-hive row. + Assert.Equal(2, viewModel.VisualStudioBranch.Rows.Count); + Assert.Contains("Visual Studio", viewModel.StatusText, StringComparison.Ordinal); + } + + [Fact] + public void SelectingVisualStudioInstance_ProducesMainAndExperimentalRows() + { + MainViewModel viewModel = CreateViewModel(); + NavigationNodeViewModel instanceNode = viewModel.NavigationRoots[0].Children.Last() + .Children[0].Children[0].Children[0]; + + viewModel.SelectedNavigationNode = instanceNode; + + VisualStudioInstanceViewModel instance = Assert.IsType(instanceNode.Value); + Assert.Equal(2, instance.Rows.Count); + Assert.False(instance.Rows[0].IsExperimental); + Assert.True(instance.Rows[1].IsExperimental); + Assert.Contains("Visual Studio instance", viewModel.StatusText, StringComparison.Ordinal); + } + + [Fact] + public void PackageJsonSerializer_RoundtripsPolymorphicAppEntries() + { + WingetPackage package = new HardcodedCatalogService().CreateDemoPackage(); + + string json = PackageJsonSerializer.Serialize(package); + WingetPackage? roundtripped = PackageJsonSerializer.Deserialize(json); + + Assert.NotNull(roundtripped); + Assert.Contains("\"$type\": \"generic\"", json, StringComparison.Ordinal); + Assert.Contains("\"$type\": \"vscode\"", json, StringComparison.Ordinal); + Assert.Contains("\"$type\": \"vs\"", json, StringComparison.Ordinal); + Assert.IsType(roundtripped.Apps[0]); + Assert.IsType(roundtripped.Apps[1]); + Assert.IsType(roundtripped.Apps[2]); + } + + [Fact] + public void AppEntryViewModel_SettersUpdateUnderlyingModel() + { + GenericAppEntry model = new() + { + Id = "Old.Id", + DisplayName = "Old" + }; + AppEntryViewModel viewModel = new(model); + + viewModel.Id = "New.Id"; + viewModel.DisplayName = "New"; + viewModel.AllowPrerelease = true; + + Assert.Equal("New.Id", model.Id); + Assert.Equal("New", model.DisplayName); + Assert.True(model.AllowPrerelease); + } + + [Fact] + public void Constructor_SeedsAndSavesDemo_WhenStoreIsEmpty() + { + FakePackageStore store = new(); + + MainViewModel viewModel = CreateViewModel(store: store); + + Assert.Single(viewModel.Packages); + Assert.Contains(store.Saved, package => package.Apps.Count > 0); + } + + [Fact] + public void Constructor_LoadsPackagesFromStore_WhenNotEmpty() + { + WingetPackage stored = new() { Name = "Persisted", Apps = [new GenericAppEntry { Id = "X", DisplayName = "X" }] }; + FakePackageStore store = new() { Initial = [stored] }; + + MainViewModel viewModel = CreateViewModel(store: store); + + Assert.Single(viewModel.Packages); + Assert.Equal("Persisted", viewModel.Packages[0].Name); + Assert.Empty(store.Saved); + } + + [Fact] + public void NewFromExistingPackage_ClonesSourceWithNewNameAndId() + { + WingetPackage source = new() { Id = "source-id", Name = "Source", Apps = [new GenericAppEntry { Id = "A", DisplayName = "A" }] }; + FakePackageStore store = new() { Initial = [source] }; + FakeDialogService dialog = new() + { + NewFromExisting = packages => new NewFromExistingResult("Copy", packages[0]) + }; + + MainViewModel viewModel = CreateViewModel(store: store, dialog: dialog); + viewModel.NewFromExistingPackageCommand.Execute(null); + + PackageViewModel created = Assert.Single(viewModel.Packages, package => package.Name == "Copy"); + Assert.NotEqual("source-id", created.Model.Id); + Assert.Single(created.Apps); + Assert.Contains(store.Saved, package => package.Name == "Copy"); + } + + [Fact] + public void RemovePackageCommand_BacksUpAndRemovesSelectedPackage() + { + MainViewModel viewModel = CreateViewModel(out FakePackageStore store, dialog: new FakeDialogService { ConfirmRemove = true }); + PackageViewModel target = viewModel.SelectedPackage!; + + viewModel.RemovePackageCommand.Execute(null); + + Assert.DoesNotContain(target, viewModel.Packages); + Assert.Contains(store.Deleted, package => ReferenceEquals(package, target.Model)); + } + + [Fact] + public void RemovePackageCommand_DoesNothing_WhenNotConfirmed() + { + MainViewModel viewModel = CreateViewModel(out FakePackageStore store, dialog: new FakeDialogService { ConfirmRemove = false }); + int originalCount = viewModel.Packages.Count; + + viewModel.RemovePackageCommand.Execute(null); + + Assert.Equal(originalCount, viewModel.Packages.Count); + Assert.Empty(store.Deleted); + } + + [Fact] + public void UpdateCurrentPackage_AddsOnlyInstalledAndMissingApps() + { + FakeInstalledAppScanner scanner = new() { InstalledIds = ["Git.Git", "Microsoft.PowerShell"] }; + MainViewModel viewModel = CreateViewModel(scanner: scanner); + PackageViewModel package = viewModel.SelectedPackage!; + + // The demo package already contains Microsoft.PowerShell, so it must not be added again. + int beforeCount = package.Apps.Count; + + viewModel.UpdateCurrentPackageCommand.Execute(null); + + Assert.Equal(beforeCount + 1, package.Apps.Count); + Assert.Contains(package.Model.Apps, app => app.Id == "Git.Git"); + Assert.Equal(1, package.Model.Apps.Count(app => app.Id == "Microsoft.PowerShell")); + } + + [Fact] + public void AddAppCommand_PersistsSelectedPackage() + { + MainViewModel viewModel = CreateViewModel(out FakePackageStore store); + store.Saved.Clear(); + + viewModel.AddAppCommand.Execute(null); + + Assert.NotEmpty(store.Saved); + } + + private static MainViewModel CreateViewModel() + => CreateViewModel(store: new FakePackageStore()); + + private static MainViewModel CreateViewModel(out FakePackageStore store, FakeDialogService? dialog = null) + { + store = new FakePackageStore(); + return CreateViewModel(store: store, dialog: dialog); + } + + private static MainViewModel CreateViewModel( + FakePackageStore? store = null, + FakeInstalledAppScanner? scanner = null, + FakeDialogService? dialog = null) + => new( + new HardcodedCatalogService(), + new ConsoleService(), + new FakeVisualStudioDiscoveryService(), + store ?? new FakePackageStore(), + scanner ?? new FakeInstalledAppScanner(), + dialog ?? new FakeDialogService()); + + private sealed class FakePackageStore : IPackageStore + { + public List Saved { get; } = []; + + public List Deleted { get; } = []; + + public IReadOnlyList Initial { get; init; } = []; + + public IReadOnlyList LoadAll() => Initial; + + public void Save(WingetPackage package) => Saved.Add(package); + + public void Delete(WingetPackage package) => Deleted.Add(package); + } + + private sealed class FakeInstalledAppScanner : IInstalledAppScanner + { + public IReadOnlyList InstalledIds { get; init; } = []; + + public IReadOnlyList GetInstalledWingetIds() => InstalledIds; + } + + private sealed class FakeDialogService : IPackageEditorDialogService + { + public Func, NewFromExistingResult?>? NewFromExisting { get; init; } + + public bool ConfirmRemove { get; init; } = true; + + public Func, AppEntry?>? PickApp { get; init; } + + public NewFromExistingResult? AskNewFromExisting(IReadOnlyList existingPackages) + => NewFromExisting?.Invoke(existingPackages); + + public bool ConfirmRemovePackage(string packageName) => ConfirmRemove; + + public AppEntry? PickAndConfigureApp(IReadOnlyList wellKnownApps) + => PickApp is not null + ? PickApp(wellKnownApps) + : WingetPackage.Clone(new WingetPackage { Apps = [wellKnownApps[0]] }).Apps[0]; + } + + private sealed class FakeVisualStudioDiscoveryService : IVisualStudioDiscoveryService + { + public IReadOnlyList DiscoverInstances() => + [ + new VisualStudioInstanceInfo( + InstanceId: "480e759d", + DisplayName: "Visual Studio Enterprise 2026", + Year: "2026", + Edition: "Enterprise", + Channel: VisualStudioChannel.Preview, + ChannelId: "VisualStudio.18.Preview", + Version: "18.7.11822.327", + ShortVersion: "18.0", + InstallDate: new DateTimeOffset(2025, 12, 9, 22, 35, 13, TimeSpan.Zero), + InstallationPath: @"C:\Program Files\Microsoft Visual Studio\18\Insiders", + ProductId: "Microsoft.VisualStudio.Product.Enterprise", + IsPrerelease: true, + Hives: + [ + new VisualStudioHiveInfo( + "18.0_480e759d", + @"C:\Users\demo\AppData\Local\Microsoft\VisualStudio\18.0_480e759d", + @"C:\Users\demo\AppData\Local\Microsoft\VisualStudio\18.0_480e759d\Settings\CurrentSettings.vssettings", + IsExperimental: false), + new VisualStudioHiveInfo( + "18.0_480e759dExp", + @"C:\Users\demo\AppData\Local\Microsoft\VisualStudio\18.0_480e759dExp", + @"C:\Users\demo\AppData\Local\Microsoft\VisualStudio\18.0_480e759dExp\Settings\CurrentSettings.vssettings", + IsExperimental: true) + ]) + ]; + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/VisualStudioDiscoveryParserTests.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/VisualStudioDiscoveryParserTests.cs new file mode 100644 index 0000000..c40d2f7 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/VisualStudioDiscoveryParserTests.cs @@ -0,0 +1,94 @@ +using WingetPackageEditor.Core.Models; +using WingetPackageEditor.Core.Services; + +namespace WingetPackageEditor.Tests; + +public sealed class VisualStudioDiscoveryParserTests +{ + private const string SampleBlock = """ + Visual Studio Locator version 3.1.7+f39851e70f [query version 4.7.7.8087] + Copyright (C) Microsoft Corporation. All rights reserved. + + instanceId: 480e759d + installDate: 12/9/2025 10:35:13 PM + installationName: VisualStudioPreview/18.7.0-insiders+11822.327 + installationPath: C:\Program Files\Microsoft Visual Studio\18\Insiders + installationVersion: 18.7.11822.327 + productId: Microsoft.VisualStudio.Product.Enterprise + productPath: C:\Program Files\Microsoft Visual Studio\18\Insiders\Common7\IDE\devenv.exe + isPrerelease: 1 + displayName: Visual Studio Enterprise 2026 + channelId: VisualStudio.18.Preview + catalog_productLineVersion: 18 + """; + + [Fact] + public void ParseBlocks_SkipsBannerLines_AndReturnsOneBlockPerInstance() + { + IReadOnlyList> blocks = VisualStudioDiscoveryParser.ParseBlocks(SampleBlock); + + Assert.Single(blocks); + Assert.Equal("480e759d", blocks[0]["instanceId"]); + // Values containing colons (URLs/paths) must be preserved past the first colon. + Assert.Equal(@"C:\Program Files\Microsoft Visual Studio\18\Insiders", blocks[0]["installationPath"]); + } + + [Fact] + public void MapInstance_MapsEditionChannelYearAndShortVersion() + { + IReadOnlyDictionary block = VisualStudioDiscoveryParser.ParseBlocks(SampleBlock)[0]; + + VisualStudioInstanceInfo? instance = VisualStudioDiscoveryParser.MapInstance(block, []); + + Assert.NotNull(instance); + Assert.Equal("480e759d", instance!.InstanceId); + Assert.Equal("Enterprise", instance.Edition); + Assert.Equal(VisualStudioChannel.Preview, instance.Channel); + Assert.Equal("2026", instance.Year); + Assert.Equal("18.0", instance.ShortVersion); + Assert.True(instance.IsPrerelease); + Assert.Equal("Preview-Enterprise", instance.SkuComboLabel); + } + + [Fact] + public void CorrelateHives_MatchesMainAndExperimentalHives_ForInstance() + { + VisualStudioHiveFolder[] folders = + [ + new("18.0_480e759d", @"C:\hive\18.0_480e759d"), + new("18.0_480e759dExp", @"C:\hive\18.0_480e759dExp"), + new("17.0_deadbeef", @"C:\hive\17.0_deadbeef"), + new("18.0_480e759dRoslynDeployment", @"C:\hive\18.0_480e759dRoslynDeployment") + ]; + + IReadOnlyList hives = + VisualStudioDiscoveryParser.CorrelateHives(folders, "18.0", "480e759d"); + + Assert.Equal(2, hives.Count); + Assert.Contains(hives, hive => !hive.IsExperimental && hive.Name == "18.0_480e759d"); + Assert.Contains(hives, hive => hive.IsExperimental && hive.Name == "18.0_480e759dExp"); + } + + [Theory] + [InlineData("VisualStudio.18.Release", VisualStudioChannel.Release)] + [InlineData("VisualStudio.18.Preview", VisualStudioChannel.Preview)] + [InlineData("VisualStudio.18.IntPreview.Canary", VisualStudioChannel.Canary)] + [InlineData("VisualStudio.18.Main", VisualStudioChannel.Main)] + [InlineData("", VisualStudioChannel.Unknown)] + public void MapChannel_ClassifiesKnownChannels(string channelId, VisualStudioChannel expected) + => Assert.Equal(expected, VisualStudioDiscoveryParser.MapChannel(channelId)); + + [Theory] + [InlineData("16", "2019")] + [InlineData("17", "2022")] + [InlineData("18", "2026")] + public void MapYear_MapsProductLineVersionToReleaseYear(string lineVersion, string expected) + { + Dictionary block = new(StringComparer.OrdinalIgnoreCase) + { + ["catalog_productLineVersion"] = lineVersion + }; + + Assert.Equal(expected, VisualStudioDiscoveryParser.MapYear(block, shortVersion: string.Empty)); + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/WingetListScannerTests.cs b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/WingetListScannerTests.cs new file mode 100644 index 0000000..2004bd9 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/WingetListScannerTests.cs @@ -0,0 +1,29 @@ +using WingetPackageEditor.Core.Services; + +namespace WingetPackageEditor.Tests; + +public sealed class WingetListScannerTests +{ + [Fact] + public void ParseIds_ExtractsIdColumn_FromAlignedOutput() + { + string header = "Name".PadRight(21) + "Id".PadRight(26) + "Version".PadRight(13) + "Available".PadRight(10) + "Source"; + string separator = new('-', 80); + string row1 = "Git".PadRight(21) + "Git.Git".PadRight(26) + "2.43.0".PadRight(13) + "2.44.0".PadRight(10) + "winget"; + string row2 = "PowerShell 7-x64".PadRight(21) + "Microsoft.PowerShell".PadRight(26) + "7.4.1".PadRight(13) + "".PadRight(10) + "winget"; + string row3 = "Some App".PadRight(21) + "Foo.Bar".PadRight(26) + "1.0"; + + string output = string.Join("\r\n", "Progress noise...", header, separator, row1, row2, row3); + + IReadOnlyList ids = WingetListScanner.ParseIds(output); + + Assert.Equal(["Git.Git", "Microsoft.PowerShell", "Foo.Bar"], ids); + } + + [Fact] + public void ParseIds_ReturnsEmpty_WhenNoHeader() + { + Assert.Empty(WingetListScanner.ParseIds("nothing useful here")); + Assert.Empty(WingetListScanner.ParseIds("")); + } +} diff --git a/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/WingetPackageEditor.Tests.csproj b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/WingetPackageEditor.Tests.csproj new file mode 100644 index 0000000..ffa7e24 --- /dev/null +++ b/src/WinForms/NET10/Winget-Package-Editor/WingetPackageEditor.Tests/WingetPackageEditor.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file