From 05c1128956fe180307a40ce19e3929c1e18b768f Mon Sep 17 00:00:00 2001 From: leoshone Date: Thu, 3 Sep 2026 18:10:20 +0800 Subject: [PATCH 01/18] Persist the tree view zoom level across Notepad++ sessions The plugin already ships a zoom slider for the JSON tree (80%..250%), but the chosen level only lived in the slider control: closing Notepad++ and starting it again always fell back to 100%. Store the zoom percentage in JSONViewer.ini under [Others] TREE_ZOOM and re-apply it when the dialog is initialised. The value is written only when it actually changes, and while the slider thumb is being dragged (TB_THUMBTRACK) nothing is written, so a drag gesture produces a single write at the end instead of one per pixel. Purely additive: the existing ini keys and the default behaviour are untouched. --- src/NppJsonViewer/Define.h | 6 ++++++ src/NppJsonViewer/JsonViewDlg.cpp | 25 +++++++++++++++++++++++++ src/NppJsonViewer/JsonViewDlg.h | 1 + src/NppJsonViewer/NppJsonPlugin.cpp | 3 ++- src/NppJsonViewer/Profile.cpp | 5 +++++ tests/UnitTest/ProfileTest.cpp | 24 ++++++++++++++++++++++++ 6 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/NppJsonViewer/Define.h b/src/NppJsonViewer/Define.h index 5ee80f1..3834ddb 100644 --- a/src/NppJsonViewer/Define.h +++ b/src/NppJsonViewer/Define.h @@ -1,4 +1,6 @@ #pragma once +#include + #include "PluginInterface.h" // Define the number of plugin commands here @@ -64,6 +66,7 @@ const TCHAR STR_INI_FORMATTING_INDENTCOUNT[] = TEXT("INDENTATION_COUNT"); const TCHAR STR_INI_OTHER_SEC[] = TEXT("Others"); const TCHAR STR_INI_OTHER_FOLLOW_TAB[] = TEXT("FOLLOW_TAB"); +const TCHAR STR_INI_OTHER_TREE_ZOOM[] = TEXT("TREE_ZOOM"); const TCHAR STR_INI_OTHER_AUTO_FORMAT[] = TEXT("AUTO_FORMAT"); const TCHAR STR_INI_OTHER_USE_HIGHLIGHT[] = TEXT("USE_JSON_HIGHLIGHT"); const TCHAR STR_INI_OTHER_IGNORE_COMMENT[] = TEXT("IGNORE_COMMENT"); @@ -117,4 +120,7 @@ struct Setting bool bAutoFormat = false; bool bUseJsonHighlight = true; ParseOptions parseOptions {}; + int nTreeZoom = 100; // Tree view font zoom in percent (80..250) + + std::wstring configPath; // Full path of JSONViewer.ini (not persisted) }; diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index 4080af0..ac9e10c 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -936,6 +936,19 @@ void JsonViewDlg::UpdateUIOnZoom(int zoomPercentage) const SetTreeViewZoom(zoomFactor); } +void JsonViewDlg::PersistZoom(int zoomPercentage) +{ + const auto& zoomRange = m_pTreeViewZoom->GetRange(); + if (zoomPercentage < zoomRange.m_nMinZoom || zoomPercentage > zoomRange.m_nMaxZoom) + return; + + if (m_pSetting->nTreeZoom != zoomPercentage) + { + m_pSetting->nTreeZoom = zoomPercentage; + ProfileSetting(m_pSetting->configPath).SetSettings(*m_pSetting); + } +} + void JsonViewDlg::HandleZoomOnScroll(WPARAM wParam) const { int pos = GetZoomLevel(); // Current zoom level @@ -1101,6 +1114,10 @@ INT_PTR JsonViewDlg::run_dlgProc(UINT message, WPARAM wParam, LPARAM lParam) m_pTreeView->OnInit(getHSelf(), IDC_TREE); m_pTreeViewZoom->OnInit(getHSelf(), IDC_ZOOM_SLIDER, IDC_ZOOM_PERCENT); + // Apply the zoom level restored from JSONViewer.ini + const auto& zoomRange = m_pTreeViewZoom->GetRange(); + UpdateUIOnZoom(std::clamp(m_pSetting->nTreeZoom, zoomRange.m_nMinZoom, zoomRange.m_nMaxZoom)); + PrepareButtons(); // Set default node path as JSON @@ -1182,6 +1199,7 @@ INT_PTR JsonViewDlg::run_dlgProc(UINT message, WPARAM wParam, LPARAM lParam) if (GetKeyState(VK_CONTROL) & 0x8000) { HandleZoomOnScroll(wParam); + PersistZoom(GetZoomLevel()); return TRUE; } return FALSE; @@ -1193,9 +1211,16 @@ INT_PTR JsonViewDlg::run_dlgProc(UINT message, WPARAM wParam, LPARAM lParam) if (reinterpret_cast(lParam) == hSlider) { + // While the thumb is being dragged (TB_THUMBTRACK) the position + // changes continuously, so only persist once the gesture is over. + const bool bDragging = (HIWORD(wParam) == TB_THUMBTRACK); + int pos = m_pTreeViewZoom->GetPosition(); UpdateUIOnZoom(pos); + if (!bDragging) + PersistZoom(pos); + return TRUE; } return FALSE; diff --git a/src/NppJsonViewer/JsonViewDlg.h b/src/NppJsonViewer/JsonViewDlg.h index 00cc599..605db34 100644 --- a/src/NppJsonViewer/JsonViewDlg.h +++ b/src/NppJsonViewer/JsonViewDlg.h @@ -95,6 +95,7 @@ class JsonViewDlg void SetTreeViewZoom(double dwZoomFactor) const; void UpdateUIOnZoom(int zoomPercentage) const; void HandleZoomOnScroll(WPARAM wParam) const; + void PersistZoom(int zoomPercentage); void HandleTreeEvents(LPARAM lParam) const; diff --git a/src/NppJsonViewer/NppJsonPlugin.cpp b/src/NppJsonViewer/NppJsonPlugin.cpp index f251481..e7224ff 100644 --- a/src/NppJsonViewer/NppJsonPlugin.cpp +++ b/src/NppJsonViewer/NppJsonPlugin.cpp @@ -169,7 +169,8 @@ void NppJsonPlugin::ConstructSetting() { if (!m_pSetting) { - m_pSetting = std::make_shared(); + m_pSetting = std::make_shared(); + m_pSetting->configPath = m_configPath; ProfileSetting(m_configPath).GetSettings(*m_pSetting); } } diff --git a/src/NppJsonViewer/Profile.cpp b/src/NppJsonViewer/Profile.cpp index 7f13a18..cf6c9c6 100644 --- a/src/NppJsonViewer/Profile.cpp +++ b/src/NppJsonViewer/Profile.cpp @@ -94,6 +94,10 @@ bool ProfileSetting::GetSettings(Setting& info) const if (bRetVal) info.bFollowCurrentTab = static_cast(nVal); + bRetVal = bRetVal && ReadValue(STR_INI_OTHER_SEC, STR_INI_OTHER_TREE_ZOOM, nVal, info.nTreeZoom); + if (bRetVal) + info.nTreeZoom = nVal; + bRetVal = bRetVal && ReadValue(STR_INI_OTHER_SEC, STR_INI_OTHER_AUTO_FORMAT, nVal, info.bAutoFormat); if (bRetVal) info.bAutoFormat = static_cast(nVal); @@ -127,6 +131,7 @@ bool ProfileSetting::SetSettings(const Setting& info) const bRetVal = bRetVal && WriteValue(STR_INI_FORMATTING_SEC, STR_INI_FORMATTING_INDENTCOUNT, info.indent.len); bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_FOLLOW_TAB, info.bFollowCurrentTab); + bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_TREE_ZOOM, info.nTreeZoom); bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_AUTO_FORMAT, info.bAutoFormat); bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_USE_HIGHLIGHT, info.bUseJsonHighlight); bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_IGNORE_COMMENT, info.parseOptions.bIgnoreComment); diff --git a/tests/UnitTest/ProfileTest.cpp b/tests/UnitTest/ProfileTest.cpp index 10ab846..873d9f6 100644 --- a/tests/UnitTest/ProfileTest.cpp +++ b/tests/UnitTest/ProfileTest.cpp @@ -137,6 +137,7 @@ namespace ProfileSettingTests EXPECT_EQ(setting.bFollowCurrentTab, false); EXPECT_EQ(setting.bAutoFormat, false); EXPECT_EQ(setting.bUseJsonHighlight, true); + EXPECT_EQ(setting.nTreeZoom, 100); EXPECT_EQ(setting.parseOptions.bIgnoreComment, true); EXPECT_EQ(setting.parseOptions.bIgnoreTrailingComma, true); @@ -173,9 +174,32 @@ namespace ProfileSettingTests EXPECT_EQ(actual.bFollowCurrentTab, expected.bFollowCurrentTab); EXPECT_EQ(actual.bAutoFormat, expected.bAutoFormat); EXPECT_EQ(actual.bUseJsonHighlight, expected.bUseJsonHighlight); + EXPECT_EQ(actual.nTreeZoom, expected.nTreeZoom); EXPECT_EQ(actual.parseOptions.bIgnoreComment, expected.parseOptions.bIgnoreComment); EXPECT_EQ(actual.parseOptions.bIgnoreTrailingComma, expected.parseOptions.bIgnoreTrailingComma); EXPECT_EQ(actual.parseOptions.bReplaceUndefined, expected.parseOptions.bReplaceUndefined); } + + TEST_F(ProfileTest, TreeZoom_RoundTrip) + { + // a profile without the TREE_ZOOM key falls back to 100% + { + Setting setting {}; + EXPECT_TRUE(m_pProfile->GetSettings(setting)); + EXPECT_EQ(setting.nTreeZoom, 100); + } + + // every value inside the slider range must survive a write/read cycle + for (int zoom : { 80, 100, 150, 200, 250 }) + { + Setting expected {}; + expected.nTreeZoom = zoom; + ASSERT_TRUE(m_pProfile->SetSettings(expected)) << zoom; + + Setting actual {}; + ASSERT_TRUE(m_pProfile->GetSettings(actual)) << zoom; + EXPECT_EQ(actual.nTreeZoom, zoom); + } + } } // namespace ProfileSettingTests From 7b8cc3679d4a6e522ef9fe37ff9e0dd7aa8b60ea Mon Sep 17 00:00:00 2001 From: leoshone Date: Thu, 3 Sep 2026 18:37:41 +0800 Subject: [PATCH 02/18] Keep the tree expansion state when refreshing the JSON tree "Refresh JSON Tree" rebuilds every node, so the tree always came back fully collapsed - even when the user only wanted to re-read a document they were already looking at. Capture which nodes are expanded and which one is selected before the tree is thrown away, then re-apply that state onto the freshly built tree, matching nodes by path. Paths that no longer exist (the document changed in the meantime) are silently dropped, and nodes that are new stay collapsed. The state is keyed by node path, which is the list of keys from the tree root down to a node. The pure path arithmetic lives in the new TreeExpansion.h/.cpp so it can be unit tested without a window. DrawJsonTree() gained a bPreserveExpansion parameter that defaults to false, so every other caller (panel opening, formatting, compressing, sorting) keeps behaving exactly as before; only the refresh button opts in. --- src/NppJsonViewer/JsonViewDlg.cpp | 174 +++++++++++++++++- src/NppJsonViewer/JsonViewDlg.h | 21 ++- src/NppJsonViewer/NPPJSONViewer.vcxproj | 2 + .../NPPJSONViewer.vcxproj.filters | 6 + src/NppJsonViewer/TreeExpansion.cpp | 75 ++++++++ src/NppJsonViewer/TreeExpansion.h | 48 +++++ src/NppJsonViewer/TreeViewCtrl.cpp | 10 + src/NppJsonViewer/TreeViewCtrl.h | 6 +- tests/UnitTest/TreeExpansionTest.cpp | 133 +++++++++++++ tests/UnitTest/UnitTest.vcxproj | 3 + 10 files changed, 473 insertions(+), 5 deletions(-) create mode 100644 src/NppJsonViewer/TreeExpansion.cpp create mode 100644 src/NppJsonViewer/TreeExpansion.h create mode 100644 tests/UnitTest/TreeExpansionTest.cpp diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index 4080af0..348b117 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -1,4 +1,5 @@ #include +#include #include #include "JsonViewDlg.h" @@ -333,7 +334,7 @@ void JsonViewDlg::ValidateJson() DrawJsonTree(); } -void JsonViewDlg::DrawJsonTree() +void JsonViewDlg::DrawJsonTree(bool bPreserveExpansion) { UpdateTitle(); @@ -341,6 +342,13 @@ void JsonViewDlg::DrawJsonTree() std::vector ctrls = {IDC_BTN_REFRESH, IDC_BTN_VALIDATE, IDC_BTN_FORMAT, IDC_BTN_SEARCH, IDC_EDT_SEARCH}; EnableControls(ctrls, false); + // Capture the expansion/selection state before the tree is thrown away, so + // that it can be re-applied onto the freshly built one. + TreeExpansionState expState; + const bool bHasCurrentTree = m_pTreeView->GetRoot() && m_pTreeView->GetNodeCount() > 1; + if (bPreserveExpansion && bHasCurrentTree) + expState = CaptureExpansionState(); + HTREEITEM rootNode = nullptr; rootNode = m_pTreeView->InitTree(); @@ -380,6 +388,11 @@ void JsonViewDlg::DrawJsonTree() m_pTreeView->Expand(rootNode); + // Re-apply the state of the previous tree. Paths that no longer exist + // (the document changed in the meantime) are silently dropped. + if (bPreserveExpansion && bHasCurrentTree && m_pTreeView->GetNodeCount() > 1) + ApplyExpansionState(expState); + // Enable all buttons and treeView EnableControls(ctrls, true); } @@ -565,6 +578,163 @@ void JsonViewDlg::SearchInTree() } } +TreeExpansionState JsonViewDlg::CaptureExpansionState() const +{ + TreeExpansionState expState; + + auto hRoot = m_pTreeView->GetRoot(); + if (!hRoot) + return expState; + + // Collect the key path of every node (root excluded) with its expanded flag + std::function&)> walk; + walk = [&](HTREEITEM hParent, const std::vector& parentKeys) { + for (HTREEITEM hChild = m_pTreeView->GetChildItem(hParent); hChild; + hChild = m_pTreeView->GetNextSibling(hChild)) + { + auto keys = parentKeys; + auto nodeKey = GetPathKey(hChild); + keys.push_back(nodeKey); + + expState.expandedPaths[TreeExpansionHelper::JoinPath(parentKeys, nodeKey)] = m_pTreeView->IsExpanded(hChild); + + walk(hChild, keys); + } + }; + + walk(hRoot, {}); + + expState.selectedPath = GetCurrentSelectedPath(); + + return expState; +} + +void JsonViewDlg::ApplyExpansionState(const TreeExpansionState& state) +{ + auto paths = CollectExpandedPaths(); + auto [pathsToExpand, pathToSelect] = TreeExpansionHelper::MatchExpansion(state, paths); + + for (const auto& path : pathsToExpand) + { + auto keys = TreeExpansionHelper::SplitPath(path); + if (!keys.empty()) + ExpandByPath(keys); + } + + if (!pathToSelect.empty()) + SelectByPath(pathToSelect); +} + +std::vector JsonViewDlg::CollectExpandedPaths() const +{ + std::vector paths; + + auto hRoot = m_pTreeView->GetRoot(); + if (!hRoot) + return paths; + + std::function&)> walk; + walk = [&](HTREEITEM hParent, const std::vector& parentKeys) { + for (HTREEITEM hChild = m_pTreeView->GetChildItem(hParent); hChild; + hChild = m_pTreeView->GetNextSibling(hChild)) + { + auto nodeKey = GetPathKey(hChild); + + auto keys = parentKeys; + keys.push_back(nodeKey); + + paths.push_back(TreeExpansionHelper::JoinPath(parentKeys, nodeKey)); + + walk(hChild, keys); + } + }; + + walk(hRoot, {}); + + return paths; +} + +std::vector JsonViewDlg::GetCurrentSelectedPath() const +{ + std::vector path; + + auto hRoot = m_pTreeView->GetRoot(); + auto hSelected = m_pTreeView->GetSelection(); + if (!hRoot || !hSelected || hSelected == hRoot) + return path; + + // Walk up to the root and reverse the collected keys on the way back + std::vector reversedKeys; + for (HTREEITEM h = hSelected; h && h != hRoot; h = m_pTreeView->GetParentItem(h)) + { + reversedKeys.push_back(GetPathKey(h)); + } + + // Guard against a selection that does not belong to this tree anymore + if (m_pTreeView->GetParentItem(hSelected) == nullptr) + return {}; + + path.assign(reversedKeys.rbegin(), reversedKeys.rend()); + return path; +} + +auto JsonViewDlg::GetPathKey(HTREEITEM hti) const -> std::wstring +{ + auto key = m_pTreeView->GetNodeKey(hti); + + // Remove the surrounding quotes of object keys: "name" -> name. + // Array indices ([0]) and unquoted keys are returned untouched. + if (key.size() >= 2 && key.front() == L'"' && key.back() == L'"') + key = key.substr(1, key.size() - 2); + + return key; +} + +auto JsonViewDlg::FindNodeByPath(const std::vector& path) const -> HTREEITEM +{ + if (path.empty()) + return nullptr; + + auto hRoot = m_pTreeView->GetRoot(); + if (!hRoot) + return nullptr; + + HTREEITEM hCurrent = hRoot; + for (const auto& key : path) + { + HTREEITEM hNext = m_pTreeView->GetChildItem(hCurrent); + while (hNext && GetPathKey(hNext) != key) + { + hNext = m_pTreeView->GetNextSibling(hNext); + } + + if (!hNext) + return nullptr; + + hCurrent = hNext; + } + + return hCurrent == hRoot ? nullptr : hCurrent; +} + +void JsonViewDlg::ExpandByPath(const std::vector& path) +{ + auto hNode = FindNodeByPath(path); + if (hNode) + m_pTreeView->Expand(hNode); +} + +void JsonViewDlg::SelectByPath(const std::vector& path) +{ + auto hNode = FindNodeByPath(path); + if (hNode) + { + // TreeView_SelectItem expands collapsed ancestors on its own, so the + // expansion state restored just before is left untouched. + m_pTreeView->SetSelection(hNode); + } +} + void JsonViewDlg::UpdateTitle() { const auto titleFileName = GetTitleFileName(); @@ -1115,7 +1285,7 @@ INT_PTR JsonViewDlg::run_dlgProc(UINT message, WPARAM wParam, LPARAM lParam) { // Handle Button events case IDC_BTN_REFRESH: - DrawJsonTree(); + DrawJsonTree(true); break; case IDC_BTN_FORMAT: diff --git a/src/NppJsonViewer/JsonViewDlg.h b/src/NppJsonViewer/JsonViewDlg.h index 00cc599..3e81db2 100644 --- a/src/NppJsonViewer/JsonViewDlg.h +++ b/src/NppJsonViewer/JsonViewDlg.h @@ -14,6 +14,7 @@ #include "JsonHandler.h" #include "JsonNode.h" #include "TreeHandler.h" +#include "TreeExpansion.h" class JsonViewDlg @@ -53,7 +54,7 @@ class JsonViewDlg void AppendNodeCount(HTREEITEM node, unsigned elementCount, bool bArray) override; private: - void DrawJsonTree(); + void DrawJsonTree(bool bPreserveExpansion = false); void ReDrawJsonTree(bool bForce = false); void HighlightAsJson(bool bForcefully = false) const; auto PopulateTreeUsingSax(HTREEITEM tree_root, const std::string& jsonText) -> std::optional; @@ -66,6 +67,24 @@ class JsonViewDlg void SearchInTree(); + // Expansion/selection state, captured before the tree is rebuilt and + // re-applied afterwards (see DrawJsonTree(bPreserveExpansion = true)). + auto CaptureExpansionState() const -> TreeExpansionState; + void ApplyExpansionState(const TreeExpansionState& state); + + auto CollectExpandedPaths() const -> std::vector; + auto GetCurrentSelectedPath() const -> std::vector; + void ExpandByPath(const std::vector& path); + void SelectByPath(const std::vector& path); + + // Key of a node as used inside a node path: the raw key without the + // surrounding quotes ("name" -> name, [0] -> [0]). + auto GetPathKey(HTREEITEM hti) const -> std::wstring; + + // Resolve a key path (relative to the tree root) back to a node. + // Returns nullptr when any level of the path cannot be found. + auto FindNodeByPath(const std::vector& path) const -> HTREEITEM; + auto GetTitleFileName() const -> std::wstring; void PrepareButtons(); void SetIconAndTooltip(eButton ctrlType, const std::wstring& toolTip); diff --git a/src/NppJsonViewer/NPPJSONViewer.vcxproj b/src/NppJsonViewer/NPPJSONViewer.vcxproj index fe41d18..1015296 100644 --- a/src/NppJsonViewer/NPPJSONViewer.vcxproj +++ b/src/NppJsonViewer/NPPJSONViewer.vcxproj @@ -197,6 +197,7 @@ + @@ -217,6 +218,7 @@ + diff --git a/src/NppJsonViewer/NPPJSONViewer.vcxproj.filters b/src/NppJsonViewer/NPPJSONViewer.vcxproj.filters index 7193f31..a4b6946 100644 --- a/src/NppJsonViewer/NPPJSONViewer.vcxproj.filters +++ b/src/NppJsonViewer/NPPJSONViewer.vcxproj.filters @@ -57,6 +57,9 @@ Source Files + + Source Files + Source Files @@ -110,6 +113,9 @@ Header Files + + Header Files + Header Files diff --git a/src/NppJsonViewer/TreeExpansion.cpp b/src/NppJsonViewer/TreeExpansion.cpp new file mode 100644 index 0000000..59edeba --- /dev/null +++ b/src/NppJsonViewer/TreeExpansion.cpp @@ -0,0 +1,75 @@ +#include "TreeExpansion.h" + +#include + +auto TreeExpansionHelper::SplitPath(const std::wstring& path) -> std::vector +{ + std::vector keys; + + if (path.empty()) + return keys; + + size_t start = 0; + while (start <= path.size()) + { + auto pos = path.find(L'.', start); + if (pos == std::wstring::npos) + { + keys.emplace_back(path.substr(start)); + break; + } + + keys.emplace_back(path.substr(start, pos - start)); + start = pos + 1; + } + + // An empty trailing key (e.g. a path ending with a dot) is dropped + if (!keys.empty() && keys.back().empty()) + keys.pop_back(); + + return keys; +} + +auto TreeExpansionHelper::JoinPath(const std::vector& parentKeys, const std::wstring& key) -> std::wstring +{ + std::wstring path; + for (const auto& part : parentKeys) + { + path += part; + path += L'.'; + } + path += key; + return path; +} + +auto TreeExpansionHelper::MatchExpansion(const TreeExpansionState& oldState, + const std::vector& newPaths) + -> std::pair, std::vector> +{ + std::vector pathsToExpand; + + for (const auto& path : newPaths) + { + auto find = oldState.expandedPaths.find(path); + if (find != oldState.expandedPaths.cend() && find->second) + pathsToExpand.push_back(path); + } + + std::vector pathToSelect; + if (!oldState.selectedPath.empty()) + { + // Reconstruct the selected path in "joined" form and check existence + std::wstring joined; + for (const auto& key : oldState.selectedPath) + { + if (!joined.empty()) + joined += L'.'; + joined += key; + } + + if (std::find(newPaths.cbegin(), newPaths.cend(), joined) != newPaths.cend()) + pathToSelect = oldState.selectedPath; + } + + return { pathsToExpand, pathToSelect }; +} diff --git a/src/NppJsonViewer/TreeExpansion.h b/src/NppJsonViewer/TreeExpansion.h new file mode 100644 index 0000000..961d835 --- /dev/null +++ b/src/NppJsonViewer/TreeExpansion.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include + +/* + * TreeExpansionState captures which nodes of the JSON tree are expanded and + * which one is selected, keyed by node path. + * + * It exists so that the state can be re-applied onto a freshly built tree. + * The typical case is "Refresh JSON Tree", which rebuilds every node and + * therefore loses the expansion the user set up. Paths that no longer exist in + * the new tree are simply dropped. + * + * A path is the list of node keys from the tree root down to a node, joined + * with '.' (e.g. "root.child", "root.[0].[1].key"). The tree root itself is not + * part of the path. + */ +struct TreeExpansionState +{ + std::unordered_map expandedPaths; // path -> was expanded + std::vector selectedPath; // key-path of the selected node, empty when none +}; + +class TreeExpansionHelper +{ +public: + // Split a node path into its keys: "root.[0].key" -> { "root", "[0]", "key" } + static auto SplitPath(const std::wstring& path) -> std::vector; + + // Build the path of a node from the path of its parent and the node key. + static auto JoinPath(const std::vector& parentKeys, const std::wstring& key) -> std::wstring; + + /* + * Compute which paths of `oldState` must be re-expanded onto a new tree + * whose node paths are listed in `newPaths`, and where the selection has to + * be restored (only if that path still exists). + * + * Duplicate keys resolve to the first matching node (accepted trade-off: + * JSON object keys are unique in practice, and making this exact would + * require disambiguating sibling order as well). + */ + static auto MatchExpansion(const TreeExpansionState& oldState, + const std::vector& newPaths) + -> std::pair /*pathsToExpand*/, std::vector /*pathToSelect*/>; +}; diff --git a/src/NppJsonViewer/TreeViewCtrl.cpp b/src/NppJsonViewer/TreeViewCtrl.cpp index d2ff8fd..95860e6 100644 --- a/src/NppJsonViewer/TreeViewCtrl.cpp +++ b/src/NppJsonViewer/TreeViewCtrl.cpp @@ -48,6 +48,16 @@ auto TreeViewCtrl::InsertNode(const std::wstring& text, LPARAM lparam, HTREEITEM return item; } +auto TreeViewCtrl::GetChildItem(HTREEITEM node) const -> HTREEITEM +{ + return TreeView_GetNextItem(m_hTree, node, TVGN_CHILD); +} + +auto TreeViewCtrl::GetNextSibling(HTREEITEM node) const -> HTREEITEM +{ + return TreeView_GetNextItem(m_hTree, node, TVGN_NEXT); +} + void TreeViewCtrl::UpdateNodeText(HTREEITEM node, const std::wstring& text) { auto tvi = std::make_unique(); diff --git a/src/NppJsonViewer/TreeViewCtrl.h b/src/NppJsonViewer/TreeViewCtrl.h index c5153af..e67fa3d 100644 --- a/src/NppJsonViewer/TreeViewCtrl.h +++ b/src/NppJsonViewer/TreeViewCtrl.h @@ -30,6 +30,10 @@ class TreeViewCtrl void UpdateNodeText(HTREEITEM node, const std::wstring& text); auto GetNodeCount() const -> unsigned int; + auto GetChildItem(HTREEITEM node) const -> HTREEITEM; + auto GetNextSibling(HTREEITEM node) const -> HTREEITEM; + auto GetParentItem(HTREEITEM node) const -> HTREEITEM; + bool IsExpanded(HTREEITEM node) const; bool IsThisOrAnyChildExpanded(HTREEITEM node) const; bool IsThisOrAnyChildCollapsed(HTREEITEM node) const; @@ -66,8 +70,6 @@ class TreeViewCtrl private: void ExpandOrCollapse(HTREEITEM node, UINT_PTR code) const; - HTREEITEM GetParentItem(HTREEITEM hti) const; - bool GetTVItem(HTREEITEM hti, TVITEM* tvi) const; bool SetTVItem(TVITEM* tvi) const; diff --git a/tests/UnitTest/TreeExpansionTest.cpp b/tests/UnitTest/TreeExpansionTest.cpp new file mode 100644 index 0000000..aca99db --- /dev/null +++ b/tests/UnitTest/TreeExpansionTest.cpp @@ -0,0 +1,133 @@ +#include + +#include "TreeExpansion.h" + +namespace TreeExpansionTests +{ + TEST(SplitPath, EmptyPath) + { + EXPECT_TRUE(TreeExpansionHelper::SplitPath(L"").empty()); + } + + TEST(SplitPath, SingleKey) + { + auto keys = TreeExpansionHelper::SplitPath(L"root"); + ASSERT_EQ(keys.size(), 1u); + EXPECT_EQ(keys[0], L"root"); + } + + TEST(SplitPath, MultipleKeys) + { + auto keys = TreeExpansionHelper::SplitPath(L"root.child.[0].name"); + ASSERT_EQ(keys.size(), 4u); + EXPECT_EQ(keys[0], L"root"); + EXPECT_EQ(keys[1], L"child"); + EXPECT_EQ(keys[2], L"[0]"); + EXPECT_EQ(keys[3], L"name"); + } + + TEST(SplitPath, TrailingDotDropped) + { + auto keys = TreeExpansionHelper::SplitPath(L"root.child."); + ASSERT_EQ(keys.size(), 2u); + EXPECT_EQ(keys[1], L"child"); + } + + TEST(JoinPath, EmptyParents) + { + EXPECT_EQ(TreeExpansionHelper::JoinPath({}, L"root"), L"root"); + } + + TEST(JoinPath, Nested) + { + EXPECT_EQ(TreeExpansionHelper::JoinPath({ L"root", L"[0]" }, L"key"), L"root.[0].key"); + } + + TEST(MatchExpansion, EmptyOldState) + { + TreeExpansionState oldState; + auto [toExpand, toSelect] = TreeExpansionHelper::MatchExpansion(oldState, { L"a", L"b" }); + EXPECT_TRUE(toExpand.empty()); + EXPECT_TRUE(toSelect.empty()); + } + + TEST(MatchExpansion, PathStillExists) + { + TreeExpansionState oldState; + oldState.expandedPaths[L"root.child"] = true; + oldState.expandedPaths[L"root.gone"] = false; + + auto [toExpand, toSelect] = TreeExpansionHelper::MatchExpansion(oldState, { L"root", L"root.child", L"root.other" }); + ASSERT_EQ(toExpand.size(), 1u); + EXPECT_EQ(toExpand[0], L"root.child"); + EXPECT_TRUE(toSelect.empty()); + } + + TEST(MatchExpansion, CollapsedPathsNotReExpanded) + { + TreeExpansionState oldState; + oldState.expandedPaths[L"root.a"] = false; + oldState.expandedPaths[L"root.b"] = true; + + auto [toExpand, toSelect] = TreeExpansionHelper::MatchExpansion(oldState, { L"root.a", L"root.b" }); + ASSERT_EQ(toExpand.size(), 1u); + EXPECT_EQ(toExpand[0], L"root.b"); + } + + TEST(MatchExpansion, SelectionRestoredWhenExists) + { + TreeExpansionState oldState; + oldState.selectedPath = { L"root", L"child" }; + + auto [toExpand, toSelect] = TreeExpansionHelper::MatchExpansion(oldState, { L"root", L"root.child" }); + ASSERT_EQ(toSelect.size(), 2u); + EXPECT_EQ(toSelect[0], L"root"); + EXPECT_EQ(toSelect[1], L"child"); + } + + TEST(MatchExpansion, SelectionDroppedWhenMissing) + { + TreeExpansionState oldState; + oldState.selectedPath = { L"root", L"gone" }; + + auto [toExpand, toSelect] = TreeExpansionHelper::MatchExpansion(oldState, { L"root", L"root.here" }); + EXPECT_TRUE(toSelect.empty()); + } + + TEST(MatchExpansion, EmptyNewTreeRestoresNothing) + { + TreeExpansionState oldState; + oldState.expandedPaths[L"root.child"] = true; + oldState.selectedPath = { L"root", L"child" }; + + auto [toExpand, toSelect] = TreeExpansionHelper::MatchExpansion(oldState, {}); + EXPECT_TRUE(toExpand.empty()); + EXPECT_TRUE(toSelect.empty()); + } + + TEST(MatchExpansion, NewPathsNotInOldStateAreIgnored) + { + TreeExpansionState oldState; + oldState.expandedPaths[L"root.child"] = true; + + // Nodes that appeared in the new document must stay collapsed + auto [toExpand, toSelect] = TreeExpansionHelper::MatchExpansion(oldState, { L"root", L"root.fresh" }); + EXPECT_TRUE(toExpand.empty()); + EXPECT_TRUE(toSelect.empty()); + } + + TEST(MatchExpansion, PreservesOrderOfNewPaths) + { + TreeExpansionState oldState; + oldState.expandedPaths[L"a"] = true; + oldState.expandedPaths[L"b"] = true; + oldState.expandedPaths[L"c"] = true; + + // Follows the order of newPaths, not the hash order of expandedPaths + auto [toExpand, toSelect] = TreeExpansionHelper::MatchExpansion(oldState, { L"c", L"a", L"b" }); + ASSERT_EQ(toExpand.size(), 3u); + EXPECT_EQ(toExpand[0], L"c"); + EXPECT_EQ(toExpand[1], L"a"); + EXPECT_EQ(toExpand[2], L"b"); + } +} // namespace TreeExpansionTests diff --git a/tests/UnitTest/UnitTest.vcxproj b/tests/UnitTest/UnitTest.vcxproj index 70446d7..e28ab0c 100644 --- a/tests/UnitTest/UnitTest.vcxproj +++ b/tests/UnitTest/UnitTest.vcxproj @@ -155,6 +155,7 @@ + @@ -163,6 +164,7 @@ + @@ -174,6 +176,7 @@ + From 8ea0a83e47cb6f771233d65a8f4e62cc7d4ed486 Mon Sep 17 00:00:00 2001 From: leoshone Date: Thu, 3 Sep 2026 19:09:54 +0800 Subject: [PATCH 03/18] Cache the JSON tree per tab instead of drawing it on tab switch When "Follow current tab" is off (the default) the plugin never drew the tree on its own, but it also never cleared it: the tree kept showing the document of some earlier tab, with no indication that it belonged there. With this change the tree is only ever drawn when the user asks for it ("Refresh JSON Tree"). Switching tabs stores the tree of the tab being left and puts it back verbatim when the tab is activated again, so no re-parsing happens and the expansion state and selection survive. The "Follow current tab" option is kept and behaves exactly as before when enabled: the document of the activated tab is parsed immediately. Only its "off" path changes, from "do nothing" to "remember per tab". Notes: - Snapshots live in memory only and are dropped when the buffer is closed, together with the association to the current buffer. - "Auto format on open" now formats the document without drawing the tree, so opening a file still cannot trigger a parse. - Formatting now redraws the tree while preserving its expansion state, which keeps it consistent with Refresh. - Built on top of the TreeExpansion helpers introduced for Refresh. --- src/NppJsonViewer/JsonViewDlg.cpp | 245 ++++++++++++++++-- src/NppJsonViewer/JsonViewDlg.h | 27 +- src/NppJsonViewer/NPPJSONViewer.vcxproj | 2 + .../NPPJSONViewer.vcxproj.filters | 6 + src/NppJsonViewer/NppJsonPlugin.cpp | 29 ++- src/NppJsonViewer/TreeState.cpp | 48 ++++ src/NppJsonViewer/TreeState.h | 44 ++++ src/NppJsonViewer/TreeViewCtrl.cpp | 19 ++ src/NppJsonViewer/TreeViewCtrl.h | 1 + tests/UnitTest/TreeStateTest.cpp | 134 ++++++++++ tests/UnitTest/UnitTest.vcxproj | 3 + 11 files changed, 534 insertions(+), 24 deletions(-) create mode 100644 src/NppJsonViewer/TreeState.cpp create mode 100644 src/NppJsonViewer/TreeState.h create mode 100644 tests/UnitTest/TreeStateTest.cpp diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index 348b117..e4dffac 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -67,14 +67,29 @@ void JsonViewDlg::ShowDlg(bool bShow) if (bShow) { - // Draw json tree now - DrawJsonTree(); + m_nCurrentBufferId = GetCurrentBufferId(); + + // Showing the panel is not a request to parse anything. When the plugin + // follows the current tab the tree is drawn as before; otherwise only a + // snapshot left behind by an explicit "Refresh JSON Tree" is restored. + if (m_pSetting->bFollowCurrentTab) + DrawJsonTree(); + else + RestoreTabState(m_nCurrentBufferId); } DockingDlgInterface::display(bShow); } void JsonViewDlg::FormatJson() +{ + // After formatting, the tree is redrawn and the tab snapshot updated. + // The expansion state of the previous tree is preserved. + if (FormatJsonDocument()) + ReDrawJsonTree(true, true); +} + +auto JsonViewDlg::FormatJsonDocument() -> bool { UpdateTitle(); @@ -85,7 +100,7 @@ void JsonViewDlg::FormatJson() { const std::wstring msg = IsMultiSelection(selectedData) ? JSON_ERR_MULTI_SELECTION : JSON_ERR_PARSE; ShowMessage(JSON_INFO_TITLE, msg, MB_OK | MB_ICONINFORMATION); - return; + return false; } auto [le, lf, indentChar, indentLen] = GetFormatSetting(); @@ -100,12 +115,12 @@ void JsonViewDlg::FormatJson() else { if (CheckForTokenUndefined(JsonViewDlg::eMethod::FormatJson, selectedText.value(), res, NULL)) - return; + return false; ReportError(res); } - ReDrawJsonTree(); + return true; } void JsonViewDlg::CompressJson() @@ -279,25 +294,77 @@ void JsonViewDlg::ProcessScintillaData(const ScintillaData& scintillaData, std:: scintillaData); } -void JsonViewDlg::HandleTabActivated() +void JsonViewDlg::HandleTabActivated(uptr_t activatedBufferId) { const bool bIsVisible = isCreated() && isVisible(); - if (bIsVisible) + if (!bIsVisible) { - m_pEditor->RefreshViewHandle(); - if (m_pEditor->IsJsonFile()) + // The panel is hidden: nothing is drawn, but the buffer id has to follow + // along so that the tree is attached to the right tab once it is shown. + m_nCurrentBufferId = activatedBufferId; + return; + } + + // Remember the tree of the tab we are leaving (only when one was drawn) + if (!m_pSetting->bFollowCurrentTab) + CaptureCurrentTabState(); + + m_pEditor->RefreshViewHandle(); + m_nCurrentBufferId = activatedBufferId; + + if (m_pEditor->IsJsonFile()) + { + if (m_pSetting->bFollowCurrentTab) { - if (m_pSetting->bFollowCurrentTab) - { - DrawJsonTree(); - } + // Original behaviour: parse the document of the newly activated tab + DrawJsonTree(); if (m_pSetting->bAutoFormat) - { FormatJson(); - } + } + else + { + // Otherwise the tab is never parsed on its own. Put back the + // snapshot recorded for it, or leave the tree empty when the user + // has not refreshed it yet. + RestoreTabState(activatedBufferId); } } + else + { + RestoreTabState(activatedBufferId); + } + + UpdateTitle(); +} + +void JsonViewDlg::HandleFileClosed(uptr_t bufferId) +{ + m_tabSnapshots.erase(bufferId); + + // Notepad++ does not guarantee whether NPPN_FILECLOSED or + // NPPN_BUFFERACTIVATED arrives first. Forgetting the association here + // prevents a later CaptureCurrentTabState() from re-creating the snapshot + // of the buffer that has just been closed. + if (bufferId == m_nCurrentBufferId) + m_nCurrentBufferId = 0; +} + +void JsonViewDlg::HandleFileOpened() +{ + // "Auto format on open" still applies, but formatting a document is not a + // request to draw its tree: the user decides when to refresh it. + if (m_pSetting->bAutoFormat && isCreated() && isVisible() && !m_pSetting->bFollowCurrentTab) + { + m_pEditor->RefreshViewHandle(); + if (m_pEditor->IsJsonFile()) + FormatJsonDocument(); + } +} + +void JsonViewDlg::SyncBufferId() +{ + m_nCurrentBufferId = GetCurrentBufferId(); } void JsonViewDlg::ValidateJson() @@ -393,17 +460,20 @@ void JsonViewDlg::DrawJsonTree(bool bPreserveExpansion) if (bPreserveExpansion && bHasCurrentTree && m_pTreeView->GetNodeCount() > 1) ApplyExpansionState(expState); + // Update the snapshot of the current tab with the freshly drawn tree + SaveTreeSnapshot(); + // Enable all buttons and treeView EnableControls(ctrls, true); } -void JsonViewDlg::ReDrawJsonTree(bool bForce) +void JsonViewDlg::ReDrawJsonTree(bool bForce, bool bPreserveExpansion) { const bool bIsVisible = isCreated() && isVisible(); const bool bReDraw = bForce || bIsVisible; if (bReDraw) { - DrawJsonTree(); + DrawJsonTree(bPreserveExpansion); } } @@ -735,6 +805,144 @@ void JsonViewDlg::SelectByPath(const std::vector& path) } } +void JsonViewDlg::CaptureCurrentTabState() +{ + // m_nCurrentBufferId == 0 means "unknown" (for instance the buffer that was + // displayed has just been closed). Nothing can be attached in that case. + if (m_nCurrentBufferId == 0) + return; + + // Snapshot the tree only when it holds a real drawn tree of this tab. + // The empty placeholder tree (single root) is not worth capturing. + if (!m_pTreeView->GetRoot()) + return; + + if (m_pTreeView->GetNodeCount() <= 1) + return; + + m_tabSnapshots[m_nCurrentBufferId] = CaptureTreeState(); +} + +void JsonViewDlg::RestoreTabState(uptr_t bufferId) +{ + auto find = m_tabSnapshots.find(bufferId); + if (find == m_tabSnapshots.end() || find->second.roots.empty()) + { + ShowEmptyTree(); + return; + } + + ApplyTreeState(find->second); +} + +void JsonViewDlg::ShowEmptyTree() +{ + m_pTreeView->InitTree(); + m_pTreeView->Expand(m_pTreeView->GetRoot()); +} + +auto JsonViewDlg::CaptureTreeState() const -> TreeState +{ + TreeState state; + + auto hRoot = m_pTreeView->GetRoot(); + if (!hRoot) + return state; + + // The tree root ("JSON") itself is not part of the snapshot: it is always + // recreated by InitTree(). Only its children are captured. + std::function&)> captureChildren; + captureChildren = [&](HTREEITEM hParent, std::vector& siblings) { + for (HTREEITEM hChild = m_pTreeView->GetChildItem(hParent); hChild; + hChild = m_pTreeView->GetNextSibling(hChild)) + { + TreeStateNode node; + node.text = m_pTreeView->GetNodeName(hChild, false); + + auto pPosition = m_pTreeView->GetNodePosition(hChild); + if (pPosition) + node.pos = *pPosition; + + node.expanded = m_pTreeView->IsExpanded(hChild); + + captureChildren(hChild, node.children); + + siblings.push_back(std::move(node)); + } + }; + + captureChildren(hRoot, state.roots); + + // Selection path (keys from the root down to the selected node) + state.selectedPath = GetCurrentSelectedPath(); + + return state; +} + +void JsonViewDlg::ApplyTreeState(const TreeState& state) +{ + // Rebuild the tree control without intermediate redraws + HWND hTree = m_pTreeView->GetTreeViewHandle(); + ::SendMessage(hTree, WM_SETREDRAW, FALSE, 0); + + m_pTreeView->InitTree(); + auto hRoot = m_pTreeView->GetRoot(); + + std::function insertNode; + insertNode = [&](const TreeStateNode& node, HTREEITEM hParent, HTREEITEM hAfter) -> HTREEITEM { + LPARAM lparam = 0; + if (node.pos.has_value()) + lparam = reinterpret_cast(new Position(node.pos.value())); + + auto hInserted = m_pTreeView->InsertNodeAfter(hAfter, node.text, lparam, hParent); + + HTREEITEM hPrev = nullptr; + for (const auto& child : node.children) + { + hPrev = insertNode(child, hInserted, hPrev); + } + + if (node.expanded && !node.children.empty()) + m_pTreeView->Expand(hInserted); + + return hInserted; + }; + + HTREEITEM hPrev = nullptr; + for (const auto& rootChild : state.roots) + { + hPrev = insertNode(rootChild, hRoot, hPrev); + } + + // Restore selection. A programmatic selection reports TVC_UNKNOWN in + // TVN_SELCHANGED, so it will not make the editor jump to the node. + auto hSelected = FindNodeByPath(state.selectedPath); + if (hSelected) + m_pTreeView->SetSelection(hSelected); + + // The root ("JSON") is always expanded + m_pTreeView->Expand(hRoot); + + ::SendMessage(hTree, WM_SETREDRAW, TRUE, 0); + ::InvalidateRect(hTree, nullptr, TRUE); +} + +void JsonViewDlg::SaveTreeSnapshot() +{ + if (m_nCurrentBufferId == 0) + return; + + if (m_pTreeView->GetNodeCount() > 1) + m_tabSnapshots[m_nCurrentBufferId] = CaptureTreeState(); + else + m_tabSnapshots.erase(m_nCurrentBufferId); +} + +uptr_t JsonViewDlg::GetCurrentBufferId() const +{ + return static_cast(::SendMessage(_hParent, NPPM_GETCURRENTBUFFERID, 0, 0)); +} + void JsonViewDlg::UpdateTitle() { const auto titleFileName = GetTitleFileName(); @@ -989,6 +1197,9 @@ void JsonViewDlg::ContextMenuExpand(bool bExpand) bExpand ? m_pTreeView->Expand(htiNext) : m_pTreeView->Collapse(htiNext); htiNext = m_pTreeView->NextItem(htiNext, htiSelected); } + + // Keep the snapshot of this tab in sync with the new expansion state + SaveTreeSnapshot(); } auto JsonViewDlg::CopyName() const -> std::wstring diff --git a/src/NppJsonViewer/JsonViewDlg.h b/src/NppJsonViewer/JsonViewDlg.h index 3e81db2..0700eb2 100644 --- a/src/NppJsonViewer/JsonViewDlg.h +++ b/src/NppJsonViewer/JsonViewDlg.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -15,6 +16,7 @@ #include "JsonNode.h" #include "TreeHandler.h" #include "TreeExpansion.h" +#include "TreeState.h" class JsonViewDlg @@ -44,9 +46,13 @@ class JsonViewDlg void ShowDlg(bool bShow); void FormatJson(); + auto FormatJsonDocument() -> bool; // true = document handled, caller may redraw the tree void CompressJson(); void SortJsonByKey(); - void HandleTabActivated(); + void HandleTabActivated(uptr_t activatedBufferId); + void HandleFileClosed(uptr_t bufferId); + void HandleFileOpened(); + void SyncBufferId(); void UpdateTitle(); HTREEITEM InsertToTree(HTREEITEM parent, const std::string& text) override; @@ -55,7 +61,7 @@ class JsonViewDlg private: void DrawJsonTree(bool bPreserveExpansion = false); - void ReDrawJsonTree(bool bForce = false); + void ReDrawJsonTree(bool bForce = false, bool bPreserveExpansion = false); void HighlightAsJson(bool bForcefully = false) const; auto PopulateTreeUsingSax(HTREEITEM tree_root, const std::string& jsonText) -> std::optional; @@ -85,6 +91,19 @@ class JsonViewDlg // Returns nullptr when any level of the path cannot be found. auto FindNodeByPath(const std::vector& path) const -> HTREEITEM; + // Per-tab snapshots. The tree control is a single shared window, so leaving + // a tab means capturing what it looked like; coming back replays the + // snapshot instead of parsing the document again. + void CaptureCurrentTabState(); + void RestoreTabState(uptr_t bufferId); + void ShowEmptyTree(); + + auto CaptureTreeState() const -> TreeState; + void ApplyTreeState(const TreeState& state); + void SaveTreeSnapshot(); + + auto GetCurrentBufferId() const -> uptr_t; + auto GetTitleFileName() const -> std::wstring; void PrepareButtons(); void SetIconAndTooltip(eButton ctrlType, const std::wstring& toolTip); @@ -146,4 +165,8 @@ class JsonViewDlg std::unique_ptr m_pTreeView = nullptr; std::unique_ptr m_pTreeViewZoom = nullptr; std::shared_ptr m_pSetting = nullptr; + + // Per-tab (buffer) tree snapshots: buffer id -> captured tree state + std::unordered_map m_tabSnapshots; + uptr_t m_nCurrentBufferId = 0; }; diff --git a/src/NppJsonViewer/NPPJSONViewer.vcxproj b/src/NppJsonViewer/NPPJSONViewer.vcxproj index 1015296..618ae82 100644 --- a/src/NppJsonViewer/NPPJSONViewer.vcxproj +++ b/src/NppJsonViewer/NPPJSONViewer.vcxproj @@ -198,6 +198,7 @@ + @@ -219,6 +220,7 @@ + diff --git a/src/NppJsonViewer/NPPJSONViewer.vcxproj.filters b/src/NppJsonViewer/NPPJSONViewer.vcxproj.filters index a4b6946..efaec38 100644 --- a/src/NppJsonViewer/NPPJSONViewer.vcxproj.filters +++ b/src/NppJsonViewer/NPPJSONViewer.vcxproj.filters @@ -60,6 +60,9 @@ Source Files + + Source Files + Source Files @@ -116,6 +119,9 @@ Header Files + + Header Files + Header Files diff --git a/src/NppJsonViewer/NppJsonPlugin.cpp b/src/NppJsonViewer/NppJsonPlugin.cpp index f251481..7fda437 100644 --- a/src/NppJsonViewer/NppJsonPlugin.cpp +++ b/src/NppJsonViewer/NppJsonPlugin.cpp @@ -57,7 +57,25 @@ void NppJsonPlugin::ProcessNotification(const SCNotification* notifyCode) { if (m_pJsonViewDlg && m_bNppReady && !m_bAboutToClose) { - m_pJsonViewDlg->HandleTabActivated(); + m_pJsonViewDlg->HandleTabActivated(notifyCode->nmhdr.idFrom); + } + break; + } + + case NPPN_FILECLOSED: + { + if (m_pJsonViewDlg) + { + m_pJsonViewDlg->HandleFileClosed(notifyCode->nmhdr.idFrom); + } + break; + } + + case NPPN_FILEOPENED: + { + if (m_pJsonViewDlg && m_bNppReady && !m_bAboutToClose) + { + m_pJsonViewDlg->HandleFileOpened(); } break; } @@ -70,11 +88,12 @@ void NppJsonPlugin::ProcessNotification(const SCNotification* notifyCode) case NPPN_READY: { - // This is workaround where dialog does not show tree on launch - if (m_pJsonViewDlg && m_pJsonViewDlg->isVisible() && !m_bAboutToClose) + // The tree is never drawn automatically: every tab starts empty and the + // user decides when to refresh it. Only the current buffer id is picked + // up so that the first refresh is attached to the right tab. + if (m_pJsonViewDlg && !m_bAboutToClose) { - ::SendMessage(m_pJsonViewDlg->getHSelf(), WM_COMMAND, IDC_BTN_REFRESH, 0); - m_pJsonViewDlg->UpdateTitle(); + m_pJsonViewDlg->SyncBufferId(); } m_bNppReady = true; break; diff --git a/src/NppJsonViewer/TreeState.cpp b/src/NppJsonViewer/TreeState.cpp new file mode 100644 index 0000000..a33b99c --- /dev/null +++ b/src/NppJsonViewer/TreeState.cpp @@ -0,0 +1,48 @@ +#include "TreeState.h" + +namespace +{ + auto AreNodesEqual(const TreeStateNode& lhs, const TreeStateNode& rhs) -> bool + { + if (lhs.text != rhs.text || lhs.expanded != rhs.expanded) + return false; + + if (lhs.pos.has_value() != rhs.pos.has_value()) + return false; + + if (lhs.pos.has_value()) + { + if (lhs.pos->nLine != rhs.pos->nLine || lhs.pos->nColumn != rhs.pos->nColumn + || lhs.pos->nKeyLength != rhs.pos->nKeyLength) + return false; + } + + if (lhs.children.size() != rhs.children.size()) + return false; + + for (size_t i = 0; i < lhs.children.size(); ++i) + { + if (!AreNodesEqual(lhs.children[i], rhs.children[i])) + return false; + } + + return true; + } +} + +auto TreeStateHelper::AreEqual(const TreeState& lhs, const TreeState& rhs) -> bool +{ + if (lhs.selectedPath != rhs.selectedPath) + return false; + + if (lhs.roots.size() != rhs.roots.size()) + return false; + + for (size_t i = 0; i < lhs.roots.size(); ++i) + { + if (!AreNodesEqual(lhs.roots[i], rhs.roots[i])) + return false; + } + + return true; +} diff --git a/src/NppJsonViewer/TreeState.h b/src/NppJsonViewer/TreeState.h new file mode 100644 index 0000000..c0257f2 --- /dev/null +++ b/src/NppJsonViewer/TreeState.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +#include "JsonNode.h" + +/* + * TreeState is a per-tab snapshot of the JSON tree: the nodes themselves + * (text and editor position), their expansion state and the current selection. + * It is a plain in-memory model, independent from any Win32 control. + * + * It is used to remember what a tab looked like: when the user switches away + * from a tab and later comes back, the tree is restored from the snapshot + * instead of being parsed again. See TreeExpansion.h for the lighter-weight + * "same document, tree rebuilt" case (refresh). + * + * Only the children of the tree root ("JSON") are captured; the root itself is + * always recreated by TreeViewCtrl::InitTree(). + */ +struct TreeStateNode +{ + std::wstring text; // Display text of the node (including trailing [n]/{n} counts) + std::optional pos; // Editor position of the key (nullopt when the node has none) + bool expanded = false; + std::vector children; +}; + +struct TreeState +{ + std::vector roots; // Children of the tree root ("JSON") + std::vector selectedPath; // Path of the selected node (key per level), empty when none +}; + +class TreeStateHelper +{ +public: + /* + * Recursive comparison used by unit tests: verifies that two states have + * identical structure, texts, positions, expansion flags and selection. + */ + static auto AreEqual(const TreeState& lhs, const TreeState& rhs) -> bool; +}; diff --git a/src/NppJsonViewer/TreeViewCtrl.cpp b/src/NppJsonViewer/TreeViewCtrl.cpp index 95860e6..19f2755 100644 --- a/src/NppJsonViewer/TreeViewCtrl.cpp +++ b/src/NppJsonViewer/TreeViewCtrl.cpp @@ -48,6 +48,25 @@ auto TreeViewCtrl::InsertNode(const std::wstring& text, LPARAM lparam, HTREEITEM return item; } +// Inserts a node right after hAfter (or as the first child when hAfter is null). +// Needed to rebuild a tree in a specific sibling order when restoring a snapshot. +auto TreeViewCtrl::InsertNodeAfter(HTREEITEM hAfter, const std::wstring& text, LPARAM lparam, HTREEITEM parentNode) -> HTREEITEM +{ + TV_INSERTSTRUCT tvInsert {}; + + tvInsert.hParent = (parentNode == TVI_ROOT) ? NULL : parentNode; + tvInsert.hInsertAfter = hAfter ? hAfter : TVI_FIRST; + + if (text.length() + 1 > m_nMaxNodeTextLength) + m_nMaxNodeTextLength = text.length() + 1; + + tvInsert.item.mask = TVIF_HANDLE | TVIF_TEXT | TVIF_PARAM; + tvInsert.item.pszText = const_cast(text.c_str()); + tvInsert.item.lParam = lparam; + + return reinterpret_cast(SendDlgItemMessage(m_hParent, m_nCtrlID, TVM_INSERTITEM, 0, reinterpret_cast(&tvInsert))); +} + auto TreeViewCtrl::GetChildItem(HTREEITEM node) const -> HTREEITEM { return TreeView_GetNextItem(m_hTree, node, TVGN_CHILD); diff --git a/src/NppJsonViewer/TreeViewCtrl.h b/src/NppJsonViewer/TreeViewCtrl.h index e67fa3d..c9eb91d 100644 --- a/src/NppJsonViewer/TreeViewCtrl.h +++ b/src/NppJsonViewer/TreeViewCtrl.h @@ -27,6 +27,7 @@ class TreeViewCtrl auto InitTree() -> HTREEITEM; auto InsertNode(const std::wstring& text, LPARAM lparam, HTREEITEM parentNode) -> HTREEITEM; + auto InsertNodeAfter(HTREEITEM hAfter, const std::wstring& text, LPARAM lparam, HTREEITEM parentNode) -> HTREEITEM; void UpdateNodeText(HTREEITEM node, const std::wstring& text); auto GetNodeCount() const -> unsigned int; diff --git a/tests/UnitTest/TreeStateTest.cpp b/tests/UnitTest/TreeStateTest.cpp new file mode 100644 index 0000000..b2a74ac --- /dev/null +++ b/tests/UnitTest/TreeStateTest.cpp @@ -0,0 +1,134 @@ +#include + +#include "TreeState.h" + +namespace TreeStateTests +{ + TEST(AreEqual, EmptyStates) + { + TreeState a, b; + EXPECT_TRUE(TreeStateHelper::AreEqual(a, b)); + } + + TEST(AreEqual, IdenticalStates) + { + TreeStateNode child; + child.text = L"a : 1"; + + TreeStateNode parent; + parent.text = L"obj {1}"; + parent.expanded = true; + parent.children = {child}; + + TreeState a, b; + a.roots.push_back(parent); + b.roots.push_back(parent); + a.selectedPath = {L"obj", L"a"}; + b.selectedPath = {L"obj", L"a"}; + + EXPECT_TRUE(TreeStateHelper::AreEqual(a, b)); + } + + TEST(AreEqual, DifferentText) + { + TreeState a, b; + TreeStateNode n; + n.text = L"key"; + a.roots.push_back(n); + n.text = L"other"; + b.roots.push_back(n); + + EXPECT_FALSE(TreeStateHelper::AreEqual(a, b)); + } + + TEST(AreEqual, DifferentExpansion) + { + TreeState a, b; + TreeStateNode n; + n.text = L"key"; + n.expanded = true; + a.roots.push_back(n); + n.expanded = false; + b.roots.push_back(n); + + EXPECT_FALSE(TreeStateHelper::AreEqual(a, b)); + } + + TEST(AreEqual, DifferentSelection) + { + TreeState a, b; + TreeStateNode n; + n.text = L"key"; + a.roots.push_back(n); + b.roots.push_back(n); + a.selectedPath = {L"key"}; + b.selectedPath = {L"other"}; + + EXPECT_FALSE(TreeStateHelper::AreEqual(a, b)); + } + + TEST(AreEqual, DifferentRootCount) + { + TreeState a, b; + TreeStateNode n; + n.text = L"key"; + a.roots.push_back(n); + b.roots.push_back(n); + b.roots.push_back(n); + + EXPECT_FALSE(TreeStateHelper::AreEqual(a, b)); + } + + TEST(AreEqual, DifferentChildrenCount) + { + TreeState a, b; + TreeStateNode parent; + parent.text = L"obj {2}"; + TreeStateNode c1, c2; + c1.text = L"a"; + c2.text = L"b"; + parent.children = {c1, c2}; + a.roots.push_back(parent); + + parent.children = {c1}; + b.roots.push_back(parent); + + EXPECT_FALSE(TreeStateHelper::AreEqual(a, b)); + } + + TEST(AreEqual, PositionCompared) + { + TreeState a, b; + TreeStateNode n; + n.text = L"key"; + n.pos = Position{3, 5, 4}; + a.roots.push_back(n); + + TreeStateNode m; + m.text = L"key"; + m.pos = Position{3, 5, 4}; + b.roots.push_back(m); + + EXPECT_TRUE(TreeStateHelper::AreEqual(a, b)); + + m.pos = Position{4, 5, 4}; + b.roots.clear(); + b.roots.push_back(m); + EXPECT_FALSE(TreeStateHelper::AreEqual(a, b)); + } + + TEST(AreEqual, PositionPresenceCompared) + { + TreeState a, b; + TreeStateNode n; + n.text = L"key"; + n.pos = Position{1, 2, 3}; + a.roots.push_back(n); + + TreeStateNode m; + m.text = L"key"; // no position at all + b.roots.push_back(m); + + EXPECT_FALSE(TreeStateHelper::AreEqual(a, b)); + } +} diff --git a/tests/UnitTest/UnitTest.vcxproj b/tests/UnitTest/UnitTest.vcxproj index e28ab0c..b0a303e 100644 --- a/tests/UnitTest/UnitTest.vcxproj +++ b/tests/UnitTest/UnitTest.vcxproj @@ -156,6 +156,7 @@ + @@ -165,6 +166,7 @@ + @@ -177,6 +179,7 @@ + From ed4c3d82ef2966025d2713c7e953b5b2713f8449 Mon Sep 17 00:00:00 2001 From: leoshone Date: Thu, 3 Sep 2026 19:36:15 +0800 Subject: [PATCH 04/18] Draw the tree once when a json file is opened (DRAW_ON_OPEN) Adds an option, off by default, that draws the tree of a json document as soon as the file is opened. It complements the per-tab snapshot caching: the document is parsed exactly once, and switching back to the tab afterwards replays the stored snapshot instead of parsing again. The check lives in RestoreTabState(), the single place reached when the tree of a tab has never been drawn, so opening a file, switching back to a tab and showing the panel are all covered by one code path. Drawing on open is initiated by the plugin, not by the user, so parse errors are reported as a node inside the tree rather than through a modal dialog: DrawJsonTree() takes a bSilent flag for that. The tree is drawn for documents whose language is JSON, the same criterion the existing "follow current tab" uses. --- src/NppJsonViewer/Define.h | 2 ++ src/NppJsonViewer/JsonViewDlg.cpp | 32 ++++++++++++++++++++++++++--- src/NppJsonViewer/JsonViewDlg.h | 7 ++++++- src/NppJsonViewer/NppJsonPlugin.cpp | 3 +++ src/NppJsonViewer/Profile.cpp | 5 +++++ src/NppJsonViewer/SettingsDlg.cpp | 2 ++ src/NppJsonViewer/resource.h | 1 + src/NppJsonViewer/resource.rc | 2 ++ tests/UnitTest/ProfileTest.cpp | 18 ++++++++++++++++ 9 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/NppJsonViewer/Define.h b/src/NppJsonViewer/Define.h index 5ee80f1..01c2382 100644 --- a/src/NppJsonViewer/Define.h +++ b/src/NppJsonViewer/Define.h @@ -64,6 +64,7 @@ const TCHAR STR_INI_FORMATTING_INDENTCOUNT[] = TEXT("INDENTATION_COUNT"); const TCHAR STR_INI_OTHER_SEC[] = TEXT("Others"); const TCHAR STR_INI_OTHER_FOLLOW_TAB[] = TEXT("FOLLOW_TAB"); +const TCHAR STR_INI_OTHER_DRAW_ON_OPEN[] = TEXT("DRAW_ON_OPEN"); const TCHAR STR_INI_OTHER_AUTO_FORMAT[] = TEXT("AUTO_FORMAT"); const TCHAR STR_INI_OTHER_USE_HIGHLIGHT[] = TEXT("USE_JSON_HIGHLIGHT"); const TCHAR STR_INI_OTHER_IGNORE_COMMENT[] = TEXT("IGNORE_COMMENT"); @@ -114,6 +115,7 @@ struct Setting LineFormat lineFormat = LineFormat::DEFAULT; Indent indent {}; bool bFollowCurrentTab = false; + bool bDrawOnOpen = false; // Draw the tree once when a json file is opened bool bAutoFormat = false; bool bUseJsonHighlight = true; ParseOptions parseOptions {}; diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index e4dffac..02dc9f9 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -367,6 +367,17 @@ void JsonViewDlg::SyncBufferId() m_nCurrentBufferId = GetCurrentBufferId(); } +void JsonViewDlg::RestoreCurrentTabTree() +{ + // A tab restored from a previous session never sees NPPN_BUFFERACTIVATED, + // so the "draw tree on open" path has to be reachable from NPPN_READY too. + if (m_nCurrentBufferId == 0) + return; + + if (isCreated() && isVisible()) + RestoreTabState(m_nCurrentBufferId); +} + void JsonViewDlg::ValidateJson() { UpdateTitle(); @@ -401,7 +412,7 @@ void JsonViewDlg::ValidateJson() DrawJsonTree(); } -void JsonViewDlg::DrawJsonTree(bool bPreserveExpansion) +void JsonViewDlg::DrawJsonTree(bool bPreserveExpansion, bool bSilent) { UpdateTitle(); @@ -428,7 +439,7 @@ void JsonViewDlg::DrawJsonTree(bool bPreserveExpansion) { m_pTreeView->InsertNode(JSON_ERR_PARSE, NULL, rootNode); - if (IsMultiSelection(selectedData)) + if (IsMultiSelection(selectedData) && !bSilent) { ShowMessage(JSON_INFO_TITLE, JSON_ERR_MULTI_SELECTION, MB_OK | MB_ICONINFORMATION); } @@ -442,7 +453,7 @@ void JsonViewDlg::DrawJsonTree(bool bPreserveExpansion) // Later on second launch, don't show the error message as this could be some text file // If it is real json file but has some error, then there must be more than 1 node exist. - if (!m_IsNppReady && m_pTreeView->GetNodeCount() <= 1) + if (bSilent || (!m_IsNppReady && m_pTreeView->GetNodeCount() <= 1)) { m_pTreeView->InsertNode(JSON_ERR_VALIDATE, NULL, rootNode); } @@ -828,6 +839,21 @@ void JsonViewDlg::RestoreTabState(uptr_t bufferId) auto find = m_tabSnapshots.find(bufferId); if (find == m_tabSnapshots.end() || find->second.roots.empty()) { + // Nothing has ever been drawn for this tab. With "draw tree on open" + // the tree of a json document is drawn once, here and now; from then + // on the snapshot exists and switching back never parses again. + if (m_pSetting->bDrawOnOpen) + { + m_pEditor->RefreshViewHandle(); + if (m_pEditor->IsJsonFile()) + { + // Drawn on the plugin's own initiative: never interrupt the + // user with a modal dialog, report the error in the tree only. + DrawJsonTree(false, true); // stores the snapshot on its way out + return; + } + } + ShowEmptyTree(); return; } diff --git a/src/NppJsonViewer/JsonViewDlg.h b/src/NppJsonViewer/JsonViewDlg.h index 0700eb2..7d50893 100644 --- a/src/NppJsonViewer/JsonViewDlg.h +++ b/src/NppJsonViewer/JsonViewDlg.h @@ -53,6 +53,7 @@ class JsonViewDlg void HandleFileClosed(uptr_t bufferId); void HandleFileOpened(); void SyncBufferId(); + void RestoreCurrentTabTree(); void UpdateTitle(); HTREEITEM InsertToTree(HTREEITEM parent, const std::string& text) override; @@ -60,7 +61,11 @@ class JsonViewDlg void AppendNodeCount(HTREEITEM node, unsigned elementCount, bool bArray) override; private: - void DrawJsonTree(bool bPreserveExpansion = false); + // bSilent suppresses the modal error box reported for an unparsable + // document; the error is only shown as a node inside the tree. It is used + // when the tree is drawn on its own (opening a file), where a modal dialog + // would interrupt the user who never asked for it. + void DrawJsonTree(bool bPreserveExpansion = false, bool bSilent = false); void ReDrawJsonTree(bool bForce = false, bool bPreserveExpansion = false); void HighlightAsJson(bool bForcefully = false) const; auto PopulateTreeUsingSax(HTREEITEM tree_root, const std::string& jsonText) -> std::optional; diff --git a/src/NppJsonViewer/NppJsonPlugin.cpp b/src/NppJsonViewer/NppJsonPlugin.cpp index 7fda437..0b73b9f 100644 --- a/src/NppJsonViewer/NppJsonPlugin.cpp +++ b/src/NppJsonViewer/NppJsonPlugin.cpp @@ -94,6 +94,9 @@ void NppJsonPlugin::ProcessNotification(const SCNotification* notifyCode) if (m_pJsonViewDlg && !m_bAboutToClose) { m_pJsonViewDlg->SyncBufferId(); + + if (m_pJsonViewDlg->isVisible()) + m_pJsonViewDlg->RestoreCurrentTabTree(); } m_bNppReady = true; break; diff --git a/src/NppJsonViewer/Profile.cpp b/src/NppJsonViewer/Profile.cpp index 7f13a18..7cd54c2 100644 --- a/src/NppJsonViewer/Profile.cpp +++ b/src/NppJsonViewer/Profile.cpp @@ -94,6 +94,10 @@ bool ProfileSetting::GetSettings(Setting& info) const if (bRetVal) info.bFollowCurrentTab = static_cast(nVal); + bRetVal = bRetVal && ReadValue(STR_INI_OTHER_SEC, STR_INI_OTHER_DRAW_ON_OPEN, nVal, info.bDrawOnOpen); + if (bRetVal) + info.bDrawOnOpen = static_cast(nVal); + bRetVal = bRetVal && ReadValue(STR_INI_OTHER_SEC, STR_INI_OTHER_AUTO_FORMAT, nVal, info.bAutoFormat); if (bRetVal) info.bAutoFormat = static_cast(nVal); @@ -127,6 +131,7 @@ bool ProfileSetting::SetSettings(const Setting& info) const bRetVal = bRetVal && WriteValue(STR_INI_FORMATTING_SEC, STR_INI_FORMATTING_INDENTCOUNT, info.indent.len); bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_FOLLOW_TAB, info.bFollowCurrentTab); + bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_DRAW_ON_OPEN, info.bDrawOnOpen); bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_AUTO_FORMAT, info.bAutoFormat); bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_USE_HIGHLIGHT, info.bUseJsonHighlight); bRetVal = bRetVal && WriteValue(STR_INI_OTHER_SEC, STR_INI_OTHER_IGNORE_COMMENT, info.parseOptions.bIgnoreComment); diff --git a/src/NppJsonViewer/SettingsDlg.cpp b/src/NppJsonViewer/SettingsDlg.cpp index 8873645..ac58b9a 100644 --- a/src/NppJsonViewer/SettingsDlg.cpp +++ b/src/NppJsonViewer/SettingsDlg.cpp @@ -121,6 +121,7 @@ bool SettingsDlg::Apply() m_pSetting->lineFormat = LineFormat::SINGLELINE; m_pSetting->bFollowCurrentTab = CUtility::GetCheckboxStatus(::GetDlgItem(_hSelf, IDC_CHK_FOLLOW_CURRENT_DOC)); + m_pSetting->bDrawOnOpen = CUtility::GetCheckboxStatus(::GetDlgItem(_hSelf, IDC_CHK_DRAW_ON_OPEN)); m_pSetting->bAutoFormat = CUtility::GetCheckboxStatus(::GetDlgItem(_hSelf, IDC_CHK_FORMAT_ON_OPEN)); m_pSetting->bUseJsonHighlight = CUtility::GetCheckboxStatus(::GetDlgItem(_hSelf, IDC_CHK_JSON_HIGHLIGHT)); m_pSetting->parseOptions.bIgnoreTrailingComma = CUtility::GetCheckboxStatus(::GetDlgItem(_hSelf, IDC_CHK_IGNORE_COMMA)); @@ -218,6 +219,7 @@ void SettingsDlg::SyncUIControlsWithSettings() // Set all checkbox controls setCheckboxIfValid(IDC_CHK_FOLLOW_CURRENT_DOC, m_pSetting->bFollowCurrentTab); + setCheckboxIfValid(IDC_CHK_DRAW_ON_OPEN, m_pSetting->bDrawOnOpen); setCheckboxIfValid(IDC_CHK_FORMAT_ON_OPEN, m_pSetting->bAutoFormat); setCheckboxIfValid(IDC_CHK_JSON_HIGHLIGHT, m_pSetting->bUseJsonHighlight); setCheckboxIfValid(IDC_CHK_IGNORE_COMMA, m_pSetting->parseOptions.bIgnoreTrailingComma); diff --git a/src/NppJsonViewer/resource.h b/src/NppJsonViewer/resource.h index 13d1e52..038817e 100644 --- a/src/NppJsonViewer/resource.h +++ b/src/NppJsonViewer/resource.h @@ -44,6 +44,7 @@ #define IDC_CHK_REPLACE_UNDEFINED 1033 #define IDC_ZOOM_SLIDER 1034 #define IDC_ZOOM_PERCENT 1035 +#define IDC_CHK_DRAW_ON_OPEN 1036 #define IDM_COPY_TREEITEM 40001 #define IDM_COPY_NODENAME 40002 #define IDM_COPY_NODEVALUE 40003 diff --git a/src/NppJsonViewer/resource.rc b/src/NppJsonViewer/resource.rc index 2c10a78..f257ef7 100644 --- a/src/NppJsonViewer/resource.rc +++ b/src/NppJsonViewer/resource.rc @@ -109,6 +109,8 @@ BEGIN CONTROL "Use json highlighting",IDC_CHK_JSON_HIGHLIGHT,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,5,59,140,10 CONTROL "Replace value 'undefined' with 'null'",IDC_CHK_REPLACE_UNDEFINED, "Button",BS_AUTOCHECKBOX | WS_TABSTOP,5,72,140,10 + CONTROL "Draw tree when a json file is opened",IDC_CHK_DRAW_ON_OPEN, + "Button",BS_AUTOCHECKBOX | WS_TABSTOP,5,85,140,10 GROUPBOX " Indentation: ",IDC_STATIC,153,7,140,43 CONTROL "Auto detect",IDC_RADIO_INDENT_AUTO,"Button",BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP,160,21,50,10 CONTROL "Use tab",IDC_RADIO_INDENT_TAB,"Button",BS_AUTORADIOBUTTON,240,21,50,10 diff --git a/tests/UnitTest/ProfileTest.cpp b/tests/UnitTest/ProfileTest.cpp index 10ab846..70d0bbe 100644 --- a/tests/UnitTest/ProfileTest.cpp +++ b/tests/UnitTest/ProfileTest.cpp @@ -135,6 +135,7 @@ namespace ProfileSettingTests EXPECT_EQ(setting.indent.style, IndentStyle::AUTO); EXPECT_EQ(setting.bFollowCurrentTab, false); + EXPECT_EQ(setting.bDrawOnOpen, false); EXPECT_EQ(setting.bAutoFormat, false); EXPECT_EQ(setting.bUseJsonHighlight, true); @@ -153,6 +154,7 @@ namespace ProfileSettingTests expected.bAutoFormat = true; expected.bFollowCurrentTab = true; + expected.bDrawOnOpen = true; expected.bAutoFormat = true; expected.bUseJsonHighlight = false; @@ -171,6 +173,7 @@ namespace ProfileSettingTests EXPECT_EQ(actual.indent.style, expected.indent.style); EXPECT_EQ(actual.bFollowCurrentTab, expected.bFollowCurrentTab); + EXPECT_EQ(actual.bDrawOnOpen, expected.bDrawOnOpen); EXPECT_EQ(actual.bAutoFormat, expected.bAutoFormat); EXPECT_EQ(actual.bUseJsonHighlight, expected.bUseJsonHighlight); @@ -178,4 +181,19 @@ namespace ProfileSettingTests EXPECT_EQ(actual.parseOptions.bIgnoreTrailingComma, expected.parseOptions.bIgnoreTrailingComma); EXPECT_EQ(actual.parseOptions.bReplaceUndefined, expected.parseOptions.bReplaceUndefined); } + + TEST_F(ProfileTest, DrawOnOpen_RoundTrip) + { + Setting expected, actual; + + expected.bDrawOnOpen = true; + EXPECT_TRUE(m_pProfile->SetSettings(expected)); + EXPECT_TRUE(m_pProfile->GetSettings(actual)); + EXPECT_EQ(actual.bDrawOnOpen, true); + + expected.bDrawOnOpen = false; + EXPECT_TRUE(m_pProfile->SetSettings(expected)); + EXPECT_TRUE(m_pProfile->GetSettings(actual)); + EXPECT_EQ(actual.bDrawOnOpen, false); + } } // namespace ProfileSettingTests From 6e23b80dc5cce448cd5ce36b3232be749d4fa79d Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 10:23:02 +0800 Subject: [PATCH 05/18] Fix the drag detection of the zoom slider WM_HSCROLL carries the notification code in LOWORD(wParam), not HIWORD: HIWORD holds the thumb position itself (80..250 here), so comparing it against TB_THUMBTRACK never matched and the zoom was written to the ini file continuously while the thumb was being dragged, instead of once when the gesture ended. Found by an independent review of the integration branch; the end-to-end harness never caught it because it only sends TB_ENDTRACK and never simulates the dragging itself. --- src/NppJsonViewer/JsonViewDlg.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index 3c50eb2..d7299fb 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -1620,7 +1620,9 @@ INT_PTR JsonViewDlg::run_dlgProc(UINT message, WPARAM wParam, LPARAM lParam) { // While the thumb is being dragged (TB_THUMBTRACK) the position // changes continuously, so only persist once the gesture is over. - const bool bDragging = (HIWORD(wParam) == TB_THUMBTRACK); + // WM_HSCROLL carries the notification code in LOWORD(wParam); + // HIWORD is the thumb position itself. + const bool bDragging = (LOWORD(wParam) == TB_THUMBTRACK); int pos = m_pTreeViewZoom->GetPosition(); UpdateUIOnZoom(pos); From 952605a4cc18121b1a88bec5cd2a31113d022dcc Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 11:18:52 +0800 Subject: [PATCH 06/18] Add fork maintenance guide Documents the four custom features (zoom persistence, per-tab tree snapshot, expansion retention on refresh, draw-tree-on-open), the branch topology, the upstream sync procedure with per-file conflict strategies, the post-sync verification checklist, and known limitations. Written for the scenario where upstream does not merge the proposed PRs and this fork keeps syncing FROM upstream instead. --- FORK-MAINTENANCE.md | 167 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 FORK-MAINTENANCE.md diff --git a/FORK-MAINTENANCE.md b/FORK-MAINTENANCE.md new file mode 100644 index 0000000..2a7758e --- /dev/null +++ b/FORK-MAINTENANCE.md @@ -0,0 +1,167 @@ +# 本 fork 的自定义改动与上游同步指南 + +> 本文件由 leoshone/JSON-Viewer fork 维护。 +> 日期基准:2026-09-04。上游基线:`NPP-JSONViewer/JSON-Viewer@` **c448336**(v2.2.0.0 + 两个 dependabot 提交)。 + +--- + +## 一、本 fork 相对上游的全部改动 + +四条需求,全部实现、验证并部署。改动**全部是新增分支**(新增设置项、新增代码路径), +不删除、不改名任何上游已有的用户可见配置——这样每次同步上游时冲突面最小。 + +| 功能 | 开关(ini `[Others]`) | 默认 | 上游 PR | +|------|------------------------|------|---------| +| R1 树面板字体缩放固化(80%~250%,重启恢复) | `TREE_ZOOM` | `100` | [#251](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/251) | +| R2 按 TAB 缓存树快照(绝不自动解析,Refresh 才画;切 TAB 回放快照不重解析;关 TAB 清缓存) | 原有 `FOLLOW_TAB=0` 的新行为 | `0` | [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | +| R3 Refresh 保留展开态与选中(按节点路径匹配,路径失效的丢弃) | 无(直接生效) | — | [#252](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/252) | +| R5 打开 json 文件时自动画一次树(之后仍走 R2 快照,不重解析) | `DRAW_ON_OPEN` | `0` | 并入 [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | + +### 分支拓扑 + +``` +c448336 (上游 master 基线) +├── fix/persist-tree-zoom = R1 → PR #251 +└── fix/keep-expansion-on-refresh = R3 → PR #252 + └── fix/per-tab-tree-snapshot = R2 + R5 → PR #253 + └── integration/all-features = R1+R3+R2+R5 合并(本地全集,不开上游 PR) +``` + +- **`integration/all-features`** 是"全家桶":合并 R1 进 #253 链,解决过 3 处 + 相邻插入冲突(`Define.h` / `Profile.cpp` / `ProfileTest.cpp` 的 + `TREE_ZOOM` vs `DRAW_ON_OPEN`)。**平时自己用、要部署 DLL,认准这条分支。** +- fork 上的 draft PR(leoshone#5 等)仅为触发 CI,**永不合并**。 + +### 与上游的关键行为差异(同步代码前先读懂) + +1. `DrawJsonTree()` 签名变了:`DrawJsonTree(bool bPreserveExpansion = false, bool bSilent = false)`。 + `bSilent=true` 时解析错误只写成树内错误节点,不弹模态框(用于 R5 的自动画树)。 +2. `FormatJson()` 走 `ReDrawJsonTree(true, true)`(重绘并保留展开态),与 R3 的 Refresh 行为一致。 +3. `HandleFileOpened()` 承接了 auto-format(原版在 `HandleTabActivated` 里做), + 使"打开文件"不再触发解析。 +4. `NPPN_READY` 会调 `RestoreCurrentTabTree()`(会话恢复场景画树入口)。 +5. 新文件:`TreeExpansion.h/.cpp`(R3 路径工具)、`TreeState.h/.cpp`(R2 快照模型)。 + +--- + +## 二、上游大概率不合并:为什么要"反向同步" + +上游实质停滞(最后一个功能提交 2026-05-30,其后只有 dependabot),三个 PR 挂着 +无人评审。**实际策略是把上游当作"别人的主线",定期把上游的新提交吸进来**, +而不是等上游吸收我们。 + +我们的优势:所有改动都是**纯增量**(新开关、新分支、新文件),与上游新代码的 +天然冲突面很小;同步时真正要盯的只有下面第四节列的高危文件。 + +--- + +## 三、从上游同步的标准操作 + +```bash +cd D:\AiSpaces\Code\JSON-Viewer\upstream-jsonviewer + +# 0) 一次性配置(只需做一次) +git remote add upstream https://github.com/NPP-JSONViewer/JSON-Viewer.git + +# 1) 取上游 +git fetch upstream + +# 2) 看上游有什么新东西(决定要不要同步) +git log --oneline integration/all-features..upstream/master + +# 3) 把上游合进集成分支 +git checkout integration/all-features +git merge upstream/master +# - 若无冲突:跑第四节的自检清单,然后 push +# - 有冲突:见第四节逐文件策略 + +# 4) 如果上游吸收了我们的某个 PR(比如先合了 #251): +# 合并会自动去重;确认 integration 分支里该功能的代码仍只剩一份: +git grep -n "TREE_ZOOM" -- src/ # 应只有 Define.h/Profile.cpp/JsonViewDlg.cpp/SettingsDlg.cpp 各一处定义 +``` + +### 同步后必跑的验证(缺一不可) + +```bash +# 单元测试(MinGW 本地,~1 分钟) +cd D:\AiSpaces\Code\JSON-Viewer\_build +bash build-tests-prc.sh # 期望 168/168 PASSED +bash build-prc.sh # DLL 编译通过(只验编译,产物不用) + +# CI 出正式 DLL +git push origin integration/all-features +# (fork 已有 base=master 的 draft PR 时,push 自动触发 CI) +gh run watch --repo leoshone/JSON-Viewer $(gh run list --repo leoshone/JSON-Viewer --branch integration/all-features --limit 1 --json databaseId -q '.[0].databaseId') +# 六 job 全绿后下载: +gh run download --repo leoshone/JSON-Viewer -n NppJSONViewer_x64_Release -D ci-integration + +# 真机 E2E(把 ci-integration\NPPJSONViewer.dll 拷进 _npp-test 后跑) +powershell -File _build\e2e-integration.ps1 # 期望 20/20 +powershell -File _build\e2e-r2.ps1 # 期望 21/21 +powershell -File _build\e2e-drawonopen.ps1 # 期望 20/20 +powershell -File _build\e2e-r3.ps1 # 期望 10/10 +powershell -File _build\e2e-r1.ps1 # 期望 6/6 +``` + +### 部署到日常 NPP + +```powershell +# NPP 必须已关闭! +$dir = "D:\Software\Notepad\plugins\NPPJSONViewer" +Copy-Item "D:\AiSpaces\Code\JSON-Viewer\_build\ci-integration\NPPJSONViewer.dll" "$dir\NPPJSONViewer.dll" -Force +``` + +日常 NPP:`D:\Software\Notepad`(doLocalConf,x64)。原版 DLL 备份在同目录 +`NPPJSONViewer.dll.official-20250223`。 + +--- + +## 四、同步冲突高危文件与处置策略 + +按优先级排列。前三个是**已知的惯犯**(R1 与 R5 的相邻插入在此冲突过一次): + +| 文件 | 冲突形态 | 处置 | +|------|----------|------| +| `src/NppJsonViewer/Define.h` | 上游改 `Setting` 结构体 / 我们加了两个常量和一个字段 | 保两边;`TREE_ZOOM` 常量在上、`DRAW_ON_OPEN` 在下的顺序保持稳定 | +| `src/NppJsonViewer/Profile.cpp` | `GetSettings`/`SetSettings` 读写链相邻行 | 保两边;注意 `bRetVal &&` 链别断(虽然上游 `ReadValue(int)` 恒真,链断了也不报错——这正是它隐蔽的地方) | +| `tests/UnitTest/ProfileTest.cpp` | 默认值断言块 + 文件尾部新测试 | 保两边;`TreeZoom_RoundTrip` 和 `DrawOnOpen_RoundTrip` 两个 TEST 都要在 | +| `src/NppJsonViewer/JsonViewDlg.cpp` | 上游若改 `display()`/`HandleTabActivated()`/`DrawJsonTree()` 会撞我们的大改动 | **人工逐段合**。改动核心都在这几处:`display()` 的 `bShow` 分支、`HandleTabActivated` 的 else 分支、`RestoreTabState` 的无快照分支、`DrawJsonTree` 的静默开关 | +| `src/NppJsonViewer/resource.rc` | 上游动设置对话框布局 | 我们的复选框 `IDC_CHK_DRAW_ON_OPEN` 在 y=85;若上游也加了控件注意 y 坐标排布。**该文件含非 ASCII 字节(©=0xa9),必须用字节级工具修冲突,禁用会转码的编辑器** | +| `NPPJSONViewer.vcxproj` / `UnitTest.vcxproj` | 上游增删源文件 | 我们的新文件(TreeState/TreeExpansion)条目要保留;文件带 UTF-8 BOM,别弄丢 | + +### 提交前的固定自查(曾两次救命) + +1. **编码完整性**:改动文件逐个比对非 ASCII 字节数(对照 HEAD 版本)。 + `resource.rc` 必须恰好 1 个 `\xa9`;vcxproj 必须恰好 1 组 `ef bb bf`(BOM)。 + 其他文件应为 0。发现 U+FFFD(`ef bf bd`)= 编码往返损坏,用 Python 字节级替换修复。 +2. **同文件多处 Edit 必须串行**,改完按清单 grep 核对每一处。 +3. 单测**不编译** `JsonViewDlg.cpp`——它编译通过必须靠 `build-prc.sh`(DLL 链接)验证。 + +--- + +## 五、已知限制(有意不修,防止遗忘) + +1. **key 含 `.` 的 JSON**:R3/R2 的路径匹配用 `.` 分隔,key 本身带点时该节点的 + 展开/选中恢复会失配(数据无损,仅恢复精度)。若要修:SplitPath/JoinPath + 换转义方案或长度前缀方案(`TreeExpansion.cpp:5`、`JsonViewDlg.cpp:764`)。 +2. **面板隐藏时打开文件不触发 auto-format**:与上游原版行为一致(原版同样要求 + `isVisible()`),不是回归。 +3. **上游既有问题**(不修,除非顺手):`Profile::ReadValue(int)` 恒返回 true + (错误链失效);`SetTreeViewZoom` 的 `static HFONT` 缓存初始字体、句柄不释放。 +4. `SetSettings_Positive` 测试里 `nTreeZoom` 断言是恒真的(没赋值就断言), + 真实覆盖靠 `TreeZoom_RoundTrip`。上游 `bAutoFormat` 重复赋值同款瑕疵。 + 修这两个要在对应 PR 分支上单独做,**不要在 integration 分支顺手修**—— + 它必须保持为三个 PR 的严格并集。 + +--- + +## 六、历史档案 + +- 开发全过程日志:`.workbuddy/memory/2026-09-03.md`、`2026-09-04.md` +- 上游 PR 跟踪(每日自动检查):`.workbuddy/memory/upstream-pr-watch.md` +- E2E 脚手架说明与踩坑全集:`.workbuddy/skills/win-gui-e2e-messaging/SKILL.md` +- 测试环境:`_npp-test\`(便携版 NPP 8.9.6.2 x64 + `-multiInst -nosession` 隔离实例) +- 本地构建脚本:`_build\build-prc.sh`(DLL)、`build-tests-prc.sh`(单测), + 需 MinGW(`C:/Users/xiongbin/WorkBuddy/2026-08-27-20-02-13/toolchain/mingw64`) +- CI:fork 的 GitHub Actions 已启用;上游 CI 只认 master 的 push/PR, + fork 分支必须开 base=master 的 draft PR 才触发 From 5a07d3863a2c0412fd60b852b61fed811e14dc47 Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 14:35:55 +0800 Subject: [PATCH 07/18] Recognize jsonc files as json (IsJsonFile accepts L_JSON5) Notepad++ maps the .jsonc extension to the json5 language, so IsJsonFile() returned false for jsonc documents and the plugin skipped them entirely: no draw-on-open, no follow-tab, no auto-format. Accepting L_JSON5 alongside L_JSON is enough - the parser already handles jsonc content (ignoring comments and trailing commas is configurable and on by default), and it was verified end to end that a jsonc document parsed through the json code path works. Full JSON5 syntax beyond comments and trailing commas (unquoted keys, single-quoted strings) is not supported by the parser and keeps failing with a parse error. --- src/NppJsonViewer/ScintillaEditor.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/NppJsonViewer/ScintillaEditor.cpp b/src/NppJsonViewer/ScintillaEditor.cpp index 4132b5f..d843f23 100644 --- a/src/NppJsonViewer/ScintillaEditor.cpp +++ b/src/NppJsonViewer/ScintillaEditor.cpp @@ -54,9 +54,15 @@ void ScintillaEditor::SetLangAsJson() const bool ScintillaEditor::IsJsonFile() const { + // JSONC files (comments + trailing commas) live under the L_JSON5 language + // in Notepad++'s default langs.xml ("json5 jsonc" share one entry). The + // parser handles them fine - ignoring comments and trailing commas is + // already configurable and on by default - so both language types count. + // Full JSON5 syntax beyond that (unquoted keys, single quotes) is not + // supported by the parser and will still fail with a parse error. unsigned languageType = 0; ::SendMessage(m_NppData._nppHandle, NPPM_GETCURRENTLANGTYPE, 0, reinterpret_cast(&languageType)); - return languageType == LangType::L_JSON; + return languageType == LangType::L_JSON || languageType == LangType::L_JSON5; } auto ScintillaEditor::GetCurrentFileName() const -> std::wstring From 376e4a8a8f83ea10638e4d8caa6e8c3776f5094d Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 14:49:48 +0800 Subject: [PATCH 08/18] Document jsonc recognition in the maintenance guide --- FORK-MAINTENANCE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/FORK-MAINTENANCE.md b/FORK-MAINTENANCE.md index 2a7758e..2277f7a 100644 --- a/FORK-MAINTENANCE.md +++ b/FORK-MAINTENANCE.md @@ -16,6 +16,11 @@ | R2 按 TAB 缓存树快照(绝不自动解析,Refresh 才画;切 TAB 回放快照不重解析;关 TAB 清缓存) | 原有 `FOLLOW_TAB=0` 的新行为 | `0` | [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | | R3 Refresh 保留展开态与选中(按节点路径匹配,路径失效的丢弃) | 无(直接生效) | — | [#252](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/252) | | R5 打开 json 文件时自动画一次树(之后仍走 R2 快照,不重解析) | `DRAW_ON_OPEN` | `0` | 并入 [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | +| R6 识别 jsonc 文件(`.jsonc` 与 `.json` 同等对待:自动画树/切 TAB 跟随/auto-format 均生效) | 无(直接生效) | — | 并入 #251/#253(`IsJsonFile` 同时接受 `L_JSON5`) | + +jsonc 支持边界:注释 + 尾逗号(解析层本就支持且默认开启);完整 JSON5 语法 +(无引号 key、单引号字符串)**不在支持范围**,会照常报解析错误。 +`IsJsonFile()` 是原版就有的判定点,此改动同时惠及 FOLLOW_TAB 与 auto-format。 ### 分支拓扑 From 39f0b3ab98e89d4fd89e6fc75d4126548727f3d1 Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 18:15:49 +0800 Subject: [PATCH 09/18] Fix bottom rows being clipped when the panel is resized at high DPI AdjustDocPanelSize multiplied the pixel delta between the new panel size and the initial one by the desktop DPI scale. Both values are already in physical pixels, so on any monitor whose scale is not 100% the tree (and the node path box) grew s times faster than the panel itself and slid below the panel's client area. The tree control computed its scroll range from its own oversized height, so the scrollbar reported the end while the last (s-1)*growth/itemHeight rows were physically outside the visible panel: fully expanded long documents showed rows that could never be scrolled into view. Measurements at 150% scaling before the fix: tree bottom 213 px below the panel client area (5+ unreachable rows at 100% zoom), node path box 115 px below it. Positioning is now absolute - template rect plus the unscaled delta, with the tree ending above a node path box that is pinned to the bottom of the client area - which also makes repeated resizes idempotent instead of accumulated. --- src/NppJsonViewer/JsonViewDlg.cpp | 102 ++++++++++++++++-------------- src/NppJsonViewer/JsonViewDlg.h | 9 ++- 2 files changed, 62 insertions(+), 49 deletions(-) diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index 4080af0..88c8f43 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -52,6 +53,10 @@ void JsonViewDlg::ShowDlg(bool bShow) m_lfInitialClientWidth = rc.right - rc.left; m_lfInitialClientHeight = rc.bottom - rc.top; + // Remember the template layout: every later resize is expressed as + // "template rect + (current client size - initial client size)". + CaptureInitialControlRects(); + // define the default docking behaviour data.uMask = DWS_DF_CONT_LEFT | DWS_ICONTAB | DWS_ADDINFO; data.pszModuleName = getPluginFileName(); @@ -654,64 +659,67 @@ void JsonViewDlg::SetIconAndTooltip(eButton ctrlType, const std::wstring& toolTi CUtility::CreateToolTip(_hSelf, nCtrlID, toolTip, _hInst); } -void JsonViewDlg::AdjustDocPanelSize(int nWidth, int nHeight) +void JsonViewDlg::CaptureInitialControlRects() { - // Calculate desktop scale. - float fDeskScale = CUtility::GetDesktopScale(_hSelf); - - auto newDeltaWidth = nWidth - m_lfInitialClientWidth - 2; // -2 is used for margin - auto addWidth = static_cast((newDeltaWidth - m_lfDeltaWidth) * fDeskScale); - m_lfDeltaWidth = newDeltaWidth; - - auto newDeltaHeight = nHeight - m_lfInitialClientHeight; - auto addHeight = static_cast((newDeltaHeight - m_lfDeltaHeight) * fDeskScale); - m_lfDeltaHeight = newDeltaHeight; - - // elements that need to be resized horizontally - const auto resizeWindowIDs = {IDC_EDT_SEARCH, IDC_TREE}; - - // elements that need to be moved - const auto moveWindowIDs = {IDC_BTN_SEARCH}; + auto capture = [this](int id, RECT& out) { + RECT r {}; + ::GetWindowRect(::GetDlgItem(getHSelf(), id), &r); + ::MapWindowPoints(NULL, getHSelf(), reinterpret_cast(&r), 2); + out = r; + }; + + capture(IDC_EDT_SEARCH, m_rcInitSearch); + capture(IDC_BTN_SEARCH, m_rcInitSearchBtn); + capture(IDC_TREE, m_rcInitTree); + capture(IDC_EDT_NODEPATH, m_rcInitNodePath); +} - // elements which requires both resizing and move - const auto resizeAndMoveWindowIDs = {IDC_EDT_NODEPATH}; +void JsonViewDlg::AdjustDocPanelSize(int nWidth, int nHeight) +{ + // nWidth/nHeight (WM_SIZE) and m_lfInitialClient* (GetClientRect) are both + // already in physical pixels, so the delta must NOT be multiplied by the + // desktop DPI scale. The previous code did exactly that: on any monitor + // whose scale is not 100% the tree grew faster than its parent panel and + // its bottom rows - together with the node path box - slid below the + // panel's client area, where no scroll bar can ever reach them. + // Every control is therefore positioned from the *current* client size + // (template rect + unscaled delta), which also makes repeated resizes + // idempotent instead of accumulated. + const int addWidth = nWidth - m_lfInitialClientWidth; + const int addHeight = nHeight - m_lfInitialClientHeight; const UINT flags = SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOACTIVATE | SWP_NOCOPYBITS | SWP_SHOWWINDOW; - RECT rc; - for (int id : resizeWindowIDs) - { - HWND hWnd = ::GetDlgItem(_hSelf, id); - ::GetWindowRect(hWnd, &rc); - int cx = rc.right - rc.left + addWidth; - int cy = rc.bottom - rc.top; - - if (id == IDC_TREE) - cy += addHeight; + const auto width = [](const RECT& r) { return r.right - r.left; }; + const auto height = [](const RECT& r) { return r.bottom - r.top; }; - ::SetWindowPos(hWnd, NULL, 0, 0, cx, cy, SWP_NOMOVE | flags); - } + // Pixels kept below the node path box. The dialog template reserves a small + // margin; fall back to 2 px when the template is already tighter than that. + // (RECT members are LONG: cast so std::max deduces a single type.) + const int bottomMargin = std::max(2, static_cast(m_lfInitialClientHeight) - static_cast(m_rcInitNodePath.bottom)); + const int gapTreeToPath = std::max(1, static_cast(m_rcInitNodePath.top) - static_cast(m_rcInitTree.bottom)); - for (int id : moveWindowIDs) - { - HWND hWnd = GetDlgItem(_hSelf, id); - ::GetWindowRect(hWnd, &rc); - ::MapWindowPoints(NULL, _hSelf, (LPPOINT)&rc, 2); + const int nodePathTop = nHeight - bottomMargin - height(m_rcInitNodePath); + const int treeHeight = std::max(20, nodePathTop - gapTreeToPath - static_cast(m_rcInitTree.top)); - ::SetWindowPos(hWnd, NULL, rc.left + addWidth, rc.top, 0, 0, SWP_NOSIZE | flags); - } + // search box stretches to the right, the search button slides along with it + ::SetWindowPos(::GetDlgItem(_hSelf, IDC_EDT_SEARCH), NULL, + m_rcInitSearch.left, m_rcInitSearch.top, + width(m_rcInitSearch) + addWidth, height(m_rcInitSearch), flags); - for (int id : resizeAndMoveWindowIDs) - { - HWND hWnd = GetDlgItem(_hSelf, id); + ::SetWindowPos(::GetDlgItem(_hSelf, IDC_BTN_SEARCH), NULL, + m_rcInitSearchBtn.left + addWidth, m_rcInitSearchBtn.top, + 0, 0, SWP_NOSIZE | flags); - ::GetWindowRect(hWnd, &rc); - int cx = rc.right - rc.left + addWidth; - int cy = rc.bottom - rc.top; - ::MapWindowPoints(NULL, _hSelf, (LPPOINT)&rc, 2); + // node path box: pinned to the bottom, full width + ::SetWindowPos(::GetDlgItem(_hSelf, IDC_EDT_NODEPATH), NULL, + m_rcInitNodePath.left, nodePathTop, + width(m_rcInitNodePath) + addWidth, height(m_rcInitNodePath), flags); - ::SetWindowPos(hWnd, NULL, rc.left, rc.top + addHeight, cx, cy, flags); - } + // tree: everything between the tool bar row and the node path box + ::SetWindowPos(::GetDlgItem(_hSelf, IDC_TREE), NULL, + m_rcInitTree.left, m_rcInitTree.top, + width(m_rcInitTree) + addWidth, treeHeight, flags); } void JsonViewDlg::ShowContextMenu(int x, int y) diff --git a/src/NppJsonViewer/JsonViewDlg.h b/src/NppJsonViewer/JsonViewDlg.h index 00cc599..236725e 100644 --- a/src/NppJsonViewer/JsonViewDlg.h +++ b/src/NppJsonViewer/JsonViewDlg.h @@ -71,6 +71,7 @@ class JsonViewDlg void SetIconAndTooltip(eButton ctrlType, const std::wstring& toolTip); void AdjustDocPanelSize(int nWidth, int nHeight); + void CaptureInitialControlRects(); // Context menu related functions void ShowContextMenu(int x, int y); @@ -116,8 +117,12 @@ class JsonViewDlg const bool& m_IsNppReady; // To handle doc panel resizing - LONG m_lfDeltaWidth = 0; - LONG m_lfDeltaHeight = 0; + // Template rects of the resizable controls, captured once at creation and + // used as the baseline for every later resize (see AdjustDocPanelSize). + RECT m_rcInitSearch = {}; + RECT m_rcInitSearchBtn = {}; + RECT m_rcInitTree = {}; + RECT m_rcInitNodePath = {}; LONG m_lfInitialClientWidth = 0; LONG m_lfInitialClientHeight = 0; RECT m_rcInitialWindowRect = {}; From e2130aab801d22612519bfadc98fa573904ae6d0 Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 18:23:22 +0800 Subject: [PATCH 10/18] Document the panel DPI clipping fix in the maintenance guide --- FORK-MAINTENANCE.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/FORK-MAINTENANCE.md b/FORK-MAINTENANCE.md index 2277f7a..9b2df20 100644 --- a/FORK-MAINTENANCE.md +++ b/FORK-MAINTENANCE.md @@ -17,6 +17,12 @@ | R3 Refresh 保留展开态与选中(按节点路径匹配,路径失效的丢弃) | 无(直接生效) | — | [#252](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/252) | | R5 打开 json 文件时自动画一次树(之后仍走 R2 快照,不重解析) | `DRAW_ON_OPEN` | `0` | 并入 [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | | R6 识别 jsonc 文件(`.jsonc` 与 `.json` 同等对待:自动画树/切 TAB 跟随/auto-format 均生效) | 无(直接生效) | — | 并入 #251/#253(`IsJsonFile` 同时接受 `L_JSON5`) | +| R7 修复高 DPI 下面板底部若干行树节点永远滚不到(上游固有 bug,见 [#254](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/254)) | 无(直接生效) | — | [#254](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/254) | + +R7 说明(2026-09-04):上游 `AdjustDocPanelSize()` 把**已是物理像素**的尺寸增量又乘了一遍 +DPI 缩放系数,任何非 100% 缩放的显示器上树控件都会比面板长得快,底部 `(s-1)×增量/行高` +行被父窗口裁掉且滚动条到不了(150% + 默认行高时约 5~10 行),节点路径输入框也会被整个 +推出面板外。修复改为绝对定位:模板矩形 + 未缩放增量,树底部始终锚在节点路径框上方。 jsonc 支持边界:注释 + 尾逗号(解析层本就支持且默认开启);完整 JSON5 语法 (无引号 key、单引号字符串)**不在支持范围**,会照常报解析错误。 @@ -27,9 +33,10 @@ jsonc 支持边界:注释 + 尾逗号(解析层本就支持且默认开启 ``` c448336 (上游 master 基线) ├── fix/persist-tree-zoom = R1 → PR #251 +├── fix/panel-dpi-clipping = R7 → PR #254 └── fix/keep-expansion-on-refresh = R3 → PR #252 └── fix/per-tab-tree-snapshot = R2 + R5 → PR #253 - └── integration/all-features = R1+R3+R2+R5 合并(本地全集,不开上游 PR) + └── integration/all-features = R1+R3+R2+R5+R7 合并(本地全集,不开上游 PR) ``` - **`integration/all-features`** 是"全家桶":合并 R1 进 #253 链,解决过 3 处 @@ -46,6 +53,12 @@ c448336 (上游 master 基线) 使"打开文件"不再触发解析。 4. `NPPN_READY` 会调 `RestoreCurrentTabTree()`(会话恢复场景画树入口)。 5. 新文件:`TreeExpansion.h/.cpp`(R3 路径工具)、`TreeState.h/.cpp`(R2 快照模型)。 +6. **`AdjustDocPanelSize()` 整个重写(R7,master 基线的 PR #254)**:上游该函数 + 用"DPI 缩放系数 × 增量"做累加式布局,是高 DPI 裁行 bug 的根源。我们改成了 + 绝对定位(模板矩形 + 未缩放增量,新增 `CaptureInitialControlRects()` 和 4 个 + `m_rcInit*` 成员,删除 `m_lfDeltaWidth/Height`)。**同步上游时此处必冲突**: + 上游若改这个函数,先确认上游是否已自行修复(看有没有乘 `GetDesktopScale`), + 没修就保我们的版本。 --- From 8c8fefe396f1f4f5f89ed3f29fe8f575c6399c26 Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 18:27:17 +0800 Subject: [PATCH 11/18] Defeat the windows.h min/max macros when calling (std::max) MSVC's windows.h defines max as a macro unless NOMINMAX is set, so std::max(...) failed to compile as std::(...). Parenthesize the call - the standard portable workaround - instead of touching the project's include settings. --- src/NppJsonViewer/JsonViewDlg.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index 56ac2cb..7517582 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -1099,12 +1099,13 @@ void JsonViewDlg::AdjustDocPanelSize(int nWidth, int nHeight) // Pixels kept below the node path box. The dialog template reserves a small // margin; fall back to 2 px when the template is already tighter than that. - // (RECT members are LONG: cast so std::max deduces a single type.) - const int bottomMargin = std::max(2, static_cast(m_lfInitialClientHeight) - static_cast(m_rcInitNodePath.bottom)); - const int gapTreeToPath = std::max(1, static_cast(m_rcInitNodePath.top) - static_cast(m_rcInitTree.bottom)); + // (RECT members are LONG: cast so (std::max) deduces a single type. + // The parens also defeat the windows.h min/max macros on MSVC.) + const int bottomMargin = (std::max)(2, static_cast(m_lfInitialClientHeight) - static_cast(m_rcInitNodePath.bottom)); + const int gapTreeToPath = (std::max)(1, static_cast(m_rcInitNodePath.top) - static_cast(m_rcInitTree.bottom)); const int nodePathTop = nHeight - bottomMargin - height(m_rcInitNodePath); - const int treeHeight = std::max(20, nodePathTop - gapTreeToPath - static_cast(m_rcInitTree.top)); + const int treeHeight = (std::max)(20, nodePathTop - gapTreeToPath - static_cast(m_rcInitTree.top)); // search box stretches to the right, the search button slides along with it ::SetWindowPos(::GetDlgItem(_hSelf, IDC_EDT_SEARCH), NULL, From b32e8d8bec4aa82d4d44ac13373a8490e8cfb3d3 Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 18:27:17 +0800 Subject: [PATCH 12/18] Defeat the windows.h min/max macros when calling (std::max) MSVC's windows.h defines max as a macro unless NOMINMAX is set, so std::max(...) failed to compile as std::(...). Parenthesize the call - the standard portable workaround - instead of touching the project's include settings. --- src/NppJsonViewer/JsonViewDlg.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index 88c8f43..f2ef1c3 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -695,12 +695,13 @@ void JsonViewDlg::AdjustDocPanelSize(int nWidth, int nHeight) // Pixels kept below the node path box. The dialog template reserves a small // margin; fall back to 2 px when the template is already tighter than that. - // (RECT members are LONG: cast so std::max deduces a single type.) - const int bottomMargin = std::max(2, static_cast(m_lfInitialClientHeight) - static_cast(m_rcInitNodePath.bottom)); - const int gapTreeToPath = std::max(1, static_cast(m_rcInitNodePath.top) - static_cast(m_rcInitTree.bottom)); + // (RECT members are LONG: cast so (std::max) deduces a single type. + // The parens also defeat the windows.h min/max macros on MSVC.) + const int bottomMargin = (std::max)(2, static_cast(m_lfInitialClientHeight) - static_cast(m_rcInitNodePath.bottom)); + const int gapTreeToPath = (std::max)(1, static_cast(m_rcInitNodePath.top) - static_cast(m_rcInitTree.bottom)); const int nodePathTop = nHeight - bottomMargin - height(m_rcInitNodePath); - const int treeHeight = std::max(20, nodePathTop - gapTreeToPath - static_cast(m_rcInitTree.top)); + const int treeHeight = (std::max)(20, nodePathTop - gapTreeToPath - static_cast(m_rcInitTree.top)); // search box stretches to the right, the search button slides along with it ::SetWindowPos(::GetDlgItem(_hSelf, IDC_EDT_SEARCH), NULL, From e6327b361785e6bf7e47c9101ea607c6368c9979 Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 18:30:53 +0800 Subject: [PATCH 13/18] Drop the unused addHeight local (MSVC W4 treats C4189 as an error) The tree height is now derived from the node path box position instead of the raw height delta, so the variable is gone. --- src/NppJsonViewer/JsonViewDlg.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index 7517582..4608941 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -1089,8 +1089,7 @@ void JsonViewDlg::AdjustDocPanelSize(int nWidth, int nHeight) // Every control is therefore positioned from the *current* client size // (template rect + unscaled delta), which also makes repeated resizes // idempotent instead of accumulated. - const int addWidth = nWidth - m_lfInitialClientWidth; - const int addHeight = nHeight - m_lfInitialClientHeight; + const int addWidth = nWidth - m_lfInitialClientWidth; const UINT flags = SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOACTIVATE | SWP_NOCOPYBITS | SWP_SHOWWINDOW; From be21c0cd66c734e711b6ece2f03d4c28c5021cb8 Mon Sep 17 00:00:00 2001 From: leoshone Date: Fri, 4 Sep 2026 18:30:53 +0800 Subject: [PATCH 14/18] Drop the unused addHeight local (MSVC W4 treats C4189 as an error) The tree height is now derived from the node path box position instead of the raw height delta, so the variable is gone. --- src/NppJsonViewer/JsonViewDlg.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/NppJsonViewer/JsonViewDlg.cpp b/src/NppJsonViewer/JsonViewDlg.cpp index f2ef1c3..7a7632e 100644 --- a/src/NppJsonViewer/JsonViewDlg.cpp +++ b/src/NppJsonViewer/JsonViewDlg.cpp @@ -685,8 +685,7 @@ void JsonViewDlg::AdjustDocPanelSize(int nWidth, int nHeight) // Every control is therefore positioned from the *current* client size // (template rect + unscaled delta), which also makes repeated resizes // idempotent instead of accumulated. - const int addWidth = nWidth - m_lfInitialClientWidth; - const int addHeight = nHeight - m_lfInitialClientHeight; + const int addWidth = nWidth - m_lfInitialClientWidth; const UINT flags = SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOACTIVATE | SWP_NOCOPYBITS | SWP_SHOWWINDOW; From 02698479095ad42c4d31b2aff41098c2fb8e29ab Mon Sep 17 00:00:00 2001 From: leoshone Date: Sat, 5 Sep 2026 12:19:11 +0800 Subject: [PATCH 15/18] Record the upstream review outcome: jsonc is fork-only again The maintainer asked to drop the L_JSON5 acceptance in #251 because the parser does not support real JSON5 syntax. The commit is removed from both PR branches (reset + force push) and the PR descriptions are rewritten; jsonc recognition stays in the integration branch, where it came from an independent merge and is unaffected by the reset. Also records that gh pr edit --body-file replaces the whole body (that is how #251 lost its description in the first place) and that a fork account cannot re-request a review upstream. --- FORK-MAINTENANCE.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/FORK-MAINTENANCE.md b/FORK-MAINTENANCE.md index 9b2df20..180adbc 100644 --- a/FORK-MAINTENANCE.md +++ b/FORK-MAINTENANCE.md @@ -16,7 +16,7 @@ | R2 按 TAB 缓存树快照(绝不自动解析,Refresh 才画;切 TAB 回放快照不重解析;关 TAB 清缓存) | 原有 `FOLLOW_TAB=0` 的新行为 | `0` | [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | | R3 Refresh 保留展开态与选中(按节点路径匹配,路径失效的丢弃) | 无(直接生效) | — | [#252](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/252) | | R5 打开 json 文件时自动画一次树(之后仍走 R2 快照,不重解析) | `DRAW_ON_OPEN` | `0` | 并入 [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | -| R6 识别 jsonc 文件(`.jsonc` 与 `.json` 同等对待:自动画树/切 TAB 跟随/auto-format 均生效) | 无(直接生效) | — | 并入 #251/#253(`IsJsonFile` 同时接受 `L_JSON5`) | +| R6 识别 jsonc 文件(`.jsonc` 与 `.json` 同等对待:自动画树/切 TAB 跟随/auto-format 均生效) | 无(直接生效) | — | **仅本 fork**(已从 #251/#253 撤下,见下) | | R7 修复高 DPI 下面板底部若干行树节点永远滚不到(上游固有 bug,见 [#254](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/254)) | 无(直接生效) | — | [#254](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/254) | R7 说明(2026-09-04):上游 `AdjustDocPanelSize()` 把**已是物理像素**的尺寸增量又乘了一遍 @@ -24,6 +24,15 @@ DPI 缩放系数,任何非 100% 缩放的显示器上树控件都会比面板 行被父窗口裁掉且滚动条到不了(150% + 默认行高时约 5~10 行),节点路径输入框也会被整个 推出面板外。修复改为绝对定位:模板矩形 + 未缩放增量,树底部始终锚在节点路径框上方。 +**R6 归属变更(2026-09-05)**:上游 maintainer SinghRajenM 在 #251 评审要求 +"remove JSON5 as it is not supported currently by the plugin"——理由是插件解析器 +不支持真正的 JSON5 语法(无引号 key、单引号字符串),接了 `L_JSON5` 会误导。 +已按他要求从 `fix/persist-tree-zoom`(#251)与 `fix/per-tab-tree-snapshot`(#253) +**reset 掉该提交**(各只动 `ScintillaEditor.cpp` 一个文件),两个 PR 恢复为纯 R1 / R3+R2+R5; +**jsonc 改动只留在 fork 的 `integration/all-features`**(合并历史独立,不受分支 reset 影响)。 +对本机无影响:xiongbin 早已把 `langs.xml` 里的 jsonc 从 json5 语言挪到 json 语言, +`.jsonc` 报的是 `L_JSON`,本来就不依赖这行代码。 + jsonc 支持边界:注释 + 尾逗号(解析层本就支持且默认开启);完整 JSON5 语法 (无引号 key、单引号字符串)**不在支持范围**,会照常报解析错误。 `IsJsonFile()` 是原版就有的判定点,此改动同时惠及 FOLLOW_TAB 与 auto-format。 @@ -154,6 +163,28 @@ Copy-Item "D:\AiSpaces\Code\JSON-Viewer\_build\ci-integration\NPPJSONViewer.dll" 其他文件应为 0。发现 U+FFFD(`ef bf bd`)= 编码往返损坏,用 Python 字节级替换修复。 2. **同文件多处 Edit 必须串行**,改完按清单 grep 核对每一处。 3. 单测**不编译** `JsonViewDlg.cpp`——它编译通过必须靠 `build-prc.sh`(DLL 链接)验证。 +4. **`gh pr edit --body-file` 是整体替换,不是追加。** 2026-09-04 给 #251 "追加" jsonc + 说明时直接把原正文冲掉了,PR 只剩一段 jsonc 文字——reviewer 看不到任何背景, + 直接问了两条本可自答的问题。要追加必须先取回原 body 再拼接: + ```bash + gh pr view N --repo --json body --jq .body > body.md # 取回 + # 拼上新段落,再 --body-file 写回 + ``` + 每次改完 PR 正文,都回读一次确认(`gh pr view N --json body --jq '(.body|length)'`)。 +5. **fork 账号无法在上游 PR 上 `requested_reviewers`**(404,无写权限)。 + 回应完评审意见只能等对方收到通知,不能主动 re-request。 + +### 上游 PR 状态(2026-09-05) + +| PR | 内容 | 状态 | +|---|---|---| +| [#251](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/251) | R1 缩放固化(65 行 / 6 文件) | **CHANGES_REQUESTED**:要求移除 JSON5 → 已整改并逐条回复 | +| [#252](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/252) | R3 Refresh 保留展开态 | 无人评审 | +| [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | R2+R5 按 TAB 缓存 / 打开画树 | 无人评审(已按 #251 的意见同步撤下 jsonc) | +| [#254](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/254) | R7 高 DPI 裁行修复 | 无人评审 | + +信号很明确:**只有小 PR 会被看**。#251 是本批唯一被 review 的(也是唯一有机会合的), +所以后续任何顺手改动都**不要塞进 #251**——先问自己"这个改动会让这个 PR 变难审吗"。 --- From aaf47b913dea50154be4a2b61a638bece7b00a08 Mon Sep 17 00:00:00 2001 From: leoshone Date: Sat, 5 Sep 2026 12:51:42 +0800 Subject: [PATCH 16/18] Recognize .jsonc without implying JSON5 support IsJsonFile() used to accept every L_JSON5 document. Notepad++ maps the .jsonc extension to the json5 language ("json5 jsonc" share one entry in the default langs.xml), but that also pulled in genuine .json5 documents, whose syntax (unquoted keys, single-quoted strings) the parser does not support: they used to be silently ignored by the plugin and would now be parsed and reported as an error. Accept the json5 language for .jsonc files only, so a real .json5 file keeps behaving exactly as before this change. Verified end to end: .jsonc and .json5 are both reported as the same language (86) by Notepad++, the .jsonc document is drawn while the .json5 one is not, and the existing jsonc assertions still pass. --- src/NppJsonViewer/ScintillaEditor.cpp | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/NppJsonViewer/ScintillaEditor.cpp b/src/NppJsonViewer/ScintillaEditor.cpp index d843f23..db96a08 100644 --- a/src/NppJsonViewer/ScintillaEditor.cpp +++ b/src/NppJsonViewer/ScintillaEditor.cpp @@ -1,4 +1,5 @@ #include "ScintillaEditor.h" +#include "StringHelper.h" #include #include @@ -54,15 +55,27 @@ void ScintillaEditor::SetLangAsJson() const bool ScintillaEditor::IsJsonFile() const { - // JSONC files (comments + trailing commas) live under the L_JSON5 language - // in Notepad++'s default langs.xml ("json5 jsonc" share one entry). The - // parser handles them fine - ignoring comments and trailing commas is - // already configurable and on by default - so both language types count. - // Full JSON5 syntax beyond that (unquoted keys, single quotes) is not - // supported by the parser and will still fail with a parse error. unsigned languageType = 0; ::SendMessage(m_NppData._nppHandle, NPPM_GETCURRENTLANGTYPE, 0, reinterpret_cast(&languageType)); - return languageType == LangType::L_JSON || languageType == LangType::L_JSON5; + + if (languageType == LangType::L_JSON) + return true; + + // Notepad++ maps the .jsonc extension to the json5 language ("json5 jsonc" + // share a single entry in the default langs.xml), so a jsonc document used + // to be skipped by everything gated on IsJsonFile(). Accept that language + // for .jsonc files ONLY: the parser handles comments and trailing commas + // (both are configurable and on by default) but not the rest of the JSON5 + // syntax (unquoted keys, single-quoted strings), and a real .json5 file + // must keep being ignored exactly as it was before this change. + if (languageType == LangType::L_JSON5) + { + auto fileName = GetCurrentFileName(); + StringHelper::ToLower(fileName); + return fileName.ends_with(L".jsonc"); + } + + return false; } auto ScintillaEditor::GetCurrentFileName() const -> std::wstring From 188bc5f549d53592ee61839bd6cfbd4a09b81d90 Mon Sep 17 00:00:00 2001 From: leoshone Date: Sat, 5 Sep 2026 13:05:49 +0800 Subject: [PATCH 17/18] Add a dedicated upstream push log FORK-MAINTENANCE.md is about merging upstream code into this fork; this new document is the other direction - every PR we pushed upstream, the review comments verbatim, why we decided what we decided, and what is still pending (#255 for the narrow jsonc recognition). The PR status table moves here so the two documents cannot drift apart. Also records the environment facts that bit us: upstream runs no CI for fork PRs, a fork account cannot re-request a review, and the language of a buffer is rewritten to L_JSON by the plugin once it draws a tree. --- FORK-MAINTENANCE.md | 33 +++++----- UPSTREAM-PR-LOG.md | 146 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 UPSTREAM-PR-LOG.md diff --git a/FORK-MAINTENANCE.md b/FORK-MAINTENANCE.md index 180adbc..79b201c 100644 --- a/FORK-MAINTENANCE.md +++ b/FORK-MAINTENANCE.md @@ -24,19 +24,20 @@ DPI 缩放系数,任何非 100% 缩放的显示器上树控件都会比面板 行被父窗口裁掉且滚动条到不了(150% + 默认行高时约 5~10 行),节点路径输入框也会被整个 推出面板外。修复改为绝对定位:模板矩形 + 未缩放增量,树底部始终锚在节点路径框上方。 -**R6 归属变更(2026-09-05)**:上游 maintainer SinghRajenM 在 #251 评审要求 -"remove JSON5 as it is not supported currently by the plugin"——理由是插件解析器 -不支持真正的 JSON5 语法(无引号 key、单引号字符串),接了 `L_JSON5` 会误导。 -已按他要求从 `fix/persist-tree-zoom`(#251)与 `fix/per-tab-tree-snapshot`(#253) -**reset 掉该提交**(各只动 `ScintillaEditor.cpp` 一个文件),两个 PR 恢复为纯 R1 / R3+R2+R5; -**jsonc 改动只留在 fork 的 `integration/all-features`**(合并历史独立,不受分支 reset 影响)。 -对本机无影响:xiongbin 早已把 `langs.xml` 里的 jsonc 从 json5 语言挪到 json 语言, -`.jsonc` 报的是 `L_JSON`,本来就不依赖这行代码。 +**R6 现在是窄版(2026-09-05)**:`IsJsonFile()` 接受 json5 语言**仅限 `.jsonc` 扩展名**, +真正的 `.json5` 文件依旧被忽略(与改动前完全一致)。起因是上游 maintainer 在 #251 要求 +移除 `L_JSON5`——完整过程、评审原文与决策理由见 **[`UPSTREAM-PR-LOG.md`](UPSTREAM-PR-LOG.md)**。 jsonc 支持边界:注释 + 尾逗号(解析层本就支持且默认开启);完整 JSON5 语法 -(无引号 key、单引号字符串)**不在支持范围**,会照常报解析错误。 +(无引号 key、单引号字符串)**不在支持范围**——`.json5` 文件会被插件忽略, +既不解析也不报错。 `IsJsonFile()` 是原版就有的判定点,此改动同时惠及 FOLLOW_TAB 与 auto-format。 +已实测确认(E2E):Notepad++ 把 `.jsonc` 与 `.json5` 都报为同一个语言(86 = L_JSON5), +内容相同的情况下只有 `.jsonc` 会被画树;且**画树成功后插件会把缓冲区语言改写成 +L_JSON**(`HighlightAsJson` → `SetLangAsJson`),所以任何语言探测都必须在 +DRAW_ON_OPEN 关闭的状态下做,否则读到的是改写后的值。 + ### 分支拓扑 ``` @@ -174,17 +175,13 @@ Copy-Item "D:\AiSpaces\Code\JSON-Viewer\_build\ci-integration\NPPJSONViewer.dll" 5. **fork 账号无法在上游 PR 上 `requested_reviewers`**(404,无写权限)。 回应完评审意见只能等对方收到通知,不能主动 re-request。 -### 上游 PR 状态(2026-09-05) +### 上游推进记录 -| PR | 内容 | 状态 | -|---|---|---| -| [#251](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/251) | R1 缩放固化(65 行 / 6 文件) | **CHANGES_REQUESTED**:要求移除 JSON5 → 已整改并逐条回复 | -| [#252](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/252) | R3 Refresh 保留展开态 | 无人评审 | -| [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | R2+R5 按 TAB 缓存 / 打开画树 | 无人评审(已按 #251 的意见同步撤下 jsonc) | -| [#254](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/254) | R7 高 DPI 裁行修复 | 无人评审 | +每个 PR 的状态、评审原文、我们的决策与理由、待办动作,全部记在 +**[`UPSTREAM-PR-LOG.md`](UPSTREAM-PR-LOG.md)**,不在这里重复(两份状态表必然不同步)。 -信号很明确:**只有小 PR 会被看**。#251 是本批唯一被 review 的(也是唯一有机会合的), -所以后续任何顺手改动都**不要塞进 #251**——先问自己"这个改动会让这个 PR 变难审吗"。 +一句话提醒:**只有小 PR 会被看**。#251(65 行)是本批唯一被 review 的, +所以任何顺手改动都**不要塞进 #251**——先问自己"这个改动会让这个 PR 变难审吗"。 --- diff --git a/UPSTREAM-PR-LOG.md b/UPSTREAM-PR-LOG.md new file mode 100644 index 0000000..a9e5b5d --- /dev/null +++ b/UPSTREAM-PR-LOG.md @@ -0,0 +1,146 @@ +# 推上游记录(leoshone fork → NPP-JSONViewer/JSON-Viewer) + +这份文档只记**推上游这件事本身**:每个 PR 的来龙去脉、评审原文、我们的决策与理由、 +当前状态和下一步。代码层面的同步操作、冲突高危文件在 +[`FORK-MAINTENANCE.md`](FORK-MAINTENANCE.md),两份文档不重复。 + +上游仓库:`NPP-JSONViewer/JSON-Viewer` 我们的 fork:`leoshone/JSON-Viewer` +基线:`c448336`(2026-08-14,上游最后一个非 dependabot 提交是 2026-05-30 的 v2.2.0.0) + +--- + +## 一、当前状态速览 + +| PR | 内容 | 提交 | 状态 | +|---|---|---|---| +| [#251](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/251) | R1 树缩放固化(65 行 / 6 文件) | 09-03 | **CHANGES_REQUESTED** → 已整改并逐条回复,等对方回应 | +| [#252](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/252) | R3 Refresh 保留展开态 | 09-03 | 无人评审 | +| [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | R2+R5 按 TAB 缓存 / 打开画树 | 09-04 | 无人评审(已按 #251 意见撤下 jsonc) | +| [#254](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/254) | R7 高 DPI 树底行裁切修复 | 09-04 | 无人评审 | +| **待开 #255** | R6 窄版 jsonc 识别 | — | **待办**:等 #251 有结果再开,见第四节 | + +**总体判断**:上游实质停滞,三个 PR 挂着无人评审。真正的策略是**把上游当主线、定期反向 +同步**,而不是等它吸收我们。但 #251 是个例外——它小而纯,是唯一有希望进上游的, +值得为它多花点功夫。 + +--- + +## 二、#251 的评审与我们的处置(2026-09-05) + +maintainer **SinghRajenM** 留了两条行内意见(review 无固定 body,只有这两条)。 + +### 意见 1 — `ScintillaEditor.cpp:65` + +> Let's remove `JSON5` as it is not supported currently by the plugin. + +**他的意思**:我们的 jsonc 改动让 `IsJsonFile()` 接受 `L_JSON5`,但插件解析器不支持真正的 +JSON5 语法(无引号 key、单引号字符串),这等于宣称支持了并不支持的东西。 + +**我们的分析**:顾虑成立,而且比他说的还严重一点——真正的 `.json5` 文件原本被插件安静 +忽略,改动后会被拉去当 json 解析并弹错,**这是行为倒退**。 + +**处置**:照做。`fix/persist-tree-zoom` reset 到 `7771ca0`、`fix/per-tab-tree-snapshot` +reset 到 `ed4c3d8`,force-push。两个 PR 都不再含 jsonc 提交(各只动 `ScintillaEditor.cpp` +一个文件)。jsonc 改动留在 fork 的 `integration/all-features`(合并历史独立, +分支 reset 不影响它)。 + +**回复原文**: +> Agreed - full JSON5 syntax (unquoted keys, single-quoted strings) is not supported by the parser, and the change was out of scope for this PR anyway. +> Removed: `IsJsonFile()` is back to `L_JSON` only, and the extra commit is gone from this branch (and from #253). jsonc recognition stays in my fork. + +### 意见 2 — `Define.h:125` + +> What is the purpose and where it is used? + +**他问的是** `Setting::configPath`,**这是我们加的**(用于对话框写回 `TREE_ZOOM`: +`JsonViewDlg.cpp:1363` → `ProfileSetting(m_pSetting->configPath).SetSettings(...)`; +上游原先只把 ini 路径单独传给 `SettingsDlg`)。 + +**这条疑问一半是我们自找的**:09-04 那次给 #251 "追加" jsonc 说明时,用 +`gh pr edit --body-file` 是**整体替换**,把原正文冲掉了,PR 只剩一段 jsonc 文字, +reviewer 看不到任何背景。已重写完整正文(R1 描述 + 拖拽修复说明 + 验证章节)。 + +**回复原文**: +> `configPath` is the full path of JSONViewer.ini. +> The tree dialog needs it because the zoom level has to be written back to the ini when the user moves the zoom slider (`JsonViewDlg.cpp` -> `ProfileSetting(m_pSetting->configPath).SetSettings(...)`), and the dialog had no access to that path before - upstream only hands it to `SettingsDlg` through that dialog's own `m_configPath` (`NppJsonPlugin.cpp`). It is assigned once in `NppJsonPlugin.cpp` and is not itself persisted to the ini. +> I also rewrote the PR description, which had lost its original content - sorry about the missing context. + +### 为什么是"撤掉"而不是"反驳" + +- jsonc 本来就是顺手加的,不是 #251 的主题;为它挡住唯一有希望的 PR 不划算 +- #251 是本批唯一被 review 的,说明**只有小 PR 会被看**——保持它小才有机会 +- 我们自己的发布包照旧带 jsonc,撤 PR 不影响自己用 + +--- + +## 三、窄版 jsonc(R6 的当前形态,2026-09-05) + +撤掉之后我们把 fork 里的实现改成了**窄版**: + +```cpp +if (languageType == LangType::L_JSON) return true; +if (languageType == LangType::L_JSON5) return 文件名以 .jsonc 结尾; // 只放 .jsonc +return false; +``` + +与宽版(`L_JSON || L_JSON5`)的差别:**真正的 `.json5` 文件行为零变化**,依旧被忽略, +不会被拉去解析然后报错。这正好回应了意见 1 的实质顾虑。 + +### 已实测坐实的事实(E2E,写进了 `e2e-jsonc.ps1` 的 C5) + +- Notepad++ 把 `.jsonc` 和 `.json5` 都报为**同一个语言:86 = L_JSON5** + (`langs.xml` 里 `json5 ext="json5 jsonc"` 共用一条记录) +- 内容完全相同的情况下:`.jsonc` 画树(4 项),`.json5` 不画(1 项) +- **画树成功后插件会把缓冲区语言改写成 L_JSON(57)**——`HighlightAsJson()` → + `SetLangAsJson()`。所以任何语言探测都必须在 DRAW_ON_OPEN **关闭**的状态下做, + 否则读到的是改写后的值,会得出"两个扩展名语言不同"的错误结论(我第一版 C5 + 就是这样误判的) +- 该断言对宽版会失败(宽版把 `.json5` 画成 6 项),所以它是真正的回归护栏,不是空断言 + +### 窄版的边界 + +只支持 jsonc(注释 + 尾逗号)。完整 JSON5 语法不支持、也不假装支持。 + +--- + +## 四、待办:窄版要不要推上游(#255) + +**结论:要推,但现在不开。** + +- **推的理由**:窄版是正面回应意见 1 的——它压根不接受 JSON5 语言,只在扩展名是 + `.jsonc` 时破例,`.json5` 行为零变化。这是把他的顾虑原样解决的方案,通过概率 + 明显高于之前那个宽版。 +- **不开的理由(时机)**:我们刚在同一条 thread 里说"Removed, jsonc stays in my fork", + 扭头又提 jsonc 显得反复;而且同时挂两个相关 PR 容易让人困惑。 +- **触发条件**:#251 被批准或合并 → 立刻开;或 #251 两周无回音 → 开。 +- **开法**:**新 PR(预计 #255),不要塞进 #251**(#251 必须保持小而纯)。 + 正文第一段直说: + > Following your comment on #251 — here is a version that does not accept JSON5 + > at all; it only recognises the `.jsonc` extension, and a real `.json5` file + > keeps behaving exactly as before. + +**风险与退路**:插件历来只按"语言类型"判定,改成看扩展名是引入第二条判据, +maintainer 可能以一致性为由拒绝。被拒也不亏——fork 留着,或者退到零代码方案 +(在说明里让用户把 `jsonc` 加进 Notepad++ 的 json 语言用户扩展名,比改 langs.xml 正当)。 + +--- + +## 五、环境事实(每次推 PR 前都适用) + +| 事实 | 影响 | +|---|---| +| 上游 master 停在 `c448336`(2026-08-14) | 我们的分支基线不用经常动 | +| **上游不为 fork PR 跑 CI** | PR 页面显示 "no checks",不是我们构建失败;要 CI 得靠 fork 自己的 draft PR(leoshone#5,指向 `integration/all-features`) | +| **fork 账号无法 `requested_reviewers`**(404,无写权限) | 回应完只能等对方收到通知,不能主动催 | +| **`gh pr edit --body-file` 是整体替换** | 追加正文必须先取回原 body 再拼接,改完回读校验(#251 正文就是这么丢的) | +| 只有小 PR 会被 review | 见第一节;#251(65 行)被看,#253(20 文件)没人碰 | + +### 写 PR 正文的硬要求 + +PR 正文是 reviewer 唯一的导览,**必须自包含**(未来他不会看我们的对话): + +1. 说清"改了什么"和"为什么",不要只贴 diff +2. 说清**验证方式**(单测覆盖了什么、E2E 在真实 NPP 里验了什么) +3. 每个新字段/新函数都要能回答"它用在哪、为什么必须有"(意见 2 就是这么来的) +4. 一个 PR 一个主题,顺手改动一律另开 +5. cherry-pick 提交到别的分支后,**正文必须同步补**(否则 diff 里有解释不到的提交) From ebfc9007d6c525983475a392d41d49ca9001800e Mon Sep 17 00:00:00 2001 From: leoshone Date: Sat, 5 Sep 2026 13:15:53 +0800 Subject: [PATCH 18/18] Pin down the #255 plan and the follow-up step Records that the plan is settled (push the narrow jsonc version as a new PR once #251 settles, leave the existing thread untouched), the reason the thread is not being edited now (comment edits send no notification, so the change would likely never be seen), and the exact comment id plus command for the forward-pointer to add afterwards. Also adds two environment facts that cost us time today: our own review comments can be edited but edits notify nobody, and the most recent CI run right after a push can still be the previous commit's run. --- UPSTREAM-PR-LOG.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/UPSTREAM-PR-LOG.md b/UPSTREAM-PR-LOG.md index a9e5b5d..7ee285a 100644 --- a/UPSTREAM-PR-LOG.md +++ b/UPSTREAM-PR-LOG.md @@ -17,7 +17,7 @@ | [#252](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/252) | R3 Refresh 保留展开态 | 09-03 | 无人评审 | | [#253](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/253) | R2+R5 按 TAB 缓存 / 打开画树 | 09-04 | 无人评审(已按 #251 意见撤下 jsonc) | | [#254](https://github.com/NPP-JSONViewer/JSON-Viewer/pull/254) | R7 高 DPI 树底行裁切修复 | 09-04 | 无人评审 | -| **待开 #255** | R6 窄版 jsonc 识别 | — | **待办**:等 #251 有结果再开,见第四节 | +| **待开 #255** | R6 窄版 jsonc 识别 | — | **计划已定(09-05)**:等 #251 有结果再开,见第四节 | **总体判断**:上游实质停滞,三个 PR 挂着无人评审。真正的策略是**把上游当主线、定期反向 同步**,而不是等它吸收我们。但 #251 是个例外——它小而纯,是唯一有希望进上游的, @@ -103,9 +103,9 @@ return false; --- -## 四、待办:窄版要不要推上游(#255) +## 四、待办:窄版推上游(#255) -**结论:要推,但现在不开。** +**计划已于 2026-09-05 定稿:要推,但现在不开;#251 的 thread 现在也不改。** - **推的理由**:窄版是正面回应意见 1 的——它压根不接受 JSON5 语言,只在扩展名是 `.jsonc` 时破例,`.json5` 行为零变化。这是把他的顾虑原样解决的方案,通过概率 @@ -119,10 +119,31 @@ return false; > at all; it only recognises the `.jsonc` extension, and a real `.json5` file > keeps behaving exactly as before. +- **#251 的 thread 现在不动**:那条 "Removed … jsonc recognition stays in my fork" + 目前仍然属实(窄版确实只在 fork 里)。而且**编辑评论不会重新发通知**,改了他也不一定 + 看得到,收益极低;刚说完 Removed 就改口还显得犹豫。 + **风险与退路**:插件历来只按"语言类型"判定,改成看扩展名是引入第二条判据, maintainer 可能以一致性为由拒绝。被拒也不亏——fork 留着,或者退到零代码方案 (在说明里让用户把 `jsonc` 加进 Notepad++ 的 json 语言用户扩展名,比改 langs.xml 正当)。 +### 开完 #255 之后的收尾(别忘了) + +在 #251 那条回复(comment id `3939416226`)**末尾追加**一句指向新 PR,让后来读 thread +的人有去处: + +```bash +gh api -X PATCH repos/NPP-JSONViewer/JSON-Viewer/pulls/comments/3939416226 \ + -f body="$(cat new-body.md)" +``` + +追加的内容: +> Superseded by #255, which recognises .jsonc without accepting JSON5 at all — +> a real .json5 file keeps behaving exactly as before. + +(另两条备用 id:我们的 `configPath` 回复是 `3939416311`;他那两条原始意见是 +`3939342913` / `3939344367`,**那两条我们不能改**。) + --- ## 五、环境事实(每次推 PR 前都适用) @@ -134,6 +155,8 @@ maintainer 可能以一致性为由拒绝。被拒也不亏——fork 留着, | **fork 账号无法 `requested_reviewers`**(404,无写权限) | 回应完只能等对方收到通知,不能主动催 | | **`gh pr edit --body-file` 是整体替换** | 追加正文必须先取回原 body 再拼接,改完回读校验(#251 正文就是这么丢的) | | 只有小 PR 会被 review | 见第一节;#251(65 行)被看,#253(20 文件)没人碰 | +| **自己发的评论可改可删**(`PATCH`/`DELETE` `/pulls/comments/{id}`) | 但**编辑不发通知**,且会留 edited 标记;别人的评论改不了 | +| **push 后别急着取 CI 产物** | `gh run list --limit 1` 可能拿到上一次的 run(新的还没登记),`watch` 会秒回 "already completed"。必须核对 `headSha` == 本地 HEAD 再下载 | ### 写 PR 正文的硬要求