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..cfce26c 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,15 @@ 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 - **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. @@ -199,7 +202,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 +243,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 @@ -247,10 +254,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. @@ -282,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. @@ -297,9 +307,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/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 fcfb9f1..430dd11 100644 --- a/VirtualTabGroups/Plugin/PluginMain.cs +++ b/VirtualTabGroups/Plugin/PluginMain.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Text; @@ -51,6 +53,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 +173,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; @@ -201,21 +224,72 @@ 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 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 + " stale unsaved-buffer entry(ies)"); + CrashLog.Write("OnNppReady: smart-purge removed " + purgedStale + " dead unsaved entry(ies); " + + liveBufferNames.Count + " live buffer(s)"); _stateStore.MarkDirty(_root, _stateStore.LastSelectedId); } 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() { + 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 ---- @@ -327,50 +401,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); @@ -459,26 +550,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; @@ -487,6 +582,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"); 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 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