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/08/06 10:41:34 UTC

(superset) 01/01: poc for ideas

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

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

commit 3fe721e2864295e72d4d5ee9a388b668c89a6e0c
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Thu Aug 6 13:41:14 2026 +0300

    poc for ideas
---
 .../pages/AnalyticsList/FolderActivityModal.tsx    | 244 +++++++++++++++++++++
 .../src/pages/AnalyticsList/FolderInfoModal.tsx    | 227 +++++++++++++++++++
 .../src/pages/AnalyticsList/RecentBar.tsx          | 145 ++++++++++++
 .../src/pages/AnalyticsList/index.tsx              |  46 +++-
 superset/commands/folder/create.py                 |   7 +
 superset/commands/folder/delete.py                 |  11 +
 superset/commands/folder/update.py                 |  26 +++
 superset/daos/folder.py                            |  16 ++
 superset/folders/activity.py                       |  58 +++++
 superset/folders/api.py                            | 109 ++++++++-
 superset/folders/models.py                         |  26 +++
 ...12-00_a1b2c3d4e5f6_add_folder_activity_table.py |  63 ++++++
 12 files changed, 974 insertions(+), 4 deletions(-)

diff --git a/superset-frontend/src/pages/AnalyticsList/FolderActivityModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderActivityModal.tsx
new file mode 100644
index 00000000000..79918fb77d4
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/FolderActivityModal.tsx
@@ -0,0 +1,244 @@
+/**
+ * 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 { styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { StandardModal } from 'src/components/Modal';
+
+interface ActivityEvent {
+  id: number;
+  action: string;
+  target_type: string | null;
+  target_name: string | null;
+  details: Record<string, unknown> | null;
+  created_on: string | null;
+  user: { id: number; first_name: string; last_name: string } | null;
+}
+
+interface FolderActivityModalProps {
+  folderUuid: string;
+  folderName: string;
+  show: boolean;
+  onHide: () => void;
+}
+
+const Timeline = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit}px;
+    max-height: 400px;
+    overflow-y: auto;
+  `}
+`;
+
+const EventRow = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    align-items: flex-start;
+    gap: ${theme.sizeUnit * 2}px;
+    padding: ${theme.sizeUnit * 2}px;
+    border-bottom: 1px solid ${theme.colorBorderSecondary};
+
+    &:last-child {
+      border-bottom: none;
+    }
+  `}
+`;
+
+const EventDot = styled.div`
+  ${({ theme }) => `
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: ${theme.colorPrimary};
+    margin-top: 6px;
+    flex-shrink: 0;
+  `}
+`;
+
+const EventContent = styled.div`
+  flex: 1;
+  min-width: 0;
+`;
+
+const EventDescription = styled.div`
+  ${({ theme }) => `
+    font-size: ${theme.fontSize}px;
+    color: ${theme.colorText};
+  `}
+`;
+
+const EventMeta = styled.div`
+  ${({ theme }) => `
+    font-size: ${theme.fontSizeXS}px;
+    color: ${theme.colorTextSecondary};
+    margin-top: 2px;
+  `}
+`;
+
+const EmptyState = styled.div`
+  ${({ theme }) => `
+    text-align: center;
+    padding: ${theme.sizeUnit * 6}px;
+    color: ${theme.colorTextSecondary};
+  `}
+`;
+
+const ACTION_LABELS: Record<string, string> = {
+  created: 'created this folder',
+  renamed: 'renamed this folder',
+  moved: 'moved this folder',
+  deleted: 'deleted a subfolder',
+  permission_changed: 'changed permissions',
+};
+
+function describeEvent(event: ActivityEvent): string {
+  const userName = event.user
+    ? `${event.user.first_name} ${event.user.last_name}`
+    : t('Unknown user');
+  const actionLabel = ACTION_LABELS[event.action] ?? event.action;
+
+  let suffix = '';
+  if (event.target_name && event.action === 'renamed') {
+    const oldName = event.details?.old_name;
+    suffix = oldName ? ` from "${oldName}" to "${event.target_name}"` : '';
+  } else if (event.target_name && event.action === 'deleted') {
+    suffix = ` "${event.target_name}"`;
+  } else if (event.action === 'permission_changed' && event.details) {
+    const detail = event.details;
+    if (detail.action === 'added') {
+      suffix = ` (added user as ${detail.permission})`;
+    } else if (detail.action === 'updated') {
+      suffix = ` (changed to ${detail.permission})`;
+    } else if (detail.action === 'removed') {
+      suffix = ' (removed user)';
+    } else if (detail.sync_permissions) {
+      suffix = ' (synced with parent)';
+    }
+  }
+
+  return `${userName} ${actionLabel}${suffix}`;
+}
+
+function formatTime(dateStr: string | null): string {
+  if (!dateStr) return '';
+  const date = new Date(dateStr);
+  const now = new Date();
+  const diffMs = now.getTime() - date.getTime();
+  const diffMins = Math.floor(diffMs / 60000);
+  if (diffMins < 1) return t('just now');
+  if (diffMins < 60) return t('%s min ago', diffMins);
+  const diffHours = Math.floor(diffMins / 60);
+  if (diffHours < 24) return t('%s hours ago', diffHours);
+  const diffDays = Math.floor(diffHours / 24);
+  if (diffDays < 30) return t('%s days ago', diffDays);
+  return date.toLocaleDateString();
+}
+
+export default function FolderActivityModal({
+  folderUuid,
+  folderName,
+  show,
+  onHide,
+}: FolderActivityModalProps) {
+  const [events, setEvents] = useState<ActivityEvent[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [total, setTotal] = useState(0);
+  const [page, setPage] = useState(0);
+
+  const fetchActivity = useCallback(
+    (pageNum: number) => {
+      setLoading(true);
+      SupersetClient.get({
+        endpoint: `/api/v1/folders/${folderUuid}/activity?page=${pageNum}&page_size=25`,
+      }).then(
+        ({ json }) => {
+          const newEvents = (json?.result ?? []) as ActivityEvent[];
+          setEvents(prev =>
+            pageNum === 0 ? newEvents : [...prev, ...newEvents],
+          );
+          setTotal(json?.count ?? 0);
+          setLoading(false);
+        },
+        () => {
+          setLoading(false);
+        },
+      );
+    },
+    [folderUuid],
+  );
+
+  useEffect(() => {
+    if (show && folderUuid) {
+      setPage(0);
+      setEvents([]);
+      fetchActivity(0);
+    }
+  }, [show, folderUuid, fetchActivity]);
+
+  const handleLoadMore = () => {
+    const nextPage = page + 1;
+    setPage(nextPage);
+    fetchActivity(nextPage);
+  };
+
+  const hasMore = events.length < total;
+
+  return (
+    <StandardModal
+      title={`${folderName} — ${t('Activity')}`}
+      icon={<Icons.HistoryOutlined />}
+      show={show}
+      onHide={onHide}
+      onSave={onHide}
+      saveText={t('Close')}
+      contentLoading={loading && events.length === 0}
+      width={600}
+    >
+      {events.length === 0 && !loading ? (
+        <EmptyState>{t('No activity recorded for this folder')}</EmptyState>
+      ) : (
+        <Timeline>
+          {events.map(event => (
+            <EventRow key={event.id}>
+              <EventDot />
+              <EventContent>
+                <EventDescription>{describeEvent(event)}</EventDescription>
+                <EventMeta>{formatTime(event.created_on)}</EventMeta>
+              </EventContent>
+            </EventRow>
+          ))}
+          {hasMore && (
+            <EventRow
+              style={{ cursor: 'pointer', justifyContent: 'center' }}
+              onClick={handleLoadMore}
+            >
+              <EventDescription>
+                {loading ? t('Loading...') : t('Load more')}
+              </EventDescription>
+            </EventRow>
+          )}
+        </Timeline>
+      )}
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderInfoModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderInfoModal.tsx
new file mode 100644
index 00000000000..e172a496d38
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/FolderInfoModal.tsx
@@ -0,0 +1,227 @@
+/**
+ * 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, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { StandardModal } from 'src/components/Modal';
+
+interface FolderDetails {
+  name: string;
+  description: string | null;
+  created_on: string;
+  changed_on: string;
+  created_by: { first_name: string; last_name: string } | null;
+  changed_by: { first_name: string; last_name: string } | null;
+  children_count: number;
+  asset_count: number;
+  asset_breakdown: {
+    dashboards: number;
+    charts: number;
+  };
+}
+
+interface FolderInfoModalProps {
+  folderUuid: string;
+  show: boolean;
+  onHide: () => void;
+}
+
+const InfoSection = styled.div`
+  ${({ theme }) => `
+    padding: ${theme.sizeUnit * 2}px 0;
+    border-bottom: 1px solid ${theme.colorBorderSecondary};
+
+    &:last-child {
+      border-bottom: none;
+    }
+  `}
+`;
+
+const InfoLabel = styled.div`
+  ${({ theme }) => `
+    font-weight: ${theme.fontWeightStrong};
+    font-size: ${theme.fontSizeSM}px;
+    color: ${theme.colorTextSecondary};
+    margin-bottom: ${theme.sizeUnit / 2}px;
+  `}
+`;
+
+const InfoValue = styled.div`
+  ${({ theme }) => `
+    font-size: ${theme.fontSize}px;
+    color: ${theme.colorText};
+  `}
+`;
+
+const SizeGrid = styled.div`
+  ${({ theme }) => `
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: ${theme.sizeUnit * 2}px;
+  `}
+`;
+
+const SizeCard = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit}px;
+    padding: ${theme.sizeUnit * 2}px;
+    background: ${theme.colorBgLayout};
+    border-radius: ${theme.borderRadius}px;
+  `}
+`;
+
+const SizeNumber = styled.span`
+  ${({ theme }) => `
+    font-size: ${theme.fontSizeXL}px;
+    font-weight: ${theme.fontWeightStrong};
+    color: ${theme.colorText};
+  `}
+`;
+
+const SizeLabel = styled.span`
+  ${({ theme }) => `
+    font-size: ${theme.fontSizeSM}px;
+    color: ${theme.colorTextSecondary};
+  `}
+`;
+
+const TotalRow = styled.div`
+  ${({ theme }) => `
+    margin-top: ${theme.sizeUnit * 2}px;
+    padding-top: ${theme.sizeUnit}px;
+    border-top: 1px solid ${theme.colorBorderSecondary};
+    font-weight: ${theme.fontWeightStrong};
+    color: ${theme.colorText};
+  `}
+`;
+
+function formatUser(
+  user: { first_name: string; last_name: string } | null,
+): string {
+  if (!user) return t('Unknown');
+  return `${user.first_name} ${user.last_name}`;
+}
+
+function formatDate(dateStr: string | null): string {
+  if (!dateStr) return '-';
+  return new Date(dateStr).toLocaleString();
+}
+
+export default function FolderInfoModal({
+  folderUuid,
+  show,
+  onHide,
+}: FolderInfoModalProps) {
+  const [details, setDetails] = useState<FolderDetails | null>(null);
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    if (!show || !folderUuid) return;
+    setLoading(true);
+    SupersetClient.get({
+      endpoint: `/api/v1/folders/${folderUuid}`,
+    }).then(
+      ({ json }) => {
+        setDetails(json?.result ?? null);
+        setLoading(false);
+      },
+      () => {
+        setLoading(false);
+      },
+    );
+  }, [show, folderUuid]);
+
+  const breakdown = details?.asset_breakdown;
+  const totalAssets =
+    (details?.asset_count ?? 0) + (details?.children_count ?? 0);
+
+  return (
+    <StandardModal
+      title={details?.name ?? t('Folder Information')}
+      icon={<Icons.FolderOutlined />}
+      show={show}
+      onHide={onHide}
+      onSave={onHide}
+      saveText={t('Close')}
+      contentLoading={loading}
+      width={500}
+    >
+      {details && (
+        <>
+          {details.description && (
+            <InfoSection>
+              <InfoLabel>{t('Description')}</InfoLabel>
+              <InfoValue>{details.description}</InfoValue>
+            </InfoSection>
+          )}
+
+          <InfoSection>
+            <InfoLabel>{t('Created by')}</InfoLabel>
+            <InfoValue>
+              {formatUser(details.created_by)} —{' '}
+              {formatDate(details.created_on)}
+            </InfoValue>
+          </InfoSection>
+
+          <InfoSection>
+            <InfoLabel>{t('Last modified by')}</InfoLabel>
+            <InfoValue>
+              {formatUser(details.changed_by)} —{' '}
+              {formatDate(details.changed_on)}
+            </InfoValue>
+          </InfoSection>
+
+          <InfoSection>
+            <InfoLabel>{t('Folder size')}</InfoLabel>
+            <SizeGrid>
+              <SizeCard>
+                <Icons.LayoutOutlined iconSize="l" />
+                <div>
+                  <SizeNumber>{breakdown?.dashboards ?? 0}</SizeNumber>{' '}
+                  <SizeLabel>{t('Dashboards')}</SizeLabel>
+                </div>
+              </SizeCard>
+              <SizeCard>
+                <Icons.LineChartOutlined iconSize="l" />
+                <div>
+                  <SizeNumber>{breakdown?.charts ?? 0}</SizeNumber>{' '}
+                  <SizeLabel>{t('Charts')}</SizeLabel>
+                </div>
+              </SizeCard>
+              <SizeCard>
+                <Icons.FolderOutlined iconSize="l" />
+                <div>
+                  <SizeNumber>{details.children_count}</SizeNumber>{' '}
+                  <SizeLabel>{t('Subfolders')}</SizeLabel>
+                </div>
+              </SizeCard>
+            </SizeGrid>
+            <TotalRow>
+              {t('Total')}: {totalAssets} {t('assets')}
+            </TotalRow>
+          </InfoSection>
+        </>
+      )}
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/RecentBar.tsx b/superset-frontend/src/pages/AnalyticsList/RecentBar.tsx
new file mode 100644
index 00000000000..c257273ef58
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/RecentBar.tsx
@@ -0,0 +1,145 @@
+/**
+ * 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, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { Tooltip } from '@superset-ui/core/components';
+import rison from 'rison';
+
+interface RecentItem {
+  action: string;
+  item_type: 'dashboard' | 'slice' | null;
+  item_url: string | null;
+  item_title: string | null;
+  time_delta_humanized: string;
+}
+
+const RecentBarWrapper = styled.div`
+  ${({ theme }) => `
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px;
+    overflow-x: auto;
+    white-space: nowrap;
+
+    &::-webkit-scrollbar {
+      height: 4px;
+    }
+    &::-webkit-scrollbar-thumb {
+      background: ${theme.colorBorderSecondary};
+      border-radius: 2px;
+    }
+  `}
+`;
+
+const SectionLabel = styled.span`
+  ${({ theme }) => `
+    font-weight: ${theme.fontWeightStrong};
+    font-size: ${theme.fontSizeSM}px;
+    color: ${theme.colorTextSecondary};
+    flex-shrink: 0;
+  `}
+`;
+
+const RecentCard = styled.a`
+  ${({ theme }) => `
+    display: inline-flex;
+    align-items: center;
+    gap: ${theme.sizeUnit}px;
+    padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
+    border: 1px solid ${theme.colorBorderSecondary};
+    border-radius: ${theme.borderRadius}px;
+    background: ${theme.colorBgContainer};
+    color: ${theme.colorText};
+    text-decoration: none;
+    flex-shrink: 0;
+    max-width: 200px;
+    cursor: pointer;
+    transition: border-color 0.2s;
+
+    &:hover {
+      border-color: ${theme.colorPrimary};
+      color: ${theme.colorPrimary};
+    }
+  `}
+`;
+
+const CardTitle = styled.span`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`;
+
+const TimeAgo = styled.span`
+  ${({ theme }) => `
+    font-size: ${theme.fontSizeXS}px;
+    color: ${theme.colorTextSecondary};
+    flex-shrink: 0;
+  `}
+`;
+
+export default function RecentBar() {
+  const [items, setItems] = useState<RecentItem[]>([]);
+
+  useEffect(() => {
+    const q = rison.encode({
+      page: 0,
+      page_size: 10,
+      distinct: true,
+    });
+    SupersetClient.get({
+      endpoint: `/api/v1/log/recent_activity/?q=${q}`,
+    }).then(
+      ({ json }) => {
+        const results = (json?.result ?? []) as RecentItem[];
+        setItems(results.filter(r => r.item_url && r.item_title).slice(0, 8));
+      },
+      () => {},
+    );
+  }, []);
+
+  if (items.length === 0) {
+    return null;
+  }
+
+  return (
+    <RecentBarWrapper>
+      <SectionLabel>{t('Recent')}</SectionLabel>
+      {items.map((item, idx) => (
+        <Tooltip
+          key={`${item.item_url}-${idx}`}
+          title={`${item.item_title} — ${item.time_delta_humanized}`}
+        >
+          <RecentCard href={item.item_url ?? '#'}>
+            {item.item_type === 'dashboard' ? (
+              <Icons.LayoutOutlined iconSize="s" />
+            ) : (
+              <Icons.LineChartOutlined iconSize="s" />
+            )}
+            <CardTitle>{item.item_title}</CardTitle>
+            <TimeAgo>{item.time_delta_humanized}</TimeAgo>
+          </RecentCard>
+        </Tooltip>
+      ))}
+    </RecentBarWrapper>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/index.tsx b/superset-frontend/src/pages/AnalyticsList/index.tsx
index 155b50001a3..1c28127ebe9 100644
--- a/superset-frontend/src/pages/AnalyticsList/index.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/index.tsx
@@ -77,6 +77,9 @@ import FolderPermissionsModal from './FolderPermissionsModal';
 import RenameFolderModal from './RenameFolderModal';
 import DeleteFolderModal from './DeleteFolderModal';
 import DashboardCharts from './DashboardCharts';
+import RecentBar from './RecentBar';
+import FolderInfoModal from './FolderInfoModal';
+import FolderActivityModal from './FolderActivityModal';
 import type { ContentItem } from './types';
 
 /** A location in the drill path; the root has a null uuid. */
@@ -213,6 +216,10 @@ function AnalyticsList({
     Record<number, { loading: boolean; charts: ContentItem[] }>
   >({});
   const [showCreateFolder, setShowCreateFolder] = useState(false);
+  const [folderToShowInfo, setFolderToShowInfo] =
+    useState<ContentItem | null>(null);
+  const [folderToShowActivity, setFolderToShowActivity] =
+    useState<ContentItem | null>(null);
   const [folderToManagePerms, setFolderToManagePerms] =
     useState<ContentItem | null>(null);
   const [folderToRename, setFolderToRename] = useState<ContentItem | null>(
@@ -936,6 +943,26 @@ function AnalyticsList({
             const canEdit = original.user_permission === 'editor';
             return (
               <Actions className="actions">
+                <Tooltip title={t('Folder info')} placement="bottom">
+                  <span
+                    role="button"
+                    tabIndex={0}
+                    className="action-button"
+                    onClick={() => setFolderToShowInfo(original)}
+                  >
+                    <Icons.InfoCircleOutlined iconSize="l" />
+                  </span>
+                </Tooltip>
+                <Tooltip title={t('Activity')} placement="bottom">
+                  <span
+                    role="button"
+                    tabIndex={0}
+                    className="action-button"
+                    onClick={() => setFolderToShowActivity(original)}
+                  >
+                    <Icons.HistoryOutlined iconSize="l" />
+                  </span>
+                </Tooltip>
                 {canEdit && (
                   <Tooltip title={t('Manage permissions')} placement="bottom">
                     <span
@@ -1286,6 +1313,21 @@ function AnalyticsList({
   return (
     <>
       {expandedRowStyle}
+      {folderToShowInfo && (
+        <FolderInfoModal
+          folderUuid={folderToShowInfo.uuid ?? ''}
+          show
+          onHide={() => setFolderToShowInfo(null)}
+        />
+      )}
+      {folderToShowActivity && (
+        <FolderActivityModal
+          folderUuid={folderToShowActivity.uuid ?? ''}
+          folderName={folderToShowActivity.name}
+          show
+          onHide={() => setFolderToShowActivity(null)}
+        />
+      )}
       {showCreateFolder && (
         <CreateFolderModal
           show
@@ -1406,7 +1448,9 @@ function AnalyticsList({
               bulkSelectEnabled={bulkSelectEnabled}
               disableBulkSelect={() => setBulkSelectEnabled(false)}
               headerContent={
-                isAtRoot ? null : (
+                isAtRoot ? (
+                  <RecentBar />
+                ) : (
                   <BreadcrumbWrap>
                     <FolderBreadcrumb items={breadcrumbItems} />
                   </BreadcrumbWrap>
diff --git a/superset/commands/folder/create.py b/superset/commands/folder/create.py
index d54661dd436..fd92736054b 100644
--- a/superset/commands/folder/create.py
+++ b/superset/commands/folder/create.py
@@ -39,6 +39,7 @@ 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
+from superset.folders.activity import log_folder_activity
 
 
 class CreateFolderCommand(BaseCommand):
@@ -69,6 +70,12 @@ class CreateFolderCommand(BaseCommand):
             FolderPermissionDAO.copy_permissions_to_subfolder(
                 self._parent.id, folder.id
             )
+        log_folder_activity(
+            folder.id,
+            "created",
+            target_type="folder",
+            target_name=folder.name,
+        )
         return folder
 
     def validate(self) -> None:
diff --git a/superset/commands/folder/delete.py b/superset/commands/folder/delete.py
index 014da68235e..132751e73c8 100644
--- a/superset/commands/folder/delete.py
+++ b/superset/commands/folder/delete.py
@@ -31,6 +31,7 @@ from superset.commands.folder.exceptions import (
 from superset.daos.folder import FolderDAO
 from superset.folders.models import Folder
 from superset.daos.folder_permissions import FolderPermissionDAO
+from superset.folders.activity import log_folder_activity
 
 
 class DeleteFolderCommand(BaseCommand):
@@ -43,6 +44,16 @@ class DeleteFolderCommand(BaseCommand):
     def run(self) -> None:
         self.validate()
         assert self._model
+        # Log before delete since CASCADE will remove the activity record's
+        # parent folder. We log to the parent folder if one exists so the
+        # activity is preserved; otherwise we accept it will be lost.
+        if self._model.parent_id:
+            log_folder_activity(
+                self._model.parent_id,
+                "deleted",
+                target_type="folder",
+                target_name=self._model.name,
+            )
         FolderDAO.delete_folder(self._model, self._archive_items)
 
     def validate(self) -> None:
diff --git a/superset/commands/folder/update.py b/superset/commands/folder/update.py
index 0c8951d7506..f89f6302bfa 100644
--- a/superset/commands/folder/update.py
+++ b/superset/commands/folder/update.py
@@ -38,6 +38,7 @@ from superset.commands.folder.exceptions import (
 from superset.daos.folder import FolderDAO
 from superset.folders.models import Folder
 from superset.daos.folder_permissions import FolderPermissionDAO
+from superset.folders.activity import log_folder_activity
 
 
 class UpdateFolderCommand(BaseCommand):
@@ -52,6 +53,7 @@ class UpdateFolderCommand(BaseCommand):
     def run(self) -> Folder:
         self.validate()
         assert self._model
+        old_name = self._model.name
 
         attributes: dict[str, Any] = {}
         if "name" in self._properties:
@@ -82,6 +84,30 @@ class UpdateFolderCommand(BaseCommand):
                 folder.parent_id, folder.id
             )
             FolderPermissionDAO.push_down_permissions(folder.id)
+            log_folder_activity(
+                folder.id,
+                "permission_changed",
+                target_type="folder",
+                target_name=folder.name,
+                details={"sync_permissions": True},
+            )
+
+        if "name" in self._properties and self._properties["name"] != old_name:
+            log_folder_activity(
+                folder.id,
+                "renamed",
+                target_type="folder",
+                target_name=folder.name,
+                details={"old_name": old_name},
+            )
+
+        if self._parent_changed:
+            log_folder_activity(
+                folder.id,
+                "moved",
+                target_type="folder",
+                target_name=folder.name,
+            )
 
         return folder
 
diff --git a/superset/daos/folder.py b/superset/daos/folder.py
index a1af64ddee3..35d6977cc85 100644
--- a/superset/daos/folder.py
+++ b/superset/daos/folder.py
@@ -98,6 +98,22 @@ class FolderDAO(BaseDAO[Folder]):
                     ids_by_type[name].append(asset_id)
         return cls._load_assets(ids_by_type)
 
+    @classmethod
+    def get_asset_breakdown(cls, folder_id: int) -> dict[str, int]:
+        """Count assets in a folder grouped by type (which FK column is set)."""
+        result = (
+            db.session.query(
+                func.count(FolderObject.dashboard_id).label("dashboards"),
+                func.count(FolderObject.chart_id).label("charts"),
+            )
+            .filter(FolderObject.folder_id == folder_id)
+            .one()
+        )
+        return {
+            "dashboards": result.dashboards,
+            "charts": result.charts,
+        }
+
     @classmethod
     def get_contents(  # noqa: C901
         cls,
diff --git a/superset/folders/activity.py b/superset/folders/activity.py
new file mode 100644
index 00000000000..d0b32e1b301
--- /dev/null
+++ b/superset/folders/activity.py
@@ -0,0 +1,58 @@
+# 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.
+"""Helper for logging folder activity events."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from superset.extensions import db
+from superset.folders.models import FolderActivity
+from superset.utils.core import get_user_id
+
+logger = logging.getLogger(__name__)
+
+
+def log_folder_activity(
+    folder_id: int,
+    action: str,
+    target_type: str | None = None,
+    target_name: str | None = None,
+    details: dict[str, Any] | None = None,
+) -> None:
+    """Record a folder activity event.
+
+    This should be called within an existing transaction (e.g. inside a
+    Command's ``run()`` method) so the activity record commits or rolls
+    back with the rest of the operation.
+    """
+    user_id = get_user_id()
+    if not user_id:
+        logger.warning("Cannot log folder activity: no authenticated user")
+        return
+
+    activity = FolderActivity(
+        folder_id=folder_id,
+        user_id=user_id,
+        action=action,
+        target_type=target_type,
+        target_name=target_name,
+        details=json.dumps(details) if details else None,
+    )
+    db.session.add(activity)
diff --git a/superset/folders/api.py b/superset/folders/api.py
index 1f0662e3874..327776294f6 100644
--- a/superset/folders/api.py
+++ b/superset/folders/api.py
@@ -18,6 +18,7 @@
 
 from __future__ import annotations
 
+import json
 import logging
 from datetime import datetime
 from typing import Any
@@ -50,10 +51,11 @@ from superset.commands.folder.exceptions import (
 )
 from superset.commands.folder.update import UpdateFolderCommand
 from superset.daos.folder import FolderDAO, ResolvedAsset
+from superset.folders.activity import log_folder_activity
 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
+from superset.folders.models import Folder, FolderActivity
 from superset.folders.schemas import (
     FolderAssetSchema,
     FolderAssetsPutSchema,
@@ -114,14 +116,18 @@ def serialize_folder(
     folder: Folder,
     children_count: int | None = None,
     asset_count: int | None = None,
+    include_breakdown: bool = False,
 ) -> 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).
+
+    When ``include_breakdown`` is True, an ``asset_breakdown`` dict with
+    per-type counts (dashboards, charts) is included in the response.
     """
-    return {
+    data: dict[str, Any] = {
         "type": "folder",
         "id": folder.id,
         "uuid": str(folder.uuid),
@@ -147,6 +153,9 @@ def serialize_folder(
             _serialize_user(u) for u in (folder.editors or [])
         ],
     }
+    if include_breakdown:
+        data["asset_breakdown"] = FolderDAO.get_asset_breakdown(folder.id)
+    return data
 
 
 def serialize_row(
@@ -556,7 +565,10 @@ class FolderRestApi(BaseSupersetApi):
             self._raise_for_folder_access(folder)
         except FolderForbiddenError:
             return self.response_403()
-        return self.response(200, result=serialize_folder(folder))
+        return self.response(
+            200,
+            result=serialize_folder(folder, include_breakdown=True),
+        )
 
     @expose("/<string:folder_uuid>/assets", methods=("GET",))
     @protect()
@@ -1211,6 +1223,13 @@ class FolderRestApi(BaseSupersetApi):
             return self.response(422, message=str(ex))
         FolderPermissionDAO.mark_permissions_explicit(folder.id)
         FolderPermissionDAO.push_down_permissions(folder.id)
+        log_folder_activity(
+            folder.id,
+            "permission_changed",
+            target_type="user",
+            target_name=str(data["user_id"]),
+            details={"action": "added", "permission": permission},
+        )
         return self.response(201, message="OK")
 
     @expose("/<folder_uuid>/subjects/<int:user_id>", methods=("PUT",))
@@ -1265,6 +1284,13 @@ class FolderRestApi(BaseSupersetApi):
         FolderDAO.update_subject(folder.id, user_id, permission)
         FolderPermissionDAO.mark_permissions_explicit(folder.id)
         FolderPermissionDAO.push_down_permissions(folder.id)
+        log_folder_activity(
+            folder.id,
+            "permission_changed",
+            target_type="user",
+            target_name=str(user_id),
+            details={"action": "updated", "permission": permission},
+        )
         return self.response(200, message="OK")
 
     @expose("/<folder_uuid>/subjects/<int:user_id>", methods=("DELETE",))
@@ -1305,6 +1331,13 @@ class FolderRestApi(BaseSupersetApi):
         FolderDAO.remove_subject(folder.id, user_id)
         FolderPermissionDAO.mark_permissions_explicit(folder.id)
         FolderPermissionDAO.push_down_permissions(folder.id)
+        log_folder_activity(
+            folder.id,
+            "permission_changed",
+            target_type="user",
+            target_name=str(user_id),
+            details={"action": "removed"},
+        )
         return self.response(200, message="OK")
 
     @expose("/<folder_uuid>/available-users", methods=("GET",))
@@ -1392,3 +1425,73 @@ class FolderRestApi(BaseSupersetApi):
             ],
             count=total,
         )
+
+    @expose("/<folder_uuid>/activity", methods=("GET",))
+    @protect()
+    @safe
+    @permission_name("read")
+    @statsd_metrics
+    def get_activity(self, folder_uuid: str) -> Response:
+        """Get activity log for a folder.
+        ---
+        get:
+          summary: Get folder activity log
+          parameters:
+          - in: path
+            name: folder_uuid
+            required: true
+            schema:
+              type: string
+          - in: query
+            name: page
+            schema:
+              type: integer
+              default: 0
+          - in: query
+            name: page_size
+            schema:
+              type: integer
+              default: 25
+          responses:
+            200:
+              description: Paginated activity log
+            404:
+              $ref: '#/components/responses/404'
+        """
+        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()
+
+        page = _positive_int("page", 0)
+        page_size = _positive_int("page_size", 25)
+
+        query = (
+            db.session.query(FolderActivity, User)
+            .outerjoin(User, User.id == FolderActivity.user_id)
+            .filter(FolderActivity.folder_id == folder.id)
+            .order_by(FolderActivity.created_on.desc())
+        )
+        total = query.count()
+        rows = query.offset(page * page_size).limit(page_size).all()
+
+        result = []
+        for activity, user in rows:
+            result.append({
+                "id": activity.id,
+                "action": activity.action,
+                "target_type": activity.target_type,
+                "target_name": activity.target_name,
+                "details": json.loads(activity.details) if activity.details else None,
+                "created_on": activity.created_on.isoformat() if activity.created_on else None,
+                "user": {
+                    "id": user.id,
+                    "first_name": user.first_name,
+                    "last_name": user.last_name,
+                } if user else None,
+            })
+
+        return self.response(200, result=result, count=total)
diff --git a/superset/folders/models.py b/superset/folders/models.py
index 4fa1d2c0d58..eae7436db4b 100644
--- a/superset/folders/models.py
+++ b/superset/folders/models.py
@@ -44,6 +44,7 @@ from sqlalchemy import (
     Table,
     Text,
     UniqueConstraint,
+    func,
 )
 from sqlalchemy.orm import relationship
 from sqlalchemy_utils import UUIDType
@@ -257,3 +258,28 @@ class FolderPin(Model):
         if self.dashboard_id is not None:
             return self.dashboard_id
         return self.chart_id
+
+
+class FolderActivity(Model):
+    """Audit log for folder-related events (create, rename, move, etc.)."""
+
+    __tablename__ = "folder_activity"
+    __table_args__ = (
+        Index("ix_folder_activity_folder_id", "folder_id"),
+        Index("ix_folder_activity_created_on", "created_on"),
+    )
+
+    id = Column(Integer, primary_key=True)
+    folder_id = Column(
+        Integer,
+        ForeignKey("folders.id", ondelete="CASCADE"),
+        nullable=False,
+    )
+    user_id = Column(Integer, nullable=False)
+    action = Column(String(50), nullable=False)
+    target_type = Column(String(50), nullable=True)
+    target_name = Column(String(250), nullable=True)
+    details = Column(Text, nullable=True)
+    created_on = Column(DateTime, nullable=False, server_default=func.now())
+
+    folder = relationship("Folder")
diff --git a/superset/migrations/versions/2026-08-04_12-00_a1b2c3d4e5f6_add_folder_activity_table.py b/superset/migrations/versions/2026-08-04_12-00_a1b2c3d4e5f6_add_folder_activity_table.py
new file mode 100644
index 00000000000..f64056c7133
--- /dev/null
+++ b/superset/migrations/versions/2026-08-04_12-00_a1b2c3d4e5f6_add_folder_activity_table.py
@@ -0,0 +1,63 @@
+# 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.
+"""Add folder_activity table
+
+Revision ID: a1b2c3d4e5f6
+Revises: 4567cf3d03cc
+Create Date: 2026-08-04 12:00:00.000000
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "a1b2c3d4e5f6"
+down_revision = "4567cf3d03cc"
+
+
+def upgrade():
+    op.create_table(
+        "folder_activity",
+        sa.Column("id", sa.Integer(), primary_key=True),
+        sa.Column(
+            "folder_id",
+            sa.Integer(),
+            sa.ForeignKey("folders.id", ondelete="CASCADE"),
+            nullable=False,
+        ),
+        sa.Column("user_id", sa.Integer(), nullable=False),
+        sa.Column("action", sa.String(50), nullable=False),
+        sa.Column("target_type", sa.String(50), nullable=True),
+        sa.Column("target_name", sa.String(250), nullable=True),
+        sa.Column("details", sa.Text(), nullable=True),
+        sa.Column(
+            "created_on",
+            sa.DateTime(),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+    op.create_index(
+        "ix_folder_activity_folder_id", "folder_activity", ["folder_id"]
+    )
+    op.create_index(
+        "ix_folder_activity_created_on", "folder_activity", ["created_on"]
+    )
+
+
+def downgrade():
+    op.drop_table("folder_activity")