From 7b8cc3679d4a6e522ef9fe37ff9e0dd7aa8b60ea Mon Sep 17 00:00:00 2001 From: leoshone Date: Thu, 3 Sep 2026 18:37:41 +0800 Subject: [PATCH] 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 @@ +