mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 06:38:45 +02:00
Dock Folder README as a collapsible right rail instead of a top block (#2520)
The README panel rendered full-width above the file grid, pushing model files below the fold with no page scroll to get past it. On lg+ it now docks as a fixed-width right-hand column beside the list (own full-height scroll); on mobile it stacks on top and the page scrolls. Added a collapse toggle (thin strip / slim bar + one-click reopen) with the choice persisted to localStorage. New i18n keys readme.show/hide/label across 11 locales.
This commit is contained in:
+2
-1
@@ -37,7 +37,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Sort File Manager folder tree by recent activity (#1770, requested by @Kingbuzz0)** — Until now the folder tree was always sorted alphabetically by name, both backend (`order_by(LibraryFolder.name)`) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. **What changed.** The folder sidebar header gains a small dropdown (**By name** / **By recent activity**) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser via `localStorage` (`library-folder-sort-field`, `library-folder-sort-direction`) so the preference survives reloads. **Activity semantics.** `latest_activity_at` per folder = `MAX(folder.updated_at, MAX(immediate-child file.updated_at))`. The DB had the data — `LibraryFile.updated_at` is `onupdate=func.now()` and `LibraryFolder.updated_at` the same — but `LibraryFolder.updated_at` alone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally **NOT** computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a single `GROUP BY` rather than a recursive CTE, matching the existing file_counts subquery shape sibling at `library.py:746`. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. **Backend.** New `latest_activity_at: datetime | None` field on `FolderResponse` and `FolderTreeItem` schemas. The `/folders` tree route picks up a sibling `func.max(LibraryFile.updated_at)` group-by alongside the existing file-count subquery; resolves the field per row. The `/folders/by-project/{id}` and `/folders/by-archive/{id}` routes collapse their per-row file-count subquery to fetch `count + max` in one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST `/folders`, GET `/folders/{id}`, PUT `/folders/{id}`, POST `/folders/external`, the create flows) populate the field with `max(folder.updated_at, latest_file)` or fall back to `folder.updated_at` when there are no files, so the API surface is consistent across every route that returns a folder. **External folders.** `LibraryFile` rows are created for scanned external files too (`library.py:526`), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last *scanned / re-indexed* the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over with `os.stat()` on every list call, which would stall the route on slow mounts. **Frontend.** A new recursive `sortedFolders` `useMemo` applies the comparator uniformly to top-level + every nested `children` level so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consume `sortedFolders` so the order is identical across breakpoints. The single-folder `findFolder()` traversal and `selectedFolder` memo still operate on the unsorted `folders` because they index by ID — sort-order-independent. **Recursion safety.** The sort creates fresh object refs at every level on every memo invocation; the `FolderTreeItem` keys stay ID-based (`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`) so React reconciliation by ID preserves folder expansion state across sort flips. **i18n.** 3 new keys in `fileManager.*` (`folderSort`, `folderSortByName`, `folderSortByActivity`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. **Tests.** 2 new backend integration cases in `test_library_api.py` (file-in-folder bubbles `latest_activity_at` to the file's timestamp, empty folder falls back to `folder.updated_at`). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass; `npm run build` clean; `ruff` clean; i18n parity green.
|
||||
|
||||
### Fixed
|
||||
- **Scanning an external folder no longer deletes the README.md record (and now indexes pre-existing markdown) (#2520, reporter @zumik3-del)** — The Folder Readme panel (#1268) worked for a `README.md` uploaded through Bambuddy's Upload button, but clicking **Scan External Folder** afterwards made the panel vanish. The reporter root-caused it precisely: `.md` was absent from `_SCANNABLE_EXTENSIONS` (`backend/app/api/routes/library.py:1342`), so the `os.walk` pass skipped markdown files (`:1619`) and never added them to `found_paths` — and the end-of-scan cleanup loop deleted any existing external `LibraryFile` whose path wasn't in `found_paths` (`:1724`), assuming it had been removed from disk. The md file was untouched on disk; only its DB row was destroyed, after which the readme endpoint (`GET /folders/{id}/readme`, which matches `filename LIKE '%.md'`) 404'd and the panel hid. **Two-part fix.** **(1)** Added `.md` to `_SCANNABLE_EXTENSIONS`, so the scan now *indexes* markdown that already exists on disk — markdown dropped in by external tools or copied in manually (feature-request item 1 in the same issue) is picked up and shown, and an uploaded md file is re-found instead of being treated as deleted. `.md` classifies as `file_type="md"` and hits none of the 3mf/gcode/image thumbnail gates, so it just creates a plain record. **(2)** Hardened the cleanup loop to gate deletion on actual disk presence (`path_str not in found_paths and not os.path.exists(path_str)`) rather than mere absence from the extension-filtered `found_paths`. This closes the broader class the reporter flagged: *any* file the upload path admitted whose extension is outside the scannable set (e.g. a `.txt` note) would previously be purged from the DB on the next scan even though it still exists on disk — now such records survive, while genuinely-deleted files (absent from disk) are still cleaned up. **Tests.** 3 new cases in `test_external_folders_api.py::TestExternalFolderScan`: a pre-existing `README.md` on disk is discovered by scan and served by the readme endpoint; an uploaded `README.md` survives a scan (`removed == 0`) and the panel still resolves it — the exact reported bug; a non-scannable `.txt` upload survives a scan via the disk-presence guard. Full suite 39/39 green; `ruff check backend/` clean. **Scope.** Backend-only. No DB migration, no new permission, no i18n change. Item 2 of the issue (the readme panel taking up too much vertical space / needing a page scroll or collapsible sidebar) is a separate File Manager frontend change, tracked as a follow-up on the same issue.
|
||||
- **Scanning an external folder no longer deletes the README.md record (and now indexes pre-existing markdown) (#2520, reporter @zumik3-del)** — The Folder Readme panel (#1268) worked for a `README.md` uploaded through Bambuddy's Upload button, but clicking **Scan External Folder** afterwards made the panel vanish. The reporter root-caused it precisely: `.md` was absent from `_SCANNABLE_EXTENSIONS` (`backend/app/api/routes/library.py:1342`), so the `os.walk` pass skipped markdown files (`:1619`) and never added them to `found_paths` — and the end-of-scan cleanup loop deleted any existing external `LibraryFile` whose path wasn't in `found_paths` (`:1724`), assuming it had been removed from disk. The md file was untouched on disk; only its DB row was destroyed, after which the readme endpoint (`GET /folders/{id}/readme`, which matches `filename LIKE '%.md'`) 404'd and the panel hid. **Two-part fix.** **(1)** Added `.md` to `_SCANNABLE_EXTENSIONS`, so the scan now *indexes* markdown that already exists on disk — markdown dropped in by external tools or copied in manually (feature-request item 1 in the same issue) is picked up and shown, and an uploaded md file is re-found instead of being treated as deleted. `.md` classifies as `file_type="md"` and hits none of the 3mf/gcode/image thumbnail gates, so it just creates a plain record. **(2)** Hardened the cleanup loop to gate deletion on actual disk presence (`path_str not in found_paths and not os.path.exists(path_str)`) rather than mere absence from the extension-filtered `found_paths`. This closes the broader class the reporter flagged: *any* file the upload path admitted whose extension is outside the scannable set (e.g. a `.txt` note) would previously be purged from the DB on the next scan even though it still exists on disk — now such records survive, while genuinely-deleted files (absent from disk) are still cleaned up. **Tests.** 3 new cases in `test_external_folders_api.py::TestExternalFolderScan`: a pre-existing `README.md` on disk is discovered by scan and served by the readme endpoint; an uploaded `README.md` survives a scan (`removed == 0`) and the panel still resolves it — the exact reported bug; a non-scannable `.txt` upload survives a scan via the disk-presence guard. Full suite 39/39 green; `ruff check backend/` clean. **Scope.** Backend-only. No DB migration, no new permission, no i18n change. Item 2 of the issue (the readme panel layout) is addressed in the separate frontend entry below.
|
||||
- **Folder README panel no longer crowds out the file list — now a collapsible right-hand rail (#2520 item 2, reporter @zumik3-del)** — The Folder Readme panel (#1268) rendered as a full-width block stacked *above* the file grid, so on a laptop a moderately long README pushed the actual model files (3MF/STL) below the fold, and — because the file list scrolls in its own container on wide screens — there was no single page scroll to get past it; you had to scroll inside the README separately. **Fix.** On wide screens (`lg+`) the panel now docks as a fixed-width **right-hand column** (`w-80` / `xl:w-96`) beside the file list instead of on top of it, so files stay visible and the README scrolls within its own full-height rail. On narrow screens it stacks above the list (`order-first`) where the page itself scrolls (the reporter's simpler Option A, which is the right behaviour for phones). The panel is **collapsible** — a header toggle shrinks it to a thin vertical strip (desktop) / slim bar (mobile) with a one-click reopen — and the collapsed/expanded choice is **persisted to `localStorage`** so hiding it once keeps it hidden across folder switches and reloads (the reporter's Option B — "open it when you need the description, then hide it to free up space"). Implementation: `FolderReadmePanel` gains the responsive rail layout + persisted collapse state; `FileManagerPage` wraps the files column and the panel in a `flex-col lg:flex-row` content wrapper so the panel is a sibling *column* of the list rather than a block *inside* it. **i18n.** 3 new keys (`fileManager.readme.show` / `.hide` / `.label`) translated across all 11 locales (`.label` is the proper-noun filename "README", identical by design); parity check green at 5579 leaves per locale. **Tests.** 2 new cases in `FolderReadmePanel.test.tsx`: collapsing hides the markdown body, exposes a reopen control, and persists the choice; a persisted-collapsed preference starts the panel collapsed. Existing 3 panel cases + 51 `FileManagerPage` cases stay green; `npm run build` and ESLint clean. **Scope.** Frontend-only. No backend change, no DB migration, no new permission.
|
||||
- **Nozzle sizes other than 0.4mm now fully supported in AMS Slot config + pre-dispatch guard (#1899, reporter @TheUltimateC0der; also hit by @icaisolutionsb2b-hub on X2D)** — On an H2S (or any printer) with a 0.6mm nozzle installed, the **Configure AMS Slot** picker only ever offered 0.4mm filament presets, so trays couldn't be set to the profile that matched the slice, and dispatching the 0.6-sliced job made the printer bail out with the cryptic HMS `_8012` "Failed to get AMS mapping table". Two distinct gaps. **(1) The slot picker was hardwired to 0.4mm.** `ConfigureAmsSlotModal` takes a `nozzleDiameter` prop that defaults to `'0.4'` (`ConfigureAmsSlotModal.tsx:287`) and drives both the local-preset compatibility filter (it builds `"Bambu Lab H2S 0.4 nozzle"` and rejects imported 0.6 presets whose `compatible_printers` lists "…0.6 nozzle") and the K-profile query. Neither call site — `PrintersPage.tsx` nor the SpoolBuddy kiosk's `SpoolBuddyAmsPage.tsx` — ever passed the prop, so the modal assumed 0.4 regardless of the hardware. The real installed diameter was already in scope on the printer status (`status.nozzles[0].nozzle_diameter`, the same field that renders the "• 0.6mm" badge on the card). **Fix:** new `resolveSlotNozzleDiameter(status, amsId)` helper in `utils/amsHelpers.ts` reads the installed nozzle for a given AMS — on dual-nozzle printers (H2D) it resolves the specific nozzle feeding that AMS via `ams_extruder_map[amsId] → nozzles[idx]`, on single-nozzle printers it falls back to the primary nozzle, and it returns `undefined` when the printer hasn't reported nozzle hardware yet so the modal keeps its 0.4 default. Both call sites now pass `nozzleDiameter={resolveSlotNozzleDiameter(status, slot.amsId)}`, so the picker filters presets by the nozzle actually on the machine. **(2) No pre-dispatch validation of nozzle size.** Nothing in the dispatch path (`_compute_ams_mapping_for_printer` / `_match_filaments_to_slots`) ever compared the sliced nozzle diameter against the installed nozzle — the AMS mapping matches on `tray_info_idx` → colour → type with a hard filter only on extruder id, never diameter — so Bambuddy would ship a mapping the firmware then rejects with `_8012` (or `0500_4038`), leaving the user staring at a printer-side error with no explanation. **Fix:** a nozzle-mismatch guard in `_start_print` (backend), placed before preheat and upload so no time is wasted, compares `archive.nozzle_diameter` (parsed from the sliced 3MF's `slice_info`; `None` when the slice doesn't declare it) against the printer's reported nozzles via two pure helpers `_installed_nozzle_diameters()` and `_nozzle_mismatch_message()`. On a positive mismatch it fails the queue item with an actionable message — "File sliced for a 0.6mm nozzle, but the printer has 0.4mm installed. Re-slice for the installed nozzle, or install the matching nozzle before printing." — and fires the same failed-notification + WS event as other dispatch failures. **Fail-safe by construction:** it blocks ONLY on a positive mismatch — when the slice carries no nozzle diameter, or the printer hasn't reported its nozzles, the guard is a no-op and dispatch proceeds exactly as before; on dual-nozzle printers a match against EITHER installed nozzle passes (a 0.6 slice is fine if one of the two hotends is a 0.6). The 0.05mm tolerance absorbs float noise while staying well inside the 0.2mm gap between adjacent nozzle sizes. **Tests.** Frontend: 7 cases in `resolveSlotNozzleDiameter.test.ts` (null/empty status, single-nozzle, dual-nozzle per-AMS resolution, fallbacks). Backend: 15 cases in `test_scheduler_nozzle_mismatch.py` — 5 for `_installed_nozzle_diameters` (parse, empty-default stub, unparseable/zero, dual-nozzle), 8 for `_nozzle_mismatch_message` (block/pass, float tolerance, dual-nozzle either-match, both fail-safe None paths, adjacent-size discrimination), and 2 end-to-end `_start_print` cases proving a mismatch fails the item *before* upload/start_print and a match lets dispatch proceed. Existing scheduler suites (cleanup-library, ams-mapping, cancel-race, preheat — 118 tests) stay green, which also proves the guard is a transparent no-op on the existing archive-without-nozzle path. `npm run build`, ESLint, `ruff check backend/` all clean. **Scope.** No DB migration, no new permission, no new i18n key (the failure message rides the existing `error_message` surface already rendered on failed queue items). Frontend picker change + backend guard only.
|
||||
- **"Remember Me" appeared broken — an authenticated visit to `/login` rendered the login form instead of redirecting (#1889, reporter @superdong69)** — Users with a perfectly valid, persisted session reported that Bambuddy "never stays logged in": they log in with Remember Me, come back later, and are met with the login form again. The reporter did the legwork and traced it to routing, not session persistence: `frontend/src/pages/LoginPage.tsx` destructured only `const { login, loginWithToken } = useAuth()` and never looked at the authenticated state, so the `/login` route (rendered unwrapped in `App.tsx` — `ProtectedRoute` only guards the *other* direction, unauthenticated → `/login`) showed the credentials step even when the token was live. On that same page load the app's own bootstrap sends `GET /api/v1/auth/me` with the Bearer token and gets a 200 with the full user object — the session is fully alive; only the view is wrong. **Why it's easy to hit and self-reinforcing.** After a few visits the browser address bar autocompletes the origin to its most-visited path, which becomes `/login`, so every subsequent visit lands on the form and the illusion of "logged out" compounds. Navigating to `/` instead lands on the dashboard, logged in, no credentials asked — which is also why it can't be reproduced by testing `/` directly. **Fix.** `LoginPage` now also reads `user` and `loading` from the auth context and, in a `useEffect`, redirects an already-authenticated visitor with `navigate('/', { replace: true })` once the auth check has settled. The effect is gated on `step === 'credentials'` so it never interrupts the 2FA step or the OIDC-callback branch, both of which perform their own `navigate()` after `loginWithToken`. It redirects to `/` rather than `resolvePostLoginRedirect()` so it can't consume the OIDC redirect stash — an already-authenticated direct visit has no pending redirect to honour. **Tests.** 2 new cases in `LoginPage.test.tsx` (`authenticated redirect (#1889)`): a live session (token set + `/auth/me` → 200) redirects to `/` with `replace: true`; an unauthenticated visit renders the Sign in form and does not redirect. Existing 29 LoginPage cases stay green; `npm run build` and ESLint clean. **Scope.** Frontend-only, routing layer. No backend change, no DB migration, no new permission, no new i18n key. Note this is the routing facet of #1889; the separate token-discard-on-transient-failure hardening in `AuthContext` (don't drop a valid persisted token on a non-401 blip) is already in the tree.
|
||||
- **Multi-nozzle prints no longer collapse all filaments onto one nozzle (#1825, reporter @needo37)** — The single-active-extruder shortcut added in #851 (for #827) at `threemf_tools.py:354` runs `before` the per-filament `group_id` mapping, and fires whenever `extruder_nozzle_stats` reports exactly one extruder as having a nozzle installed. On the H2D / H2D Pro / X2D (2-nozzle) and H2C (3+-nozzle tool-changer), this field is data-driven from the slicer profile's enumerated nozzle volume types — when an HT-AMS or High-Flow nozzle's type isn't enumerated in the slice's profile (common with asymmetric extruder setups, e.g. HT-AMS feeding the right nozzle on an H2D), the slicer emits e.g. `['Standard#1', 'Standard#0']` even though the print genuinely uses both extruders. `sum(active_extruders) == 1` triggered → every filament was force-assigned to `physical_extruder_map[active_idx]`, the authoritative per-filament `group_id` was discarded, and the Filament Mapping panel showed both filaments badged **L** with the auto-match hard filter (`print_scheduler.py` `_compute_ams_mapping_for_printer` ~line 1239) blocking the wrong-nozzle tray as "Type not found". Bug is **parser-side and model-agnostic** — triggers purely on 3MF data shape, not on the attached AMS hardware: regular dual-AMS H2D installs typically slice to `['Standard#1', 'Standard#1']` (sum==2) and never enter the buggy branch, which is why this bug was invisible on the most common dual-AMS setup. Physical nozzle routing was **not** affected — the actual extrude path comes from the sliced gcode + the verbatim `nozzle_mapping` from the project_file (#1780), not from this parse — so the bug surfaced as auto-match failure + wrong L/R badge, not wrong-nozzle extrusion. **Fix.** Gate the single-active shortcut on `len(distinct_group_ids) <= 1` from `slice_info.config`. The slice_info parse is hoisted above the shortcut check (and reused by Priority 1) so the gate adds zero extra I/O. When the slice contains ≥2 distinct group_ids, the shortcut skips and the existing `group_id`-based Priority 1 mapping runs. The gate only **narrows** the shortcut path — it can't widen the buggy collapse onto any previously-working slice. The same condition generalizes to H2C and any future N-nozzle printer for free (no nozzle-count branching). **Tests.** Two new cases in `TestExtractNozzleMappingFrom3MF`: `test_single_active_under_report_with_multi_group_falls_through` pins the #1825 regression (`['Standard#1','Standard#0']` + group_ids `{0,1}` → `{1:1, 2:0}` not `{1:1, 2:1}`); `test_single_active_with_single_group_still_uses_shortcut` preserves the #851 behaviour (same stats + only `group_id=0` → shortcut still fires → `{1:1, 2:1}`). Existing `test_single_active_extruder_maps_all_slots` and `test_two_active_extruders_falls_through` stay green. **Suites.** `pytest -n 30 backend/tests/unit/test_scheduler_ams_mapping.py backend/tests/unit/test_scheduler_filament_deficit.py backend/tests/unit/test_scheduler_filament_override.py backend/tests/unit/test_fallback_archive_mqtt_filament.py backend/tests/integration/test_archives_api.py backend/tests/integration/test_library_api.py` 272/272 green. `ruff check backend/` clean. **Scope.** Backend-only, parse layer. No DB migration. No new permission. No frontend change. The L/R-only badge limitation on 3+-nozzle printers (H2C tool-changer) called out in the report is a separate cosmetic follow-up and not part of this fix.
|
||||
|
||||
@@ -2,14 +2,23 @@
|
||||
* Tests for FolderReadmePanel (#1268).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { FolderReadmePanel } from '../../components/FolderReadmePanel';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
describe('FolderReadmePanel', () => {
|
||||
beforeEach(() => {
|
||||
// localStorage is a vi.fn() mock in setup.ts (no real persistence) and
|
||||
// calls/return values leak across tests — reset it so each test starts
|
||||
// with the collapse preference unset (expanded).
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(null);
|
||||
vi.mocked(localStorage.setItem).mockClear();
|
||||
});
|
||||
|
||||
it('renders nothing when the folder has no markdown (404)', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/library/folders/:id/readme', () =>
|
||||
@@ -55,4 +64,50 @@ describe('FolderReadmePanel', () => {
|
||||
render(<FolderReadmePanel folderId={7} />);
|
||||
expect(await screen.findByText('Truncated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses to a reopen control and hides the content, persisting the choice (#2520)', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/library/folders/:id/readme', () =>
|
||||
HttpResponse.json({
|
||||
filename: 'README.md',
|
||||
content: '# Robot model\n\nA cute robot.',
|
||||
truncated: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<FolderReadmePanel folderId={99} />);
|
||||
|
||||
// Expanded by default: content visible.
|
||||
expect(await screen.findByRole('heading', { name: 'Robot model' })).toBeInTheDocument();
|
||||
|
||||
// Collapse hides the markdown body...
|
||||
await user.click(screen.getByRole('button', { name: 'Hide README' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('heading', { name: 'Robot model' })).not.toBeInTheDocument();
|
||||
});
|
||||
// ...and offers a reopen control + persists the choice.
|
||||
expect(screen.getAllByRole('button', { name: 'Show README' }).length).toBeGreaterThan(0);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('fileManager.readmeCollapsed', '1');
|
||||
});
|
||||
|
||||
it('starts collapsed when the persisted preference is collapsed (#2520)', async () => {
|
||||
vi.mocked(localStorage.getItem).mockImplementation((k) =>
|
||||
k === 'fileManager.readmeCollapsed' ? '1' : null,
|
||||
);
|
||||
server.use(
|
||||
http.get('/api/v1/library/folders/:id/readme', () =>
|
||||
HttpResponse.json({
|
||||
filename: 'README.md',
|
||||
content: '# Robot model\n\nA cute robot.',
|
||||
truncated: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<FolderReadmePanel folderId={5} />);
|
||||
|
||||
// Reopen control is present; the markdown body is not rendered.
|
||||
expect((await screen.findAllByRole('button', { name: 'Show README' })).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('heading', { name: 'Robot model' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronUp, FileText } from 'lucide-react';
|
||||
import { FileText, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
@@ -11,14 +11,31 @@ interface FolderReadmePanelProps {
|
||||
folderId: number;
|
||||
}
|
||||
|
||||
// Persist the collapsed choice so hiding the README once keeps it hidden
|
||||
// across folder switches and page reloads (#2520 item 2).
|
||||
const COLLAPSE_STORAGE_KEY = 'fileManager.readmeCollapsed';
|
||||
|
||||
/**
|
||||
* Side panel that renders a `.md` file from the selected folder (#1268).
|
||||
* Hidden when the folder has no markdown file. Disables raw HTML and links
|
||||
* stay text-only — same posture as the print-archive note panel.
|
||||
* Markdown panel for the selected folder (#1268).
|
||||
*
|
||||
* Docks as a collapsible right-hand rail on wide screens so the README sits
|
||||
* *beside* the file list instead of pushing it down and eating vertical
|
||||
* space (#2520 item 2); on narrow screens it stacks above the list
|
||||
* (`order-first`) where the page itself scrolls. Collapsing shrinks it to a
|
||||
* thin strip (desktop) / slim bar (mobile) with a one-click reopen, and the
|
||||
* choice is persisted. Auto-hidden when the folder has no markdown. Raw HTML
|
||||
* is disabled and links stay text-only — same posture as the print-archive
|
||||
* note panel.
|
||||
*/
|
||||
export function FolderReadmePanel({ folderId }: FolderReadmePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => localStorage.getItem(COLLAPSE_STORAGE_KEY) === '1',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(COLLAPSE_STORAGE_KEY, collapsed ? '1' : '0');
|
||||
}, [collapsed]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['folder-readme', folderId],
|
||||
@@ -29,13 +46,41 @@ export function FolderReadmePanel({ folderId }: FolderReadmePanelProps) {
|
||||
|
||||
if (isLoading || error || !data) return null;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="mb-2 lg:mb-0 order-first lg:order-none lg:w-10 lg:flex-shrink-0 lg:h-full">
|
||||
{/* Mobile: a slim horizontal bar that reopens the panel. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title={t('fileManager.readme.show')}
|
||||
aria-label={t('fileManager.readme.show')}
|
||||
className="flex lg:hidden w-full items-center gap-2 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark/40 transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-bambu-green flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-white truncate">{data.filename}</span>
|
||||
<PanelRightOpen className="w-4 h-4 text-bambu-gray flex-shrink-0 ml-auto" />
|
||||
</button>
|
||||
{/* Desktop: a thin vertical strip with a reopen button. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title={t('fileManager.readme.show')}
|
||||
aria-label={t('fileManager.readme.show')}
|
||||
className="hidden lg:flex h-full w-10 flex-col items-center gap-2 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark/40 transition-colors"
|
||||
>
|
||||
<PanelRightOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-bambu-gray [writing-mode:vertical-rl] rotate-180 select-none">
|
||||
{t('fileManager.readme.label')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left hover:bg-bambu-dark/40 transition-colors"
|
||||
>
|
||||
<div className="mb-4 lg:mb-0 order-first lg:order-none lg:w-80 xl:w-96 lg:flex-shrink-0 lg:h-full flex flex-col bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-4 h-4 text-bambu-green flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-white truncate" title={data.filename}>
|
||||
@@ -47,55 +92,57 @@ export function FolderReadmePanel({ folderId }: FolderReadmePanelProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<ChevronDown className="w-4 h-4 text-bambu-gray flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronUp className="w-4 h-4 text-bambu-gray flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-3 border-t border-bambu-dark-tertiary max-h-96 overflow-y-auto text-sm text-bambu-gray-light leading-relaxed space-y-2">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className="text-lg font-semibold text-white mt-2 mb-1">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-base font-semibold text-white mt-2 mb-1">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold text-white mt-2 mb-1">{children}</h3>,
|
||||
p: ({ children }) => <p className="my-1">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside space-y-0.5 ml-2">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside space-y-0.5 ml-2">{children}</ol>,
|
||||
li: ({ children }) => <li>{children}</li>,
|
||||
code: ({ children, ...props }) => {
|
||||
const inline = !(props as { className?: string }).className;
|
||||
return inline ? (
|
||||
<code className="px-1 py-0.5 bg-bambu-dark rounded text-xs font-mono text-bambu-green">{children}</code>
|
||||
) : (
|
||||
<code className="block p-2 bg-bambu-dark rounded text-xs font-mono text-bambu-gray-light overflow-x-auto">{children}</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => <pre className="my-2">{children}</pre>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-bambu-dark-tertiary pl-3 text-bambu-gray italic">{children}</blockquote>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-bambu-green hover:underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-xs border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => <th className="border border-bambu-dark-tertiary px-2 py-1 text-left font-semibold text-white">{children}</th>,
|
||||
td: ({ children }) => <td className="border border-bambu-dark-tertiary px-2 py-1">{children}</td>,
|
||||
hr: () => <hr className="border-bambu-dark-tertiary my-2" />,
|
||||
}}
|
||||
>
|
||||
{data.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(true)}
|
||||
title={t('fileManager.readme.hide')}
|
||||
aria-label={t('fileManager.readme.hide')}
|
||||
className="p-1 rounded text-bambu-gray hover:text-white hover:bg-bambu-dark/40 transition-colors flex-shrink-0"
|
||||
>
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-bambu-dark-tertiary flex-1 overflow-y-auto max-h-96 lg:max-h-none text-sm text-bambu-gray-light leading-relaxed space-y-2">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className="text-lg font-semibold text-white mt-2 mb-1">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-base font-semibold text-white mt-2 mb-1">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold text-white mt-2 mb-1">{children}</h3>,
|
||||
p: ({ children }) => <p className="my-1">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside space-y-0.5 ml-2">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside space-y-0.5 ml-2">{children}</ol>,
|
||||
li: ({ children }) => <li>{children}</li>,
|
||||
code: ({ children, ...props }) => {
|
||||
const inline = !(props as { className?: string }).className;
|
||||
return inline ? (
|
||||
<code className="px-1 py-0.5 bg-bambu-dark rounded text-xs font-mono text-bambu-green">{children}</code>
|
||||
) : (
|
||||
<code className="block p-2 bg-bambu-dark rounded text-xs font-mono text-bambu-gray-light overflow-x-auto">{children}</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => <pre className="my-2">{children}</pre>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-bambu-dark-tertiary pl-3 text-bambu-gray italic">{children}</blockquote>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-bambu-green hover:underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-xs border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => <th className="border border-bambu-dark-tertiary px-2 py-1 text-left font-semibold text-white">{children}</th>,
|
||||
td: ({ children }) => <td className="border border-bambu-dark-tertiary px-2 py-1">{children}</td>,
|
||||
hr: () => <hr className="border-bambu-dark-tertiary my-2" />,
|
||||
}}
|
||||
>
|
||||
{data.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3582,6 +3582,9 @@ export default {
|
||||
searchSubfoldersHint: 'Inklusive Unterordner',
|
||||
readme: {
|
||||
truncated: 'Gekürzt',
|
||||
show: 'README anzeigen',
|
||||
hide: 'README ausblenden',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: 'Tags',
|
||||
|
||||
@@ -3611,6 +3611,9 @@ export default {
|
||||
searchSubfoldersHint: 'Including subfolders',
|
||||
readme: {
|
||||
truncated: 'Truncated',
|
||||
show: 'Show README',
|
||||
hide: 'Hide README',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: 'Tags',
|
||||
|
||||
@@ -3585,6 +3585,9 @@ export default {
|
||||
searchSubfoldersHint: 'Incluyendo subcarpetas',
|
||||
readme: {
|
||||
truncated: 'Truncado',
|
||||
show: 'Mostrar README',
|
||||
hide: 'Ocultar README',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: 'Etiquetas',
|
||||
|
||||
@@ -3571,6 +3571,9 @@ export default {
|
||||
searchSubfoldersHint: 'Sous-dossiers inclus',
|
||||
readme: {
|
||||
truncated: 'Tronqué',
|
||||
show: 'Afficher le README',
|
||||
hide: 'Masquer le README',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: 'Étiquettes',
|
||||
|
||||
@@ -3570,6 +3570,9 @@ export default {
|
||||
searchSubfoldersHint: 'Sottocartelle incluse',
|
||||
readme: {
|
||||
truncated: 'Troncato',
|
||||
show: 'Mostra README',
|
||||
hide: 'Nascondi README',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: 'Etichette',
|
||||
|
||||
@@ -3582,6 +3582,9 @@ export default {
|
||||
searchSubfoldersHint: 'サブフォルダーを含む',
|
||||
readme: {
|
||||
truncated: '切り詰め',
|
||||
show: 'READMEを表示',
|
||||
hide: 'READMEを非表示',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: 'タグ',
|
||||
|
||||
@@ -3394,6 +3394,9 @@ export default {
|
||||
searchSubfoldersHint: '하위 폴더 포함',
|
||||
readme: {
|
||||
truncated: '잘림',
|
||||
show: 'README 표시',
|
||||
hide: 'README 숨기기',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: '태그',
|
||||
|
||||
@@ -3570,6 +3570,9 @@ export default {
|
||||
searchSubfoldersHint: 'Incluindo subpastas',
|
||||
readme: {
|
||||
truncated: 'Truncado',
|
||||
show: 'Mostrar README',
|
||||
hide: 'Ocultar README',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: 'Tags',
|
||||
|
||||
@@ -3578,6 +3578,9 @@ export default {
|
||||
searchSubfoldersHint: 'Alt klasörler dahil',
|
||||
readme: {
|
||||
truncated: 'Kısaltıldı',
|
||||
show: 'README’yi göster',
|
||||
hide: 'README’yi gizle',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: 'Etiketler',
|
||||
|
||||
@@ -3570,6 +3570,9 @@ export default {
|
||||
searchSubfoldersHint: '包含子文件夹',
|
||||
readme: {
|
||||
truncated: '已截断',
|
||||
show: '显示 README',
|
||||
hide: '隐藏 README',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: '标签',
|
||||
|
||||
@@ -3570,6 +3570,9 @@ export default {
|
||||
searchSubfoldersHint: '包含子資料夾',
|
||||
readme: {
|
||||
truncated: '已截斷',
|
||||
show: '顯示 README',
|
||||
hide: '隱藏 README',
|
||||
label: 'README',
|
||||
},
|
||||
tags: {
|
||||
title: '標籤',
|
||||
|
||||
@@ -1935,11 +1935,13 @@ export function FileManagerPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files area */}
|
||||
{/* Files area + README rail (#2520 item 2). On wide screens the
|
||||
README docks as a collapsible right-hand column (rendered after
|
||||
the files column, below) so it no longer steals vertical space
|
||||
from the file list; on narrow screens it stacks above the list
|
||||
via `order-first` and the page itself scrolls. */}
|
||||
<div className="flex-1 flex flex-col lg:flex-row min-w-0 min-h-0 gap-4 lg:gap-6">
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
{/* Markdown description panel (#1268) — auto-hides if the folder
|
||||
has no README/description.md so non-users pay no UI cost. */}
|
||||
{selectedFolderId !== null && <FolderReadmePanel folderId={selectedFolderId} />}
|
||||
{/* Tag filter rail (#1268). Lists every catalog tag as a togglable
|
||||
chip — active chips are filled green and show an X, inactive
|
||||
chips are outlined and toggle ON when clicked. Clicking an active
|
||||
@@ -2557,6 +2559,10 @@ export function FileManagerPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* README rail — collapsible right column on lg+, stacks on top
|
||||
on mobile. See the files-area wrapper comment above (#2520). */}
|
||||
{selectedFolderId !== null && <FolderReadmePanel folderId={selectedFolderId} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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-CvBwRD12.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DvjR9OL3.css">
|
||||
<script type="module" crossorigin src="/assets/index-BC5UbFyP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-blSspT6K.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user