Explorar o código

feat(file-manager): show last activity on folder rows via the existing date toggle (issue #2680)

Follow-up to #2680: the calendar toggle only put dates on the file pane, so
the folder tree had no way to show the timestamp it was already sorting on.
FolderTreeItem now takes showModified and renders latest_activity_at under
the folder name, threaded through the recursive call so nested folders get it
too. No backend change - the field was already on the wire from the sort fix.

Folders are labelled "last activity", not "last modified", and get their own
i18n key. The value is the newest timestamp among the folder, its files and
everything below it, so a folder can read as newer than its own directory
mtime - calling that "modified" would look like a fresh instance of the
ls -lt mismatch the issue was originally about. Folders with no activity
render nothing rather than an Invalid Date placeholder.

The name span moved into a flex column so the second line does not disturb
the row's link badge, file count or kebab menu. That broke a folder-delete
test that reached the row via parentElement, now fixed to use closest().

Separately, the #996 collapse describe left an implementation on the
module-global localStorage.getItem mock, which silently collapsed the folder
tree for every describe after it. It resets in afterEach now; without that,
any later test asserting on nested folders fails for reasons unrelated to
what it is testing.
maziggy hai 1 mes
pai
achega
9ef03067f7

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
CHANGELOG.md


+ 4 - 1
frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx

@@ -71,7 +71,10 @@ function mockAuthUser(permissions: string[]) {
 }
 }
 
 
 async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
 async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
-  const row = screen.getByText(folderName).parentElement!;
+  // Walk up to the row itself rather than assuming the name is its direct
+  // child — the name sits in a wrapper that also holds the optional
+  // last-activity line (#2680).
+  const row = screen.getByText(folderName).closest('div.group')!;
   const buttons = within(row).getAllByRole('button');
   const buttons = within(row).getAllByRole('button');
   // The kebab (MoreVertical) menu toggle is the last button in the row
   // The kebab (MoreVertical) menu toggle is the last button in the row
   await user.click(buttons[buttons.length - 1]);
   await user.click(buttons[buttons.length - 1]);

+ 44 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -2,7 +2,7 @@
  * Tests for the FileManagerPage component.
  * Tests for the FileManagerPage component.
  */
  */
 
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
 import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
@@ -21,6 +21,9 @@ const mockFolders = [
     archive_id: null,
     archive_id: null,
     project_name: null,
     project_name: null,
     archive_name: null,
     archive_name: null,
+    // #2680: distinctive year so the folder-pane display test can assert on it
+    // without colliding with the file mtimes below.
+    latest_activity_at: '2031-04-05T10:00:00Z',
     children: [
     children: [
       {
       {
         id: 2,
         id: 2,
@@ -31,6 +34,7 @@ const mockFolders = [
         archive_id: null,
         archive_id: null,
         project_name: null,
         project_name: null,
         archive_name: null,
         archive_name: null,
+        latest_activity_at: '2032-06-07T10:00:00Z',
         children: [],
         children: [],
       },
       },
     ],
     ],
@@ -44,6 +48,9 @@ const mockFolders = [
     archive_id: null,
     archive_id: null,
     project_name: 'My Art Project',
     project_name: 'My Art Project',
     archive_name: null,
     archive_name: null,
+    // No activity timestamp — must render no date line rather than an
+    // "Invalid Date" placeholder.
+    latest_activity_at: null,
     children: [],
     children: [],
   },
   },
 ];
 ];
@@ -880,6 +887,13 @@ describe('FileManagerPage', () => {
       setItemMock.mockReset();
       setItemMock.mockReset();
     });
     });
 
 
+    // The mock is module-global, so an implementation left behind here would
+    // silently change every later describe (e.g. collapsing the folder tree).
+    afterEach(() => {
+      getItemMock.mockReset();
+      setItemMock.mockReset();
+    });
+
     it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
     it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
       getItemMock.mockReturnValue(null);
       getItemMock.mockReturnValue(null);
       render(<FileManagerPage />);
       render(<FileManagerPage />);
@@ -1111,5 +1125,34 @@ describe('FileManagerPage', () => {
         expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
         expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
       });
       });
     });
     });
+
+    it('the same toggle reveals latest activity on folder rows, including nested ones', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
+
+      await user.click(screen.getByTitle('Show modified dates'));
+
+      await waitFor(() => {
+        expect(screen.getByText(/2031/)).toBeInTheDocument();
+      });
+      // Nested folders get it too — the prop must survive the recursion.
+      expect(screen.getByText(/2032/)).toBeInTheDocument();
+
+      // A folder with no activity timestamp renders nothing rather than an
+      // "Invalid Date" string.
+      const artRow = screen.getByText('Art Projects').closest('div.group')!;
+      expect(artRow.textContent).not.toMatch(/Invalid/);
+
+      await user.click(screen.getByTitle('Hide modified dates'));
+      await waitFor(() => {
+        expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
+      });
+    });
   });
   });
 });
 });

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -3670,6 +3670,7 @@ export default {
     showModified: 'Änderungsdatum anzeigen',
     showModified: 'Änderungsdatum anzeigen',
     hideModified: 'Änderungsdatum ausblenden',
     hideModified: 'Änderungsdatum ausblenden',
     lastModified: 'Zuletzt geändert',
     lastModified: 'Zuletzt geändert',
+    lastActivity: 'Letzte Aktivität',
     resultsCount: '{{showing}} von {{total}} Dateien',
     resultsCount: '{{showing}} von {{total}} Dateien',
     selectAll: 'Alle auswählen',
     selectAll: 'Alle auswählen',
     deselectAll: 'Auswahl aufheben',
     deselectAll: 'Auswahl aufheben',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -3699,6 +3699,7 @@ export default {
     showModified: 'Show modified dates',
     showModified: 'Show modified dates',
     hideModified: 'Hide modified dates',
     hideModified: 'Hide modified dates',
     lastModified: 'Last modified',
     lastModified: 'Last modified',
+    lastActivity: 'Last activity',
     resultsCount: '{{showing}} of {{total}} files',
     resultsCount: '{{showing}} of {{total}} files',
     selectAll: 'Select All',
     selectAll: 'Select All',
     deselectAll: 'Deselect All',
     deselectAll: 'Deselect All',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -3673,6 +3673,7 @@ export default {
     showModified: 'Mostrar fechas de modificación',
     showModified: 'Mostrar fechas de modificación',
     hideModified: 'Ocultar fechas de modificación',
     hideModified: 'Ocultar fechas de modificación',
     lastModified: 'Última modificación',
     lastModified: 'Última modificación',
+    lastActivity: 'Última actividad',
     resultsCount: '{{showing}} de {{total}} archivos',
     resultsCount: '{{showing}} de {{total}} archivos',
     selectAll: 'Seleccionar todo',
     selectAll: 'Seleccionar todo',
     deselectAll: 'Deseleccionar todo',
     deselectAll: 'Deseleccionar todo',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -3659,6 +3659,7 @@ export default {
     showModified: 'Afficher les dates de modification',
     showModified: 'Afficher les dates de modification',
     hideModified: 'Masquer les dates de modification',
     hideModified: 'Masquer les dates de modification',
     lastModified: 'Dernière modification',
     lastModified: 'Dernière modification',
+    lastActivity: 'Dernière activité',
     resultsCount: '{{showing}} sur {{total}} fichiers',
     resultsCount: '{{showing}} sur {{total}} fichiers',
     selectAll: 'Tout sélectionner',
     selectAll: 'Tout sélectionner',
     deselectAll: 'Tout désélectionner',
     deselectAll: 'Tout désélectionner',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -3658,6 +3658,7 @@ export default {
     showModified: 'Mostra date di modifica',
     showModified: 'Mostra date di modifica',
     hideModified: 'Nascondi date di modifica',
     hideModified: 'Nascondi date di modifica',
     lastModified: 'Ultima modifica',
     lastModified: 'Ultima modifica',
+    lastActivity: 'Ultima attività',
     resultsCount: '{{showing}} di {{total}} file',
     resultsCount: '{{showing}} di {{total}} file',
     selectAll: 'Seleziona tutto',
     selectAll: 'Seleziona tutto',
     deselectAll: 'Deseleziona tutto',
     deselectAll: 'Deseleziona tutto',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -3670,6 +3670,7 @@ export default {
     showModified: '更新日時を表示',
     showModified: '更新日時を表示',
     hideModified: '更新日時を非表示',
     hideModified: '更新日時を非表示',
     lastModified: '最終更新',
     lastModified: '最終更新',
+    lastActivity: '最終アクティビティ',
     resultsCount: '{{total}}件中{{showing}}件',
     resultsCount: '{{total}}件中{{showing}}件',
     selectAll: 'すべて選択',
     selectAll: 'すべて選択',
     deselectAll: 'すべて選択解除',
     deselectAll: 'すべて選択解除',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -3482,6 +3482,7 @@ export default {
     showModified: '수정 날짜 표시',
     showModified: '수정 날짜 표시',
     hideModified: '수정 날짜 숨기기',
     hideModified: '수정 날짜 숨기기',
     lastModified: '마지막 수정',
     lastModified: '마지막 수정',
+    lastActivity: '마지막 활동',
     resultsCount: '전체 {{total}}개 중 {{showing}}개',
     resultsCount: '전체 {{total}}개 중 {{showing}}개',
     selectAll: '모두 선택',
     selectAll: '모두 선택',
     deselectAll: '모두 선택 해제',
     deselectAll: '모두 선택 해제',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3658,6 +3658,7 @@ export default {
     showModified: 'Mostrar datas de modificação',
     showModified: 'Mostrar datas de modificação',
     hideModified: 'Ocultar datas de modificação',
     hideModified: 'Ocultar datas de modificação',
     lastModified: 'Última modificação',
     lastModified: 'Última modificação',
+    lastActivity: 'Última atividade',
     resultsCount: '{{showing}} de {{total}} arquivos',
     resultsCount: '{{showing}} de {{total}} arquivos',
     selectAll: 'Selecionar tudo',
     selectAll: 'Selecionar tudo',
     deselectAll: 'Desmarcar tudo',
     deselectAll: 'Desmarcar tudo',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -3474,6 +3474,7 @@ export default {
     showModified: "Показать даты изменения",
     showModified: "Показать даты изменения",
     hideModified: "Скрыть даты изменения",
     hideModified: "Скрыть даты изменения",
     lastModified: "Изменено",
     lastModified: "Изменено",
+    lastActivity: "Последняя активность",
     resultsCount: "Показано {{showing}} из {{total}} файлов",
     resultsCount: "Показано {{showing}} из {{total}} файлов",
     selectAll: "Выбрать всё",
     selectAll: "Выбрать всё",
     deselectAll: "Снять выделение",
     deselectAll: "Снять выделение",

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -3666,6 +3666,7 @@ export default {
     showModified: 'Değiştirme tarihlerini göster',
     showModified: 'Değiştirme tarihlerini göster',
     hideModified: 'Değiştirme tarihlerini gizle',
     hideModified: 'Değiştirme tarihlerini gizle',
     lastModified: 'Son değiştirme',
     lastModified: 'Son değiştirme',
+    lastActivity: 'Son etkinlik',
     resultsCount: '{{total}} dosyadan {{showing}} tanesi',
     resultsCount: '{{total}} dosyadan {{showing}} tanesi',
     selectAll: 'Tümünü Seç',
     selectAll: 'Tümünü Seç',
     deselectAll: 'Seçimi Kaldır',
     deselectAll: 'Seçimi Kaldır',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -3699,6 +3699,7 @@ export default {
     showModified: "Показати змінені дати",
     showModified: "Показати змінені дати",
     hideModified: "Приховати змінені дати",
     hideModified: "Приховати змінені дати",
     lastModified: "Востаннє змінено",
     lastModified: "Востаннє змінено",
+    lastActivity: "Остання активність",
     resultsCount: "{{showing}} з {{total}} файлів",
     resultsCount: "{{showing}} з {{total}} файлів",
     selectAll: "Вибрати усі",
     selectAll: "Вибрати усі",
     deselectAll: "Зняти вибір із усіх",
     deselectAll: "Зняти вибір із усіх",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3658,6 +3658,7 @@ export default {
     showModified: '显示修改日期',
     showModified: '显示修改日期',
     hideModified: '隐藏修改日期',
     hideModified: '隐藏修改日期',
     lastModified: '最后修改',
     lastModified: '最后修改',
+    lastActivity: '最近活动',
     resultsCount: '{{showing}} / {{total}} 个文件',
     resultsCount: '{{showing}} / {{total}} 个文件',
     selectAll: '全选',
     selectAll: '全选',
     deselectAll: '取消全选',
     deselectAll: '取消全选',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3658,6 +3658,7 @@ export default {
     showModified: '顯示修改日期',
     showModified: '顯示修改日期',
     hideModified: '隱藏修改日期',
     hideModified: '隱藏修改日期',
     lastModified: '最後修改',
     lastModified: '最後修改',
+    lastActivity: '最近活動',
     resultsCount: '{{showing}} / {{total}} 個檔案',
     resultsCount: '{{showing}} / {{total}} 個檔案',
     selectAll: '全選',
     selectAll: '全選',
     deselectAll: '取消全選',
     deselectAll: '取消全選',

+ 18 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -558,11 +558,12 @@ interface FolderTreeItemProps {
   depth?: number;
   depth?: number;
   wrapNames?: boolean;
   wrapNames?: boolean;
   defaultExpanded?: boolean;
   defaultExpanded?: boolean;
+  showModified?: boolean;
   hasPermission: (permission: Permission) => boolean;
   hasPermission: (permission: Permission) => boolean;
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, hasPermission, t }: FolderTreeItemProps) {
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
   const [expanded, setExpanded] = useState(defaultExpanded);
   const [expanded, setExpanded] = useState(defaultExpanded);
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
   const hasChildren = folder.children.length > 0;
@@ -609,7 +610,20 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
         ) : (
         ) : (
           <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
           <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
         )}
         )}
-        <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
+        <div className="flex-1 min-w-0">
+          <span className={`block text-sm ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
+          {/* #2680 follow-up: the same toolbar toggle that shows dates on file
+              cards also shows them here. This is `latest_activity_at` — the
+              newest timestamp among the folder itself, its files and its
+              subfolders (the value "sort by recent activity" orders on) — not
+              the folder's own on-disk mtime, hence the distinct label. */}
+          {showModified && folder.latest_activity_at && (
+            <span className="mt-0.5 flex items-center gap-1 text-xs text-bambu-gray" title={t('fileManager.lastActivity')}>
+              <CalendarClock className="w-3 h-3 flex-shrink-0" />
+              <span className="truncate">{formatDate(folder.latest_activity_at)}</span>
+            </span>
+          )}
+        </div>
         {/* Link indicator - clickable to change link */}
         {/* Link indicator - clickable to change link */}
         {isLinked && (
         {isLinked && (
           <button
           <button
@@ -709,6 +723,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               depth={depth + 1}
               depth={depth + 1}
               wrapNames={wrapNames}
               wrapNames={wrapNames}
               defaultExpanded={defaultExpanded}
               defaultExpanded={defaultExpanded}
+              showModified={showModified}
               hasPermission={hasPermission}
               hasPermission={hasPermission}
               t={t}
               t={t}
             />
             />
@@ -1957,6 +1972,7 @@ export function FileManagerPage() {
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 wrapNames={wrapFolderNames}
                 wrapNames={wrapFolderNames}
                 defaultExpanded={!collapseFoldersByDefault}
                 defaultExpanded={!collapseFoldersByDefault}
+                showModified={showModified}
                 hasPermission={hasPermission}
                 hasPermission={hasPermission}
                 t={t}
                 t={t}
               />
               />

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-DAvf4vFq.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BSGkCTjg.js"></script>
+    <script type="module" crossorigin src="/assets/index-DAvf4vFq.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
     <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
   </head>
   </head>
   <body>
   <body>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio