Skip to content

Commit c249aec

Browse files
committed
Sidebar search: Preserve original collapse/expand state
1 parent d7c77ff commit c249aec

6 files changed

Lines changed: 138 additions & 43 deletions

File tree

gitfourchette/repowidget.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ def __init__(self, repoModel: RepoModel, taskRunner: RepoTaskRunner, parent: QWi
231231
with QSignalBlockerContext(self.sidebar):
232232
collapseCache = repoModel.prefs.collapseCache
233233
if collapseCache:
234-
self.sidebar.sidebarModel.collapseCache = set(collapseCache)
234+
self.sidebar.sidebarModel.collapseCache.update(collapseCache)
235235
self.sidebar.refresh(repoModel)
236236

237237
# ----------------------------------
@@ -392,7 +392,7 @@ def prepareForDeletion(self):
392392
# Save sidebar collapse cache
393393
with NonCriticalOperation("Write repo prefs"): # May raise OSError
394394
uiPrefs = self.repoModel.prefs
395-
collapseCache = self.sidebar.sidebarModel.collapseCache
395+
collapseCache = self.sidebar.sidebarModel.collapseCacheLayers[0]
396396
if uiPrefs.collapseCache != collapseCache:
397397
uiPrefs.collapseCache = collapseCache.copy()
398398
uiPrefs.setDirty()

gitfourchette/sidebar/sidebar.py

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,6 @@ def nodeToFilterIndex(self, node: SidebarNode) -> QModelIndex:
101101
assert index.model() is self.sidebarModel
102102
return self.filterModel.mapFromSource(index)
103103

104-
def onIndexExpanded(self, index: QModelIndex):
105-
node = self.filterIndexToNode(index)
106-
self.sidebarModel.cacheNodeCollapsedState(node, collapsed=False)
107-
108-
def onIndexCollapsed(self, index: QModelIndex):
109-
node = self.filterIndexToNode(index)
110-
self.sidebarModel.cacheNodeCollapsedState(node, collapsed=True)
111-
112104
def drawBranches(self, painter, rect, index):
113105
"""
114106
(overridden function)
@@ -861,6 +853,9 @@ def revealStashParent(self, oid: Oid):
861853
parent = commit.parent_ids[0]
862854
Jump.invoke(self, NavLocator.inCommit(parent))
863855

856+
# -------------------------------------------------------------------------
857+
# Sidebar nodes
858+
864859
def walk(self):
865860
return self.sidebarModel.rootNode.walk()
866861

@@ -937,8 +932,16 @@ def selectAnyRef(self, *refs: str) -> QModelIndex | None:
937932
self.clearSelection()
938933
return None
939934

935+
# -------------------------------------------------------------------------
936+
# Collapse/expand
937+
940938
@benchmark
941939
def restoreExpandedItems(self):
940+
"""
941+
Assumes all collapsible rows are collapsed before this is called.
942+
(After rebuilding the model, all rows are collapsed by default.)
943+
"""
944+
942945
model = self.sidebarModel
943946

944947
frontier = model.rootNode.children[:]
@@ -954,23 +957,52 @@ def restoreExpandedItems(self):
954957

955958
frontier.extend(node.children)
956959

957-
def collapseChildFolders(self, node: SidebarNode):
960+
def expandChildFolders(self, node: SidebarNode, collapse: bool = False):
961+
operation = self.collapse if collapse else self.expand
958962
frontier = node.children[:]
959963
while frontier:
960964
node = frontier.pop()
961965
if node.kind == SidebarItem.RefFolder:
962966
frontier.extend(node.children)
963967
index = self.nodeToFilterIndex(node)
964-
self.collapse(index)
968+
operation(index)
965969

966-
def expandChildFolders(self, node: SidebarNode):
967-
frontier = node.children[:]
968-
while frontier:
969-
node = frontier.pop()
970-
if node.kind == SidebarItem.RefFolder:
971-
frontier.extend(node.children)
972-
index = self.nodeToFilterIndex(node)
973-
self.expand(index)
970+
def collapseChildFolders(self, node: SidebarNode):
971+
self.expandChildFolders(node, collapse=True)
972+
973+
def onIndexExpanded(self, index: QModelIndex):
974+
node = self.filterIndexToNode(index)
975+
self.sidebarModel.cacheNodeCollapsedState(node, collapsed=False)
976+
977+
def onIndexCollapsed(self, index: QModelIndex):
978+
node = self.filterIndexToNode(index)
979+
self.sidebarModel.cacheNodeCollapsedState(node, collapsed=True)
980+
981+
def setCollapseStateLayer(self, transient: bool):
982+
model = self.sidebarModel
983+
currentLayer = len(model.collapseCacheLayers) - 1
984+
targetLayer = 1 if transient else 0
985+
986+
# No-op if switching to same layer
987+
if targetLayer == currentLayer:
988+
return
989+
990+
if currentLayer == 0:
991+
# Create layer 1 (transient)
992+
model.collapseCacheLayers.append(set())
993+
elif currentLayer == 1:
994+
# Collapse everything first so restoreExpandedItems will work,
995+
# then drop down to layer 0 (permanent)
996+
self.collapseAll()
997+
model.collapseCacheLayers.pop()
998+
else:
999+
raise NotImplementedError(f"unsupported layer {currentLayer}")
1000+
1001+
assert targetLayer == len(model.collapseCacheLayers) - 1
1002+
1003+
self.restoreExpandedItems()
1004+
1005+
# -------------------------------------------------------------------------
9741006

9751007
def copyToClipboard(self, text: str):
9761008
QApplication.clipboard().setText(text)

gitfourchette/sidebar/sidebarfilter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def __init__(self, parent=None):
3737

3838
def setFilterText(self, text: str):
3939
with FilterChangeContext(self):
40-
self._filterText = text
40+
self._filterText = text.lower()
4141

4242
def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool:
4343
if not self._filterText:
@@ -63,7 +63,7 @@ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool:
6363
# This ensures that branches like "wip/leaf" match when searching for "wip"
6464
if item in _refItems:
6565
refName = index.data(SidebarModel.Role.Ref)
66-
if refName and self._filterText.lower() in refName.lower():
66+
if refName and self._filterText in refName.lower():
6767
return True
6868

69-
return self._filterText.lower() in displayText.lower()
69+
return self._filterText in displayText.lower()

gitfourchette/sidebar/sidebarmodel.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,16 @@ class SidebarModel(QAbstractItemModel):
204204
_cachedToolTipIndex: QModelIndex
205205
_cachedToolTipText: str
206206

207-
collapseCache: set[str]
207+
collapseCacheLayers: list[set[str]]
208+
"""
209+
Keeps a cache of collapsed nodes.
210+
211+
Layer #0 is the permanent state. It is saved to disk as part of RepoPrefs.
212+
It is applied when SidebarFilter is inactive.
213+
214+
Layer #1 is the transient state. It only exists as long as SidebarFilter is
215+
active.
216+
"""
208217

209218
class Role:
210219
Ref = Qt.ItemDataRole(Qt.ItemDataRole.UserRole + 0)
@@ -220,10 +229,16 @@ def _parentWidget(self) -> QWidget:
220229
def repo(self) -> Repo:
221230
return self.repoModel.repo
222231

232+
@property
233+
def collapseCache(self) -> set[str]:
234+
return self.collapseCacheLayers[-1]
235+
223236
def __init__(self, parent=None):
224237
super().__init__(parent)
225238

226-
self.collapseCache = set()
239+
# Initialize collapse cache with an empty permanent layer
240+
# (i.e. all items start expanded)
241+
self.collapseCacheLayers = [set()]
227242

228243
self.clear()
229244

gitfourchette/sidebar/sidebarsearch.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,23 @@ def __init__(self, sidebar: Sidebar):
2323

2424
def invalidate(self):
2525
super().invalidate()
26+
27+
# Wipe term from filter, return to permanent collapse state
2628
self.setTerm("")
2729

30+
# After returning to the permanent collapse state, force expand the node
31+
# that the user selected while the filter was active
32+
node = self.sidebar.selectedNode()
33+
if node is not None and not self.sidebar.sidebarModel.isAncestryChainExpanded(node):
34+
self.sidebar.selectionModel().clear()
35+
self.sidebar.selectNode(node)
36+
2837
def _termChanged(self):
2938
term = self.term()
3039
self.sidebar.filterModel.setFilterText(term)
40+
3141
if term:
42+
self.sidebar.setCollapseStateLayer(transient=True)
3243
self.sidebar.expandAll()
3344
else:
34-
self.sidebar.restoreExpandedItems()
45+
self.sidebar.setCollapseStateLayer(transient=False)

test/test_sidebar.py

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@
1515

1616

1717
def _summonSearchBar(rw):
18+
rw.activateWindow() # macOS offscreen compat (e.g. after a context menu)
19+
waitUntilTrue(rw.isActiveWindow)
20+
1821
searchBar = rw.sidebar.searchBar
1922
assert not searchBar.isVisible()
23+
2024
rw.sidebar.setFocus()
25+
assert rw.sidebar.hasFocus()
26+
2127
QTest.keySequence(rw.window(), "Ctrl+F")
2228
assert searchBar.isVisible()
29+
2330
return searchBar
2431

2532

@@ -657,31 +664,61 @@ def testSidebarFilterPreservesSelection(tempDir, mainWindow):
657664
assert len(sb.selectedIndexes()) > 0
658665

659666

660-
def testSidebarFilterExpandsAll(tempDir, mainWindow):
667+
def testSidebarFilterCollapseState(tempDir, mainWindow):
661668
wd = unpackRepo(tempDir)
662669

663670
with RepoContext(wd) as repo:
664-
repo.create_branch_on_head("team/frontend/login")
665-
repo.create_branch_on_head("team/backend/api")
671+
repo.create_branch_on_head("folder1/leaf")
672+
repo.create_branch_on_head("folder2/leaf")
673+
repo.create_branch_on_head("folder3/leaf")
666674

667675
rw = mainWindow.openRepo(wd)
668676
sb = rw.sidebar
669-
sm = sb.sidebarModel
670-
searchBar = _summonSearchBar(rw)
671677

672-
# Collapse all folders first so we have something to expand
678+
# Bypass isAncestryChainExpanded
679+
def isExpanded(ref: str) -> bool:
680+
i = sb.indexForRef(ref)
681+
assert i.isValid()
682+
683+
i = i.parent()
684+
while i.isValid():
685+
if not sb.isExpanded(i):
686+
return False
687+
i = i.parent()
688+
return True
689+
690+
# Collapse all local branch folders first
673691
localBranchesNode = sb.findNodeByKind(SidebarItem.LocalBranchesHeader)
674692
sb.selectNode(localBranchesNode)
675693
triggerContextMenuAction(sb.viewport(), "collapse all folders")
694+
assert not isExpanded("refs/heads/folder1/leaf")
695+
assert not isExpanded("refs/heads/folder2/leaf")
696+
assert not isExpanded("refs/heads/folder3/leaf")
676697

677-
# Confirm the nested branch is now unreachable (ancestry chain collapsed)
678-
loginNode = sb.findNodeByRef("refs/heads/team/frontend/login")
679-
assert not sm.isAncestryChainExpanded(loginNode)
680-
681-
# Applying a filter should expand all matching items
682-
searchBar.lineEdit.setText("login")
683-
684-
# The branch must now be visible in the proxy model
685-
assert sb.indexForRef("refs/heads/team/frontend/login").isValid()
686-
# And its entire parent chain must be expanded in the source model
687-
assert sm.isAncestryChainExpanded(loginNode)
698+
# Summon search bar, search for "leaf"
699+
searchBar = _summonSearchBar(rw)
700+
searchBar.lineEdit.setText("leaf")
701+
assert isExpanded("refs/heads/folder1/leaf")
702+
assert isExpanded("refs/heads/folder2/leaf")
703+
assert isExpanded("refs/heads/folder3/leaf")
704+
705+
# Search for a term with no matches, then revert to searching for "leaf"
706+
searchBar.lineEdit.setText("bogusbogus")
707+
assert not sb.indexForRef("refs/heads/folder1/leaf").isValid()
708+
searchBar.lineEdit.setText("leaf")
709+
assert isExpanded("refs/heads/folder1/leaf")
710+
assert isExpanded("refs/heads/folder2/leaf")
711+
assert isExpanded("refs/heads/folder3/leaf")
712+
713+
# Select folder2/leaf before closing search bar
714+
sb.selectAnyRef("refs/heads/folder2/leaf")
715+
716+
# Close search bar
717+
searchBar.bail()
718+
719+
# folder1 & folder3 must be collapsed, as they were before filtering.
720+
assert not isExpanded("refs/heads/folder1/leaf")
721+
assert not isExpanded("refs/heads/folder3/leaf")
722+
# folder2 was originally collapsed, but it should now be expanded because
723+
# we selected it before closing the search bar.
724+
assert isExpanded("refs/heads/folder2/leaf")

0 commit comments

Comments
 (0)