diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0cd1b8f..9ebd802e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ All notable changes to Bambuddy will be documented in this file. - **LDAP Distinguished Names weren't redacted from the support bundle / bug report (#2681, reporter @MaxBareiss)** — With LDAP auth in use, the debug log carried lines like `LDAP authentication successful for user: … (DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, …)`. A DN's leaf `CN` is the user's real name — PII on par with the email address Bambuddy already redacts — and it passed straight through into an uploaded support bundle. **Fix.** The log sanitizer (used by both the support bundle and the in-app bug report) now redacts LDAP DNs to `[DN]` wherever they appear — the auth line, ldap3 exception strings, and group DNs alike — matching a run of `attr=value` RDN components (`CN/OU/DC/UID/…`) so ordinary `key=value` log text isn't affected. As primary hygiene the LDAP service also no longer logs the raw DN on successful auth (the username plus group count is enough). Covered by tests, including the exact reported line and non-DN `key=value` lines that must be left intact. Redaction list on the Bug Report wiki page updated. - **An external USB camera could stay locked (LED stuck on) after closing the live view, blocking reopen (#2675, reporter @bitbarista)** — Closing an external USB (V4L2) camera's live view abruptly — tab/popup closed, or a dropped connection — could leave the backend's `ffmpeg` process running and holding `/dev/videoN` open. The camera LED stayed lit and the next attempt to open the view (or click Test) failed or took 10-30+ seconds while the new `ffmpeg` fought for exclusive device access. **Root cause.** This is the same class of leak as #776 (fixed for the built-in RTSP path), but the external/USB path was never wired into that fix. #776 added the `_active_streams` / `_disconnect_events` / spawned-PID registries so both the `/camera/stop` endpoint and the periodic orphan janitor could find and kill leaked ffmpeg — but external streams registered into none of them, so for USB cameras both were structurally blind: `/camera/stop` returned `{"stopped": 0}` even while a stream was genuinely running, and the janitor's `/proc` net matched only `rtsp(s)://bblp:` cmdlines, never a USB `ffmpeg`. Cleanup ran only via the stream generator's own `finally`, which an abrupt disconnect can skip. **Fix.** External USB (and external-RTSP) streams now register their `ffmpeg` process into the same registries the built-in path uses, so `/camera/stop` terminates them promptly (now `{"stopped": 1}`) and the janitor reaps any that leak within its cleanup interval. The `/proc` safety-net scan also now recognises USB (`-f v4l2`) `ffmpeg`, so orphans surviving an app restart are caught too; a leaked process that hangs on a still-locked device (rather than exiting) is registered before the startup probe so it can still be killed. Covered by tests: the stream hands its process to the registry, the stop endpoint and janitor both reap a registered external stream, and the `/proc` scan matches `v4l2` while ignoring unrelated `ffmpeg`. Thanks to @bitbarista for the precise diagnosis. (Reported alongside a working fix; implemented here.) - **A broken slicer sidecar silently produced tiny corrupt files that were queued and printed anyway, and a reverse-proxy 413 wasn't self-explanatory (#2671, reporter @Austinzveare)** — With the slicer-API sidecar behind a reverse proxy, slicing produced ~28-byte files that "did nothing" (and could still be sent to the printer), while a separate proxy attempt failed with a bare **413 Request Entity Too Large** that the recommended nginx fix didn't seem to resolve. **Root cause.** Bambuddy's slice client only validated the sidecar's HTTP *status*, not its body. When the sidecar — or a proxy in front of it — returned `200 OK` with a body that wasn't a real 3MF (a stock/misconfigured sidecar, a proxy error page, a truncated response, or an OrcaSlicer/Bambu Studio CLI crash that emitted no output), Bambuddy wrote that tiny blob straight to a `.gcode.3mf`, stored it as a valid sliced file (the 3MF-parse failure was swallowed as merely "no thumbnail"), and let it be queued and FTP'd to the printer. Separately, a genuine 413 comes from the reverse proxy in front of the sidecar rejecting the multi-MB upload (model + profiles), not from the slicer — so raising the body limit on the wrong proxy layer had no effect. **Fix.** The slice client now validates the sidecar's output: when a 3MF export was requested, the response body must be a real ZIP (3MF container) or the job fails loudly with an actionable message ("…the body is not a valid 3MF (N bytes) — check the sidecar URL and any proxy in front of it") instead of persisting a corrupt file. A 413 now yields a targeted message naming the fix — raise `client_max_body_size` (or equivalent) on the proxy directly in front of the sidecar. Covered by tests: a 200 with a non-3MF body raises a server error (both the profile and embedded-settings paths), a 413 surfaces the reverse-proxy guidance, a valid 3MF still slices, and raw-gcode preview output is not zip-validated. Wiki troubleshooting updated with both scenarios. -- **File Manager "sort by recent activity" didn't match `ls -t`, and there was no way to see a file's modified date (#2680 / #1770 follow-up, reporter @Kingbuzz0)** — For external (mapped/NAS) folders the folder tree's activity sort and the file pane's date sort put things in a seemingly random order — some entries roughly right, most not — instead of the real newest-first order shown by `ls -t` or Windows Explorer. **Root cause.** Nothing captured the files' actual on-disk modification time. The sort keyed off Bambuddy's own database `updated_at`/`created_at` timestamps, which for a bulk external scan are all the same instant (the scan time), so a whole block of files tied and sorted arbitrarily; only the few rows Bambuddy had later touched individually looked "partially correct." The folder tree also only bubbled up *immediate* child-file activity, so a file added deep in a subtree never lifted its parent folders. **Fix.** External scans now record each file's and each directory's real filesystem mtime (`os.stat().st_mtime`), refreshing it on every re-scan so a file edited over the mount re-sorts correctly. The folder tree's "recent activity" is now a **recursive** newest-descendant roll-up — a freshly-added file anywhere inside a folder lifts every ancestor — and both the tree sort and the file pane's date sort use the real mtime (falling back to `created_at` for managed uploads that have none). A new toolbar toggle shows/hides each item's **last-modified date** in the right-hand pane (grid and list views). Existing external folders backfill their mtimes on the next scan. Covered by tests: scan captures real file/folder mtimes, a re-scan refreshes a changed file, and a deep file bubbles its subtree's root ahead of a sibling with only a middle-aged file. +- **File Manager "sort by recent activity" didn't match `ls -t`, and there was no way to see a file's modified date (#2680 / #1770 follow-up, reporter @Kingbuzz0)** — For external (mapped/NAS) folders the folder tree's activity sort and the file pane's date sort put things in a seemingly random order — some entries roughly right, most not — instead of the real newest-first order shown by `ls -t` or Windows Explorer. **Root cause.** Nothing captured the files' actual on-disk modification time. The sort keyed off Bambuddy's own database `updated_at`/`created_at` timestamps, which for a bulk external scan are all the same instant (the scan time), so a whole block of files tied and sorted arbitrarily; only the few rows Bambuddy had later touched individually looked "partially correct." The folder tree also only bubbled up *immediate* child-file activity, so a file added deep in a subtree never lifted its parent folders. **Fix.** External scans now record each file's and each directory's real filesystem mtime (`os.stat().st_mtime`), refreshing it on every re-scan so a file edited over the mount re-sorts correctly. The folder tree's "recent activity" is now a **recursive** newest-descendant roll-up — a freshly-added file anywhere inside a folder lifts every ancestor — and both the tree sort and the file pane's date sort use the real mtime (falling back to `created_at` for managed uploads that have none). A new toolbar toggle shows/hides each item's **last-modified date** in the right-hand pane (grid and list views), and the same toggle puts a date on every row of the folder tree as well, nested folders included. Folders show **last activity** rather than "last modified" — the newest timestamp among the folder itself, its files and everything below it, which is exactly the value the tree sorts on — so a folder holding a file you touched an hour ago reads as an hour old even though its own directory mtime is older. Folders with no activity at all show no date instead of a placeholder. Existing external folders backfill their mtimes on the next scan. Covered by tests: scan captures real file/folder mtimes, a re-scan refreshes a changed file, and a deep file bubbles its subtree's root ahead of a sibling with only a middle-aged file. - **An AMS-HT slot kept showing the removed filament and never cleared (#2670, reporter @needo37)** — After the #2594 fix, every empty-slot clearing path skipped AMS-HT units, so once a spool was removed the HT slot on the printer card stayed stuck on the old filament (Bambu Studio correctly showed it as Empty). The root cause was the HT's presence signal: firmware reports it as a single consecutive bit in `tray_exist_bits` at `16 + (ams_id − 128)` (HT-A = bit 16, HT-B = bit 17, …), not the regular `ams_id × 4` position — so the bitmask cleanup skipped the HT entirely, and the HT's `state` field is firmware-variant and can't be used instead. Confirmed against a live H2D capture (loaded HT reports the bit set, empty reports it clear) and cross-checked with the OrcaSlicer reference. **Fix.** The bitmask cleanup now understands the HT's real bit position and clears an empty HT slot the same way it clears a regular one, using firmware's own authoritative presence bit — so a loaded HT is never wrongly cleared (its bit stays set, keeping the #2594 fix intact). The AMS change detection now hashes the merged state, so a removal signalled only by the bitmask still unbinds the slot's spool assignment; and the websocket status now carries the presence bit so the card renders "Empty" (not "?") consistently. Verified for both single- and dual-HT setups. - **The print dialog clipped the per-filament gram usage when the material name was long, especially on mobile (#2669, reporter @apizz)** — In the Print dialog's Filament Mapping, each required filament shows its name and the grams the job needs, e.g. `Bambu PLA Basic (281.2g)`. The name and the gram figure lived in a single fixed-width column that truncated as one unit, so a long name (e.g. `Polymaker PLA Matte`) pushed the `(…g)` off the end and cut it off — partially on a wide screen, entirely in mobile portrait. The gram usage is the more important number here (it's what tells you whether a spool has enough left), so hiding it was the wrong thing to drop. **Fix.** The gram usage is now pinned and never shrinks or truncates; only the material name truncates (with the full name on hover), so the `(…g)` stays fully visible at every width. Applied to both the Specific-Printer and "Any [model]" mapping panels. Frontend-only, no behaviour change beyond layout. Covered by a test asserting the gram figure renders in its own non-truncating element separate from the truncating name. - **A printer's nozzle size got overwritten to the wrong value (often 0.8mm), then blocked prints as a nozzle mismatch (#2663, reporter @huykent)** — A1 printers with a 0.4mm nozzle intermittently showed **0.8mm** (or no size at all) on the dashboard, and since 1.2.5 that wrong value made the nozzle-mismatch guard (#1899) refuse to dispatch the job — "File sliced for a 0.4mm nozzle, but the printer has 0.8mm installed." It was intermittent and could flip *after* a job was sent. **Root cause.** Bambuddy fetches K-profiles by probing every nozzle size in turn — it sends an `extrusion_cali_get` request for 0.2, 0.4, 0.6 **and** 0.8mm. The printer's response to each echoes the *requested* nozzle diameter at the top level, and the MQTT handler passed every `print` message — including these K-profile responses — through `_update_state`, which treats a top-level `nozzle_diameter` as the installed hardware. So the last size probed (0.8) clobbered the real nozzle size in memory; a later genuine status push would correct it, and the next K-profile fetch would break it again, which is why it flickered and "changed after the job was sent." The raw MQTT status always reported the correct 0.4 — only the derived hardware-nozzle field was corrupted. **Fix.** `extrusion_cali_get` responses are now handled *only* by the K-profile parser and no longer fed to `_update_state`, so they can't touch the nozzle hardware state — mirroring the existing guard that already stops `get_accessories` responses from doing the same thing. The installed nozzle size now comes solely from the printer's real status push, where it was always correct. No configuration or migration needed: the value lives in memory and self-corrects on the next status push after updating. Covered by tests: a 0.8mm K-profile response leaves a 0.4mm nozzle untouched, the response's profiles are still parsed into `state.kprofiles`, and a genuine status push still sets (and corrects) the nozzle. diff --git a/frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx b/frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx index 0a8f86132..2c84eef2b 100644 --- a/frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx +++ b/frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx @@ -71,7 +71,10 @@ function mockAuthUser(permissions: string[]) { } async function openFolderMenu(user: ReturnType, 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]); diff --git a/frontend/src/__tests__/pages/FileManagerPage.test.tsx b/frontend/src/__tests__/pages/FileManagerPage.test.tsx index 68155399f..2f354f817 100644 --- a/frontend/src/__tests__/pages/FileManagerPage.test.tsx +++ b/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(); @@ -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(); + + 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(); + }); + }); }); }); diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 709a76381..75997f08b 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 403dcb22f..6e339d5df 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 0f8d0f927..8713c130d 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 557681c35..05836b9e0 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index d68b05732..e4090fca8 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 23284fae1..edfcf1c87 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -3670,6 +3670,7 @@ export default { showModified: '更新日時を表示', hideModified: '更新日時を非表示', lastModified: '最終更新', + lastActivity: '最終アクティビティ', resultsCount: '{{total}}件中{{showing}}件', selectAll: 'すべて選択', deselectAll: 'すべて選択解除', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index e5a12582f..639d6833c 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -3482,6 +3482,7 @@ export default { showModified: '수정 날짜 표시', hideModified: '수정 날짜 숨기기', lastModified: '마지막 수정', + lastActivity: '마지막 활동', resultsCount: '전체 {{total}}개 중 {{showing}}개', selectAll: '모두 선택', deselectAll: '모두 선택 해제', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 4b369bdd0..4c99f982e 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 313f0fa38..174a879b7 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -3474,6 +3474,7 @@ export default { showModified: "Показать даты изменения", hideModified: "Скрыть даты изменения", lastModified: "Изменено", + lastActivity: "Последняя активность", resultsCount: "Показано {{showing}} из {{total}} файлов", selectAll: "Выбрать всё", deselectAll: "Снять выделение", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index f6edec4fd..a011cdeff 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index b193e1e4c..950ea8443 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -3699,6 +3699,7 @@ export default { showModified: "Показати змінені дати", hideModified: "Приховати змінені дати", lastModified: "Востаннє змінено", + lastActivity: "Остання активність", resultsCount: "{{showing}} з {{total}} файлів", selectAll: "Вибрати усі", deselectAll: "Зняти вибір із усіх", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 73179f39d..2b6643457 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3658,6 +3658,7 @@ export default { showModified: '显示修改日期', hideModified: '隐藏修改日期', lastModified: '最后修改', + lastActivity: '最近活动', resultsCount: '{{showing}} / {{total}} 个文件', selectAll: '全选', deselectAll: '取消全选', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index ab875024f..a2637e6fc 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -3658,6 +3658,7 @@ export default { showModified: '顯示修改日期', hideModified: '隱藏修改日期', lastModified: '最後修改', + lastActivity: '最近活動', resultsCount: '{{showing}} / {{total}} 個檔案', selectAll: '全選', deselectAll: '取消全選', diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 695f8e746..f522b411d 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/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, ) : ( )} - {folder.name} +
+ {folder.name} + {/* #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 && ( + + + {formatDate(folder.latest_activity_at)} + + )} +
{/* Link indicator - clickable to change link */} {isLinked && (