Repository: Textualize/textual

Issue:
Add a method to `DirectoryTree` that allows for reloading an individual node
The question cropped up on Discord and seems like a useful thing to have: the ability to say "clear out and reload this specific node" when using `DirectoryTree` -- a developer may know that a specific directory has been modified and they want to refresh the content.


Discussion:
(This issue is reserved for the [EuroPython 2023 sprint](https://ep2023.europython.eu/sprints). If you are not participating in the sprint, please refrain from working on this issue. Thanks!)

The [`DirectoryTree` widget](https://textual.textualize.io/widgets/directory_tree/) loads its directory upon instantiation and only once, so it displays static data.

The widget should grow a method called something like`reload_node` and that method should do what @davep describes above.
It should clear whatever is under that node and it should load it again.

Good luck and have fun!
I'd like to work on this one. Thanks!

Resolving patch:
diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py
--- a/src/textual/widgets/_directory_tree.py
+++ b/src/textual/widgets/_directory_tree.py
@@ -3,7 +3,10 @@
 from asyncio import Queue
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Callable, ClassVar, Iterable, Iterator
+from typing import TYPE_CHECKING, Callable, ClassVar, Iterable, Iterator
+
+if TYPE_CHECKING:
+    from typing_extensions import Self
 
 from rich.style import Style
 from rich.text import Text, TextType
@@ -146,6 +149,56 @@ def reload(self) -> None:
         # loading up.
         self._add_to_load_queue(self.root)
 
+    def clear_node(self, node: TreeNode[DirEntry]) -> Self:
+        """Clear all nodes under the given node.
+
+        Returns:
+            The `Tree` instance.
+        """
+        self._clear_line_cache()
+        node_label = node._label
+        node_data = node.data
+        node_parent = node.parent
+        node = TreeNode(
+            self,
+            node_parent,
+            self._new_id(),
+            node_label,
+            node_data,
+            expanded=True,
+        )
+        self._updates += 1
+        self.refresh()
+        return self
+
+    def reset_node(
+        self, node: TreeNode[DirEntry], label: TextType, data: DirEntry | None = None
+    ) -> Self:
+        """Clear the subtree and reset the given node.
+
+        Args:
+            label: The label for the node.
+            data: Optional data for the node.
+
+        Returns:
+            The `Tree` instance.
+        """
+        self.clear_node(node)
+        node.label = label
+        node.data = data
+        return self
+
+    def reload_node(self, node: TreeNode[DirEntry]) -> None:
+        """Reload the given node's contents.
+
+        Args:
+            node: The node to reload.
+        """
+        self.reset_node(
+            node, str(node.data.path.name), DirEntry(self.PATH(node.data.path))
+        )
+        self._add_to_load_queue(node)
+
     def validate_path(self, path: str | Path) -> Path:
         """Ensure that the path is of the `Path` type.
 
diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py
--- a/src/textual/widgets/_tree.py
+++ b/src/textual/widgets/_tree.py
@@ -692,14 +692,18 @@ def get_label_width(self, node: TreeNode[TreeDataType]) -> int:
         label = self.render_label(node, NULL_STYLE, NULL_STYLE)
         return label.cell_len
 
+    def _clear_line_cache(self) -> None:
+        """Clear line cache."""
+        self._line_cache.clear()
+        self._tree_lines_cached = None
+
     def clear(self) -> Self:
         """Clear all nodes under root.
 
         Returns:
             The `Tree` instance.
         """
-        self._line_cache.clear()
-        self._tree_lines_cached = None
+        self._clear_line_cache()
         self._current_id = 0
         root_label = self.root._label
         root_data = self.root.data
@@ -798,8 +802,7 @@ def validate_guide_depth(self, value: int) -> int:
 
     def _invalidate(self) -> None:
         """Invalidate caches."""
-        self._line_cache.clear()
-        self._tree_lines_cached = None
+        self._clear_line_cache()
         self._updates += 1
         self.root._reset()
         self.refresh(layout=True)


Test patch:
diff --git a/tests/tree/test_directory_tree_reload_node.py b/tests/tree/test_directory_tree_reload_node.py
new file mode 100644
--- /dev/null
+++ b/tests/tree/test_directory_tree_reload_node.py
@@ -0,0 +1,110 @@
+from rich.text import Text
+
+from textual.app import App, ComposeResult
+from textual.widgets import DirectoryTree
+
+
+class DirectoryTreeReloadApp(App[None]):
+    """DirectoryTree reloading test app."""
+
+    def __init__(self, path):
+        super().__init__()
+        self._tmp_path = path
+
+    def compose(self) -> ComposeResult:
+        yield DirectoryTree(self._tmp_path)
+
+
+async def test_directory_tree_reload_node(tmp_path) -> None:
+    """Reloading a node of a directory tree should display newly created file inside the directory."""
+
+    RELOADED_DIRECTORY = "parentdir"
+    FILE1_NAME = "log.txt"
+    FILE2_NAME = "hello.txt"
+
+    # Creating node with one file as its child
+    reloaded_dir = tmp_path / RELOADED_DIRECTORY
+    reloaded_dir.mkdir()
+    file1 = reloaded_dir / FILE1_NAME
+    file1.touch()
+
+    async with DirectoryTreeReloadApp(tmp_path).run_test() as pilot:
+        tree = pilot.app.query_one(DirectoryTree)
+        await pilot.pause()
+
+        # Sanity check - node is the only child of root
+        assert len(tree.root.children) == 1
+        node = tree.root.children[0]
+        assert node.label == Text(RELOADED_DIRECTORY)
+        node.expand()
+        await pilot.pause()
+
+        # Creating new file under the node
+        file2 = reloaded_dir / FILE2_NAME
+        file2.touch()
+
+        # Without reloading the node, the newly created file does not show up as its child
+        assert len(node.children) == 1
+        assert node.children[0].label == Text(FILE1_NAME)
+
+        tree.reload_node(node)
+        node.collapse()
+        node.expand()
+        await pilot.pause()
+
+        # After reloading the node, both files show up as children
+        assert len(node.children) == 2
+        assert [child.label for child in node.children] == [
+            Text(filename) for filename in sorted({FILE1_NAME, FILE2_NAME})
+        ]
+
+
+async def test_directory_tree_reload_other_node(tmp_path) -> None:
+    """Reloading a node of a directory tree should not reload content of other directory."""
+
+    RELOADED_DIRECTORY = "parentdir"
+    NOT_RELOADED_DIRECTORY = "otherdir"
+    FILE1_NAME = "log.txt"
+    NOT_RELOADED_FILE3_NAME = "demo.txt"
+    NOT_RELOADED_FILE4_NAME = "unseen.txt"
+
+    # Creating two nodes, each having one file as child
+    reloaded_dir = tmp_path / RELOADED_DIRECTORY
+    reloaded_dir.mkdir()
+    file1 = reloaded_dir / FILE1_NAME
+    file1.touch()
+    non_reloaded_dir = tmp_path / NOT_RELOADED_DIRECTORY
+    non_reloaded_dir.mkdir()
+    file3 = non_reloaded_dir / NOT_RELOADED_FILE3_NAME
+    file3.touch()
+
+    async with DirectoryTreeReloadApp(tmp_path).run_test() as pilot:
+        tree = pilot.app.query_one(DirectoryTree)
+        await pilot.pause()
+
+        # Sanity check - the root has the two nodes as its children (in alphabetical order)
+        assert len(tree.root.children) == 2
+        unaffected_node = tree.root.children[0]
+        node = tree.root.children[1]
+        assert unaffected_node.label == Text(NOT_RELOADED_DIRECTORY)
+        assert node.label == Text(RELOADED_DIRECTORY)
+        unaffected_node.expand()
+        node.expand()
+        await pilot.pause()
+        assert len(unaffected_node.children) == 1
+        assert unaffected_node.children[0].label == Text(NOT_RELOADED_FILE3_NAME)
+
+        # Creating new file under the node that won't be reloaded
+        file4 = non_reloaded_dir / NOT_RELOADED_FILE4_NAME
+        file4.touch()
+
+        tree.reload_node(node)
+        node.collapse()
+        node.expand()
+        unaffected_node.collapse()
+        unaffected_node.expand()
+        await pilot.pause()
+
+        # After reloading one node, the new file under the other one does not show up
+        assert len(unaffected_node.children) == 1
+        assert unaffected_node.children[0].label == Text(NOT_RELOADED_FILE3_NAME)