Repository: Textualize/textual
Pull request: Allow modifying tabs in nested contexts
This fixes #3145 as far as not being able to manipulate tabs in nested contexts goes.

Problem statement:
TooManyMatches error with nested TabbedContent
I'm running 0.34 - was actually really looking forward to the ability to hide and show tabs programmatically, fortuitous timing for the thing I'm working on.

However, I've run into a number of issues that I think are all inter-related, potentially due to some nested elements on the DOM having the same id. Allow me to explain with the minor issues first before the showstopper that caused me to write this ticket.

First symptom is that I am unable to `query_one()` with the expected type of `TabPane` as it gets type of `ContentTab` and so throws a `WrongType` error. `ContentTab` is protected so can't/shouldn't directly import.
The second symptom is that removing the `expect_type=` param from `query_one` now begins to throw a `TooManyMatches` error. I initially got around this by using `query().first`. 
I concede that maybe `TabPanes` shouldn't be used directly? Maybe this needs clarification in the documentation if so. Reason for me attempting to use this was as a way to hide tabs prior to 0.34.

Anyway, switching to using `TabbedContent().hide_tab("tab-id")` worked in testing a simple scenario but fails when there is more than one tab AND there is a nested `TabbedContent`. There may be other conditions for this as well. As you can see from the example below, everything has a unique id.

### Example Code
```
from textual.app import App, ComposeResult
from textual.widgets import Label, TabbedContent, TabPane


class ExampleApp(App):
    CSS = """
    Screen {
        align: center middle;
    }
    #tabbed-content {
        height: 1fr;
    }
    #another-tabbed-content {
        height: 1fr;
    }
    """

    def compose(self) -> ComposeResult:
        with TabbedContent(id="tabbed-content"):
            with TabPane("Tab Pane 1", id="first-tab"):
                yield Label("foo")
            with TabPane("Tab Pane 2", id="second-tab"):
                yield Label("bar")
            with TabPane("Tab Pane 3", id="third-tab"):
                with TabbedContent(id="another-tabbed-content"):
                    with TabPane("Inner Pane 1", id="inner-tab-1"):
                        yield Label("fizz")
                    with TabPane("Inner Pane 2", id="inner-tab-2"):
                        yield Label("bang")

    def on_mount(self):
        # tab_pane = self.query_one("#first-tab", expect_type=TabPane)  # WrongType error: unexpected type of ContentTab
        # tab_pane = self.query_one("#first-tab")  # TooManyMatches error
        tabber = self.query_one("#tabbed-content", expect_type=TabbedContent)
        tabber.hide_tab("second-tab")  # TooManyMatches: Call to only_one resulted in more than one matched node


if __name__ == "__main__":
    app = ExampleApp()
    app.run()

```
### Error Trace
```
│ textual\widgets\_tabbed_content.py:431 in hide_tab                           │
│                                                                                                                                                          │
│   428 │   │   │   Tabs.TabError: If there are any issues with the request.                     ╭────────────────── locals ───────────────────╮           │
│   429 │   │   """                                                                              │   self = TabbedContent(id='tabbed-content') │           │
│   430 │   │                                                                                    │ tab_id = 'second-tab'                       │           │
│ ❱ 431 │   │   self.query_one(Tabs).hide(tab_id)                                                ╰─────────────────────────────────────────────╯           │
│   432 │                                                                                                                                                  │
│   433 │   def show_tab(self, tab_id: str) -> None:                                                                                                       │
│   434 │   │   """Shows the tab with the given ID.                                                                                                        │
```

Hints from issue discussion:
Good spot. The code that is enabling/disabling and showing/hiding the tabs is using `query_one` when it should be using `get_child_by_type` or `get_child_by_id`.
Hey @klott, sorry for the mess. I distinctly remember thinking about the need to handle nested tabs but looks like I didn't do it... Let me fix this for you. 🔧

Commit message: Changelog.

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b2517c9de..f30d0dc336 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## Unreleased
+
+### Fixed
+
+- Could not hide/show/disable/enable tabs in nested `TabbedContent` https://github.com/Textualize/textual/pull/3150
+
 ## [0.34.0] - 2023-08-22
 
 ### Added
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
index e3d8d20c28..7dc62a537e 100644
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -379,18 +379,22 @@ def tab_count(self) -> int:
     def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None:
         """Disable the corresponding tab pane."""
         event.stop()
-        tab_id = event.tab.id
+        tab_id = event.tab.id or ""
         try:
-            self.query_one(f"TabPane#{tab_id}").disabled = True
+            self.get_child_by_type(ContentSwitcher).get_child_by_id(
+                tab_id, expect_type=TabPane
+            ).disabled = True
         except NoMatches:
             return
 
     def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None:
         """Enable the corresponding tab pane."""
         event.stop()
-        tab_id = event.tab.id
+        tab_id = event.tab.id or ""
         try:
-            self.query_one(f"TabPane#{tab_id}").disabled = False
+            self.get_child_by_type(ContentSwitcher).get_child_by_id(
+                tab_id, expect_type=TabPane
+            ).disabled = False
         except NoMatches:
             return
 
@@ -404,7 +408,7 @@ def disable_tab(self, tab_id: str) -> None:
             Tabs.TabError: If there are any issues with the request.
         """
 
-        self.query_one(Tabs).disable(tab_id)
+        self.get_child_by_type(Tabs).disable(tab_id)
 
     def enable_tab(self, tab_id: str) -> None:
         """Enables the tab with the given ID.
@@ -416,7 +420,7 @@ def enable_tab(self, tab_id: str) -> None:
             Tabs.TabError: If there are any issues with the request.
         """
 
-        self.query_one(Tabs).enable(tab_id)
+        self.get_child_by_type(Tabs).enable(tab_id)
 
     def hide_tab(self, tab_id: str) -> None:
         """Hides the tab with the given ID.
@@ -428,7 +432,7 @@ def hide_tab(self, tab_id: str) -> None:
             Tabs.TabError: If there are any issues with the request.
         """
 
-        self.query_one(Tabs).hide(tab_id)
+        self.get_child_by_type(Tabs).hide(tab_id)
 
     def show_tab(self, tab_id: str) -> None:
         """Shows the tab with the given ID.
@@ -440,4 +444,4 @@ def show_tab(self, tab_id: str) -> None:
             Tabs.TabError: If there are any issues with the request.
         """
 
-        self.query_one(Tabs).show(tab_id)
+        self.get_child_by_type(Tabs).show(tab_id)
diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py
index 5eec2abcfb..adef61b9c3 100644
--- a/tests/test_tabbed_content.py
+++ b/tests/test_tabbed_content.py
@@ -696,3 +696,63 @@ def compose(self) -> ComposeResult:
         tabbed_content.show_tab(tab_id)
         await pilot.pause()
         assert tabbed_content.active == tab_id
+
+
+async def test_disabling_nested_tabs():
+    """Regression test for https://github.com/Textualize/textual/issues/3145."""
+
+    class TabbedApp(App):
+        def compose(self) -> ComposeResult:
+            with TabbedContent(id="tabbed-content"):
+                with TabPane("Tab Pane 1", id="first-tab"):
+                    yield Label("foo")
+                with TabPane("Tab Pane 2", id="second-tab"):
+                    yield Label("bar")
+                with TabPane("Tab Pane 3", id="third-tab"):
+                    with TabbedContent(id="another-tabbed-content"):
+                        with TabPane("Inner Pane 1", id="inner-tab-1"):
+                            yield Label("fizz")
+                        with TabPane("Inner Pane 2", id="inner-tab-2"):
+                            yield Label("bang")
+
+        def on_mount(self):
+            # tab_pane = self.query_one("#first-tab", expect_type=TabPane)  # WrongType error: unexpected type of ContentTab
+            # tab_pane = self.query_one("#first-tab")  # TooManyMatches error
+            tabber = self.query_one("#tabbed-content", expect_type=TabbedContent)
+            tabber.hide_tab(
+                "second-tab"
+            )  # TooManyMatches: Call to only_one resulted in more than one matched node
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        tabber = app.query_one("#tabbed-content", expect_type=TabbedContent)
+        tabber.disable_tab("second-tab")
+        await pilot.pause()
+        tabber.enable_tab("second-tab")
+        await pilot.pause()
+
+
+async def test_hiding_nested_tabs():
+    """Regression test for https://github.com/Textualize/textual/issues/3145."""
+
+    class TabbedApp(App):
+        def compose(self) -> ComposeResult:
+            with TabbedContent(id="tabbed-content"):
+                with TabPane("Tab Pane 1", id="first-tab"):
+                    yield Label("foo")
+                with TabPane("Tab Pane 2", id="second-tab"):
+                    yield Label("bar")
+                with TabPane("Tab Pane 3", id="third-tab"):
+                    with TabbedContent(id="another-tabbed-content"):
+                        with TabPane("Inner Pane 1", id="inner-tab-1"):
+                            yield Label("fizz")
+                        with TabPane("Inner Pane 2", id="inner-tab-2"):
+                            yield Label("bang")
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        tabber = app.query_one("#tabbed-content", expect_type=TabbedContent)
+        tabber.hide_tab("second-tab")
+        await pilot.pause()
+        tabber.show_tab("second-tab")
+        await pilot.pause()


Code review comments:
diff_hunk: @@ -696,3 +696,63 @@ def compose(self) -> ComposeResult:
         tabbed_content.show_tab(tab_id)
         await pilot.pause()
         assert tabbed_content.active == tab_id
+
+
+async def test_disabling_nested_tabs():
+    """Regression test for https://github.com/Textualize/textual/issues/3145."""
+
+    class TabbedApp(App):
+        def compose(self) -> ComposeResult:
+            with TabbedContent(id="tabbed-content"):
+                with TabPane("Tab Pane 1", id="first-tab"):
+                    yield Label("foo")
+                with TabPane("Tab Pane 2", id="second-tab"):
+                    yield Label("bar")
+                with TabPane("Tab Pane 3", id="third-tab"):
+                    with TabbedContent(id="another-tabbed-content"):
+                        with TabPane("Inner Pane 1", id="inner-tab-1"):
path: tests/test_tabbed_content.py
text: @user1:
Would it not make more sense to test where the ID is duplicated, so `first-tab` is used again for the nested first tab?

@author:
I took the regression test from the issue and rolled with it because it looked good to me.
What you're suggesting is that change the IDs of the inner tab panes to match the outer panes?

@user1:
Aye, that's it. A worst case is going to be the same ID being used within a `TabPane` that is within a `TabPane` of the same ID. This should then hit all the bases.

@author:
Thanks for the suggestion. Implemented in 22b63f6.