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

feat(file-manager): sort folder tree by recent activity (#1770)

  Reporter has a lot of nested cad / slicer directories and wanted
  "folders that just got a new 3MF" surfaced without scrolling the
  alphabet. Tree was always alphabetical; LibraryFolder.updated_at
  only bumps on rename / move, not on file-add inside the folder.

  Backend exposes latest_activity_at = max(folder.updated_at,
  max(immediate-child file.updated_at)) on FolderResponse +
  FolderTreeItem. The /folders tree route picks up a sibling
  func.max(updated_at) group-by alongside the existing file-count
  subquery; the by-project / by-archive / single-folder routes
  collapse count + max into one trip. Recursion across subfolders
  is intentionally not computed - bubbles immediate parent only,
  keeps the query a single GROUP BY rather than a recursive CTE.

  Frontend adds a folder-sidebar sort dropdown (By name / By recent
  activity) plus an asc / desc arrow, persisted in localStorage.
  sortedFolders memo applies the comparator recursively so order is
  consistent at every depth. Empty folders fall back to name within
  the activity bucket so they never elbow a recently-used folder to
  a random position. Both the desktop sidebar and the mobile selector
  consume the sorted list so order is identical across breakpoints.

  External folders: LibraryFile rows are created for scanned external
  files too, so the aggregate works on them - but the timestamp
  reflects last scan, not filesystem mtime. Documented in the wiki.

  Same change also fixes File Manager list-view column alignment:
  header and body were sibling grids with min-content as the trailing
  column, computed independently. Header empty trailing div resolved
  to 0; body action strip to ~220px. Different trailing widths gave
  the 1fr Name column different remaining space, shifting every fixed
  column to its right. Replaced min-content with fixed 220px in both
  auth-on / auth-off grid templates.
maziggy 2 месяцев назад
Родитель
Сommit
4d16faed76

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 60 - 16
backend/app/api/routes/library.py

@@ -750,11 +750,24 @@ async def list_folders(
     )
     file_counts = dict(file_counts_result.all())
 
+    # Latest immediate-child file activity per folder (#1770). Sibling of the
+    # file_counts subquery — same WHERE clause, MAX(updated_at) instead of
+    # COUNT(id). Subfolder descent is not aggregated here; the frontend's
+    # "sort by recent activity" mode is satisfied by immediate-parent bubble.
+    latest_file_activity_result = await db.execute(
+        select(LibraryFile.folder_id, func.max(LibraryFile.updated_at))
+        .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
+        .group_by(LibraryFile.folder_id)
+    )
+    latest_file_activity = dict(latest_file_activity_result.all())
+
     # Build tree structure
     folder_map = {}
     root_folders = []
 
     for folder, project_name, archive_name in rows:
+        latest_file = latest_file_activity.get(folder.id)
+        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
         folder_item = FolderTreeItem(
             id=folder.id,
             name=folder.name,
@@ -767,6 +780,7 @@ async def list_folders(
             external_path=folder.external_path,
             external_readonly=folder.external_readonly,
             file_count=file_counts.get(folder.id, 0),
+            latest_activity_at=latest_activity_at,
             children=[],
         )
         folder_map[folder.id] = folder_item
@@ -804,14 +818,19 @@ async def get_folders_by_project(
 
     folders = []
     for folder, project_name in rows:
-        # Get file count
-        file_count_result = await db.execute(
-            select(func.count(LibraryFile.id)).where(
+        # Get file count + latest file activity (#1770) in one trip
+        agg_result = await db.execute(
+            select(
+                func.count(LibraryFile.id),
+                func.max(LibraryFile.updated_at),
+            ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
             )
         )
-        file_count = file_count_result.scalar() or 0
+        file_count, latest_file = agg_result.one()
+        file_count = file_count or 0
+        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
         folders.append(
             FolderResponse(
@@ -827,6 +846,7 @@ async def get_folders_by_project(
                 external_readonly=folder.external_readonly,
                 external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
+                latest_activity_at=latest_activity_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
             )
@@ -857,14 +877,19 @@ async def get_folders_by_archive(
 
     folders = []
     for folder, archive_name in rows:
-        # Get file count
-        file_count_result = await db.execute(
-            select(func.count(LibraryFile.id)).where(
+        # Get file count + latest file activity (#1770) in one trip
+        agg_result = await db.execute(
+            select(
+                func.count(LibraryFile.id),
+                func.max(LibraryFile.updated_at),
+            ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
             )
         )
-        file_count = file_count_result.scalar() or 0
+        file_count, latest_file = agg_result.one()
+        file_count = file_count or 0
+        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
         folders.append(
             FolderResponse(
@@ -880,6 +905,7 @@ async def get_folders_by_archive(
                 external_readonly=folder.external_readonly,
                 external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
+                latest_activity_at=latest_activity_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
             )
@@ -943,6 +969,9 @@ async def create_folder(
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         file_count=0,
+        # New folder has no files yet — fall back to the folder's own
+        # updated_at so this matches the list-route semantics (#1770).
+        latest_activity_at=folder.updated_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
     )
@@ -973,14 +1002,19 @@ async def get_folder(
 
     folder, project_name, archive_name = row
 
-    # Get file count
-    file_count_result = await db.execute(
-        select(func.count(LibraryFile.id)).where(
+    # Get file count + latest file activity (#1770) in one trip
+    agg_result = await db.execute(
+        select(
+            func.count(LibraryFile.id),
+            func.max(LibraryFile.updated_at),
+        ).where(
             LibraryFile.folder_id == folder_id,
             LibraryFile.deleted_at.is_(None),
         )
     )
-    file_count = file_count_result.scalar() or 0
+    file_count, latest_file = agg_result.one()
+    file_count = file_count or 0
+    latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
     return FolderResponse(
         id=folder.id,
@@ -995,6 +1029,7 @@ async def get_folder(
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
+        latest_activity_at=latest_activity_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
     )
@@ -1064,14 +1099,19 @@ async def update_folder(
     await db.commit()
     await db.refresh(folder)
 
-    # Get file count and names
-    file_count_result = await db.execute(
-        select(func.count(LibraryFile.id)).where(
+    # Get file count + latest file activity (#1770) and names
+    agg_result = await db.execute(
+        select(
+            func.count(LibraryFile.id),
+            func.max(LibraryFile.updated_at),
+        ).where(
             LibraryFile.folder_id == folder_id,
             LibraryFile.deleted_at.is_(None),
         )
     )
-    file_count = file_count_result.scalar() or 0
+    file_count, latest_file = agg_result.one()
+    file_count = file_count or 0
+    latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
     # Get project and archive names
     project_name = None
@@ -1096,6 +1136,7 @@ async def update_folder(
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
+        latest_activity_at=latest_activity_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
     )
@@ -1365,6 +1406,9 @@ async def create_external_folder(
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         file_count=0,
+        # Newly-created external folder hasn't been scanned yet — fall back
+        # to the folder's own updated_at (#1770).
+        latest_activity_at=folder.updated_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
     )

+ 8 - 0
backend/app/schemas/library.py

@@ -50,6 +50,12 @@ class FolderResponse(BaseModel):
     external_readonly: bool = False
     external_show_hidden: bool = False
     file_count: int = 0  # Computed field
+    # max(folder.updated_at, max(immediate-child file.updated_at)). Used by the
+    # File Manager folder tree's "sort by recent activity" mode (#1770) so that
+    # adding a file inside a folder bubbles it up — folder.updated_at alone only
+    # tracks rename/move events. Recursion across subfolders is intentionally
+    # left out to keep the route a single GROUP BY rather than a recursive CTE.
+    latest_activity_at: datetime | None = None
     created_at: datetime
     updated_at: datetime
 
@@ -71,6 +77,8 @@ class FolderTreeItem(BaseModel):
     external_path: str | None = None
     external_readonly: bool = False
     file_count: int = 0
+    # See FolderResponse.latest_activity_at — #1770 folder sort source.
+    latest_activity_at: datetime | None = None
     children: list["FolderTreeItem"] = []
 
     class Config:

+ 55 - 0
backend/tests/integration/test_library_api.py

@@ -44,6 +44,61 @@ class TestLibraryFoldersAPI:
         assert response.status_code == 200
         assert response.json() == []
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_folder_tree_exposes_latest_activity_at_from_files(
+        self, async_client: AsyncClient, folder_factory, db_session
+    ):
+        """#1770: folder list returns latest_activity_at = MAX(folder.updated_at,
+        MAX(immediate-child file.updated_at)) so the frontend can sort by
+        recent activity. Adding a file with a later updated_at must bubble it.
+        """
+        from datetime import datetime, timedelta
+
+        from backend.app.models.library import LibraryFile
+
+        folder = await folder_factory(name="Active Folder")
+        # File whose updated_at is well after the folder's. Activity should
+        # surface this timestamp, not the folder's stale one.
+        future = datetime.utcnow() + timedelta(hours=24)
+        db_session.add(
+            LibraryFile(
+                folder_id=folder.id,
+                filename="model.3mf",
+                file_path="library/model.3mf",
+                file_type="3mf",
+                file_size=123,
+                updated_at=future,
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/library/folders")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) == 1
+        item = items[0]
+        assert item["id"] == folder.id
+        assert item["latest_activity_at"] is not None
+        # latest_activity_at should be at least the future stamp we set.
+        assert item["latest_activity_at"] >= future.isoformat()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_folder_tree_latest_activity_at_falls_back_to_folder_updated_at(
+        self, async_client: AsyncClient, folder_factory, db_session
+    ):
+        """#1770: a folder with no files reports its own updated_at, not null —
+        otherwise the activity sort would dump every empty folder to one end."""
+        await folder_factory(name="Empty Folder")
+        response = await async_client.get("/api/v1/library/folders")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) == 1
+        item = items[0]
+        # latest_activity_at == folder.updated_at when there are no files
+        assert item["latest_activity_at"] is not None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_create_folder(self, async_client: AsyncClient, db_session):

+ 4 - 0
frontend/src/api/client.ts

@@ -6314,6 +6314,9 @@ export interface LibraryFolderTree {
   external_path: string | null;
   external_readonly: boolean;
   file_count: number;
+  // max(folder.updated_at, max(immediate-child file.updated_at)). Used by
+  // the File Manager folder tree's "sort by recent activity" mode (#1770).
+  latest_activity_at: string | null;
   children: LibraryFolderTree[];
 }
 
@@ -6330,6 +6333,7 @@ export interface LibraryFolder {
   external_readonly: boolean;
   external_show_hidden: boolean;
   file_count: number;
+  latest_activity_at: string | null;
   created_at: string;
   updated_at: string;
 }

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

@@ -3320,6 +3320,9 @@ export default {
     collapse: 'Einklappen',
     collapseFoldersByDefault: 'Ordner standardmäßig einklappen',
     expandFoldersByDefault: 'Ordner standardmäßig ausklappen',
+    folderSort: 'Ordner sortieren',
+    folderSortByName: 'Nach Name',
+    folderSortByActivity: 'Nach letzter Aktivität',
     dragToResizeTooltip: 'Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen',
     searchFiles: 'Dateien suchen...',
     allTypes: 'Alle Typen',

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

@@ -3335,6 +3335,9 @@ export default {
     collapse: 'Collapse',
     collapseFoldersByDefault: 'Collapse folders by default',
     expandFoldersByDefault: 'Expand folders by default',
+    folderSort: 'Sort folders',
+    folderSortByName: 'By name',
+    folderSortByActivity: 'By recent activity',
     dragToResizeTooltip: 'Drag to resize, double-click to reset',
     searchFiles: 'Search files...',
     allTypes: 'All types',

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

@@ -3323,6 +3323,9 @@ export default {
     collapse: 'Contraer',
     collapseFoldersByDefault: 'Contraer las carpetas de forma predeterminada',
     expandFoldersByDefault: 'Expandir las carpetas de forma predeterminada',
+    folderSort: 'Ordenar carpetas',
+    folderSortByName: 'Por nombre',
+    folderSortByActivity: 'Por actividad reciente',
     dragToResizeTooltip: 'Arrastre para redimensionar, doble clic para restablecer',
     searchFiles: 'Buscar archivos...',
     allTypes: 'Todos los tipos',

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

@@ -3309,6 +3309,9 @@ export default {
     collapse: 'Réduire',
     collapseFoldersByDefault: 'Réduire les dossiers par défaut',
     expandFoldersByDefault: 'Développer les dossiers par défaut',
+    folderSort: 'Trier les dossiers',
+    folderSortByName: 'Par nom',
+    folderSortByActivity: 'Par activité récente',
     dragToResizeTooltip: 'Glisser pour redimensionner, double-clic reset',
     searchFiles: 'Chercher fichiers...',
     allTypes: 'Tous types',

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

@@ -3308,6 +3308,9 @@ export default {
     collapse: 'Comprimi',
     collapseFoldersByDefault: 'Comprimi le cartelle per impostazione predefinita',
     expandFoldersByDefault: 'Espandi le cartelle per impostazione predefinita',
+    folderSort: 'Ordina cartelle',
+    folderSortByName: 'Per nome',
+    folderSortByActivity: 'Per attività recente',
     dragToResizeTooltip: 'Trascina per ridimensionare, doppio clic per reset',
     searchFiles: 'Cerca file...',
     allTypes: 'Tutti i tipi',

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

@@ -3320,6 +3320,9 @@ export default {
     collapse: '折りたたむ',
     collapseFoldersByDefault: 'フォルダをデフォルトで折りたたむ',
     expandFoldersByDefault: 'フォルダをデフォルトで展開する',
+    folderSort: 'フォルダの並べ替え',
+    folderSortByName: '名前順',
+    folderSortByActivity: '最終更新順',
     dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',
     searchFiles: 'ファイルを検索...',
     allTypes: 'すべての種類',

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

@@ -3133,6 +3133,9 @@ export default {
     collapse: '접기',
     collapseFoldersByDefault: '기본적으로 폴더 접기',
     expandFoldersByDefault: '기본적으로 폴더 펼치기',
+    folderSort: '폴더 정렬',
+    folderSortByName: '이름순',
+    folderSortByActivity: '최근 활동순',
     dragToResizeTooltip: '드래그하여 크기 조정, 더블클릭하여 초기화',
     searchFiles: '파일 검색...',
     allTypes: '모든 유형',

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

@@ -3308,6 +3308,9 @@ export default {
     collapse: 'Recolher',
     collapseFoldersByDefault: 'Recolher pastas por padrão',
     expandFoldersByDefault: 'Expandir pastas por padrão',
+    folderSort: 'Ordenar pastas',
+    folderSortByName: 'Por nome',
+    folderSortByActivity: 'Por atividade recente',
     dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',
     searchFiles: 'Pesquisar arquivos...',
     allTypes: 'Todos os tipos',

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

@@ -3315,6 +3315,9 @@ export default {
     collapse: 'Daralt',
     collapseFoldersByDefault: 'Klasörleri varsayılan olarak daralt',
     expandFoldersByDefault: 'Klasörleri varsayılan olarak genişlet',
+    folderSort: 'Klasörleri sırala',
+    folderSortByName: 'Ada göre',
+    folderSortByActivity: 'Son etkinliğe göre',
     dragToResizeTooltip: 'Yeniden boyutlandırmak için sürükleyin, sıfırlamak için çift tıklayın',
     searchFiles: 'Dosyalarda ara...',
     allTypes: 'Tüm türler',

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

@@ -3308,6 +3308,9 @@ export default {
     collapse: '折叠',
     collapseFoldersByDefault: '默认折叠文件夹',
     expandFoldersByDefault: '默认展开文件夹',
+    folderSort: '文件夹排序',
+    folderSortByName: '按名称',
+    folderSortByActivity: '按最近活动',
     dragToResizeTooltip: '拖动调整大小,双击重置',
     searchFiles: '搜索文件...',
     allTypes: '所有类型',

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

@@ -3308,6 +3308,9 @@ export default {
     collapse: '折疊',
     collapseFoldersByDefault: '預設折疊資料夾',
     expandFoldersByDefault: '預設展開資料夾',
+    folderSort: '資料夾排序',
+    folderSortByName: '依名稱',
+    folderSortByActivity: '依最近活動',
     dragToResizeTooltip: '拖曳調整大小,雙擊重設',
     searchFiles: '搜尋檔案...',
     allTypes: '所有類型',

+ 86 - 7
frontend/src/pages/FileManagerPage.tsx

@@ -978,6 +978,17 @@ export function FileManagerPage() {
   const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => {
     return localStorage.getItem('library-collapse-folders') === 'true';
   });
+  // Folder tree sort (#1770). 'name' = alphabetical (the prior behaviour);
+  // 'activity' = most recent file activity inside the folder first. Persisted
+  // independently from the file-side sort so each can be tuned to taste.
+  const [folderSortField, setFolderSortField] = useState<'name' | 'activity'>(() => {
+    const saved = localStorage.getItem('library-folder-sort-field');
+    return saved === 'activity' ? 'activity' : 'name';
+  });
+  const [folderSortDirection, setFolderSortDirection] = useState<'asc' | 'desc'>(() => {
+    const saved = localStorage.getItem('library-folder-sort-direction');
+    return saved === 'desc' ? 'desc' : 'asc';
+  });
 
   // Resizable sidebar state
   const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -1060,6 +1071,42 @@ export function FileManagerPage() {
     queryFn: () => api.getLibraryFolders(),
   });
 
+  // Recursive folder tree sort (#1770). Applies the same comparator to the
+  // top-level list AND to each level of `children`, so sort order is uniform
+  // at every depth of nesting. When sorting by activity, the comparator falls
+  // back to a created-at fallback for folders with no files (`latest_activity_at`
+  // is null) so they stay grouped at the end / start of the bucket instead of
+  // randomly interspersed.
+  const sortedFolders = useMemo(() => {
+    if (!folders) return folders;
+    const sortLevel = (items: LibraryFolderTree[]): LibraryFolderTree[] => {
+      const sorted = [...items].sort((a, b) => {
+        let comparison = 0;
+        if (folderSortField === 'name') {
+          comparison = a.name.localeCompare(b.name);
+        } else {
+          // activity: newest first on 'desc', oldest first on 'asc'.
+          // Folders with no activity timestamp sort to the end regardless
+          // of direction so an empty folder doesn't elbow a recently-used one.
+          const aTs = a.latest_activity_at ? new Date(a.latest_activity_at).getTime() : null;
+          const bTs = b.latest_activity_at ? new Date(b.latest_activity_at).getTime() : null;
+          if (aTs === null && bTs === null) {
+            comparison = a.name.localeCompare(b.name);
+          } else if (aTs === null) {
+            return 1;
+          } else if (bTs === null) {
+            return -1;
+          } else {
+            comparison = aTs - bTs;
+          }
+        }
+        return folderSortDirection === 'asc' ? comparison : -comparison;
+      });
+      return sorted.map((f) => ({ ...f, children: sortLevel(f.children) }));
+    };
+    return sortLevel(folders);
+  }, [folders, folderSortField, folderSortDirection]);
+
   // Trash count for the header badge (#1008). Empty/error are silently treated
   // as zero so a broken trash endpoint doesn't break the File Manager.
   const { data: trashCount } = useQuery({
@@ -1606,7 +1653,7 @@ export function FileManagerPage() {
             {folders?.some((f) => f.is_external) && (
               <option value="__top:external">🔗 {t('fileManager.allExternal')}</option>
             )}
-            {folders && (() => {
+            {sortedFolders && (() => {
               // Flatten folder tree for mobile selector
               const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
                 const result: { id: number; name: string; fileCount: number; depth: number }[] = [];
@@ -1618,7 +1665,7 @@ export function FileManagerPage() {
                 }
                 return result;
               };
-              return flattenFolders(folders).map((folder) => (
+              return flattenFolders(sortedFolders).map((folder) => (
                 <option key={folder.id} value={folder.id}>
                   {'│ '.repeat(folder.depth)}📂 {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}
                 </option>
@@ -1658,6 +1705,35 @@ export function FileManagerPage() {
           <div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
             <h2 className="text-sm font-medium text-white">{t('fileManager.folders')}</h2>
             <div className="flex items-center gap-1">
+              {/* Folder tree sort (#1770). Dropdown drives the comparator;
+                  direction button flips asc/desc. Both persist to localStorage
+                  on change so the choice survives reloads. */}
+              <select
+                value={folderSortField}
+                onChange={(e) => {
+                  const v = e.target.value === 'activity' ? 'activity' : 'name';
+                  setFolderSortField(v);
+                  localStorage.setItem('library-folder-sort-field', v);
+                }}
+                className="text-xs px-1 py-0.5 rounded bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray focus:outline-none focus:border-bambu-green"
+                title={t('fileManager.folderSort')}
+                aria-label={t('fileManager.folderSort')}
+              >
+                <option value="name">{t('fileManager.folderSortByName')}</option>
+                <option value="activity">{t('fileManager.folderSortByActivity')}</option>
+              </select>
+              <button
+                onClick={() => {
+                  const newValue = folderSortDirection === 'asc' ? 'desc' : 'asc';
+                  setFolderSortDirection(newValue);
+                  localStorage.setItem('library-folder-sort-direction', newValue);
+                }}
+                className="text-bambu-gray hover:text-white hover:bg-bambu-dark p-1 rounded transition-colors"
+                title={folderSortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
+                aria-label={folderSortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
+              >
+                {folderSortDirection === 'asc' ? <SortAsc className="w-3.5 h-3.5" /> : <SortDesc className="w-3.5 h-3.5" />}
+              </button>
               <button
                 onClick={() => {
                   const newValue = !collapseFoldersByDefault;
@@ -1732,7 +1808,7 @@ export function FileManagerPage() {
             {/* Folder tree — re-key on the collapse toggle so flipping it
                 remounts every FolderTreeItem, which re-reads defaultExpanded
                 and makes the preference take effect immediately. */}
-            {folders?.map((folder) => (
+            {sortedFolders?.map((folder) => (
               <FolderTreeItem
                 key={`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`}
                 folder={folder}
@@ -2086,9 +2162,12 @@ export function FileManagerPage() {
                   column couldn't fit (#1325 follow-up reported in chat). */}
               <div className="bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-x-auto">
                 {/* List header - hidden on mobile, show simplified on small screens.
-                    The trailing column is `min-content` so it sizes to the widest
-                    action-icon strip across all rows (sliced 3MF = 7 icons ~220px). */}
-                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]' : 'grid-cols-[auto_1fr_100px_100px_100px_min-content]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
+                    Trailing actions column is fixed at 220px (sliced 3MF = 7 icons
+                    ~220px). It used to be `min-content`, but header + body are sibling
+                    grids that compute `min-content` independently — the header's empty
+                    trailing div resolved to 0px, leaving body columns shifted left of
+                    their headers. Fixed width keeps header and body in lockstep. */}
+                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_220px]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
                   <div className="w-6" />
                   <div>{t('common.name')}</div>
                   {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
@@ -2101,7 +2180,7 @@ export function FileManagerPage() {
                 {filteredAndSortedFiles.map((file) => (
                   <div
                     key={file.id}
-                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]' : 'grid-cols-[auto_1fr_100px_100px_100px_min-content]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
+                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_220px]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     onClick={() => handleFileSelect(file.id)}

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


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


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


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DWkuyGzA.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CPDrjcz-.css">
+    <script type="module" crossorigin src="/assets/index-BPZeidkD.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-D9kvaB_m.css">
   </head>
   <body>
     <div id="root"></div>

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