This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new e14854221ad Group and filter Dags by folder on the Dags page (#68544)
e14854221ad is described below

commit e14854221ad25391f883503c58de83775be736e4
Author: Rabah <[email protected]>
AuthorDate: Fri Oct 2 14:41:52 2026 +0200

    Group and filter Dags by folder on the Dags page (#68544)
    
    * UI: Group and filter Dags by folder on the Dags page
    
    Add a collapsible folder navigation tree to the Dags page, built from each 
Dag's
    relative_fileloc directory. Selecting a folder filters the list to the Dags 
it
    contains (and its subfolders) via a new server-side filter, so the result 
stays
    correct across pagination, sorting and other filters.
    
    Because relative_fileloc is relative to each bundle, folders are scoped to 
their
    bundle: in multi-bundle deployments the bundle is shown as the top-level 
entry
    with its folder tree nested underneath, so folders sharing the same path in
    different bundles stay separate. Single-bundle deployments keep the flat 
tree.
    
    Backend adds a relative_fileloc_prefix filter on GET /ui/dags and a new
    GET /ui/dags/folders endpoint returning the distinct (bundle, folder) pairs 
of
    all readable Dags. Dags at the bundle root contribute no folder and appear 
under
    'All Dags'. The folder sidebar is hidden for flat deployments with no 
folders.
    
    * Fix button style
    
    * Fix CI
    
    ---------
    
    Co-authored-by: pierrejeambrun <[email protected]>
---
 .../api_fastapi/common/parameters/__init__.py      |   1 +
 .../airflow/api_fastapi/common/parameters/dag.py   |  41 +++
 .../api_fastapi/core_api/datamodels/ui/dags.py     |  14 +
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  90 ++++++
 .../airflow/api_fastapi/core_api/routes/ui/dags.py |  51 ++++
 .../src/airflow/ui/openapi-gen/queries/common.ts   |   9 +-
 .../ui/openapi-gen/queries/ensureQueryData.ts      |  25 +-
 .../src/airflow/ui/openapi-gen/queries/prefetch.ts |  25 +-
 .../src/airflow/ui/openapi-gen/queries/queries.ts  |  25 +-
 .../src/airflow/ui/openapi-gen/queries/suspense.ts |  25 +-
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |  37 +++
 .../ui/openapi-gen/requests/services.gen.ts        |  29 +-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  32 ++
 .../airflow/ui/public/i18n/locales/en/dags.json    |   7 +
 .../src/airflow/ui/src/constants/localStorage.ts   |   1 +
 .../src/airflow/ui/src/constants/searchParams.ts   |   2 +
 .../DagsList/DagFolderTree/DagFolderTree.test.tsx  | 323 +++++++++++++++++++++
 .../pages/DagsList/DagFolderTree/DagFolderTree.tsx | 216 ++++++++++++++
 .../DagsList/DagFolderTree/buildFolderTree.test.ts | 119 ++++++++
 .../DagsList/DagFolderTree/buildFolderTree.ts      | 152 ++++++++++
 .../ui/src/pages/DagsList/DagFolderTree/index.ts   |  19 ++
 .../src/airflow/ui/src/pages/DagsList/DagsList.tsx | 188 ++++++++----
 .../src/airflow/ui/src/queries/useDagFolders.ts    |  35 +++
 .../src/airflow/ui/src/queries/useDags.tsx         |   6 +
 .../api_fastapi/core_api/routes/ui/test_dags.py    | 183 ++++++++++++
 25 files changed, 1587 insertions(+), 68 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters/__init__.py 
b/airflow-core/src/airflow/api_fastapi/common/parameters/__init__.py
index fa1a24866b0..d3dbbbeb11f 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters/__init__.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters/__init__.py
@@ -64,6 +64,7 @@ from airflow.api_fastapi.common.parameters.dag import (
     QueryHasImportErrorsFilter as QueryHasImportErrorsFilter,
     QueryOwnersFilter as QueryOwnersFilter,
     QueryPausedFilter as QueryPausedFilter,
+    QueryRelativeFilelocPrefixFilter as QueryRelativeFilelocPrefixFilter,
     QueryTagsFilter as QueryTagsFilter,
     QueryTeamsFilter as QueryTeamsFilter,
     QueryTimetableTypePrefixPatternSearch as 
QueryTimetableTypePrefixPatternSearch,
diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters/dag.py 
b/airflow-core/src/airflow/api_fastapi/common/parameters/dag.py
index e5c469e4eb4..fd7ecdd11a5 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters/dag.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters/dag.py
@@ -216,6 +216,43 @@ def teams_filter_factory(
     return depends_teams_filter
 
 
+class _RelativeFilelocPrefixFilter(BaseParam[str | None]):
+    """
+    Filter Dags by the folder they live in, derived from ``relative_fileloc``.
+
+    The value is treated as a directory path relative to the bundle root (e.g.
+    ``team_a/etl``). It matches every Dag whose file lives directly in that 
folder
+    or in any of its subfolders, using an escaped ``LIKE 'team_a/etl/%'`` so a
+    folder name is never a substring/prefix of another (``team_a`` won't match
+    ``team_alpha``). Dags at the bundle root (no ``/`` in ``relative_fileloc``)
+    are not matched by any folder value and appear only when no folder is 
selected.
+    """
+
+    def to_orm(self, select: Select) -> Select:
+        if self.value is None and self.skip_none:
+            return select
+
+        if not self.value:
+            return select
+
+        directory = self.value.rstrip("/")
+        escaped = _escape_like_pattern(directory)
+        return select.where(DagModel.relative_fileloc.like(f"{escaped}/%", 
escape=_LIKE_ESCAPE_CHAR))
+
+    @classmethod
+    def depends(
+        cls,
+        relative_fileloc_prefix: str | None = Query(
+            default=None,
+            description=(
+                "Filter Dags by the folder (directory of ``relative_fileloc``) 
they live in. "
+                "Matches the given folder and all of its subfolders."
+            ),
+        ),
+    ) -> _RelativeFilelocPrefixFilter:
+        return cls().set_value(relative_fileloc_prefix)
+
+
 class _DagIdTagsFilter(BaseParam[_TagFilterModel]):
     """Filter rows by Dag tags through their ``dag_id``."""
 
@@ -367,6 +404,10 @@ QueryOwnersFilter = Annotated[_OwnersFilter, 
Depends(_OwnersFilter.depends)]
 
 QueryTeamsFilter = Annotated[_TeamsFilter, Depends(_TeamsFilter.depends)]
 
+QueryRelativeFilelocPrefixFilter = Annotated[
+    _RelativeFilelocPrefixFilter, Depends(_RelativeFilelocPrefixFilter.depends)
+]
+
 
 # DagTags
 QueryDagTagPatternSearch = Annotated[
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
index c8236395d2c..8f13e9a2491 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
@@ -62,3 +62,17 @@ class DAGsRunStateCountsCollectionResponse(BaseModel):
 
     dags: list[DAGRunStateCountsResponse]
     state_count_limit: int
+
+
+class DagFolderResponse(BaseModel):
+    """A distinct Dag folder (directory of ``relative_fileloc``) within a 
bundle."""
+
+    bundle_name: str
+    folder: str
+
+
+class DagFolderCollectionResponse(BaseModel):
+    """Collection of distinct Dag folders, each scoped to the bundle it 
belongs to."""
+
+    folders: list[DagFolderResponse]
+    total_entries: int
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index e5e8a4db4a3..170393f4cde 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -731,6 +731,18 @@ paths:
           - type: string
           - type: 'null'
           title: Bundle Version
+      - name: relative_fileloc_prefix
+        in: query
+        required: false
+        schema:
+          anyOf:
+          - type: string
+          - type: 'null'
+          description: Filter Dags by the folder (directory of 
``relative_fileloc``)
+            they live in. Matches the given folder and all of its subfolders.
+          title: Relative Fileloc Prefix
+        description: Filter Dags by the folder (directory of 
``relative_fileloc``)
+          they live in. Matches the given folder and all of its subfolders.
       - name: order_by
         in: query
         required: false
@@ -856,6 +868,52 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/HTTPValidationError'
+  /ui/dags/folders:
+    get:
+      tags:
+      - DAG
+      summary: Get Dag Folders
+      description: 'Get the distinct folders the readable Dags live in, scoped 
to
+        their bundle.
+
+
+        A folder is the directory part of a Dag''s ``relative_fileloc`` 
(relative
+        to its
+
+        bundle root). Because ``relative_fileloc`` is relative to each bundle, 
the
+        same
+
+        path can exist in several bundles, so every folder is paired with its 
bundle
+
+        name to keep them apart. Dags located directly at the bundle root have 
no
+        folder
+
+        and are not represented here. The result powers the folder navigation 
tree
+        in
+
+        the UI, which reconstructs the hierarchy by splitting each path on 
``/`` and
+
+        groups it under its bundle when more than one bundle is present.
+
+
+        Stale Dags are left out to match the Dag list, which hides them by 
default:
+        keeping
+
+        them would surface folders (or whole bundles, once they stop being 
parsed)
+        that
+
+        select down to an empty list.'
+      operationId: get_dag_folders
+      responses:
+        '200':
+          description: Successful Response
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/DagFolderCollectionResponse'
+      security:
+      - OAuth2PasswordBearer: []
+      - HTTPBearer: []
   /ui/dags/{dag_id}/latest_run:
     get:
       tags:
@@ -3379,6 +3437,38 @@ components:
       - state_count_limit
       title: DAGsRunStateCountsCollectionResponse
       description: Collection of per-Dag DagRun-state counts for the Dag list 
page.
+    DagFolderCollectionResponse:
+      properties:
+        folders:
+          items:
+            $ref: '#/components/schemas/DagFolderResponse'
+          type: array
+          title: Folders
+        total_entries:
+          type: integer
+          title: Total Entries
+      type: object
+      required:
+      - folders
+      - total_entries
+      title: DagFolderCollectionResponse
+      description: Collection of distinct Dag folders, each scoped to the 
bundle it
+        belongs to.
+    DagFolderResponse:
+      properties:
+        bundle_name:
+          type: string
+          title: Bundle Name
+        folder:
+          type: string
+          title: Folder
+      type: object
+      required:
+      - bundle_name
+      - folder
+      title: DagFolderResponse
+      description: A distinct Dag folder (directory of ``relative_fileloc``) 
within
+        a bundle.
     DagRunState:
       type: string
       enum:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
index eb7dd48b279..55b1202cbc8 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 from collections.abc import Sequence
+from pathlib import PurePosixPath
 from typing import Annotated
 
 from fastapi import Depends, HTTPException, Query, status
@@ -52,6 +53,7 @@ from airflow.api_fastapi.common.parameters import (
     QueryOwnersFilter,
     QueryPausedFilter,
     QueryPendingActionsFilter,
+    QueryRelativeFilelocPrefixFilter,
     QueryTagsFilter,
     QueryTeamsFilter,
     QueryTimetableTypePrefixPatternSearch,
@@ -62,6 +64,8 @@ from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.core_api.datamodels.dags import DAG_ALIAS_MAPPING, 
DAGResponse
 from airflow.api_fastapi.core_api.datamodels.ui.dag_runs import 
DAGRunLightResponse
 from airflow.api_fastapi.core_api.datamodels.ui.dags import (
+    DagFolderCollectionResponse,
+    DagFolderResponse,
     DAGRunStateCountsResponse,
     DAGsRunStateCountsCollectionResponse,
     DagTimetableTypeCollectionResponse,
@@ -120,6 +124,7 @@ def get_dags(
     dag_run_state: QueryAnyDagRunStateFilter,
     bundle_name: QueryBundleNameFilter,
     bundle_version: QueryBundleVersionFilter,
+    relative_fileloc_prefix: QueryRelativeFilelocPrefixFilter,
     order_by: Annotated[
         SortParam,
         Depends(
@@ -182,6 +187,7 @@ def get_dags(
             readable_dags_filter,
             bundle_name,
             bundle_version,
+            relative_fileloc_prefix,
         ],
         order_by=order_by,
         offset=offset,
@@ -342,6 +348,51 @@ def get_dag_timetable_types(
     )
 
 
+@dags_router.get(
+    "/folders",
+    dependencies=[Depends(requires_access_dag(method="GET"))],
+    operation_id="get_dag_folders",
+)
+def get_dag_folders(
+    readable_dags_filter: ReadableDagsFilterDep,
+    session: SessionDep,
+) -> DagFolderCollectionResponse:
+    """
+    Get the distinct folders the readable Dags live in, scoped to their bundle.
+
+    A folder is the directory part of a Dag's ``relative_fileloc`` (relative 
to its
+    bundle root). Because ``relative_fileloc`` is relative to each bundle, the 
same
+    path can exist in several bundles, so every folder is paired with its 
bundle
+    name to keep them apart. Dags located directly at the bundle root have no 
folder
+    and are not represented here. The result powers the folder navigation tree 
in
+    the UI, which reconstructs the hierarchy by splitting each path on ``/`` 
and
+    groups it under its bundle when more than one bundle is present.
+
+    Stale Dags are left out to match the Dag list, which hides them by 
default: keeping
+    them would surface folders (or whole bundles, once they stop being parsed) 
that
+    select down to an empty list.
+    """
+    query = readable_dags_filter.to_orm(
+        select(DagModel.bundle_name, DagModel.relative_fileloc)
+        .where(DagModel.relative_fileloc.is_not(None), DagModel.is_stale == 
false())
+        .distinct()
+    )
+    folders: set[tuple[str, str]] = set()
+    for bundle_name, relative_fileloc in session.execute(query):
+        parent = PurePosixPath(relative_fileloc).parent
+        if str(parent) != ".":
+            folders.add((bundle_name, str(parent)))
+
+    sorted_folders = sorted(folders)
+    return DagFolderCollectionResponse(
+        folders=[
+            DagFolderResponse(bundle_name=bundle_name, folder=folder)
+            for bundle_name, folder in sorted_folders
+        ],
+        total_entries=len(sorted_folders),
+    )
+
+
 @dags_router.get(
     "/{dag_id}/latest_run",
     responses=create_openapi_http_exception_doc(
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index 60c05efbc48..4385e089895 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -410,7 +410,7 @@ export const UseDagServiceGetDagTagsKeyFn = ({ limit, 
offset, orderBy, tagNamePa
 export type DagServiceGetDagsUiDefaultResponse = Awaited<ReturnType<typeof 
DagService.getDagsUi>>;
 export type DagServiceGetDagsUiQueryResult<TData = 
DagServiceGetDagsUiDefaultResponse, TError = unknown> = UseQueryResult<TData, 
TError>;
 export const useDagServiceGetDagsUiKey = "DagServiceGetDagsUi";
-export const UseDagServiceGetDagsUiKeyFn = ({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, 
paused, schedulingState, tags, tagsMatchMode, teams, timetableType }: {
+export const UseDagServiceGetDagsUiKeyFn = ({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, 
paused, relativeFilelocPrefix, schedulingState, tags, tagsMatchMode, teams, 
timetableType }: {
   assetDependency?: string;
   bundleName?: string;
   bundleVersion?: string;
@@ -433,12 +433,13 @@ export const UseDagServiceGetDagsUiKeyFn = ({ 
assetDependency, bundleName, bundl
   orderBy?: string[];
   owners?: string[];
   paused?: boolean;
+  relativeFilelocPrefix?: string;
   schedulingState?: DagSchedulingState;
   tags?: string[];
   tagsMatchMode?: "any" | "all";
   teams?: string[];
   timetableType?: string[];
-} = {}, queryKey?: Array<unknown>) => [useDagServiceGetDagsUiKey, ...(queryKey 
?? [{ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, 
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, 
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale, 
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, schedulingState, tags, 
tagsMatchMode, teams, timetableType }])];
+} = {}, queryKey?: Array<unknown>) => [useDagServiceGetDagsUiKey, ...(queryKey 
?? [{ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, 
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, 
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale, 
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, 
schedulingState, tags, tagsMatchMode, teams, timetableType }])];
 export type DagServiceGetDagTimetableTypesUiDefaultResponse = 
Awaited<ReturnType<typeof DagService.getDagTimetableTypesUi>>;
 export type DagServiceGetDagTimetableTypesUiQueryResult<TData = 
DagServiceGetDagTimetableTypesUiDefaultResponse, TError = unknown> = 
UseQueryResult<TData, TError>;
 export const useDagServiceGetDagTimetableTypesUiKey = 
"DagServiceGetDagTimetableTypesUi";
@@ -447,6 +448,10 @@ export const UseDagServiceGetDagTimetableTypesUiKeyFn = ({ 
limit, offset, timeta
   offset?: number;
   timetableTypePrefixPattern?: string;
 } = {}, queryKey?: Array<unknown>) => [useDagServiceGetDagTimetableTypesUiKey, 
...(queryKey ?? [{ limit, offset, timetableTypePrefixPattern }])];
+export type DagServiceGetDagFoldersDefaultResponse = Awaited<ReturnType<typeof 
DagService.getDagFolders>>;
+export type DagServiceGetDagFoldersQueryResult<TData = 
DagServiceGetDagFoldersDefaultResponse, TError = unknown> = 
UseQueryResult<TData, TError>;
+export const useDagServiceGetDagFoldersKey = "DagServiceGetDagFolders";
+export const UseDagServiceGetDagFoldersKeyFn = (queryKey?: Array<unknown>) => 
[useDagServiceGetDagFoldersKey, ...(queryKey ?? [])];
 export type DagServiceGetLatestRunInfoDefaultResponse = 
Awaited<ReturnType<typeof DagService.getLatestRunInfo>>;
 export type DagServiceGetLatestRunInfoQueryResult<TData = 
DagServiceGetLatestRunInfoDefaultResponse, TError = unknown> = 
UseQueryResult<TData, TError>;
 export const useDagServiceGetLatestRunInfoKey = "DagServiceGetLatestRunInfo";
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index 25b66d11174..8ad3eccf42a 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -828,6 +828,7 @@ export const ensureUseDagServiceGetDagTagsData = 
(queryClient: QueryClient, { li
 * @param data.dagRunStateWithinHours Only match DagRuns whose run_after falls 
within the last given hours. Ignored unless dag_run_state is set.
 * @param data.bundleName
 * @param data.bundleVersion
+* @param data.relativeFilelocPrefix Filter Dags by the folder (directory of 
``relative_fileloc``) they live in. Matches the given folder and all of its 
subfolders.
 * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `dag_id, 
dag_display_name, next_dagrun, state, start_date, last_run_state, 
last_run_start_date, last_run_run_after`
 * @param data.isFavorite
 * @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -837,7 +838,7 @@ export const ensureUseDagServiceGetDagTagsData = 
(queryClient: QueryClient, { li
 * @returns DAGWithLatestDagRunsCollectionResponse Successful Response
 * @throws ApiError
 */
-export const ensureUseDagServiceGetDagsUiData = (queryClient: QueryClient, { 
assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, 
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, 
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale, 
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, schedulingState, tags, 
tagsMatchMode, teams, timetableType }: {
+export const ensureUseDagServiceGetDagsUiData = (queryClient: QueryClient, { 
assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, 
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, 
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale, 
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, 
schedulingState, tags, tagsMatchMode, teams, timetableType }: {
   assetDependency?: string;
   bundleName?: string;
   bundleVersion?: string;
@@ -860,12 +861,13 @@ export const ensureUseDagServiceGetDagsUiData = 
(queryClient: QueryClient, { ass
   orderBy?: string[];
   owners?: string[];
   paused?: boolean;
+  relativeFilelocPrefix?: string;
   schedulingState?: DagSchedulingState;
   tags?: string[];
   tagsMatchMode?: "any" | "all";
   teams?: string[];
   timetableType?: string[];
-} = {}) => queryClient.ensureQueryData({ queryKey: 
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, 
paused, schedulingState, tags, tagsMatchMode, teams, timetableType }), queryFn: 
() =>  [...]
+} = {}) => queryClient.ensureQueryData({ queryKey: 
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, 
paused, relativeFilelocPrefix, schedulingState, tags, tagsMatchMode, teams, 
timetableT [...]
 /**
 * Get Dag Timetable Types
 * Get timetable types used by readable Dags.
@@ -882,6 +884,25 @@ export const ensureUseDagServiceGetDagTimetableTypesUiData 
= (queryClient: Query
   timetableTypePrefixPattern?: string;
 } = {}) => queryClient.ensureQueryData({ queryKey: 
Common.UseDagServiceGetDagTimetableTypesUiKeyFn({ limit, offset, 
timetableTypePrefixPattern }), queryFn: () => 
DagService.getDagTimetableTypesUi({ limit, offset, timetableTypePrefixPattern 
}) });
 /**
+* Get Dag Folders
+* Get the distinct folders the readable Dags live in, scoped to their bundle.
+*
+* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to 
its
+* bundle root). Because ``relative_fileloc`` is relative to each bundle, the 
same
+* path can exist in several bundles, so every folder is paired with its bundle
+* name to keep them apart. Dags located directly at the bundle root have no 
folder
+* and are not represented here. The result powers the folder navigation tree in
+* the UI, which reconstructs the hierarchy by splitting each path on ``/`` and
+* groups it under its bundle when more than one bundle is present.
+*
+* Stale Dags are left out to match the Dag list, which hides them by default: 
keeping
+* them would surface folders (or whole bundles, once they stop being parsed) 
that
+* select down to an empty list.
+* @returns DagFolderCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const ensureUseDagServiceGetDagFoldersData = (queryClient: QueryClient) 
=> queryClient.ensureQueryData({ queryKey: 
Common.UseDagServiceGetDagFoldersKeyFn(), queryFn: () => 
DagService.getDagFolders() });
+/**
 * Get Latest Run Info
 * Get latest run.
 * @param data The data for the request.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index 0f8b2db2660..65c7f1cde56 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -828,6 +828,7 @@ export const prefetchUseDagServiceGetDagTags = 
(queryClient: QueryClient, { limi
 * @param data.dagRunStateWithinHours Only match DagRuns whose run_after falls 
within the last given hours. Ignored unless dag_run_state is set.
 * @param data.bundleName
 * @param data.bundleVersion
+* @param data.relativeFilelocPrefix Filter Dags by the folder (directory of 
``relative_fileloc``) they live in. Matches the given folder and all of its 
subfolders.
 * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `dag_id, 
dag_display_name, next_dagrun, state, start_date, last_run_state, 
last_run_start_date, last_run_run_after`
 * @param data.isFavorite
 * @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -837,7 +838,7 @@ export const prefetchUseDagServiceGetDagTags = 
(queryClient: QueryClient, { limi
 * @returns DAGWithLatestDagRunsCollectionResponse Successful Response
 * @throws ApiError
 */
-export const prefetchUseDagServiceGetDagsUi = (queryClient: QueryClient, { 
assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, 
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, 
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale, 
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, schedulingState, tags, 
tagsMatchMode, teams, timetableType }: {
+export const prefetchUseDagServiceGetDagsUi = (queryClient: QueryClient, { 
assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, 
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, 
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale, 
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, 
schedulingState, tags, tagsMatchMode, teams, timetableType }: {
   assetDependency?: string;
   bundleName?: string;
   bundleVersion?: string;
@@ -860,12 +861,13 @@ export const prefetchUseDagServiceGetDagsUi = 
(queryClient: QueryClient, { asset
   orderBy?: string[];
   owners?: string[];
   paused?: boolean;
+  relativeFilelocPrefix?: string;
   schedulingState?: DagSchedulingState;
   tags?: string[];
   tagsMatchMode?: "any" | "all";
   teams?: string[];
   timetableType?: string[];
-} = {}) => queryClient.prefetchQuery({ queryKey: 
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, 
paused, schedulingState, tags, tagsMatchMode, teams, timetableType }), queryFn: 
() => Da [...]
+} = {}) => queryClient.prefetchQuery({ queryKey: 
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, 
paused, relativeFilelocPrefix, schedulingState, tags, tagsMatchMode, teams, 
timetableTyp [...]
 /**
 * Get Dag Timetable Types
 * Get timetable types used by readable Dags.
@@ -882,6 +884,25 @@ export const prefetchUseDagServiceGetDagTimetableTypesUi = 
(queryClient: QueryCl
   timetableTypePrefixPattern?: string;
 } = {}) => queryClient.prefetchQuery({ queryKey: 
Common.UseDagServiceGetDagTimetableTypesUiKeyFn({ limit, offset, 
timetableTypePrefixPattern }), queryFn: () => 
DagService.getDagTimetableTypesUi({ limit, offset, timetableTypePrefixPattern 
}) });
 /**
+* Get Dag Folders
+* Get the distinct folders the readable Dags live in, scoped to their bundle.
+*
+* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to 
its
+* bundle root). Because ``relative_fileloc`` is relative to each bundle, the 
same
+* path can exist in several bundles, so every folder is paired with its bundle
+* name to keep them apart. Dags located directly at the bundle root have no 
folder
+* and are not represented here. The result powers the folder navigation tree in
+* the UI, which reconstructs the hierarchy by splitting each path on ``/`` and
+* groups it under its bundle when more than one bundle is present.
+*
+* Stale Dags are left out to match the Dag list, which hides them by default: 
keeping
+* them would surface folders (or whole bundles, once they stop being parsed) 
that
+* select down to an empty list.
+* @returns DagFolderCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const prefetchUseDagServiceGetDagFolders = (queryClient: QueryClient) 
=> queryClient.prefetchQuery({ queryKey: 
Common.UseDagServiceGetDagFoldersKeyFn(), queryFn: () => 
DagService.getDagFolders() });
+/**
 * Get Latest Run Info
 * Get latest run.
 * @param data The data for the request.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index d50d14d7c1b..d5dc9b17b5f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -828,6 +828,7 @@ export const useDagServiceGetDagTags = <TData = 
Common.DagServiceGetDagTagsDefau
 * @param data.dagRunStateWithinHours Only match DagRuns whose run_after falls 
within the last given hours. Ignored unless dag_run_state is set.
 * @param data.bundleName
 * @param data.bundleVersion
+* @param data.relativeFilelocPrefix Filter Dags by the folder (directory of 
``relative_fileloc``) they live in. Matches the given folder and all of its 
subfolders.
 * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `dag_id, 
dag_display_name, next_dagrun, state, start_date, last_run_state, 
last_run_start_date, last_run_run_after`
 * @param data.isFavorite
 * @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -837,7 +838,7 @@ export const useDagServiceGetDagTags = <TData = 
Common.DagServiceGetDagTagsDefau
 * @returns DAGWithLatestDagRunsCollectionResponse Successful Response
 * @throws ApiError
 */
-export const useDagServiceGetDagsUi = <TData = 
Common.DagServiceGetDagsUiDefaultResponse, TError = unknown, TQueryKey extends 
Array<unknown> = unknown[]>({ assetDependency, bundleName, bundleVersion, 
dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, 
dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, dagRunStateWithinHours, 
excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, scheduling [...]
+export const useDagServiceGetDagsUi = <TData = 
Common.DagServiceGetDagsUiDefaultResponse, TError = unknown, TQueryKey extends 
Array<unknown> = unknown[]>({ assetDependency, bundleName, bundleVersion, 
dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, 
dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, dagRunStateWithinHours, 
excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, relativeFi [...]
   assetDependency?: string;
   bundleName?: string;
   bundleVersion?: string;
@@ -860,12 +861,13 @@ export const useDagServiceGetDagsUi = <TData = 
Common.DagServiceGetDagsUiDefault
   orderBy?: string[];
   owners?: string[];
   paused?: boolean;
+  relativeFilelocPrefix?: string;
   schedulingState?: DagSchedulingState;
   tags?: string[];
   tagsMatchMode?: "any" | "all";
   teams?: string[];
   timetableType?: string[];
-} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owne 
[...]
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owne 
[...]
 /**
 * Get Dag Timetable Types
 * Get timetable types used by readable Dags.
@@ -882,6 +884,25 @@ export const useDagServiceGetDagTimetableTypesUi = <TData 
= Common.DagServiceGet
   timetableTypePrefixPattern?: string;
 } = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagTimetableTypesUiKeyFn({ limit, offset, 
timetableTypePrefixPattern }, queryKey), queryFn: () => 
DagService.getDagTimetableTypesUi({ limit, offset, timetableTypePrefixPattern 
}) as TData, ...options });
 /**
+* Get Dag Folders
+* Get the distinct folders the readable Dags live in, scoped to their bundle.
+*
+* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to 
its
+* bundle root). Because ``relative_fileloc`` is relative to each bundle, the 
same
+* path can exist in several bundles, so every folder is paired with its bundle
+* name to keep them apart. Dags located directly at the bundle root have no 
folder
+* and are not represented here. The result powers the folder navigation tree in
+* the UI, which reconstructs the hierarchy by splitting each path on ``/`` and
+* groups it under its bundle when more than one bundle is present.
+*
+* Stale Dags are left out to match the Dag list, which hides them by default: 
keeping
+* them would surface folders (or whole bundles, once they stop being parsed) 
that
+* select down to an empty list.
+* @returns DagFolderCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const useDagServiceGetDagFolders = <TData = 
Common.DagServiceGetDagFoldersDefaultResponse, TError = unknown, TQueryKey 
extends Array<unknown> = unknown[]>(queryKey?: TQueryKey, options?: 
Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) => 
useQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagFoldersKeyFn(queryKey), queryFn: () => 
DagService.getDagFolders() as TData, ...options });
+/**
 * Get Latest Run Info
 * Get latest run.
 * @param data The data for the request.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index b0979f8e663..24b2ae2a7e0 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -828,6 +828,7 @@ export const useDagServiceGetDagTagsSuspense = <TData = 
Common.DagServiceGetDagT
 * @param data.dagRunStateWithinHours Only match DagRuns whose run_after falls 
within the last given hours. Ignored unless dag_run_state is set.
 * @param data.bundleName
 * @param data.bundleVersion
+* @param data.relativeFilelocPrefix Filter Dags by the folder (directory of 
``relative_fileloc``) they live in. Matches the given folder and all of its 
subfolders.
 * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `dag_id, 
dag_display_name, next_dagrun, state, start_date, last_run_state, 
last_run_start_date, last_run_run_after`
 * @param data.isFavorite
 * @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -837,7 +838,7 @@ export const useDagServiceGetDagTagsSuspense = <TData = 
Common.DagServiceGetDagT
 * @returns DAGWithLatestDagRunsCollectionResponse Successful Response
 * @throws ApiError
 */
-export const useDagServiceGetDagsUiSuspense = <TData = 
Common.DagServiceGetDagsUiDefaultResponse, TError = unknown, TQueryKey extends 
Array<unknown> = unknown[]>({ assetDependency, bundleName, bundleVersion, 
dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, 
dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, dagRunStateWithinHours, 
excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, sc [...]
+export const useDagServiceGetDagsUiSuspense = <TData = 
Common.DagServiceGetDagsUiDefaultResponse, TError = unknown, TQueryKey extends 
Array<unknown> = unknown[]>({ assetDependency, bundleName, bundleVersion, 
dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, 
dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, dagRunStateWithinHours, 
excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, 
lastDagRunState, limit, offset, orderBy, owners, paused, re [...]
   assetDependency?: string;
   bundleName?: string;
   bundleVersion?: string;
@@ -860,12 +861,13 @@ export const useDagServiceGetDagsUiSuspense = <TData = 
Common.DagServiceGetDagsU
   orderBy?: string[];
   owners?: string[];
   paused?: boolean;
+  relativeFilelocPrefix?: string;
   schedulingState?: DagSchedulingState;
   tags?: string[];
   tagsMatchMode?: "any" | "all";
   teams?: string[];
   timetableType?: string[];
-} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, order [...]
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, 
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, 
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, 
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors, 
hasPendingActions, isFavorite, lastDagRunState, limit, offset, order [...]
 /**
 * Get Dag Timetable Types
 * Get timetable types used by readable Dags.
@@ -882,6 +884,25 @@ export const useDagServiceGetDagTimetableTypesUiSuspense = 
<TData = Common.DagSe
   timetableTypePrefixPattern?: string;
 } = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagTimetableTypesUiKeyFn({ limit, offset, 
timetableTypePrefixPattern }, queryKey), queryFn: () => 
DagService.getDagTimetableTypesUi({ limit, offset, timetableTypePrefixPattern 
}) as TData, ...options });
 /**
+* Get Dag Folders
+* Get the distinct folders the readable Dags live in, scoped to their bundle.
+*
+* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to 
its
+* bundle root). Because ``relative_fileloc`` is relative to each bundle, the 
same
+* path can exist in several bundles, so every folder is paired with its bundle
+* name to keep them apart. Dags located directly at the bundle root have no 
folder
+* and are not represented here. The result powers the folder navigation tree in
+* the UI, which reconstructs the hierarchy by splitting each path on ``/`` and
+* groups it under its bundle when more than one bundle is present.
+*
+* Stale Dags are left out to match the Dag list, which hides them by default: 
keeping
+* them would surface folders (or whole bundles, once they stop being parsed) 
that
+* select down to an empty list.
+* @returns DagFolderCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const useDagServiceGetDagFoldersSuspense = <TData = 
Common.DagServiceGetDagFoldersDefaultResponse, TError = unknown, TQueryKey 
extends Array<unknown> = unknown[]>(queryKey?: TQueryKey, options?: 
Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) => 
useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagFoldersKeyFn(queryKey), queryFn: () => 
DagService.getDagFolders() as TData, ...options });
+/**
 * Get Latest Run Info
 * Get latest run.
 * @param data The data for the request.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index d5943c54077..4df2dabe060 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -10553,6 +10553,43 @@ export const $DAGsRunStateCountsCollectionResponse = {
     description: 'Collection of per-Dag DagRun-state counts for the Dag list 
page.'
 } as const;
 
+export const $DagFolderCollectionResponse = {
+    properties: {
+        folders: {
+            items: {
+                '$ref': '#/components/schemas/DagFolderResponse'
+            },
+            type: 'array',
+            title: 'Folders'
+        },
+        total_entries: {
+            type: 'integer',
+            title: 'Total Entries'
+        }
+    },
+    type: 'object',
+    required: ['folders', 'total_entries'],
+    title: 'DagFolderCollectionResponse',
+    description: 'Collection of distinct Dag folders, each scoped to the 
bundle it belongs to.'
+} as const;
+
+export const $DagFolderResponse = {
+    properties: {
+        bundle_name: {
+            type: 'string',
+            title: 'Bundle Name'
+        },
+        folder: {
+            type: 'string',
+            title: 'Folder'
+        }
+    },
+    type: 'object',
+    required: ['bundle_name', 'folder'],
+    title: 'DagFolderResponse',
+    description: 'A distinct Dag folder (directory of ``relative_fileloc``) 
within a bundle.'
+} as const;
+
 export const $DagRunStatsResponse = {
     properties: {
         duration: {
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index 37f6155ee3c..97ead7a6f40 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -3,7 +3,7 @@
 import type { CancelablePromise } from './core/CancelablePromise';
 import { OpenAPI } from './core/OpenAPI';
 import { request as __request } from './core/request';
-import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, 
GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, 
GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, 
CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, 
GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, 
DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, 
GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, 
Dele [...]
+import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, 
GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, 
GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, 
CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, 
GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, 
DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, 
GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, 
Dele [...]
 
 export class AssetService {
     /**
@@ -2183,6 +2183,7 @@ export class DagService {
      * @param data.dagRunStateWithinHours Only match DagRuns whose run_after 
falls within the last given hours. Ignored unless dag_run_state is set.
      * @param data.bundleName
      * @param data.bundleVersion
+     * @param data.relativeFilelocPrefix Filter Dags by the folder (directory 
of ``relative_fileloc``) they live in. Matches the given folder and all of its 
subfolders.
      * @param data.orderBy Attributes to order by, multi criteria sort is 
supported. Prefix with `-` for descending order. Supported attributes: `dag_id, 
dag_display_name, next_dagrun, state, start_date, last_run_state, 
last_run_start_date, last_run_run_after`
      * @param data.isFavorite
      * @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -2218,6 +2219,7 @@ export class DagService {
                 dag_run_state_within_hours: data.dagRunStateWithinHours,
                 bundle_name: data.bundleName,
                 bundle_version: data.bundleVersion,
+                relative_fileloc_prefix: data.relativeFilelocPrefix,
                 order_by: data.orderBy,
                 is_favorite: data.isFavorite,
                 has_asset_schedule: data.hasAssetSchedule,
@@ -2256,6 +2258,31 @@ export class DagService {
         });
     }
     
+    /**
+     * Get Dag Folders
+     * Get the distinct folders the readable Dags live in, scoped to their 
bundle.
+     *
+     * A folder is the directory part of a Dag's ``relative_fileloc`` 
(relative to its
+     * bundle root). Because ``relative_fileloc`` is relative to each bundle, 
the same
+     * path can exist in several bundles, so every folder is paired with its 
bundle
+     * name to keep them apart. Dags located directly at the bundle root have 
no folder
+     * and are not represented here. The result powers the folder navigation 
tree in
+     * the UI, which reconstructs the hierarchy by splitting each path on 
``/`` and
+     * groups it under its bundle when more than one bundle is present.
+     *
+     * Stale Dags are left out to match the Dag list, which hides them by 
default: keeping
+     * them would surface folders (or whole bundles, once they stop being 
parsed) that
+     * select down to an empty list.
+     * @returns DagFolderCollectionResponse Successful Response
+     * @throws ApiError
+     */
+    public static getDagFolders(): CancelablePromise<GetDagFoldersResponse> {
+        return __request(OpenAPI, {
+            method: 'GET',
+            url: '/ui/dags/folders'
+        });
+    }
+    
     /**
      * Get Latest Run Info
      * Get latest run.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index b1250bdbc04..5a5d86c4b58 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -2677,6 +2677,22 @@ export type DAGsRunStateCountsCollectionResponse = {
     state_count_limit: number;
 };
 
+/**
+ * Collection of distinct Dag folders, each scoped to the bundle it belongs to.
+ */
+export type DagFolderCollectionResponse = {
+    folders: Array<DagFolderResponse>;
+    total_entries: number;
+};
+
+/**
+ * A distinct Dag folder (directory of ``relative_fileloc``) within a bundle.
+ */
+export type DagFolderResponse = {
+    bundle_name: string;
+    folder: string;
+};
+
 /**
  * DAG Run statistics serializer for responses.
  */
@@ -3944,6 +3960,10 @@ export type GetDagsUiData = {
     orderBy?: Array<(string)>;
     owners?: Array<(string)>;
     paused?: boolean | null;
+    /**
+     * Filter Dags by the folder (directory of ``relative_fileloc``) they live 
in. Matches the given folder and all of its subfolders.
+     */
+    relativeFilelocPrefix?: string | null;
     schedulingState?: DagSchedulingState | null;
     tags?: Array<(string)>;
     tagsMatchMode?: 'any' | 'all' | null;
@@ -3964,6 +3984,8 @@ export type GetDagTimetableTypesUiData = {
 
 export type GetDagTimetableTypesUiResponse = 
DagTimetableTypeCollectionResponse;
 
+export type GetDagFoldersResponse = DagFolderCollectionResponse;
+
 export type GetLatestRunInfoData = {
     dagId: string;
 };
@@ -6992,6 +7014,16 @@ export type $OpenApiTs = {
             };
         };
     };
+    '/ui/dags/folders': {
+        get: {
+            res: {
+                /**
+                 * Successful Response
+                 */
+                200: DagFolderCollectionResponse;
+            };
+        };
+    };
     '/ui/dags/{dag_id}/latest_run': {
         get: {
             req: GetLatestRunInfoData;
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
index 595ce204e69..f7d5e39d3d1 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
@@ -31,6 +31,13 @@
     "runStatePlaceholder": "Select run state…",
     "timetableType": "Timetable type"
   },
+  "folders": {
+    "all": "All Dags",
+    "empty": "No folders",
+    "hide": "Hide folders",
+    "show": "Show folders",
+    "title": "Folders"
+  },
   "ownerLink": "Owner link for {{owner}}",
   "runAndTaskActions": {
     "affectedTasks": {
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts 
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 43b814aa0d3..0b0ec8f4e92 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -21,6 +21,7 @@
 export const TIMEZONE_KEY = "timezone";
 export const DEFAULT_DAG_VIEW_KEY = "default_dag_view";
 export const DAGS_LIST_DISPLAY_KEY = "dags_list_display";
+export const DAGS_LIST_SHOW_FOLDERS_KEY = "dags_list_show_folders";
 export const CALENDAR_GRANULARITY_KEY = "calendar-granularity";
 export const CALENDAR_VIEW_MODE_KEY = "calendar-view-mode";
 export const LOG_WRAP_KEY = "log_wrap";
diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts 
b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
index 195a8eccc1c..74453e5a44e 100644
--- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
+++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
@@ -31,7 +31,9 @@ export enum SearchParamsKeys {
   CREATED_AT_LTE = "created_at_lte",
   CREATED_AT_RANGE = "created_at_range",
   CURSOR = "cursor",
+  DAG_BUNDLE = "dag_bundle",
   DAG_DISPLAY_NAME_PATTERN = "dag_display_name_pattern",
+  DAG_FOLDER = "dag_folder",
   DAG_ID = "dag_id",
   DAG_ID_PATTERN = "dag_id_pattern",
   DAG_RUN_STATE = "dag_run_state",
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.test.tsx
new file mode 100644
index 00000000000..c85b83d5d27
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.test.tsx
@@ -0,0 +1,323 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { DagFolderResponse } from "openapi/requests/types.gen";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { DagFolderTree } from "./DagFolderTree";
+
+const SINGLE_BUNDLE: Array<DagFolderResponse> = [
+  { bundle_name: "dags-folder", folder: "team_a/etl" },
+  { bundle_name: "dags-folder", folder: "team_a/report" },
+  { bundle_name: "dags-folder", folder: "team_b/ml" },
+];
+
+// Both bundles reuse the ``team_a/etl`` path to check they stay separate.
+const MULTI_BUNDLE: Array<DagFolderResponse> = [
+  { bundle_name: "analytics", folder: "team_a/etl" },
+  { bundle_name: "ml", folder: "team_a/etl" },
+  { bundle_name: "ml", folder: "features" },
+];
+
+// Tree nodes are identified by their own label, ignoring the labels of nested 
children.
+// ``hidden`` is needed because collapsed branches are hidden from the 
accessibility tree.
+const nodes = () => screen.getAllByRole("treeitem", { hidden: true });
+
+const node = (label: string) =>
+  nodes().find(
+    (item) => item.querySelector('[data-part="branch-text"], 
[data-part="item-text"]')?.textContent === label,
+  );
+
+describe("DagFolderTree (single bundle)", () => {
+  it("renders the top-level folders and an 'All Dags' entry, without a bundle 
level", () => {
+    render(
+      <DagFolderTree
+        folders={SINGLE_BUNDLE}
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder={undefined}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(screen.getByRole("button", { name: "folders.all" 
})).toBeInTheDocument();
+    expect(node("team_a")).toBeDefined();
+    expect(node("team_b")).toBeDefined();
+    expect(screen.queryByText("dags-folder")).not.toBeInTheDocument();
+  });
+
+  it("renders an accessible tree instead of click handlers on plain elements", 
() => {
+    render(
+      <DagFolderTree
+        folders={SINGLE_BUNDLE}
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder={undefined}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(screen.getByRole("tree")).toBeInTheDocument();
+    expect(nodes().length).toBeGreaterThan(0);
+    // Expandable nodes expose their state, and the tree is keyboard reachable.
+    expect(node("team_a")).toHaveAttribute("aria-expanded", "false");
+    expect(node("team_a")).toHaveAttribute("aria-level", "1");
+    expect(screen.getAllByRole("button").length).toBeGreaterThan(0);
+  });
+
+  it("reserves the expand indicator width on leaves so siblings line up", () 
=> {
+    render(
+      <DagFolderTree
+        folders={[
+          { bundle_name: "dags-folder", folder: "reports" },
+          { bundle_name: "dags-folder", folder: "team_a/etl" },
+        ]}
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder={undefined}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    // ``reports`` has no children, so it renders a hidden placeholder where 
``team_a`` shows
+    // its expand indicator; without it the two siblings would not be indented 
the same way.
+    const leaf = node("reports");
+
+    expect(leaf?.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
+    
expect(node("team_a")?.querySelector('[data-part="branch-indicator"]')).toBeInTheDocument();
+  });
+
+  it("truncates long names so the selected row highlight is not cut off", () 
=> {
+    const longName = "a-very-long-folder-name-that-does-not-fit-in-the-panel";
+
+    render(
+      <DagFolderTree
+        folders={[{ bundle_name: "dags-folder", folder: longName }]}
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder={longName}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    // The row spans the panel and the label truncates, so the highlight 
covers the whole row.
+    // The full name stays reachable through the native tooltip.
+    const label = screen.getByTitle(longName);
+
+    expect(label).toHaveTextContent(longName);
+    expect(node(longName)).toHaveAttribute("aria-selected", "true");
+  });
+
+  it("keeps sub-folders hidden until their parent is expanded", () => {
+    render(
+      <DagFolderTree
+        folders={SINGLE_BUNDLE}
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder={undefined}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(screen.getByText("etl")).not.toBeVisible();
+  });
+
+  it("expands down to the selected folder once folders finish loading", () => {
+    // Folders are fetched asynchronously, so the first render has nothing to 
expand. Landing on a
+    // URL that already points at a nested folder must still open the tree 
once they arrive.
+    const props = {
+      onSelectFolder: vi.fn(),
+      selectedBundle: undefined,
+      selectedFolder: "team_a/etl",
+    };
+    const { rerender } = render(<DagFolderTree folders={[]} {...props} />, { 
wrapper: BaseWrapper });
+
+    expect(screen.queryByRole("tree")).not.toBeInTheDocument();
+
+    rerender(<DagFolderTree folders={SINGLE_BUNDLE} {...props} />);
+
+    expect(node("team_a")).toHaveAttribute("aria-expanded", "true");
+    expect(screen.getByText("etl")).toBeVisible();
+    expect(node("etl")).toHaveAttribute("aria-selected", "true");
+  });
+
+  it("expands the ancestors of the selected folder and marks it as selected", 
() => {
+    render(
+      <DagFolderTree
+        folders={SINGLE_BUNDLE}
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder="team_a/etl"
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(node("team_a")).toHaveAttribute("aria-expanded", "true");
+    expect(screen.getByText("etl")).toBeVisible();
+    expect(node("etl")).toHaveAttribute("aria-selected", "true");
+    expect(node("team_b")).toHaveAttribute("aria-selected", "false");
+  });
+
+  it("clears the selection when 'All Dags' is clicked", () => {
+    const onSelectFolder = vi.fn();
+
+    render(
+      <DagFolderTree
+        folders={SINGLE_BUNDLE}
+        onSelectFolder={onSelectFolder}
+        selectedBundle={undefined}
+        selectedFolder="team_b/ml"
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    fireEvent.click(screen.getByRole("button", { name: "folders.all" }));
+
+    expect(onSelectFolder).toHaveBeenCalledWith({ bundleName: undefined, 
folder: undefined });
+  });
+
+  it("shows an empty message and no tree when there are no folders", () => {
+    render(
+      <DagFolderTree
+        folders={[]}
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder={undefined}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(screen.getByText("folders.empty")).toBeInTheDocument();
+    expect(screen.queryByRole("tree")).not.toBeInTheDocument();
+  });
+
+  it("renders skeletons while loading", () => {
+    render(
+      <DagFolderTree
+        folders={[]}
+        isLoading
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder={undefined}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(screen.queryByRole("tree")).not.toBeInTheDocument();
+    expect(screen.queryByText("folders.empty")).not.toBeInTheDocument();
+  });
+});
+
+describe("DagFolderTree (multiple bundles)", () => {
+  it("renders bundles as the top level with their folders nested underneath", 
() => {
+    render(
+      <DagFolderTree
+        folders={MULTI_BUNDLE}
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder={undefined}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(node("analytics")).toHaveAttribute("aria-level", "1");
+    expect(node("ml")).toHaveAttribute("aria-level", "1");
+    // Folders live below their bundle and stay hidden until it is expanded.
+    expect(screen.getByText("features")).not.toBeVisible();
+    expect(node("features")).toHaveAttribute("aria-level", "2");
+  });
+
+  it("keeps the same folder path separate under each bundle", () => {
+    render(
+      <DagFolderTree
+        folders={MULTI_BUNDLE}
+        onSelectFolder={vi.fn()}
+        selectedBundle={undefined}
+        selectedFolder={undefined}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    // ``team_a`` exists in both bundles, so it must appear once per bundle.
+    const teamANodes = nodes().filter(
+      (item) => item.querySelector('[data-part="branch-text"]')?.textContent 
=== "team_a",
+    );
+
+    expect(teamANodes).toHaveLength(2);
+    expect(teamANodes.map((item) => item.getAttribute("data-value"))).toEqual([
+      "folder:analytics:team_a",
+      "folder:ml:team_a",
+    ]);
+  });
+
+  it("expands the selected bundle down to the selected folder", () => {
+    render(
+      <DagFolderTree
+        folders={MULTI_BUNDLE}
+        onSelectFolder={vi.fn()}
+        selectedBundle="ml"
+        selectedFolder="team_a/etl"
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(node("ml")).toHaveAttribute("aria-expanded", "true");
+
+    // ``etl`` exists in both bundles; only the one under ``ml`` is selected.
+    const selected = nodes().filter((item) => 
item.getAttribute("aria-selected") === "true");
+
+    expect(selected.map((item) => 
item.getAttribute("data-value"))).toEqual(["folder:ml:team_a/etl"]);
+  });
+
+  it("expands down to the selected folder once folders finish loading", () => {
+    const props = {
+      onSelectFolder: vi.fn(),
+      selectedBundle: "ml",
+      selectedFolder: "team_a/etl",
+    };
+    const { rerender } = render(<DagFolderTree folders={[]} {...props} />, { 
wrapper: BaseWrapper });
+
+    rerender(<DagFolderTree folders={MULTI_BUNDLE} {...props} />);
+
+    expect(node("ml")).toHaveAttribute("aria-expanded", "true");
+    // ``etl`` exists in both bundles; the one under ``ml`` is the one 
revealed and selected.
+    const selected = nodes().filter((item) => 
item.getAttribute("aria-selected") === "true");
+
+    expect(selected.map((item) => 
item.getAttribute("data-value"))).toEqual(["folder:ml:team_a/etl"]);
+  });
+
+  it("marks a bundle as selected when only the bundle is selected", () => {
+    render(
+      <DagFolderTree
+        folders={MULTI_BUNDLE}
+        onSelectFolder={vi.fn()}
+        selectedBundle="analytics"
+        selectedFolder={undefined}
+      />,
+      { wrapper: BaseWrapper },
+    );
+
+    expect(node("analytics")).toHaveAttribute("aria-selected", "true");
+    expect(node("ml")).toHaveAttribute("aria-selected", "false");
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.tsx
 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.tsx
new file mode 100644
index 00000000000..f3790657dba
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.tsx
@@ -0,0 +1,216 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState } from "react";
+
+import {
+  Box,
+  Button,
+  createTreeCollection,
+  Heading,
+  Skeleton,
+  TreeView,
+  type TreeViewNodeRenderProps,
+  VStack,
+} from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiBox, FiChevronRight, FiFolder } from "react-icons/fi";
+
+import type { DagFolderResponse } from "openapi/requests/types.gen";
+
+import {
+  bundleNodeValue,
+  buildFolderTreeNodes,
+  folderNodeValue,
+  type FolderTreeNode,
+} from "./buildFolderTree";
+
+export type FolderSelection = {
+  readonly bundleName: string | undefined;
+  readonly folder: string | undefined;
+};
+
+type Props = {
+  readonly folders: ReadonlyArray<DagFolderResponse>;
+  readonly isLoading?: boolean;
+  readonly onSelectFolder: (selection: FolderSelection) => void;
+  readonly selectedBundle: string | undefined;
+  readonly selectedFolder: string | undefined;
+};
+
+const NodeIcon = ({ node }: { readonly node: FolderTreeNode }) => (
+  <Box as="span" color="fg.muted" flexShrink={0}>
+    {node.folder === undefined ? <FiBox /> : <FiFolder />}
+  </Box>
+);
+
+// Rows span the full panel width and long names are truncated, so the 
selected background always
+// covers the whole row instead of stopping where the text overflows. The full 
name stays available
+// as a native tooltip.
+const renderNode = (node: FolderTreeNode, isBranch: boolean) =>
+  isBranch ? (
+    <TreeView.BranchControl width="100%">
+      <TreeView.BranchIndicator flexShrink={0}>
+        <FiChevronRight />
+      </TreeView.BranchIndicator>
+      <NodeIcon node={node} />
+      <TreeView.BranchText minWidth={0} title={node.label} truncate>
+        {node.label}
+      </TreeView.BranchText>
+    </TreeView.BranchControl>
+  ) : (
+    <TreeView.Item width="100%">
+      {/* Reserve the width of the expand indicator so leaves line up with 
their sibling folders. */}
+      <Box aria-hidden as="span" flexShrink={0} visibility="hidden">
+        <FiChevronRight />
+      </Box>
+      <NodeIcon node={node} />
+      <TreeView.ItemText minWidth={0} title={node.label} truncate>
+        {node.label}
+      </TreeView.ItemText>
+    </TreeView.Item>
+  );
+
+// Ancestor folders of the selection, so the tree opens far enough to reveal 
it.
+const expandedForSelection = (
+  bundleName: string | undefined,
+  folder: string | undefined,
+  isMultiBundle: boolean,
+): Array<string> => {
+  const values: Array<string> = [];
+
+  if (isMultiBundle && bundleName !== undefined) {
+    values.push(bundleNodeValue(bundleName));
+  }
+
+  if (folder !== undefined && folder !== "" && bundleName !== undefined) {
+    const segments = folder.split("/");
+
+    values.push(
+      ...segments.map((_, index) => folderNodeValue(bundleName, 
segments.slice(0, index + 1).join("/"))),
+    );
+  }
+
+  return values;
+};
+
+export const DagFolderTree = ({
+  folders,
+  isLoading = false,
+  onSelectFolder,
+  selectedBundle,
+  selectedFolder,
+}: Props) => {
+  const { t: translate } = useTranslation("dags");
+  const nodes = buildFolderTreeNodes(folders);
+  // Bundle nodes are only emitted when there is more than one bundle.
+  const isMultiBundle = nodes.some((node) => node.folder === undefined);
+  // Node values always carry a bundle; with a single bundle the URL omits it, 
so fall back to
+  // the only bundle present to keep selection and expansion pointing at the 
right nodes.
+  const nodeBundle = isMultiBundle ? selectedBundle : nodes[0]?.bundleName;
+
+  const [expandedValue, setExpandedValue] = useState<Array<string>>(() =>
+    expandedForSelection(nodeBundle, selectedFolder, isMultiBundle),
+  );
+
+  // Folders arrive asynchronously, so the first render has no nodes to expand 
yet: re-apply the
+  // auto-expansion whenever the selection resolves to different nodes (on 
load, or when the user
+  // lands on a URL that already points at a nested folder). Manual expansions 
are kept.
+  const selectionKey = `${nodeBundle ?? ""}|${selectedFolder ?? 
""}|${isMultiBundle}`;
+  const [appliedSelectionKey, setAppliedSelectionKey] = useState(selectionKey);
+
+  if (appliedSelectionKey !== selectionKey) {
+    setAppliedSelectionKey(selectionKey);
+    setExpandedValue((previous) => [
+      ...new Set([...previous, ...expandedForSelection(nodeBundle, 
selectedFolder, isMultiBundle)]),
+    ]);
+  }
+
+  const collection = createTreeCollection<FolderTreeNode>({
+    rootNode: { bundleName: "", children: nodes, label: "", value: "__root__" 
},
+  });
+
+  const selectedValue =
+    selectedFolder !== undefined && nodeBundle !== undefined
+      ? [folderNodeValue(nodeBundle, selectedFolder)]
+      : selectedBundle === undefined
+        ? []
+        : [bundleNodeValue(selectedBundle)];
+
+  const isAllSelected = selectedBundle === undefined && selectedFolder === 
undefined;
+
+  return (
+    <Box minWidth={0} width="100%">
+      <Heading mb={2} size="sm">
+        {translate("folders.title")}
+      </Heading>
+      {isLoading ? (
+        <VStack align="stretch" gap={2}>
+          <Skeleton height="24px" />
+          <Skeleton height="24px" />
+          <Skeleton height="24px" />
+        </VStack>
+      ) : (
+        <Box>
+          <Button
+            bg={isAllSelected ? "blue.subtle" : undefined}
+            fontWeight={isAllSelected ? "bold" : "normal"}
+            justifyContent="flex-start"
+            onClick={() => onSelectFolder({ bundleName: undefined, folder: 
undefined })}
+            size="sm"
+            variant="ghost"
+            width="100%"
+          >
+            {translate("folders.all")}
+          </Button>
+          {nodes.length === 0 ? (
+            <Box color="fg.muted" fontSize="sm" pl="4px" py={1}>
+              {translate("folders.empty")}
+            </Box>
+          ) : (
+            <TreeView.Root
+              collection={collection}
+              expandedValue={expandedValue}
+              onExpandedChange={(details) => 
setExpandedValue(details.expandedValue)}
+              onSelectionChange={(details) => {
+                const [value] = details.selectedValue;
+                const selectedNode = value === undefined ? undefined : 
collection.findNode(value);
+
+                if (selectedNode !== undefined) {
+                  onSelectFolder({ bundleName: selectedNode.bundleName, 
folder: selectedNode.folder });
+                }
+              }}
+              selectedValue={selectedValue}
+              selectionMode="single"
+              size="sm"
+            >
+              <TreeView.Tree>
+                <TreeView.Node
+                  indentGuide={<TreeView.BranchIndentGuide />}
+                  render={({ node, nodeState }: 
TreeViewNodeRenderProps<FolderTreeNode>) =>
+                    renderNode(node, nodeState.isBranch)
+                  }
+                />
+              </TreeView.Tree>
+            </TreeView.Root>
+          )}
+        </Box>
+      )}
+    </Box>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.test.ts
 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.test.ts
new file mode 100644
index 00000000000..9521f977551
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.test.ts
@@ -0,0 +1,119 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { describe, expect, it } from "vitest";
+
+import { buildFolderTree, groupFoldersByBundle } from "./buildFolderTree";
+
+describe("buildFolderTree", () => {
+  it("returns an empty array for no folders", () => {
+    expect(buildFolderTree([])).toEqual([]);
+  });
+
+  it("builds single-level roots", () => {
+    const tree = buildFolderTree(["team_a", "team_b"]);
+
+    expect(tree.map((node) => node.path)).toEqual(["team_a", "team_b"]);
+    expect(tree.every((node) => node.children.length === 0)).toBe(true);
+  });
+
+  it("nests sub-folders and keeps full paths", () => {
+    const tree = buildFolderTree(["team_a/etl", "team_b/ml"]);
+
+    expect(tree.map((node) => node.path)).toEqual(["team_a", "team_b"]);
+
+    const [teamA] = tree;
+
+    expect(teamA?.name).toBe("team_a");
+    expect(teamA?.children).toHaveLength(1);
+    expect(teamA?.children[0]?.name).toBe("etl");
+    expect(teamA?.children[0]?.path).toBe("team_a/etl");
+  });
+
+  it("synthesizes intermediate folders that contain no Dag of their own", () 
=> {
+    const tree = buildFolderTree(["team_a/etl/extract"]);
+
+    expect(tree).toHaveLength(1);
+    expect(tree[0]?.path).toBe("team_a");
+    expect(tree[0]?.children[0]?.path).toBe("team_a/etl");
+    expect(tree[0]?.children[0]?.children[0]?.path).toBe("team_a/etl/extract");
+  });
+
+  it("merges a folder that is both a leaf and a parent", () => {
+    const tree = buildFolderTree(["team_a", "team_a/etl"]);
+
+    expect(tree).toHaveLength(1);
+    expect(tree[0]?.path).toBe("team_a");
+    expect(tree[0]?.children.map((node) => node.path)).toEqual(["team_a/etl"]);
+  });
+
+  it("deduplicates repeated folders", () => {
+    const tree = buildFolderTree(["team_a/etl", "team_a/etl"]);
+
+    expect(tree).toHaveLength(1);
+    expect(tree[0]?.children).toHaveLength(1);
+  });
+
+  it("sorts siblings alphabetically at every level", () => {
+    const tree = buildFolderTree(["team_b/zeta", "team_b/alpha", "team_a"]);
+
+    expect(tree.map((node) => node.name)).toEqual(["team_a", "team_b"]);
+    const teamB = tree.find((node) => node.name === "team_b");
+
+    expect(teamB?.children.map((node) => node.name)).toEqual(["alpha", 
"zeta"]);
+  });
+});
+
+describe("groupFoldersByBundle", () => {
+  it("returns an empty array for no folders", () => {
+    expect(groupFoldersByBundle([])).toEqual([]);
+  });
+
+  it("groups folders under their bundle and sorts bundles alphabetically", () 
=> {
+    const bundles = groupFoldersByBundle([
+      { bundle_name: "ml", folder: "features" },
+      { bundle_name: "analytics", folder: "team_a/etl" },
+    ]);
+
+    expect(bundles.map((bundle) => bundle.bundleName)).toEqual(["analytics", 
"ml"]);
+  });
+
+  it("keeps the same folder path separate per bundle", () => {
+    const bundles = groupFoldersByBundle([
+      { bundle_name: "analytics", folder: "team_a/etl" },
+      { bundle_name: "ml", folder: "team_a/etl" },
+    ]);
+
+    const analytics = bundles.find((bundle) => bundle.bundleName === 
"analytics");
+    const ml = bundles.find((bundle) => bundle.bundleName === "ml");
+
+    expect(analytics?.tree[0]?.children[0]?.path).toBe("team_a/etl");
+    expect(ml?.tree[0]?.children[0]?.path).toBe("team_a/etl");
+  });
+
+  it("builds a full folder tree within each bundle", () => {
+    const bundles = groupFoldersByBundle([
+      { bundle_name: "b1", folder: "team_a/etl" },
+      { bundle_name: "b1", folder: "team_a/report" },
+    ]);
+
+    expect(bundles).toHaveLength(1);
+    expect(bundles[0]?.tree[0]?.path).toBe("team_a");
+    expect(bundles[0]?.tree[0]?.children.map((node) => 
node.name)).toEqual(["etl", "report"]);
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.ts
 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.ts
new file mode 100644
index 00000000000..9eeb29bb3a6
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.ts
@@ -0,0 +1,152 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export type FolderNode = {
+  /** Direct child folders, keyed alphabetically by their display name. */
+  readonly children: Array<FolderNode>;
+  /** Last path segment, shown in the tree (e.g. ``etl``). */
+  readonly name: string;
+  /** Full folder path from the bundle root (e.g. ``team_a/etl``), used as the 
filter value. */
+  readonly path: string;
+};
+
+/**
+ * Build a nested folder tree from a flat list of folder paths.
+ *
+ * The backend only returns the directory of each Dag file (leaf folders), so 
intermediate
+ * folders that contain no Dag of their own — but do contain sub-folders — are 
synthesized
+ * here. Given ``["team_a/etl", "team_b/ml"]`` the ``team_a``/``team_b`` nodes 
are created even
+ * though no Dag lives directly in them.
+ *
+ * Children are sorted alphabetically at every level for a stable, predictable 
rendering.
+ */
+export const buildFolderTree = (folders: ReadonlyArray<string>): 
Array<FolderNode> => {
+  type MutableNode = { children: Map<string, MutableNode>; name: string; path: 
string };
+
+  const roots = new Map<string, MutableNode>();
+
+  for (const folder of folders) {
+    const segments = folder.split("/").filter((segment) => segment !== "");
+
+    let level = roots;
+    let prefix = "";
+
+    for (const segment of segments) {
+      prefix = prefix === "" ? segment : `${prefix}/${segment}`;
+
+      let node = level.get(segment);
+
+      if (node === undefined) {
+        node = { children: new Map(), name: segment, path: prefix };
+        level.set(segment, node);
+      }
+
+      level = node.children;
+    }
+  }
+
+  const toSortedNodes = (level: Map<string, MutableNode>): Array<FolderNode> =>
+    [...level.values()]
+      .sort((left, right) => left.name.localeCompare(right.name))
+      .map((node) => ({
+        children: toSortedNodes(node.children),
+        name: node.name,
+        path: node.path,
+      }));
+
+  return toSortedNodes(roots);
+};
+
+export type BundleFolders = {
+  /** The bundle these folders belong to. */
+  readonly bundleName: string;
+  /** Folder tree built from this bundle's folder paths. */
+  readonly tree: Array<FolderNode>;
+};
+
+/**
+ * Group folders by their bundle, then build a folder tree per bundle.
+ *
+ * Because ``relative_fileloc`` is relative to each bundle root, the same 
folder path
+ * can exist in several bundles; grouping first keeps them separate. Bundles 
are sorted
+ * alphabetically for stable rendering.
+ */
+export const groupFoldersByBundle = (
+  folders: ReadonlyArray<{ readonly bundle_name: string; readonly folder: 
string }>,
+): Array<BundleFolders> => {
+  const pathsByBundle = new Map<string, Array<string>>();
+
+  for (const { bundle_name: bundleName, folder } of folders) {
+    const paths = pathsByBundle.get(bundleName) ?? [];
+
+    paths.push(folder);
+    pathsByBundle.set(bundleName, paths);
+  }
+
+  return [...pathsByBundle.entries()]
+    .sort(([left], [right]) => left.localeCompare(right))
+    .map(([bundleName, paths]) => ({ bundleName, tree: buildFolderTree(paths) 
}));
+};
+
+export type FolderTreeNode = {
+  readonly bundleName: string;
+  readonly children?: Array<FolderTreeNode>;
+  /** Folder path within the bundle; undefined for a bundle node. */
+  readonly folder?: string;
+  readonly label: string;
+  /** Stable id used by the tree widget for selection and expansion. */
+  readonly value: string;
+};
+
+export const bundleNodeValue = (bundleName: string) => `bundle:${bundleName}`;
+export const folderNodeValue = (bundleName: string, folder: string) => 
`folder:${bundleName}:${folder}`;
+
+const toTreeNodes = (bundleName: string, nodes: ReadonlyArray<FolderNode>): 
Array<FolderTreeNode> =>
+  nodes.map((node) => ({
+    bundleName,
+    ...(node.children.length > 0 ? { children: toTreeNodes(bundleName, 
node.children) } : {}),
+    folder: node.path,
+    label: node.name,
+    value: folderNodeValue(bundleName, node.path),
+  }));
+
+/**
+ * Turn the folder payload into the node list the tree widget renders.
+ *
+ * With several bundles each bundle becomes a top-level node holding its own 
folder tree, so
+ * identical folder paths in different bundles stay distinct. With a single 
bundle the bundle
+ * level is dropped and folders are returned directly.
+ */
+export const buildFolderTreeNodes = (
+  folders: ReadonlyArray<{ readonly bundle_name: string; readonly folder: 
string }>,
+): Array<FolderTreeNode> => {
+  const bundles = groupFoldersByBundle(folders);
+
+  if (bundles.length <= 1) {
+    const [bundle] = bundles;
+
+    return bundle === undefined ? [] : toTreeNodes(bundle.bundleName, 
bundle.tree);
+  }
+
+  return bundles.map(({ bundleName, tree }) => ({
+    bundleName,
+    children: toTreeNodes(bundleName, tree),
+    label: bundleName,
+    value: bundleNodeValue(bundleName),
+  }));
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/index.ts 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/index.ts
new file mode 100644
index 00000000000..6dd75a1d756
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/index.ts
@@ -0,0 +1,19 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export { DagFolderTree, type FolderSelection } from "./DagFolderTree";
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
index 503cf5b0220..1e4ac9784cd 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -16,9 +16,10 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Skeleton, VStack, type SelectValueChangeDetails } from 
"@chakra-ui/react";
+import { Box, Flex, Skeleton, VStack, type SelectValueChangeDetails } from 
"@chakra-ui/react";
 import type { ColumnDef } from "@tanstack/react-table";
 import { useTranslation } from "react-i18next";
+import { FiSidebar } from "react-icons/fi";
 import { useSearchParams } from "react-router-dom";
 import { useLocalStorage } from "usehooks-ts";
 
@@ -28,7 +29,7 @@ import type {
   DAGWithLatestDagRunsResponse,
 } from "openapi/requests/types.gen";
 
-import { ActionBar, RouterLink } from "src/system-components";
+import { ActionBar, IconButton, RouterLink, Tooltip } from 
"src/system-components";
 
 import { DagsLayout } from "src/layouts/DagsLayout";
 
@@ -52,10 +53,11 @@ import { TeamName } from "src/components/TeamName";
 import { TogglePause } from "src/components/TogglePause";
 import { TriggerDAGButton } from "src/components/TriggerDag/TriggerDAGButton";
 
-import { DAGS_LIST_DISPLAY_KEY } from "src/constants/localStorage";
+import { DAGS_LIST_DISPLAY_KEY, DAGS_LIST_SHOW_FOLDERS_KEY } from 
"src/constants/localStorage";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
 import { useConfig } from "src/queries/useConfig";
+import { useDagFolders } from "src/queries/useDagFolders";
 import { useDagRunStateCounts } from "src/queries/useDagRunStateCounts";
 import { useDags } from "src/queries/useDags";
 import { useDocumentTitle } from "src/utils";
@@ -64,6 +66,7 @@ import { DagImportErrors } from 
"../Dashboard/Stats/DagImportErrors";
 import BulkPauseDrainDagsButton from "./BulkPauseDrainDagsButton";
 import BulkUnpauseDagsButton from "./BulkUnpauseDagsButton";
 import { DagCard } from "./DagCard";
+import { DagFolderTree, type FolderSelection } from "./DagFolderTree";
 import { DagRunStateCounts } from "./DagRunStateCounts";
 import { DagTags } from "./DagTags";
 import { DagsFilters } from "./DagsFilters";
@@ -244,6 +247,8 @@ const createColumns = (
 ];
 
 const {
+  DAG_BUNDLE,
+  DAG_FOLDER,
   DAG_RUN_STATE,
   DAG_RUN_STATE_WITHIN_HOURS,
   FAVORITE,
@@ -300,6 +305,16 @@ export const DagsList = () => {
   const owners = searchParams.getAll(OWNERS).filter((value) => value !== "");
   const teams = searchParams.getAll(TEAMS);
   const timetableType = searchParams.getAll(TIMETABLE_TYPE).filter((value) => 
value !== "");
+  const selectedFolder = searchParams.get(DAG_FOLDER) ?? undefined;
+  const selectedBundle = searchParams.get(DAG_BUNDLE) ?? undefined;
+
+  const { folders, isLoading: foldersLoading } = useDagFolders();
+  const [showFolders, setShowFolders] = 
useLocalStorage<boolean>(DAGS_LIST_SHOW_FOLDERS_KEY, true);
+  // Keep the panel out of the way for flat deployments (all Dags at the 
bundle root). Still offer it
+  // while loading, or when a folder/bundle is selected so the user can always 
navigate back to "All Dags".
+  const hasFolderTree =
+    foldersLoading || folders.length > 0 || Boolean(selectedFolder) || 
Boolean(selectedBundle);
+  const showFolderTree = hasFolderTree && showFolders;
 
   const { setTableURLState, tableURLState } = useTableURLState();
 
@@ -325,6 +340,25 @@ export const DagsList = () => {
     setSearchParams(searchParams);
   };
 
+  const handleFolderChange = ({ bundleName, folder }: FolderSelection) => {
+    setTableURLState({
+      pagination: { ...pagination, pageIndex: 0 },
+      sorting,
+    });
+    if (folder === undefined || folder === "") {
+      searchParams.delete(DAG_FOLDER);
+    } else {
+      searchParams.set(DAG_FOLDER, folder);
+    }
+    if (bundleName === undefined || bundleName === "") {
+      searchParams.delete(DAG_BUNDLE);
+    } else {
+      searchParams.set(DAG_BUNDLE, bundleName);
+    }
+    searchParams.delete(OFFSET);
+    setSearchParams(searchParams);
+  };
+
   // No param means no paused filtering. usePausedDefault seeds the configured 
default on load,
   // so an absent param after that is the user having removed the pill to see 
every Dag.
   let paused = undefined;
@@ -351,6 +385,7 @@ export const DagsList = () => {
 
   const { data, error, isFetching, isLoading } = useDags({
     advancedSearch: advancedSearch.enabled,
+    bundleName: selectedBundle,
     dagDisplayNamePattern: Boolean(dagDisplayNamePattern) ? 
dagDisplayNamePattern : undefined,
     dagRunsLimit,
     dagRunState,
@@ -363,6 +398,7 @@ export const DagsList = () => {
     owners,
     paused,
     pendingHitl,
+    relativeFilelocPrefix: selectedFolder,
     schedulingState: schedulingState ?? undefined,
     tags: selectedTags,
     tagsMatchMode: selectedMatchMode,
@@ -414,60 +450,98 @@ export const DagsList = () => {
 
   return (
     <DagsLayout>
-      <Box pb={8}>
-        <SelectionProvider
-          allRowsSelected={allRowsSelected}
-          onRowSelect={handleRowSelect}
-          onSelectAll={handleSelectAll}
-          selectedRows={selectedRows}
-        >
-          <DataTable
-            cardDef={cardDef}
-            columns={columns}
-            data={data?.dags ?? []}
-            displayMode={display}
-            enableMultiSort
-            errorMessage={<ErrorAlert error={error} />}
-            filterActions={
-              <VStack alignItems="flex-start" gap={2} w="100%">
-                <SearchBar
-                  advancedSearch={advancedSearch}
-                  defaultValue={dagDisplayNamePattern}
-                  onChange={handleSearchChange}
-                  placeholder={translate("dags:search.dags")}
-                />
-                <DagsFilters />
-              </VStack>
-            }
-            headingExtra={<DagImportErrors iconOnly />}
-            initialState={tableURLState}
-            isFetching={isFetching}
-            isLoading={isLoading}
-            modelName="common:dag"
-            onDisplayToggleChange={handleDisplayToggleChange}
-            onStateChange={setTableURLState}
-            presentationActions={
-              display === "card" ? (
-                <SortSelect handleSortChange={handleSortChange} 
orderBy={orderBy[0]} />
-              ) : undefined
-            }
-            showDisplayToggle
-            skeletonCount={display === "card" ? 5 : undefined}
-            total={totalEntries}
-          />
-          <ActionBar.Root closeOnInteractOutside={false} open={display === 
"table" && selectedRows.size > 0}>
-            <ActionBar.Content>
-              <ActionBar.SelectionTrigger>
-                {selectedRows.size} {translate("selected")}
-              </ActionBar.SelectionTrigger>
-              <ActionBar.Separator />
-              <BulkPauseDrainDagsButton deselectKeys={deselectKeys} 
selectedDags={selectedDags} />
-              <BulkUnpauseDagsButton deselectKeys={deselectKeys} 
selectedDags={selectedDags} />
-              <ActionBar.CloseTrigger onClick={clearSelections} />
-            </ActionBar.Content>
-          </ActionBar.Root>
-        </SelectionProvider>
-      </Box>
+      <Flex align="flex-start" gap={4} pb={8}>
+        {/* Only show the folder sidebar when there is something to navigate; 
deployments with all
+            Dags at the bundle root would otherwise get an empty panel. */}
+        {showFolderTree ? (
+          <Box flexShrink={0} overflowY="auto" position="sticky" top={0} 
width="280px">
+            <DagFolderTree
+              folders={folders}
+              isLoading={foldersLoading}
+              onSelectFolder={handleFolderChange}
+              selectedBundle={selectedBundle}
+              selectedFolder={selectedFolder}
+            />
+          </Box>
+        ) : undefined}
+        <Box flex={1} minWidth={0}>
+          <SelectionProvider
+            allRowsSelected={allRowsSelected}
+            onRowSelect={handleRowSelect}
+            onSelectAll={handleSelectAll}
+            selectedRows={selectedRows}
+          >
+            <DataTable
+              cardDef={cardDef}
+              columns={columns}
+              data={data?.dags ?? []}
+              displayMode={display}
+              enableMultiSort
+              errorMessage={<ErrorAlert error={error} />}
+              filterActions={
+                <VStack alignItems="flex-start" gap={2} w="100%">
+                  <SearchBar
+                    advancedSearch={advancedSearch}
+                    defaultValue={dagDisplayNamePattern}
+                    onChange={handleSearchChange}
+                    placeholder={translate("dags:search.dags")}
+                  />
+                  <DagsFilters />
+                </VStack>
+              }
+              headingExtra={<DagImportErrors iconOnly />}
+              initialState={tableURLState}
+              isFetching={isFetching}
+              isLoading={isLoading}
+              modelName="common:dag"
+              onDisplayToggleChange={handleDisplayToggleChange}
+              onStateChange={setTableURLState}
+              presentationActions={
+                hasFolderTree || display === "card" ? (
+                  <>
+                    {hasFolderTree ? (
+                      <Tooltip
+                        content={translate(showFolders ? "dags:folders.hide" : 
"dags:folders.show")}
+                        openDelay={200}
+                        portalled
+                      >
+                        <IconButton
+                          aria-label={translate(showFolders ? 
"dags:folders.hide" : "dags:folders.show")}
+                          onClick={() => setShowFolders(!showFolders)}
+                          size="sm"
+                          variant={showFolders ? "solid" : "outline"}
+                        >
+                          <FiSidebar />
+                        </IconButton>
+                      </Tooltip>
+                    ) : undefined}
+                    {display === "card" ? (
+                      <SortSelect handleSortChange={handleSortChange} 
orderBy={orderBy[0]} />
+                    ) : undefined}
+                  </>
+                ) : undefined
+              }
+              showDisplayToggle
+              skeletonCount={display === "card" ? 5 : undefined}
+              total={totalEntries}
+            />
+            <ActionBar.Root
+              closeOnInteractOutside={false}
+              open={display === "table" && selectedRows.size > 0}
+            >
+              <ActionBar.Content>
+                <ActionBar.SelectionTrigger>
+                  {selectedRows.size} {translate("selected")}
+                </ActionBar.SelectionTrigger>
+                <ActionBar.Separator />
+                <BulkPauseDrainDagsButton deselectKeys={deselectKeys} 
selectedDags={selectedDags} />
+                <BulkUnpauseDagsButton deselectKeys={deselectKeys} 
selectedDags={selectedDags} />
+                <ActionBar.CloseTrigger onClick={clearSelections} />
+              </ActionBar.Content>
+            </ActionBar.Root>
+          </SelectionProvider>
+        </Box>
+      </Flex>
     </DagsLayout>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/queries/useDagFolders.ts 
b/airflow-core/src/airflow/ui/src/queries/useDagFolders.ts
new file mode 100644
index 00000000000..91866836390
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useDagFolders.ts
@@ -0,0 +1,35 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useDagServiceGetDagFolders } from "openapi/queries";
+
+/**
+ * Fetch the distinct folders of all readable Dags, each paired with its 
bundle.
+ *
+ * The list powers the folder navigation tree on the Dags page; the tree 
hierarchy is
+ * reconstructed client-side by splitting each path on ``/`` and grouping by 
bundle.
+ */
+export const useDagFolders = () => {
+  const { data, error, isLoading } = useDagServiceGetDagFolders();
+
+  return {
+    error,
+    folders: data?.folders ?? [],
+    isLoading,
+  };
+};
diff --git a/airflow-core/src/airflow/ui/src/queries/useDags.tsx 
b/airflow-core/src/airflow/ui/src/queries/useDags.tsx
index a8d24eba136..df0cd6de996 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDags.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useDags.tsx
@@ -23,6 +23,7 @@ import { isStatePending, useAutoRefresh } from "src/utils";
 
 export const useDags = ({
   advancedSearch = false,
+  bundleName,
   dagDisplayNamePattern,
   dagIdPattern,
   dagRunsLimit,
@@ -37,6 +38,7 @@ export const useDags = ({
   owners,
   paused,
   pendingHitl,
+  relativeFilelocPrefix,
   schedulingState,
   tags,
   tagsMatchMode,
@@ -44,6 +46,7 @@ export const useDags = ({
   timetableType,
 }: {
   advancedSearch?: boolean;
+  bundleName?: string;
   dagDisplayNamePattern?: string;
   dagIdPattern?: string;
   dagRunsLimit: number;
@@ -58,6 +61,7 @@ export const useDags = ({
   owners?: Array<string>;
   paused?: boolean;
   pendingHitl?: boolean;
+  relativeFilelocPrefix?: string;
   schedulingState?: DagSchedulingState;
   tags?: Array<string>;
   tagsMatchMode?: "all" | "any";
@@ -71,6 +75,7 @@ export const useDags = ({
       ...(advancedSearch
         ? { dagDisplayNamePattern, dagIdPattern }
         : { dagDisplayNamePrefixPattern: dagDisplayNamePattern, 
dagIdPrefixPattern: dagIdPattern }),
+      bundleName,
       dagRunsLimit,
       dagRunState,
       dagRunStateWithinHours,
@@ -83,6 +88,7 @@ export const useDags = ({
       orderBy,
       owners,
       paused,
+      relativeFilelocPrefix,
       schedulingState,
       tags,
       tagsMatchMode,
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
index a15aac5f7a5..2321e6a4a95 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
@@ -31,6 +31,7 @@ from airflow.configuration import conf
 from airflow.models import DagRun
 from airflow.models.dag import DagModel, DagTag
 from airflow.models.dag_favorite import DagFavorite
+from airflow.models.dagbundle import DagBundleModel
 from airflow.models.hitl import HITLDetail
 from airflow.sdk.timezone import utcnow
 from airflow.utils.session import NEW_SESSION, provide_session
@@ -852,3 +853,185 @@ class TestGetDagRunStateCounts(TestPublicDagEndpoint):
     def test_should_response_403(self, unauthorized_test_client):
         response = unauthorized_test_client.get("/dags/run_state_counts", 
params={"dag_ids": [DAG1_ID]})
         assert response.status_code == 403
+
+
+# Maps dag_id -> (bundle_name, relative_fileloc). ``team_alpha`` proves a 
folder
+# name is never matched as a prefix of another (``team_a`` must not catch it),
+# ``root_dag.py`` lives at the bundle root (no folder), and ``other_bundle`` 
reuses
+# the ``team_a/etl`` path to prove folders are kept separate per bundle.
+OTHER_BUNDLE = "other_bundle"
+FOLDER_DAGS = {
+    "folder_dag_a_etl_extract": ("dag_maker", "team_a/etl/extract.py"),
+    "folder_dag_a_etl_load": ("dag_maker", "team_a/etl/load.py"),
+    "folder_dag_a_report": ("dag_maker", "team_a/report.py"),
+    "folder_dag_b_ml_train": ("dag_maker", "team_b/ml/train.py"),
+    "folder_dag_alpha": ("dag_maker", "team_alpha/x.py"),
+    "folder_dag_root": ("dag_maker", "root_dag.py"),
+    "folder_dag_other_etl": (OTHER_BUNDLE, "team_a/etl/other.py"),
+}
+
+
+class TestDagFolders(TestPublicDagEndpoint):
+    @pytest.fixture(autouse=True)
+    @provide_session
+    def setup_folder_dags(self, *, session: Session = NEW_SESSION) -> None:
+        # The extra bundle must exist before its Dags are inserted (FK on 
``bundle_name``).
+        session.merge(DagBundleModel(name=OTHER_BUNDLE))
+        session.flush()
+        for dag_id, (bundle_name, relative_fileloc) in FOLDER_DAGS.items():
+            session.add(
+                DagModel(
+                    dag_id=dag_id,
+                    bundle_name=bundle_name,
+                    relative_fileloc=relative_fileloc,
+                    fileloc=f"/tmp/{relative_fileloc}",
+                    is_stale=False,
+                    is_paused=False,
+                )
+            )
+        session.commit()
+
+    def test_get_dag_folders(self, test_client):
+        response = test_client.get("/dags/folders")
+        assert response.status_code == 200
+        body = response.json()
+        # Distinct (bundle, folder) pairs of every readable Dag, sorted. 
Root-level
+        # Dags contribute no folder, and ``team_a/etl`` exists under both 
bundles
+        # yet stays as two separate entries.
+        assert body["folders"] == [
+            {"bundle_name": "dag_maker", "folder": "team_a"},
+            {"bundle_name": "dag_maker", "folder": "team_a/etl"},
+            {"bundle_name": "dag_maker", "folder": "team_alpha"},
+            {"bundle_name": "dag_maker", "folder": "team_b/ml"},
+            {"bundle_name": OTHER_BUNDLE, "folder": "team_a/etl"},
+        ]
+        assert body["total_entries"] == 5
+
+    def test_get_dag_folders_query_count_does_not_scale_with_dags(self, 
session, test_client):
+        """The folders endpoint must run a finite number of queries regardless 
of how many Dags exist."""
+        with count_queries() as result:
+            response = test_client.get("/dags/folders")
+        assert response.status_code == 200
+        baseline = sum(result.values())
+
+        # Add many more Dags (in both new and existing folders); the query 
count must not grow.
+        for i in range(50):
+            relative_fileloc = f"team_c/sub_{i}/dag_{i}.py"
+            session.add(
+                DagModel(
+                    dag_id=f"folder_scale_dag_{i}",
+                    bundle_name="dag_maker",
+                    relative_fileloc=relative_fileloc,
+                    fileloc=f"/tmp/{relative_fileloc}",
+                    is_stale=False,
+                    is_paused=False,
+                )
+            )
+        session.commit()
+        session.expire_all()
+
+        with count_queries() as result_after:
+            response = test_client.get("/dags/folders")
+        assert response.status_code == 200
+        assert sum(result_after.values()) == baseline
+
+    def test_get_dag_folders_excludes_stale_dags(self, session, test_client):
+        # A bundle that stops being parsed leaves its Dags stale; the Dag list 
hides them by
+        # default, so their folders must not linger in the tree and select 
down to nothing.
+        session.merge(DagBundleModel(name="retired_bundle"))
+        session.flush()
+        session.add(
+            DagModel(
+                dag_id="folder_dag_retired",
+                bundle_name="retired_bundle",
+                relative_fileloc="retired/old.py",
+                fileloc="/tmp/retired/old.py",
+                is_stale=True,
+                is_paused=False,
+            )
+        )
+        session.commit()
+
+        response = test_client.get("/dags/folders")
+        assert response.status_code == 200
+        folders = response.json()["folders"]
+        assert {"bundle_name": "retired_bundle", "folder": "retired"} not in 
folders
+        # The live folders are still returned.
+        assert {"bundle_name": OTHER_BUNDLE, "folder": "team_a/etl"} in folders
+
+    def test_get_dag_folders_should_response_401(self, 
unauthenticated_test_client):
+        response = unauthenticated_test_client.get("/dags/folders")
+        assert response.status_code == 401
+
+    def test_get_dag_folders_should_response_403(self, 
unauthorized_test_client):
+        response = unauthorized_test_client.get("/dags/folders")
+        assert response.status_code == 403
+
+    @pytest.mark.parametrize(
+        ("prefix", "expected_dag_ids"),
+        [
+            pytest.param(
+                "team_a",
+                {
+                    "folder_dag_a_etl_extract",
+                    "folder_dag_a_etl_load",
+                    "folder_dag_a_report",
+                    "folder_dag_other_etl",
+                },
+                id="folder-with-subfolders",
+            ),
+            pytest.param(
+                "team_a/etl",
+                {"folder_dag_a_etl_extract", "folder_dag_a_etl_load", 
"folder_dag_other_etl"},
+                id="nested-folder-across-bundles",
+            ),
+            pytest.param(
+                "team_a/etl/",
+                {"folder_dag_a_etl_extract", "folder_dag_a_etl_load", 
"folder_dag_other_etl"},
+                id="trailing-slash-normalized",
+            ),
+            pytest.param("team_b", {"folder_dag_b_ml_train"}, 
id="intermediate-folder"),
+            pytest.param("team_b/ml", {"folder_dag_b_ml_train"}, 
id="leaf-folder"),
+            pytest.param("team_alpha", {"folder_dag_alpha"}, 
id="sibling-prefix-folder"),
+            pytest.param("does/not/exist", set(), id="no-match"),
+        ],
+    )
+    def test_folder_filter(self, test_client, prefix, expected_dag_ids):
+        # The folder filter matches on path only; bundle scoping is layered on 
via the
+        # existing ``bundle_name`` filter (see 
test_folder_filter_scoped_by_bundle).
+        response = test_client.get("/dags", params={"relative_fileloc_prefix": 
prefix})
+        assert response.status_code == 200
+        returned = {dag["dag_id"] for dag in response.json()["dags"]}
+        # Intersect with our Dags so pre-existing setup Dags don't affect the 
assertion.
+        assert returned & set(FOLDER_DAGS) == expected_dag_ids
+        # ``team_a`` must never match ``team_alpha`` (and vice-versa).
+        if prefix == "team_a":
+            assert "folder_dag_alpha" not in returned
+
+    @pytest.mark.parametrize(
+        ("bundle_name", "expected_dag_ids"),
+        [
+            pytest.param(
+                "dag_maker",
+                {"folder_dag_a_etl_extract", "folder_dag_a_etl_load"},
+                id="dag_maker-bundle",
+            ),
+            pytest.param(OTHER_BUNDLE, {"folder_dag_other_etl"}, 
id="other-bundle"),
+        ],
+    )
+    def test_folder_filter_scoped_by_bundle(self, test_client, bundle_name, 
expected_dag_ids):
+        # Selecting a folder in the UI combines the folder path with its 
bundle, so the
+        # same ``team_a/etl`` path resolves to different Dags in different 
bundles.
+        response = test_client.get(
+            "/dags",
+            params={"relative_fileloc_prefix": "team_a/etl", "bundle_name": 
bundle_name},
+        )
+        assert response.status_code == 200
+        returned = {dag["dag_id"] for dag in response.json()["dags"]}
+        assert returned & set(FOLDER_DAGS) == expected_dag_ids
+
+    def test_no_folder_filter_returns_all_folder_dags(self, test_client):
+        response = test_client.get("/dags", params={"limit": 100})
+        assert response.status_code == 200
+        returned = {dag["dag_id"] for dag in response.json()["dags"]}
+        assert set(FOLDER_DAGS) <= returned

Reply via email to