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:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5535d3101..b9078861c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 ### Added
 - Added an interface for replacing prompt of an individual option in an `OptionList` https://github.com/Textualize/textual/issues/2603 
+- Added `DirectoryTree.reload_node` method https://github.com/Textualize/textual/issues/2757
 
 ## [0.32.0] - 2023-08-03
 
diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py
index 7957d9245..1855aa2d9 100644
--- a/src/textual/widgets/_directory_tree.py
+++ b/src/textual/widgets/_directory_tree.py
@@ -3,7 +3,10 @@ from __future__ import annotations
 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 @@ class DirectoryTree(Tree[DirEntry]):
         # 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
index f4c218cb9..0d7b495ed 100644
--- a/src/textual/widgets/_tree.py
+++ b/src/textual/widgets/_tree.py
@@ -692,14 +692,18 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
         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 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
 
     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
index 000000000..07ea048f7
--- /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)