Просмотр исходного кода

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 1 месяц назад
Родитель
Сommit
9ef03067f7

Разница между файлами не показана из-за своего большого размера
+ 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) {
-  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');
   // The kebab (MoreVertical) menu toggle is the last button in the row
   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.
  */
 
-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 userEvent from '@testing-library/user-event';
 import { render } from '../utils';
@@ -21,6 +21,9 @@ const mockFolders = [
     archive_id: null,
     project_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: [
       {
         id: 2,
@@ -31,6 +34,7 @@ const mockFolders = [
         archive_id: null,
         project_name: null,
         archive_name: null,
+        latest_activity_at: '2032-06-07T10:00:00Z',
         children: [],
       },
     ],
@@ -44,6 +48,9 @@ const mockFolders = [
     archive_id: null,
     project_name: 'My Art Project',
     archive_name: null,
+    // No activity timestamp — must render no date line rather than an
+    // "Invalid Date" placeholder.
+    latest_activity_at: null,
     children: [],
   },
 ];
@@ -880,6 +887,13 @@ describe('FileManagerPage', () => {
       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 () => {
       getItemMock.mockReturnValue(null);
       render(<FileManagerPage />);
@@ -1111,5 +1125,34 @@ describe('FileManagerPage', () => {
         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',
     hideModified: 'Änderungsdatum ausblenden',
     lastModified: 'Zuletzt geändert',
+    lastActivity: 'Letzte Aktivität',
     resultsCount: '{{showing}} von {{total}} Dateien',
     selectAll: 'Alle auswählen',
     deselectAll: 'Auswahl aufheben',

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

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

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

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

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

@@ -3659,6 +3659,7 @@ export default {
     showModified: 'Afficher les dates de modification',
     hideModified: 'Masquer les dates de modification',
     lastModified: 'Dernière modification',
+    lastActivity: 'Dernière activité',
     resultsCount: '{{showing}} sur {{total}} fichiers',
     selectAll: 'Tout 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',
     hideModified: 'Nascondi date di modifica',
     lastModified: 'Ultima modifica',
+    lastActivity: 'Ultima attività',
     resultsCount: '{{showing}} di {{total}} file',
     selectAll: 'Seleziona tutto',
     deselectAll: 'Deseleziona tutto',

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -558,11 +558,12 @@ interface FolderTreeItemProps {
   depth?: number;
   wrapNames?: boolean;
   defaultExpanded?: boolean;
+  showModified?: boolean;
   hasPermission: (permission: Permission) => boolean;
   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 [showActions, setShowActions] = useState(false);
   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" />
         )}
-        <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 */}
         {isLinked && (
           <button
@@ -709,6 +723,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               depth={depth + 1}
               wrapNames={wrapNames}
               defaultExpanded={defaultExpanded}
+              showModified={showModified}
               hasPermission={hasPermission}
               t={t}
             />
@@ -1957,6 +1972,7 @@ export function FileManagerPage() {
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 wrapNames={wrapFolderNames}
                 defaultExpanded={!collapseFoldersByDefault}
+                showModified={showModified}
                 hasPermission={hasPermission}
                 t={t}
               />

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DAvf4vFq.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <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">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов