Skip to content

Commit 8036270

Browse files
authored
Merge pull request #4123 from Textualize/directory-tree-reload
Directory tree reload now preserves state
2 parents 300074d + fb5d649 commit 8036270

File tree

7 files changed

+419
-72
lines changed

7 files changed

+419
-72
lines changed

CHANGELOG.md

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

1010
### Fixed
1111

12+
- Fixed `DirectoryTree.clear_node` not clearing the node specified https://github.com/Textualize/textual/issues/4122
13+
14+
### Changed
15+
16+
- `DirectoryTree.reload` and `DirectoryTree.reload_node` now preserve state when reloading https://github.com/Textualize/textual/issues/4056
1217
- Fixed a crash in the TextArea when performing a backward replace https://github.com/Textualize/textual/pull/4126
1318
- Fixed selection not updating correctly when pasting while there's a non-zero selection https://github.com/Textualize/textual/pull/4126
1419
- Breaking change: `TextArea` will not use `Escape` to shift focus if the `tab_behaviour` is the default https://github.com/Textualize/textual/issues/4110
@@ -20,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2025
- Added DOMNode.data_bind https://github.com/Textualize/textual/pull/4075
2126
- Added DOMNode.action_toggle https://github.com/Textualize/textual/pull/4075
2227
- Added Worker.cancelled_event https://github.com/Textualize/textual/pull/4075
28+
- `Tree` (and `DirectoryTree`) grew an attribute `lock` that can be used for synchronization across coroutines https://github.com/Textualize/textual/issues/4056
2329

2430
## [0.48.2] - 2024-02-02
2531

src/textual/widgets/_directory_tree.py

Lines changed: 124 additions & 52 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.
@@ -192,17 +193,7 @@ def clear_node(self, node: TreeNode[DirEntry]) -> Self:
192193
The `Tree` instance.
193194
"""
194195
self._clear_line_cache()
195-
node_label = node._label
196-
node_data = node.data
197-
node_parent = node.parent
198-
node = TreeNode(
199-
self,
200-
node_parent,
201-
self._new_id(),
202-
node_label,
203-
node_data,
204-
expanded=True,
205-
)
196+
node.remove_children()
206197
self._updates += 1
207198
self.refresh()
208199
return self
@@ -225,6 +216,86 @@ def reset_node(
225216
node.data = data
226217
return self
227218

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 reopening.allow_expand and (
260+
reopening.data.path in currently_open or reopening == node
261+
):
262+
try:
263+
content = await self._load_directory(reopening).wait()
264+
except (WorkerCancelled, WorkerFailed):
265+
continue
266+
reopening.data.loaded = True
267+
self._populate_node(reopening, content)
268+
to_reopen.extend(reopening.children)
269+
reopening.expand()
270+
271+
if highlighted_path is None:
272+
return
273+
274+
# Restore the highlighted path and consider the parents as fallbacks.
275+
looking = [node]
276+
highlight_candidates = set(highlighted_path.parents)
277+
highlight_candidates.add(highlighted_path)
278+
best_found: None | TreeNode[DirEntry] = None
279+
while looking:
280+
checking = looking.pop()
281+
checking_path = (
282+
checking.data.path if checking.data is not None else None
283+
)
284+
if checking_path in highlight_candidates:
285+
best_found = checking
286+
if checking_path == highlighted_path:
287+
break
288+
if (
289+
checking.allow_expand
290+
and checking.is_expanded
291+
and checking_path in highlighted_path.parents
292+
):
293+
looking.extend(checking.children)
294+
if best_found is not None:
295+
# We need valid lines. Make sure the tree lines have been computed:
296+
_ = self._tree_lines
297+
self.cursor_line = best_found.line
298+
228299
def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete:
229300
"""Reload the given node's contents.
230301
@@ -233,12 +304,12 @@ def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete:
233304
or any other nodes).
234305
235306
Args:
236-
node: The node to reload.
307+
node: The root of the subtree to reload.
308+
309+
Returns:
310+
An optionally awaitable that ensures the subtree has finished reloading.
237311
"""
238-
self.reset_node(
239-
node, str(node.data.path.name), DirEntry(self.PATH(node.data.path))
240-
)
241-
return self._add_to_load_queue(node)
312+
return AwaitComplete(self._reload(node))
242313

243314
def validate_path(self, path: str | Path) -> Path:
244315
"""Ensure that the path is of the `Path` type.
@@ -398,7 +469,7 @@ def _directory_content(self, location: Path, worker: Worker) -> Iterator[Path]:
398469
except PermissionError:
399470
pass
400471

401-
@work(thread=True)
472+
@work(thread=True, exit_on_error=False)
402473
def _load_directory(self, node: TreeNode[DirEntry]) -> list[Path]:
403474
"""Load the directory contents for a given node.
404475
@@ -425,28 +496,29 @@ async def _loader(self) -> None:
425496
# this blocks if the queue is empty.
426497
node = await self._load_queue.get()
427498
content: list[Path] = []
428-
try:
429-
# Spin up a short-lived thread that will load the content of
430-
# the directory associated with that node.
431-
content = await self._load_directory(node).wait()
432-
except WorkerCancelled:
433-
# The worker was cancelled, that would suggest we're all
434-
# done here and we should get out of the loader in general.
435-
break
436-
except WorkerFailed:
437-
# This particular worker failed to start. We don't know the
438-
# reason so let's no-op that (for now anyway).
439-
pass
440-
else:
441-
# We're still here and we have directory content, get it into
442-
# the tree.
443-
if content:
444-
self._populate_node(node, content)
445-
finally:
446-
# Mark this iteration as done.
447-
self._load_queue.task_done()
448-
449-
async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
499+
async with self.lock:
500+
try:
501+
# Spin up a short-lived thread that will load the content of
502+
# the directory associated with that node.
503+
content = await self._load_directory(node).wait()
504+
except WorkerCancelled:
505+
# The worker was cancelled, that would suggest we're all
506+
# done here and we should get out of the loader in general.
507+
break
508+
except WorkerFailed:
509+
# This particular worker failed to start. We don't know the
510+
# reason so let's no-op that (for now anyway).
511+
pass
512+
else:
513+
# We're still here and we have directory content, get it into
514+
# the tree.
515+
if content:
516+
self._populate_node(node, content)
517+
finally:
518+
# Mark this iteration as done.
519+
self._load_queue.task_done()
520+
521+
async def _on_tree_node_expanded(self, event: Tree.NodeExpanded[DirEntry]) -> None:
450522
event.stop()
451523
dir_entry = event.node.data
452524
if dir_entry is None:
@@ -456,7 +528,7 @@ async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
456528
else:
457529
self.post_message(self.FileSelected(event.node, dir_entry.path))
458530

459-
def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
531+
def _on_tree_node_selected(self, event: Tree.NodeSelected[DirEntry]) -> None:
460532
event.stop()
461533
dir_entry = event.node.data
462534
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)