Skip to content

Commit 43f15d8

Browse files
Preserve state while reloading directory tree.
1 parent 0023370 commit 43f15d8

File tree

7 files changed

+388
-71
lines changed

7 files changed

+388
-71
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1111

1212
- Fixed `DirectoryTree.clear_node` not clearing the node specified https://github.com/Textualize/textual/issues/4122
1313

14+
### Added
15+
16+
- `Tree` (and `DirectoryTree`) grew an attribute `lock` that can be used for synchronization across coroutines https://github.com/Textualize/textual/issues/4056
17+
18+
### Changed
19+
20+
- `DirectoryTree.reload` and `DirectoryTree.reload_node` now preserve state when reloading https://github.com/Textualize/textual/issues/4056
21+
1422
## [0.48.2] - 2024-02-02
1523

1624
### Fixed

src/textual/widgets/_directory_tree.py

Lines changed: 124 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,19 @@
55
from pathlib import Path
66
from typing import TYPE_CHECKING, Callable, ClassVar, Iterable, Iterator
77

8-
from ..await_complete import AwaitComplete
9-
10-
if TYPE_CHECKING:
11-
from typing_extensions import Self
12-
138
from rich.style import Style
149
from rich.text import Text, TextType
1510

1611
from .. import work
12+
from ..await_complete import AwaitComplete
1713
from ..message import Message
1814
from ..reactive import var
1915
from ..worker import Worker, WorkerCancelled, WorkerFailed, get_current_worker
2016
from ._tree import TOGGLE_STYLE, Tree, TreeNode
2117

18+
if TYPE_CHECKING:
19+
from typing_extensions import Self
20+
2221

2322
@dataclass
2423
class DirEntry:
@@ -164,7 +163,7 @@ def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete:
164163
165164
Returns:
166165
An optionally awaitable object that can be awaited until the
167-
load queue has finished processing.
166+
load queue has finished processing.
168167
"""
169168
assert node.data is not None
170169
if not node.data.loaded:
@@ -174,16 +173,18 @@ def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete:
174173
return AwaitComplete(self._load_queue.join())
175174

176175
def reload(self) -> AwaitComplete:
177-
"""Reload the `DirectoryTree` contents."""
178-
self.reset(str(self.path), DirEntry(self.PATH(self.path)))
176+
"""Reload the `DirectoryTree` contents.
177+
178+
Returns:
179+
An optionally awaitable that ensures the tree has finished reloading.
180+
"""
179181
# Orphan the old queue...
180182
self._load_queue = Queue()
183+
# ... reset the root node ...
184+
processed = self.reload_node(self.root)
181185
# ...and replace the old load with a new one.
182186
self._loader()
183-
# We have a fresh queue, we have a fresh loader, get the fresh root
184-
# loading up.
185-
queue_processed = self._add_to_load_queue(self.root)
186-
return queue_processed
187+
return processed
187188

188189
def clear_node(self, node: TreeNode[DirEntry]) -> Self:
189190
"""Clear all nodes under the given node.
@@ -215,6 +216,88 @@ def reset_node(
215216
node.data = data
216217
return self
217218

219+
async def _reload(self, node: TreeNode[DirEntry]) -> None:
220+
"""Reloads the subtree rooted at the given node while preserving state.
221+
222+
After reloading the subtree, nodes that were expanded and still exist
223+
will remain expanded and the highlighted node will be preserved, if it
224+
still exists. If it doesn't, highlighting goes up to the first parent
225+
directory that still exists.
226+
227+
Args:
228+
node: The root of the subtree to reload.
229+
"""
230+
async with self.lock:
231+
# Track nodes that were expanded before reloading.
232+
currently_open: set[Path] = set()
233+
to_check: list[TreeNode[DirEntry]] = [node]
234+
while to_check:
235+
checking = to_check.pop()
236+
if checking.allow_expand and checking.is_expanded:
237+
if checking.data:
238+
currently_open.add(checking.data.path)
239+
to_check.extend(checking.children)
240+
241+
# Track node that was highlighted before reloading.
242+
highlighted_path: None | Path = None
243+
if self.cursor_line > -1:
244+
highlighted_node = self.get_node_at_line(self.cursor_line)
245+
if highlighted_node is not None and highlighted_node.data is not None:
246+
highlighted_path = highlighted_node.data.path
247+
248+
if node.data is not None:
249+
self.reset_node(
250+
node, str(node.data.path.name), DirEntry(self.PATH(node.data.path))
251+
)
252+
253+
# Reopen nodes that were expanded and still exist.
254+
to_reopen = [node]
255+
while to_reopen:
256+
reopening = to_reopen.pop()
257+
if not reopening.data:
258+
continue
259+
if (
260+
reopening.allow_expand
261+
and (reopening.data.path in currently_open or reopening == node)
262+
and reopening.data.path.exists()
263+
):
264+
try:
265+
content = await self._load_directory(reopening).wait()
266+
except (WorkerCancelled, WorkerFailed):
267+
continue
268+
reopening.data.loaded = True
269+
self._populate_node(reopening, content)
270+
to_reopen.extend(reopening.children)
271+
reopening.expand()
272+
273+
if highlighted_path is None:
274+
return
275+
276+
# Restore the highlighted path and consider the parents as fallbacks.
277+
looking = [node]
278+
highlight_candidates = set(highlighted_path.parents)
279+
highlight_candidates.add(highlighted_path)
280+
best_found: None | TreeNode[DirEntry] = None
281+
while looking:
282+
checking = looking.pop()
283+
checking_path = (
284+
checking.data.path if checking.data is not None else None
285+
)
286+
if checking_path in highlight_candidates:
287+
best_found = checking
288+
if checking_path == highlighted_path:
289+
break
290+
if (
291+
checking.allow_expand
292+
and checking.is_expanded
293+
and checking_path in highlighted_path.parents
294+
):
295+
looking.extend(checking.children)
296+
if best_found is not None:
297+
# We need valid lines. Make sure the tree lines have been computed:
298+
_ = self._tree_lines
299+
self.cursor_line = best_found.line
300+
218301
def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete:
219302
"""Reload the given node's contents.
220303
@@ -223,12 +306,12 @@ def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete:
223306
or any other nodes).
224307
225308
Args:
226-
node: The node to reload.
309+
node: The root of the subtree to reload.
310+
311+
Returns:
312+
An optionally awaitable that ensures the subtree has finished reloading.
227313
"""
228-
self.reset_node(
229-
node, str(node.data.path.name), DirEntry(self.PATH(node.data.path))
230-
)
231-
return self._add_to_load_queue(node)
314+
return AwaitComplete(self._reload(node))
232315

233316
def validate_path(self, path: str | Path) -> Path:
234317
"""Ensure that the path is of the `Path` type.
@@ -415,28 +498,29 @@ async def _loader(self) -> None:
415498
# this blocks if the queue is empty.
416499
node = await self._load_queue.get()
417500
content: list[Path] = []
418-
try:
419-
# Spin up a short-lived thread that will load the content of
420-
# the directory associated with that node.
421-
content = await self._load_directory(node).wait()
422-
except WorkerCancelled:
423-
# The worker was cancelled, that would suggest we're all
424-
# done here and we should get out of the loader in general.
425-
break
426-
except WorkerFailed:
427-
# This particular worker failed to start. We don't know the
428-
# reason so let's no-op that (for now anyway).
429-
pass
430-
else:
431-
# We're still here and we have directory content, get it into
432-
# the tree.
433-
if content:
434-
self._populate_node(node, content)
435-
finally:
436-
# Mark this iteration as done.
437-
self._load_queue.task_done()
438-
439-
async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
501+
async with self.lock:
502+
try:
503+
# Spin up a short-lived thread that will load the content of
504+
# the directory associated with that node.
505+
content = await self._load_directory(node).wait()
506+
except WorkerCancelled:
507+
# The worker was cancelled, that would suggest we're all
508+
# done here and we should get out of the loader in general.
509+
break
510+
except WorkerFailed:
511+
# This particular worker failed to start. We don't know the
512+
# reason so let's no-op that (for now anyway).
513+
pass
514+
else:
515+
# We're still here and we have directory content, get it into
516+
# the tree.
517+
if content:
518+
self._populate_node(node, content)
519+
finally:
520+
# Mark this iteration as done.
521+
self._load_queue.task_done()
522+
523+
async def _on_tree_node_expanded(self, event: Tree.NodeExpanded[DirEntry]) -> None:
440524
event.stop()
441525
dir_entry = event.node.data
442526
if dir_entry is None:
@@ -446,7 +530,7 @@ async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
446530
else:
447531
self.post_message(self.FileSelected(event.node, dir_entry.path))
448532

449-
def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
533+
def _on_tree_node_selected(self, event: Tree.NodeSelected[DirEntry]) -> None:
450534
event.stop()
451535
dir_entry = event.node.data
452536
if dir_entry is None:

src/textual/widgets/_tree.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from asyncio import Lock
56
from dataclasses import dataclass
67
from typing import TYPE_CHECKING, ClassVar, Generic, Iterable, NewType, TypeVar, cast
78

@@ -615,8 +616,10 @@ def __init__(
615616
self.root = self._add_node(None, text_label, data)
616617
"""The root node of the tree."""
617618
self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1024)
618-
self._tree_lines_cached: list[_TreeLine] | None = None
619+
self._tree_lines_cached: list[_TreeLine[TreeDataType]] | None = None
619620
self._cursor_node: TreeNode[TreeDataType] | None = None
621+
self.lock = Lock()
622+
"""Used to synchronise stateful directory tree operations."""
620623

621624
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
622625

@@ -815,7 +818,7 @@ def _invalidate(self) -> None:
815818
self.root._reset()
816819
self.refresh(layout=True)
817820

818-
def _on_mouse_move(self, event: events.MouseMove):
821+
def _on_mouse_move(self, event: events.MouseMove) -> None:
819822
meta = event.style.meta
820823
if meta and "line" in meta:
821824
self.hover_line = meta["line"]
@@ -948,7 +951,7 @@ def _refresh_node(self, node: TreeNode[TreeDataType]) -> None:
948951
self._refresh_line(line_no)
949952

950953
@property
951-
def _tree_lines(self) -> list[_TreeLine]:
954+
def _tree_lines(self) -> list[_TreeLine[TreeDataType]]:
952955
if self._tree_lines_cached is None:
953956
self._build()
954957
assert self._tree_lines_cached is not None
@@ -957,13 +960,14 @@ def _tree_lines(self) -> list[_TreeLine]:
957960
async def _on_idle(self, event: events.Idle) -> None:
958961
"""Check tree needs a rebuild on idle."""
959962
# Property calls build if required
960-
self._tree_lines
963+
async with self.lock:
964+
self._tree_lines
961965

962966
def _build(self) -> None:
963967
"""Builds the tree by traversing nodes, and creating tree lines."""
964968

965969
TreeLine = _TreeLine
966-
lines: list[_TreeLine] = []
970+
lines: list[_TreeLine[TreeDataType]] = []
967971
add_line = lines.append
968972

969973
root = self.root
@@ -989,7 +993,7 @@ def add_node(
989993
show_root = self.show_root
990994
get_label_width = self.get_label_width
991995

992-
def get_line_width(line: _TreeLine) -> int:
996+
def get_line_width(line: _TreeLine[TreeDataType]) -> int:
993997
return get_label_width(line.node) + line._get_guide_width(
994998
guide_depth, show_root
995999
)
@@ -1147,17 +1151,18 @@ def _toggle_node(self, node: TreeNode[TreeDataType]) -> None:
11471151
node.expand()
11481152

11491153
async def _on_click(self, event: events.Click) -> None:
1150-
meta = event.style.meta
1151-
if "line" in meta:
1152-
cursor_line = meta["line"]
1153-
if meta.get("toggle", False):
1154-
node = self.get_node_at_line(cursor_line)
1155-
if node is not None:
1156-
self._toggle_node(node)
1154+
async with self.lock:
1155+
meta = event.style.meta
1156+
if "line" in meta:
1157+
cursor_line = meta["line"]
1158+
if meta.get("toggle", False):
1159+
node = self.get_node_at_line(cursor_line)
1160+
if node is not None:
1161+
self._toggle_node(node)
11571162

1158-
else:
1159-
self.cursor_line = cursor_line
1160-
await self.run_action("select_cursor")
1163+
else:
1164+
self.cursor_line = cursor_line
1165+
await self.run_action("select_cursor")
11611166

11621167
def notify_style_update(self) -> None:
11631168
self._invalidate()

0 commit comments

Comments
 (0)