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

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

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


+ 56 - 1
frontend/src/__tests__/components/FolderReadmePanel.test.tsx

@@ -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();
+  });
 });

+ 108 - 61
frontend/src/components/FolderReadmePanel.tsx

@@ -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>
   );
 }

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

@@ -3582,6 +3582,9 @@ export default {
     searchSubfoldersHint: 'Inklusive Unterordner',
     readme: {
       truncated: 'Gekürzt',
+      show: 'README anzeigen',
+      hide: 'README ausblenden',
+      label: 'README',
     },
     tags: {
       title: 'Tags',

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

@@ -3611,6 +3611,9 @@ export default {
     searchSubfoldersHint: 'Including subfolders',
     readme: {
       truncated: 'Truncated',
+      show: 'Show README',
+      hide: 'Hide README',
+      label: 'README',
     },
     tags: {
       title: 'Tags',

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

@@ -3585,6 +3585,9 @@ export default {
     searchSubfoldersHint: 'Incluyendo subcarpetas',
     readme: {
       truncated: 'Truncado',
+      show: 'Mostrar README',
+      hide: 'Ocultar README',
+      label: 'README',
     },
     tags: {
       title: 'Etiquetas',

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

@@ -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',

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

@@ -3570,6 +3570,9 @@ export default {
     searchSubfoldersHint: 'Sottocartelle incluse',
     readme: {
       truncated: 'Troncato',
+      show: 'Mostra README',
+      hide: 'Nascondi README',
+      label: 'README',
     },
     tags: {
       title: 'Etichette',

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

@@ -3582,6 +3582,9 @@ export default {
     searchSubfoldersHint: 'サブフォルダーを含む',
     readme: {
       truncated: '切り詰め',
+      show: 'READMEを表示',
+      hide: 'READMEを非表示',
+      label: 'README',
     },
     tags: {
       title: 'タグ',

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

@@ -3394,6 +3394,9 @@ export default {
     searchSubfoldersHint: '하위 폴더 포함',
     readme: {
       truncated: '잘림',
+      show: 'README 표시',
+      hide: 'README 숨기기',
+      label: 'README',
     },
     tags: {
       title: '태그',

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

@@ -3570,6 +3570,9 @@ export default {
     searchSubfoldersHint: 'Incluindo subpastas',
     readme: {
       truncated: 'Truncado',
+      show: 'Mostrar README',
+      hide: 'Ocultar README',
+      label: 'README',
     },
     tags: {
       title: 'Tags',

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

@@ -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',

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

@@ -3570,6 +3570,9 @@ export default {
     searchSubfoldersHint: '包含子文件夹',
     readme: {
       truncated: '已截断',
+      show: '显示 README',
+      hide: '隐藏 README',
+      label: 'README',
     },
     tags: {
       title: '标签',

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

@@ -3570,6 +3570,9 @@ export default {
     searchSubfoldersHint: '包含子資料夾',
     readme: {
       truncated: '已截斷',
+      show: '顯示 README',
+      hide: '隱藏 README',
+      label: 'README',
     },
     tags: {
       title: '標籤',

+ 10 - 4
frontend/src/pages/FileManagerPage.tsx

@@ -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 */}

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


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


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-blSspT6K.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-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>

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