From 19da8fbeba546b4ba92a443b566d106c970963dc Mon Sep 17 00:00:00 2001 From: James Date: Wed, 20 May 2026 08:49:41 -0700 Subject: [PATCH 1/8] Move test project to tests/ subdirectory; add CONTRIBUTING.md Reorganizes the repo to follow the standard .NET layout where shipping code lives under the project name and tests live under tests/. The solution file, project reference, and CI workflow path are updated to match. The README's Architecture section now explicitly explains the purpose of the test project; CONTRIBUTING.md gives a full project- structure walkthrough for new contributors plus what's covered by automated tests vs. manual smoke tests. --- .github/workflows/validate.yml | 2 +- CONTRIBUTING.md | 103 ++++++++++++++++++ README.md | 10 +- VirtualTabGroups.sln | 2 +- .../AliasResolverTests.cs | 0 .../Fixtures/state-good.json | 0 .../NodeJsonConverterTests.cs | 0 .../NotepadPlusPlusObserverTests.cs | 0 .../VirtualTabGroups.Tests}/SanityTests.cs | 0 .../StateStoreTests.cs | 0 .../ThemeManagerTests.cs | 0 .../TreeMutatorTests.cs | 0 .../TreeNodeModelTests.cs | 0 .../VirtualTabGroups.Tests.csproj | 4 +- 14 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 CONTRIBUTING.md rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/AliasResolverTests.cs (100%) rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/Fixtures/state-good.json (100%) rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/NodeJsonConverterTests.cs (100%) rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/NotepadPlusPlusObserverTests.cs (100%) rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/SanityTests.cs (100%) rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/StateStoreTests.cs (100%) rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/ThemeManagerTests.cs (100%) rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/TreeMutatorTests.cs (100%) rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/TreeNodeModelTests.cs (100%) rename {VirtualTabGroups.Tests => tests/VirtualTabGroups.Tests}/VirtualTabGroups.Tests.csproj (81%) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 697d003..332eabb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -27,4 +27,4 @@ jobs: run: msbuild VirtualTabGroups.sln /p:Configuration=Debug /p:Platform=x64 - name: Run tests - run: dotnet test VirtualTabGroups.Tests/VirtualTabGroups.Tests.csproj --nologo --no-build + run: dotnet test tests/VirtualTabGroups.Tests/VirtualTabGroups.Tests.csproj --nologo --no-build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c53d05b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,103 @@ +# Contributing to Virtual Tab Groups + +Thanks for considering a contribution. This file orients you to the codebase so you can get from `git clone` to a useful change without surprises. + +## Project structure + +``` +VirtualTabGroups.sln ← Solution file +VirtualTabGroups/ ← Shipping plugin (the only project users install) + Core/ ← Pure C# library, no Notepad++ dependency + Plugin/ ← Notepad++ integration: entry points, UI, interop + Plugin/Npp/ ← Vendored Notepad++ interop types + Plugin/UI/ ← WinForms custom controls + theme manager + Plugin/Resources/ ← Embedded resources (plugin icon) + Properties/ ← AssemblyInfo, etc. + packages.config ← Legacy-style NuGet manifest + VirtualTabGroups.csproj +tests/ + VirtualTabGroups.Tests/ ← Unit tests — never shipped + *Tests.cs + Fixtures/ ← Known-good JSON used by StateStore tests + VirtualTabGroups.Tests.csproj ← SDK-style csproj, references the main project +.github/workflows/ ← CI: validate.yml (PR/main) + release.yml (tag v*) +docs/ ← (none in repo — see README) +README.md +CONTRIBUTING.md ← (this file) +LICENSE +``` + +**Only the `VirtualTabGroups/` project ships.** Users get `VirtualTabGroups.dll` + `Newtonsoft.Json.dll` in the release zip — nothing from `tests/` or build artifacts. + +## Building locally + +### Prerequisites + +- Windows 10 or 11 +- Visual Studio 2022 Build Tools (or full IDE) with .NET Framework 4.8 SDK + targeting pack +- NuGet on PATH (or use the `nuget.exe` checked into the repo root) + +### Build commands + +```powershell +# Restore packages first time (or after pulling someone else's package changes) +nuget restore VirtualTabGroups.sln + +# Build the plugin (Release x64 is the standard test target) +msbuild VirtualTabGroups.sln /p:Configuration=Release /p:Platform=x64 + +# Run the test suite +dotnet test tests/VirtualTabGroups.Tests/VirtualTabGroups.Tests.csproj --nologo + +# Deploy locally for manual testing (elevated PowerShell) +Copy-Item -Force "VirtualTabGroups\bin\Release\x64\VirtualTabGroups.dll" "C:\Program Files\Notepad++\plugins\VirtualTabGroups\" +Copy-Item -Force "VirtualTabGroups\bin\Release\x64\Newtonsoft.Json.dll" "C:\Program Files\Notepad++\plugins\VirtualTabGroups\" +``` + +## What the test project covers (and what it doesn't) + +`tests/VirtualTabGroups.Tests/` covers the **model layer** — code under `VirtualTabGroups/Core/` plus a couple of test seams in `Plugin/` (the observer dedup logic, the theme manager). Specifically: + +| Test file | What it verifies | +|---|---| +| `AliasResolverTests` | Display-name aliasing (`User.php`, `User(1).php`, sparse-fill, no-extension files, multi-dot names) | +| `NodeJsonConverterTests` | JSON round-trip, polymorphic folder/file discriminator, unknown-field forward compat, error paths | +| `StateStoreTests` | Load (missing/empty/valid/corrupt/future-schema), save (debounced atomic write, failure observer notification, dirty-state preservation, shutdown semantics, observer-throws isolation) | +| `TreeMutatorTests` | Add with alias collision, Remove, Move with cyclic prevention, RemoveAllByPath case-insensitive | +| `NotepadPlusPlusObserverTests` | Save-failure dedup, MessageBox routing via `IMessageBoxProxy` seam | +| `ThemeManagerTests` | Dark vs light palette resolution via `INppMessageSender` fake | + +Tests run against a **temp directory** (no shared state) and a couple of fake interfaces (`IMessageBoxProxy`, `INppMessageSender`) so they never touch real Notepad++ or pop dialogs. + +**What's NOT covered by automated tests** — anything that needs a running Notepad++: +- The dockable panel rendering and docking behavior +- Custom owner-draw output (chevrons, connector lines, insertion lines, theming) +- Drag-and-drop interaction +- Context menu structure +- Keyboard shortcuts +- File-close auto-removal (the `NPPN_FILEBEFORECLOSE` plumbing) +- Double-click open via `NPPM_DOOPEN` / `NPPM_SWITCHTOFILE` + +These get **manual smoke-tested** inside Notepad++. The plugin writes a diagnostic log (`%appdata%\Notepad++\plugins\config\VirtualTabGroups\plugin.log`) that breadcrumbs every notable action, so reproducing a bug usually means doing the action and checking the log. + +## Filing a bug + +A good bug report has three things: + +1. **What you did** — the steps to reproduce, in order. +2. **What happened vs. what you expected.** +3. **The relevant section of `plugin.log`** — the file lives at `%appdata%\Notepad++\plugins\config\VirtualTabGroups\plugin.log`. Paste the last few entries that bracket the failure. + +With those three, most bugs become a one-round fix. + +## Pull requests + +- Run `dotnet test` locally before opening the PR. The suite is fast (~1 second) and catches most regressions in the model layer. +- Keep PRs focused on one logical change. A "fix bug X + refactor Y + add feature Z" PR is harder to review than three separate ones. +- For changes to `Core/`, add or update a test alongside the change. The test suite is your safety net during the next refactor. +- For UI changes, include before/after notes in the PR description (a screenshot is great if relevant). UI bugs that pass code review but fail in Notepad++ are common; the PR description should help the reviewer mentally simulate the change. +- `[DllExport]`-decorated methods in `PluginMain` are the unmanaged interface Notepad++ calls. **Don't rename them** (the export names are referenced by Notepad++) and **don't add untyped exceptions to their signatures** — any uncaught exception there crashes the host. Wrap their bodies in try/catch and route through `ReportCrash`. + +## License + +By contributing, you agree your contribution is licensed under the [MIT License](LICENSE) — same as the rest of the project. diff --git a/README.md b/README.md index c2da4e9..90cbdf5 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ For `x86` or `ARM64`, swap the `/p:Platform=` value. ### Running tests ```powershell -dotnet test VirtualTabGroups.Tests\VirtualTabGroups.Tests.csproj --nologo +dotnet test tests\VirtualTabGroups.Tests\VirtualTabGroups.Tests.csproj --nologo ``` The test project covers the model layer (StateStore, TreeMutator, NodeJsonConverter, AliasResolver, theme manager, observer) — 54 unit tests at the time of writing. UI behavior is verified manually inside Notepad++ since WinForms doesn't lend itself to automated testing without a real message loop. @@ -240,6 +240,10 @@ The codebase is split into: The model layer (`Core/`) is fully unit-tested without any Notepad++ instance. The UI layer (`Plugin/`) is exercised through manual smoke testing. +### The `tests/` directory + +`tests/VirtualTabGroups.Tests/` is a separate xUnit project that covers everything under `Core/` plus the testable seams in `Plugin/` (the observer dedup logic, the theme manager). It is **never shipped** — the release zip contains only `VirtualTabGroups.dll` and `Newtonsoft.Json.dll`. The test project exists for CI (every PR and push to `main` runs `dotnet test`) and for refactoring safety. See [CONTRIBUTING.md](CONTRIBUTING.md) for a detailed breakdown of what's covered. + --- ## Roadmap @@ -297,9 +301,7 @@ If you're interested in any of these, an issue or PR is welcome — see [Contrib Bug reports, feature requests, and PRs are all welcome via [GitHub Issues](https://github.com/JamesShaver/VirtualTabGroups/issues). -When filing a bug, including the contents of `plugin.log` (or the relevant lines near the failure) helps enormously. - -For PRs, please run `dotnet test` locally before opening — the test suite is fast (~1 second) and catches most regressions in the model layer. +See [CONTRIBUTING.md](CONTRIBUTING.md) for the project structure, build steps, and details on what the test project covers vs. what gets manual smoke-tested. When filing a bug, including the contents of `plugin.log` (or the relevant lines near the failure) helps enormously. --- diff --git a/VirtualTabGroups.sln b/VirtualTabGroups.sln index ba33bd3..688ef75 100644 --- a/VirtualTabGroups.sln +++ b/VirtualTabGroups.sln @@ -5,7 +5,7 @@ VisualStudioVersion = 17.4.33205.214 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualTabGroups", "VirtualTabGroups\VirtualTabGroups.csproj", "{DFB2432F-5C55-4D76-A7FA-934881E5D60A}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VirtualTabGroups.Tests", "VirtualTabGroups.Tests\VirtualTabGroups.Tests.csproj", "{47970DAB-57C2-4E86-A7A7-97CA1766601E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VirtualTabGroups.Tests", "tests\VirtualTabGroups.Tests\VirtualTabGroups.Tests.csproj", "{47970DAB-57C2-4E86-A7A7-97CA1766601E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/VirtualTabGroups.Tests/AliasResolverTests.cs b/tests/VirtualTabGroups.Tests/AliasResolverTests.cs similarity index 100% rename from VirtualTabGroups.Tests/AliasResolverTests.cs rename to tests/VirtualTabGroups.Tests/AliasResolverTests.cs diff --git a/VirtualTabGroups.Tests/Fixtures/state-good.json b/tests/VirtualTabGroups.Tests/Fixtures/state-good.json similarity index 100% rename from VirtualTabGroups.Tests/Fixtures/state-good.json rename to tests/VirtualTabGroups.Tests/Fixtures/state-good.json diff --git a/VirtualTabGroups.Tests/NodeJsonConverterTests.cs b/tests/VirtualTabGroups.Tests/NodeJsonConverterTests.cs similarity index 100% rename from VirtualTabGroups.Tests/NodeJsonConverterTests.cs rename to tests/VirtualTabGroups.Tests/NodeJsonConverterTests.cs diff --git a/VirtualTabGroups.Tests/NotepadPlusPlusObserverTests.cs b/tests/VirtualTabGroups.Tests/NotepadPlusPlusObserverTests.cs similarity index 100% rename from VirtualTabGroups.Tests/NotepadPlusPlusObserverTests.cs rename to tests/VirtualTabGroups.Tests/NotepadPlusPlusObserverTests.cs diff --git a/VirtualTabGroups.Tests/SanityTests.cs b/tests/VirtualTabGroups.Tests/SanityTests.cs similarity index 100% rename from VirtualTabGroups.Tests/SanityTests.cs rename to tests/VirtualTabGroups.Tests/SanityTests.cs diff --git a/VirtualTabGroups.Tests/StateStoreTests.cs b/tests/VirtualTabGroups.Tests/StateStoreTests.cs similarity index 100% rename from VirtualTabGroups.Tests/StateStoreTests.cs rename to tests/VirtualTabGroups.Tests/StateStoreTests.cs diff --git a/VirtualTabGroups.Tests/ThemeManagerTests.cs b/tests/VirtualTabGroups.Tests/ThemeManagerTests.cs similarity index 100% rename from VirtualTabGroups.Tests/ThemeManagerTests.cs rename to tests/VirtualTabGroups.Tests/ThemeManagerTests.cs diff --git a/VirtualTabGroups.Tests/TreeMutatorTests.cs b/tests/VirtualTabGroups.Tests/TreeMutatorTests.cs similarity index 100% rename from VirtualTabGroups.Tests/TreeMutatorTests.cs rename to tests/VirtualTabGroups.Tests/TreeMutatorTests.cs diff --git a/VirtualTabGroups.Tests/TreeNodeModelTests.cs b/tests/VirtualTabGroups.Tests/TreeNodeModelTests.cs similarity index 100% rename from VirtualTabGroups.Tests/TreeNodeModelTests.cs rename to tests/VirtualTabGroups.Tests/TreeNodeModelTests.cs diff --git a/VirtualTabGroups.Tests/VirtualTabGroups.Tests.csproj b/tests/VirtualTabGroups.Tests/VirtualTabGroups.Tests.csproj similarity index 81% rename from VirtualTabGroups.Tests/VirtualTabGroups.Tests.csproj rename to tests/VirtualTabGroups.Tests/VirtualTabGroups.Tests.csproj index a261cd4..a0a5bb3 100644 --- a/VirtualTabGroups.Tests/VirtualTabGroups.Tests.csproj +++ b/tests/VirtualTabGroups.Tests/VirtualTabGroups.Tests.csproj @@ -11,11 +11,11 @@ - + - ..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + ..\..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll True From 660f141844196dd54638ca604d2f3f659bfb3709 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 20 May 2026 10:23:38 -0700 Subject: [PATCH 2/8] Add 'Reveal in Explorer' and 'Copy full path' to file context menu Both items are file-only and disabled for unsaved buffers (non-rooted paths). Process.Start is wrapped in using() so the returned Process handle does not leak per invocation. --- .../Plugin/VirtualTabGroupsPanel.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/VirtualTabGroups/Plugin/VirtualTabGroupsPanel.cs b/VirtualTabGroups/Plugin/VirtualTabGroupsPanel.cs index 5635eee..6105e38 100644 --- a/VirtualTabGroups/Plugin/VirtualTabGroupsPanel.cs +++ b/VirtualTabGroups/Plugin/VirtualTabGroupsPanel.cs @@ -345,7 +345,22 @@ private void Menu_Opening(object sender, System.ComponentModel.CancelEventArgs e if (target is FileNode fileTarget) { + // Saved files have rooted paths (e.g., "C:\src\foo.cs"). Unsaved buffers + // have names like "new 40" — there's no on-disk file to reveal or path + // worth copying, so those items are shown but grayed out. + bool isOnDisk = System.IO.Path.IsPathRooted(fileTarget.Path); + _menu.Items.Add(new ToolStripMenuItem("Open", null, (s, ev) => OnFileOpen(fileTarget))); + _menu.Items.Add(new ToolStripSeparator()); + + var revealItem = new ToolStripMenuItem("Reveal in Explorer", null, + (s, ev) => OnRevealInExplorer(fileTarget)) { Enabled = isOnDisk }; + _menu.Items.Add(revealItem); + + var copyPathItem = new ToolStripMenuItem("Copy full path", null, + (s, ev) => OnCopyFullPath(fileTarget)) { Enabled = isOnDisk }; + _menu.Items.Add(copyPathItem); + _menu.Items.Add(new ToolStripSeparator()); _menu.Items.Add(new ToolStripMenuItem("Rename", null, (s, ev) => hit.Node.BeginEdit())); _menu.Items.Add(new ToolStripMenuItem("Remove…", null, (s, ev) => OnRemove(fileTarget))); @@ -386,6 +401,40 @@ private void OnFileOpen(FileNode file) ReportError("Open File", ex); } } + + /// + /// Opens Windows Explorer at the file's parent folder with the file selected. + /// No-op for unsaved buffers (non-rooted paths) — the menu item is grayed out + /// in that case, but we re-check here for defense-in-depth. + /// + private void OnRevealInExplorer(FileNode file) + { + try + { + if (file == null || !System.IO.Path.IsPathRooted(file.Path)) return; + // /select,"" opens Explorer at the parent folder with the file highlighted. + // Filenames can't contain " on Windows (NTFS forbids it), so simple quoting suffices. + // Wrap in using() so the returned Process handle is disposed — otherwise each + // invocation leaks a Win32 process handle for the lifetime of the plugin. + using (System.Diagnostics.Process.Start("explorer.exe", "/select,\"" + file.Path + "\"")) { } + } + catch (Exception ex) { ReportError("Reveal in Explorer", ex); } + } + + /// + /// Copies the file's absolute path to the clipboard. No-op for unsaved buffers + /// (non-rooted paths) — the menu item is grayed out in that case. + /// + private void OnCopyFullPath(FileNode file) + { + try + { + if (file == null || !System.IO.Path.IsPathRooted(file.Path)) return; + Clipboard.SetText(file.Path); + } + catch (Exception ex) { ReportError("Copy full path", ex); } + } + private void OnAddActive(FolderNode targetFolder) { try From 05e7313001ce6847a632cb48bbfa140b84d42996 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 20 May 2026 10:34:20 -0700 Subject: [PATCH 3/8] Persist panel visibility across sessions: register early on NPPN_READY The panel was previously lazy-created on first menu click, by which time Notepad++'s docking manager had already applied its saved layout. Moving creation + DMM registration into OnNppReady lets Notepad++ restore the panel's last-session visibility automatically. OnShowPanel reduces to a visibility toggle. README updated to list the Reveal in Explorer / Copy full path entries in the file-operations section, and the roadmap section is trimmed to reflect what has shipped. --- README.md | 7 +- VirtualTabGroups/Plugin/PluginMain.cs | 96 +++++++++++++++++---------- 2 files changed, 64 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 90cbdf5..1e608c2 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ The panel is **purely virtual**. It does not scan your disk, mirror folders, or - **Rename** — `F2` or context menu, edits the *display label* only; the actual file on disk is never renamed. - **Remove** — removes the entry from the panel; **does not delete the file on disk**. Folder removals prompt for confirmation when non-empty. - **Open / activate** — double-click a file (or press `Enter` on it) to switch to its tab in Notepad++. If the file isn't currently open, it's reopened from disk. If it was an unsaved scratch buffer that's since been closed, you get a friendly "buffer no longer open" message instead of trying to create a file at a path that doesn't exist. +- **Reveal in Explorer** — right-click a file → opens Windows Explorer at the file's parent folder with the file highlighted. Disabled for unsaved buffers (nothing to reveal). +- **Copy full path** — right-click a file → puts the file's absolute path on the clipboard. Disabled for unsaved buffers. - **Expand All / Collapse All** — context menu actions for any folder, applies recursively to its subtree. ### Persistence @@ -251,10 +253,9 @@ The model layer (`Core/`) is fully unit-tested without any Notepad++ instance. T These would all fit naturally with what the plugin already does. None are committed — but each one would slot in cleanly. ### Likely-soon -- **"Reveal in Explorer"** in the file context menu — opens an Explorer window with the file selected. -- **"Copy full path"** in the file context menu. - **Search / filter box** above the tree to quickly find a file in a large workspace. -- **Customizable keybindings** — currently the menu shortcuts are baked in. +- **Customizable keybindings** — currently the menu shortcuts are baked in. (Note: the *Show panel* shortcut `Ctrl+Shift+T` is already rebindable via *Notepad++ → Settings → Shortcut Mapper → Plugin commands*. This roadmap item is specifically about the in-panel shortcuts: `F2`, `Delete`, `Ctrl+N`, etc.) +- **`Ctrl+C` to copy the selected file's path** without opening the context menu. ### Bigger ideas - **Multiple workspaces.** A dropdown above the tree to switch between, say, "Work project A", "Personal scripts", "Documentation cleanup" — each with its own root tree, saved to its own state file. Useful when you context-switch between unrelated projects. diff --git a/VirtualTabGroups/Plugin/PluginMain.cs b/VirtualTabGroups/Plugin/PluginMain.cs index fcfb9f1..52ed12b 100644 --- a/VirtualTabGroups/Plugin/PluginMain.cs +++ b/VirtualTabGroups/Plugin/PluginMain.cs @@ -210,6 +210,13 @@ private static void OnNppReady() Theme = new ThemeManager(new Win32NppMessageSender(_nppData._nppHandle)); Theme.Initialize(); + + // Eagerly create and register the panel so Notepad++'s docking manager + // can restore its last-session visibility from dockingMgr.xml. If we wait + // until the user clicks the menu (lazy create), Notepad++ has already + // finished applying its saved layout by then and our panel can never + // be visible at startup. + EnsurePanelRegistered(); } private static void OnNppShutdown() @@ -327,50 +334,67 @@ private static void BuildFuncItems() } } - private static void OnShowPanel() + /// + /// Loads the 16x16 panel-tab icon from the embedded resource once. + /// + private static void LoadTabIconOnce() { - // Load the 16x16 tab icon from our embedded plugin.ico. - // Held in a static field so the HICON stays valid for the panel lifetime. - if (_tabIconHandle == IntPtr.Zero) + if (_tabIconHandle != IntPtr.Zero) return; + try { - try + using (var stream = typeof(PluginMain).Assembly.GetManifestResourceStream( + "VirtualTabGroups.Plugin.Resources.plugin.ico")) { - using (var stream = typeof(PluginMain).Assembly.GetManifestResourceStream( - "VirtualTabGroups.Plugin.Resources.plugin.ico")) + if (stream != null) { - if (stream != null) - { - // Pick the 16x16 frame from the multi-resolution .ico. - _tabIcon = new System.Drawing.Icon(stream, new System.Drawing.Size(16, 16)); - _tabIconHandle = _tabIcon.Handle; - } + _tabIcon = new System.Drawing.Icon(stream, new System.Drawing.Size(16, 16)); + _tabIconHandle = _tabIcon.Handle; } } - catch { /* missing icon resource is non-fatal */ } } + catch { /* missing icon resource is non-fatal */ } + } - if (_panel == null) - { - _panel = new VirtualTabGroupsPanel(); - _panel.Show(); - _panel.AttachTheme(Theme); - - _panel.RegisterAsDockedPanel( - nppHandle: _nppData._nppHandle, - moduleName: "VirtualTabGroups", - caption: PluginName, - cmdId: CmdId_ShowPanel, - dockingFlags: NppTbMsg.DWS_DF_CONT_LEFT | NppTbMsg.DWS_ICONTAB, - iconHandle: _tabIconHandle); - - Win32.SendMessage(_nppData._nppHandle, - (int)NppMsg.NPPM_DARKMODESUBCLASSANDTHEME, - new IntPtr(1), - _panel.Handle); - - _panel.BindRoot(_root, _stateStore, _stateStore.LastSelectedId); - return; - } + /// + /// Creates the panel form and registers it as a docked panel with Notepad++. + /// Idempotent — subsequent calls are no-ops. Called eagerly from OnNppReady so + /// Notepad++'s docking manager can restore the panel's last-session visibility + /// before the user even sees the editor window. + /// + private static void EnsurePanelRegistered() + { + if (_panel != null) return; + + LoadTabIconOnce(); + + _panel = new VirtualTabGroupsPanel(); + // Force handle creation without making the form visible — Notepad++'s + // docking manager applies visibility from its saved layout right after + // we register, so an explicit Show() here would just cause a brief flash. + var _ = _panel.Handle; + _panel.AttachTheme(Theme); + + _panel.RegisterAsDockedPanel( + nppHandle: _nppData._nppHandle, + moduleName: "VirtualTabGroups", + caption: PluginName, + cmdId: CmdId_ShowPanel, + dockingFlags: NppTbMsg.DWS_DF_CONT_LEFT | NppTbMsg.DWS_ICONTAB, + iconHandle: _tabIconHandle); + + Win32.SendMessage(_nppData._nppHandle, + (int)NppMsg.NPPM_DARKMODESUBCLASSANDTHEME, + new IntPtr(1), + _panel.Handle); + + _panel.BindRoot(_root, _stateStore, _stateStore.LastSelectedId); + } + + private static void OnShowPanel() + { + // Defensive: NPPN_READY normally runs before the user can click any menu item, + // but if something goes wrong, lazy-create the panel here as a fallback. + EnsurePanelRegistered(); if (_panel.Visible) _panel.HideDocked(_nppData._nppHandle); From ca469bda7866021b3cac9151e9923b48bbf2f93e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 20 May 2026 10:48:22 -0700 Subject: [PATCH 4/8] Suspend auto-removal during Notepad++ shutdown Without this, every open buffer fires NPPN_FILEBEFORECLOSE during shutdown, each one removing its file from the virtual tree before state.json is saved. After restart the user's folders persisted but every file they had open at shutdown was gone. Listen for NPPN_BEFORESHUTDOWN to set a shutdown flag; restore it on NPPN_CANCELSHUTDOWN (user backed out of save-dirty-files dialog). Skip OnFileClosed cleanup while the flag is set so the tree survives intact. Adds NPPN_BEFORESHUTDOWN/NPPN_CANCELSHUTDOWN to the local enum. --- VirtualTabGroups/Plugin/Npp/NppNotif.cs | 8 +++++++ VirtualTabGroups/Plugin/PluginMain.cs | 29 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/VirtualTabGroups/Plugin/Npp/NppNotif.cs b/VirtualTabGroups/Plugin/Npp/NppNotif.cs index 53918f8..19538d0 100644 --- a/VirtualTabGroups/Plugin/Npp/NppNotif.cs +++ b/VirtualTabGroups/Plugin/Npp/NppNotif.cs @@ -16,6 +16,14 @@ public enum NppNotif : uint NPPN_LANGCHANGED = NPPN_FIRST + 11, NPPN_WORDSTYLESUPDATED = NPPN_FIRST + 12, NPPN_SHORTCUTREMAPPED = NPPN_FIRST + 13, + // Fires when Notepad++ has decided to shut down but BEFORE per-file + // close notifications start. Use it to suppress per-file cleanup that + // would otherwise strip every open document out of the virtual tree + // (since each open buffer closes during the shutdown sequence). + NPPN_BEFORESHUTDOWN = NPPN_FIRST + 19, + // Fires if the user backs out of shutdown (e.g., they hit Cancel on the + // "Save dirty files?" dialog). Used to clear the shutdown flag. + NPPN_CANCELSHUTDOWN = NPPN_FIRST + 20, NPPN_DARKMODECHANGED = NPPN_FIRST + 27, } } diff --git a/VirtualTabGroups/Plugin/PluginMain.cs b/VirtualTabGroups/Plugin/PluginMain.cs index 52ed12b..08db4a6 100644 --- a/VirtualTabGroups/Plugin/PluginMain.cs +++ b/VirtualTabGroups/Plugin/PluginMain.cs @@ -51,6 +51,12 @@ public static class PluginMain private static System.Drawing.Icon _tabIcon; private static IntPtr _tabIconHandle = IntPtr.Zero; + // Set when Notepad++ signals it's about to shut down. Used to suppress per-file + // auto-removal during shutdown, since every open buffer fires NPPN_FILEBEFORECLOSE + // as Notepad++ closes them — without this guard, those notifications would strip + // every currently-open document out of the user's saved virtual tree. + private static bool _isShuttingDown; + // ---- Notepad++ unmanaged entry points ---- /// @@ -165,6 +171,21 @@ public static void beNotified(IntPtr notifyCodePtr) Theme?.RefreshColors(); break; + case NppNotif.NPPN_BEFORESHUTDOWN: + // Notepad++ is about to start closing buffers. Block auto-removal + // so the per-file close notifications that follow don't strip the + // user's open documents out of the virtual tree before we save. + _isShuttingDown = true; + CrashLog.Write("beNotified: NPPN_BEFORESHUTDOWN — auto-removal suspended"); + break; + + case NppNotif.NPPN_CANCELSHUTDOWN: + // User backed out of shutdown (e.g., Cancel on "Save dirty files?"). + // Restore normal auto-removal behavior. + _isShuttingDown = false; + CrashLog.Write("beNotified: NPPN_CANCELSHUTDOWN — auto-removal restored"); + break; + case NppNotif.NPPN_SHUTDOWN: OnNppShutdown(); break; @@ -511,6 +532,14 @@ private static int PurgeUnsavedEntries(FolderNode folder) private static void OnFileClosed(IntPtr bufferId) { CrashLog.Write("OnFileClosed: entered, bufferId=" + bufferId.ToInt64().ToString("X")); + if (_isShuttingDown) + { + // Notepad++ closes every open buffer as part of shutdown. Removing each + // one here would silently delete the user's entire open document set from + // the virtual tree before we save state. Skip the cleanup entirely. + CrashLog.Write("OnFileClosed: bailout - shutdown in progress, preserving tree"); + return; + } if (_root == null || _stateStore == null) { CrashLog.Write("OnFileClosed: bailout - _root or _stateStore null"); From c45a02457c30677e26da43e1b4c192617f552e6d Mon Sep 17 00:00:00 2001 From: James Date: Wed, 20 May 2026 10:56:34 -0700 Subject: [PATCH 5/8] Smart-purge unsaved entries on startup based on live buffers Previously, all non-rooted entries were stripped on load, so unsaved-buffer references like "new 32" never survived a restart. With Notepad++'s session backup enabled, those buffers DO persist with the same name across launches, and the virtual entries were being deleted needlessly. On NPPN_READY, fetch the current open-file list via NPPM_GETOPENFILENAMES and only purge unsaved entries whose name doesn't match a live buffer. Entries with a matching buffer stay; rooted (saved) entries always stay. --- VirtualTabGroups/Plugin/PluginMain.cs | 30 +++++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/VirtualTabGroups/Plugin/PluginMain.cs b/VirtualTabGroups/Plugin/PluginMain.cs index 08db4a6..990343a 100644 --- a/VirtualTabGroups/Plugin/PluginMain.cs +++ b/VirtualTabGroups/Plugin/PluginMain.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Reflection; using System.Runtime.InteropServices; @@ -222,10 +223,17 @@ private static void OnNppReady() if (_stateStore == null) return; _root = _stateStore.Load(); - int purgedStale = PurgeUnsavedEntries(_root); + // Smart purge: drop only the unsaved-buffer entries whose buffer didn't survive + // the restart. Entries that match a currently-open unsaved buffer (Notepad++ + // session-backup case) are preserved so the user's curated tree stays intact. + var liveBufferNames = new HashSet( + GetAllOpenFilePaths() ?? Array.Empty(), + StringComparer.OrdinalIgnoreCase); + int purgedStale = PurgeDeadUnsavedEntries(_root, liveBufferNames); if (purgedStale > 0) { - CrashLog.Write("OnNppReady: purged " + purgedStale + " stale unsaved-buffer entry(ies)"); + CrashLog.Write("OnNppReady: purged " + purgedStale + " dead unsaved-buffer entry(ies), kept " + + liveBufferNames.Count + " live buffer(s)"); _stateStore.MarkDirty(_root, _stateStore.LastSelectedId); } @@ -504,26 +512,30 @@ internal static void OpenFile(string path) } /// - /// Walks the tree and removes FileNode entries with non-rooted paths. - /// These are stale references to unsaved Notepad++ buffers from a prior session — - /// the buffer data didn't survive Notepad++ closing, so the references are dead. - /// Folders are kept regardless of contents (an empty folder is still meaningful). + /// Walks the tree and removes unsaved-buffer FileNode entries (non-rooted paths) + /// whose buffer is no longer present in Notepad++. If Notepad++'s session-backup + /// feature is enabled, unsaved buffers like "new 32" survive a restart with the + /// same name and the entry stays valid — we keep those. Saved files (rooted + /// paths) are always kept regardless of whether they're currently open. + /// Folders are kept regardless of contents. /// - private static int PurgeUnsavedEntries(FolderNode folder) + private static int PurgeDeadUnsavedEntries(FolderNode folder, HashSet liveBufferNames) { if (folder == null) return 0; int purged = 0; for (int i = folder.Children.Count - 1; i >= 0; i--) { var child = folder.Children[i]; - if (child is FileNode file && !System.IO.Path.IsPathRooted(file.Path)) + if (child is FileNode file + && !System.IO.Path.IsPathRooted(file.Path) + && !liveBufferNames.Contains(file.Path)) { folder.Children.RemoveAt(i); purged++; } else if (child is FolderNode sub) { - purged += PurgeUnsavedEntries(sub); + purged += PurgeDeadUnsavedEntries(sub, liveBufferNames); } } return purged; From b19a660d63d5575201a3165d096ea5f21e89eca4 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 20 May 2026 13:29:01 -0700 Subject: [PATCH 6/8] Trim smart-purge diagnostic logging to single summary line Per-path enumeration was only needed to verify NPPM_GETOPENFILENAMES vs. stored paths during the smart-purge bring-up. The logic is confirmed working, so reduce log noise to one line that only fires when entries are actually purged. --- VirtualTabGroups/Plugin/PluginMain.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/VirtualTabGroups/Plugin/PluginMain.cs b/VirtualTabGroups/Plugin/PluginMain.cs index 990343a..dd232a0 100644 --- a/VirtualTabGroups/Plugin/PluginMain.cs +++ b/VirtualTabGroups/Plugin/PluginMain.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Text; @@ -226,13 +227,12 @@ private static void OnNppReady() // Smart purge: drop only the unsaved-buffer entries whose buffer didn't survive // the restart. Entries that match a currently-open unsaved buffer (Notepad++ // session-backup case) are preserved so the user's curated tree stays intact. - var liveBufferNames = new HashSet( - GetAllOpenFilePaths() ?? Array.Empty(), - StringComparer.OrdinalIgnoreCase); + var openPaths = GetAllOpenFilePaths() ?? Array.Empty(); + var liveBufferNames = new HashSet(openPaths.Where(p => p != null), StringComparer.OrdinalIgnoreCase); int purgedStale = PurgeDeadUnsavedEntries(_root, liveBufferNames); if (purgedStale > 0) { - CrashLog.Write("OnNppReady: purged " + purgedStale + " dead unsaved-buffer entry(ies), kept " + CrashLog.Write("OnNppReady: smart-purge removed " + purgedStale + " dead unsaved entry(ies); " + liveBufferNames.Count + " live buffer(s)"); _stateStore.MarkDirty(_root, _stateStore.LastSelectedId); } From 0a821c790de43b3a7a1ac9cad2404478d315b0cb Mon Sep 17 00:00:00 2001 From: James Date: Wed, 20 May 2026 14:03:52 -0700 Subject: [PATCH 7/8] Dispose panel and tab icon on Notepad++ shutdown OnNppShutdown previously only disposed the state store, leaving the panel form (which owns a ToolTip, cached GDI brushes/pens, and an embedded DarkAwareTreeView) and the 16x16 tab Icon to be finalized off-thread once Notepad++ tore its window tree down. Dispose them synchronously so all managed resources are released by the time Notepad++ regains control. Adds shutdown breadcrumbs (entered / complete-in-Nms) so we can spot any future shutdown-path regression without re-instrumenting from scratch. --- VirtualTabGroups/Plugin/PluginMain.cs | 40 ++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/VirtualTabGroups/Plugin/PluginMain.cs b/VirtualTabGroups/Plugin/PluginMain.cs index dd232a0..430dd11 100644 --- a/VirtualTabGroups/Plugin/PluginMain.cs +++ b/VirtualTabGroups/Plugin/PluginMain.cs @@ -250,8 +250,46 @@ private static void OnNppReady() private static void OnNppShutdown() { + var sw = System.Diagnostics.Stopwatch.StartNew(); + CrashLog.Write("OnNppShutdown: entered"); + try { _stateStore?.Flush(); } - finally { _stateStore?.Dispose(); } + catch (Exception ex) { CrashLog.Write("OnNppShutdown: state flush failed - " + ex.Message); } + finally + { + try { _stateStore?.Dispose(); } + catch (Exception ex) { CrashLog.Write("OnNppShutdown: state dispose failed - " + ex.Message); } + } + + // Without explicit Dispose, the panel's owned resources (ToolTip's hidden window, + // cached GDI brushes/pens, the embedded TreeView) get finalized off-thread once + // Notepad++ tears its window tree down. Finalization can hold the process alive + // long enough that a quick re-launch of notepad++.exe sees the dying instance and + // does nothing — making the user click again. Dispose synchronously here. + try + { + if (_panel != null && !_panel.IsDisposed) + { + _panel.Dispose(); + } + } + catch (Exception ex) { CrashLog.Write("OnNppShutdown: panel dispose failed - " + ex.Message); } + finally { _panel = null; } + + // Release the 16x16 tab icon. The HICON is owned by the managed Icon, so + // disposing the Icon releases the GDI handle (no separate DestroyIcon needed). + try + { + _tabIcon?.Dispose(); + } + catch (Exception ex) { CrashLog.Write("OnNppShutdown: icon dispose failed - " + ex.Message); } + finally + { + _tabIcon = null; + _tabIconHandle = IntPtr.Zero; + } + + CrashLog.Write("OnNppShutdown: complete in " + sw.ElapsedMilliseconds + "ms"); } // ---- Helpers ---- From 018efbdfc04e7e06dd43178ecad8c6b1a6ecafd6 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 20 May 2026 14:21:56 -0700 Subject: [PATCH 8/8] Document panel persistence, unsaved-buffer survival, and N++ shutdown delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README updates for the recent persistence work: - Add a Persistence bullet for the new panel-visibility-survives-restart behavior (eager registration on NPPN_READY). - Replace the stale "unsaved buffers are session-only" bullet with the correct smart-purge story: unsaved buffers survive a restart when Notepad++'s session backup is enabled, and stale references are silently cleaned up when it isn't. - Note that auto-removal is suspended during Notepad++ shutdown so the per-tab close notifications don't strip every open file from the tree. - Troubleshooting: add an explanation of the "Notepad++ takes two clicks to relaunch" symptom — caused by Notepad++ writing session backups for unsaved buffers, not the plugin (confirmed with plugin.log; our shutdown completes in ~30 ms). --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1e608c2..cfce26c 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ The panel is **purely virtual**. It does not scan your disk, mirror folders, or ### Persistence - **State survives restarts.** Your virtual tree (folders, files, expanded state, last selection) is saved to a JSON file in Notepad++'s plugin config directory and loaded again on next launch. -- **Auto-removal when files close.** Closing a file via Notepad++'s tab `X` removes it from any virtual folder it was in. Saves you from accumulating stale references. -- **Unsaved buffers are session-only.** You can absolutely add `new 17` or any other unsaved scratch buffer to a folder — useful for organizing work-in-progress alongside saved files. On Notepad++ restart, references to unsaved buffers are silently cleaned up (their data didn't survive shutdown, so the references would be dead anyway). +- **Panel visibility survives restarts.** If the panel was open when you closed Notepad++, it reopens automatically next launch — same way the built-in docked panels behave. No need to bring it up from the Plugins menu every session. The panel registers itself with Notepad++'s docking manager early enough that the saved layout from `dockingMgr.xml` applies cleanly. +- **Auto-removal when files close.** Closing a file via Notepad++'s tab `X` removes it from any virtual folder it was in. Saves you from accumulating stale references. The auto-removal is suspended during Notepad++ shutdown so the per-tab close notifications don't strip everything that was open at exit time. +- **Unsaved buffers can persist across sessions.** Add `new 17` or any other unsaved scratch buffer to a folder — useful for organizing work-in-progress alongside saved files. If Notepad++'s session backup is enabled (*Settings → Preferences → Backup → "Remember current session for next launch"*), the buffer comes back on relaunch with the same name and your virtual entry stays valid. With session backup off, the buffer is gone after shutdown and the dead reference is silently cleaned up on the next launch — saved-file entries are unaffected either way. - **Corrupt-state recovery.** If something hand-edits or corrupts the state file, the plugin backs it up to `state.json.corrupt-yyyyMMdd-HHmmssZ-` and starts with a clean tree rather than crashing. A dialog tells you where the backup landed. - **Future-version safety.** If a future version of the plugin writes a newer schema, an older plugin reading that file enters read-only mode for the session rather than overwriting newer data. @@ -287,6 +288,10 @@ If you're interested in any of these, an issue or PR is welcome — see [Contrib ### "Plugin is not compatible" error on launch - The most common cause is deploying a Debug build (links against `VCRUNTIME140D.dll`, a non-redistributable). Always deploy from `bin\Release\x64\`, not `bin\Debug\`. +### Notepad++ takes two clicks to relaunch after closing +- Not caused by this plugin. If you have several unsaved scratch buffers open at shutdown with session backup enabled, Notepad++ writes each one to `%appdata%\Notepad++\backup\` before the process actually exits, holding its single-instance mutex for a few extra seconds. Clicking the Notepad++ icon during that window is bounced to the dying process (invisibly) and does nothing — a second click after the process is fully gone launches a fresh instance normally. +- Confirmed with `plugin.log`: our shutdown disposal completes in ~30 ms, and the gap before the next launch is entirely Notepad++-side. Closing unused scratch buffers before quitting (or disabling session backup if you don't need it) shortens the gap. + ### Things break in unexpected ways - Open `%appdata%\Notepad++\plugins\config\VirtualTabGroups\plugin.log`. The plugin logs every notable event (startup, dialogs, exceptions with stack traces, breadcrumbs through user actions). Most issues are visible in one read. - If you file an issue, attaching the relevant section of `plugin.log` makes a fix dramatically faster.