Repository: Textualize/textual

Issue:
DirectoryTree - message when directory was selected
It doesn't seem possible to hook up an action when someone selects a directory using a `DirectoryTree` widget - let's say I want to connect it to some `Input` and then update that input when something is selected in the `DirectoryTree` widget - e.g. user selects directory where to save some file (then append some default filename to that directory).

For a file it's fine because there is a `FileSelected` message posted, but no such message of `DirectorySelected` is available. Also watching `DirectoryTree.path` doesn't seem to solve the problem as this path is not updated when a directory is selected.




Discussion:
We found the following entry in the [FAQ](https://textual.textualize.io/FAQ/) which you may find helpful:


- [Does Textual support images?](https://textual.textualize.io/FAQ/#does-textual-support-images)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)

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
@@ -90,6 +90,31 @@ def control(self) -> Tree[DirEntry]:
             """The `Tree` that had a file selected."""
             return self.node.tree
 
+    class DirectorySelected(Message):
+        """Posted when a directory is selected.
+
+        Can be handled using `on_directory_tree_directory_selected` in a
+        subclass of `DirectoryTree` or in a parent widget in the DOM.
+        """
+
+        def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
+            """Initialise the DirectorySelected object.
+
+            Args:
+                node: The tree node for the directory that was selected.
+                path: The path of the directory that was selected.
+            """
+            super().__init__()
+            self.node: TreeNode[DirEntry] = node
+            """The tree node of the directory that was selected."""
+            self.path: Path = path
+            """The path of the directory that was selected."""
+
+        @property
+        def control(self) -> Tree[DirEntry]:
+            """The `Tree` that had a directory selected."""
+            return self.node.tree
+
     path: var[str | Path] = var["str | Path"](PATH("."), init=False, always_update=True)
     """The path that is the root of the directory tree.
 
@@ -414,5 +439,7 @@ def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
         dir_entry = event.node.data
         if dir_entry is None:
             return
-        if not self._safe_is_dir(dir_entry.path):
+        if self._safe_is_dir(dir_entry.path):
+            self.post_message(self.DirectorySelected(event.node, dir_entry.path))
+        else:
             self.post_message(self.FileSelected(event.node, dir_entry.path))


Test patch:
diff --git a/tests/tree/test_directory_tree.py b/tests/tree/test_directory_tree.py
new file mode 100644
--- /dev/null
+++ b/tests/tree/test_directory_tree.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+from rich.text import Text
+from textual import on
+from textual.app import App, ComposeResult
+from textual.widgets import DirectoryTree
+
+
+class DirectoryTreeApp(App[None]):
+    """DirectoryTree test app."""
+
+    def __init__(self, path):
+        super().__init__()
+        self._tmp_path = path
+        self.messages = []
+
+    def compose(self) -> ComposeResult:
+        yield DirectoryTree(self._tmp_path)
+
+    @on(DirectoryTree.FileSelected)
+    @on(DirectoryTree.DirectorySelected)
+    def record(
+        self, event: DirectoryTree.FileSelected | DirectoryTree.DirectorySelected
+    ) -> None:
+        self.messages.append(event.__class__.__name__)
+
+
+async def test_directory_tree_file_selected_message(tmp_path) -> None:
+    """Selecting a file should result in a file selected message being emitted."""
+
+    FILE_NAME = "hello.txt"
+
+    # Creating one file under root
+    file = tmp_path / FILE_NAME
+    file.touch()
+    async with DirectoryTreeApp(tmp_path).run_test() as pilot:
+        tree = pilot.app.query_one(DirectoryTree)
+        await pilot.pause()
+
+        # Sanity check - file is the only child of root
+        assert len(tree.root.children) == 1
+        node = tree.root.children[0]
+        assert node.label == Text(FILE_NAME)
+
+        # Navigate to the file and select it
+        await pilot.press("down", "enter")
+        await pilot.pause()
+        assert pilot.app.messages == ["FileSelected"]
+
+
+async def test_directory_tree_directory_selected_message(tmp_path) -> None:
+    """Selecting a directory should result in a directory selected message being emitted."""
+
+    SUBDIR = "subdir"
+    FILE_NAME = "hello.txt"
+
+    # Creating node with one file as its child
+    subdir = tmp_path / SUBDIR
+    subdir.mkdir()
+    file = subdir / FILE_NAME
+    file.touch()
+    async with DirectoryTreeApp(tmp_path).run_test() as pilot:
+        tree = pilot.app.query_one(DirectoryTree)
+        await pilot.pause()
+
+        # Sanity check - subdirectory is the only child of root
+        assert len(tree.root.children) == 1
+        node = tree.root.children[0]
+        assert node.label == Text(SUBDIR)
+
+        # Navigate to the subdirectory and select it
+        await pilot.press("down", "enter")
+        await pilot.pause()
+        assert pilot.app.messages == ["DirectorySelected"]
+
+        # Select the subdirectory again
+        await pilot.press("enter")
+        await pilot.pause()
+        assert pilot.app.messages == ["DirectorySelected", "DirectorySelected"]
+
+
+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 DirectoryTreeApp(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 DirectoryTreeApp(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)