You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by as...@apache.org on 2026/09/11 11:19:21 UTC

(superset) 01/01: latest state

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

alexandrusoare pushed a commit to branch alexandrusoare/feat/folders-testing-phase2-v2
in repository https://gitbox.apache.org/repos/asf/superset.git

commit b892bba091febe756f3c8bb941f657fb0b2fa485
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Fri Sep 11 14:18:53 2026 +0300

    latest state
---
 .../Folders/FolderBreadcrumb.stories.tsx           |   83 +
 .../components/Folders/FolderBreadcrumb.test.tsx   |   87 +
 .../src/components/Folders/FolderBreadcrumb.tsx    |   99 ++
 .../src/components/Folders/FolderList.stories.tsx  |  292 ++++
 .../src/components/Folders/FolderList.test.tsx     |  200 +++
 .../src/components/Folders/FolderList.tsx          |  209 +++
 .../src/components/Folders/assetIcons.tsx          |   35 +
 .../src/components/Folders/index.ts                |   30 +
 .../src/components/Folders/types.ts                |  106 ++
 .../src/pages/AnalyticsList/CreateFolderModal.tsx  |  141 ++
 .../src/pages/AnalyticsList/DashboardCharts.tsx    |  216 +++
 .../src/pages/AnalyticsList/DeleteFolderModal.tsx  |  127 ++
 .../src/pages/AnalyticsList/EditFolderModal.tsx    |  153 ++
 .../pages/AnalyticsList/FolderPermissionsModal.tsx |  438 +++++
 .../src/pages/AnalyticsList/FolderSelector.tsx     |  170 ++
 .../src/pages/AnalyticsList/RenameFolderModal.tsx  |  110 ++
 .../src/pages/AnalyticsList/TransferModal.tsx      | 1026 +++++++++++
 .../src/pages/AnalyticsList/index.tsx              | 1783 ++++++++++++++++++++
 .../src/pages/AnalyticsList/styles.ts              |   25 +
 superset-frontend/src/pages/AnalyticsList/types.ts |   62 +
 .../pages/components/FolderBreadcrumb.stories.tsx  |   83 +
 .../src/pages/components/FolderBreadcrumb.test.tsx |   87 +
 .../src/pages/components/FolderBreadcrumb.tsx      |   99 ++
 .../src/pages/components/FolderList.stories.tsx    |  298 ++++
 .../src/pages/components/FolderList.test.tsx       |  200 +++
 .../src/pages/components/FolderList.tsx            |  209 +++
 .../src/pages/components/assetIcons.tsx            |   35 +
 superset-frontend/src/pages/components/index.ts    |   30 +
 superset-frontend/src/pages/components/types.ts    |  106 ++
 superset-frontend/src/views/routes.tsx             |   12 +
 superset/commands/folder/__init__.py               |   16 +
 superset/commands/folder/assets.py                 |  135 ++
 superset/commands/folder/create.py                 |  102 ++
 superset/commands/folder/delete.py                 |   65 +
 superset/commands/folder/exceptions.py             |  125 ++
 superset/commands/folder/update.py                 |  151 ++
 superset/config.py                                 |    7 +
 superset/daos/folder.py                            | 1030 +++++++++++
 superset/daos/folder_permissions.py                |  457 +++++
 superset/folders/__init__.py                       |   16 +
 superset/folders/api.py                            | 1558 +++++++++++++++++
 superset/folders/constants.py                      |   83 +
 superset/folders/hooks.py                          |  312 ++++
 superset/folders/models.py                         |  248 +++
 superset/folders/schemas.py                        |  242 +++
 superset/folders/utils.py                          |   52 +
 superset/initialization/__init__.py                |   49 +-
 ...6-05-27_16-01_4567cf3d03cc_add_folder_tables.py |  332 ++++
 superset/security/manager.py                       |   29 +
 superset/views/folders.py                          |   47 +
 tests/integration_tests/folders/__init__.py        |   16 +
 tests/integration_tests/folders/api_tests.py       |  442 +++++
 tests/unit_tests/folders/__init__.py               |   16 +
 tests/unit_tests/folders/test_schemas.py           |   91 +
 tests/unit_tests/folders/test_serialization.py     |  117 ++
 55 files changed, 12273 insertions(+), 16 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.stories.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.stories.tsx
new file mode 100644
index 00000000000..3b9c4f1e578
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.stories.tsx
@@ -0,0 +1,83 @@
+/**
+ * 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 { action } from 'storybook/actions';
+import { FolderBreadcrumb } from './FolderBreadcrumb';
+import type { FolderBreadcrumbProps } from './types';
+
+export default {
+  title: 'Components/Folders/FolderBreadcrumb',
+  component: FolderBreadcrumb,
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'Folder navigation breadcrumb. Uses a `>` separator, a folder icon per ' +
+          'segment (open folder for the current location), and semantic emphasis ' +
+          'on the trailing segment.',
+      },
+    },
+  },
+};
+
+const samplePath = [
+  { key: 'root', title: 'All assets' },
+  { key: 'marketing', title: 'Marketing' },
+  { key: 'q2', title: 'Q2 Campaigns' },
+];
+
+export const InteractiveFolderBreadcrumb = (args: FolderBreadcrumbProps) => (
+  <FolderBreadcrumb {...args} />
+);
+
+InteractiveFolderBreadcrumb.args = {
+  items: samplePath,
+  separator: '>',
+};
+
+InteractiveFolderBreadcrumb.argTypes = {
+  separator: {
+    description: 'Separator rendered between segments.',
+    control: 'text',
+  },
+  items: {
+    description:
+      'Ordered folder path from root to current; the last item is the current location.',
+    control: false,
+  },
+};
+
+export const Clickable = () => (
+  <FolderBreadcrumb
+    items={samplePath.map(item => ({
+      ...item,
+      onClick: action(`navigate:${item.key}`),
+    }))}
+  />
+);
+
+Clickable.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        'Parent segments become links when given an `onClick`; hovering shows the ' +
+        'primary color and clicking fires the handler.',
+    },
+  },
+};
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.test.tsx
new file mode 100644
index 00000000000..3d61dbf0042
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.test.tsx
@@ -0,0 +1,87 @@
+/**
+ * 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 { render, screen, userEvent } from '@superset-ui/core/spec';
+import { FolderBreadcrumb } from './FolderBreadcrumb';
+import type { FolderBreadcrumbItem } from './types';
+
+const items: FolderBreadcrumbItem[] = [
+  { key: 'root', title: 'All assets' },
+  { key: 'analytics', title: 'Analytics' },
+  { key: 'sales', title: 'Sales' },
+];
+
+test('renders every segment in order', () => {
+  render(<FolderBreadcrumb items={items} />);
+
+  expect(screen.getByText('All assets')).toBeInTheDocument();
+  expect(screen.getByText('Analytics')).toBeInTheDocument();
+  expect(screen.getByText('Sales')).toBeInTheDocument();
+});
+
+test('renders the default ">" separator between segments', () => {
+  render(<FolderBreadcrumb items={items} />);
+
+  expect(screen.getAllByText('>')).toHaveLength(items.length - 1);
+});
+
+test('renders a custom separator', () => {
+  render(<FolderBreadcrumb items={items} separator="/" />);
+
+  expect(screen.getAllByText('/')).toHaveLength(items.length - 1);
+  expect(screen.queryByText('>')).not.toBeInTheDocument();
+});
+
+test('marks the trailing segment as current with the open-folder icon', () => {
+  render(<FolderBreadcrumb items={items} />);
+
+  // Ancestors get the closed-folder icon; the current leaf gets the open one.
+  expect(screen.getAllByTestId('folder')).toHaveLength(items.length - 1);
+  expect(screen.getByTestId('folder-open')).toBeInTheDocument();
+});
+
+test('omits the icon for a segment with hideIcon', () => {
+  render(
+    <FolderBreadcrumb
+      items={[
+        { key: 'root', title: 'All assets', hideIcon: true },
+        { key: 'sales', title: 'Sales' },
+      ]}
+    />,
+  );
+
+  // The only icon left is the current leaf's open-folder icon.
+  expect(screen.queryByTestId('folder')).not.toBeInTheDocument();
+  expect(screen.getByTestId('folder-open')).toBeInTheDocument();
+});
+
+test('invokes onClick with the segment key', async () => {
+  const onClick = jest.fn();
+  render(
+    <FolderBreadcrumb
+      items={[
+        { key: 'root', title: 'All assets', onClick },
+        { key: 'sales', title: 'Sales' },
+      ]}
+    />,
+  );
+
+  await userEvent.click(screen.getByText('All assets'));
+
+  expect(onClick).toHaveBeenCalledWith('root');
+});
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.tsx
new file mode 100644
index 00000000000..48aeb52f48c
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderBreadcrumb.tsx
@@ -0,0 +1,99 @@
+/**
+ * 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 { styled } from '@apache-superset/core/theme';
+import { Breadcrumb as AntdBreadcrumb } from 'antd';
+import { FolderIcon, FolderOpenIcon } from './assetIcons';
+import type { FolderBreadcrumbProps } from './types';
+
+const StyledBreadcrumb = styled(AntdBreadcrumb)`
+  ${({ theme }) => `
+    margin: ${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 4}px;
+    font-size: ${theme.fontSize}px;
+    line-height: ${theme.sizeUnit * 6}px;
+
+    .ant-breadcrumb-separator {
+      color: ${theme.colorTextTertiary};
+      margin-inline: ${theme.sizeUnit * 2}px;
+    }
+
+    .ant-breadcrumb-link {
+      color: ${theme.colorTextSecondary};
+    }
+
+    a.ant-breadcrumb-link:hover,
+    .ant-breadcrumb-link a:hover {
+      color: ${theme.colorPrimary};
+      background: transparent;
+    }
+
+    /* Semantic emphasis: the trailing item is the current location. */
+    li:last-of-type .ant-breadcrumb-link {
+      color: ${theme.colorText};
+      font-weight: ${theme.fontWeightStrong};
+    }
+  `}
+`;
+
+const ItemLabel = styled.span<{ clickable?: boolean }>`
+  display: inline-flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+  ${({ clickable, theme }) =>
+    clickable &&
+    `
+      cursor: pointer;
+
+      &:hover {
+        color: ${theme.colorPrimary};
+      }
+    `}
+`;
+
+const DEFAULT_SEPARATOR = '>';
+
+export function FolderBreadcrumb({
+  items,
+  separator = DEFAULT_SEPARATOR,
+  className,
+}: FolderBreadcrumbProps) {
+  const antdItems = items.map((item, index) => {
+    const isCurrent = index === items.length - 1;
+    const Icon = isCurrent ? FolderOpenIcon : FolderIcon;
+    const clickable = !isCurrent && Boolean(item.onClick || item.href);
+    return {
+      key: item.key,
+      href: item.href,
+      onClick: item.onClick ? () => item.onClick?.(item.key) : undefined,
+      title: (
+        <ItemLabel clickable={clickable}>
+          {!item.hideIcon && <Icon iconSize="m" aria-hidden />}
+          {item.title}
+        </ItemLabel>
+      ),
+    };
+  });
+
+  return (
+    <StyledBreadcrumb
+      className={className}
+      separator={separator}
+      items={antdItems}
+    />
+  );
+}
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.stories.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.stories.tsx
new file mode 100644
index 00000000000..083dfa145ce
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.stories.tsx
@@ -0,0 +1,292 @@
+/**
+ * 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 { action } from 'storybook/actions';
+import { FolderBreadcrumb } from './FolderBreadcrumb';
+import { FolderList } from './FolderList';
+import type {
+  Folder,
+  FolderAsset,
+  FolderBreadcrumbItem,
+  FolderListProps,
+} from './types';
+
+const sampleFolders: Folder[] = [
+  {
+    key: 'sales',
+    title: 'Sales',
+    assets: [
+      { key: 'd1', name: 'Revenue Overview', type: 'dashboard' },
+      { key: 'd2', name: 'Pipeline Health', type: 'dashboard' },
+      { key: 'ds1', name: 'opportunities', type: 'dataset' },
+    ],
+  },
+  {
+    key: 'marketing',
+    title: 'Marketing',
+    assets: [
+      { key: 'd3', name: 'Campaign Performance', type: 'dashboard' },
+      { key: 'c1', name: 'Spend by Channel', type: 'chart' },
+      { key: 'ds2', name: 'web_events', type: 'dataset' },
+      { key: 'ds3', name: 'ad_spend', type: 'dataset' },
+    ],
+  },
+  {
+    key: 'product',
+    title: 'Product',
+    assets: [{ key: 'c2', name: 'Daily Active Users', type: 'chart' }],
+  },
+];
+
+/**
+ * A nested hierarchy for the drill-down story: top-level folders, each holding
+ * dashboards, each dashboard holding its charts.
+ */
+const dashboardsByFolder: {
+  key: string;
+  title: string;
+  dashboards: { key: string; title: string; charts: FolderAsset[] }[];
+}[] = [
+  {
+    key: 'sales',
+    title: 'Sales',
+    dashboards: [
+      {
+        key: 'revenue',
+        title: 'Revenue Overview',
+        charts: [
+          { key: 'mrr', name: 'MRR by month', type: 'chart' },
+          { key: 'region', name: 'Revenue by region', type: 'chart' },
+        ],
+      },
+      {
+        key: 'pipeline',
+        title: 'Pipeline Health',
+        charts: [{ key: 'opps', name: 'Open opportunities', type: 'chart' }],
+      },
+    ],
+  },
+  {
+    key: 'marketing',
+    title: 'Marketing',
+    dashboards: [
+      {
+        key: 'campaigns',
+        title: 'Campaign Performance',
+        charts: [
+          { key: 'spend', name: 'Spend by channel', type: 'chart' },
+          { key: 'conversions', name: 'Conversions', type: 'chart' },
+        ],
+      },
+    ],
+  },
+];
+
+export default {
+  title: 'Components/Folders/FolderList',
+  component: FolderList,
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'A list of folders. A row whose `type` is listed in `expandableTypes` ' +
+          'is a Collapse panel that expands to reveal its assets (or any custom ' +
+          'body supplied via `renderExpandedContent`). Any other row is a single, ' +
+          'non-collapsible entry that calls `onFolderClick` instead — e.g. to ' +
+          'navigate into its own page. With the default empty `expandableTypes`, ' +
+          'nothing expands.',
+      },
+    },
+  },
+};
+
+export const InteractiveFolderList = (args: FolderListProps) => (
+  <FolderList {...args} />
+);
+
+InteractiveFolderList.args = {
+  folders: sampleFolders,
+  expandableTypes: ['folder'],
+  defaultActiveKeys: ['sales'],
+  accordion: false,
+  showCount: true,
+  onAssetClick: action('asset-click'),
+  onFolderClick: action('folder-click'),
+};
+
+InteractiveFolderList.argTypes = {
+  expandableTypes: {
+    description:
+      'Entry types that expand in place. Types left out render as a single ' +
+      'row that fires onFolderClick instead.',
+    control: 'check',
+    options: ['folder', 'dashboard', 'dataset', 'chart'],
+  },
+  accordion: {
+    description: 'Only allow a single folder to be open at a time.',
+    control: 'boolean',
+  },
+  showCount: {
+    description: 'Show the asset-count badge next to each folder title.',
+    control: 'boolean',
+  },
+  defaultActiveKeys: {
+    description: 'Keys of folders expanded on first render.',
+    control: false,
+  },
+  folders: {
+    description: 'Folders to render, each with its nested assets.',
+    control: false,
+  },
+};
+
+export const NavigableFolders = () => (
+  <div>
+    <FolderBreadcrumb
+      items={[
+        { key: 'root', title: 'All assets', onClick: action('navigate:root') },
+        { key: 'analytics', title: 'Analytics' },
+      ]}
+    />
+    <FolderList
+      folders={sampleFolders}
+      onFolderClick={action('navigate:folder')}
+    />
+  </div>
+);
+
+NavigableFolders.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        'With an empty `expandableTypes`, folders do not expand; clicking a row ' +
+        'fires `onFolderClick` to drill into it, tracked by the breadcrumb.',
+    },
+  },
+};
+
+export const CustomExpandedContent = () => (
+  <FolderList
+    folders={sampleFolders}
+    expandableTypes={['folder']}
+    defaultActiveKeys={['sales']}
+    renderExpandedContent={folder => (
+      <div style={{ padding: 12 }}>
+        Custom body for <strong>{String(folder.title)}</strong> —{' '}
+        {folder.assets.length} assets
+      </div>
+    )}
+  />
+);
+
+CustomExpandedContent.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        '`renderExpandedContent` replaces the default asset list with arbitrary ' +
+        'content, e.g. a preview for an expandable dashboard entry.',
+    },
+  },
+};
+
+export const DrillIntoExpandableDashboards = () => {
+  const [openFolder, setOpenFolder] = useState<string | null>(null);
+  const folder = dashboardsByFolder.find(entry => entry.key === openFolder);
+
+  const breadcrumbItems: FolderBreadcrumbItem[] = [
+    { key: 'root', title: 'All assets', onClick: () => setOpenFolder(null) },
+    ...(folder ? [{ key: folder.key, title: folder.title }] : []),
+  ];
+
+  // At the root we list folders, which navigate on click and extend the
+  // breadcrumb. Inside a folder we list its dashboards — the only type in
+  // `expandableTypes` — so each expands in place to reveal its charts.
+  const folders: Folder[] = folder
+    ? folder.dashboards.map((dashboard): Folder => ({
+        key: dashboard.key,
+        title: dashboard.title,
+        type: 'dashboard',
+        assets: dashboard.charts,
+      }))
+    : dashboardsByFolder.map((entry): Folder => ({
+        key: entry.key,
+        title: entry.title,
+        type: 'folder',
+        assets: entry.dashboards.map((dashboard): FolderAsset => ({
+          key: dashboard.key,
+          name: dashboard.title,
+          type: 'dashboard',
+        })),
+      }));
+
+  return (
+    <div>
+      <FolderBreadcrumb items={breadcrumbItems} />
+      <FolderList
+        folders={folders}
+        expandableTypes={['dashboard']}
+        onFolderClick={({ key }) => setOpenFolder(key)}
+        onAssetClick={action('open-chart')}
+      />
+    </div>
+  );
+};
+
+DrillIntoExpandableDashboards.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        'Only the `dashboard` type is expandable. At the root, folders are not ' +
+        'expandable: clicking one drills in and extends the breadcrumb, whose ' +
+        'segments are clickable to navigate back. Inside a folder, each ' +
+        'dashboard expands in place to reveal its charts.',
+    },
+  },
+};
+
+export const WithBreadcrumb = () => (
+  <div>
+    <FolderBreadcrumb
+      items={[
+        { key: 'root', title: 'All assets', onClick: action('navigate:root') },
+        { key: 'analytics', title: 'Analytics' },
+      ]}
+    />
+    <FolderList
+      folders={sampleFolders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales', 'marketing']}
+      onAssetClick={action('asset-click')}
+    />
+  </div>
+);
+
+WithBreadcrumb.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        'Breadcrumb and folder list composed together, as they appear on an ' +
+        'analytics listing page.',
+    },
+  },
+};
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.test.tsx
new file mode 100644
index 00000000000..f681016fa63
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.test.tsx
@@ -0,0 +1,200 @@
+/**
+ * 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 { render, screen, userEvent } from '@superset-ui/core/spec';
+import { FolderList } from './FolderList';
+import type { Folder } from './types';
+
+const folders: Folder[] = [
+  {
+    key: 'sales',
+    title: 'Sales',
+    assets: [
+      { key: 'd1', name: 'Revenue Overview', type: 'dashboard' },
+      { key: 'ds1', name: 'opportunities', type: 'dataset' },
+    ],
+  },
+  {
+    key: 'marketing',
+    title: 'Marketing',
+    assets: [{ key: 'c1', name: 'Spend by Channel', type: 'chart' }],
+  },
+];
+
+test('renders a header with the title and asset count for each folder', () => {
+  render(<FolderList folders={folders} expandableTypes={['folder']} />);
+
+  expect(screen.getByRole('button', { name: /Sales/ })).toHaveTextContent('2');
+  expect(screen.getByRole('button', { name: /Marketing/ })).toHaveTextContent(
+    '1',
+  );
+});
+
+test('omits the asset count when showCount is false', () => {
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      showCount={false}
+    />,
+  );
+
+  expect(screen.getByRole('button', { name: /Sales/ })).not.toHaveTextContent(
+    '2',
+  );
+  expect(
+    screen.getByRole('button', { name: /Marketing/ }),
+  ).not.toHaveTextContent('1');
+});
+
+test('does not expand folders whose type is not in expandableTypes', () => {
+  render(<FolderList folders={folders} />);
+
+  // Headers still render, but there is no toggle and the assets stay hidden.
+  expect(screen.getByText('Sales')).toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', { name: /Sales/ }),
+  ).not.toBeInTheDocument();
+  expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
+});
+
+test('keeps folder contents collapsed by default', () => {
+  render(<FolderList folders={folders} expandableTypes={['folder']} />);
+
+  expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
+  expect(screen.queryByText('Spend by Channel')).not.toBeInTheDocument();
+});
+
+test('expands the folders named in defaultActiveKeys', () => {
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+    />,
+  );
+
+  expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
+  expect(screen.getByText('opportunities')).toBeInTheDocument();
+  expect(screen.queryByText('Spend by Channel')).not.toBeInTheDocument();
+});
+
+test('reveals folder assets when its header is clicked', async () => {
+  render(<FolderList folders={folders} expandableTypes={['folder']} />);
+
+  await userEvent.click(screen.getByRole('button', { name: /Marketing/ }));
+
+  expect(screen.getByText('Spend by Channel')).toBeInTheDocument();
+});
+
+test('calls onAssetClick with the clicked asset', async () => {
+  const onAssetClick = jest.fn();
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+      onAssetClick={onAssetClick}
+    />,
+  );
+
+  await userEvent.click(screen.getByText('Revenue Overview'));
+
+  expect(onAssetClick).toHaveBeenCalledWith(folders[0].assets[0]);
+});
+
+test('accordion mode hides the open folder when another is opened', async () => {
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+      accordion
+    />,
+  );
+
+  expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
+
+  // In accordion mode antd renders the headers as tabs rather than buttons.
+  await userEvent.click(screen.getByRole('tab', { name: /Marketing/ }));
+
+  expect(screen.getByText('Spend by Channel')).toBeInTheDocument();
+  // antd keeps collapsed content mounted but flags it hidden.
+  expect(
+    screen.getByText('Revenue Overview').closest('.ant-collapse-content'),
+  ).toHaveClass('ant-collapse-content-hidden');
+});
+
+test('calls onFolderClick when a non-expandable row is clicked', async () => {
+  const onFolderClick = jest.fn();
+  render(<FolderList folders={folders} onFolderClick={onFolderClick} />);
+
+  await userEvent.click(screen.getByRole('button', { name: /Sales/ }));
+
+  expect(onFolderClick).toHaveBeenCalledWith(folders[0]);
+  // Clicking navigates rather than expanding, so the assets stay hidden.
+  expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
+});
+
+test('renders custom expanded content instead of the asset list', () => {
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+      renderExpandedContent={folder => <div>preview of {folder.key}</div>}
+    />,
+  );
+
+  expect(screen.getByText('preview of sales')).toBeInTheDocument();
+  expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
+});
+
+test('expands only the types listed in expandableTypes', async () => {
+  const mixed: Folder[] = [
+    {
+      key: 'sales',
+      title: 'Sales',
+      type: 'folder',
+      assets: [{ key: 'd1', name: 'Revenue Overview', type: 'dashboard' }],
+    },
+    {
+      key: 'kpis',
+      title: 'KPIs',
+      type: 'dashboard',
+      assets: [{ key: 'c1', name: 'Active Users', type: 'chart' }],
+    },
+  ];
+  const onFolderClick = jest.fn();
+  render(
+    <FolderList
+      folders={mixed}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+      onFolderClick={onFolderClick}
+    />,
+  );
+
+  // The folder expands in place; the dashboard row navigates on click.
+  expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
+  expect(screen.queryByText('Active Users')).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByRole('button', { name: /KPIs/ }));
+
+  expect(onFolderClick).toHaveBeenCalledWith(mixed[1]);
+});
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.tsx
new file mode 100644
index 00000000000..3f73f515fe4
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/FolderList.tsx
@@ -0,0 +1,209 @@
+/**
+ * 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 { styled } from '@apache-superset/core/theme';
+import { Collapse, type CollapseProps } from '../Collapse';
+import { FolderIcon, assetTypeIcons } from './assetIcons';
+import type { Folder, FolderItemType, FolderListProps } from './types';
+
+type FolderCollapseItem = NonNullable<CollapseProps['items']>[number];
+
+// Keep the header text at its content width and drop antd's auto end-margin so
+// the end-positioned caret sits immediately after the folder label rather than
+// at the far right of the row. The doubled root (&&) raises specificity above
+// antd's `flex: auto` / `margin-inline-end: auto` rules on the header text.
+const StyledFolderCollapse = styled(Collapse)`
+  ${({ theme }) => `
+    && .ant-collapse-item > .ant-collapse-header > .ant-collapse-header-text {
+      flex: none;
+      margin-inline-end: 0;
+    }
+
+    .ant-collapse-expand-icon {
+      margin-inline-start: ${theme.sizeUnit}px;
+    }
+
+    /* Non-expandable rows have no caret, so let the label fill the row. */
+    && .folder-row--static > .ant-collapse-header > .ant-collapse-header-text {
+      flex: auto;
+    }
+
+    /* A non-expandable row that navigates on click reads as one target. */
+    && .folder-row--clickable > .ant-collapse-header {
+      cursor: pointer;
+    }
+
+    && .folder-row--clickable > .ant-collapse-header:hover {
+      background: ${theme.colorFillTertiary};
+    }
+  `}
+`;
+
+const FolderLabel = styled.span`
+  ${({ theme }) => `
+    display: inline-flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    font-weight: ${theme.fontWeightStrong};
+    color: ${theme.colorText};
+  `}
+`;
+
+const AssetCount = styled.span`
+  ${({ theme }) => `
+    color: ${theme.colorTextTertiary};
+    font-weight: normal;
+    font-size: ${theme.fontSizeSM}px;
+  `}
+`;
+
+const AssetList = styled.ul`
+  ${({ theme }) => `
+    list-style: none;
+    margin: 0;
+    padding: 0;
+
+    li + li {
+      border-top: 1px solid ${theme.colorBorderSecondary};
+    }
+  `}
+`;
+
+const AssetRow = styled.button`
+  ${({ theme }) => `
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    width: 100%;
+    padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit}px;
+    border: 0;
+    background: transparent;
+    color: ${theme.colorText};
+    font-size: ${theme.fontSize}px;
+    text-align: left;
+    cursor: pointer;
+
+    &:hover {
+      background: ${theme.colorFillTertiary};
+    }
+  `}
+`;
+
+// Strips the native button chrome so a navigable folder row matches the look
+// of an expandable header while staying a real, focusable button.
+const FolderRowButton = styled.button`
+  display: flex;
+  align-items: center;
+  width: 100%;
+  margin: 0;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+`;
+
+const iconFor = (type: FolderItemType) =>
+  type === 'folder' ? FolderIcon : assetTypeIcons[type];
+
+export function FolderList({
+  folders,
+  expandableTypes = [],
+  defaultActiveKeys,
+  accordion = false,
+  showCount = true,
+  onAssetClick,
+  onFolderClick,
+  renderExpandedContent,
+  className,
+}: FolderListProps) {
+  const isExpandable = (folder: Folder) =>
+    expandableTypes.includes(folder.type ?? 'folder');
+
+  const defaultAssetList = (folder: Folder) => (
+    <AssetList>
+      {folder.assets.map(asset => {
+        const AssetIcon = assetTypeIcons[asset.type];
+        return (
+          <li key={asset.key}>
+            <AssetRow type="button" onClick={() => onAssetClick?.(asset)}>
+              <AssetIcon iconSize="m" aria-hidden />
+              {asset.name}
+            </AssetRow>
+          </li>
+        );
+      })}
+    </AssetList>
+  );
+
+  const items = folders.map((folder): FolderCollapseItem => {
+    const Icon = iconFor(folder.type ?? 'folder');
+    const label = (
+      <FolderLabel>
+        <Icon iconSize="m" aria-hidden />
+        {folder.title}
+        {showCount && <AssetCount>{folder.assets.length}</AssetCount>}
+      </FolderLabel>
+    );
+
+    if (isExpandable(folder)) {
+      return {
+        key: folder.key,
+        label,
+        children: renderExpandedContent?.(folder) ?? defaultAssetList(folder),
+      };
+    }
+
+    // A non-expandable row carries no caret and never toggles (`collapsible:
+    // 'icon'` + `showArrow: false`). With a handler the label becomes a button
+    // that navigates; without one the row is an inert header.
+    return {
+      key: folder.key,
+      showArrow: false,
+      collapsible: 'icon',
+      className: onFolderClick
+        ? 'folder-row--static folder-row--clickable'
+        : 'folder-row--static',
+      label: onFolderClick ? (
+        <FolderRowButton type="button" onClick={() => onFolderClick(folder)}>
+          {label}
+        </FolderRowButton>
+      ) : (
+        label
+      ),
+    };
+  });
+
+  // antd would still mark a key active even if its row can't expand; keep the
+  // initial open set to rows that actually have a body.
+  const activeKeys = defaultActiveKeys?.filter(key =>
+    folders.some(folder => folder.key === key && isExpandable(folder)),
+  );
+
+  return (
+    <StyledFolderCollapse
+      className={className}
+      items={items}
+      accordion={accordion}
+      expandIconPosition="end"
+      defaultActiveKey={activeKeys}
+    />
+  );
+}
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/assetIcons.tsx b/superset-frontend/packages/superset-ui-core/src/components/Folders/assetIcons.tsx
new file mode 100644
index 00000000000..891a4e5f8be
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/assetIcons.tsx
@@ -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 type { FC } from 'react';
+import { Icons } from '../Icons';
+import type { IconType } from '../Icons/types';
+import type { FolderAssetType } from './types';
+
+/** Icon for a collapsed folder. */
+export const FolderIcon = Icons.FolderOutlined;
+
+/** Icon for an expanded folder. */
+export const FolderOpenIcon = Icons.FolderOpenOutlined;
+
+/** Maps each asset type to its semantic icon. */
+export const assetTypeIcons: Record<FolderAssetType, FC<IconType>> = {
+  dashboard: Icons.DashboardOutlined,
+  dataset: Icons.TableOutlined,
+  chart: Icons.AreaChartOutlined,
+};
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/index.ts b/superset-frontend/packages/superset-ui-core/src/components/Folders/index.ts
new file mode 100644
index 00000000000..1b8e1bab787
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/index.ts
@@ -0,0 +1,30 @@
+/**
+ * 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 { FolderBreadcrumb } from './FolderBreadcrumb';
+export { FolderList } from './FolderList';
+export { FolderIcon, FolderOpenIcon, assetTypeIcons } from './assetIcons';
+export type {
+  FolderBreadcrumbItem,
+  FolderBreadcrumbProps,
+  Folder,
+  FolderAsset,
+  FolderAssetType,
+  FolderItemType,
+  FolderListProps,
+} from './types';
diff --git a/superset-frontend/packages/superset-ui-core/src/components/Folders/types.ts b/superset-frontend/packages/superset-ui-core/src/components/Folders/types.ts
new file mode 100644
index 00000000000..09b85e91e2e
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/components/Folders/types.ts
@@ -0,0 +1,106 @@
+/**
+ * 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 type { ReactNode } from 'react';
+
+/**
+ * The kinds of leaf assets that can live inside a folder. Each maps to a
+ * dedicated icon in `assetTypeIcons`.
+ */
+export type FolderAssetType = 'dashboard' | 'dataset' | 'chart';
+
+/**
+ * The kind of a top-level entry in a {@link FolderList}. A folder groups
+ * assets; the asset kinds may also appear as top-level entries in their own
+ * right. The type drives the row's icon and, via `expandableTypes`, whether
+ * the row expands in place or navigates on click.
+ */
+export type FolderItemType = 'folder' | FolderAssetType;
+
+/** A single navigable segment of the folder path. */
+export interface FolderBreadcrumbItem {
+  /** Stable identifier, passed back to `onClick`. */
+  key: string;
+  /** Label rendered for the segment. */
+  title: ReactNode;
+  /** Marks the segment as a link; called with the item `key` when clicked. */
+  onClick?: (key: string) => void;
+  /** Optional href when the segment should navigate via an anchor. */
+  href?: string;
+  /** Hide the leading folder icon for this segment (defaults to shown). */
+  hideIcon?: boolean;
+}
+
+export interface FolderBreadcrumbProps {
+  /** Ordered path from root to current folder; the last item is the current location. */
+  items: FolderBreadcrumbItem[];
+  /** Separator between segments. Defaults to a `>` chevron. */
+  separator?: ReactNode;
+  className?: string;
+}
+
+/** A leaf asset (dashboard, dataset or chart) shown inside a folder. */
+export interface FolderAsset {
+  key: string;
+  name: ReactNode;
+  type: FolderAssetType;
+}
+
+/** A top-level entry in a {@link FolderList}, typically a folder of assets. */
+export interface Folder {
+  key: string;
+  title: ReactNode;
+  /**
+   * Entry kind; controls the icon and whether the row is expandable (see
+   * {@link FolderListProps.expandableTypes}). Defaults to `'folder'`.
+   */
+  type?: FolderItemType;
+  assets: FolderAsset[];
+}
+
+export interface FolderListProps {
+  folders: Folder[];
+  /**
+   * Entry types that expand in place to reveal their contents. A row whose
+   * type is not listed renders as a single, non-collapsible row that calls
+   * `onFolderClick` instead (e.g. to navigate into it). Defaults to `[]`, so
+   * nothing is expandable unless a consumer opts a type in.
+   */
+  expandableTypes?: FolderItemType[];
+  /** Keys of folders expanded on first render (expandable types only). */
+  defaultActiveKeys?: string[];
+  /** Only allow one folder open at a time. */
+  accordion?: boolean;
+  /** Show the asset-count badge next to each folder title. Defaults to `true`. */
+  showCount?: boolean;
+  /** Invoked when an asset row inside an expanded folder is clicked. */
+  onAssetClick?: (asset: FolderAsset) => void;
+  /**
+   * Invoked when a non-expandable row is clicked, e.g. to open the folder or
+   * asset's own page. Without a handler the row renders as an inert header.
+   */
+  onFolderClick?: (folder: Folder) => void;
+  /**
+   * Overrides the body shown when an expandable row is open. Receives the
+   * entry and may branch on `folder.type`; return `undefined` to fall back to
+   * the default asset list. Use it to render, say, a chart preview for an
+   * expandable `dashboard` entry instead of a list of assets.
+   */
+  renderExpandedContent?: (folder: Folder) => ReactNode;
+  className?: string;
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
new file mode 100644
index 00000000000..90725fe4046
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
@@ -0,0 +1,141 @@
+/**
+ * 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 { useCallback, useEffect, useState } from 'react';
+import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { Checkbox, Input } from '@superset-ui/core/components';
+import { StandardModal } from 'src/components/Modal';
+import { ModalFormField } from 'src/components/Modal/ModalFormField';
+
+import { ModalContent } from './styles';
+
+interface CreateFolderModalProps {
+  show: boolean;
+  /** UUID of the folder to create this one under, or null for the root. */
+  parentFolderUuid: string | null;
+  onHide: () => void;
+  onSuccess: () => void;
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}
+
+export default function CreateFolderModal({
+  show,
+  parentFolderUuid,
+  onHide,
+  onSuccess,
+  addDangerToast,
+  addSuccessToast,
+}: CreateFolderModalProps) {
+  const [name, setName] = useState('');
+  const [description, setDescription] = useState('');
+  const [nameError, setNameError] = useState('');
+  const [isPrivate, setIsPrivate] = useState(false);
+  const [saving, setSaving] = useState(false);
+
+  useEffect(() => {
+    if (show) {
+      setName('');
+      setDescription('');
+      setNameError('');
+    }
+  }, [show]);
+
+  const handleSave = useCallback(async () => {
+    if (!name.trim()) {
+      setNameError(t('Folder name is required'));
+      return;
+    }
+    setNameError('');
+    setSaving(true);
+    try {
+      await SupersetClient.post({
+        endpoint: '/api/v1/folders/',
+        jsonPayload: {
+          name: name.trim(),
+          description: description.trim() || null,
+          folder_type: 'analytics',
+          ...(parentFolderUuid ? { parent_uuid: parentFolderUuid } : {}),
+          ...(isPrivate ? { is_private: true } : {}),
+        },
+      });
+      addSuccessToast(t('Folder "%s" created', name.trim()));
+      onSuccess();
+      onHide();
+    } catch (err: any) {
+      const { error } = await getClientErrorObject(err);
+      const isDuplicate = error?.toLowerCase().includes('already exists');
+      addDangerToast(isDuplicate ? error : t('Error creating folder'));
+    } finally {
+      setSaving(false);
+    }
+  }, [
+    name,
+    description,
+    parentFolderUuid,
+    addSuccessToast,
+    addDangerToast,
+    onSuccess,
+    onHide,
+  ]);
+
+  return (
+    <StandardModal
+      title={t('Create folder')}
+      show={show}
+      onHide={onHide}
+      onSave={handleSave}
+      saveText={t('Create')}
+      saveDisabled={!name.trim()}
+      saveLoading={saving}
+    >
+      <ModalContent>
+        <ModalFormField label={t('Name')} required error={nameError}>
+          <Input
+            placeholder={t('Folder name')}
+            value={name}
+            onChange={e => {
+              setName(e.target.value);
+              if (nameError) setNameError('');
+            }}
+            onPressEnter={handleSave}
+            status={nameError ? 'error' : undefined}
+            autoFocus
+          />
+        </ModalFormField>
+        <ModalFormField label={t('Description')}>
+          <Input.TextArea
+            placeholder={t('Optional description')}
+            value={description}
+            onChange={e => setDescription(e.target.value)}
+            rows={3}
+          />
+        </ModalFormField>
+        {!parentFolderUuid && (
+          <Checkbox
+            checked={isPrivate}
+            onChange={e => setIsPrivate(e.target.checked)}
+          >
+            {t('Private folder')}
+          </Checkbox>
+        )}
+      </ModalContent>
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/DashboardCharts.tsx b/superset-frontend/src/pages/AnalyticsList/DashboardCharts.tsx
new file mode 100644
index 00000000000..7849117d231
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/DashboardCharts.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 { useEffect, useRef, useState } from 'react';
+import {
+  isFeatureEnabled,
+  FeatureFlag,
+  getChartMetadataRegistry,
+} from '@superset-ui/core';
+import { styled, css, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { EmptyState, Skeleton } from '@superset-ui/core/components';
+import { ModifiedInfo, TagsList, TagType } from 'src/components';
+import { SubjectPile } from 'src/features/subjects/SubjectPile';
+import { TagTypeEnum } from 'src/components/Tag/TagType';
+import { Icons } from '@superset-ui/core/components/Icons';
+
+import type { ContentItem } from './types';
+
+const chartRegistry = getChartMetadataRegistry();
+
+interface DashboardChartsProps {
+  dashboardId: number;
+  charts: ContentItem[];
+  loading: boolean;
+  onRequest: (dashboardId: number) => void;
+}
+
+const Wrapper = styled.div`
+  max-height: 240px;
+  overflow-y: auto;
+  /* Pull grid to the td edges so columns align with table headers */
+  margin: 0 -16px;
+`;
+
+const SubHeader = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit}px;
+    padding: ${theme.sizeUnit * 3}px 16px;
+    font-size: ${theme.fontSizeSM}px;
+    color: ${theme.colorTextSecondary};
+  `}
+`;
+
+const Row = styled.div`
+  ${({ theme }) => css`
+    display: grid;
+    grid-template-columns: 2fr 0.8fr 1fr 1fr 1fr 1fr 1fr;
+    align-items: center;
+    padding: ${theme.sizeUnit * 3}px 0;
+    font-size: ${theme.fontSize}px;
+    color: ${theme.colorText};
+
+    & + & {
+      border-top: 1px solid ${theme.colorBorderSecondary};
+    }
+
+    /* Match antd table cell padding so content aligns with headers */
+    > * {
+      padding: 0 16px;
+    }
+  `}
+`;
+
+const NameCell = styled.span`
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  padding-left: 16px !important;
+
+  a {
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+`;
+
+const Cell = styled.span`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+export default function DashboardCharts({
+  dashboardId,
+  charts,
+  loading,
+  onRequest,
+}: DashboardChartsProps) {
+  const theme = useTheme();
+  const wrapperRef = useRef<HTMLDivElement>(null);
+  const [colWidths, setColWidths] = useState<string | undefined>();
+
+  useEffect(() => {
+    onRequest(dashboardId);
+  }, [dashboardId, onRequest]);
+
+  // Read parent table header widths to align expanded rows with table columns.
+  // Re-read on resize so the grid stays aligned when the window changes size.
+  useEffect(() => {
+    const readWidths = () => {
+      const el = wrapperRef.current;
+      if (!el) return;
+      const table = el.closest('.ant-table-wrapper')?.querySelector('thead tr');
+      if (!table) return;
+      const ths = Array.from(
+        table.querySelectorAll<HTMLTableCellElement>('th'),
+      );
+      const dataHeaders = ths.filter(th => th.offsetWidth > 40);
+      if (dataHeaders.length > 0) {
+        setColWidths(dataHeaders.map(th => `${th.offsetWidth}px`).join(' '));
+      }
+    };
+    readWidths();
+    window.addEventListener('resize', readWidths);
+    return () => window.removeEventListener('resize', readWidths);
+  }, [charts.length]);
+
+  if (loading) {
+    return <Skeleton active />;
+  }
+
+  if (charts.length === 0) {
+    return (
+      <EmptyState
+        size="small"
+        description={t('This dashboard has no charts')}
+      />
+    );
+  }
+
+  const showTags = isFeatureEnabled(FeatureFlag.TaggingSystem);
+
+  return (
+    <Wrapper ref={wrapperRef}>
+      <SubHeader>
+        <Icons.LineChartOutlined iconSize="m" />
+        {t('Charts in the dashboard (%s)', charts.length)}
+      </SubHeader>
+      {charts.map(chart => (
+        <Row
+          key={chart.id}
+          style={colWidths ? { gridTemplateColumns: colWidths } : undefined}
+        >
+          <NameCell>
+            <Icons.LineChartOutlined
+              iconSize="m"
+              css={{ color: theme.colorErrorBorderHover, flexShrink: 0 }}
+            />
+            {chart.url ? <a href={chart.url}>{chart.name}</a> : chart.name}
+          </NameCell>
+          <Cell />
+          <Cell>
+            {chart.viz_type
+              ? (chartRegistry.get(chart.viz_type)?.name ?? chart.viz_type)
+              : null}
+          </Cell>
+          <Cell>
+            {chart.datasource_name && chart.datasource_url ? (
+              <a href={chart.datasource_url}>{chart.datasource_name}</a>
+            ) : (
+              (chart.datasource_name ?? null)
+            )}
+          </Cell>
+          <Cell>
+            {showTags && chart.tags?.length ? (
+              <TagsList
+                tags={chart.tags.filter(
+                  (tag: TagType) =>
+                    tag.type === 'TagTypes.custom' ||
+                    tag.type === TagTypeEnum.Custom,
+                )}
+                maxTags={3}
+              />
+            ) : null}
+          </Cell>
+          <Cell>
+            <SubjectPile subjects={chart.editors || []} />
+          </Cell>
+          <Cell>
+            <SubjectPile subjects={chart.viewers || []} />
+          </Cell>
+          <Cell>
+            {chart.changed_on_humanized ? (
+              <ModifiedInfo
+                date={chart.changed_on_humanized}
+                user={chart.changed_by ?? undefined}
+              />
+            ) : (
+              '-'
+            )}
+          </Cell>
+        </Row>
+      ))}
+    </Wrapper>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx
new file mode 100644
index 00000000000..525b14a3181
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx
@@ -0,0 +1,127 @@
+/**
+ * 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 { useCallback, useEffect, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import {
+  Checkbox,
+  DeleteModal,
+  Flex,
+  Typography,
+  type CheckboxChangeEvent,
+} from '@superset-ui/core/components';
+
+interface DeleteFolderModalProps {
+  folder: {
+    uuid: string;
+    name: string;
+    asset_count: number;
+    children_count: number;
+  };
+  show: boolean;
+  /** When true, suppresses the per-folder success toast (used in bulk-delete flows). */
+  silent?: boolean;
+  onHide: () => void;
+  onSuccess: () => void;
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}
+
+export default function DeleteFolderModal({
+  folder,
+  show,
+  silent = false,
+  onHide,
+  onSuccess,
+  addDangerToast,
+  addSuccessToast,
+}: DeleteFolderModalProps) {
+  const theme = useTheme();
+  const [deleteItems, setDeleteItems] = useState(false);
+
+  // Reset checkbox when folder changes (e.g. queue processing)
+  useEffect(() => {
+    setDeleteItems(false);
+  }, [folder.uuid]);
+
+  const isEmpty = folder.asset_count === 0 && folder.children_count === 0;
+
+  const handleConfirm = useCallback(async () => {
+    try {
+      await SupersetClient.delete({
+        endpoint: `/api/v1/folders/${folder.uuid}${deleteItems ? '?archive_items=true' : ''}`,
+      });
+      if (!silent) {
+        addSuccessToast(t('Folder "%s" deleted', folder.name));
+      }
+      onSuccess();
+      onHide();
+    } catch {
+      addDangerToast(t('Error deleting folder'));
+    }
+  }, [
+    folder,
+    deleteItems,
+    silent,
+    addSuccessToast,
+    addDangerToast,
+    onSuccess,
+    onHide,
+  ]);
+
+  const handleHide = useCallback(() => {
+    setDeleteItems(false);
+    onHide();
+  }, [onHide]);
+
+  return (
+    <DeleteModal
+      open={show}
+      title={t('Delete folder "%s"', folder.name)}
+      onHide={handleHide}
+      onConfirm={handleConfirm}
+      description={
+        <Flex
+          vertical
+          gap={theme.sizeUnit * 3}
+          style={{ marginTop: -theme.sizeUnit * 2 }}
+        >
+          <Typography.Paragraph style={{ margin: 0 }}>
+            {isEmpty
+              ? t("This folder doesn't contain any items.")
+              : t(
+                  'This folder contains items. Items will be moved to the parent folder or Analytics page unless you choose to delete everything together.',
+                )}
+          </Typography.Paragraph>
+          {!isEmpty && (
+            <Checkbox
+              checked={deleteItems}
+              onChange={(e: CheckboxChangeEvent) =>
+                setDeleteItems(e.target.checked)
+              }
+            >
+              {t('Delete all items in this folder')}
+            </Checkbox>
+          )}
+        </Flex>
+      }
+    />
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/EditFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/EditFolderModal.tsx
new file mode 100644
index 00000000000..5aadf2c0903
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/EditFolderModal.tsx
@@ -0,0 +1,153 @@
+/**
+ * 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 { useCallback, useEffect, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { Input } from '@superset-ui/core/components';
+import { StandardModal } from 'src/components/Modal';
+import { ModalFormField } from 'src/components/Modal/ModalFormField';
+
+import { ModalContent } from './styles';
+
+interface RenameFolderModalProps {
+  folderUuid: string;
+  currentName: string;
+  currentDescription?: string | null;
+  show: boolean;
+  onHide: () => void;
+  onSuccess: () => void;
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}
+
+export default function RenameFolderModal({
+  folderUuid,
+  currentName,
+  currentDescription = '',
+  show,
+  onHide,
+  onSuccess,
+  addDangerToast,
+  addSuccessToast,
+}: RenameFolderModalProps) {
+  const [name, setName] = useState(currentName);
+  const [description, setDescription] = useState(currentDescription || '');
+  const [nameError, setNameError] = useState('');
+  const [saving, setSaving] = useState(false);
+
+  useEffect(() => {
+    if (show) {
+      setName(currentName);
+      setDescription(currentDescription || '');
+      setNameError('');
+    }
+  }, [show, currentName, currentDescription]);
+
+  const hasChanges =
+    name.trim() !== currentName ||
+    (description.trim() || null) !== (currentDescription || null);
+
+  const handleSave = useCallback(async () => {
+    const trimmed = name.trim();
+    if (!trimmed) {
+      setNameError(t('Folder name is required'));
+      return;
+    }
+    if (!hasChanges) {
+      onHide();
+      return;
+    }
+    setNameError('');
+    setSaving(true);
+    try {
+      await SupersetClient.put({
+        endpoint: `/api/v1/folders/${folderUuid}`,
+        jsonPayload: {
+          name: trimmed,
+          description: description.trim() || null,
+        },
+      });
+      addSuccessToast(t('Folder updated'));
+      onSuccess();
+      onHide();
+    } catch (err: any) {
+      const message =
+        err?.json?.message?.name?.[0] ||
+        err?.json?.message ||
+        t('Error updating folder');
+      if (
+        typeof message === 'string' &&
+        message.toLowerCase().includes('already exists')
+      ) {
+        setNameError(message);
+      } else {
+        addDangerToast(
+          typeof message === 'string' ? message : t('Error updating folder'),
+        );
+      }
+    } finally {
+      setSaving(false);
+    }
+  }, [
+    name,
+    description,
+    hasChanges,
+    folderUuid,
+    addSuccessToast,
+    addDangerToast,
+    onSuccess,
+    onHide,
+  ]);
+
+  return (
+    <StandardModal
+      title={t('Edit folder')}
+      show={show}
+      onHide={onHide}
+      onSave={handleSave}
+      saveText={t('Save')}
+      saveDisabled={!name.trim() || !hasChanges}
+      saveLoading={saving}
+    >
+      <ModalContent>
+        <ModalFormField label={t('Name')} required error={nameError}>
+          <Input
+            placeholder={t('Folder name')}
+            value={name}
+            onChange={e => {
+              setName(e.target.value);
+              if (nameError) setNameError('');
+            }}
+            onPressEnter={handleSave}
+            status={nameError ? 'error' : undefined}
+            autoFocus
+          />
+        </ModalFormField>
+        <ModalFormField label={t('Description')}>
+          <Input.TextArea
+            placeholder={t('Optional description')}
+            value={description}
+            onChange={e => setDescription(e.target.value)}
+            rows={3}
+          />
+        </ModalFormField>
+      </ModalContent>
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
new file mode 100644
index 00000000000..06f56f62650
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
@@ -0,0 +1,438 @@
+/**
+ * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { styled, css, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Alert } from '@apache-superset/core/components';
+import {
+  AsyncSelect,
+  FormLabel,
+  Input,
+  Select,
+  Table,
+  TableSize,
+  type ColumnsType,
+  type SelectOptionsTypePage,
+  Tooltip,
+  Typography,
+  type SelectValue,
+} from '@superset-ui/core/components';
+import { StandardModal } from 'src/components/Modal';
+import { Icons } from '@superset-ui/core/components/Icons';
+
+type Permission = 'editor' | 'viewer' | 'admin';
+
+interface Subject {
+  user_id: number;
+  permission: Permission;
+  email?: string;
+  is_admin?: boolean;
+}
+
+interface LocalSubject {
+  key: string;
+  user_id: number;
+  permission: Permission;
+  label: string;
+  isCurrentUser: boolean;
+  isAdmin: boolean;
+}
+
+interface FolderPermissionsModalProps {
+  folderUuid: string;
+  folderName: string;
+  currentUserId: number;
+  isSubfolder?: boolean;
+  show: boolean;
+  onHide: () => void;
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}
+
+const PERMISSION_OPTIONS = [
+  { value: 'viewer', label: t('Viewer') },
+  { value: 'editor', label: t('Editor') },
+];
+
+const StyledFormLabel = styled(FormLabel)`
+  ${({ theme }) => css`
+    margin-bottom: ${theme.sizeUnit * 0.5}px;
+    font-size: ${theme.fontSizeSM}px;
+  `}
+`;
+
+const ModalContent = styled.div`
+  ${({ theme }) => css`
+    padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit * 3}px;
+  `}
+`;
+
+function getUserLabel(u: { email?: string | null }): string {
+  return u.email || t('N/A');
+}
+
+export default function FolderPermissionsModal({
+  folderUuid,
+  folderName,
+  currentUserId,
+  isSubfolder,
+  show,
+  onHide,
+  addDangerToast,
+  addSuccessToast,
+}: FolderPermissionsModalProps) {
+  const theme = useTheme();
+  const [serverSubjects, setServerSubjects] = useState<LocalSubject[]>([]);
+  const [localSubjects, setLocalSubjects] = useState<LocalSubject[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [memberSearch, setMemberSearch] = useState('');
+  const adminIdsRef = useRef<Set<number>>(new Set());
+
+  const fetchSubjects = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { json } = await SupersetClient.get({
+        endpoint: `/api/v1/folders/${folderUuid}/subjects`,
+      });
+      const subjects = (json.result as Subject[]) || [];
+      const enriched: LocalSubject[] = subjects.map(s => ({
+        key: String(s.user_id),
+        user_id: s.user_id,
+        permission: s.is_admin ? 'admin' : s.permission,
+        label: getUserLabel(s),
+        isCurrentUser: s.user_id === currentUserId,
+        isAdmin: !!s.is_admin,
+      }));
+      setServerSubjects(enriched);
+      setLocalSubjects(enriched);
+    } catch {
+      addDangerToast(t('Error loading permissions'));
+    } finally {
+      setLoading(false);
+    }
+  }, [folderUuid, currentUserId, addDangerToast]);
+
+  useEffect(() => {
+    if (show) {
+      fetchSubjects();
+      setMemberSearch('');
+    }
+  }, [show, fetchSubjects]);
+
+  const handleAddUser = useCallback(
+    (selected: SelectValue) => {
+      if (!selected || typeof selected !== 'object') return;
+      const item = selected as { value: number; label: string };
+      const userId = Number(item.value);
+      if (!userId || Number.isNaN(userId)) return;
+      const isAdmin = adminIdsRef.current.has(userId);
+      setLocalSubjects(prev => {
+        if (prev.some(s => s.user_id === userId)) return prev;
+        return [
+          ...prev,
+          {
+            key: String(userId),
+            user_id: userId,
+            permission: isAdmin
+              ? ('admin' as Permission)
+              : ('viewer' as Permission),
+            label: String(item.label),
+            isCurrentUser: userId === currentUserId,
+            isAdmin,
+          },
+        ];
+      });
+    },
+    [currentUserId],
+  );
+
+  const handleChangePermission = useCallback(
+    (userId: number, permission: Permission) => {
+      if (userId === currentUserId) return;
+      setLocalSubjects(prev =>
+        prev.map(s => (s.user_id === userId ? { ...s, permission } : s)),
+      );
+    },
+    [currentUserId],
+  );
+
+  const handleRemoveSubject = useCallback(
+    (userId: number) => {
+      if (userId === currentUserId) return;
+      setLocalSubjects(prev => prev.filter(s => s.user_id !== userId));
+    },
+    [currentUserId],
+  );
+
+  const handleSave = useCallback(async () => {
+    setSaving(true);
+    try {
+      const serverMap = new Map(
+        serverSubjects.map(s => [s.user_id, s.permission]),
+      );
+      const localMap = new Map(
+        localSubjects.map(s => [s.user_id, s.permission]),
+      );
+
+      const calls: Promise<unknown>[] = [];
+
+      for (const [userId, permission] of localMap) {
+        if (!serverMap.has(userId)) {
+          calls.push(
+            SupersetClient.post({
+              endpoint: `/api/v1/folders/${folderUuid}/subjects`,
+              jsonPayload: { user_id: userId, permission },
+            }),
+          );
+        }
+      }
+
+      for (const [userId] of serverMap) {
+        if (!localMap.has(userId)) {
+          calls.push(
+            SupersetClient.delete({
+              endpoint: `/api/v1/folders/${folderUuid}/subjects/${userId}`,
+            }),
+          );
+        }
+      }
+
+      for (const [userId, permission] of localMap) {
+        const serverPerm = serverMap.get(userId);
+        if (serverPerm && serverPerm !== permission) {
+          calls.push(
+            SupersetClient.put({
+              endpoint: `/api/v1/folders/${folderUuid}/subjects/${userId}`,
+              jsonPayload: { permission },
+            }),
+          );
+        }
+      }
+
+      const results = await Promise.allSettled(calls);
+      const failures = results.filter(r => r.status === 'rejected');
+      if (failures.length > 0) {
+        addDangerToast(t('%s permission update(s) failed', failures.length));
+      } else {
+        addSuccessToast(t('Permissions updated'));
+      }
+      onHide();
+    } catch {
+      addDangerToast(t('Error updating permissions'));
+    } finally {
+      setSaving(false);
+    }
+  }, [
+    folderUuid,
+    serverSubjects,
+    localSubjects,
+    addSuccessToast,
+    addDangerToast,
+    onHide,
+  ]);
+
+  const hasChanges = useMemo(() => {
+    if (serverSubjects.length !== localSubjects.length) return true;
+    const serverMap = new Map(
+      serverSubjects.map(s => [s.user_id, s.permission]),
+    );
+    return localSubjects.some(s => serverMap.get(s.user_id) !== s.permission);
+  }, [serverSubjects, localSubjects]);
+
+  const fetchAvailableUsers = useCallback(
+    async (
+      search: string,
+      page: number,
+      pageSize: number,
+    ): Promise<SelectOptionsTypePage> => {
+      const params = new URLSearchParams({
+        q: search,
+        page: String(page),
+        page_size: String(pageSize),
+      });
+      const { json } = await SupersetClient.get({
+        endpoint: `/api/v1/folders/${folderUuid}/available-users?${params}`,
+      });
+      const results = json?.result || [];
+      results.forEach((u: { id: number; is_admin?: boolean }) => {
+        if (u.is_admin) adminIdsRef.current.add(u.id);
+      });
+      return {
+        data: results.map(
+          (u: { id: number; email: string; is_admin?: boolean }) => ({
+            value: u.id,
+            label: getUserLabel(u),
+          }),
+        ),
+        totalCount: json?.count ?? 0,
+      };
+    },
+    [folderUuid],
+  );
+
+  const filtered = useMemo(() => {
+    if (!memberSearch) return localSubjects;
+    const q = memberSearch.toLowerCase();
+    return localSubjects.filter(s => s.label.toLowerCase().includes(q));
+  }, [localSubjects, memberSearch]);
+
+  const columns: ColumnsType<LocalSubject> = useMemo(
+    () => [
+      {
+        title: t('User'),
+        dataIndex: 'label',
+        key: 'label',
+        render: (label: string, record: LocalSubject) => (
+          <span>
+            {label}
+            {record.isCurrentUser && (
+              <span css={{ opacity: 0.5, marginLeft: theme.sizeUnit }}>
+                ({t('you')})
+              </span>
+            )}
+          </span>
+        ),
+      },
+      {
+        title: t('Permission'),
+        dataIndex: 'permission',
+        key: 'permission',
+        width: 130,
+        render: (permission: Permission, record: LocalSubject) =>
+          record.isAdmin ? (
+            <Tooltip
+              title={t(
+                'This user is an admin and has full access to all folders',
+              )}
+            >
+              <Typography.Text type="secondary">{t('Admin')}</Typography.Text>
+            </Tooltip>
+          ) : record.isCurrentUser ? (
+            <Tooltip
+              title={t("You can't change your own permission")}
+            >
+              <Typography.Text type="secondary">
+                {PERMISSION_OPTIONS.find(o => o.value === permission)?.label ??
+                  permission}
+              </Typography.Text>
+            </Tooltip>
+          ) : (
+            <Select
+              ariaLabel={t('Permission')}
+              options={PERMISSION_OPTIONS}
+              value={permission}
+              onChange={(val: Permission) =>
+                handleChangePermission(record.user_id, val)
+              }
+              getPopupContainer={trigger =>
+                trigger.closest('.ant-modal-content') || document.body
+              }
+            />
+          ),
+      },
+      {
+        title: '',
+        key: 'actions',
+        width: 40,
+        render: (_: unknown, record: LocalSubject) =>
+          record.isCurrentUser || record.isAdmin ? null : (
+            <Icons.DeleteOutlined
+              iconSize="m"
+              role="button"
+              tabIndex={0}
+              onClick={() => handleRemoveSubject(record.user_id)}
+              css={css`
+                cursor: pointer;
+                color: ${theme.colorIcon};
+                &:hover {
+                  color: ${theme.colorError};
+                }
+              `}
+            />
+          ),
+      },
+    ],
+    [theme, handleChangePermission, handleRemoveSubject],
+  );
+
+  return (
+    <StandardModal
+      title={t('Manage permissions — %s', folderName)}
+      show={show}
+      onHide={onHide}
+      onSave={handleSave}
+      saveText={t('Save')}
+      saveLoading={saving}
+      saveDisabled={!hasChanges}
+      contentLoading={loading}
+    >
+      <ModalContent>
+        {isSubfolder && (
+          <Alert
+            type="warning"
+            showIcon
+            message={t(
+              'Changing permissions here will stop this folder from automatically inheriting updates from its parent. You can re-sync later from the actions menu.',
+            )}
+          />
+        )}
+        <div>
+          <StyledFormLabel>{t('Add user')}</StyledFormLabel>
+          <AsyncSelect
+            ariaLabel={t('Search users')}
+            placeholder={t('Search for a user…')}
+            options={fetchAvailableUsers}
+            onChange={handleAddUser}
+            value={undefined}
+            getPopupContainer={trigger =>
+              trigger.closest('.ant-modal-content') || document.body
+            }
+          />
+        </div>
+
+        <div>
+          <StyledFormLabel>
+            {t('Members')} ({localSubjects.length})
+          </StyledFormLabel>
+          <Input
+            placeholder={t('Search members…')}
+            value={memberSearch}
+            onChange={e => setMemberSearch(e.target.value)}
+            allowClear
+            css={{ marginBottom: theme.sizeUnit * 2 }}
+          />
+          <Table<LocalSubject>
+            key={memberSearch}
+            data={filtered}
+            columns={columns}
+            usePagination
+            defaultPageSize={10}
+            size={TableSize.Small}
+            height={300}
+          />
+        </div>
+      </ModalContent>
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderSelector.tsx b/superset-frontend/src/pages/AnalyticsList/FolderSelector.tsx
new file mode 100644
index 00000000000..3eaf0f6cc2f
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/FolderSelector.tsx
@@ -0,0 +1,170 @@
+/**
+ * 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 { useCallback, useEffect, useMemo, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { TreeSelect } from '@superset-ui/core/components/TreeSelect';
+import { ModalFormField } from 'src/components/Modal/ModalFormField';
+
+interface FolderInfo {
+  uuid: string;
+  name: string;
+  parent_uuid: string | null;
+  user_permission?: string | null;
+  is_only_me?: boolean;
+  is_private?: boolean;
+}
+
+export interface FolderSelectorProps {
+  assetId: number;
+  assetType: 'chart' | 'dashboard';
+  accessorCount?: number;
+  onChange?: (folderUuid: string | null) => void;
+  onValidationError?: (error: string | null) => void;
+}
+
+const ROOT_VALUE = '__root__';
+
+export default function FolderSelector({
+  assetId,
+  assetType,
+  accessorCount = 0,
+  onChange,
+  onValidationError,
+}: FolderSelectorProps) {
+  const [folders, setFolders] = useState<FolderInfo[]>([]);
+  const [selectedValue, setSelectedValue] = useState<string | null>(null);
+  const [initialValue, setInitialValue] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  // Fetch folder list + current asset location on mount
+  useEffect(() => {
+    setLoading(true);
+    Promise.all([
+      SupersetClient.get({
+        endpoint: '/api/v1/folders/?folder_type=analytics',
+      }),
+      SupersetClient.get({
+        endpoint: `/api/v1/folders/asset-location?type=${assetType}&id=${assetId}`,
+      }),
+    ])
+      .then(([foldersRes, locationRes]) => {
+        const allFolders = (foldersRes.json.result as FolderInfo[]) || [];
+        setFolders(allFolders.filter(f => f.user_permission === 'editor'));
+
+        const location = locationRes.json.result;
+        const currentUuid = location?.folder_uuid ?? ROOT_VALUE;
+        setSelectedValue(currentUuid);
+        setInitialValue(currentUuid);
+      })
+      .catch(() => {})
+      .finally(() => setLoading(false));
+  }, [assetId, assetType]);
+
+  // Build tree data for TreeSelect
+  const treeData = useMemo(() => {
+    type TreeNode = {
+      value: string;
+      title: string;
+      children: TreeNode[];
+    };
+
+    const nodeMap = new Map<string, TreeNode>();
+    for (const f of folders) {
+      nodeMap.set(f.uuid, {
+        value: f.uuid,
+        title: f.name,
+        children: [],
+      });
+    }
+    const roots: TreeNode[] = [];
+    for (const f of folders) {
+      const node = nodeMap.get(f.uuid)!;
+      if (f.parent_uuid && nodeMap.has(f.parent_uuid)) {
+        nodeMap.get(f.parent_uuid)!.children.push(node);
+      } else {
+        roots.push(node);
+      }
+    }
+
+    return [
+      { value: ROOT_VALUE, title: t('Analytics (root)'), children: [] },
+      ...roots,
+    ];
+  }, [folders]);
+
+  // Only Me validation: item is in Only Me folder AND has multiple accessors
+  const validateOnlyMe = useCallback(
+    (folderValue: string, count: number) => {
+      const folder = folders.find(f => f.uuid === folderValue);
+      if (folder?.is_only_me && count > 1) {
+        const errMsg = t(
+          'Move this item out of your private folder before sharing',
+        );
+        onValidationError?.(errMsg);
+      } else {
+        onValidationError?.(null);
+      }
+    },
+    [folders, onValidationError],
+  );
+
+  // Re-validate when accessorCount changes (user adds/removes people)
+  useEffect(() => {
+    if (selectedValue) {
+      validateOnlyMe(selectedValue, accessorCount);
+    }
+  }, [accessorCount, selectedValue, validateOnlyMe]);
+
+  const handleChange = useCallback(
+    (value: string) => {
+      setSelectedValue(value);
+      const folderUuid = value === ROOT_VALUE ? null : value;
+
+      validateOnlyMe(value, accessorCount);
+
+      onChange?.(folderUuid);
+    },
+    [accessorCount, onChange, validateOnlyMe],
+  );
+
+  const hasChanged = selectedValue !== initialValue;
+
+  return (
+    <ModalFormField
+      label={t('Folder')}
+      helperText={
+        hasChanged ? t('The item will be moved when you save.') : undefined
+      }
+    >
+      <TreeSelect
+        value={selectedValue ?? ROOT_VALUE}
+        treeData={treeData}
+        onChange={handleChange}
+        treeDefaultExpandAll
+        showSearch
+        filterTreeNode={(input: string, node: any) =>
+          (node?.title as string)?.toLowerCase().includes(input.toLowerCase())
+        }
+        loading={loading}
+        style={{ width: '100%' }}
+      />
+    </ModalFormField>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/RenameFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/RenameFolderModal.tsx
new file mode 100644
index 00000000000..9038a63b3bc
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/RenameFolderModal.tsx
@@ -0,0 +1,110 @@
+/**
+ * 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 { useCallback, useEffect, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { Input } from '@superset-ui/core/components';
+import { StandardModal } from 'src/components/Modal';
+
+interface RenameFolderModalProps {
+  folderUuid: string;
+  currentName: string;
+  show: boolean;
+  onHide: () => void;
+  onSuccess: () => void;
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}
+
+import { ModalContent } from './styles';
+
+export default function RenameFolderModal({
+  folderUuid,
+  currentName,
+  show,
+  onHide,
+  onSuccess,
+  addDangerToast,
+  addSuccessToast,
+}: RenameFolderModalProps) {
+  const [name, setName] = useState(currentName);
+  const [saving, setSaving] = useState(false);
+
+  useEffect(() => {
+    if (show) setName(currentName);
+  }, [show, currentName]);
+
+  const handleSave = useCallback(async () => {
+    const trimmed = name.trim();
+    if (!trimmed) {
+      addDangerToast(t('Folder name is required'));
+      return;
+    }
+    if (trimmed === currentName) {
+      onHide();
+      return;
+    }
+    setSaving(true);
+    try {
+      await SupersetClient.put({
+        endpoint: `/api/v1/folders/${folderUuid}`,
+        jsonPayload: { name: trimmed },
+      });
+      addSuccessToast(t('Folder renamed to "%s"', trimmed));
+      onSuccess();
+      onHide();
+    } catch {
+      addDangerToast(t('Error renaming folder'));
+    } finally {
+      setSaving(false);
+    }
+  }, [
+    name,
+    currentName,
+    folderUuid,
+    addSuccessToast,
+    addDangerToast,
+    onSuccess,
+    onHide,
+  ]);
+
+  return (
+    <StandardModal
+      title={t('Rename folder')}
+      show={show}
+      onHide={onHide}
+      onSave={handleSave}
+      saveText={t('Save')}
+      saveDisabled={!name.trim() || name.trim() === currentName}
+      saveLoading={saving}
+    >
+      <ModalContent>
+        <label htmlFor="rename-folder-name">{t('Name')}</label>
+        <Input
+          id="rename-folder-name"
+          placeholder={t('Folder name')}
+          value={name}
+          onChange={e => setName(e.target.value)}
+          onPressEnter={handleSave}
+          autoFocus
+        />
+      </ModalContent>
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx b/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
new file mode 100644
index 00000000000..843643074f3
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
@@ -0,0 +1,1026 @@
+/**
+ * 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 {
+  type ReactNode,
+  type UIEvent,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { SupersetClient, getNumberFormatter } from '@superset-ui/core';
+import { styled, css, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import rison from 'rison';
+import { Alert } from '@apache-superset/core/components';
+import {
+  Button,
+  Checkbox,
+  Constants,
+  Flex,
+  FormLabel,
+  Input,
+  List,
+  Skeleton,
+} from '@superset-ui/core/components';
+import { TreeSelect } from '@superset-ui/core/components/TreeSelect';
+import { StandardModal, MODAL_LARGE_WIDTH } from 'src/components/Modal';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { useConfirmModal } from 'src/hooks/useConfirmModal';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+
+type ItemType = 'folder' | 'chart' | 'dashboard';
+
+interface TransferItem {
+  key: string;
+  type: ItemType;
+  id: number;
+  uuid?: string | null;
+  name: string;
+}
+
+interface FolderOption {
+  uuid: string;
+  name: string;
+  parent_uuid: string | null;
+  is_private?: boolean;
+  user_permission?: 'editor' | 'viewer' | 'implicit' | null;
+}
+
+interface TransferModalProps {
+  currentFolderUuid: string | null;
+  currentFolderName: string;
+  currentFolderIsPrivate?: boolean;
+  preSelectedKeys?: string[];
+  show: boolean;
+  onHide: () => void;
+  onSuccess: () => void;
+}
+
+const PAGE_SIZE = 25;
+const SCROLL_THRESHOLD = 0.7;
+
+const PanelContainer = styled.div`
+  ${({ theme }) => css`
+    display: grid;
+    grid-template-columns: 1fr auto 1fr;
+    gap: ${theme.sizeUnit * 2}px;
+    align-items: start;
+    min-height: 400px;
+  `}
+`;
+
+const Panel = styled.div`
+  ${({ theme }) => css`
+    display: grid;
+    grid-template-rows: auto auto 1fr;
+    gap: ${theme.sizeUnit * 4}px;
+    min-width: 0;
+  `}
+`;
+
+const Field = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit}px;
+    min-width: 0;
+  `}
+`;
+
+const BulkActionsBar = styled(Flex)`
+  ${({ theme }) => css`
+    padding: ${theme.sizeUnit}px 0;
+    .superset-button {
+      font-family: inherit;
+      font-weight: normal;
+      margin-left: 0 !important;
+    }
+    .superset-button:first-of-type {
+      padding-right: 0 !important;
+    }
+    .superset-button:last-of-type {
+      padding-left: 0 !important;
+    }
+  `}
+`;
+
+const StyledFormLabel = styled(FormLabel)`
+  ${({ theme }) => css`
+    margin-bottom: ${theme.sizeUnit * 0.5}px;
+    font-size: ${theme.fontSizeSM}px;
+  `}
+`;
+
+const PanelCount = styled.div`
+  ${({ theme }) => css`
+    font-size: ${theme.fontSizeSM}px;
+    color: ${theme.colorTextSecondary};
+  `}
+`;
+
+const ArrowColumn = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: ${theme.sizeUnit}px;
+    padding: 0 ${theme.sizeUnit}px;
+    align-self: center;
+  `}
+`;
+
+const ModalContent = styled.div`
+  ${({ theme }) => css`
+    padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit * 2}px;
+  `}
+`;
+
+const ScrollableList = styled.div`
+  ${({ theme }) => css`
+    max-height: 320px;
+    overflow-y: auto;
+    border: 1px solid ${theme.colorBorderSecondary};
+    border-radius: ${theme.borderRadius}px;
+  `}
+`;
+
+const EmptyState = styled.p`
+  ${({ theme }) => css`
+    opacity: 0.5;
+    padding: ${theme.sizeUnit * 4}px;
+    text-align: center;
+  `}
+`;
+
+function toTransferItem(item: {
+  type: string;
+  id: number;
+  uuid?: string | null;
+  name: string;
+}): TransferItem {
+  return {
+    key: `${item.type}-${item.id}`,
+    type: item.type as ItemType,
+    id: item.id,
+    uuid: item.uuid,
+    name: item.name,
+  };
+}
+
+const smartFormatter = getNumberFormatter('SMART_NUMBER');
+const formatCount = (n: number) => smartFormatter(n);
+
+function ItemIcon({ type }: { type: ItemType }) {
+  if (type === 'chart') return <Icons.LineChartOutlined iconSize="m" />;
+  if (type === 'dashboard') return <Icons.LayoutOutlined iconSize="m" />;
+  return <Icons.FolderOutlined iconSize="m" />;
+}
+
+async function fetchPage(
+  folderUuid: string | null,
+  page: number,
+  search?: string,
+): Promise<{ items: TransferItem[]; total: number }> {
+  const base = folderUuid
+    ? `/api/v1/folders/${folderUuid}/assets`
+    : '/api/v1/folders/assets';
+  const risonParams: Record<string, unknown> = {
+    page,
+    page_size: PAGE_SIZE,
+    order_column: 'changed_on',
+    order_direction: 'desc',
+  };
+  if (search) {
+    risonParams.filters = [{ col: 'name', opr: 'ct', value: search }];
+  }
+  const q = rison.encode_uri(risonParams);
+  const folderParam = folderUuid ? '' : 'folder_type=analytics&';
+  const response = await SupersetClient.get({
+    endpoint: `${base}?${folderParam}q=${q}`,
+  });
+  const results = (
+    (response.json.result as {
+      type: string;
+      id: number;
+      uuid?: string | null;
+      name: string;
+      user_permission?: string | null;
+    }[]) || []
+  ).filter(r => r.user_permission !== 'implicit');
+  return {
+    items: results.map(toTransferItem),
+    total: (response.json.count as number) || 0,
+  };
+}
+
+export default function TransferModal({
+  currentFolderUuid,
+  currentFolderName,
+  currentFolderIsPrivate = false,
+  preSelectedKeys,
+  show,
+  onHide,
+  onSuccess,
+}: TransferModalProps) {
+  const { addDangerToast, addSuccessToast } = useToasts();
+  const theme = useTheme();
+  const [leftItems, setLeftItems] = useState<TransferItem[]>([]);
+  const [leftTotal, setLeftTotal] = useState(0);
+  const [leftPage, setLeftPage] = useState(0);
+  const [rightItems, setRightItems] = useState<TransferItem[]>([]);
+  const [rightTotal, setRightTotal] = useState(0);
+  const [rightPage, setRightPage] = useState(0);
+  const [leftSelected, setLeftSelected] = useState<Set<string>>(new Set());
+  const [rightSelected, setRightSelected] = useState<Set<string>>(new Set());
+  const [movedToRight, setMovedToRight] = useState<TransferItem[]>([]);
+  const [movedToLeft, setMovedToLeft] = useState<TransferItem[]>([]);
+  const [showDestinationBanner, setShowDestinationBanner] = useState(false);
+  const [leftSearch, setLeftSearch] = useState('');
+  const [rightSearch, setRightSearch] = useState('');
+  const leftSyncedSearch = useRef('');
+  const rightSyncedSearch = useRef('');
+  const [targetFolderUuid, setTargetFolderUuid] = useState<string | null>(null);
+  const [allFolders, setAllFolders] = useState<FolderOption[]>([]);
+  const [leftLoading, setLeftLoading] = useState(false);
+  const [rightLoading, setRightLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const { showConfirm, ConfirmModal } = useConfirmModal();
+
+  // Fetch left (source) first page + folder list on open
+  useEffect(() => {
+    if (!show) return;
+    setLeftSearch('');
+    setRightSearch('');
+    setLeftSelected(new Set());
+    setRightSelected(new Set());
+    setMovedToRight([]);
+    setMovedToLeft([]);
+    setShowDestinationBanner(false);
+    setRightItems([]);
+    setRightTotal(0);
+    setRightPage(0);
+    setTargetFolderUuid(null);
+    setLeftLoading(true);
+
+    Promise.all([
+      fetchPage(currentFolderUuid, 0),
+      SupersetClient.get({
+        endpoint: '/api/v1/folders/?folder_type=analytics',
+      }),
+    ])
+      .then(([{ items, total }, foldersRes]) => {
+        setLeftItems(items);
+        setLeftTotal(total);
+        setLeftPage(0);
+        const folders = (
+          (foldersRes.json.result as FolderOption[]) || []
+        ).filter(
+          f => f.uuid !== currentFolderUuid && f.user_permission !== 'implicit',
+        );
+        setAllFolders(folders);
+        if (preSelectedKeys?.length) {
+          setLeftSelected(new Set(preSelectedKeys));
+        }
+      })
+      .catch(() => addDangerToast(t('Error loading data')))
+      .finally(() => setLeftLoading(false));
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [show, currentFolderUuid, addDangerToast]);
+
+  // Fetch right (target) first page when target changes
+  useEffect(() => {
+    if (!show || !targetFolderUuid) {
+      setRightItems([]);
+      setRightTotal(0);
+      setRightPage(0);
+      return;
+    }
+    setRightSearch('');
+    setRightSelected(new Set());
+    if (movedToRight.length > 0) {
+      setShowDestinationBanner(true);
+    }
+    setRightLoading(true);
+    const apiUuid = targetFolderUuid === '__root__' ? null : targetFolderUuid;
+    fetchPage(apiUuid, 0)
+      .then(({ items, total }) => {
+        setRightItems(items);
+        setRightTotal(total);
+        setRightPage(0);
+      })
+      .catch(() => addDangerToast(t('Error loading folder contents')))
+      .finally(() => setRightLoading(false));
+  }, [show, targetFolderUuid, addDangerToast]);
+
+  // Debounced server-side search — server data only, no merging.
+  // Moved items are handled in the filtered memos below.
+  useEffect(() => {
+    if (!show) return;
+    const timer = setTimeout(() => {
+      setLeftLoading(true);
+      fetchPage(currentFolderUuid, 0, leftSearch || undefined)
+        .then(({ items, total }) => {
+          leftSyncedSearch.current = leftSearch;
+          setLeftItems(items);
+          setLeftTotal(total);
+          setLeftPage(0);
+        })
+        .catch(() => addDangerToast(t('Error searching items')))
+        .finally(() => setLeftLoading(false));
+    }, Constants.SLOW_DEBOUNCE);
+    return () => clearTimeout(timer);
+  }, [leftSearch, show, currentFolderUuid, addDangerToast]);
+
+  useEffect(() => {
+    if (!show || !targetFolderUuid) return;
+    const timer = setTimeout(() => {
+      setRightLoading(true);
+      const apiUuid = targetFolderUuid === '__root__' ? null : targetFolderUuid;
+      fetchPage(apiUuid, 0, rightSearch || undefined)
+        .then(({ items, total }) => {
+          rightSyncedSearch.current = rightSearch;
+          setRightItems(items);
+          setRightTotal(total);
+          setRightPage(0);
+        })
+        .catch(() => addDangerToast(t('Error searching items')))
+        .finally(() => setRightLoading(false));
+    }, Constants.SLOW_DEBOUNCE);
+    return () => clearTimeout(timer);
+  }, [rightSearch, show, targetFolderUuid, addDangerToast]);
+
+  // Scroll handler — fetch next page when near bottom (AsyncSelect pattern)
+  const handleScroll = useCallback(
+    (side: 'left' | 'right') => (e: UIEvent<HTMLDivElement>) => {
+      const isLeft = side === 'left';
+      const page = isLeft ? leftPage : rightPage;
+      const total = isLeft ? leftTotal : rightTotal;
+      const loading = isLeft ? leftLoading : rightLoading;
+      const search = isLeft ? leftSearch : rightSearch;
+      const setItems = isLeft ? setLeftItems : setRightItems;
+      const setPage = isLeft ? setLeftPage : setRightPage;
+      const setLoading = isLeft ? setLeftLoading : setRightLoading;
+      const folderUuid = isLeft
+        ? currentFolderUuid
+        : targetFolderUuid === '__root__'
+          ? null
+          : targetFolderUuid;
+
+      const el = e.currentTarget;
+      const nearBottom =
+        el.scrollTop > (el.scrollHeight - el.offsetHeight) * SCROLL_THRESHOLD;
+      const hasMore = (page + 1) * PAGE_SIZE < total;
+      if (!loading && hasMore && nearBottom) {
+        const nextPage = page + 1;
+        setLoading(true);
+        fetchPage(folderUuid, nextPage, search || undefined)
+          .then(({ items }) => {
+            setItems(prev => {
+              const existing = new Set(prev.map(i => i.key));
+              return [...prev, ...items.filter(i => !existing.has(i.key))];
+            });
+            setPage(nextPage);
+          })
+          .catch(() => addDangerToast(t('Error loading more items')))
+          .finally(() => setLoading(false));
+      }
+    },
+    [
+      leftPage,
+      leftTotal,
+      leftLoading,
+      leftSearch,
+      rightPage,
+      rightTotal,
+      rightLoading,
+      rightSearch,
+      currentFolderUuid,
+      targetFolderUuid,
+      addDangerToast,
+    ],
+  );
+
+  const toggleSelection = useCallback((key: string, side: 'left' | 'right') => {
+    const setter = side === 'left' ? setLeftSelected : setRightSelected;
+    setter(prev => {
+      const next = new Set(prev);
+      if (next.has(key)) next.delete(key);
+      else next.add(key);
+      return next;
+    });
+  }, []);
+
+  const selectAll = useCallback(
+    (filtered: TransferItem[], setter: typeof setLeftSelected) => {
+      setter(prev => new Set([...prev, ...filtered.map(i => i.key)]));
+    },
+    [],
+  );
+
+  const deselectAll = useCallback(
+    (filtered: TransferItem[], setter: typeof setLeftSelected) => {
+      setter(prev => {
+        const next = new Set(prev);
+        filtered.forEach(i => next.delete(i.key));
+        return next;
+      });
+    },
+    [],
+  );
+
+  const moveRight = useCallback(() => {
+    if (!leftSelected.size) return;
+    const moving = leftItems.filter(i => leftSelected.has(i.key));
+    const movingKeys = new Set(moving.map(i => i.key));
+    setMovedToRight(prev => [
+      ...prev.filter(i => !movingKeys.has(i.key)),
+      ...moving,
+    ]);
+    setMovedToLeft(prev => prev.filter(i => !movingKeys.has(i.key)));
+    setLeftSelected(new Set());
+  }, [leftSelected, leftItems]);
+
+  const moveLeft = useCallback(() => {
+    if (!rightSelected.size) return;
+    const moving = rightItems.filter(i => rightSelected.has(i.key));
+    const movingKeys = new Set(moving.map(i => i.key));
+    setMovedToLeft(prev => [
+      ...prev.filter(i => !movingKeys.has(i.key)),
+      ...moving,
+    ]);
+    setMovedToRight(prev => prev.filter(i => !movingKeys.has(i.key)));
+    setRightSelected(new Set());
+  }, [rightSelected, rightItems]);
+
+  const hasChanges = useMemo(
+    () => movedToRight.length > 0 || movedToLeft.length > 0,
+    [movedToRight, movedToLeft],
+  );
+
+  const targetFolderName = useMemo(() => {
+    if (!targetFolderUuid || targetFolderUuid === '__root__')
+      return t('Root (Analytics)');
+    return allFolders.find(f => f.uuid === targetFolderUuid)?.name ?? '';
+  }, [targetFolderUuid, allFolders]);
+
+  const movedItemNames = useMemo(() => {
+    const names = [
+      ...movedToRight.map(i => i.name),
+      ...movedToLeft.map(i => i.name),
+    ];
+    if (names.length <= 2) return names.join(` ${t('and')} `);
+    return `${names.slice(0, 2).join(', ')} ${t('and %s more', names.length - 2)}`;
+  }, [movedToRight, movedToLeft]);
+
+  const handleDone = useCallback(async () => {
+    if (!targetFolderUuid) return;
+    const targetApiUuid =
+      targetFolderUuid === '__root__' ? null : targetFolderUuid;
+    setSaving(true);
+    try {
+      const calls: Promise<unknown>[] = [];
+
+      // Items moved from left → right (add to target)
+      const assetsToAdd = movedToRight.filter(i => i.type !== 'folder');
+      const foldersToAdd = movedToRight.filter(i => i.type === 'folder');
+
+      if (assetsToAdd.length && targetApiUuid) {
+        calls.push(
+          SupersetClient.post({
+            endpoint: `/api/v1/folders/${targetApiUuid}/assets`,
+            jsonPayload: {
+              assets: assetsToAdd.map(i => ({ type: i.type, id: i.id })),
+            },
+          }),
+        );
+      } else if (assetsToAdd.length && !targetApiUuid && currentFolderUuid) {
+        const q = rison.encode(
+          assetsToAdd.map(i => ({ type: i.type, id: i.id })),
+        );
+        calls.push(
+          SupersetClient.delete({
+            endpoint: `/api/v1/folders/${currentFolderUuid}/assets?q=${q}`,
+          }),
+        );
+      }
+      for (const f of foldersToAdd) {
+        if (f.uuid) {
+          calls.push(
+            SupersetClient.put({
+              endpoint: `/api/v1/folders/${f.uuid}`,
+              jsonPayload: { parent_uuid: targetApiUuid },
+            }),
+          );
+        }
+      }
+
+      // Items moved from right → left (return to source)
+      for (const item of movedToLeft) {
+        if (item.type === 'folder' && item.uuid) {
+          calls.push(
+            SupersetClient.put({
+              endpoint: `/api/v1/folders/${item.uuid}`,
+              jsonPayload: { parent_uuid: currentFolderUuid },
+            }),
+          );
+        } else if (currentFolderUuid) {
+          calls.push(
+            SupersetClient.post({
+              endpoint: `/api/v1/folders/${currentFolderUuid}/assets`,
+              jsonPayload: {
+                assets: [{ type: item.type, id: item.id }],
+              },
+            }),
+          );
+        }
+      }
+
+      const results = await Promise.allSettled(calls);
+      const failures = results.filter(r => r.status === 'rejected');
+      if (failures.length) {
+        addDangerToast(t('%s item(s) failed to move', failures.length));
+      } else {
+        addSuccessToast(t('Items moved successfully'));
+      }
+      onSuccess();
+      onHide();
+    } catch {
+      addDangerToast(t('Error moving items'));
+    } finally {
+      setSaving(false);
+    }
+  }, [
+    targetFolderUuid,
+    currentFolderUuid,
+    movedToRight,
+    movedToLeft,
+    addSuccessToast,
+    addDangerToast,
+    onSuccess,
+    onHide,
+  ]);
+
+  const handleSave = useCallback(async () => {
+    if (!targetFolderUuid) return;
+    const sourceUuid =
+      currentFolderUuid && currentFolderUuid !== '__root__'
+        ? currentFolderUuid
+        : null;
+    const targetUuid =
+      targetFolderUuid === '__root__' ? null : targetFolderUuid;
+
+    // If both are root or same folder, skip permission check
+    if (sourceUuid === targetUuid) {
+      handleDone();
+      return;
+    }
+
+    // Check if crossing the Only Me boundary
+    const targetFolder = allFolders.find(f => f.uuid === targetUuid);
+    const targetIsPrivate = targetFolder?.is_private ?? false;
+
+    // Moving OUT of Only Me to a non-private folder
+    if (currentFolderIsPrivate && !targetIsPrivate) {
+      showConfirm({
+        title: t('Move item out of Only Me?'),
+        body: t(
+          'Moving this item out of Only Me will apply the destination folder permissions and may change who can access it.',
+        ),
+        confirmText: t('Move'),
+        onConfirm: handleDone,
+        icon: null,
+      });
+      return;
+    }
+
+    // Moving between subfolders inside Only Me — no confirmation
+    if (currentFolderIsPrivate && targetIsPrivate) {
+      handleDone();
+      return;
+    }
+
+    // Standard permission diff check for non-private folders
+    try {
+      const [sourcePerms, targetPerms] = await Promise.all([
+        sourceUuid
+          ? SupersetClient.get({
+              endpoint: `/api/v1/folders/${sourceUuid}/subjects`,
+            }).then(r => r.json.result as { user_id: number }[])
+          : Promise.resolve([]),
+        targetUuid
+          ? SupersetClient.get({
+              endpoint: `/api/v1/folders/${targetUuid}/subjects`,
+            }).then(r => r.json.result as { user_id: number }[])
+          : Promise.resolve([]),
+      ]);
+
+      const sourceIds = new Set(sourcePerms.map(s => s.user_id));
+      const targetIds = new Set(targetPerms.map(s => s.user_id));
+
+      const permsDiffer =
+        sourceIds.size !== targetIds.size ||
+        [...sourceIds].some(id => !targetIds.has(id));
+
+      if (permsDiffer) {
+        showConfirm({
+          title: t('Move to %s?', targetFolderName),
+          body: t(
+            'Anyone with access to this folder will be able to see %s.',
+            movedItemNames,
+          ),
+          confirmText: t('Move'),
+          onConfirm: handleDone,
+          icon: null,
+        });
+      } else {
+        handleDone();
+      }
+    } catch {
+      // If permission check fails, proceed without confirmation
+      handleDone();
+    }
+  }, [
+    targetFolderUuid,
+    currentFolderUuid,
+    currentFolderIsPrivate,
+    allFolders,
+    handleDone,
+    showConfirm,
+    targetFolderName,
+    movedItemNames,
+  ]);
+
+  const filteredLeft = useMemo(() => {
+    const movedRightKeys = new Set(movedToRight.map(i => i.key));
+    // Server items minus anything moved to the right
+    let items = leftItems.filter(i => !movedRightKeys.has(i.key));
+    if (targetFolderUuid) {
+      const rightKeys = new Set(rightItems.map(i => i.key));
+      items = items.filter(
+        i =>
+          !(i.type === 'folder' && i.uuid === targetFolderUuid) &&
+          !rightKeys.has(i.key),
+      );
+    }
+    // Append moved-to-left items filtered by the synced search value,
+    // so they disappear at the same time as server results.
+    const serverKeys = new Set(items.map(i => i.key));
+    const q = leftSyncedSearch.current?.toLowerCase();
+    const localMatches = movedToLeft.filter(
+      i => !serverKeys.has(i.key) && (!q || i.name.toLowerCase().includes(q)),
+    );
+    return [...items, ...localMatches];
+  }, [leftItems, movedToRight, movedToLeft, targetFolderUuid, rightItems]);
+
+  const filteredRight = useMemo(() => {
+    const movedLeftKeys = new Set(movedToLeft.map(i => i.key));
+    const items = rightItems.filter(
+      i =>
+        !movedLeftKeys.has(i.key) &&
+        !(i.type === 'folder' && i.uuid === currentFolderUuid),
+    );
+    // Append moved-to-right items filtered by the synced search value,
+    // so they disappear at the same time as server results.
+    const serverKeys = new Set(items.map(i => i.key));
+    const q = rightSyncedSearch.current?.toLowerCase();
+    const localMatches = movedToRight.filter(
+      i => !serverKeys.has(i.key) && (!q || i.name.toLowerCase().includes(q)),
+    );
+    return [...items, ...localMatches];
+  }, [rightItems, movedToLeft, movedToRight, currentFolderUuid]);
+
+  const leftSelectableCount = filteredLeft.filter(
+    (i: TransferItem) => !leftSelected.has(i.key),
+  ).length;
+  const leftDeselectableCount = filteredLeft.filter((i: TransferItem) =>
+    leftSelected.has(i.key),
+  ).length;
+  const rightSelectableCount = filteredRight.filter(
+    (i: TransferItem) => !rightSelected.has(i.key),
+  ).length;
+  const rightDeselectableCount = filteredRight.filter((i: TransferItem) =>
+    rightSelected.has(i.key),
+  ).length;
+
+  const folderTreeData = useMemo(() => {
+    // Collect UUIDs of selected folders — they can't be their own target.
+    const selectedFolderUuids = new Set(
+      leftItems
+        .filter(i => i.type === 'folder' && i.uuid && leftSelected.has(i.key))
+        .map(i => i.uuid as string),
+    );
+
+    // BFS: also exclude all descendants so a parent can't be moved into a child.
+    const childrenMap = new Map<string, string[]>();
+    for (const f of allFolders) {
+      if (f.parent_uuid) {
+        const siblings = childrenMap.get(f.parent_uuid) ?? [];
+        siblings.push(f.uuid);
+        childrenMap.set(f.parent_uuid, siblings);
+      }
+    }
+    const excluded = new Set(selectedFolderUuids);
+    const queue = [...selectedFolderUuids];
+    while (queue.length) {
+      const uuid = queue.shift()!;
+      for (const childUuid of childrenMap.get(uuid) ?? []) {
+        if (!excluded.has(childUuid)) {
+          excluded.add(childUuid);
+          queue.push(childUuid);
+        }
+      }
+    }
+
+    const eligible = allFolders.filter(f => !excluded.has(f.uuid));
+
+    // Build tree from flat list
+    type TreeNode = {
+      value: string;
+      title: ReactNode;
+      children: TreeNode[];
+    };
+    const nodeMap = new Map<string, TreeNode & { name: string }>();
+    for (const f of eligible) {
+      nodeMap.set(f.uuid, {
+        value: f.uuid,
+        name: f.name,
+        title: (
+          <Flex align="center" gap={4}>
+            <Icons.FolderOutlined iconSize="m" />
+            {f.name}
+          </Flex>
+        ),
+        children: [],
+      });
+    }
+    const roots: TreeNode[] = [];
+    for (const f of eligible) {
+      const node = nodeMap.get(f.uuid)!;
+      const parentNode = f.parent_uuid ? nodeMap.get(f.parent_uuid) : null;
+      if (parentNode) {
+        parentNode.children.push(node);
+      } else {
+        roots.push(node);
+      }
+    }
+
+    // Prepend root option when inside a folder
+    if (currentFolderUuid) {
+      roots.unshift({
+        value: '__root__',
+        title: (
+          <Flex align="center" gap={4}>
+            <Icons.HomeOutlined iconSize="m" />
+            {t('Root (Analytics)')}
+          </Flex>
+        ),
+        children: [],
+      });
+    }
+
+    return roots;
+  }, [allFolders, currentFolderUuid, leftItems, leftSelected]);
+
+  return (
+    <>
+      <StandardModal
+        title={t('Move items')}
+        show={show}
+        onHide={onHide}
+        onSave={handleSave}
+        saveText={t('Save')}
+        saveLoading={saving}
+        saveDisabled={!hasChanges || !targetFolderUuid}
+        width={MODAL_LARGE_WIDTH}
+      >
+        <ModalContent>
+          <PanelContainer>
+            <Panel>
+              <Field>
+                <StyledFormLabel>{t('Source folder')}</StyledFormLabel>
+                <Input
+                  value={currentFolderName || t('Root (Analytics)')}
+                  disabled
+                />
+              </Field>
+              <Input
+                placeholder={t('Search the folder')}
+                value={leftSearch}
+                onChange={e => setLeftSearch(e.target.value)}
+                allowClear
+                prefix={<Icons.SearchOutlined iconSize="l" />}
+              />
+              <Field>
+                <BulkActionsBar gap={theme.sizeUnit}>
+                  <Button
+                    buttonStyle="link"
+                    buttonSize="xsmall"
+                    disabled={leftSelectableCount === 0}
+                    onClick={() => selectAll(filteredLeft, setLeftSelected)}
+                  >
+                    {t('Select all (%s)', formatCount(leftSelectableCount))}
+                  </Button>
+                  <Button
+                    buttonStyle="link"
+                    buttonSize="xsmall"
+                    disabled={leftDeselectableCount === 0}
+                    onClick={() => deselectAll(filteredLeft, setLeftSelected)}
+                  >
+                    {t('Deselect all (%s)', formatCount(leftDeselectableCount))}
+                  </Button>
+                  <PanelCount css={{ marginLeft: 'auto' }}>
+                    {leftSelected.size > 0
+                      ? t(
+                          '%s/%s items selected',
+                          formatCount(leftSelected.size),
+                          formatCount(leftTotal),
+                        )
+                      : t('0 items selected')}
+                  </PanelCount>
+                </BulkActionsBar>
+                {leftLoading && !leftItems.length ? (
+                  <Skeleton active />
+                ) : (
+                  <ScrollableList onScroll={handleScroll('left')}>
+                    <List
+                      dataSource={filteredLeft}
+                      locale={{ emptyText: t('No items') }}
+                      renderItem={(item: TransferItem) => (
+                        <List.Item
+                          key={item.key}
+                          onClick={() => toggleSelection(item.key, 'left')}
+                          css={{ cursor: 'pointer' }}
+                        >
+                          <Flex align="center" gap={theme.sizeUnit * 2}>
+                            <Checkbox
+                              css={{ paddingLeft: theme.sizeUnit * 2 }}
+                              checked={leftSelected.has(item.key)}
+                            />
+                            <ItemIcon type={item.type} />
+                            {item.name}
+                          </Flex>
+                        </List.Item>
+                      )}
+                    />
+                  </ScrollableList>
+                )}
+              </Field>
+            </Panel>
+
+            <ArrowColumn>
+              <Button
+                buttonStyle="primary"
+                buttonSize="small"
+                disabled={!leftSelected.size || !targetFolderUuid}
+                onClick={moveRight}
+              >
+                <Icons.RightOutlined iconSize="m" />
+              </Button>
+              <Button
+                buttonStyle="primary"
+                buttonSize="small"
+                disabled={!rightSelected.size}
+                onClick={moveLeft}
+                css={css`
+                  margin-left: 0 !important;
+                `}
+              >
+                <Icons.LeftOutlined iconSize="m" />
+              </Button>
+            </ArrowColumn>
+
+            <Panel>
+              <Field>
+                <StyledFormLabel>{t('Destination folder')}</StyledFormLabel>
+                <TreeSelect
+                  aria-label={t('Target folder')}
+                  placeholder={t('Select a location…')}
+                  suffixIcon={<Icons.DownOutlined iconSize="m" />}
+                  treeIcon
+                  treeData={folderTreeData}
+                  value={targetFolderUuid}
+                  onChange={(val: string) => setTargetFolderUuid(val)}
+                  allowClear
+                  treeDefaultExpandAll
+                  showSearch
+                  filterTreeNode={(input, node) =>
+                    (node as any)?.name
+                      ?.toLowerCase()
+                      .includes(input.toLowerCase()) ?? false
+                  }
+                  getPopupContainer={trigger =>
+                    trigger.closest('.ant-modal-content') || document.body
+                  }
+                  css={{ width: '100%' }}
+                />
+              </Field>
+              <Input
+                placeholder={t('Search the folder')}
+                value={rightSearch}
+                onChange={e => setRightSearch(e.target.value)}
+                allowClear
+                prefix={<Icons.SearchOutlined iconSize="l" />}
+              />
+              {showDestinationBanner && (
+                <Alert
+                  type="warning"
+                  showIcon
+                  closable
+                  onClose={() => setShowDestinationBanner(false)}
+                  message={t(
+                    'Destination folder changed. Previously moved items will be saved to the new destination.',
+                  )}
+                />
+              )}
+              <Field>
+                <BulkActionsBar gap={theme.sizeUnit}>
+                  <Button
+                    buttonStyle="link"
+                    buttonSize="xsmall"
+                    disabled={rightSelectableCount === 0}
+                    onClick={() => selectAll(filteredRight, setRightSelected)}
+                  >
+                    {t('Select all (%s)', formatCount(rightSelectableCount))}
+                  </Button>
+                  <Button
+                    buttonStyle="link"
+                    buttonSize="xsmall"
+                    disabled={rightDeselectableCount === 0}
+                    onClick={() => deselectAll(filteredRight, setRightSelected)}
+                  >
+                    {t(
+                      'Deselect all (%s)',
+                      formatCount(rightDeselectableCount),
+                    )}
+                  </Button>
+                  <PanelCount css={{ marginLeft: 'auto' }}>
+                    {rightSelected.size > 0
+                      ? t(
+                          '%s/%s items selected',
+                          formatCount(rightSelected.size),
+                          formatCount(rightTotal),
+                        )
+                      : t('0 items selected')}
+                  </PanelCount>
+                </BulkActionsBar>
+                {!targetFolderUuid ? (
+                  <EmptyState>
+                    {t('Select a location to see its contents')}
+                  </EmptyState>
+                ) : rightLoading && !rightItems.length ? (
+                  <Skeleton active />
+                ) : (
+                  <ScrollableList onScroll={handleScroll('right')}>
+                    <List
+                      dataSource={filteredRight}
+                      locale={{ emptyText: t('No items') }}
+                      renderItem={(item: TransferItem) => (
+                        <List.Item
+                          key={item.key}
+                          onClick={() => toggleSelection(item.key, 'right')}
+                          css={{ cursor: 'pointer' }}
+                        >
+                          <Flex align="center" gap={theme.sizeUnit * 2}>
+                            <Checkbox
+                              css={{ paddingLeft: theme.sizeUnit * 2 }}
+                              checked={rightSelected.has(item.key)}
+                            />
+                            <ItemIcon type={item.type} />
+                            {item.name}
+                          </Flex>
+                        </List.Item>
+                      )}
+                    />
+                  </ScrollableList>
+                )}
+              </Field>
+            </Panel>
+          </PanelContainer>
+        </ModalContent>
+      </StandardModal>
+      {ConfirmModal}
+    </>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/index.tsx b/superset-frontend/src/pages/AnalyticsList/index.tsx
new file mode 100644
index 00000000000..0a388f76d7d
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/index.tsx
@@ -0,0 +1,1783 @@
+/**
+ * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useSelector } from 'react-redux';
+import {
+  isFeatureEnabled,
+  FeatureFlag,
+  getChartMetadataRegistry,
+  SupersetClient,
+} from '@superset-ui/core';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+// eslint-disable-next-line no-restricted-imports -- icons not yet in AntdEnhanced on 6.0-release
+import {
+  MinusSquareOutlined,
+  PlusSquareOutlined,
+  PushpinOutlined,
+} from '@ant-design/icons';
+import rison from 'rison';
+import { useHistory, useParams } from 'react-router-dom';
+import type { CellProps } from 'react-table';
+import {
+  ImportModal as ImportModelsModal,
+  ListView,
+  ListViewFilterOperator as FilterOperator,
+  ModifiedInfo,
+  TagType,
+  TagsList,
+  type ListViewFilters,
+} from 'src/components';
+import { SubjectPile } from 'src/features/subjects/SubjectPile';
+import { TagTypeEnum } from 'src/components/Tag/TagType';
+import { loadTags } from 'src/components/Tag/utils';
+import {
+  Button,
+  ConfirmStatusChange,
+  Dropdown,
+  Flex,
+  InfoTooltip,
+  Tooltip,
+} from '@superset-ui/core/components';
+import { Alert } from '@apache-superset/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { FolderBreadcrumb, type FolderBreadcrumbItem } from '../components';
+import SubMenu, { type SubMenuProps } from 'src/features/home/SubMenu';
+import withToasts from 'src/components/MessageToasts/withToasts';
+import {
+  createErrorHandler,
+  createFetchRelated,
+  handleChartDelete,
+  handleDashboardDelete,
+} from 'src/views/CRUD/utils';
+import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils';
+import handleResourceExport from 'src/utils/export';
+import ChartPropertiesModal from 'src/explore/components/PropertiesModal';
+import DashboardPropertiesModal from 'src/dashboard/components/PropertiesModal';
+import { type Slice } from 'src/types/Chart';
+import CreateFolderModal from './CreateFolderModal';
+import TransferModal from './TransferModal';
+import FolderPermissionsModal from './FolderPermissionsModal';
+import EditFolderModal from './EditFolderModal';
+import DeleteFolderModal from './DeleteFolderModal';
+import FolderSelector from './FolderSelector';
+import DashboardCharts from './DashboardCharts';
+import type { ContentItem } from './types';
+
+/**
+ * withToasts types the properties modals as ComponentType<any>, so the
+ * renderExtraFields callback argument has to be annotated at the call site.
+ */
+type ExtraFieldsContext = {
+  assetId: number;
+  assetType: 'chart' | 'dashboard';
+  accessorCount: number;
+};
+
+const PASSWORDS_NEEDED_MESSAGE = t(
+  'The passwords for the databases below are needed in order to ' +
+    'import them together with the assets. Please note that the ' +
+    '"Secure Extra" and "Certificate" sections of ' +
+    'the database configuration are not present in export files, and ' +
+    'should be added manually after the import if they are needed.',
+);
+const CONFIRM_OVERWRITE_MESSAGE = t(
+  'You are importing one or more assets that already exist. ' +
+    'Overwriting might cause you to lose some of your work. Are you ' +
+    'sure you want to overwrite?',
+);
+
+/** A location in the drill path; the root has a null uuid. */
+interface Crumb {
+  uuid: string | null;
+  name: string;
+  user_permission?: 'editor' | 'viewer' | 'implicit' | null;
+  is_only_me?: boolean;
+}
+
+interface SortColumn {
+  id: string;
+  desc?: boolean;
+}
+
+interface FetchConfig {
+  pageIndex: number;
+  pageSize: number;
+  sortBy: SortColumn[];
+  filters: { id: string; operator?: string; value?: unknown }[];
+}
+
+const PAGE_SIZE = 25;
+const FOLDER_TYPE = 'analytics';
+
+const Actions = styled.div`
+  ${({ theme }) => `
+    color: ${theme.colorIcon};
+    display: flex;
+    gap: ${theme.sizeUnit * 2}px;
+
+    .action-button {
+      cursor: pointer;
+      &:hover {
+        color: ${theme.colorPrimary};
+      }
+    }
+  `}
+`;
+
+const NameLink = styled.a`
+  ${({ theme }) => `
+    display: inline-flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    cursor: pointer;
+  `}
+`;
+
+const NameRow = styled.span`
+  ${({ theme }) => `
+    display: inline-flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+  `}
+`;
+
+// Expand/collapse caret rendered immediately after a dashboard's title.
+const CaretButton = styled.button`
+  ${({ theme }) => `
+    display: inline-flex;
+    align-items: center;
+    margin: 0;
+    padding: 0;
+    border: 0;
+    background: transparent;
+    color: ${theme.colorIcon};
+    cursor: pointer;
+    &:hover {
+      color: ${theme.colorPrimary};
+    }
+  `}
+`;
+
+const BreadcrumbWrap = styled.div`
+  padding: 0;
+`;
+
+const chartRegistry = getChartMetadataRegistry();
+
+const createFetchDatasets = async (
+  filterValue = '',
+  page: number,
+  pageSize: number,
+) => {
+  const filters = filterValue
+    ? { filters: [{ col: 'table_name', opr: 'sw', value: filterValue }] }
+    : {};
+  const queryParams = rison.encode({
+    columns: ['table_name', 'id'],
+    keys: ['none'],
+    order_column: 'table_name',
+    order_direction: 'asc',
+    page,
+    page_size: pageSize,
+    ...filters,
+  });
+  const { json = {} } = await SupersetClient.get({
+    endpoint: `/api/v1/dataset/?q=${queryParams}`,
+  });
+  return {
+    data: (json?.result ?? []).map(
+      ({ table_name: tableName, id }: { table_name: string; id: number }) => ({
+        label: tableName,
+        value: id,
+      }),
+    ),
+    totalCount: json?.count ?? 0,
+  };
+};
+
+function AnalyticsList({
+  addDangerToast,
+  addSuccessToast,
+}: {
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}) {
+  const history = useHistory();
+  const { folderUuid } = useParams<{ folderUuid?: string }>();
+  const theme = useTheme();
+  const currentUserId = useSelector<any, number>(state => state.user?.userId);
+  const [loading, setLoading] = useState(true);
+  const [items, setItems] = useState<ContentItem[]>([]);
+  const [count, setCount] = useState(0);
+  const [breadcrumb, setBreadcrumb] = useState<Crumb[]>(() => [
+    { uuid: null, name: t('Analytics') },
+  ]);
+  const [refreshKey, setRefreshKey] = useState(0);
+  const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
+  const [searchQuery, setSearchQuery] = useState('');
+  const [hasFolders, setHasFolders] = useState(true);
+  // Charts per expanded dashboard, owned here so they survive antd remounting
+  // the expanded-row subtree (which would otherwise re-flash the loader).
+  const [chartsByDashboard, setChartsByDashboard] = useState<
+    Record<number, { loading: boolean; charts: ContentItem[] }>
+  >({});
+  const [showCreateFolder, setShowCreateFolder] = useState(false);
+  const [folderToManagePerms, setFolderToManagePerms] =
+    useState<ContentItem | null>(null);
+  const [folderToRename, setFolderToRename] = useState<ContentItem | null>(
+    null,
+  );
+  const [editChart, setEditChart] = useState<Slice | null>(null);
+  const [editDashboard, setEditDashboard] = useState<{
+    id: number;
+    title: string;
+  } | null>(null);
+  const [folderValidationError, setFolderValidationError] = useState<
+    string | null
+  >(null);
+  const pendingFolderMove = useRef<{
+    assetType: 'chart' | 'dashboard';
+    assetId: number;
+    folderUuid: string | null;
+  } | null>(null);
+  const executePendingFolderMove = useCallback(async () => {
+    const move = pendingFolderMove.current;
+    if (!move) return;
+    pendingFolderMove.current = null;
+    try {
+      if (move.folderUuid) {
+        await SupersetClient.post({
+          endpoint: `/api/v1/folders/${move.folderUuid}/assets`,
+          jsonPayload: {
+            assets: [{ type: move.assetType, id: move.assetId }],
+          },
+        });
+      } else {
+        // Move to root — remove from current folder
+        const { json } = await SupersetClient.get({
+          endpoint: `/api/v1/folders/asset-location?type=${move.assetType}&id=${move.assetId}`,
+        });
+        const currentFolder = json?.result?.folder_uuid;
+        if (currentFolder) {
+          await SupersetClient.delete({
+            endpoint: `/api/v1/folders/${currentFolder}/assets?q=${encodeURIComponent(
+              JSON.stringify([{ type: move.assetType, id: move.assetId }]),
+            )}`,
+          });
+        }
+      }
+    } catch {
+      addDangerToast(t('Error moving item to folder'));
+    }
+  }, [addDangerToast]);
+  const [bulkSelectEnabled, setBulkSelectEnabled] = useState(false);
+  const [moveTarget, setMoveTarget] = useState<ContentItem | null>(null);
+  const [showTransferModal, setShowTransferModal] = useState(false);
+  // Queue of folders awaiting the DeleteFolderModal — used for both single-
+  // and bulk-delete flows. The modal shows for queue[0] and pops on close.
+  const [folderDeleteQueue, setFolderDeleteQueue] = useState<ContentItem[]>([]);
+  // Batch-delete result deferred until the folder modal queue is empty, so
+  // toasts appear only after the user has finished handling all folder prompts.
+  const [pendingBatchResult, setPendingBatchResult] = useState<{
+    succeeded: number;
+    failed: number;
+  } | null>(null);
+
+  const [importResource, setImportResource] = useState<
+    'chart' | 'dashboard' | null
+  >(null);
+  const [passwordFields, setPasswordFields] = useState<string[]>([]);
+  const [sshTunnelPasswordFields, setSSHTunnelPasswordFields] = useState<
+    string[]
+  >([]);
+  const [sshTunnelPrivateKeyFields, setSSHTunnelPrivateKeyFields] = useState<
+    string[]
+  >([]);
+  const [
+    sshTunnelPrivateKeyPasswordFields,
+    setSSHTunnelPrivateKeyPasswordFields,
+  ] = useState<string[]>([]);
+
+  // --- Pinning state ---
+  const [pins, setPins] = useState<
+    Array<{
+      id: number;
+      object_id: number;
+      object_type: string;
+      position: number;
+    }>
+  >([]);
+  const pinnedKeys = useMemo(
+    () => new Set(pins.map(p => `${p.object_type}-${p.object_id}`)),
+    [pins],
+  );
+  const isPinned = useCallback(
+    (item: ContentItem) => pinnedKeys.has(`${item.type}-${item.id}`),
+    [pinnedKeys],
+  );
+  const fetchPins = useCallback(async () => {
+    try {
+      const { json } = await SupersetClient.get({
+        endpoint: '/api/v1/folders/pins',
+      });
+      setPins(json.result || []);
+    } catch {
+      // silently fail — pins are non-critical
+    }
+  }, []);
+
+  // Clear stale items when navigating to a different folder
+  useEffect(() => {
+    setItems([]);
+    setCount(0);
+  }, [folderUuid]);
+
+  // Fetch pins only at root level (pins are root-only)
+  useEffect(() => {
+    if (!folderUuid) fetchPins();
+  }, [fetchPins, refreshKey, folderUuid]);
+
+  // Check if any folders exist (for disabling "Move assets")
+  useEffect(() => {
+    SupersetClient.get({
+      endpoint: `/api/v1/folders/?folder_type=${FOLDER_TYPE}`,
+    })
+      .then(({ json }) => setHasFolders((json.count ?? 0) > 0))
+      .catch(() => setHasFolders(false));
+  }, [refreshKey]);
+
+  const refreshData = useCallback(() => setRefreshKey(key => key + 1), []);
+
+  const pinItem = useCallback(
+    async (item: ContentItem) => {
+      if (pinnedKeys.size >= 3) {
+        addDangerToast(t('Maximum 3 pins allowed'));
+        return;
+      }
+      const usedPositions = new Set(pins.map(p => p.position));
+      const nextPosition = [1, 2, 3].find(p => !usedPositions.has(p)) ?? 1;
+      try {
+        await SupersetClient.post({
+          endpoint: '/api/v1/folders/pins',
+          jsonPayload: {
+            object_id: item.id,
+            object_type: item.type,
+            position: nextPosition,
+          },
+        });
+        addSuccessToast(t('Pinned: %s', item.name));
+        fetchPins();
+        refreshData();
+      } catch {
+        addDangerToast(t('Error pinning item'));
+      }
+    },
+    [
+      pinnedKeys.size,
+      pins,
+      addSuccessToast,
+      addDangerToast,
+      fetchPins,
+      refreshData,
+    ],
+  );
+
+  const unpinItem = useCallback(
+    async (item: ContentItem) => {
+      const pin = pins.find(
+        p => p.object_type === item.type && p.object_id === item.id,
+      );
+      if (!pin) return;
+      try {
+        await SupersetClient.delete({
+          endpoint: `/api/v1/folders/pins/${pin.id}`,
+        });
+        addSuccessToast(t('Unpinned: %s', item.name));
+        fetchPins();
+        refreshData();
+      } catch {
+        addDangerToast(t('Error unpinning item'));
+      }
+    },
+    [pins, addSuccessToast, addDangerToast, fetchPins, refreshData],
+  );
+
+  const currentFolder = breadcrumb[breadcrumb.length - 1];
+  const isAtRoot = currentFolder.uuid === null;
+  const canEditCurrentFolder =
+    isAtRoot || currentFolder.user_permission === 'editor';
+  // True while the breadcrumb effect is still resolving the URL folder UUID.
+  // Prevents ListView from firing a fetch against the root endpoint on reload.
+  const breadcrumbLoading = !!folderUuid && isAtRoot;
+
+  const fetchData = useCallback(
+    async ({ pageIndex, pageSize, sortBy, filters }: FetchConfig) => {
+      setLoading(true);
+      setExpandedKeys([]);
+      setChartsByDashboard({});
+
+      // Track the name filter for search highlighting
+      const nameFilter = filters.find(f => f.id === 'name');
+      setSearchQuery(
+        nameFilter?.value
+          ? String(
+              typeof nameFilter.value === 'object' &&
+                nameFilter.value !== null &&
+                'value' in nameFilter.value
+                ? (nameFilter.value as { value: unknown }).value
+                : nameFilter.value,
+            )
+          : '',
+      );
+
+      // Build rison-encoded filters matching the standard {col, opr, value} format
+      const filterExps: Array<{ col: string; opr: string; value: unknown }> =
+        [];
+      filters.forEach(({ id, operator, value }) => {
+        if (value === undefined || value === null || value === '') return;
+        const scalar =
+          typeof value === 'object' &&
+          !Array.isArray(value) &&
+          value !== null &&
+          'value' in value
+            ? (value as { value: unknown }).value
+            : value;
+        switch (id) {
+          case 'name':
+            filterExps.push({ col: 'name', opr: 'ct', value: scalar });
+            break;
+          case 'type':
+            filterExps.push({ col: 'type', opr: 'in', value: scalar });
+            break;
+          case 'editors':
+            filterExps.push({ col: 'editors', opr: 'rel_m_m', value: scalar });
+            break;
+          case 'viewers':
+            filterExps.push({ col: 'viewers', opr: 'rel_m_m', value: scalar });
+            break;
+          case 'changed_on':
+            if (operator === 'between' && Array.isArray(scalar)) {
+              filterExps.push({
+                col: 'changed_on',
+                opr: 'gt',
+                value: scalar[0],
+              });
+              filterExps.push({
+                col: 'changed_on',
+                opr: 'lt',
+                value: scalar[1],
+              });
+            } else if (operator === 'gt') {
+              filterExps.push({ col: 'changed_on', opr: 'gt', value: scalar });
+            } else if (operator === 'lt') {
+              filterExps.push({ col: 'changed_on', opr: 'lt', value: scalar });
+            }
+            break;
+          case 'viz_type':
+            filterExps.push({ col: 'viz_type', opr: 'in', value: scalar });
+            break;
+          case 'datasource':
+            filterExps.push({ col: 'dataset', opr: 'in', value: scalar });
+            break;
+          case 'tags':
+            filterExps.push({ col: 'tags', opr: 'rel_m_m', value: scalar });
+            break;
+          default:
+            break;
+        }
+      });
+
+      const queryParams = rison.encode_uri({
+        order_column: sortBy[0]?.id ?? 'changed_on',
+        order_direction: sortBy[0]?.desc === false ? 'asc' : 'desc',
+        page: pageIndex,
+        page_size: pageSize,
+        ...(filterExps.length ? { filters: filterExps } : {}),
+      });
+
+      const base = currentFolder.uuid
+        ? `/api/v1/folders/${currentFolder.uuid}/assets`
+        : `/api/v1/folders/assets?folder_type=${FOLDER_TYPE}&`;
+      const separator = base.includes('?') ? '' : '?';
+      try {
+        const { json } = await SupersetClient.get({
+          endpoint: `${base}${separator}q=${queryParams}`,
+        });
+        setItems((json.result as ContentItem[]) || []);
+        setCount((json.count as number) || 0);
+      } catch {
+        addDangerToast(t('Error fetching folder contents'));
+      } finally {
+        setLoading(false);
+      }
+    },
+    [currentFolder.uuid, addDangerToast],
+  );
+
+  // Sync URL → breadcrumb on mount, back/forward, or direct link
+  useEffect(() => {
+    const currentUuid = breadcrumb[breadcrumb.length - 1]?.uuid ?? undefined;
+    if ((folderUuid ?? undefined) === (currentUuid ?? undefined)) return;
+
+    if (!folderUuid) {
+      setBreadcrumb([{ uuid: null, name: t('Analytics') }]);
+      return;
+    }
+
+    let cancelled = false;
+    (async () => {
+      const crumbs: Crumb[] = [];
+      let uuid: string | null = folderUuid;
+      try {
+        while (uuid) {
+          const response = await SupersetClient.get({
+            endpoint: `/api/v1/folders/${uuid}`,
+          });
+          const folder = response.json.result as {
+            uuid: string;
+            name: string;
+            parent_uuid: string | null;
+            user_permission?: 'editor' | 'viewer' | 'implicit' | null;
+            is_only_me?: boolean;
+          };
+          crumbs.unshift({
+            uuid: folder.uuid,
+            name: folder.name,
+            user_permission: folder.user_permission,
+            is_only_me: folder.is_only_me,
+          });
+          uuid = folder.parent_uuid;
+        }
+        crumbs.unshift({ uuid: null, name: t('Analytics') });
+        if (!cancelled) setBreadcrumb(crumbs);
+      } catch {
+        if (!cancelled) {
+          addDangerToast(t('Folder not found'));
+          history.replace('/analytics/');
+        }
+      }
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, [folderUuid]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  const drillInto = useCallback(
+    (item: ContentItem) => {
+      setExpandedKeys([]);
+      // Breadcrumb is resolved by the URL-sync effect from the folder's parent chain,
+      // so we only need to push the URL. This handles both normal navigation and
+      // jumping to deeply nested folders from search results.
+      history.push(`/analytics/${item.uuid}/`);
+    },
+    [history],
+  );
+
+  const navigateToCrumb = useCallback(
+    (index: number) => {
+      setExpandedKeys([]);
+      setBreadcrumb(prev => {
+        const sliced = prev.slice(0, index + 1);
+        const target = sliced[sliced.length - 1];
+        history.push(
+          target.uuid ? `/analytics/${target.uuid}/` : '/analytics/',
+        );
+        return sliced;
+      });
+    },
+    [history],
+  );
+
+  const toggleExpand = useCallback((rowId: string) => {
+    setExpandedKeys(prev =>
+      prev.includes(rowId)
+        ? prev.filter(key => key !== rowId)
+        : [...prev, rowId],
+    );
+  }, []);
+
+  // Lazily load a dashboard's charts, deduped via the functional updater so
+  // repeated remounts of DashboardCharts don't refetch an already-loaded entry.
+  const requestDashboardCharts = useCallback(
+    (dashboardId: number) => {
+      setChartsByDashboard(prev => {
+        if (prev[dashboardId]) return prev;
+        (async () => {
+          try {
+            const { json } = await SupersetClient.get({
+              endpoint: `/api/v1/folders/dashboard-charts/${dashboardId}`,
+            });
+            const charts = (json.result as ContentItem[]) || [];
+            setChartsByDashboard(curr => ({
+              ...curr,
+              [dashboardId]: { loading: false, charts },
+            }));
+          } catch {
+            addDangerToast(t('Error loading dashboard charts'));
+            setChartsByDashboard(curr => ({
+              ...curr,
+              [dashboardId]: { loading: false, charts: [] },
+            }));
+          }
+        })();
+        return { ...prev, [dashboardId]: { loading: true, charts: [] } };
+      });
+    },
+    [addDangerToast],
+  );
+
+  // Mirror ChartList: PropertiesModal fetches the chart itself by `slice_id`,
+  // so it only needs a minimal slice built from the row (no pre-fetch).
+  const openChartEdit = useCallback(
+    (item: ContentItem) =>
+      setEditChart({ slice_id: item.id, slice_name: item.name } as Slice),
+    [],
+  );
+
+  const exportAsset = useCallback((item: ContentItem) => {
+    handleResourceExport(
+      item.type === 'chart' ? 'chart' : 'dashboard',
+      [item.id],
+      () => {},
+    );
+  }, []);
+
+  const deleteAsset = useCallback(
+    (item: ContentItem) => {
+      if (item.type === 'chart') {
+        handleChartDelete(
+          { id: item.id, slice_name: item.name } as Parameters<
+            typeof handleChartDelete
+          >[0],
+          addSuccessToast,
+          addDangerToast,
+          refreshData,
+        );
+      } else {
+        handleDashboardDelete(
+          { id: item.id, dashboard_title: item.name } as Parameters<
+            typeof handleDashboardDelete
+          >[0],
+          refreshData,
+          addSuccessToast,
+          addDangerToast,
+        );
+      }
+    },
+    [addSuccessToast, addDangerToast, refreshData],
+  );
+
+  // Show deferred batch-delete toasts once the folder modal queue drains.
+  useEffect(() => {
+    if (!pendingBatchResult || folderDeleteQueue.length > 0) return;
+    const { succeeded, failed } = pendingBatchResult;
+    if (failed === 0) {
+      addSuccessToast(t('Successfully deleted selected items'));
+    } else if (succeeded > 0) {
+      addSuccessToast(t('Some items were deleted successfully'));
+      addDangerToast(t('Some items could not be deleted'));
+    } else {
+      addDangerToast(t('There was an issue deleting the selected items'));
+    }
+    setPendingBatchResult(null);
+  }, [folderDeleteQueue.length, pendingBatchResult]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  const handleBulkDelete = useCallback(
+    async (rows: ContentItem[]) => {
+      const charts = rows.filter(r => r.type === 'chart').map(r => r.id);
+      const dashboards = rows
+        .filter(r => r.type === 'dashboard')
+        .map(r => r.id);
+      const emptyFolders = rows.filter(
+        r =>
+          r.type === 'folder' &&
+          (r.asset_count ?? 0) === 0 &&
+          (r.children_count ?? 0) === 0,
+      );
+      const nonEmptyFolders = rows.filter(
+        r =>
+          r.type === 'folder' &&
+          ((r.asset_count ?? 0) > 0 || (r.children_count ?? 0) > 0),
+      );
+
+      const calls: Promise<unknown>[] = [];
+      if (charts.length) {
+        calls.push(
+          SupersetClient.delete({
+            endpoint: `/api/v1/chart/?q=${rison.encode(charts)}`,
+          }),
+        );
+      }
+      if (dashboards.length) {
+        calls.push(
+          SupersetClient.delete({
+            endpoint: `/api/v1/dashboard/?q=${rison.encode(dashboards)}`,
+          }),
+        );
+      }
+      emptyFolders.forEach(f =>
+        calls.push(
+          SupersetClient.delete({ endpoint: `/api/v1/folders/${f.uuid}` }),
+        ),
+      );
+
+      if (calls.length) {
+        const results = await Promise.allSettled(calls);
+        const succeeded = results.filter(r => r.status === 'fulfilled').length;
+        const failed = results.filter(r => r.status === 'rejected').length;
+        if (succeeded > 0) refreshData();
+        if (nonEmptyFolders.length > 0) {
+          // Defer toasts until after all folder modals have been handled.
+          setPendingBatchResult({ succeeded, failed });
+        } else if (failed === 0) {
+          addSuccessToast(t('Successfully deleted selected items'));
+        } else if (succeeded > 0) {
+          addSuccessToast(t('Some items were deleted successfully'));
+          addDangerToast(t('Some items could not be deleted'));
+        } else {
+          addDangerToast(t('There was an issue deleting the selected items'));
+        }
+      }
+
+      // Non-empty folders need individual confirmation via DeleteFolderModal.
+      if (nonEmptyFolders.length > 0) {
+        setFolderDeleteQueue(nonEmptyFolders);
+      }
+    },
+    [addSuccessToast, addDangerToast, refreshData],
+  );
+
+  const handleBulkExport = useCallback((rows: ContentItem[]) => {
+    const charts = rows.filter(r => r.type === 'chart').map(r => r.id);
+    const dashboards = rows.filter(r => r.type === 'dashboard').map(r => r.id);
+    if (charts.length) handleResourceExport('chart', charts, () => {});
+    if (dashboards.length)
+      handleResourceExport('dashboard', dashboards, () => {});
+  }, []);
+
+  const highlightName = useCallback(
+    (name: string) => {
+      if (!searchQuery) return name;
+      const idx = name.toLowerCase().indexOf(searchQuery.toLowerCase());
+      if (idx === -1) return name;
+      return (
+        <span>
+          {name.slice(0, idx)}
+          <mark css={{ background: theme.colorPrimaryBg, padding: 0 }}>
+            {name.slice(idx, idx + searchQuery.length)}
+          </mark>
+          {name.slice(idx + searchQuery.length)}
+        </span>
+      );
+    },
+    [searchQuery, theme.colorPrimaryBg],
+  );
+
+  const columns = useMemo(
+    () => [
+      {
+        accessor: 'name',
+        Header: t('Name'),
+        id: 'name',
+        Cell: ({ row: { original, id } }: CellProps<ContentItem>) => {
+          if (!original.type) {
+            return null;
+          }
+          const pinIcon =
+            isAtRoot && (isPinned(original) || original.is_only_me) ? (
+              <PushpinOutlined
+                style={{ fontSize: 12, color: theme.colorPrimary }}
+              />
+            ) : null;
+
+          const contextMenuItems: Array<{
+            key: string;
+            label: string;
+            disabled?: boolean;
+            onClick: () => void;
+          }> = [];
+
+          if (isAtRoot && !original.is_only_me) {
+            if (isPinned(original)) {
+              contextMenuItems.push({
+                key: 'unpin',
+                label: t('Unpin'),
+                onClick: () => unpinItem(original),
+              });
+            } else {
+              contextMenuItems.push({
+                key: 'pin',
+                label:
+                  pinnedKeys.size >= 3 ? t('Pin (max 3 reached)') : t('Pin'),
+                disabled: pinnedKeys.size >= 3,
+                onClick: () => pinItem(original),
+              });
+            }
+          }
+          if (
+            canEditCurrentFolder &&
+            !original.is_only_me &&
+            original.user_permission !== 'implicit'
+          ) {
+            contextMenuItems.push({
+              key: 'move',
+              label: t('Move to…'),
+              onClick: () => setMoveTarget(original),
+            });
+          }
+
+          const nameContent = (() => {
+            if (original.type === 'folder') {
+              return (
+                <Tooltip
+                  title={
+                    original.user_permission === 'implicit'
+                      ? t('You have limited access to this folder')
+                      : undefined
+                  }
+                >
+                  <NameLink
+                    role="button"
+                    tabIndex={0}
+                    onClick={() => drillInto(original)}
+                    onKeyDown={e => {
+                      if (e.key === 'Enter') drillInto(original);
+                    }}
+                  >
+                    {original.is_private ? (
+                      <Icons.LockOutlined
+                        iconSize="m"
+                        css={{ color: theme.colorTextSecondary }}
+                      />
+                    ) : (
+                      <Icons.FolderOutlined
+                        iconSize="m"
+                        css={{
+                          color: original.parent_uuid
+                            ? theme.colorWarning
+                            : theme.colorSuccess,
+                        }}
+                      />
+                    )}
+                    {highlightName(original.name)}
+                    {original.is_private && (
+                      <span
+                        css={{
+                          fontSize: theme.fontSizeXS,
+                          padding: `0 ${theme.sizeUnit}px`,
+                          borderRadius: theme.borderRadius,
+                          background: theme.colorBgLayout,
+                          color: theme.colorTextSecondary,
+                        }}
+                      >
+                        {t('Private')}
+                      </span>
+                    )}
+                    {pinIcon}
+                  </NameLink>
+                </Tooltip>
+              );
+            }
+            if (original.type === 'dashboard') {
+              const expanded = expandedKeys.includes(id);
+              return (
+                <NameRow>
+                  <Icons.LayoutOutlined
+                    iconSize="m"
+                    css={{ color: theme.colorWarning }}
+                  />
+                  {original.url ? (
+                    <NameLink href={original.url}>
+                      {highlightName(original.name)}
+                    </NameLink>
+                  ) : (
+                    highlightName(original.name)
+                  )}
+                  {pinIcon}
+                  <CaretButton
+                    type="button"
+                    aria-label={expanded ? t('Collapse') : t('Expand')}
+                    onClick={() => toggleExpand(id)}
+                  >
+                    {expanded ? (
+                      <MinusSquareOutlined style={{ fontSize: 12 }} />
+                    ) : (
+                      <PlusSquareOutlined style={{ fontSize: 12 }} />
+                    )}
+                  </CaretButton>
+                </NameRow>
+              );
+            }
+            return original.url ? (
+              <NameLink href={original.url}>
+                <Icons.LineChartOutlined
+                  iconSize="m"
+                  css={{ color: theme.colorErrorBorderHover }}
+                />
+                {highlightName(original.name)}
+                {original.description && (
+                  <InfoTooltip tooltip={original.description} />
+                )}
+                {pinIcon}
+              </NameLink>
+            ) : (
+              <NameRow>
+                <Icons.LineChartOutlined
+                  iconSize="m"
+                  css={{ color: theme.colorErrorBorderHover }}
+                />
+                {highlightName(original.name)}
+                {original.description && (
+                  <InfoTooltip tooltip={original.description} />
+                )}
+                {pinIcon}
+              </NameRow>
+            );
+          })();
+
+          return (
+            <Dropdown
+              trigger={['contextMenu']}
+              menu={{ items: contextMenuItems }}
+            >
+              <span>
+                {nameContent}
+                {original.folder_path && (
+                  <Flex
+                    align="center"
+                    gap={theme.sizeUnit * 0.5}
+                    css={{
+                      fontSize: theme.fontSizeXS,
+                      color: theme.colorTextSecondary,
+                      marginTop: theme.sizeUnit,
+                      overflow: 'hidden',
+                      whiteSpace: 'nowrap',
+                      textOverflow: 'ellipsis',
+                    }}
+                  >
+                    <Icons.HomeOutlined iconSize="s" />
+                    {t('Analytics')}
+                    {original.folder_path.map(p => (
+                      <Flex
+                        key={p.uuid}
+                        align="center"
+                        gap={theme.sizeUnit * 0.5}
+                      >
+                        <span css={{ margin: `0 ${theme.sizeUnit * 0.5}px` }}>
+                          /
+                        </span>
+                        <Icons.FolderOutlined iconSize="s" />
+                        {p.name}
+                      </Flex>
+                    ))}
+                  </Flex>
+                )}
+              </span>
+            </Dropdown>
+          );
+        },
+      },
+      {
+        accessor: 'type',
+        Header: t('Type'),
+        id: 'type',
+        size: 'sm',
+        Cell: ({ row: { original } }: CellProps<ContentItem>) =>
+          original.type
+            ? original.type.charAt(0).toUpperCase() + original.type.slice(1)
+            : '',
+      },
+      {
+        accessor: 'viz_type',
+        Header: t('Visualization type'),
+        id: 'viz_type',
+        disableSortBy: true,
+        Cell: ({ row: { original } }: CellProps<ContentItem>) =>
+          original.viz_type
+            ? (chartRegistry.get(original.viz_type)?.name ?? original.viz_type)
+            : null,
+      },
+      {
+        accessor: 'datasource_name',
+        Header: t('Dataset'),
+        id: 'datasource',
+        disableSortBy: false,
+        Cell: ({ row: { original } }: CellProps<ContentItem>) => {
+          if (!original.datasource_name) return null;
+          if (original.datasource_url) {
+            return (
+              <a href={original.datasource_url}>{original.datasource_name}</a>
+            );
+          }
+          return original.datasource_name;
+        },
+      },
+      {
+        accessor: 'tags',
+        Header: t('Tags'),
+        id: 'tags',
+        disableSortBy: true,
+        hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem),
+        Cell: ({ row: { original } }: CellProps<ContentItem>) => {
+          if (!original.tags?.length) return null;
+          return (
+            <TagsList
+              tags={original.tags.filter(
+                (tag: TagType) =>
+                  tag.type === 'TagTypes.custom' ||
+                  tag.type === TagTypeEnum.Custom,
+              )}
+              maxTags={3}
+            />
+          );
+        },
+      },
+      {
+        accessor: 'editors',
+        Header: t('Editors'),
+        id: 'editors',
+        disableSortBy: true,
+        Cell: ({ row: { original } }: CellProps<ContentItem>) => (
+          <SubjectPile subjects={original.editors || []} />
+        ),
+      },
+      {
+        accessor: 'viewers',
+        Header: t('Viewers'),
+        id: 'viewers',
+        disableSortBy: true,
+        Cell: ({ row: { original } }: CellProps<ContentItem>) => (
+          <SubjectPile subjects={original.viewers || []} />
+        ),
+      },
+      {
+        accessor: 'changed_on',
+        Header: t('Last modified'),
+        id: 'changed_on',
+        Cell: ({ row: { original } }: CellProps<ContentItem>) =>
+          original.changed_on_humanized ? (
+            <ModifiedInfo
+              date={original.changed_on_humanized}
+              user={original.changed_by ?? undefined}
+            />
+          ) : (
+            '-'
+          ),
+      },
+      {
+        Header: t('Actions'),
+        id: 'actions',
+        disableSortBy: true,
+        Cell: ({ row: { original } }: CellProps<ContentItem>) => {
+          if (!original.type) {
+            return null;
+          }
+          if (original.type === 'folder') {
+            const canEdit = original.user_permission === 'editor';
+            return (
+              <Actions className="actions">
+                {canEdit && !original.is_private && (
+                  <Tooltip title={t('Manage permissions')} placement="bottom">
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={() => setFolderToManagePerms(original)}
+                    >
+                      <Icons.ShareAltOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
+                {canEdit && !original.is_only_me && (
+                  <Tooltip title={t('Edit folder')} placement="bottom">
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={() => setFolderToRename(original)}
+                    >
+                      <Icons.EditOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
+                {canEdit &&
+                  original.parent_uuid &&
+                  original.inherits_permissions === false && (
+                    <Tooltip
+                      title={t('Sync permissions with parent')}
+                      placement="bottom"
+                    >
+                      <Icons.SyncOutlined
+                        iconSize="l"
+                        className="action-button"
+                        onClick={async () => {
+                          try {
+                            await SupersetClient.put({
+                              endpoint: `/api/v1/folders/${original.uuid}`,
+                              jsonPayload: { sync_permissions: true },
+                            });
+                            addSuccessToast(
+                              t('Permissions synced with parent'),
+                            );
+                            refreshData();
+                          } catch {
+                            addDangerToast(t('Error syncing permissions'));
+                          }
+                        }}
+                      />
+                    </Tooltip>
+                  )}
+                {canEdit && !original.is_only_me && (
+                  <Tooltip title={t('Delete folder')} placement="bottom">
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={() => setFolderDeleteQueue([original])}
+                    >
+                      <Icons.DeleteOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
+              </Actions>
+            );
+          }
+          // chart / dashboard rows: mirror the CRUD lists' row actions.
+          const isAssetEditor = original.editors?.some(
+            o => o.user_id === currentUserId,
+          );
+          const canEditAsset = canEditCurrentFolder || isAssetEditor;
+          return (
+            <Actions className="actions">
+              {canEditAsset && (
+                <Tooltip title={t('Edit')} placement="bottom">
+                  <span
+                    role="button"
+                    tabIndex={0}
+                    className="action-button"
+                    onClick={() =>
+                      original.type === 'chart'
+                        ? openChartEdit(original)
+                        : setEditDashboard({
+                            id: original.id,
+                            title: original.name,
+                          })
+                    }
+                  >
+                    <Icons.EditOutlined iconSize="l" />
+                  </span>
+                </Tooltip>
+              )}
+              <Tooltip title={t('Export')} placement="bottom">
+                <span
+                  role="button"
+                  tabIndex={0}
+                  className="action-button"
+                  onClick={() => exportAsset(original)}
+                >
+                  <Icons.UploadOutlined iconSize="l" />
+                </span>
+              </Tooltip>
+              {canEditAsset && (
+                <ConfirmStatusChange
+                  title={t('Please confirm')}
+                  description={t(
+                    'Are you sure you want to delete %s?',
+                    original.name,
+                  )}
+                  onConfirm={() => deleteAsset(original)}
+                >
+                  {confirmDelete => (
+                    <Tooltip title={t('Delete')} placement="bottom">
+                      <span
+                        role="button"
+                        tabIndex={0}
+                        className="action-button"
+                        onClick={confirmDelete}
+                      >
+                        <Icons.DeleteOutlined iconSize="l" />
+                      </span>
+                    </Tooltip>
+                  )}
+                </ConfirmStatusChange>
+              )}
+            </Actions>
+          );
+        },
+      },
+    ],
+    [
+      drillInto,
+      expandedKeys,
+      toggleExpand,
+      openChartEdit,
+      exportAsset,
+      deleteAsset,
+      isPinned,
+      isAtRoot,
+      canEditCurrentFolder,
+      pinnedKeys.size,
+      pinItem,
+      unpinItem,
+      highlightName,
+      theme,
+    ],
+  );
+
+  const filters: ListViewFilters = useMemo(
+    () => [
+      {
+        Header: t('Search'),
+        key: 'search',
+        id: 'name',
+        input: 'search',
+        operator: FilterOperator.Contains,
+      },
+      {
+        Header: t('Type'),
+        key: 'type',
+        id: 'type',
+        input: 'select',
+        operator: FilterOperator.Equals,
+        unfilteredLabel: t('All'),
+        selects: [
+          { label: t('Folder'), value: 'folder' },
+          { label: t('Chart'), value: 'chart' },
+          { label: t('Dashboard'), value: 'dashboard' },
+        ],
+      },
+      {
+        Header: t('Editor'),
+        key: 'editors',
+        id: 'editors',
+        input: 'select',
+        operator: FilterOperator.RelationManyMany,
+        unfilteredLabel: t('All'),
+        fetchSelects: createFetchRelated(
+          'chart',
+          'editors',
+          createErrorHandler(errMsg =>
+            addDangerToast(
+              t('An error occurred while fetching editors: %s', errMsg),
+            ),
+          ),
+        ),
+        paginate: true,
+      },
+      {
+        Header: t('Viewer'),
+        key: 'viewers',
+        id: 'viewers',
+        input: 'select',
+        operator: FilterOperator.RelationManyMany,
+        unfilteredLabel: t('All'),
+        fetchSelects: createFetchRelated(
+          'chart',
+          'viewers',
+          createErrorHandler(errMsg =>
+            addDangerToast(
+              t('An error occurred while fetching viewers: %s', errMsg),
+            ),
+          ),
+        ),
+        paginate: true,
+      },
+      {
+        Header: t('Last modified'),
+        key: 'changed_on',
+        id: 'changed_on',
+        input: 'datetime_range',
+        operator: FilterOperator.Between,
+        dateFilterValueType: 'iso',
+      },
+      {
+        Header: t('Visualization type'),
+        key: 'viz_type',
+        id: 'viz_type',
+        input: 'select',
+        operator: FilterOperator.Equals,
+        unfilteredLabel: t('All'),
+        selects: chartRegistry
+          .keys()
+          .filter(k => nativeFilterGate(chartRegistry.get(k)?.behaviors ?? []))
+          .map(k => ({ label: chartRegistry.get(k)?.name ?? k, value: k }))
+          .sort((a, b) => a.label.localeCompare(b.label)),
+      },
+      {
+        Header: t('Dataset'),
+        key: 'datasource',
+        id: 'datasource',
+        input: 'select',
+        operator: FilterOperator.Equals,
+        unfilteredLabel: t('All'),
+        fetchSelects: createFetchDatasets,
+        paginate: true,
+      },
+      ...(isFeatureEnabled(FeatureFlag.TaggingSystem)
+        ? [
+            {
+              Header: t('Tag'),
+              key: 'tags',
+              id: 'tags',
+              input: 'select' as const,
+              operator: FilterOperator.RelationManyMany,
+              unfilteredLabel: t('All'),
+              fetchSelects: loadTags,
+            },
+          ]
+        : []),
+    ],
+    [addDangerToast],
+  );
+
+  // Pinned-on-top sorting is handled by the backend
+  const sortedItems = items;
+
+  const expandable = useMemo(
+    () => ({
+      // Expansion is driven by the caret in the Name cell, so hide antd's
+      // default expand column and control the open rows ourselves.
+      showExpandColumn: false,
+      expandedRowKeys: expandedKeys,
+      onExpandedRowsChange: (keys: readonly (string | number | bigint)[]) =>
+        setExpandedKeys(keys.map(String)),
+      rowExpandable: (record: Record<string, unknown>) =>
+        record.type === 'dashboard',
+      expandedRowRender: (record: Record<string, unknown>) => {
+        const dashboardId = record.id as number;
+        const entry = chartsByDashboard[dashboardId];
+        return (
+          <DashboardCharts
+            dashboardId={dashboardId}
+            charts={entry?.charts ?? []}
+            loading={entry?.loading ?? true}
+            onRequest={requestDashboardCharts}
+          />
+        );
+      },
+    }),
+    [chartsByDashboard, requestDashboardCharts, expandedKeys],
+  );
+
+  const breadcrumbItems: FolderBreadcrumbItem[] = useMemo(
+    () =>
+      breadcrumb.map((crumb, index) => ({
+        key: crumb.uuid ?? 'root',
+        title:
+          index === 0 ? (
+            <Flex align="center" gap={4}>
+              <Icons.HomeOutlined iconSize="m" />
+              {crumb.name}
+            </Flex>
+          ) : (
+            crumb.name
+          ),
+        hideIcon: index === 0,
+        onClick: () => navigateToCrumb(index),
+      })),
+    [breadcrumb, navigateToCrumb],
+  );
+
+  const menuData: SubMenuProps = {
+    name: t('Analytics'),
+    buttons: [
+      {
+        name: bulkSelectEnabled ? t('Cancel') : t('Bulk select'),
+        buttonStyle: 'secondary',
+        onClick: () => setBulkSelectEnabled(prev => !prev),
+      },
+      {
+        name: t('Move assets'),
+        buttonStyle: 'secondary',
+        onClick: () => setShowTransferModal(true),
+        disabled: !hasFolders,
+        tooltip: !hasFolders
+          ? t('No folders yet. Create one with "New" to move assets.')
+          : undefined,
+      },
+      {
+        name: t('Import'),
+        buttonStyle: 'secondary',
+        component: (
+          <Dropdown
+            trigger={['click']}
+            menu={{
+              items: [
+                {
+                  key: 'import-chart',
+                  icon: <Icons.LineChartOutlined />,
+                  label: t('Chart'),
+                  onClick: () => setImportResource('chart'),
+                },
+                {
+                  key: 'import-dashboard',
+                  icon: <Icons.LayoutOutlined />,
+                  label: t('Dashboard'),
+                  onClick: () => setImportResource('dashboard'),
+                },
+              ],
+            }}
+          >
+            <Button buttonStyle="secondary">
+              <Icons.DownloadOutlined iconSize="m" /> {t('Import')}{' '}
+              <Icons.DownOutlined iconSize="m" />
+            </Button>
+          </Dropdown>
+        ),
+      },
+      {
+        name: t('New'),
+        buttonStyle: 'primary',
+        component: (
+          <Dropdown
+            trigger={['click']}
+            menu={{
+              items: [
+                {
+                  key: 'chart',
+                  icon: <Icons.LineChartOutlined />,
+                  label: t('Chart'),
+                  onClick: () => history.push('/chart/add'),
+                },
+                {
+                  key: 'dashboard',
+                  icon: <Icons.LayoutOutlined />,
+                  label: t('Dashboard'),
+                  onClick: () => window.location.assign('/dashboard/new'),
+                },
+                ...(canEditCurrentFolder
+                  ? [
+                      {
+                        key: 'folder',
+                        icon: <Icons.FolderOutlined />,
+                        label: t('Folder'),
+                        onClick: () => setShowCreateFolder(true),
+                      },
+                    ]
+                  : []),
+              ],
+            }}
+          >
+            <Button buttonStyle="primary">
+              <Icons.PlusOutlined iconSize="m" /> {t('New')}{' '}
+              <Icons.DownOutlined iconSize="m" />
+            </Button>
+          </Dropdown>
+        ),
+      } as any,
+    ],
+  };
+
+  // Persistent hover-like highlight on expanded dashboard rows via dynamic CSS,
+  // since antd Table doesn't support styling the parent row of an expanded section.
+  const expandedRowStyle = useMemo(() => {
+    if (!expandedKeys.length) return null;
+    const selectors = expandedKeys
+      .map(key => `.ant-table-row[data-row-key="${key}"] > td`)
+      .join(', ');
+    return (
+      <style>{`${selectors} { background: ${theme.controlItemBgHover} !important; }`}</style>
+    );
+  }, [expandedKeys, theme.controlItemBgHover]);
+
+  return (
+    <>
+      {expandedRowStyle}
+      {showCreateFolder && (
+        <CreateFolderModal
+          show
+          parentFolderUuid={currentFolder.uuid}
+          onHide={() => setShowCreateFolder(false)}
+          onSuccess={refreshData}
+          addDangerToast={addDangerToast}
+          addSuccessToast={addSuccessToast}
+        />
+      )}
+      {folderToRename && (
+        <EditFolderModal
+          folderUuid={folderToRename.uuid ?? ''}
+          currentName={folderToRename.name}
+          currentDescription={folderToRename.description}
+          show
+          onHide={() => setFolderToRename(null)}
+          onSuccess={refreshData}
+          addDangerToast={addDangerToast}
+          addSuccessToast={addSuccessToast}
+        />
+      )}
+      {folderToManagePerms && (
+        <FolderPermissionsModal
+          folderUuid={folderToManagePerms.uuid ?? ''}
+          folderName={folderToManagePerms.name}
+          currentUserId={currentUserId}
+          isSubfolder={!!folderToManagePerms.parent_uuid}
+          show
+          onHide={() => {
+            setFolderToManagePerms(null);
+            refreshData();
+          }}
+          addDangerToast={addDangerToast}
+          addSuccessToast={addSuccessToast}
+        />
+      )}
+      {(moveTarget || showTransferModal) && (
+        <TransferModal
+          currentFolderUuid={currentFolder.uuid}
+          currentFolderName={currentFolder.name}
+          currentFolderIsPrivate={breadcrumb.some(c => c.is_only_me)}
+          preSelectedKeys={
+            moveTarget ? [`${moveTarget.type}-${moveTarget.id}`] : []
+          }
+          show
+          onHide={() => {
+            setMoveTarget(null);
+            setShowTransferModal(false);
+          }}
+          onSuccess={refreshData}
+        />
+      )}
+      {editChart && (
+        <ChartPropertiesModal
+          slice={editChart}
+          show
+          onHide={() => {
+            pendingFolderMove.current = null;
+            setFolderValidationError(null);
+            setEditChart(null);
+          }}
+          onSave={async () => {
+            await executePendingFolderMove();
+            setEditChart(null);
+            refreshData();
+          }}
+          renderExtraFields={({
+            assetId,
+            assetType,
+            accessorCount,
+          }: ExtraFieldsContext) => ({
+            content: (
+              <FolderSelector
+                assetId={assetId}
+                assetType={assetType}
+                accessorCount={accessorCount}
+                onChange={folderUuid => {
+                  pendingFolderMove.current = {
+                    assetType,
+                    assetId,
+                    folderUuid,
+                  };
+                }}
+                onValidationError={setFolderValidationError}
+              />
+            ),
+            saveDisabled: !!folderValidationError,
+            saveTooltip: folderValidationError ?? undefined,
+          })}
+        />
+      )}
+      {folderDeleteQueue.length > 0 && (
+        <DeleteFolderModal
+          folder={{
+            uuid: folderDeleteQueue[0].uuid ?? '',
+            name: folderDeleteQueue[0].name,
+            asset_count: folderDeleteQueue[0].asset_count ?? 0,
+            children_count: folderDeleteQueue[0].children_count ?? 0,
+          }}
+          show
+          silent={pendingBatchResult !== null || folderDeleteQueue.length > 1}
+          onHide={() =>
+            setFolderDeleteQueue((prev: ContentItem[]) => prev.slice(1))
+          }
+          onSuccess={() => {
+            setFolderDeleteQueue((prev: ContentItem[]) => prev.slice(1));
+            refreshData();
+          }}
+          addDangerToast={addDangerToast}
+          addSuccessToast={addSuccessToast}
+        />
+      )}
+      {editDashboard && (
+        <DashboardPropertiesModal
+          dashboardId={editDashboard.id}
+          dashboardTitle={editDashboard.title}
+          show
+          onHide={() => {
+            pendingFolderMove.current = null;
+            setFolderValidationError(null);
+            setEditDashboard(null);
+          }}
+          onSubmit={async () => {
+            await executePendingFolderMove();
+            setEditDashboard(null);
+            refreshData();
+          }}
+          renderExtraFields={({
+            assetId,
+            assetType,
+            accessorCount,
+          }: ExtraFieldsContext) => ({
+            content: (
+              <FolderSelector
+                assetId={assetId}
+                assetType={assetType}
+                accessorCount={accessorCount}
+                onChange={folderUuid => {
+                  pendingFolderMove.current = {
+                    assetType,
+                    assetId,
+                    folderUuid,
+                  };
+                }}
+                onValidationError={setFolderValidationError}
+              />
+            ),
+            saveDisabled: !!folderValidationError,
+            saveTooltip: folderValidationError ?? undefined,
+          })}
+        />
+      )}
+      {importResource && (
+        <ImportModelsModal
+          resourceName={importResource}
+          resourceLabel={
+            importResource === 'chart' ? t('chart') : t('dashboard')
+          }
+          passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE}
+          confirmOverwriteMessage={CONFIRM_OVERWRITE_MESSAGE}
+          addDangerToast={addDangerToast}
+          addSuccessToast={addSuccessToast}
+          onModelImport={() => {
+            setImportResource(null);
+            refreshData();
+            addSuccessToast(
+              importResource === 'chart'
+                ? t('Chart imported')
+                : t('Dashboard imported'),
+            );
+          }}
+          show
+          onHide={() => setImportResource(null)}
+          passwordFields={passwordFields}
+          setPasswordFields={setPasswordFields}
+          sshTunnelPasswordFields={sshTunnelPasswordFields}
+          setSSHTunnelPasswordFields={setSSHTunnelPasswordFields}
+          sshTunnelPrivateKeyFields={sshTunnelPrivateKeyFields}
+          setSSHTunnelPrivateKeyFields={setSSHTunnelPrivateKeyFields}
+          sshTunnelPrivateKeyPasswordFields={sshTunnelPrivateKeyPasswordFields}
+          setSSHTunnelPrivateKeyPasswordFields={
+            setSSHTunnelPrivateKeyPasswordFields
+          }
+        />
+      )}
+      {!breadcrumbLoading && <SubMenu {...menuData} />}
+      {!breadcrumbLoading && (
+        <ConfirmStatusChange
+          title={t('Please confirm')}
+          description={t('Are you sure you want to delete the selected items?')}
+          onConfirm={handleBulkDelete}
+        >
+          {confirmDelete => (
+            <ListView<ContentItem>
+              key={`${currentFolder.uuid ?? 'root'}-${refreshKey}`}
+              className="analytics-list-view"
+              columns={columns}
+              count={count}
+              data={sortedItems}
+              fetchData={fetchData}
+              refreshData={refreshData}
+              loading={loading}
+              initialSort={[{ id: 'changed_on', desc: true }]}
+              pageSize={PAGE_SIZE}
+              filters={filters}
+              expandable={expandable}
+              defaultViewMode="table"
+              bulkSelectEnabled={bulkSelectEnabled}
+              disableBulkSelect={() => setBulkSelectEnabled(false)}
+              headerContent={
+                isAtRoot ? null : (
+                  <>
+                    <BreadcrumbWrap>
+                      <FolderBreadcrumb items={breadcrumbItems} />
+                    </BreadcrumbWrap>
+                    {currentFolder.is_only_me && (
+                      <Alert
+                        type="success"
+                        showIcon
+                        message={
+                          <>
+                            <span style={{ fontWeight: 800 }}>
+                              {t("Only you can see what's in this folder.")}
+                            </span>{' '}
+                            {t(
+                              'New charts and dashboards land here by default. Move them out to share. Permissions then inherit from the destination folder.',
+                            )}
+                          </>
+                        }
+                        css={{
+                          marginBottom: theme.sizeUnit * 2,
+                          padding: `${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px`,
+                        }}
+                      />
+                    )}
+                  </>
+                )
+              }
+              bulkActions={[
+                ...(canEditCurrentFolder
+                  ? [
+                      {
+                        key: 'delete',
+                        name: t('Delete'),
+                        type: 'danger' as const,
+                        onSelect: confirmDelete,
+                      },
+                    ]
+                  : []),
+                {
+                  key: 'export',
+                  name: (
+                    <Tooltip
+                      title={t(
+                        'Export is only available for dashboards and charts',
+                      )}
+                    >
+                      <Flex gap={theme.sizeUnit}>
+                        <Icons.WarningOutlined iconSize="s" />
+                        {t('Export')}
+                      </Flex>
+                    </Tooltip>
+                  ),
+                  type: 'primary',
+                  onSelect: handleBulkExport,
+                  hidden: (rows: ContentItem[]) =>
+                    rows.every(r => r.type === 'folder'),
+                },
+              ]}
+              addSuccessToast={addSuccessToast}
+              addDangerToast={addDangerToast}
+            />
+          )}
+        </ConfirmStatusChange>
+      )}
+    </>
+  );
+}
+
+export default withToasts(AnalyticsList);
diff --git a/superset-frontend/src/pages/AnalyticsList/styles.ts b/superset-frontend/src/pages/AnalyticsList/styles.ts
new file mode 100644
index 00000000000..4e00ac34cd5
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/styles.ts
@@ -0,0 +1,25 @@
+/**
+ * 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 { styled, css } from '@apache-superset/core/theme';
+
+export const ModalContent = styled.div`
+  ${({ theme }) => css`
+    padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
+  `}
+`;
diff --git a/superset-frontend/src/pages/AnalyticsList/types.ts b/superset-frontend/src/pages/AnalyticsList/types.ts
new file mode 100644
index 00000000000..fee77945cc6
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/types.ts
@@ -0,0 +1,62 @@
+/**
+ * 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 type { TagType } from 'src/components';
+
+export type ItemType = 'folder' | 'chart' | 'dashboard';
+
+export interface User {
+  id: number;
+  first_name?: string;
+  last_name?: string;
+}
+
+export interface AssetEditor {
+  id: number;
+  label: string;
+  type: number;
+  /** Set for user subjects; null for roles and groups. */
+  user_id?: number | null;
+}
+
+/** A contents row (folder or asset) from /api/v1/folders/[<uuid>/]assets. */
+export interface ContentItem {
+  type: ItemType;
+  id: number;
+  uuid: string | null;
+  name: string;
+  description?: string | null;
+  url?: string | null;
+  viz_type?: string | null;
+  datasource_name?: string | null;
+  datasource_url?: string | null;
+  editors?: AssetEditor[];
+  viewers?: AssetEditor[];
+  changed_on?: string;
+  changed_on_humanized?: string | null;
+  changed_by?: User | null;
+  tags?: TagType[];
+  asset_count?: number;
+  children_count?: number;
+  user_permission?: 'editor' | 'viewer' | 'implicit' | null;
+  parent_uuid?: string | null;
+  inherits_permissions?: boolean;
+  is_private?: boolean;
+  is_only_me?: boolean;
+  folder_path?: Array<{ uuid: string; name: string }>;
+}
diff --git a/superset-frontend/src/pages/components/FolderBreadcrumb.stories.tsx b/superset-frontend/src/pages/components/FolderBreadcrumb.stories.tsx
new file mode 100644
index 00000000000..3b9c4f1e578
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderBreadcrumb.stories.tsx
@@ -0,0 +1,83 @@
+/**
+ * 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 { action } from 'storybook/actions';
+import { FolderBreadcrumb } from './FolderBreadcrumb';
+import type { FolderBreadcrumbProps } from './types';
+
+export default {
+  title: 'Components/Folders/FolderBreadcrumb',
+  component: FolderBreadcrumb,
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'Folder navigation breadcrumb. Uses a `>` separator, a folder icon per ' +
+          'segment (open folder for the current location), and semantic emphasis ' +
+          'on the trailing segment.',
+      },
+    },
+  },
+};
+
+const samplePath = [
+  { key: 'root', title: 'All assets' },
+  { key: 'marketing', title: 'Marketing' },
+  { key: 'q2', title: 'Q2 Campaigns' },
+];
+
+export const InteractiveFolderBreadcrumb = (args: FolderBreadcrumbProps) => (
+  <FolderBreadcrumb {...args} />
+);
+
+InteractiveFolderBreadcrumb.args = {
+  items: samplePath,
+  separator: '>',
+};
+
+InteractiveFolderBreadcrumb.argTypes = {
+  separator: {
+    description: 'Separator rendered between segments.',
+    control: 'text',
+  },
+  items: {
+    description:
+      'Ordered folder path from root to current; the last item is the current location.',
+    control: false,
+  },
+};
+
+export const Clickable = () => (
+  <FolderBreadcrumb
+    items={samplePath.map(item => ({
+      ...item,
+      onClick: action(`navigate:${item.key}`),
+    }))}
+  />
+);
+
+Clickable.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        'Parent segments become links when given an `onClick`; hovering shows the ' +
+        'primary color and clicking fires the handler.',
+    },
+  },
+};
diff --git a/superset-frontend/src/pages/components/FolderBreadcrumb.test.tsx b/superset-frontend/src/pages/components/FolderBreadcrumb.test.tsx
new file mode 100644
index 00000000000..3d61dbf0042
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderBreadcrumb.test.tsx
@@ -0,0 +1,87 @@
+/**
+ * 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 { render, screen, userEvent } from '@superset-ui/core/spec';
+import { FolderBreadcrumb } from './FolderBreadcrumb';
+import type { FolderBreadcrumbItem } from './types';
+
+const items: FolderBreadcrumbItem[] = [
+  { key: 'root', title: 'All assets' },
+  { key: 'analytics', title: 'Analytics' },
+  { key: 'sales', title: 'Sales' },
+];
+
+test('renders every segment in order', () => {
+  render(<FolderBreadcrumb items={items} />);
+
+  expect(screen.getByText('All assets')).toBeInTheDocument();
+  expect(screen.getByText('Analytics')).toBeInTheDocument();
+  expect(screen.getByText('Sales')).toBeInTheDocument();
+});
+
+test('renders the default ">" separator between segments', () => {
+  render(<FolderBreadcrumb items={items} />);
+
+  expect(screen.getAllByText('>')).toHaveLength(items.length - 1);
+});
+
+test('renders a custom separator', () => {
+  render(<FolderBreadcrumb items={items} separator="/" />);
+
+  expect(screen.getAllByText('/')).toHaveLength(items.length - 1);
+  expect(screen.queryByText('>')).not.toBeInTheDocument();
+});
+
+test('marks the trailing segment as current with the open-folder icon', () => {
+  render(<FolderBreadcrumb items={items} />);
+
+  // Ancestors get the closed-folder icon; the current leaf gets the open one.
+  expect(screen.getAllByTestId('folder')).toHaveLength(items.length - 1);
+  expect(screen.getByTestId('folder-open')).toBeInTheDocument();
+});
+
+test('omits the icon for a segment with hideIcon', () => {
+  render(
+    <FolderBreadcrumb
+      items={[
+        { key: 'root', title: 'All assets', hideIcon: true },
+        { key: 'sales', title: 'Sales' },
+      ]}
+    />,
+  );
+
+  // The only icon left is the current leaf's open-folder icon.
+  expect(screen.queryByTestId('folder')).not.toBeInTheDocument();
+  expect(screen.getByTestId('folder-open')).toBeInTheDocument();
+});
+
+test('invokes onClick with the segment key', async () => {
+  const onClick = jest.fn();
+  render(
+    <FolderBreadcrumb
+      items={[
+        { key: 'root', title: 'All assets', onClick },
+        { key: 'sales', title: 'Sales' },
+      ]}
+    />,
+  );
+
+  await userEvent.click(screen.getByText('All assets'));
+
+  expect(onClick).toHaveBeenCalledWith('root');
+});
diff --git a/superset-frontend/src/pages/components/FolderBreadcrumb.tsx b/superset-frontend/src/pages/components/FolderBreadcrumb.tsx
new file mode 100644
index 00000000000..48aeb52f48c
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderBreadcrumb.tsx
@@ -0,0 +1,99 @@
+/**
+ * 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 { styled } from '@apache-superset/core/theme';
+import { Breadcrumb as AntdBreadcrumb } from 'antd';
+import { FolderIcon, FolderOpenIcon } from './assetIcons';
+import type { FolderBreadcrumbProps } from './types';
+
+const StyledBreadcrumb = styled(AntdBreadcrumb)`
+  ${({ theme }) => `
+    margin: ${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 4}px;
+    font-size: ${theme.fontSize}px;
+    line-height: ${theme.sizeUnit * 6}px;
+
+    .ant-breadcrumb-separator {
+      color: ${theme.colorTextTertiary};
+      margin-inline: ${theme.sizeUnit * 2}px;
+    }
+
+    .ant-breadcrumb-link {
+      color: ${theme.colorTextSecondary};
+    }
+
+    a.ant-breadcrumb-link:hover,
+    .ant-breadcrumb-link a:hover {
+      color: ${theme.colorPrimary};
+      background: transparent;
+    }
+
+    /* Semantic emphasis: the trailing item is the current location. */
+    li:last-of-type .ant-breadcrumb-link {
+      color: ${theme.colorText};
+      font-weight: ${theme.fontWeightStrong};
+    }
+  `}
+`;
+
+const ItemLabel = styled.span<{ clickable?: boolean }>`
+  display: inline-flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+  ${({ clickable, theme }) =>
+    clickable &&
+    `
+      cursor: pointer;
+
+      &:hover {
+        color: ${theme.colorPrimary};
+      }
+    `}
+`;
+
+const DEFAULT_SEPARATOR = '>';
+
+export function FolderBreadcrumb({
+  items,
+  separator = DEFAULT_SEPARATOR,
+  className,
+}: FolderBreadcrumbProps) {
+  const antdItems = items.map((item, index) => {
+    const isCurrent = index === items.length - 1;
+    const Icon = isCurrent ? FolderOpenIcon : FolderIcon;
+    const clickable = !isCurrent && Boolean(item.onClick || item.href);
+    return {
+      key: item.key,
+      href: item.href,
+      onClick: item.onClick ? () => item.onClick?.(item.key) : undefined,
+      title: (
+        <ItemLabel clickable={clickable}>
+          {!item.hideIcon && <Icon iconSize="m" aria-hidden />}
+          {item.title}
+        </ItemLabel>
+      ),
+    };
+  });
+
+  return (
+    <StyledBreadcrumb
+      className={className}
+      separator={separator}
+      items={antdItems}
+    />
+  );
+}
diff --git a/superset-frontend/src/pages/components/FolderList.stories.tsx b/superset-frontend/src/pages/components/FolderList.stories.tsx
new file mode 100644
index 00000000000..bc6b1ccb9af
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderList.stories.tsx
@@ -0,0 +1,298 @@
+/**
+ * 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 { action } from 'storybook/actions';
+import { FolderBreadcrumb } from './FolderBreadcrumb';
+import { FolderList } from './FolderList';
+import type {
+  Folder,
+  FolderAsset,
+  FolderBreadcrumbItem,
+  FolderListProps,
+} from './types';
+
+const sampleFolders: Folder[] = [
+  {
+    key: 'sales',
+    title: 'Sales',
+    assets: [
+      { key: 'd1', name: 'Revenue Overview', type: 'dashboard' },
+      { key: 'd2', name: 'Pipeline Health', type: 'dashboard' },
+      { key: 'ds1', name: 'opportunities', type: 'dataset' },
+    ],
+  },
+  {
+    key: 'marketing',
+    title: 'Marketing',
+    assets: [
+      { key: 'd3', name: 'Campaign Performance', type: 'dashboard' },
+      { key: 'c1', name: 'Spend by Channel', type: 'chart' },
+      { key: 'ds2', name: 'web_events', type: 'dataset' },
+      { key: 'ds3', name: 'ad_spend', type: 'dataset' },
+    ],
+  },
+  {
+    key: 'product',
+    title: 'Product',
+    assets: [{ key: 'c2', name: 'Daily Active Users', type: 'chart' }],
+  },
+];
+
+/**
+ * A nested hierarchy for the drill-down story: top-level folders, each holding
+ * dashboards, each dashboard holding its charts.
+ */
+const dashboardsByFolder: {
+  key: string;
+  title: string;
+  dashboards: { key: string; title: string; charts: FolderAsset[] }[];
+}[] = [
+  {
+    key: 'sales',
+    title: 'Sales',
+    dashboards: [
+      {
+        key: 'revenue',
+        title: 'Revenue Overview',
+        charts: [
+          { key: 'mrr', name: 'MRR by month', type: 'chart' },
+          { key: 'region', name: 'Revenue by region', type: 'chart' },
+        ],
+      },
+      {
+        key: 'pipeline',
+        title: 'Pipeline Health',
+        charts: [{ key: 'opps', name: 'Open opportunities', type: 'chart' }],
+      },
+    ],
+  },
+  {
+    key: 'marketing',
+    title: 'Marketing',
+    dashboards: [
+      {
+        key: 'campaigns',
+        title: 'Campaign Performance',
+        charts: [
+          { key: 'spend', name: 'Spend by channel', type: 'chart' },
+          { key: 'conversions', name: 'Conversions', type: 'chart' },
+        ],
+      },
+    ],
+  },
+];
+
+export default {
+  title: 'Components/Folders/FolderList',
+  component: FolderList,
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'A list of folders. A row whose `type` is listed in `expandableTypes` ' +
+          'is a Collapse panel that expands to reveal its assets (or any custom ' +
+          'body supplied via `renderExpandedContent`). Any other row is a single, ' +
+          'non-collapsible entry that calls `onFolderClick` instead — e.g. to ' +
+          'navigate into its own page. With the default empty `expandableTypes`, ' +
+          'nothing expands.',
+      },
+    },
+  },
+};
+
+export const InteractiveFolderList = (args: FolderListProps) => (
+  <FolderList {...args} />
+);
+
+InteractiveFolderList.args = {
+  folders: sampleFolders,
+  expandableTypes: ['folder'],
+  defaultActiveKeys: ['sales'],
+  accordion: false,
+  showCount: true,
+  onAssetClick: action('asset-click'),
+  onFolderClick: action('folder-click'),
+};
+
+InteractiveFolderList.argTypes = {
+  expandableTypes: {
+    description:
+      'Entry types that expand in place. Types left out render as a single ' +
+      'row that fires onFolderClick instead.',
+    control: 'check',
+    options: ['folder', 'dashboard', 'dataset', 'chart'],
+  },
+  accordion: {
+    description: 'Only allow a single folder to be open at a time.',
+    control: 'boolean',
+  },
+  showCount: {
+    description: 'Show the asset-count badge next to each folder title.',
+    control: 'boolean',
+  },
+  defaultActiveKeys: {
+    description: 'Keys of folders expanded on first render.',
+    control: false,
+  },
+  folders: {
+    description: 'Folders to render, each with its nested assets.',
+    control: false,
+  },
+};
+
+export const NavigableFolders = () => (
+  <div>
+    <FolderBreadcrumb
+      items={[
+        { key: 'root', title: 'All assets', onClick: action('navigate:root') },
+        { key: 'analytics', title: 'Analytics' },
+      ]}
+    />
+    <FolderList
+      folders={sampleFolders}
+      onFolderClick={action('navigate:folder')}
+    />
+  </div>
+);
+
+NavigableFolders.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        'With an empty `expandableTypes`, folders do not expand; clicking a row ' +
+        'fires `onFolderClick` to drill into it, tracked by the breadcrumb.',
+    },
+  },
+};
+
+export const CustomExpandedContent = () => (
+  <FolderList
+    folders={sampleFolders}
+    expandableTypes={['folder']}
+    defaultActiveKeys={['sales']}
+    renderExpandedContent={folder => (
+      <div style={{ padding: 12 }}>
+        Custom body for <strong>{String(folder.title)}</strong> —{' '}
+        {folder.assets.length} assets
+      </div>
+    )}
+  />
+);
+
+CustomExpandedContent.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        '`renderExpandedContent` replaces the default asset list with arbitrary ' +
+        'content, e.g. a preview for an expandable dashboard entry.',
+    },
+  },
+};
+
+export const DrillIntoExpandableDashboards = () => {
+  const [openFolder, setOpenFolder] = useState<string | null>(null);
+  const folder = dashboardsByFolder.find(entry => entry.key === openFolder);
+
+  const breadcrumbItems: FolderBreadcrumbItem[] = [
+    { key: 'root', title: 'All assets', onClick: () => setOpenFolder(null) },
+    ...(folder ? [{ key: folder.key, title: folder.title }] : []),
+  ];
+
+  // At the root we list folders, which navigate on click and extend the
+  // breadcrumb. Inside a folder we list its dashboards — the only type in
+  // `expandableTypes` — so each expands in place to reveal its charts.
+  const folders: Folder[] = folder
+    ? folder.dashboards.map(
+        (dashboard): Folder => ({
+          key: dashboard.key,
+          title: dashboard.title,
+          type: 'dashboard',
+          assets: dashboard.charts,
+        }),
+      )
+    : dashboardsByFolder.map(
+        (entry): Folder => ({
+          key: entry.key,
+          title: entry.title,
+          type: 'folder',
+          assets: entry.dashboards.map(
+            (dashboard): FolderAsset => ({
+              key: dashboard.key,
+              name: dashboard.title,
+              type: 'dashboard',
+            }),
+          ),
+        }),
+      );
+
+  return (
+    <div>
+      <FolderBreadcrumb items={breadcrumbItems} />
+      <FolderList
+        folders={folders}
+        expandableTypes={['dashboard']}
+        onFolderClick={({ key }) => setOpenFolder(key)}
+        onAssetClick={action('open-chart')}
+      />
+    </div>
+  );
+};
+
+DrillIntoExpandableDashboards.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        'Only the `dashboard` type is expandable. At the root, folders are not ' +
+        'expandable: clicking one drills in and extends the breadcrumb, whose ' +
+        'segments are clickable to navigate back. Inside a folder, each ' +
+        'dashboard expands in place to reveal its charts.',
+    },
+  },
+};
+
+export const WithBreadcrumb = () => (
+  <div>
+    <FolderBreadcrumb
+      items={[
+        { key: 'root', title: 'All assets', onClick: action('navigate:root') },
+        { key: 'analytics', title: 'Analytics' },
+      ]}
+    />
+    <FolderList
+      folders={sampleFolders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales', 'marketing']}
+      onAssetClick={action('asset-click')}
+    />
+  </div>
+);
+
+WithBreadcrumb.parameters = {
+  controls: { disable: true },
+  docs: {
+    description: {
+      story:
+        'Breadcrumb and folder list composed together, as they appear on an ' +
+        'analytics listing page.',
+    },
+  },
+};
diff --git a/superset-frontend/src/pages/components/FolderList.test.tsx b/superset-frontend/src/pages/components/FolderList.test.tsx
new file mode 100644
index 00000000000..f681016fa63
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderList.test.tsx
@@ -0,0 +1,200 @@
+/**
+ * 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 { render, screen, userEvent } from '@superset-ui/core/spec';
+import { FolderList } from './FolderList';
+import type { Folder } from './types';
+
+const folders: Folder[] = [
+  {
+    key: 'sales',
+    title: 'Sales',
+    assets: [
+      { key: 'd1', name: 'Revenue Overview', type: 'dashboard' },
+      { key: 'ds1', name: 'opportunities', type: 'dataset' },
+    ],
+  },
+  {
+    key: 'marketing',
+    title: 'Marketing',
+    assets: [{ key: 'c1', name: 'Spend by Channel', type: 'chart' }],
+  },
+];
+
+test('renders a header with the title and asset count for each folder', () => {
+  render(<FolderList folders={folders} expandableTypes={['folder']} />);
+
+  expect(screen.getByRole('button', { name: /Sales/ })).toHaveTextContent('2');
+  expect(screen.getByRole('button', { name: /Marketing/ })).toHaveTextContent(
+    '1',
+  );
+});
+
+test('omits the asset count when showCount is false', () => {
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      showCount={false}
+    />,
+  );
+
+  expect(screen.getByRole('button', { name: /Sales/ })).not.toHaveTextContent(
+    '2',
+  );
+  expect(
+    screen.getByRole('button', { name: /Marketing/ }),
+  ).not.toHaveTextContent('1');
+});
+
+test('does not expand folders whose type is not in expandableTypes', () => {
+  render(<FolderList folders={folders} />);
+
+  // Headers still render, but there is no toggle and the assets stay hidden.
+  expect(screen.getByText('Sales')).toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', { name: /Sales/ }),
+  ).not.toBeInTheDocument();
+  expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
+});
+
+test('keeps folder contents collapsed by default', () => {
+  render(<FolderList folders={folders} expandableTypes={['folder']} />);
+
+  expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
+  expect(screen.queryByText('Spend by Channel')).not.toBeInTheDocument();
+});
+
+test('expands the folders named in defaultActiveKeys', () => {
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+    />,
+  );
+
+  expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
+  expect(screen.getByText('opportunities')).toBeInTheDocument();
+  expect(screen.queryByText('Spend by Channel')).not.toBeInTheDocument();
+});
+
+test('reveals folder assets when its header is clicked', async () => {
+  render(<FolderList folders={folders} expandableTypes={['folder']} />);
+
+  await userEvent.click(screen.getByRole('button', { name: /Marketing/ }));
+
+  expect(screen.getByText('Spend by Channel')).toBeInTheDocument();
+});
+
+test('calls onAssetClick with the clicked asset', async () => {
+  const onAssetClick = jest.fn();
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+      onAssetClick={onAssetClick}
+    />,
+  );
+
+  await userEvent.click(screen.getByText('Revenue Overview'));
+
+  expect(onAssetClick).toHaveBeenCalledWith(folders[0].assets[0]);
+});
+
+test('accordion mode hides the open folder when another is opened', async () => {
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+      accordion
+    />,
+  );
+
+  expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
+
+  // In accordion mode antd renders the headers as tabs rather than buttons.
+  await userEvent.click(screen.getByRole('tab', { name: /Marketing/ }));
+
+  expect(screen.getByText('Spend by Channel')).toBeInTheDocument();
+  // antd keeps collapsed content mounted but flags it hidden.
+  expect(
+    screen.getByText('Revenue Overview').closest('.ant-collapse-content'),
+  ).toHaveClass('ant-collapse-content-hidden');
+});
+
+test('calls onFolderClick when a non-expandable row is clicked', async () => {
+  const onFolderClick = jest.fn();
+  render(<FolderList folders={folders} onFolderClick={onFolderClick} />);
+
+  await userEvent.click(screen.getByRole('button', { name: /Sales/ }));
+
+  expect(onFolderClick).toHaveBeenCalledWith(folders[0]);
+  // Clicking navigates rather than expanding, so the assets stay hidden.
+  expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
+});
+
+test('renders custom expanded content instead of the asset list', () => {
+  render(
+    <FolderList
+      folders={folders}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+      renderExpandedContent={folder => <div>preview of {folder.key}</div>}
+    />,
+  );
+
+  expect(screen.getByText('preview of sales')).toBeInTheDocument();
+  expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
+});
+
+test('expands only the types listed in expandableTypes', async () => {
+  const mixed: Folder[] = [
+    {
+      key: 'sales',
+      title: 'Sales',
+      type: 'folder',
+      assets: [{ key: 'd1', name: 'Revenue Overview', type: 'dashboard' }],
+    },
+    {
+      key: 'kpis',
+      title: 'KPIs',
+      type: 'dashboard',
+      assets: [{ key: 'c1', name: 'Active Users', type: 'chart' }],
+    },
+  ];
+  const onFolderClick = jest.fn();
+  render(
+    <FolderList
+      folders={mixed}
+      expandableTypes={['folder']}
+      defaultActiveKeys={['sales']}
+      onFolderClick={onFolderClick}
+    />,
+  );
+
+  // The folder expands in place; the dashboard row navigates on click.
+  expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
+  expect(screen.queryByText('Active Users')).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByRole('button', { name: /KPIs/ }));
+
+  expect(onFolderClick).toHaveBeenCalledWith(mixed[1]);
+});
diff --git a/superset-frontend/src/pages/components/FolderList.tsx b/superset-frontend/src/pages/components/FolderList.tsx
new file mode 100644
index 00000000000..128f6eb4fcb
--- /dev/null
+++ b/superset-frontend/src/pages/components/FolderList.tsx
@@ -0,0 +1,209 @@
+/**
+ * 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 { styled } from '@apache-superset/core/theme';
+import { Collapse, type CollapseProps } from '@superset-ui/core/components';
+import { FolderIcon, assetTypeIcons } from './assetIcons';
+import type { Folder, FolderItemType, FolderListProps } from './types';
+
+type FolderCollapseItem = NonNullable<CollapseProps['items']>[number];
+
+// Keep the header text at its content width and drop antd's auto end-margin so
+// the end-positioned caret sits immediately after the folder label rather than
+// at the far right of the row. The doubled root (&&) raises specificity above
+// antd's `flex: auto` / `margin-inline-end: auto` rules on the header text.
+const StyledFolderCollapse = styled(Collapse)`
+  ${({ theme }) => `
+    && .ant-collapse-item > .ant-collapse-header > .ant-collapse-header-text {
+      flex: none;
+      margin-inline-end: 0;
+    }
+
+    .ant-collapse-expand-icon {
+      margin-inline-start: ${theme.sizeUnit}px;
+    }
+
+    /* Non-expandable rows have no caret, so let the label fill the row. */
+    && .folder-row--static > .ant-collapse-header > .ant-collapse-header-text {
+      flex: auto;
+    }
+
+    /* A non-expandable row that navigates on click reads as one target. */
+    && .folder-row--clickable > .ant-collapse-header {
+      cursor: pointer;
+    }
+
+    && .folder-row--clickable > .ant-collapse-header:hover {
+      background: ${theme.colorFillTertiary};
+    }
+  `}
+`;
+
+const FolderLabel = styled.span`
+  ${({ theme }) => `
+    display: inline-flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    font-weight: ${theme.fontWeightStrong};
+    color: ${theme.colorText};
+  `}
+`;
+
+const AssetCount = styled.span`
+  ${({ theme }) => `
+    color: ${theme.colorTextTertiary};
+    font-weight: normal;
+    font-size: ${theme.fontSizeSM}px;
+  `}
+`;
+
+const AssetList = styled.ul`
+  ${({ theme }) => `
+    list-style: none;
+    margin: 0;
+    padding: 0;
+
+    li + li {
+      border-top: 1px solid ${theme.colorBorderSecondary};
+    }
+  `}
+`;
+
+const AssetRow = styled.button`
+  ${({ theme }) => `
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    width: 100%;
+    padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit}px;
+    border: 0;
+    background: transparent;
+    color: ${theme.colorText};
+    font-size: ${theme.fontSize}px;
+    text-align: left;
+    cursor: pointer;
+
+    &:hover {
+      background: ${theme.colorFillTertiary};
+    }
+  `}
+`;
+
+// Strips the native button chrome so a navigable folder row matches the look
+// of an expandable header while staying a real, focusable button.
+const FolderRowButton = styled.button`
+  display: flex;
+  align-items: center;
+  width: 100%;
+  margin: 0;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+`;
+
+const iconFor = (type: FolderItemType) =>
+  type === 'folder' ? FolderIcon : assetTypeIcons[type];
+
+export function FolderList({
+  folders,
+  expandableTypes = [],
+  defaultActiveKeys,
+  accordion = false,
+  showCount = true,
+  onAssetClick,
+  onFolderClick,
+  renderExpandedContent,
+  className,
+}: FolderListProps) {
+  const isExpandable = (folder: Folder) =>
+    expandableTypes.includes(folder.type ?? 'folder');
+
+  const defaultAssetList = (folder: Folder) => (
+    <AssetList>
+      {folder.assets.map(asset => {
+        const AssetIcon = assetTypeIcons[asset.type];
+        return (
+          <li key={asset.key}>
+            <AssetRow type="button" onClick={() => onAssetClick?.(asset)}>
+              <AssetIcon iconSize="m" aria-hidden />
+              {asset.name}
+            </AssetRow>
+          </li>
+        );
+      })}
+    </AssetList>
+  );
+
+  const items = folders.map((folder): FolderCollapseItem => {
+    const Icon = iconFor(folder.type ?? 'folder');
+    const label = (
+      <FolderLabel>
+        <Icon iconSize="m" aria-hidden />
+        {folder.title}
+        {showCount && <AssetCount>{folder.assets.length}</AssetCount>}
+      </FolderLabel>
+    );
+
+    if (isExpandable(folder)) {
+      return {
+        key: folder.key,
+        label,
+        children: renderExpandedContent?.(folder) ?? defaultAssetList(folder),
+      };
+    }
+
+    // A non-expandable row carries no caret and never toggles (`collapsible:
+    // 'icon'` + `showArrow: false`). With a handler the label becomes a button
+    // that navigates; without one the row is an inert header.
+    return {
+      key: folder.key,
+      showArrow: false,
+      collapsible: 'icon',
+      className: onFolderClick
+        ? 'folder-row--static folder-row--clickable'
+        : 'folder-row--static',
+      label: onFolderClick ? (
+        <FolderRowButton type="button" onClick={() => onFolderClick(folder)}>
+          {label}
+        </FolderRowButton>
+      ) : (
+        label
+      ),
+    };
+  });
+
+  // antd would still mark a key active even if its row can't expand; keep the
+  // initial open set to rows that actually have a body.
+  const activeKeys = defaultActiveKeys?.filter(key =>
+    folders.some(folder => folder.key === key && isExpandable(folder)),
+  );
+
+  return (
+    <StyledFolderCollapse
+      className={className}
+      items={items}
+      accordion={accordion}
+      expandIconPosition="end"
+      defaultActiveKey={activeKeys}
+    />
+  );
+}
diff --git a/superset-frontend/src/pages/components/assetIcons.tsx b/superset-frontend/src/pages/components/assetIcons.tsx
new file mode 100644
index 00000000000..82625678a33
--- /dev/null
+++ b/superset-frontend/src/pages/components/assetIcons.tsx
@@ -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 type { FC } from 'react';
+import { Icons } from '@superset-ui/core/components/Icons';
+import type { IconType } from '@superset-ui/core/components/Icons/types';
+import type { FolderAssetType } from './types';
+
+/** Icon for a collapsed folder. */
+export const FolderIcon = Icons.FolderOutlined;
+
+/** Icon for an expanded folder. */
+export const FolderOpenIcon = Icons.FolderOpenOutlined;
+
+/** Maps each asset type to its semantic icon. */
+export const assetTypeIcons: Record<FolderAssetType, FC<IconType>> = {
+  dashboard: Icons.DashboardOutlined,
+  dataset: Icons.TableOutlined,
+  chart: Icons.AreaChartOutlined,
+};
diff --git a/superset-frontend/src/pages/components/index.ts b/superset-frontend/src/pages/components/index.ts
new file mode 100644
index 00000000000..1b8e1bab787
--- /dev/null
+++ b/superset-frontend/src/pages/components/index.ts
@@ -0,0 +1,30 @@
+/**
+ * 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 { FolderBreadcrumb } from './FolderBreadcrumb';
+export { FolderList } from './FolderList';
+export { FolderIcon, FolderOpenIcon, assetTypeIcons } from './assetIcons';
+export type {
+  FolderBreadcrumbItem,
+  FolderBreadcrumbProps,
+  Folder,
+  FolderAsset,
+  FolderAssetType,
+  FolderItemType,
+  FolderListProps,
+} from './types';
diff --git a/superset-frontend/src/pages/components/types.ts b/superset-frontend/src/pages/components/types.ts
new file mode 100644
index 00000000000..09b85e91e2e
--- /dev/null
+++ b/superset-frontend/src/pages/components/types.ts
@@ -0,0 +1,106 @@
+/**
+ * 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 type { ReactNode } from 'react';
+
+/**
+ * The kinds of leaf assets that can live inside a folder. Each maps to a
+ * dedicated icon in `assetTypeIcons`.
+ */
+export type FolderAssetType = 'dashboard' | 'dataset' | 'chart';
+
+/**
+ * The kind of a top-level entry in a {@link FolderList}. A folder groups
+ * assets; the asset kinds may also appear as top-level entries in their own
+ * right. The type drives the row's icon and, via `expandableTypes`, whether
+ * the row expands in place or navigates on click.
+ */
+export type FolderItemType = 'folder' | FolderAssetType;
+
+/** A single navigable segment of the folder path. */
+export interface FolderBreadcrumbItem {
+  /** Stable identifier, passed back to `onClick`. */
+  key: string;
+  /** Label rendered for the segment. */
+  title: ReactNode;
+  /** Marks the segment as a link; called with the item `key` when clicked. */
+  onClick?: (key: string) => void;
+  /** Optional href when the segment should navigate via an anchor. */
+  href?: string;
+  /** Hide the leading folder icon for this segment (defaults to shown). */
+  hideIcon?: boolean;
+}
+
+export interface FolderBreadcrumbProps {
+  /** Ordered path from root to current folder; the last item is the current location. */
+  items: FolderBreadcrumbItem[];
+  /** Separator between segments. Defaults to a `>` chevron. */
+  separator?: ReactNode;
+  className?: string;
+}
+
+/** A leaf asset (dashboard, dataset or chart) shown inside a folder. */
+export interface FolderAsset {
+  key: string;
+  name: ReactNode;
+  type: FolderAssetType;
+}
+
+/** A top-level entry in a {@link FolderList}, typically a folder of assets. */
+export interface Folder {
+  key: string;
+  title: ReactNode;
+  /**
+   * Entry kind; controls the icon and whether the row is expandable (see
+   * {@link FolderListProps.expandableTypes}). Defaults to `'folder'`.
+   */
+  type?: FolderItemType;
+  assets: FolderAsset[];
+}
+
+export interface FolderListProps {
+  folders: Folder[];
+  /**
+   * Entry types that expand in place to reveal their contents. A row whose
+   * type is not listed renders as a single, non-collapsible row that calls
+   * `onFolderClick` instead (e.g. to navigate into it). Defaults to `[]`, so
+   * nothing is expandable unless a consumer opts a type in.
+   */
+  expandableTypes?: FolderItemType[];
+  /** Keys of folders expanded on first render (expandable types only). */
+  defaultActiveKeys?: string[];
+  /** Only allow one folder open at a time. */
+  accordion?: boolean;
+  /** Show the asset-count badge next to each folder title. Defaults to `true`. */
+  showCount?: boolean;
+  /** Invoked when an asset row inside an expanded folder is clicked. */
+  onAssetClick?: (asset: FolderAsset) => void;
+  /**
+   * Invoked when a non-expandable row is clicked, e.g. to open the folder or
+   * asset's own page. Without a handler the row renders as an inert header.
+   */
+  onFolderClick?: (folder: Folder) => void;
+  /**
+   * Overrides the body shown when an expandable row is open. Receives the
+   * entry and may branch on `folder.type`; return `undefined` to fall back to
+   * the default asset list. Use it to render, say, a chart preview for an
+   * expandable `dashboard` entry instead of a list of assets.
+   */
+  renderExpandedContent?: (folder: Folder) => ReactNode;
+  className?: string;
+}
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 94e99e197d9..7d1455d504f 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -37,6 +37,11 @@ const ChartCreation = lazy(
     import(/* webpackChunkName: "ChartCreation" */ 'src/pages/ChartCreation'),
 );
 
+const AnalyticsList = lazy(
+  () =>
+    import(/* webpackChunkName: "AnalyticsList" */ 'src/pages/AnalyticsList'),
+);
+
 const AnnotationLayerList = lazy(
   () =>
     import(
@@ -266,6 +271,13 @@ if (isFeatureEnabled(FeatureFlag.TaggingSystem)) {
   routes.push({ path: RoutePaths.TAGS, Component: Tags });
 }
 
+if (isFeatureEnabled('FOLDERS' as FeatureFlag)) {
+  routes.push({
+    path: '/analytics/:folderUuid?/',
+    Component: AnalyticsList,
+  });
+}
+
 // Recently-Archived view — gated by the soft-delete feature (T007).
 if (isFeatureEnabled(FeatureFlag.SoftDelete)) {
   routes.push({
diff --git a/superset/commands/folder/__init__.py b/superset/commands/folder/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/commands/folder/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/superset/commands/folder/assets.py b/superset/commands/folder/assets.py
new file mode 100644
index 00000000000..d833d14a797
--- /dev/null
+++ b/superset/commands/folder/assets.py
@@ -0,0 +1,135 @@
+# 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.
+from __future__ import annotations
+
+from functools import partial
+from typing import Any
+
+from marshmallow import ValidationError
+from superset import security_manager
+from superset.commands.base import BaseCommand
+from superset.utils.core import get_user_id
+from superset.utils.decorators import on_error, transaction
+
+from superset.commands.folder.exceptions import (
+    FolderAssetNotFoundValidationError,
+    FolderAssetTypeValidationError,
+    FolderForbiddenError,
+    FolderInvalidError,
+    FolderNotFoundError,
+    FolderUpdateFailedError,
+)
+from superset.folders.constants import ASSET_TYPE_CONFIGS, asset_types_for_folder_type
+from superset.daos.folder import FolderDAO
+from superset.folders.models import Folder
+from superset.daos.folder_permissions import FolderPermissionDAO
+
+
+def _validate_folder_assets(
+    folder_id_or_uuid: str, assets: list[dict[str, Any]]
+) -> Folder:
+    """Validate folder existence, editor permissions, and asset references."""
+    model = FolderDAO.find_by_id_or_uuid(folder_id_or_uuid)
+    if not model:
+        raise FolderNotFoundError()
+
+    if not security_manager.is_admin():
+        user_id = get_user_id()
+        if not user_id or not FolderPermissionDAO.user_is_folder_editor(
+            user_id, model.id
+        ):
+            raise FolderForbiddenError()
+
+    exceptions: list[ValidationError] = []
+    allowed = set(asset_types_for_folder_type(model.folder_type))
+    is_admin = security_manager.is_admin()
+    for asset in assets:
+        asset_type = asset["type"]
+        if asset_type not in allowed:
+            exceptions.append(
+                FolderAssetTypeValidationError(asset_type, model.folder_type)
+            )
+        elif not FolderDAO.asset_exists(asset_type, asset["id"]):
+            exceptions.append(
+                FolderAssetNotFoundValidationError(asset_type, asset["id"])
+            )
+        elif not is_admin:
+            from superset.extensions import db
+
+            config = ASSET_TYPE_CONFIGS[asset_type]
+            asset_obj = db.session.get(config.model, asset["id"])
+            if asset_obj:
+                try:
+                    if asset_type == "dashboard":
+                        security_manager.raise_for_access(dashboard=asset_obj)
+                    elif asset_type == "chart":
+                        security_manager.raise_for_access(chart=asset_obj)
+                except Exception as ex:
+                    raise FolderForbiddenError() from ex
+
+    if exceptions:
+        raise FolderInvalidError(exceptions=exceptions)
+
+    return model
+
+
+class UpdateFolderAssetsCommand(BaseCommand):
+    """Set a folder's asset membership.
+
+    ``assets`` is the full desired membership: listed assets are moved into the
+    folder (from wherever they were), and any of the folder's current assets not
+    in the list are moved back to the root. An empty list empties the folder.
+    """
+
+    def __init__(self, folder_id_or_uuid: str, assets: list[dict[str, Any]]):
+        self._id = folder_id_or_uuid
+        self._assets = assets
+        self._model: Folder | None = None
+
+    @transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
+    def run(self) -> Folder:
+        self.validate()
+        assert self._model
+        FolderDAO.set_assets(self._model, self._assets)
+        return self._model
+
+    def validate(self) -> None:
+        self._model = _validate_folder_assets(self._id, self._assets)
+
+
+class AddFolderAssetsCommand(BaseCommand):
+    """Add assets to a folder (upsert).
+
+    Assets already in another folder are moved. Assets already in this folder
+    are left as-is. Unlike ``UpdateFolderAssetsCommand``, existing assets in
+    the folder that are not listed are **not** removed.
+    """
+
+    def __init__(self, folder_id_or_uuid: str, assets: list[dict[str, Any]]):
+        self._id = folder_id_or_uuid
+        self._assets = assets
+        self._model: Folder | None = None
+
+    @transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
+    def run(self) -> Folder:
+        self.validate()
+        assert self._model
+        FolderDAO.assign_assets(self._model, self._assets)
+        return self._model
+
+    def validate(self) -> None:
+        self._model = _validate_folder_assets(self._id, self._assets)
diff --git a/superset/commands/folder/create.py b/superset/commands/folder/create.py
new file mode 100644
index 00000000000..d54661dd436
--- /dev/null
+++ b/superset/commands/folder/create.py
@@ -0,0 +1,102 @@
+# 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.
+from __future__ import annotations
+
+from functools import partial
+from typing import Any
+
+from flask import g
+from marshmallow import ValidationError
+from superset import security_manager
+from superset.commands.base import BaseCommand
+from superset.extensions import db
+from superset.utils.decorators import on_error, transaction
+
+from superset.commands.folder.exceptions import (
+    FolderCreateFailedError,
+    FolderForbiddenError,
+    FolderInvalidError,
+    FolderNameUniquenessValidationError,
+    FolderParentNotFoundValidationError,
+    FolderParentTypeMismatchValidationError,
+    FolderTypeValidationError,
+)
+from superset.folders.constants import FOLDER_TYPE_ASSETS
+from superset.daos.folder import FolderDAO
+from superset.folders.models import Folder
+from superset.daos.folder_permissions import FolderPermissionDAO
+
+
+class CreateFolderCommand(BaseCommand):
+    def __init__(self, data: dict[str, Any]):
+        self._properties = data.copy()
+        self._parent: Folder | None = None
+
+    @transaction(on_error=partial(on_error, reraise=FolderCreateFailedError))
+    def run(self) -> Folder:
+        self.validate()
+        folder = FolderDAO.create(
+            attributes={
+                "name": self._properties["name"],
+                "description": self._properties.get("description"),
+                "parent_id": self._parent.id if self._parent else None,
+                "folder_type": self._properties["folder_type"],
+                "is_private": self._properties.get("is_private", False),
+            }
+        )
+        db.session.flush()
+        if (
+            hasattr(g, "user")
+            and not g.user.is_anonymous
+            and not security_manager.is_admin()
+        ):
+            FolderDAO.add_subject(folder.id, g.user.id, "editor")
+        if self._parent:
+            FolderPermissionDAO.copy_permissions_to_subfolder(
+                self._parent.id, folder.id
+            )
+        return folder
+
+    def validate(self) -> None:
+        exceptions: list[ValidationError] = []
+        name = self._properties["name"]
+        folder_type = self._properties["folder_type"]
+
+        if folder_type not in FOLDER_TYPE_ASSETS:
+            exceptions.append(FolderTypeValidationError(folder_type))
+
+        if parent_uuid := self._properties.get("parent_uuid"):
+            self._parent = FolderDAO.find_by_id_or_uuid(parent_uuid)
+            if self._parent is None:
+                exceptions.append(FolderParentNotFoundValidationError())
+            elif self._parent.folder_type != folder_type:
+                exceptions.append(FolderParentTypeMismatchValidationError())
+            elif not security_manager.is_admin():
+                from superset.utils.core import get_user_id
+
+                user_id = get_user_id()
+                if not FolderPermissionDAO.user_is_folder_editor(
+                    user_id, self._parent.id
+                ):
+                    raise FolderForbiddenError()
+
+        parent_id = self._parent.id if self._parent else None
+        if not FolderDAO.validate_name_uniqueness(name, parent_id, folder_type):
+            exceptions.append(FolderNameUniquenessValidationError())
+
+        if exceptions:
+            raise FolderInvalidError(exceptions=exceptions)
diff --git a/superset/commands/folder/delete.py b/superset/commands/folder/delete.py
new file mode 100644
index 00000000000..70e32e52fad
--- /dev/null
+++ b/superset/commands/folder/delete.py
@@ -0,0 +1,65 @@
+# 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.
+from __future__ import annotations
+
+from functools import partial
+
+from superset import security_manager
+from superset.commands.base import BaseCommand
+from superset.utils.core import get_user_id
+from superset.utils.decorators import on_error, transaction
+
+from superset.commands.folder.exceptions import (
+    FolderDeleteFailedError,
+    FolderForbiddenError,
+    FolderNotFoundError,
+)
+from superset.daos.folder import FolderDAO
+from superset.folders.models import Folder
+from superset.daos.folder_permissions import FolderPermissionDAO
+
+
+class DeleteFolderCommand(BaseCommand):
+    def __init__(self, folder_id_or_uuid: str, archive_items: bool = False):
+        self._id = folder_id_or_uuid
+        self._archive_items = archive_items
+        self._model: Folder | None = None
+
+    @transaction(on_error=partial(on_error, reraise=FolderDeleteFailedError))
+    def run(self) -> None:
+        self.validate()
+        assert self._model
+        FolderDAO.delete_folder(self._model, self._archive_items)
+
+    def validate(self) -> None:
+        from superset.utils import json as json_utils
+
+        self._model = FolderDAO.find_by_id_or_uuid(self._id)
+        if not self._model:
+            raise FolderNotFoundError()
+
+        # "Only Me" folder cannot be deleted
+        extra = json_utils.loads(self._model.extra) if self._model.extra else {}
+        if extra.get("only_me"):
+            raise FolderForbiddenError()
+
+        if not security_manager.is_admin():
+            user_id = get_user_id()
+            if not user_id or not FolderPermissionDAO.user_is_folder_editor(
+                user_id, self._model.id
+            ):
+                raise FolderForbiddenError()
diff --git a/superset/commands/folder/exceptions.py b/superset/commands/folder/exceptions.py
new file mode 100644
index 00000000000..a2f3593370e
--- /dev/null
+++ b/superset/commands/folder/exceptions.py
@@ -0,0 +1,125 @@
+# 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.
+from flask_babel import lazy_gettext as _
+from superset.commands.exceptions import (
+    CommandException,
+    CommandInvalidError,
+    CreateFailedError,
+    DeleteFailedError,
+    ForbiddenError,
+    UpdateFailedError,
+    ValidationError,
+)
+
+
+class FolderForbiddenError(ForbiddenError):
+    message = _("You don't have permission to access this folder.")
+
+
+class FolderInvalidError(CommandInvalidError):
+    message = _("Folder parameters are invalid.")
+
+
+class FolderNotFoundError(CommandException):
+    status = 404
+    message = _("Folder not found.")
+
+
+class FolderCreateFailedError(CreateFailedError):
+    message = _("Folder could not be created.")
+
+
+class FolderUpdateFailedError(UpdateFailedError):
+    message = _("Folder could not be updated.")
+
+
+class FolderDeleteFailedError(DeleteFailedError):
+    message = _("Folder could not be deleted.")
+
+
+class FolderNameUniquenessValidationError(ValidationError):
+    """A sibling folder of the same type already uses this name."""
+
+    def __init__(self) -> None:
+        super().__init__(
+            [_("A folder with this name already exists in this location")],
+            field_name="name",
+        )
+
+
+class FolderParentNotFoundValidationError(ValidationError):
+    """The referenced parent folder does not exist."""
+
+    def __init__(self) -> None:
+        super().__init__([_("Parent folder not found")], field_name="parent_uuid")
+
+
+class FolderParentTypeMismatchValidationError(ValidationError):
+    """The parent folder belongs to a different folder type."""
+
+    def __init__(self) -> None:
+        super().__init__(
+            [_("Parent folder belongs to a different folder type")],
+            field_name="parent_uuid",
+        )
+
+
+class FolderCycleValidationError(ValidationError):
+    """A move would place a folder inside itself or a descendant."""
+
+    def __init__(self) -> None:
+        super().__init__(
+            [_("Cannot move a folder into itself or one of its descendants")],
+            field_name="parent_uuid",
+        )
+
+
+class FolderTypeValidationError(ValidationError):
+    """The folder_type is not a recognised namespace."""
+
+    def __init__(self, folder_type: str) -> None:
+        super().__init__(
+            [_("Unknown folder type: '%(type)s'", type=folder_type)],
+            field_name="folder_type",
+        )
+
+
+class FolderAssetNotFoundValidationError(ValidationError):
+    """A referenced asset does not exist."""
+
+    def __init__(self, asset_type: str, asset_id: int) -> None:
+        super().__init__(
+            [_("%(type)s %(id)s not found", type=asset_type, id=asset_id)],
+            field_name="assets",
+        )
+
+
+class FolderAssetTypeValidationError(ValidationError):
+    """An asset kind cannot be placed in a folder of this type."""
+
+    def __init__(self, asset_type: str, folder_type: str) -> None:
+        super().__init__(
+            [
+                _(
+                    "Assets of type '%(type)s' cannot be added to a "
+                    "'%(folder_type)s' folder",
+                    type=asset_type,
+                    folder_type=folder_type,
+                )
+            ],
+            field_name="assets",
+        )
diff --git a/superset/commands/folder/update.py b/superset/commands/folder/update.py
new file mode 100644
index 00000000000..1b6fd909a0e
--- /dev/null
+++ b/superset/commands/folder/update.py
@@ -0,0 +1,151 @@
+# 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.
+from __future__ import annotations
+
+from functools import partial
+from typing import Any
+
+from marshmallow import ValidationError
+from superset import db, security_manager
+from superset.commands.base import BaseCommand
+from superset.utils.core import get_user_id
+from superset.utils.decorators import on_error, transaction
+
+from superset.commands.folder.exceptions import (
+    FolderCycleValidationError,
+    FolderForbiddenError,
+    FolderInvalidError,
+    FolderNameUniquenessValidationError,
+    FolderNotFoundError,
+    FolderParentNotFoundValidationError,
+    FolderParentTypeMismatchValidationError,
+    FolderUpdateFailedError,
+)
+from superset.daos.folder import FolderDAO
+from superset.folders.models import Folder
+from superset.daos.folder_permissions import FolderPermissionDAO
+
+
+class UpdateFolderCommand(BaseCommand):
+    def __init__(self, folder_id_or_uuid: str, data: dict[str, Any]):
+        self._id = folder_id_or_uuid
+        self._properties = data.copy()
+        self._model: Folder | None = None
+        self._parent_changed = False
+        self._new_parent_id: int | None = None
+
+    @transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
+    def run(self) -> Folder:
+        self.validate()
+        assert self._model
+
+        attributes: dict[str, Any] = {}
+        if "name" in self._properties:
+            attributes["name"] = self._properties["name"]
+        if "description" in self._properties:
+            attributes["description"] = self._properties["description"]
+        if "is_private" in self._properties:
+            attributes["is_private"] = self._properties["is_private"]
+        if self._parent_changed:
+            attributes["parent_id"] = self._new_parent_id
+
+        folder = FolderDAO.update(self._model, attributes)
+
+        # When moving to a new parent: copy permissions and remove root pins
+        if self._parent_changed and self._new_parent_id is not None:
+            FolderPermissionDAO.copy_permissions_to_subfolder(
+                self._new_parent_id, folder.id
+            )
+            from superset.folders.models import FolderPin
+
+            db.session.query(FolderPin).filter(
+                FolderPin.folder_id == folder.id,
+            ).delete()
+
+        # Sync permissions from parent when requested
+        if self._properties.get("sync_permissions") and folder.parent_id:
+            FolderPermissionDAO.copy_permissions_to_subfolder(
+                folder.parent_id, folder.id
+            )
+            FolderPermissionDAO.push_down_permissions(folder.id)
+
+        return folder
+
+    def _validate_parent(self, exceptions: list[ValidationError]) -> int | None:
+        """Validate and resolve parent_uuid, returning the new parent_id."""
+        new_parent_id = self._model.parent_id  # type: ignore[union-attr]
+        if "parent_uuid" not in self._properties:
+            return new_parent_id
+
+        self._parent_changed = True
+        parent: Folder | None = None
+        if parent_uuid := self._properties["parent_uuid"]:
+            parent = FolderDAO.find_by_id_or_uuid(parent_uuid)
+            if parent is None:
+                exceptions.append(FolderParentNotFoundValidationError())
+            elif parent.folder_type != self._model.folder_type:  # type: ignore[union-attr]
+                exceptions.append(FolderParentTypeMismatchValidationError())
+            elif FolderDAO.is_descendant(parent, self._model):  # type: ignore[arg-type]
+                exceptions.append(FolderCycleValidationError())
+            elif not security_manager.is_admin():
+                user_id = get_user_id()
+                if not user_id or not FolderPermissionDAO.user_is_folder_editor(user_id, parent.id):
+                    raise FolderForbiddenError()
+        new_parent_id = parent.id if parent else None
+        self._new_parent_id = new_parent_id
+        return new_parent_id
+
+    def validate(self) -> None:
+        from superset.utils import json as json_utils
+
+        self._model = FolderDAO.find_by_id_or_uuid(self._id)
+        if not self._model:
+            raise FolderNotFoundError()
+
+        if not security_manager.is_admin():
+            user_id = get_user_id()
+            if not user_id or not FolderPermissionDAO.user_is_folder_editor(
+                user_id, self._model.id
+            ):
+                raise FolderForbiddenError()
+
+        # "Only Me" folder: reject name/parent changes (sync_permissions is allowed)
+        extra = json_utils.loads(self._model.extra) if self._model.extra else {}
+        if extra.get("only_me"):
+            if "name" in self._properties or "parent_uuid" in self._properties:
+                raise FolderForbiddenError()
+
+        # Private folders cannot be moved into other folders
+        if self._model.is_private and "parent_uuid" in self._properties:
+            raise FolderForbiddenError()
+
+        exceptions: list[ValidationError] = []
+        new_parent_id = self._validate_parent(exceptions)
+
+        new_name = self._properties.get("name", self._model.name)
+        if new_name != self._model.name or new_parent_id != self._model.parent_id:
+            resolved = FolderDAO.resolve_name_conflict(
+                new_name,
+                new_parent_id,
+                self._model.folder_type,
+                exclude_id=self._model.id,
+            )
+            if resolved != new_name:
+                self._properties["name"] = resolved
+
+        if exceptions:
+            raise FolderInvalidError(exceptions=exceptions)
diff --git a/superset/config.py b/superset/config.py
index 6886ee48df8..aa99a627ef2 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -693,6 +693,9 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
     # Allow metrics and columns to be grouped into folders in the chart builder
     # @lifecycle: development
     "DATASET_FOLDERS": False,
+    # Enable the folder-based analytics view (replaces Charts/Dashboards nav)
+    # @lifecycle: development
+    "FOLDERS": False,
     # Enable support for date range timeshifts (e.g., "2015-01-03 : 2015-01-04")
     # in addition to relative timeshifts (e.g., "1 day ago")
     # @lifecycle: development
@@ -3229,6 +3232,10 @@ EXTRA_ASSET_EXPORT_FIELDS: Callable[[Any, str], dict[str, Any]] | None = None
 # Consume the "extra" mapping when importing a chart or dashboard. Receives
 # (model, asset_type, extra) once the asset exists.
 EXTRA_ASSET_IMPORT_HANDLER: Callable[[Any, str, dict[str, Any]], None] | None = None
+# When True, folder viewers/editors can access all charts and datasources on a
+# dashboard that is in their folder, even if those charts are not individually
+# in any folder. Disabled by default.
+FOLDER_DASHBOARD_TRANSITIVE_ACCESS: bool = False
 
 
 # The migrations that add catalog permissions might take a considerably long time
diff --git a/superset/daos/folder.py b/superset/daos/folder.py
new file mode 100644
index 00000000000..a1080c7205e
--- /dev/null
+++ b/superset/daos/folder.py
@@ -0,0 +1,1030 @@
+# 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.
+"""Data access for folders and the assets they contain.
+
+Validation and orchestration live in ``superset.commands.folder``; this DAO is
+limited to queries and persistence.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from datetime import datetime
+from typing import Any
+
+from flask_appbuilder.security.sqla.models import User
+from sqlalchemy import and_, case, func, literal, or_, select, union_all
+from sqlalchemy.orm import joinedload
+
+from superset.daos.base import BaseDAO
+from superset.extensions import db
+from superset.folders.constants import ASSET_TYPE_CONFIGS, asset_types_for_folder_type
+from superset.folders.models import (
+    Folder,
+    folder_editors,
+    folder_viewers,
+    FolderObject,
+    FolderPin,
+)
+
+# A resolved asset: its type label paired with the underlying model instance.
+ResolvedAsset = tuple[str, Any]
+
+
+class FolderDAO(BaseDAO[Folder]):
+    """Queries and persistence for folders and their asset links."""
+
+    # ------------------------------------------------------------------ #
+    # Lookups
+    # ------------------------------------------------------------------ #
+    @classmethod
+    def get_by_uuid(cls, folder_uuid: str) -> Folder | None:
+        """Find a folder by UUID (or numeric id)."""
+        return cls.find_by_id_or_uuid(folder_uuid)
+
+    @classmethod
+    def get_folders(cls, folder_type: str | None = None) -> list[Folder]:
+        """List folders, optionally filtered by ``folder_type``."""
+        query = db.session.query(Folder)
+        if folder_type:
+            query = query.filter(Folder.folder_type == folder_type)
+        return query.order_by(Folder.changed_on.desc()).all()
+
+    @classmethod
+    def get_roots(cls, folder_type: str) -> list[Folder]:
+        """Top-level folders (no parent) for a folder type."""
+        return (
+            db.session.query(Folder)
+            .filter(Folder.parent_id.is_(None), Folder.folder_type == folder_type)
+            .order_by(Folder.changed_on.desc())
+            .all()
+        )
+
+    @classmethod
+    def get_children(cls, folder: Folder) -> list[Folder]:
+        """Direct subfolders of ``folder``."""
+        return (
+            db.session.query(Folder)
+            .filter(Folder.parent_id == folder.id)
+            .order_by(Folder.changed_on.desc())
+            .all()
+        )
+
+    # ------------------------------------------------------------------ #
+    # Asset queries
+    # ------------------------------------------------------------------ #
+    @classmethod
+    def get_folder_assets(cls, folder: Folder) -> list[ResolvedAsset]:
+        """Resolve the assets linked directly to ``folder`` into model objects."""
+        ids_by_type: dict[str, list[int]] = {name: [] for name in ASSET_TYPE_CONFIGS}
+        for link in folder.objects:
+            for name, config in ASSET_TYPE_CONFIGS.items():
+                asset_id = getattr(link, config.fk_column)
+                if asset_id is not None:
+                    ids_by_type[name].append(asset_id)
+        return cls._load_assets(ids_by_type)
+
+    @classmethod
+    def get_contents(  # noqa: C901
+        cls,
+        folder: Folder | None,
+        folder_type: str,
+        *,
+        search: str | None = None,
+        types: list[str] | None = None,
+        viz_types: list[str] | None = None,
+        datasets: list[int] | None = None,
+        tags: list[int] | None = None,
+        owners: list[int] | None = None,
+        editors: list[int] | None = None,
+        viewers: list[int] | None = None,
+        modified_start: datetime | None = None,
+        modified_end: datetime | None = None,
+        page: int = 0,
+        page_size: int = 25,
+        sort_column: str = "changed_on",
+        sort_order: str = "desc",
+    ) -> tuple[list[ResolvedAsset], int]:
+        """Paginated, filtered contents of a folder (or the root).
+
+        Returns ``(rows, total)`` where each row is ``(kind, model_instance)`` with
+        ``kind`` in ``{"folder", <asset types>}``. Pagination happens in the DB via a
+        ``UNION ALL`` over the included sources ordered by (folders first, then most
+        recently changed), so callers get exactly one page plus the total count.
+        """
+        from superset import security_manager
+        from superset.connectors.sqla.models import Database as ConnDatabase, SqlaTable
+        from superset.models.dashboard import Dashboard
+        from superset.models.slice import Slice
+        from superset.utils.core import get_user_id
+
+        folder_id = folder.id if folder else None
+        requested = set(types) if types else None
+        asset_only_filter = bool(viz_types or datasets or owners)
+        is_admin = security_manager.is_admin()
+        user_id = get_user_id()
+
+        # Escape LIKE wildcards in search input
+        if search:
+            search = (
+                search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+            )
+
+        # When searching, find ALL descendant folders via recursive CTE.
+        child_folder_ids_sub = None
+        if search:
+            base = (
+                select(Folder.id)
+                .where(
+                    Folder.parent_id == folder_id
+                    if folder_id is not None
+                    else Folder.parent_id.is_(None),
+                    Folder.folder_type == folder_type,
+                )
+                .cte(recursive=True, name="descendants")
+            )
+            descendants = base.union_all(
+                select(Folder.id).where(
+                    Folder.parent_id == base.c.id,
+                    Folder.folder_type == folder_type,
+                )
+            )
+            child_folder_ids_sub = select(descendants.c.id)
+
+        def included(kind: str) -> bool:
+            return requested is None or kind in requested
+
+        selects = []
+
+        # --- Folders (subfolders / top-level) ---
+        if included("folder") and not asset_only_filter:
+            fq = select(
+                literal(0).label("kind_order"),
+                literal("folder").label("item_type"),
+                Folder.id.label("item_id"),
+                Folder.changed_on.label("changed_on"),
+                Folder.name.label("sort_name"),
+                literal("").label("sort_datasource"),
+            ).where(Folder.folder_type == folder_type)
+            if search and child_folder_ids_sub is not None:
+                fq = fq.where(
+                    or_(
+                        Folder.parent_id == folder_id
+                        if folder_id is not None
+                        else Folder.parent_id.is_(None),
+                        Folder.parent_id.in_(child_folder_ids_sub),
+                    )
+                )
+                fq = fq.where(Folder.name.ilike(f"%{search}%", escape="\\"))
+            else:
+                fq = fq.where(
+                    Folder.parent_id.is_(None)
+                    if folder_id is None
+                    else Folder.parent_id == folder_id
+                )
+            if modified_start:
+                fq = fq.where(Folder.changed_on >= modified_start)
+            if modified_end:
+                fq = fq.where(Folder.changed_on <= modified_end)
+            # Hide other users' private folders from everyone (including admins)
+            if user_id:
+                own_private_ids = (
+                    select(folder_editors.c.folder_id).where(
+                        folder_editors.c.user_id == user_id
+                    )
+                )
+                fq = fq.where(
+                    or_(
+                        Folder.is_private.is_(False),
+                        Folder.id.in_(own_private_ids),
+                    )
+                )
+            # Access filter: non-admins see member folders + implicit folders
+            if not is_admin and user_id:
+                from superset.folders.utils import user_accessible_folder_ids
+
+                member_folder_ids = user_accessible_folder_ids(user_id)
+                implicit_ids = cls.folders_with_accessible_assets(user_id, folder_type)
+                if implicit_ids:
+                    fq = fq.where(
+                        or_(
+                            Folder.id.in_(member_folder_ids),
+                            Folder.id.in_(list(implicit_ids)),
+                        )
+                    )
+                else:
+                    fq = fq.where(Folder.id.in_(member_folder_ids))
+            if editors:
+                editor_folder_ids = select(folder_editors.c.folder_id).where(
+                    folder_editors.c.user_id.in_(editors)
+                )
+                fq = fq.where(Folder.id.in_(editor_folder_ids))
+            if viewers:
+                viewer_folder_ids = select(folder_viewers.c.folder_id).where(
+                    folder_viewers.c.user_id.in_(viewers)
+                )
+                fq = fq.where(Folder.id.in_(viewer_folder_ids))
+            selects.append(fq)
+
+        # --- Assets (charts / dashboards for this folder type) ---
+        title_attrs = {
+            "chart": Slice.slice_name,
+            "dashboard": Dashboard.dashboard_title,
+        }
+        for name in asset_types_for_folder_type(folder_type):
+            if not included(name):
+                continue
+            # viz/dataset filters only make sense for charts.
+            if (viz_types or datasets) and name != "chart":
+                continue
+            model = ASSET_TYPE_CONFIGS[name].model
+            fk_col = getattr(FolderObject, ASSET_TYPE_CONFIGS[name].fk_column)
+            if name == "chart":
+                aq = select(
+                    literal(1).label("kind_order"),
+                    literal(name).label("item_type"),
+                    model.id.label("item_id"),
+                    model.changed_on.label("changed_on"),
+                    title_attrs[name].label("sort_name"),
+                    func.coalesce(
+                        func.concat(
+                            func.coalesce(SqlaTable.schema, ""),
+                            case(
+                                (SqlaTable.schema.isnot(None), "."),
+                                else_="",
+                            ),
+                            func.coalesce(SqlaTable.table_name, ""),
+                        ),
+                        "",
+                    ).label("sort_datasource"),
+                ).outerjoin(SqlaTable, Slice.datasource_id == SqlaTable.id)
+            else:
+                aq = select(
+                    literal(1).label("kind_order"),
+                    literal(name).label("item_type"),
+                    model.id.label("item_id"),
+                    model.changed_on.label("changed_on"),
+                    title_attrs[name].label("sort_name"),
+                    literal("").label("sort_datasource"),
+                )
+            if folder_id is None:
+                if search and child_folder_ids_sub is not None:
+                    used = select(fk_col).where(fk_col.isnot(None))
+                    in_child = select(fk_col).where(
+                        FolderObject.folder_id.in_(child_folder_ids_sub),
+                        fk_col.isnot(None),
+                    )
+                    aq = aq.where(or_(model.id.notin_(used), model.id.in_(in_child)))
+                else:
+                    used = select(fk_col).where(fk_col.isnot(None))
+                    aq = aq.where(model.id.notin_(used))
+            else:
+                if search and child_folder_ids_sub is not None:
+                    in_folder = select(fk_col).where(
+                        or_(
+                            FolderObject.folder_id == folder_id,
+                            FolderObject.folder_id.in_(child_folder_ids_sub),
+                        ),
+                        fk_col.isnot(None),
+                    )
+                else:
+                    in_folder = select(fk_col).where(
+                        FolderObject.folder_id == folder_id, fk_col.isnot(None)
+                    )
+                aq = aq.where(model.id.in_(in_folder))
+            if search:
+                aq = aq.where(title_attrs[name].ilike(f"%{search}%", escape="\\"))
+            if modified_start:
+                aq = aq.where(model.changed_on >= modified_start)
+            if modified_end:
+                aq = aq.where(model.changed_on <= modified_end)
+            if owners:
+                from superset.subjects.models import Subject
+
+                aq = aq.where(model.editors.any(Subject.user_id.in_(owners)))
+            if name == "chart":
+                if viz_types:
+                    aq = aq.where(Slice.viz_type.in_(viz_types))
+                if datasets:
+                    aq = aq.where(Slice.datasource_id.in_(datasets))
+            if tags:
+                from superset.tags.models import ObjectType, TaggedObject
+
+                object_type = (
+                    ObjectType.chart if name == "chart" else ObjectType.dashboard
+                )
+                tagged = select(TaggedObject.object_id).where(
+                    TaggedObject.object_type == object_type,
+                    TaggedObject.tag_id.in_(tags),
+                )
+                aq = aq.where(model.id.in_(tagged))
+            if editors:
+                fk_col_filter = getattr(
+                    FolderObject, ASSET_TYPE_CONFIGS[name].fk_column
+                )
+                editor_asset_ids = select(fk_col_filter).where(
+                    FolderObject.folder_id.in_(
+                        select(folder_editors.c.folder_id).where(
+                            folder_editors.c.user_id.in_(editors)
+                        )
+                    ),
+                    fk_col_filter.isnot(None),
+                )
+                aq = aq.where(model.id.in_(editor_asset_ids))
+            if viewers:
+                fk_col_filter = getattr(
+                    FolderObject, ASSET_TYPE_CONFIGS[name].fk_column
+                )
+                viewer_asset_ids = select(fk_col_filter).where(
+                    FolderObject.folder_id.in_(
+                        select(folder_viewers.c.folder_id).where(
+                            folder_viewers.c.user_id.in_(viewers)
+                        )
+                    ),
+                    fk_col_filter.isnot(None),
+                )
+                aq = aq.where(model.id.in_(viewer_asset_ids))
+
+            # Access filter: non-admins only see assets they can access
+            if not is_admin and user_id:
+                from superset.utils.filters import get_dataset_access_filters
+
+                access_conditions = []
+
+                # 1. User is an editor of the asset
+                from superset.subjects.models import Subject
+
+                access_conditions.append(
+                    model.editors.any(Subject.user_id == user_id)
+                )
+
+                # 2. User has folder membership for the asset
+                from superset.folders.utils import user_accessible_folder_ids
+
+                user_folder_ids = user_accessible_folder_ids(user_id)
+                folder_accessible = (
+                    select(fk_col)
+                    .select_from(FolderObject)
+                    .where(
+                        fk_col.isnot(None),
+                        FolderObject.folder_id.in_(user_folder_ids),
+                    )
+                )
+                access_conditions.append(model.id.in_(folder_accessible))
+
+                # 3. Chart: user has datasource access
+                can_access_all = security_manager.can_access_all_datasources()
+                if name == "chart" and not can_access_all:
+                    ds_accessible = (
+                        select(Slice.id)
+                        .join(
+                            SqlaTable,
+                            Slice.datasource_id == SqlaTable.id,
+                        )
+                        .join(
+                            ConnDatabase,
+                            SqlaTable.database_id == ConnDatabase.id,
+                        )
+                        .where(get_dataset_access_filters(Slice))
+                    )
+                    access_conditions.append(model.id.in_(ds_accessible))
+
+                # 4. Dashboard: published + user has datasource access
+                if name == "dashboard":
+                    if can_access_all:
+                        access_conditions.append(Dashboard.published.is_(True))
+                    else:
+                        published_accessible = (
+                            select(Dashboard.id)
+                            .join(Dashboard.slices, isouter=True)
+                            .join(
+                                SqlaTable,
+                                Slice.datasource_id == SqlaTable.id,
+                            )
+                            .join(
+                                ConnDatabase,
+                                SqlaTable.database_id == ConnDatabase.id,
+                            )
+                            .where(
+                                Dashboard.published.is_(True),
+                                get_dataset_access_filters(Slice, can_access_all),
+                            )
+                        )
+                        access_conditions.append(model.id.in_(published_accessible))
+
+                aq = aq.where(or_(*access_conditions))
+            selects.append(aq)
+
+        if not selects:
+            return [], 0
+
+        unioned = union_all(*selects).subquery()
+        total = (
+            db.session.execute(select(func.count()).select_from(unioned)).scalar() or 0
+        )
+        sort_map = {
+            "name": unioned.c.sort_name,
+            "type": unioned.c.item_type,
+            "changed_on": unioned.c.changed_on,
+            "datasource": unioned.c.sort_datasource,
+        }
+        sort_col = sort_map.get(sort_column, unioned.c.changed_on)
+        order = sort_col.asc() if sort_order == "asc" else sort_col.desc()
+
+        # Sort priority at root: Only Me (0) → pinned (1-3) → unpinned (4)
+        pin_order = (literal(4) + literal(0)).label("pin_order")
+        if folder_id is None and user_id:
+            only_me_order = (
+                select(literal(0) + literal(0))
+                .where(
+                    unioned.c.item_type == "folder",
+                    Folder.id == unioned.c.item_id,
+                    Folder.is_private.is_(True),
+                )
+                .correlate(unioned)
+                .scalar_subquery()
+            )
+            pin_position = (
+                select(FolderPin.position)
+                .where(
+                    FolderPin.user_id == user_id,
+                    or_(
+                        and_(
+                            unioned.c.item_type == "folder",
+                            FolderPin.folder_id == unioned.c.item_id,
+                        ),
+                        and_(
+                            unioned.c.item_type == "dashboard",
+                            FolderPin.dashboard_id == unioned.c.item_id,
+                        ),
+                        and_(
+                            unioned.c.item_type == "chart",
+                            FolderPin.chart_id == unioned.c.item_id,
+                        ),
+                    ),
+                )
+                .correlate(unioned)
+                .scalar_subquery()
+            )
+            pin_order = func.coalesce(
+                only_me_order, pin_position, literal(4) + literal(0)
+            ).label("pin_order")
+
+        page_rows = db.session.execute(
+            select(unioned.c.item_type, unioned.c.item_id)
+            .order_by(pin_order, order)
+            .limit(page_size)
+            .offset(page * page_size)
+        ).all()
+
+        # Hydrate the page's ids into model instances, preserving order.
+        ids_by_type: dict[str, list[int]] = defaultdict(list)
+        for item_type, item_id in page_rows:
+            ids_by_type[item_type].append(item_id)
+        objects: dict[tuple[str, int], Any] = {}
+        if ids_by_type.get("folder"):
+            for obj in (
+                db.session.query(Folder)
+                .options(
+                    joinedload(Folder.parent),
+                    joinedload(Folder.created_by),
+                    joinedload(Folder.changed_by),
+                )
+                .filter(Folder.id.in_(ids_by_type["folder"]))
+                .all()
+            ):
+                objects[("folder", obj.id)] = obj
+        for name in asset_types_for_folder_type(folder_type):
+            if not ids_by_type.get(name):
+                continue
+            model = ASSET_TYPE_CONFIGS[name].model
+            for obj in (
+                db.session.query(model).filter(model.id.in_(ids_by_type[name])).all()
+            ):
+                objects[(name, obj.id)] = obj
+
+        rows: list[ResolvedAsset] = [
+            (item_type, objects[(item_type, item_id)])
+            for item_type, item_id in page_rows
+            if (item_type, item_id) in objects
+        ]
+        return rows, total
+
+    @classmethod
+    def get_folder_path(cls, folder_id: int) -> list[dict[str, str]]:
+        """Build the breadcrumb path from root to the given folder."""
+        path: list[dict[str, str]] = []
+        folder = db.session.get(Folder, folder_id)
+        while folder:
+            path.append({"uuid": str(folder.uuid), "name": folder.name})
+            folder = folder.parent
+        path.reverse()
+        return path
+
+    @classmethod
+    def get_asset_folder_paths(
+        cls, asset_ids: dict[str, list[int]]
+    ) -> dict[tuple[str, int], list[dict[str, str]]]:
+        """Get folder paths for a batch of assets. Returns {(type, id): path}."""
+        result: dict[tuple[str, int], list[dict[str, str]]] = {}
+        path_cache: dict[int, list[dict[str, str]]] = {}
+        for asset_type, ids in asset_ids.items():
+            config = ASSET_TYPE_CONFIGS.get(asset_type)
+            if not config:
+                continue
+            fk_col = getattr(FolderObject, config.fk_column)
+            links = (
+                db.session.query(FolderObject.folder_id, fk_col)
+                .filter(fk_col.in_(ids), fk_col.isnot(None))
+                .all()
+            )
+            for folder_id, asset_id in links:
+                if folder_id not in path_cache:
+                    path_cache[folder_id] = cls.get_folder_path(folder_id)
+                result[(asset_type, asset_id)] = path_cache[folder_id]
+        return result
+
+    @staticmethod
+    def _user_model() -> Any:
+        from superset import security_manager
+
+        return security_manager.user_model
+
+    @classmethod
+    def _load_assets(cls, ids_by_type: dict[str, list[int]]) -> list[ResolvedAsset]:
+        resolved: list[ResolvedAsset] = []
+        for name, ids in ids_by_type.items():
+            if not ids:
+                continue
+            model = ASSET_TYPE_CONFIGS[name].model
+            rows = db.session.query(model).filter(model.id.in_(ids)).all()
+            resolved.extend((name, row) for row in rows)
+        return resolved
+
+    # ------------------------------------------------------------------ #
+    # Persistence
+    # ------------------------------------------------------------------ #
+    @classmethod
+    def delete_folder(cls, folder: Folder, archive_items: bool = False) -> None:
+        """Delete a folder, re-parenting children to its parent.
+
+        When ``archive_items`` is False (default), assets linked to the folder
+        become unfoldered. When True, the assets themselves are also deleted.
+        """
+        for child in list(folder.children):
+            child.parent_id = folder.parent_id
+            child.name = cls.resolve_name_conflict(
+                child.name, folder.parent_id, folder.folder_type, exclude_id=child.id
+            )
+        # Clear the in-memory collection so db.session.delete(folder) below
+        # doesn't re-null the children's parent_id.
+        folder.children.clear()
+        db.session.flush()
+
+        if archive_items:
+            for link in list(folder.objects):
+                for _name, config in ASSET_TYPE_CONFIGS.items():
+                    asset_id = getattr(link, config.fk_column)
+                    if asset_id is not None:
+                        asset = db.session.get(config.model, asset_id)
+                        if asset:
+                            db.session.delete(asset)
+                        break
+
+        db.session.delete(folder)
+
+    @classmethod
+    def assign_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
+        """Assign/move assets into ``folder`` (upsert).
+
+        An asset can live in only one folder, so an existing link is moved rather
+        than duplicated. Callers are expected to have validated the assets.
+        """
+        for asset in assets:
+            config = ASSET_TYPE_CONFIGS[asset["type"]]
+            link_column = getattr(FolderObject, config.fk_column)
+            link = (
+                db.session.query(FolderObject)
+                .filter(link_column == asset["id"])
+                .one_or_none()
+            )
+            if link:
+                link.folder_id = folder.id
+            else:
+                link = FolderObject(folder_id=folder.id, created_on=datetime.utcnow())
+                setattr(link, config.fk_column, asset["id"])
+                db.session.add(link)
+
+            # Remove any pins for this asset since it's no longer at root
+            pin_col = {
+                "folder": FolderPin.folder_id,
+                "dashboard": FolderPin.dashboard_id,
+                "chart": FolderPin.chart_id,
+            }[asset["type"]]
+            db.session.query(FolderPin).filter(
+                pin_col == asset["id"],
+            ).delete()
+
+    @classmethod
+    def set_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
+        """Set ``folder``'s membership to exactly ``assets``.
+
+        Listed assets are moved into the folder; the folder's current assets that
+        are not listed have their link removed (back to the root). An empty list
+        empties the folder. Callers are expected to have validated the assets.
+        """
+        desired = {(asset["type"], asset["id"]) for asset in assets}
+        for link in list(folder.objects):
+            for name, config in ASSET_TYPE_CONFIGS.items():
+                asset_id = getattr(link, config.fk_column)
+                if asset_id is not None and (name, asset_id) not in desired:
+                    db.session.delete(link)
+                    break
+        db.session.flush()
+        cls.assign_assets(folder, assets)
+
+    @classmethod
+    def remove_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
+        """Remove specific assets from ``folder`` (back to root)."""
+        for asset in assets:
+            config = ASSET_TYPE_CONFIGS[asset["type"]]
+            link_column = getattr(FolderObject, config.fk_column)
+            link = (
+                db.session.query(FolderObject)
+                .filter(
+                    FolderObject.folder_id == folder.id,
+                    link_column == asset["id"],
+                )
+                .one_or_none()
+            )
+            if link:
+                db.session.delete(link)
+
+    # ------------------------------------------------------------------ #
+    # Validation helpers (used by commands)
+    # ------------------------------------------------------------------ #
+    @classmethod
+    def validate_name_uniqueness(
+        cls,
+        name: str,
+        parent_id: int | None,
+        folder_type: str,
+        exclude_id: int | None = None,
+    ) -> bool:
+        """True if no sibling of the same type already uses ``name``."""
+        query = db.session.query(Folder.id).filter(
+            Folder.parent_id.is_(None)
+            if parent_id is None
+            else Folder.parent_id == parent_id,
+            Folder.name == name,
+            Folder.folder_type == folder_type,
+        )
+        if exclude_id is not None:
+            query = query.filter(Folder.id != exclude_id)
+        return query.first() is None
+
+    @classmethod
+    def resolve_name_conflict(
+        cls,
+        name: str,
+        parent_id: int | None,
+        folder_type: str,
+        exclude_id: int | None = None,
+    ) -> str:
+        """Return a unique name by appending a numeric suffix if needed."""
+        if cls.validate_name_uniqueness(name, parent_id, folder_type, exclude_id):
+            return name
+        for counter in range(1, 1001):
+            candidate = f"{name} ({counter})"
+            if cls.validate_name_uniqueness(
+                candidate, parent_id, folder_type, exclude_id
+            ):
+                return candidate
+        raise ValueError(f"Cannot resolve name conflict for '{name}'")
+
+    @classmethod
+    def is_descendant(cls, candidate: Folder, ancestor: Folder) -> bool:
+        """True if ``candidate`` is ``ancestor`` itself or below it in the tree."""
+        node: Folder | None = candidate
+        while node is not None:
+            if node.id == ancestor.id:
+                return True
+            node = node.parent
+        return False
+
+    @classmethod
+    def asset_exists(cls, asset_type: str, asset_id: int) -> bool:
+        """True if the referenced asset exists."""
+        model = ASSET_TYPE_CONFIGS[asset_type].model
+        return (
+            db.session.query(model.id).filter(model.id == asset_id).first() is not None
+        )
+
+    # ------------------------------------------------------------------ #
+    # Private / "Only Me" folders
+    # ------------------------------------------------------------------ #
+    @classmethod
+    def get_or_create_only_me_folder(cls, user_id: int) -> Folder:
+        """Return the user's 'Only Me' folder, creating it if it doesn't exist."""
+        from superset.utils import json as json_utils
+
+        folder = (
+            db.session.query(Folder)
+            .join(folder_editors, folder_editors.c.folder_id == Folder.id)
+            .filter(
+                folder_editors.c.user_id == user_id,
+                Folder.is_private.is_(True),
+                Folder.parent_id.is_(None),
+            )
+            .all()
+        )
+        for f in folder:
+            extra = json_utils.loads(f.extra) if f.extra else {}
+            if extra.get("only_me"):
+                return f
+
+        new_folder = cls.create(
+            attributes={
+                "name": "Only Me",
+                "is_private": True,
+                "parent_id": None,
+                "folder_type": "analytics",
+                "extra": json_utils.dumps({"only_me": True, "inherits_permissions": False}),
+            }
+        )
+        db.session.flush()
+        db.session.execute(
+            folder_editors.insert().values(folder_id=new_folder.id, user_id=user_id)
+        )
+        db.session.flush()
+        return new_folder
+
+    # ------------------------------------------------------------------ #
+    # Pins
+    # ------------------------------------------------------------------ #
+    @classmethod
+    def get_pins(cls, user_id: int) -> list[FolderPin]:
+        return (
+            db.session.query(FolderPin)
+            .filter(FolderPin.user_id == user_id)
+            .order_by(FolderPin.position)
+            .all()
+        )
+
+    @classmethod
+    def create_pin(
+        cls,
+        user_id: int,
+        object_id: int,
+        object_type: str,
+        position: int,
+    ) -> FolderPin:
+        pin = FolderPin(
+            user_id=user_id,
+            folder_id=object_id if object_type == "folder" else None,
+            dashboard_id=object_id if object_type == "dashboard" else None,
+            chart_id=object_id if object_type == "chart" else None,
+            position=position,
+            created_on=datetime.utcnow(),
+        )
+        db.session.add(pin)
+        db.session.flush()
+        return pin
+
+    @classmethod
+    def delete_pin(cls, pin_id: int, user_id: int) -> bool:
+        pin = (
+            db.session.query(FolderPin)
+            .filter(FolderPin.id == pin_id, FolderPin.user_id == user_id)
+            .first()
+        )
+        if not pin:
+            return False
+        db.session.delete(pin)
+        db.session.flush()
+        return True
+
+    # ------------------------------------------------------------------ #
+    # Subjects (permissions)
+    # ------------------------------------------------------------------ #
+    @classmethod
+    def get_subjects(cls, folder_id: int) -> list[dict[str, Any]]:
+        from superset import security_manager
+
+        subjects: list[dict[str, Any]] = []
+        admin_role = security_manager.find_role("Admin")
+
+        editors = db.session.execute(
+            folder_editors.select().where(folder_editors.c.folder_id == folder_id)
+        ).fetchall()
+        viewers = db.session.execute(
+            folder_viewers.select().where(folder_viewers.c.folder_id == folder_id)
+        ).fetchall()
+
+        all_user_ids = [r.user_id for r in editors] + [r.user_id for r in viewers]
+        users_by_id = (
+            {
+                u.id: u
+                for u in db.session.query(User).filter(User.id.in_(all_user_ids)).all()
+            }
+            if all_user_ids
+            else {}
+        )
+
+        def _is_admin(user: User | None) -> bool:
+            if not user or not admin_role:
+                return False
+            return admin_role in user.roles
+
+        for row in editors:
+            user = users_by_id.get(row.user_id)
+            is_admin = _is_admin(user)
+            subjects.append(
+                {
+                    "user_id": row.user_id,
+                    "permission": "admin" if is_admin else "editor",
+                    "email": user.email if user else None,
+                    "is_admin": is_admin,
+                }
+            )
+
+        for row in viewers:
+            user = users_by_id.get(row.user_id)
+            is_admin = _is_admin(user)
+            subjects.append(
+                {
+                    "user_id": row.user_id,
+                    "permission": "admin" if is_admin else "viewer",
+                    "email": user.email if user else None,
+                    "is_admin": is_admin,
+                }
+            )
+
+        return subjects
+
+    @classmethod
+    def add_subject(cls, folder_id: int, user_id: int, permission: str) -> None:
+        from flask_appbuilder.security.sqla.models import User
+
+        if not db.session.get(User, user_id):
+            raise ValueError(f"User {user_id} does not exist")
+
+        existing = (
+            db.session.execute(
+                folder_editors.select().where(
+                    and_(
+                        folder_editors.c.folder_id == folder_id,
+                        folder_editors.c.user_id == user_id,
+                    )
+                )
+            ).first()
+            or db.session.execute(
+                folder_viewers.select().where(
+                    and_(
+                        folder_viewers.c.folder_id == folder_id,
+                        folder_viewers.c.user_id == user_id,
+                    )
+                )
+            ).first()
+        )
+        if existing:
+            raise ValueError(f"User {user_id} is already a member of this folder")
+
+        if permission == "editor":
+            db.session.execute(
+                folder_editors.insert().values(folder_id=folder_id, user_id=user_id)
+            )
+        else:
+            db.session.execute(
+                folder_viewers.insert().values(folder_id=folder_id, user_id=user_id)
+            )
+        db.session.flush()
+
+    @classmethod
+    def update_subject(cls, folder_id: int, user_id: int, permission: str) -> None:
+        existing = (
+            db.session.execute(
+                folder_editors.select().where(
+                    and_(
+                        folder_editors.c.folder_id == folder_id,
+                        folder_editors.c.user_id == user_id,
+                    )
+                )
+            ).first()
+            or db.session.execute(
+                folder_viewers.select().where(
+                    and_(
+                        folder_viewers.c.folder_id == folder_id,
+                        folder_viewers.c.user_id == user_id,
+                    )
+                )
+            ).first()
+        )
+        if not existing:
+            raise ValueError(f"User {user_id} is not a member of this folder")
+        db.session.execute(
+            folder_editors.delete().where(
+                and_(
+                    folder_editors.c.folder_id == folder_id,
+                    folder_editors.c.user_id == user_id,
+                )
+            )
+        )
+        db.session.execute(
+            folder_viewers.delete().where(
+                and_(
+                    folder_viewers.c.folder_id == folder_id,
+                    folder_viewers.c.user_id == user_id,
+                )
+            )
+        )
+        cls.add_subject(folder_id, user_id, permission)
+
+    @classmethod
+    def folders_with_accessible_assets(cls, user_id: int, folder_type: str) -> set[int]:
+        """Return folder IDs containing at least one asset the user owns.
+
+        Only checks ownership — not datasource access. This is intentional:
+        the folder gate in raise_for_access blocks non-members from accessing
+        foldered assets even if they have datasource access. So implicit
+        folder visibility should only show folders with assets the user owns,
+        since those are the only assets the gate allows through.
+        """
+        folder_ids: set[int] = set()
+
+        for name in asset_types_for_folder_type(folder_type):
+            config = ASSET_TYPE_CONFIGS[name]
+            model = config.model
+            fk_col = getattr(FolderObject, config.fk_column)
+
+            from superset.subjects.models import Subject
+
+            owned_assets = (
+                select(model.id)
+                .where(model.editors.any(Subject.user_id == user_id))
+                .subquery()
+            )
+            rows = (
+                db.session.query(FolderObject.folder_id)
+                .join(Folder, Folder.id == FolderObject.folder_id)
+                .filter(
+                    fk_col.isnot(None),
+                    fk_col.in_(select(owned_assets.c.id)),
+                    Folder.folder_type == folder_type,
+                )
+                .distinct()
+                .all()
+            )
+            folder_ids.update(r[0] for r in rows)
+
+        # Include ancestor folders so the tree stays navigable.
+        if folder_ids:
+            parent_map = {
+                f.id: f.parent_id
+                for f in db.session.query(Folder.id, Folder.parent_id)
+                .filter(Folder.folder_type == folder_type)
+                .all()
+            }
+            ancestors: set[int] = set()
+            for fid in folder_ids:
+                parent_id = parent_map.get(fid)
+                while parent_id is not None and parent_id not in ancestors:
+                    ancestors.add(parent_id)
+                    parent_id = parent_map.get(parent_id)
+            folder_ids |= ancestors
+
+        return folder_ids
+
+    @classmethod
+    def remove_subject(cls, folder_id: int, user_id: int) -> None:
+        db.session.execute(
+            folder_editors.delete().where(
+                and_(
+                    folder_editors.c.folder_id == folder_id,
+                    folder_editors.c.user_id == user_id,
+                )
+            )
+        )
+        db.session.execute(
+            folder_viewers.delete().where(
+                and_(
+                    folder_viewers.c.folder_id == folder_id,
+                    folder_viewers.c.user_id == user_id,
+                )
+            )
+        )
+        db.session.flush()
diff --git a/superset/daos/folder_permissions.py b/superset/daos/folder_permissions.py
new file mode 100644
index 00000000000..1342c6fdbcc
--- /dev/null
+++ b/superset/daos/folder_permissions.py
@@ -0,0 +1,457 @@
+# 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.
+"""Data access for folder permission operations."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from sqlalchemy import and_
+from superset import db
+from superset.utils import json as json_utils
+
+from superset.folders.models import (
+    Folder,
+    folder_editors,
+    folder_viewers,
+    FolderObject,
+)
+
+
+class FolderPermissionDAO:
+    """Queries and persistence for folder permissions (editors/viewers)."""
+
+    # ------------------------------------------------------------------ #
+    # Subject queries
+    # ------------------------------------------------------------------ #
+    @staticmethod
+    def get_subjects(folder_id: int) -> list[dict[str, Any]]:
+        """Get all editors and viewers for a folder."""
+        subjects: list[dict[str, Any]] = []
+
+        editors = db.session.execute(
+            folder_editors.select().where(folder_editors.c.folder_id == folder_id)
+        ).fetchall()
+        for row in editors:
+            subjects.append({"user_id": row.user_id, "permission": "editor"})
+
+        viewers = db.session.execute(
+            folder_viewers.select().where(folder_viewers.c.folder_id == folder_id)
+        ).fetchall()
+        for row in viewers:
+            subjects.append({"user_id": row.user_id, "permission": "viewer"})
+
+        return subjects
+
+    # ------------------------------------------------------------------ #
+    # Subject mutations
+    # ------------------------------------------------------------------ #
+    @staticmethod
+    def add_editor(folder_id: int, user_id: int) -> None:
+        db.session.execute(
+            folder_editors.insert().values(folder_id=folder_id, user_id=user_id)
+        )
+        db.session.flush()
+
+    @staticmethod
+    def add_viewer(folder_id: int, user_id: int) -> None:
+        db.session.execute(
+            folder_viewers.insert().values(folder_id=folder_id, user_id=user_id)
+        )
+        db.session.flush()
+
+    @staticmethod
+    def remove_editor(folder_id: int, user_id: int) -> None:
+        db.session.execute(
+            folder_editors.delete().where(
+                and_(
+                    folder_editors.c.folder_id == folder_id,
+                    folder_editors.c.user_id == user_id,
+                )
+            )
+        )
+        db.session.flush()
+
+    @staticmethod
+    def remove_viewer(folder_id: int, user_id: int) -> None:
+        db.session.execute(
+            folder_viewers.delete().where(
+                and_(
+                    folder_viewers.c.folder_id == folder_id,
+                    folder_viewers.c.user_id == user_id,
+                )
+            )
+        )
+        db.session.flush()
+
+    @staticmethod
+    def update_subject_permission(
+        folder_id: int, user_id: int, permission: str
+    ) -> None:
+        """Move a user between editors and viewers."""
+        if permission not in ("editor", "viewer"):
+            raise ValueError(
+                f"Invalid permission: {permission!r}. Must be 'editor' or 'viewer'."
+            )
+        if permission == "editor":
+            FolderPermissionDAO.remove_viewer(folder_id, user_id)
+            FolderPermissionDAO.add_editor(folder_id, user_id)
+        else:
+            FolderPermissionDAO.remove_editor(folder_id, user_id)
+            FolderPermissionDAO.add_viewer(folder_id, user_id)
+
+    @staticmethod
+    def remove_subject(folder_id: int, user_id: int) -> None:
+        """Remove a user from both editors and viewers."""
+        FolderPermissionDAO.remove_editor(folder_id, user_id)
+        FolderPermissionDAO.remove_viewer(folder_id, user_id)
+
+    # ------------------------------------------------------------------ #
+    # Permission inheritance
+    # ------------------------------------------------------------------ #
+    @staticmethod
+    def copy_permissions_to_subfolder(
+        parent_folder_id: int, child_folder_id: int
+    ) -> None:
+        """Replace child folder permissions with parent's.
+
+        Clears existing editors/viewers on the child, then copies the parent's.
+        Sets extra.inherits_permissions = true on the child.
+        Skips private folders — their permissions are owner-only.
+        """
+        child = db.session.query(Folder).get(child_folder_id)
+        if child and child.is_private:
+            return
+
+        # Clear child's existing permissions
+        db.session.execute(
+            folder_editors.delete().where(
+                folder_editors.c.folder_id == child_folder_id
+            )
+        )
+        db.session.execute(
+            folder_viewers.delete().where(
+                folder_viewers.c.folder_id == child_folder_id
+            )
+        )
+
+        # Copy from parent
+        parent_editors = db.session.execute(
+            folder_editors.select().where(
+                folder_editors.c.folder_id == parent_folder_id
+            )
+        ).fetchall()
+        for row in parent_editors:
+            db.session.execute(
+                folder_editors.insert().values(
+                    folder_id=child_folder_id, user_id=row.user_id
+                )
+            )
+
+        parent_viewers = db.session.execute(
+            folder_viewers.select().where(
+                folder_viewers.c.folder_id == parent_folder_id
+            )
+        ).fetchall()
+        for row in parent_viewers:
+            db.session.execute(
+                folder_viewers.insert().values(
+                    folder_id=child_folder_id, user_id=row.user_id
+                )
+            )
+
+        # Set inherits flag on child
+        if child := db.session.query(Folder).get(child_folder_id):
+            extra = json_utils.loads(child.extra) if child.extra else {}
+            extra["inherits_permissions"] = True
+            child.extra = json_utils.dumps(extra)
+
+        db.session.flush()
+
+    @staticmethod
+    def push_down_permissions(folder_id: int) -> None:
+        """Re-sync permissions to all descendant folders still inheriting.
+
+        Called when a folder's permissions change. Only affects descendants
+        where extra.inherits_permissions = true.
+        """
+
+        folder = db.session.query(Folder).get(folder_id)
+        if not folder:
+            return
+
+        # Get current folder's permissions
+        current_subjects = FolderPermissionDAO.get_subjects(folder_id)
+
+        # Find all descendants that still inherit
+        def _get_inheriting_descendants(parent_id: int) -> list[int]:
+            children = (
+                db.session.query(Folder).filter(Folder.parent_id == parent_id).all()
+            )
+            result = []
+            for child in children:
+                if child.is_private:
+                    continue
+                extra = json_utils.loads(child.extra) if child.extra else {}
+                if extra.get("inherits_permissions", True):
+                    result.append(child.id)
+                    result.extend(_get_inheriting_descendants(child.id))
+            return result
+
+        descendant_ids = _get_inheriting_descendants(folder_id)
+
+        for desc_id in descendant_ids:
+            # Clear existing permissions
+            db.session.execute(
+                folder_editors.delete().where(folder_editors.c.folder_id == desc_id)
+            )
+            db.session.execute(
+                folder_viewers.delete().where(folder_viewers.c.folder_id == desc_id)
+            )
+            # Copy from parent
+            for subject in current_subjects:
+                if subject["permission"] == "editor":
+                    db.session.execute(
+                        folder_editors.insert().values(
+                            folder_id=desc_id, user_id=subject["user_id"]
+                        )
+                    )
+                else:
+                    db.session.execute(
+                        folder_viewers.insert().values(
+                            folder_id=desc_id, user_id=subject["user_id"]
+                        )
+                    )
+
+        db.session.flush()
+
+    @staticmethod
+    def mark_permissions_explicit(folder_id: int) -> None:
+        """Mark a folder as having explicitly set permissions (no longer inheriting)."""
+
+        if folder := db.session.query(Folder).get(folder_id):
+            extra = json_utils.loads(folder.extra) if folder.extra else {}
+            extra["inherits_permissions"] = False
+            folder.extra = json_utils.dumps(extra)
+            db.session.flush()
+
+    # ------------------------------------------------------------------ #
+    # Access checks
+    # ------------------------------------------------------------------ #
+    @staticmethod
+    def user_has_folder_access(user_id: int, folder_id: int) -> bool:
+        """Check if a user has viewer or editor access to a folder."""
+        is_editor = db.session.execute(
+            folder_editors.select().where(
+                and_(
+                    folder_editors.c.folder_id == folder_id,
+                    folder_editors.c.user_id == user_id,
+                )
+            )
+        ).first()
+        if is_editor:
+            return True
+
+        is_viewer = db.session.execute(
+            folder_viewers.select().where(
+                and_(
+                    folder_viewers.c.folder_id == folder_id,
+                    folder_viewers.c.user_id == user_id,
+                )
+            )
+        ).first()
+        return is_viewer is not None
+
+    @staticmethod
+    def user_is_folder_editor(user_id: int, folder_id: int) -> bool:
+        """Check if a user is an editor of a specific folder."""
+        return (
+            db.session.execute(
+                folder_editors.select().where(
+                    and_(
+                        folder_editors.c.folder_id == folder_id,
+                        folder_editors.c.user_id == user_id,
+                    )
+                )
+            ).first()
+            is not None
+        )
+
+    @staticmethod
+    def user_has_any_folder_access(user_id: int) -> bool:
+        """Check if user has any folder-level access (editor or viewer)."""
+        has_access = (
+            db.session.query(folder_editors.c.id)
+            .filter(folder_editors.c.user_id == user_id)
+            .first()
+        ) or (
+            db.session.query(folder_viewers.c.id)
+            .filter(folder_viewers.c.user_id == user_id)
+            .first()
+        )
+        return has_access is not None
+
+    @staticmethod
+    def user_has_any_folder_editor_access(user_id: int) -> bool:
+        """Check if user is an editor on any folder."""
+        return (
+            db.session.query(folder_editors.c.id)
+            .filter(folder_editors.c.user_id == user_id)
+            .first()
+        ) is not None
+
+    @staticmethod
+    def _check_folder_object_access(user_id: int, filter_condition: Any) -> bool:
+        """Check if a matching FolderObject is in an accessible folder."""
+        fo = db.session.query(FolderObject).filter(filter_condition).first()
+        return bool(
+            fo and FolderPermissionDAO.user_has_folder_access(user_id, fo.folder_id)
+        )
+
+    @staticmethod
+    def user_has_folder_access_for_asset(
+        user_id: int,
+        dashboard_id: int | None = None,
+        chart_id: int | None = None,
+        datasource_id: int | None = None,
+    ) -> bool:
+        """Check if user has folder access that covers this asset.
+
+        Checks multiple paths:
+        1. Dashboard/chart is directly in a folder the user has access to
+        2. Datasource is used by a chart in a folder the user has access to
+        """
+        check = FolderPermissionDAO._check_folder_object_access
+
+        if dashboard_id and check(user_id, FolderObject.dashboard_id == dashboard_id):
+            return True
+
+        if chart_id and check(user_id, FolderObject.chart_id == chart_id):
+            return True
+
+        if datasource_id:
+            from superset.models.slice import Slice as SliceModel
+
+            folder_objects = (
+                db.session.query(FolderObject)
+                .join(SliceModel, SliceModel.id == FolderObject.chart_id)
+                .filter(SliceModel.datasource_id == datasource_id)
+                .all()
+            )
+            for fo in folder_objects:
+                if FolderPermissionDAO.user_has_folder_access(user_id, fo.folder_id):
+                    return True
+
+        return False
+
+    @staticmethod
+    def user_has_transitive_dashboard_access(
+        user_id: int,
+        datasource_id: int,
+    ) -> bool:
+        """Check if a datasource is reachable via: datasource → chart → dashboard → folder.
+
+        Grants access when a dashboard in the user's folder contains a chart
+        that uses this datasource, even if the chart itself is not in any folder.
+        """
+        from superset.models.dashboard import dashboard_slices
+        from superset.models.slice import Slice as SliceModel
+
+        # Find dashboards that contain a chart using this datasource
+        # and are in a folder the user has access to.
+        rows = (
+            db.session.query(FolderObject.folder_id)
+            .join(
+                dashboard_slices,
+                dashboard_slices.c.dashboard_id == FolderObject.dashboard_id,
+            )
+            .join(SliceModel, SliceModel.id == dashboard_slices.c.slice_id)
+            .filter(
+                FolderObject.dashboard_id.isnot(None),
+                SliceModel.datasource_id == datasource_id,
+            )
+            .all()
+        )
+        return any(
+            FolderPermissionDAO.user_has_folder_access(user_id, row.folder_id)
+            for row in rows
+        )
+
+    @staticmethod
+    def user_is_folder_editor_for_asset(
+        user_id: int,
+        dashboard_id: int | None = None,
+        chart_id: int | None = None,
+    ) -> bool:
+        """Check if user is a folder editor for an asset."""
+        if dashboard_id:
+            fo = (
+                db.session.query(FolderObject)
+                .filter(FolderObject.dashboard_id == dashboard_id)
+                .first()
+            )
+            if fo:
+                return FolderPermissionDAO.user_is_folder_editor(user_id, fo.folder_id)
+
+        if chart_id:
+            fo = (
+                db.session.query(FolderObject)
+                .filter(FolderObject.chart_id == chart_id)
+                .first()
+            )
+            if fo:
+                return FolderPermissionDAO.user_is_folder_editor(user_id, fo.folder_id)
+
+        return False
+
+    @staticmethod
+    def get_folder_editors_as_owners(
+        dashboard_id: int | None = None,
+        chart_id: int | None = None,
+    ) -> list[dict[str, Any]]:
+        """Return folder editors for an asset as owner-shaped dicts.
+
+        Used to populate the `extra_owners` field in API responses.
+        """
+        from superset.folders.utils import get_folder_editor_users
+
+        if dashboard_id:
+            fo = (
+                db.session.query(FolderObject)
+                .filter(FolderObject.dashboard_id == dashboard_id)
+                .first()
+            )
+        elif chart_id:
+            fo = (
+                db.session.query(FolderObject)
+                .filter(FolderObject.chart_id == chart_id)
+                .first()
+            )
+        else:
+            return []
+
+        if not fo:
+            return []
+
+        return [
+            {
+                "id": u.id,
+                "first_name": u.first_name,
+                "last_name": u.last_name,
+            }
+            for u in get_folder_editor_users(fo.folder_id)
+        ]
diff --git a/superset/folders/__init__.py b/superset/folders/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/folders/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/superset/folders/api.py b/superset/folders/api.py
new file mode 100644
index 00000000000..803b867c1aa
--- /dev/null
+++ b/superset/folders/api.py
@@ -0,0 +1,1558 @@
+# 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.
+"""REST API for folder management and asset assignment."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from typing import Any
+
+from flask import g, request, Response
+from flask_appbuilder.api import (
+    expose,
+    permission_name,
+    protect,
+    rison as parse_rison,
+    safe,
+)
+from flask_appbuilder.security.sqla.models import User
+from marshmallow import ValidationError
+
+from superset import security_manager
+from superset.commands.folder.assets import (
+    AddFolderAssetsCommand,
+    UpdateFolderAssetsCommand,
+)
+from superset.commands.folder.create import CreateFolderCommand
+from superset.commands.folder.delete import DeleteFolderCommand
+from superset.commands.folder.exceptions import (
+    FolderCreateFailedError,
+    FolderDeleteFailedError,
+    FolderForbiddenError,
+    FolderInvalidError,
+    FolderNotFoundError,
+    FolderUpdateFailedError,
+)
+from superset.commands.folder.update import UpdateFolderCommand
+from superset.daos.folder import FolderDAO, ResolvedAsset
+from superset.daos.folder_permissions import FolderPermissionDAO
+from superset.extensions import db, event_logger
+from superset.folders.constants import ASSET_TYPE_CONFIGS, DEFAULT_FOLDER_TYPE
+from superset.folders.models import Folder, folder_editors, folder_viewers
+from superset.folders.schemas import (
+    FolderAssetSchema,
+    FolderAssetsPutSchema,
+    FolderContentItemSchema,
+    FolderContentsResponseSchema,
+    FolderListResponseSchema,
+    FolderPinPostSchema,
+    FolderPostSchema,
+    FolderPutSchema,
+    FolderResponseSchema,
+    FolderRootResponseSchema,
+    FolderSchema,
+    FolderSubjectPostSchema,
+    FolderSubjectPutSchema,
+)
+from superset.folders.utils import can_manage_folders
+from superset.utils import json as json_utils
+from superset.utils.core import get_user_id
+from superset.utils.decorators import transaction
+from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
+
+logger = logging.getLogger(__name__)
+
+
+def _serialize_user(user: Any) -> dict[str, Any] | None:
+    if not user:
+        return None
+    return {
+        "id": user.id,
+        "first_name": user.first_name,
+        "last_name": user.last_name,
+    }
+
+
+def _serialize_subjects(subjects: Any) -> list[dict[str, Any]]:
+    return [
+        {"id": s.id, "label": s.label, "type": s.type, "user_id": s.user_id}
+        for s in (subjects or [])
+    ]
+
+
+def _get_user_permission(
+    folder: Folder, implicit_folder_ids: set[int] | None = None
+) -> str | None:
+    """Return the current user's permission level for this folder."""
+    if security_manager.is_admin():
+        return "editor"
+    user_id = get_user_id()
+    if not user_id:
+        return None
+    if FolderPermissionDAO.user_is_folder_editor(user_id, folder.id):
+        return "editor"
+    if FolderPermissionDAO.user_has_folder_access(user_id, folder.id):
+        return "viewer"
+    if implicit_folder_ids and folder.id in implicit_folder_ids:
+        return "implicit"
+    return None
+
+
+def _is_only_me(folder: Folder) -> bool:
+    """Return whether this is the system 'Only Me' private folder."""
+    if not folder.is_private or not folder.extra:
+        return False
+    extra = json_utils.loads(folder.extra)
+    return bool(extra.get("only_me"))
+
+
+def _get_inherits_permissions(folder: Folder) -> bool:
+    """Return whether this folder inherits permissions from its parent."""
+    if not folder.extra:
+        return True
+    extra = json_utils.loads(folder.extra)
+    return extra.get("inherits_permissions", True)
+
+
+def serialize_folder(
+    folder: Folder,
+    children_count: int | None = None,
+    asset_count: int | None = None,
+    implicit_folder_ids: set[int] | None = None,
+) -> dict[str, Any]:
+    """Serialize a folder's metadata for API responses.
+
+    Accepts optional pre-computed counts to avoid N+1 queries. When not
+    provided, falls back to relationship access (acceptable for single-folder
+    responses but expensive in list views).
+    """
+    return {
+        "type": "folder",
+        "id": folder.id,
+        "uuid": str(folder.uuid),
+        "name": folder.name,
+        "description": folder.description,
+        "parent_uuid": str(folder.parent.uuid) if folder.parent else None,
+        "folder_type": folder.folder_type,
+        "is_private": folder.is_private,
+        "children_count": (
+            children_count if children_count is not None else len(folder.children)
+        ),
+        "asset_count": (
+            asset_count if asset_count is not None else len(folder.objects)
+        ),
+        "created_on": folder.created_on,
+        "changed_on": folder.changed_on,
+        "changed_on_humanized": folder.changed_on_humanized,
+        "created_by": _serialize_user(folder.created_by),
+        "changed_by": _serialize_user(folder.changed_by),
+        "user_permission": _get_user_permission(folder, implicit_folder_ids),
+        "inherits_permissions": _get_inherits_permissions(folder),
+        "is_only_me": _is_only_me(folder),
+        "owners": [],
+    }
+
+
+def serialize_row(
+    kind: str,
+    obj: Any,
+    folder_path: list[dict[str, str]] | None = None,
+    implicit_folder_ids: set[int] | None = None,
+) -> dict[str, Any]:
+    """Serialize a contents row (folder or asset) into a uniform-ish item."""
+    data = (
+        serialize_folder(obj, implicit_folder_ids=implicit_folder_ids)
+        if kind == "folder"
+        else serialize_asset(kind, obj)
+    )
+    if folder_path is not None:
+        data["folder_path"] = folder_path
+    return data
+
+
+def serialize_rows_with_paths(
+    rows: list[ResolvedAsset],
+    current_folder_id: int | None,
+) -> list[dict[str, Any]]:
+    """Serialize rows and attach folder_path for items from child folders."""
+    # Batch-resolve folder paths for assets
+    asset_ids: dict[str, list[int]] = {}
+    for kind, obj in rows:
+        if kind != "folder":
+            asset_ids.setdefault(kind, []).append(obj.id)
+    asset_paths = FolderDAO.get_asset_folder_paths(asset_ids) if asset_ids else {}
+
+    # Resolve current folder UUID once, not per row
+    current_uuid = None
+    if current_folder_id is not None:
+        current_folder = db.session.get(Folder, current_folder_id)
+        current_uuid = str(current_folder.uuid) if current_folder else None
+
+    result = []
+    for kind, obj in rows:
+        path = None
+        if kind == "folder" and obj.parent_id != current_folder_id:
+            # Folder from a deeper level — show path to its parent (not itself)
+            path = FolderDAO.get_folder_path(obj.parent_id) if obj.parent_id else None
+        elif kind != "folder":
+            full_path = asset_paths.get((kind, obj.id))
+            if full_path:
+                asset_folder_uuid = full_path[-1]["uuid"] if full_path else None
+                if asset_folder_uuid != current_uuid:
+                    path = full_path
+        result.append(serialize_row(kind, obj, path))
+    return result
+
+
+def serialize_asset(asset_type: str, asset: Any) -> dict[str, Any]:
+    """Serialize a single asset (dashboard/chart/dataset) with column data."""
+    config = ASSET_TYPE_CONFIGS[asset_type]
+    return {
+        "type": asset_type,
+        "id": asset.id,
+        "uuid": str(asset.uuid) if getattr(asset, "uuid", None) else None,
+        "name": getattr(asset, config.title_attr, None),
+        "url": getattr(asset, "url", None),
+        "changed_on": getattr(asset, "changed_on", None),
+        "changed_on_humanized": getattr(asset, "changed_on_humanized", None),
+        "changed_by": _serialize_user(getattr(asset, "changed_by", None)),
+        "editors": _serialize_subjects(getattr(asset, "editors", [])),
+        "viewers": _serialize_subjects(getattr(asset, "viewers", [])),
+        # Chart-only columns; ``None`` for other asset kinds.
+        "viz_type": getattr(asset, "viz_type", None),
+        "datasource_name": (
+            asset.datasource_name_text()
+            if asset_type == "chart"
+            and callable(getattr(asset, "datasource_name_text", None))
+            else None
+        ),
+        "datasource_url": (
+            asset.datasource_url()
+            if asset_type == "chart"
+            and callable(getattr(asset, "datasource_url", None))
+            else None
+        ),
+        "tags": [
+            {
+                "id": t.id,
+                "name": t.name,
+                "type": t.type.value if hasattr(t.type, "value") else t.type,
+            }
+            for t in getattr(asset, "tags", []) or []
+        ],
+    }
+
+
+def serialize_assets(assets: list[ResolvedAsset]) -> list[dict[str, Any]]:
+    """Serialize assets, most-recently-changed first."""
+    serialized = [serialize_asset(asset_type, asset) for asset_type, asset in assets]
+    serialized.sort(key=lambda item: item["changed_on"] or datetime.min, reverse=True)
+    return serialized
+
+
+def _positive_int(key: str, default: int, maximum: int = 100) -> int:
+    """Parse a non-negative integer query param, clamped to ``maximum``."""
+    raw = request.args.get(key)
+    if raw is not None and raw.isdigit():
+        return min(int(raw), maximum)
+    return default
+
+
+def _parse_rison_args(rison_args: dict[str, Any]) -> dict[str, Any]:
+    """Convert rison-encoded query params into ``get_contents()`` kwargs.
+
+    Expected rison format (matches ``useListViewResource``)::
+
+        {
+          "order_column": "changed_on",
+          "order_direction": "desc",
+          "page": 0,
+          "page_size": 25,
+          "filters": [
+            {"col": "name", "opr": "ct", "value": "sales"},
+            {"col": "type", "opr": "in", "value": ["chart", "dashboard"]},
+            {"col": "editors", "opr": "rel_m_m", "value": 1},
+            {"col": "changed_on", "opr": "gt", "value": "2025-01-01"},
+            {"col": "changed_on", "opr": "lt", "value": "2025-12-31"},
+            {"col": "viz_type", "opr": "in", "value": ["pie", "bar"]},
+            {"col": "dataset", "opr": "in", "value": [1, 2, 3]},
+          ]
+        }
+    """
+    result: dict[str, Any] = {
+        "search": None,
+        "types": None,
+        "viz_types": None,
+        "datasets": None,
+        "tags": None,
+        "editors": None,
+        "viewers": None,
+        "modified_start": None,
+        "modified_end": None,
+        "page": min(int(rison_args.get("page", 0)), 10000),
+        "page_size": min(int(rison_args.get("page_size", 25)), 100),
+        "sort_column": rison_args.get("order_column", "changed_on"),
+        "sort_order": rison_args.get("order_direction", "desc"),
+    }
+    for f in rison_args.get("filters", []):
+        col = f.get("col")
+        opr = f.get("opr")
+        val = f.get("value")
+        if val is None or val == "":
+            continue
+        if col == "name" and opr == "ct":
+            result["search"] = str(val)[:255]
+        elif col == "type" and opr == "in":
+            result["types"] = val if isinstance(val, list) else [val]
+        elif col == "editors" and opr == "rel_m_m":
+            editors = result["editors"] or []
+            editors.append(int(val))
+            result["editors"] = editors
+        elif col == "viewers" and opr == "rel_m_m":
+            viewers = result["viewers"] or []
+            viewers.append(int(val))
+            result["viewers"] = viewers
+        elif col == "changed_on":
+            try:
+                dt = datetime.fromisoformat(str(val))
+            except ValueError:
+                continue
+            if opr == "gt":
+                result["modified_start"] = dt
+            elif opr == "lt":
+                result["modified_end"] = dt
+        elif col == "viz_type" and opr == "in":
+            result["viz_types"] = val if isinstance(val, list) else [val]
+        elif col == "dataset" and opr == "in":
+            result["datasets"] = [
+                int(v) for v in (val if isinstance(val, list) else [val])
+            ]
+        elif col == "tags" and opr == "rel_m_m":
+            result["tags"] = [int(v) for v in (val if isinstance(val, list) else [val])]
+    return result
+
+
+class FolderRestApi(BaseSupersetApi):
+    """CRUD and asset assignment for folders."""
+
+    resource_name = "folders"
+    class_permission_name = "Folder"
+    allow_browser_login = True
+    openapi_spec_tag = "Folders"
+    openapi_spec_component_schemas = (
+        FolderSchema,
+        FolderAssetSchema,
+        FolderPostSchema,
+        FolderPutSchema,
+        FolderAssetsPutSchema,
+        FolderListResponseSchema,
+        FolderResponseSchema,
+        FolderContentItemSchema,
+        FolderContentsResponseSchema,
+        FolderRootResponseSchema,
+    )
+
+    add_model_schema = FolderPostSchema()
+    edit_model_schema = FolderPutSchema()
+    assets_model_schema = FolderAssetsPutSchema()
+
+    @staticmethod
+    def _raise_for_folder_access(folder: Folder) -> None:
+        """Raise FolderForbiddenError if user cannot view the folder.
+
+        Allows explicit members (editors/viewers) and implicit access
+        (users who own at least one asset inside the folder).
+        """
+        if security_manager.is_admin():
+            return
+        user_id = get_user_id()
+        if not user_id:
+            raise FolderForbiddenError()
+        if FolderPermissionDAO.user_has_folder_access(user_id, folder.id):
+            return
+        implicit_ids = FolderDAO.folders_with_accessible_assets(
+            user_id, folder.folder_type
+        )
+        if folder.id not in implicit_ids:
+            raise FolderForbiddenError()
+
+    @staticmethod
+    def _raise_for_folder_edit(folder: Folder) -> None:
+        """Raise FolderForbiddenError if user cannot edit the folder."""
+
+        if security_manager.is_admin():
+            return
+        user_id = get_user_id()
+        if not user_id or not FolderPermissionDAO.user_is_folder_editor(
+            user_id, folder.id
+        ):
+            raise FolderForbiddenError()
+
+    @expose("/", methods=("GET",))
+    @protect()
+    @safe
+    @permission_name("read")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_list",
+        log_to_statsd=False,
+    )
+    def get_list(self) -> Response:
+        """List folders, optionally filtered by folder type.
+        ---
+        get:
+          summary: List folders
+          parameters:
+          - in: query
+            name: folder_type
+            schema:
+              type: string
+            description: Only return folders of this type.
+          responses:
+            200:
+              description: A list of folders
+              content:
+                application/json:
+                  schema:
+                    $ref: '#/components/schemas/FolderListResponseSchema'
+            401:
+              $ref: '#/components/responses/401'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        folder_type = request.args.get("folder_type")
+        folders = FolderDAO.get_folders(folder_type=folder_type)
+
+        # Non-admins see explicit member folders + implicit access folders
+        # Private folders are only visible to their own members
+        implicit_folder_ids: set[int] = set()
+        user_id = get_user_id()
+        if not security_manager.is_admin():
+            if user_id:
+                # Batch query instead of N+1 per-folder checks
+                all_folder_ids = [f.id for f in folders]
+                editor_ids = (
+                    {
+                        r[0]
+                        for r in db.session.query(folder_editors.c.folder_id)
+                        .filter(
+                            folder_editors.c.user_id == user_id,
+                            folder_editors.c.folder_id.in_(all_folder_ids),
+                        )
+                        .all()
+                    }
+                    if all_folder_ids
+                    else set()
+                )
+                viewer_ids = (
+                    {
+                        r[0]
+                        for r in db.session.query(folder_viewers.c.folder_id)
+                        .filter(
+                            folder_viewers.c.user_id == user_id,
+                            folder_viewers.c.folder_id.in_(all_folder_ids),
+                        )
+                        .all()
+                    }
+                    if all_folder_ids
+                    else set()
+                )
+                member_ids = editor_ids | viewer_ids
+                implicit_folder_ids = (
+                    FolderDAO.folders_with_accessible_assets(
+                        user_id, folder_type or "analytics"
+                    )
+                    - member_ids
+                )
+                visible_ids = member_ids | implicit_folder_ids
+                folders = [f for f in folders if f.id in visible_ids]
+            else:
+                folders = []
+        # Hide other users' private folders (even for admins)
+        if user_id:
+            own_editor_ids = {
+                r[0]
+                for r in db.session.query(folder_editors.c.folder_id)
+                .filter(folder_editors.c.user_id == user_id)
+                .all()
+            }
+            folders = [
+                f for f in folders
+                if not f.is_private or f.id in own_editor_ids
+            ]
+
+        # Precompute counts to avoid N+1 queries
+        folder_ids = [f.id for f in folders]
+        children_counts: dict[int, int] = {}
+        asset_counts: dict[int, int] = {}
+        if folder_ids:
+            from sqlalchemy import func
+
+            for fid, cnt in (
+                db.session.query(Folder.parent_id, func.count())
+                .filter(Folder.parent_id.in_(folder_ids))
+                .group_by(Folder.parent_id)
+                .all()
+            ):
+                children_counts[fid] = cnt
+            from superset.folders.models import FolderObject
+
+            for fid, cnt in (
+                db.session.query(FolderObject.folder_id, func.count())
+                .filter(FolderObject.folder_id.in_(folder_ids))
+                .group_by(FolderObject.folder_id)
+                .all()
+            ):
+                asset_counts[fid] = cnt
+
+        result = [
+            serialize_folder(
+                folder,
+                children_count=children_counts.get(folder.id, 0),
+                asset_count=asset_counts.get(folder.id, 0),
+                implicit_folder_ids=implicit_folder_ids,
+            )
+            for folder in folders
+        ]
+        return self.response(200, count=len(result), result=result)
+
+    @expose("/assets", methods=("GET",))
+    @protect()
+    @safe
+    @permission_name("read")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.root_assets",
+        log_to_statsd=False,
+    )
+    @parse_rison()
+    def root_assets(self, **kwargs: Any) -> Response:
+        """Root view: top-level folders plus unfoldered assets.
+        ---
+        get:
+          summary: Root folder view
+          parameters:
+          - in: query
+            name: folder_type
+            schema:
+              type: string
+              default: analytics
+            description: Folder type namespace to scope the view to.
+          - in: query
+            name: q
+            schema:
+              type: string
+            description: >
+              Rison-encoded query parameters (order_column, order_direction,
+              page, page_size, filters).
+          responses:
+            200:
+              description: Top-level folders and assets not in any folder
+              content:
+                application/json:
+                  schema:
+                    $ref: '#/components/schemas/FolderRootResponseSchema'
+            401:
+              $ref: '#/components/responses/401'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        folder_type = request.args.get("folder_type", DEFAULT_FOLDER_TYPE)
+
+        # Lazy-create the "Only Me" folder for eligible users
+        user_id = get_user_id()
+        if user_id and can_manage_folders(g.user):
+            FolderDAO.get_or_create_only_me_folder(user_id)
+
+        parsed = _parse_rison_args(kwargs.get("rison", {}))
+        rows, count = FolderDAO.get_contents(None, folder_type, **parsed)
+        # Compute implicit folder IDs for user_permission serialization
+        implicit_ids: set[int] = set()
+        if not security_manager.is_admin():
+            user_id = get_user_id()
+            if user_id:
+                implicit_ids = FolderDAO.folders_with_accessible_assets(
+                    user_id, folder_type
+                )
+        result = (
+            serialize_rows_with_paths(rows, None)
+            if parsed.get("search")
+            else [
+                serialize_row(kind, obj, implicit_folder_ids=implicit_ids)
+                for kind, obj in rows
+            ]
+        )
+        return self.response(
+            200,
+            result=result,
+            count=count,
+            page=parsed["page"],
+            page_size=parsed["page_size"],
+        )
+
+    @expose("/<string:folder_uuid>", methods=("GET",))
+    @protect()
+    @safe
+    @permission_name("read")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get",
+        log_to_statsd=False,
+    )
+    def get(self, folder_uuid: str) -> Response:
+        """Get a folder's details.
+        ---
+        get:
+          summary: Get a folder
+          parameters:
+          - in: path
+            name: folder_uuid
+            required: true
+            schema:
+              type: string
+          responses:
+            200:
+              description: Folder details
+              content:
+                application/json:
+                  schema:
+                    $ref: '#/components/schemas/FolderResponseSchema'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        folder = FolderDAO.get_by_uuid(folder_uuid)
+        if not folder:
+            return self.response_404()
+        try:
+            self._raise_for_folder_access(folder)
+        except FolderForbiddenError:
+            return self.response_403()
+        return self.response(200, result=serialize_folder(folder))
+
+    @expose("/<string:folder_uuid>/assets", methods=("GET",))
+    @protect()
+    @safe
+    @permission_name("read")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_assets",
+        log_to_statsd=False,
+    )
+    @parse_rison()
+    def get_assets(self, folder_uuid: str, **kwargs: Any) -> Response:
+        """List the subfolders and assets inside a folder.
+        ---
+        get:
+          summary: Get folder contents
+          parameters:
+          - in: path
+            name: folder_uuid
+            required: true
+            schema:
+              type: string
+          - in: query
+            name: q
+            schema:
+              type: string
+            description: >
+              Rison-encoded query parameters (order_column, order_direction,
+              page, page_size, filters).
+          responses:
+            200:
+              description: Subfolders and assets inside the folder
+              content:
+                application/json:
+                  schema:
+                    $ref: '#/components/schemas/FolderContentsResponseSchema'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        folder = FolderDAO.get_by_uuid(folder_uuid)
+        if not folder:
+            return self.response_404()
+        try:
+            self._raise_for_folder_access(folder)
+        except FolderForbiddenError:
+            return self.response_403()
+        implicit_ids: set[int] = set()
+        user_id = get_user_id()
+        if user_id and not security_manager.is_admin():
+            implicit_ids = FolderDAO.folders_with_accessible_assets(
+                user_id, folder.folder_type
+            )
+        parsed = _parse_rison_args(kwargs.get("rison", {}))
+        rows, count = FolderDAO.get_contents(folder, folder.folder_type, **parsed)
+        result = (
+            serialize_rows_with_paths(rows, folder.id)
+            if parsed.get("search")
+            else [
+                serialize_row(kind, obj, implicit_folder_ids=implicit_ids)
+                for kind, obj in rows
+            ]
+        )
+        folder_implicit = set()
+        if (
+            user_id
+            and not security_manager.is_admin()
+            and not FolderPermissionDAO.user_has_folder_access(user_id, folder.id)
+        ):
+            folder_implicit = {folder.id}
+        return self.response(
+            200,
+            folder=serialize_folder(folder, implicit_folder_ids=folder_implicit),
+            result=result,
+            count=count,
+            page=parsed["page"],
+            page_size=parsed["page_size"],
+        )
+
+    @expose("/", methods=("POST",))
+    @protect()
+    @safe
+    @permission_name("write")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
+        log_to_statsd=False,
+    )
+    @requires_json
+    def post(self) -> Response:
+        """Create a folder.
+        ---
+        post:
+          summary: Create a folder
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/FolderPostSchema'
+          responses:
+            201:
+              description: The folder was created
+              content:
+                application/json:
+                  schema:
+                    $ref: '#/components/schemas/FolderResponseSchema'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not can_manage_folders(g.user):
+            return self.response_403()
+        try:
+            data = self.add_model_schema.load(request.json)
+        except ValidationError as ex:
+            return self.response(400, message=ex.messages)
+        try:
+            folder = CreateFolderCommand(data).run()
+            return self.response(
+                201,
+                id=folder.id,
+                uuid=str(folder.uuid),
+                result=serialize_folder(folder),
+            )
+        except FolderInvalidError as ex:
+            return self.response(422, message=ex.normalized_messages())
+        except FolderCreateFailedError as ex:
+            return self.response_422(message=str(ex))
+
+    @expose("/<string:folder_uuid>", methods=("PUT",))
+    @protect()
+    @safe
+    @permission_name("write")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
+        log_to_statsd=False,
+    )
+    @requires_json
+    def put(self, folder_uuid: str) -> Response:
+        """Update a folder (rename, describe, or move to a new parent).
+        ---
+        put:
+          summary: Update a folder
+          parameters:
+          - in: path
+            name: folder_uuid
+            required: true
+            schema:
+              type: string
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/FolderPutSchema'
+          responses:
+            200:
+              description: The folder was updated
+              content:
+                application/json:
+                  schema:
+                    $ref: '#/components/schemas/FolderResponseSchema'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not can_manage_folders(g.user):
+            return self.response_403()
+        try:
+            data = self.edit_model_schema.load(request.json)
+        except ValidationError as ex:
+            return self.response(400, message=ex.messages)
+        try:
+            folder = UpdateFolderCommand(folder_uuid, data).run()
+            return self.response(200, result=serialize_folder(folder))
+        except FolderNotFoundError:
+            return self.response_404()
+        except FolderForbiddenError:
+            return self.response_403()
+        except FolderInvalidError as ex:
+            return self.response(422, message=ex.normalized_messages())
+        except FolderUpdateFailedError as ex:
+            return self.response_422(message=str(ex))
+
+    @expose("/<string:folder_uuid>", methods=("DELETE",))
+    @protect()
+    @safe
+    @permission_name("write")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
+        log_to_statsd=False,
+    )
+    def delete(self, folder_uuid: str) -> Response:
+        """Delete a folder.
+
+        Children are re-parented to the deleted folder's parent and the assets it
+        contained become unfoldered.
+        ---
+        delete:
+          summary: Delete a folder
+          parameters:
+          - in: path
+            name: folder_uuid
+            required: true
+            schema:
+              type: string
+          responses:
+            200:
+              description: The folder was deleted
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not can_manage_folders(g.user):
+            return self.response_403()
+        archive_items = request.args.get("archive_items", "").lower() == "true"
+        try:
+            DeleteFolderCommand(folder_uuid, archive_items).run()
+            return self.response(200, message="OK")
+        except FolderNotFoundError:
+            return self.response_404()
+        except FolderForbiddenError:
+            return self.response_403()
+        except FolderDeleteFailedError as ex:
+            return self.response_422(message=str(ex))
+
+    @expose("/<string:folder_uuid>/assets", methods=("PUT",))
+    @protect()
+    @safe
+    @permission_name("write")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put_assets",
+        log_to_statsd=False,
+    )
+    @requires_json
+    def put_assets(self, folder_uuid: str) -> Response:
+        """Assign or move one or more assets into a folder.
+        ---
+        put:
+          summary: Assign assets to a folder
+          parameters:
+          - in: path
+            name: folder_uuid
+            required: true
+            schema:
+              type: string
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/FolderAssetsPutSchema'
+          responses:
+            200:
+              description: The folder contents after assignment
+              content:
+                application/json:
+                  schema:
+                    $ref: '#/components/schemas/FolderContentsResponseSchema'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not can_manage_folders(g.user):
+            return self.response_403()
+        try:
+            data = self.assets_model_schema.load(request.json)
+        except ValidationError as ex:
+            return self.response(400, message=ex.messages)
+        try:
+            folder = UpdateFolderAssetsCommand(folder_uuid, data["assets"]).run()
+            rows, count = FolderDAO.get_contents(folder, folder.folder_type)
+            return self.response(
+                200,
+                folder=serialize_folder(folder),
+                result=[serialize_row(kind, obj) for kind, obj in rows],
+                count=count,
+            )
+        except FolderNotFoundError:
+            return self.response_404()
+        except FolderForbiddenError:
+            return self.response_403()
+        except FolderInvalidError as ex:
+            return self.response(422, message=ex.normalized_messages())
+        except FolderUpdateFailedError as ex:
+            return self.response_422(message=str(ex))
+
+    @expose("/<string:folder_uuid>/assets", methods=("POST",))
+    @protect()
+    @safe
+    @permission_name("write")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post_assets",
+        log_to_statsd=False,
+    )
+    @requires_json
+    def post_assets(self, folder_uuid: str) -> Response:
+        """Add assets to a folder (upsert).
+
+        Assets already in another folder are moved. Existing assets in this
+        folder that are not listed are left untouched (unlike PUT which
+        replaces the full membership).
+        ---
+        post:
+          summary: Add assets to a folder
+          parameters:
+          - in: path
+            name: folder_uuid
+            required: true
+            schema:
+              type: string
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/FolderAssetsPutSchema'
+          responses:
+            200:
+              description: Assets added
+            400:
+              $ref: '#/components/responses/400'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
... 2716 lines suppressed ...