55from pathlib import Path
66from 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-
138from rich .style import Style
149from rich .text import Text , TextType
1510
1611from .. import work
12+ from ..await_complete import AwaitComplete
1713from ..message import Message
1814from ..reactive import var
1915from ..worker import Worker , WorkerCancelled , WorkerFailed , get_current_worker
2016from ._tree import TOGGLE_STYLE , Tree , TreeNode
2117
18+ if TYPE_CHECKING :
19+ from typing_extensions import Self
20+
2221
2322@dataclass
2423class 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 :
0 commit comments