Browse Source

File Manager: a third view mode, Miller columns (#3190)

Thomansky 10 hours ago
parent
commit
413633d811
100 changed files with 1789 additions and 198 deletions
  1. 765 2
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  2. 40 0
      frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx
  3. 42 0
      frontend/src/__tests__/pages/TouchReachableActions.test.tsx
  4. 9 5
      frontend/src/components/ContextMenu.tsx
  5. 2 0
      frontend/src/i18n/locales/de.ts
  6. 2 0
      frontend/src/i18n/locales/en.ts
  7. 2 0
      frontend/src/i18n/locales/es.ts
  8. 2 0
      frontend/src/i18n/locales/fr.ts
  9. 2 0
      frontend/src/i18n/locales/it.ts
  10. 2 0
      frontend/src/i18n/locales/ja.ts
  11. 2 0
      frontend/src/i18n/locales/ko.ts
  12. 2 0
      frontend/src/i18n/locales/nl.ts
  13. 2 0
      frontend/src/i18n/locales/pt-BR.ts
  14. 2 0
      frontend/src/i18n/locales/ru.ts
  15. 2 0
      frontend/src/i18n/locales/sv.ts
  16. 2 0
      frontend/src/i18n/locales/tr.ts
  17. 2 0
      frontend/src/i18n/locales/uk.ts
  18. 2 0
      frontend/src/i18n/locales/zh-CN.ts
  19. 2 0
      frontend/src/i18n/locales/zh-TW.ts
  20. 903 179
      frontend/src/pages/FileManagerPage.tsx
  21. 0 0
      static/assets/ImagePreviewModal-BNS3PW0l.js
  22. 0 1
      static/assets/PdfPreviewModal-BvXcfvYe.js
  23. 0 1
      static/assets/SpreadsheetPreviewModal-BNJIB5jH.js
  24. 0 1
      static/assets/chunk-aKtaBQYM.js
  25. 0 1
      static/assets/index-CV30AFBJ.js
  26. 0 1
      static/assets/index-CyW_3l56.css
  27. BIN
      static/assets/occt-import-js-BhHfLpto.wasm
  28. 0 0
      static/assets/papaparse.min-CR43Mv99.js
  29. 0 0
      static/assets/pdf-BsRY7zBD.js
  30. 0 0
      static/assets/pdf.worker.min-0idpi3xP.js
  31. 0 1
      static/assets/pdf.worker.min-DpOxPwGP.js
  32. BIN
      static/assets/pdfjs/cmaps/78-EUC-H.bcmap
  33. BIN
      static/assets/pdfjs/cmaps/78-EUC-V.bcmap
  34. BIN
      static/assets/pdfjs/cmaps/78-H.bcmap
  35. BIN
      static/assets/pdfjs/cmaps/78-RKSJ-H.bcmap
  36. BIN
      static/assets/pdfjs/cmaps/78-RKSJ-V.bcmap
  37. BIN
      static/assets/pdfjs/cmaps/78-V.bcmap
  38. BIN
      static/assets/pdfjs/cmaps/78ms-RKSJ-H.bcmap
  39. BIN
      static/assets/pdfjs/cmaps/78ms-RKSJ-V.bcmap
  40. BIN
      static/assets/pdfjs/cmaps/83pv-RKSJ-H.bcmap
  41. BIN
      static/assets/pdfjs/cmaps/90ms-RKSJ-H.bcmap
  42. BIN
      static/assets/pdfjs/cmaps/90ms-RKSJ-V.bcmap
  43. BIN
      static/assets/pdfjs/cmaps/90msp-RKSJ-H.bcmap
  44. BIN
      static/assets/pdfjs/cmaps/90msp-RKSJ-V.bcmap
  45. BIN
      static/assets/pdfjs/cmaps/90pv-RKSJ-H.bcmap
  46. BIN
      static/assets/pdfjs/cmaps/90pv-RKSJ-V.bcmap
  47. BIN
      static/assets/pdfjs/cmaps/Add-H.bcmap
  48. BIN
      static/assets/pdfjs/cmaps/Add-RKSJ-H.bcmap
  49. BIN
      static/assets/pdfjs/cmaps/Add-RKSJ-V.bcmap
  50. BIN
      static/assets/pdfjs/cmaps/Add-V.bcmap
  51. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-0.bcmap
  52. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-1.bcmap
  53. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-2.bcmap
  54. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-3.bcmap
  55. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-4.bcmap
  56. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-5.bcmap
  57. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-6.bcmap
  58. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap
  59. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-0.bcmap
  60. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-1.bcmap
  61. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-2.bcmap
  62. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-3.bcmap
  63. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-4.bcmap
  64. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-5.bcmap
  65. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap
  66. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-0.bcmap
  67. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-1.bcmap
  68. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-2.bcmap
  69. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-3.bcmap
  70. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-4.bcmap
  71. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-5.bcmap
  72. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-6.bcmap
  73. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap
  74. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-0.bcmap
  75. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-1.bcmap
  76. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-2.bcmap
  77. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap
  78. BIN
      static/assets/pdfjs/cmaps/B5-H.bcmap
  79. BIN
      static/assets/pdfjs/cmaps/B5-V.bcmap
  80. BIN
      static/assets/pdfjs/cmaps/B5pc-H.bcmap
  81. BIN
      static/assets/pdfjs/cmaps/B5pc-V.bcmap
  82. BIN
      static/assets/pdfjs/cmaps/CNS-EUC-H.bcmap
  83. BIN
      static/assets/pdfjs/cmaps/CNS-EUC-V.bcmap
  84. BIN
      static/assets/pdfjs/cmaps/CNS1-H.bcmap
  85. BIN
      static/assets/pdfjs/cmaps/CNS1-V.bcmap
  86. BIN
      static/assets/pdfjs/cmaps/CNS2-H.bcmap
  87. 0 3
      static/assets/pdfjs/cmaps/CNS2-V.bcmap
  88. BIN
      static/assets/pdfjs/cmaps/ETHK-B5-H.bcmap
  89. BIN
      static/assets/pdfjs/cmaps/ETHK-B5-V.bcmap
  90. BIN
      static/assets/pdfjs/cmaps/ETen-B5-H.bcmap
  91. BIN
      static/assets/pdfjs/cmaps/ETen-B5-V.bcmap
  92. 0 3
      static/assets/pdfjs/cmaps/ETenms-B5-H.bcmap
  93. BIN
      static/assets/pdfjs/cmaps/ETenms-B5-V.bcmap
  94. BIN
      static/assets/pdfjs/cmaps/EUC-H.bcmap
  95. BIN
      static/assets/pdfjs/cmaps/EUC-V.bcmap
  96. BIN
      static/assets/pdfjs/cmaps/Ext-H.bcmap
  97. BIN
      static/assets/pdfjs/cmaps/Ext-RKSJ-H.bcmap
  98. BIN
      static/assets/pdfjs/cmaps/Ext-RKSJ-V.bcmap
  99. BIN
      static/assets/pdfjs/cmaps/Ext-V.bcmap
  100. BIN
      static/assets/pdfjs/cmaps/GB-EUC-H.bcmap

+ 765 - 2
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -27,6 +27,11 @@ vi.mock('../../components/SliceModal', () => ({
   ),
 }));
 
+// The viewer pulls in three.js; the tests only care whether it opened.
+vi.mock('../../components/ModelViewerModal', () => ({
+  ModelViewerModal: ({ title }: { title: string }) => <div data-testid="model-viewer-modal">{title}</div>,
+}));
+
 // Mock data
 const mockFolders = [
   {
@@ -120,6 +125,84 @@ const mockFiles = [
   },
 ];
 
+// Per-folder contents. The columns view lists every level's files, so each
+// folder needs its own set — otherwise the same name shows up in two columns
+// and the queries below cannot tell them apart.
+const mockFolderFiles: Record<string, Record<string, unknown>[]> = {
+  '1': [
+    {
+      id: 11,
+      filename: 'spacer.3mf',
+      file_path: '/library/functional/spacer.3mf',
+      file_size: 131072,
+      file_type: '3mf',
+      folder_id: 1,
+      thumbnail_path: null,
+      print_name: 'Spacer',
+      print_time_seconds: 900,
+      print_count: 0,
+      duplicate_count: 0,
+      created_at: '2024-02-01T00:00:00Z',
+    },
+  ],
+  '2': [
+    {
+      id: 12,
+      filename: 'clamp.stl',
+      file_path: '/library/functional/brackets/clamp.stl',
+      file_size: 65536,
+      file_type: 'stl',
+      folder_id: 2,
+      thumbnail_path: null,
+      print_name: null,
+      print_time_seconds: null,
+      print_count: 0,
+      duplicate_count: 0,
+      created_at: '2024-02-02T00:00:00Z',
+    },
+    {
+      id: 13,
+      filename: 'hinge.3mf',
+      file_path: '/library/functional/brackets/hinge.3mf',
+      file_size: 98304,
+      file_type: '3mf',
+      folder_id: 2,
+      thumbnail_path: null,
+      print_name: 'Hinge',
+      print_time_seconds: 1200,
+      print_count: 1,
+      duplicate_count: 0,
+      created_at: '2024-02-03T00:00:00Z',
+    },
+  ],
+  '3': [
+    {
+      id: 14,
+      filename: 'vase.3mf',
+      file_path: '/library/art/vase.3mf',
+      file_size: 262144,
+      file_type: '3mf',
+      folder_id: 3,
+      thumbnail_path: null,
+      print_name: 'Vase',
+      print_time_seconds: 5400,
+      print_count: 0,
+      duplicate_count: 0,
+      created_at: '2024-02-04T00:00:00Z',
+    },
+  ],
+};
+
+// A request carrying folder_id wants that folder's own files. Without one,
+// include_root decides as the server does: true is the root level (the files in
+// no folder), false the whole library ("All Files", every folder included).
+const filesForRequest = (request: Request) => {
+  const params = new URL(request.url).searchParams;
+  const folderId = params.get('folder_id');
+  if (folderId) return mockFolderFiles[folderId] ?? [];
+  return params.get('include_root') === 'true' ? mockFiles : [...mockFiles, ...Object.values(mockFolderFiles).flat()];
+};
+
 const mockStats = {
   total_files: 10,
   total_folders: 3,
@@ -137,8 +220,8 @@ describe('FileManagerPage', () => {
       http.get('/api/v1/library/folders', () => {
         return HttpResponse.json(mockFolders);
       }),
-      http.get('/api/v1/library/files', () => {
-        return HttpResponse.json(mockFiles);
+      http.get('/api/v1/library/files', ({ request }) => {
+        return HttpResponse.json(filesForRequest(request));
       }),
       http.get('/api/v1/library/stats', () => {
         return HttpResponse.json(mockStats);
@@ -365,6 +448,666 @@ describe('FileManagerPage', () => {
       // Verify files are still displayed after toggling
       expect(screen.getByText('Benchy')).toBeInTheDocument();
     });
+
+    it('can switch to columns view and descend through folder columns', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByTitle('Column view'));
+
+      // Scoped to the columns pane — the folder names also live in the tree
+      // sidebar, so unscoped queries would double-match.
+      const columns = within(screen.getByTestId('columns-view'));
+      expect(columns.getByText('Functional Parts')).toBeInTheDocument();
+      expect(columns.getByText('Art Projects')).toBeInTheDocument();
+      // Root files render in the files pane.
+      expect(columns.getByText('Benchy')).toBeInTheDocument();
+
+      // Descend: clicking a folder opens its child column, and the level the
+      // user just left keeps listing its own files.
+      await user.click(columns.getByText('Functional Parts'));
+      await waitFor(() => {
+        expect(columns.getByText('Brackets')).toBeInTheDocument();
+      });
+      await waitFor(() => {
+        expect(columns.getByText('Benchy')).toBeInTheDocument();
+      });
+      expect(within(screen.getByTestId('columns-files-pane')).getByText('Spacer')).toBeInTheDocument();
+
+      // Leaf folder: selecting it adds no further column and the pane swaps to
+      // its contents.
+      await user.click(columns.getByText('Brackets'));
+      await waitFor(() => {
+        expect(within(screen.getByTestId('columns-files-pane')).getByText('clamp.stl')).toBeInTheDocument();
+      });
+    });
+
+    it('hides folder columns while a search filters across folders', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      expect(columns.getByText('Functional Parts')).toBeInTheDocument();
+
+      await user.type(screen.getByPlaceholderText('Search files...'), 'benchy');
+
+      // Search results span every folder — the per-level columns disappear,
+      // the matching file stays.
+      await waitFor(() => {
+        expect(columns.queryByText('Functional Parts')).not.toBeInTheDocument();
+      });
+      expect(columns.getByText('Benchy')).toBeInTheDocument();
+    });
+
+    it('navigates the columns with the arrow keys', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      // The highlight sits on the row, which also hosts the folder kebab —
+      // the name button itself is unstyled.
+      const activeClassOf = (name: string) =>
+        columns.getByText(name).closest('[data-folder-id]')?.className ?? '';
+
+      // The pane auto-focuses when the view opens, so keys work immediately.
+      // Down from the root selects the first folder (name-sorted: Art
+      // Projects), Down again moves to its sibling.
+      await user.keyboard('{ArrowDown}');
+      await waitFor(() => {
+        expect(activeClassOf('Art Projects')).toContain('bg-bambu-green/20');
+      });
+      await user.keyboard('{ArrowDown}');
+      await waitFor(() => {
+        expect(activeClassOf('Functional Parts')).toContain('bg-bambu-green/20');
+      });
+
+      // Right descends into the first child folder…
+      await user.keyboard('{ArrowRight}');
+      await waitFor(() => {
+        expect(activeClassOf('Brackets')).toContain('bg-bambu-green/20');
+      });
+      // …and Left climbs back up to the parent.
+      await user.keyboard('{ArrowLeft}');
+      await waitFor(() => {
+        expect(activeClassOf('Functional Parts')).toContain('bg-bambu-green/20');
+      });
+
+      // Right on a folder without child folders moves the focus into the
+      // files pane; Down walks the file rows. Wait for the refetched file
+      // list before the second Right — during the folder switch the pane can
+      // be momentarily empty.
+      await user.keyboard('{ArrowRight}');
+      await waitFor(() => {
+        expect(activeClassOf('Brackets')).toContain('bg-bambu-green/20');
+        expect(columns.getByText('clamp.stl')).toBeInTheDocument();
+      });
+      await user.keyboard('{ArrowRight}');
+      await waitFor(() => {
+        expect(columns.getByText('clamp.stl').closest('[title="clamp.stl"]')?.className).toContain('ring-1');
+      });
+      await user.keyboard('{ArrowDown}');
+      await waitFor(() => {
+        expect(columns.getByText('Hinge').closest('[title="Hinge"]')?.className).toContain('ring-1');
+      });
+      // Left leaves the files pane again.
+      await user.keyboard('{ArrowLeft}');
+      await waitFor(() => {
+        expect(columns.getByText('Hinge').closest('[title="Hinge"]')?.className).not.toContain('ring-1');
+      });
+    });
+
+    it('keeps the columns pane mounted while a folder\'s files load', async () => {
+      const user = userEvent.setup();
+      // First files request (initial load) resolves immediately, later ones
+      // (the refetch a keyboard descent triggers) stay pending briefly.
+      let filesCalls = 0;
+      server.use(
+        http.get('/api/v1/library/files', async ({ request }) => {
+          filesCalls += 1;
+          if (filesCalls > 1) await new Promise((resolve) => setTimeout(resolve, 150));
+          return HttpResponse.json(filesForRequest(request));
+        })
+      );
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+      await user.click(screen.getByTitle('Column view'));
+      expect(screen.getByTestId('columns-view')).toBeInTheDocument();
+
+      // Descend while the folder's file list is still in flight. Swapping
+      // the pane for the global spinner here would strip its tabindex and,
+      // in real browsers, drop keyboard focus to <body> for good.
+      await user.keyboard('{ArrowDown}');
+      expect(screen.getByTestId('columns-view')).toBeInTheDocument();
+
+      await waitFor(() => {
+        expect(within(screen.getByTestId('columns-files-pane')).getByText('Vase')).toBeInTheDocument();
+      });
+    });
+
+    // #3020 follow-up: the files pane carries the list row's icon strip, so
+    // the columns view offers every per-file action in the same order.
+    const listRow = (name: string) => screen.getByText(name).closest('div[class*="cursor-pointer"]') as HTMLElement;
+    const actionTitlesOf = (row: HTMLElement) =>
+      Array.from(row.querySelector('[data-file-actions]')!.querySelectorAll('button')).map((b) => b.title);
+
+    it('offers the same per-file actions as the list view, in the same order', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('List view'));
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+      const listBenchy = actionTitlesOf(listRow('Benchy'));
+      const listBracket = actionTitlesOf(listRow('bracket.stl'));
+      expect(listBenchy).toEqual(['Print', '3D Preview', 'Download', 'File details', 'Rename', 'Delete']);
+      expect(listBracket).toContain('Generate Thumbnail');
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      await waitFor(() => expect(columns.getByText('Benchy')).toBeInTheDocument());
+      expect(actionTitlesOf(columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement)).toEqual(listBenchy);
+      expect(actionTitlesOf(columns.getByText('bracket.stl').closest('[data-file-id]') as HTMLElement)).toEqual(listBracket);
+    });
+
+    it('fires a row action without toggling the row selection', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      const row = columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement;
+
+      await user.click(within(row).getByTitle('Rename'));
+      // The rename modal edits the base name; the extension is fixed.
+      expect(await screen.findByDisplayValue('benchy')).toBeInTheDocument();
+      expect(row.className).not.toContain('bg-bambu-green/10');
+
+      await user.click(screen.getByText('Cancel'));
+      await user.click(within(row).getByTitle('Delete'));
+      expect(await screen.findByText('Delete File')).toBeInTheDocument();
+    });
+
+    it('reveals the focused row\'s actions and keeps the rest touch-reachable (#2865)', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      const stripWrapper = (name: string) =>
+        (columns.getByText(name).closest('[data-file-id]') as HTMLElement).querySelector('[data-file-actions]')!.parentElement!;
+
+      // Right from the root moves the focus into the files pane.
+      await user.keyboard('{ArrowRight}');
+      await waitFor(() => expect(stripWrapper('Benchy').className).not.toContain('opacity-0'));
+
+      // Other rows hide the strip only for pointers that can hover — never a
+      // bare opacity-0 — and stay out of the Tab order.
+      expect(stripWrapper('bracket.stl').className).not.toMatch(/(^|\s)opacity-0(\s|$)/);
+      expect(stripWrapper('bracket.stl').className).toContain('can-hover:opacity-0');
+      expect(within(stripWrapper('bracket.stl')).getByTitle('Download')).toHaveAttribute('tabindex', '-1');
+      expect(within(stripWrapper('Benchy')).getByTitle('Download')).toHaveAttribute('tabindex', '0');
+
+      // Tab from the pane lands on the focused row's first action.
+      await user.tab();
+      expect(document.activeElement).toBe(within(stripWrapper('Benchy')).getByTitle('Print'));
+    });
+
+    it('reaches and triggers the focused file\'s actions from the keyboard', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      await user.keyboard('{ArrowRight}');
+      await waitFor(() => {
+        expect(columns.getByText('Benchy').closest('[title="Benchy"]')?.className).toContain('ring-1');
+      });
+      const row = columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement;
+
+      // The context-menu key jumps into the strip; Left/Right walk it.
+      await user.keyboard('{ContextMenu}');
+      expect(document.activeElement).toBe(within(row).getByTitle('Print'));
+      await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}');
+      expect(document.activeElement).toBe(within(row).getByTitle('File details'));
+      await user.keyboard('{ArrowLeft}');
+      expect(document.activeElement).toBe(within(row).getByTitle('Download'));
+
+      // Escape hands focus back to the pane, so the arrows walk rows again.
+      await user.keyboard('{Escape}');
+      expect(document.activeElement).toBe(screen.getByTestId('columns-view'));
+      await user.keyboard('{ArrowDown}');
+      await waitFor(() => {
+        expect(columns.getByText('bracket.stl').closest('[title="bracket.stl"]')?.className).toContain('ring-1');
+      });
+
+      // Enter on a strip button activates that button, not the row.
+      await user.keyboard('{Shift>}{F10}{/Shift}');
+      const bracketRow = columns.getByText('bracket.stl').closest('[data-file-id]') as HTMLElement;
+      expect(document.activeElement?.closest('[data-file-id]')).toBe(bracketRow);
+      within(bracketRow).getByTitle('Rename').focus();
+      await user.keyboard('{Enter}');
+      // .stl is not a split-off extension, so the whole name is editable.
+      expect(await screen.findByDisplayValue('bracket.stl')).toBeInTheDocument();
+    });
+
+    it('offers the tree\'s folder actions on folder rows', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      const row = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
+      const kebab = within(row).getByTitle('Actions');
+      // Unselected row: kebab hidden only for pointers that can hover (#2865).
+      expect(kebab.closest('.flex-shrink-0')!.className).toContain('can-hover:opacity-0');
+
+      await user.click(kebab);
+      expect(within(row).getByRole('button', { name: 'Rename' })).toBeInTheDocument();
+      // Art Projects is linked to a project, so the link entry offers a change.
+      expect(within(row).getByRole('button', { name: 'Change Link...' })).toBeInTheDocument();
+      expect(within(row).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
+      // Opening the menu did not select the folder.
+      expect(row.className).not.toContain('bg-bambu-green/20');
+
+      await user.click(within(row).getByRole('button', { name: 'Rename' }));
+      expect(await screen.findByDisplayValue('Art Projects')).toBeInTheDocument();
+    });
+
+    it('opens the selected folder\'s kebab from the keyboard', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      await user.keyboard('{ArrowDown}');
+      const row = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
+      await waitFor(() => expect(row.className).toContain('bg-bambu-green/20'));
+      // The selected row's kebab is the only folder control in the Tab order.
+      expect(within(row).getByTitle('Actions')).toHaveAttribute('tabindex', '0');
+      expect(
+        within(columns.getByText('Functional Parts').closest('[data-folder-id]') as HTMLElement).getByTitle('Actions'),
+      ).toHaveAttribute('tabindex', '-1');
+
+      await user.keyboard('{ContextMenu}');
+      expect(within(row).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
+    });
+
+    it('closes the folder kebab menu on a second click and keeps it opaque while open', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      const row = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
+      const kebab = within(row).getByTitle('Actions');
+      const wrapper = kebab.closest('[data-folder-actions]') as HTMLElement;
+      expect(wrapper.className).toContain('can-hover:opacity-0');
+
+      await user.click(kebab);
+      expect(within(row).getByRole('button', { name: 'Rename' })).toBeInTheDocument();
+      // The menu is a descendant of the hover-revealed wrapper: while it is
+      // open the wrapper must not depend on hover/focus to stay visible.
+      expect(wrapper.className).not.toContain('opacity-0');
+
+      await user.click(kebab);
+      expect(within(row).queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument();
+      expect(wrapper.className).toContain('can-hover:opacity-0');
+    });
+
+    it('hands focus back to the pane after the folder kebab', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      await user.keyboard('{ArrowDown}');
+      const artRow = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
+      const functionalRow = columns.getByText('Functional Parts').closest('[data-folder-id]') as HTMLElement;
+      await waitFor(() => expect(artRow.className).toContain('bg-bambu-green/20'));
+
+      // Escape closes the menu and returns focus to the pane, so the arrows
+      // keep walking rows instead of dying on the kebab.
+      await user.keyboard('{ContextMenu}');
+      expect(within(artRow).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
+      expect(document.activeElement).toBe(within(artRow).getByTitle('Actions'));
+      await user.keyboard('{Escape}');
+      expect(within(artRow).queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
+      expect(document.activeElement).toBe(screen.getByTestId('columns-view'));
+      await user.keyboard('{ArrowDown}');
+      await waitFor(() => expect(functionalRow.className).toContain('bg-bambu-green/20'));
+      await user.keyboard('{ArrowUp}');
+      await waitFor(() => expect(artRow.className).toContain('bg-bambu-green/20'));
+
+      // Down straight out of an open menu closes it and moves the selection.
+      await user.keyboard('{ContextMenu}');
+      expect(within(artRow).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
+      await user.keyboard('{ArrowDown}');
+      expect(within(artRow).queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
+      await waitFor(() => expect(functionalRow.className).toContain('bg-bambu-green/20'));
+      expect(document.activeElement).toBe(screen.getByTestId('columns-view'));
+
+      // Activating an entry with Enter must not strand focus on <body>.
+      await user.keyboard('{ContextMenu}');
+      await user.tab();
+      expect(document.activeElement).toBe(within(functionalRow).getByRole('button', { name: 'Rename' }));
+      await user.keyboard('{Enter}');
+      expect(await screen.findByDisplayValue('Functional Parts')).toBeInTheDocument();
+      expect(document.activeElement).not.toBe(document.body);
+    });
+
+    it('does not open the row preview when a strip icon is double-clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      const row = columns.getByText('bracket.stl').closest('[data-file-id]') as HTMLElement;
+
+      await user.dblClick(within(row).getByTitle('Rename'));
+      expect(await screen.findByDisplayValue('bracket.stl')).toBeInTheDocument();
+      expect(screen.queryByTestId('model-viewer-modal')).not.toBeInTheDocument();
+
+      // The row itself still opens the viewer.
+      await user.click(screen.getByText('Cancel'));
+      await user.dblClick(columns.getByText('bracket.stl'));
+      expect(await screen.findByTestId('model-viewer-modal')).toBeInTheDocument();
+    });
+
+    // Every column is that level's contents, folders AND files. Before this a
+    // folder holding files but no subfolders looked empty until it was the
+    // selection.
+    const descendToBrackets = async (user: ReturnType<typeof userEvent.setup>) => {
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      await user.click(columns.getByText('Functional Parts'));
+      await user.click(await columns.findByText('Brackets'));
+      return columns;
+    };
+
+    it('lists an intermediate level\'s folders and its files in the same column', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+
+      const level = within(screen.getByTestId('columns-level-folder-1'));
+      expect(level.getByText('Brackets')).toBeInTheDocument();
+      await waitFor(() => expect(level.getByText('Spacer')).toBeInTheDocument());
+
+      // The root column lists the files that sit in no folder at all.
+      const root = within(screen.getByTestId('columns-level-root'));
+      await waitFor(() => expect(root.getByText('Benchy')).toBeInTheDocument());
+      expect(root.getByText('Functional Parts')).toBeInTheDocument();
+    });
+
+    it('focuses a file in an intermediate column without moving the folder selection', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+      const pane = within(screen.getByTestId('columns-files-pane'));
+      await waitFor(() => expect(pane.getByText('clamp.stl')).toBeInTheDocument());
+
+      const level = within(screen.getByTestId('columns-level-folder-1'));
+      await user.click(await level.findByText('Spacer'));
+
+      expect(level.getByText('Spacer').closest('[data-file-id]')?.className).toContain('ring-1');
+      // Brackets is still the selection: its own files still fill the pane.
+      expect(pane.getByText('clamp.stl')).toBeInTheDocument();
+      expect(level.getByText('Brackets').closest('[data-folder-id]')?.className).toContain('bg-bambu-green/20');
+    });
+
+    it('opens the preview on double-click from an intermediate column', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+      const level = within(screen.getByTestId('columns-level-folder-1'));
+      await user.dblClick(await level.findByText('Spacer'));
+
+      expect(await screen.findByTestId('model-viewer-modal')).toBeInTheDocument();
+    });
+
+    it('keeps a level\'s folders visible while its files are still loading', async () => {
+      const user = userEvent.setup();
+      server.use(
+        http.get('/api/v1/library/files', async ({ request }) => {
+          if (new URL(request.url).searchParams.get('folder_id') === '1') {
+            await new Promise((resolve) => setTimeout(resolve, 200));
+          }
+          return HttpResponse.json(filesForRequest(request));
+        })
+      );
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+
+      // The column paints its folders straight away and marks the pending file
+      // list instead of blocking.
+      const level = within(screen.getByTestId('columns-level-folder-1'));
+      expect(level.getByText('Brackets')).toBeInTheDocument();
+      expect(level.getByText('…')).toBeInTheDocument();
+      await waitFor(() => expect(level.getByText('Spacer')).toBeInTheDocument());
+    });
+
+    it('leaves the rightmost pane as a leaf folder\'s contents', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+      await user.click(columns.getByText('Art Projects'));
+
+      const pane = within(screen.getByTestId('columns-files-pane'));
+      await waitFor(() => expect(pane.getByText('Vase')).toBeInTheDocument());
+      // A leaf contributes no column of its own.
+      expect(screen.queryByTestId('columns-level-folder-3')).not.toBeInTheDocument();
+    });
+
+    it('walks from a column\'s last folder into that column\'s files and back', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+      const level = within(screen.getByTestId('columns-level-folder-1'));
+      await waitFor(() => expect(level.getByText('Spacer')).toBeInTheDocument());
+      screen.getByTestId('columns-view').focus();
+      const spacerRow = () => level.getByText('Spacer').closest('[data-file-id]') as HTMLElement;
+
+      // Brackets is the column's only folder, so the next row down is the
+      // column's first file: the only way a keyboard reaches it at all.
+      await user.keyboard('{ArrowDown}');
+      await waitFor(() => expect(spacerRow().className).toContain('ring-1'));
+
+      // Up steps back onto the folder, which stayed the selection throughout.
+      await user.keyboard('{ArrowUp}');
+      await waitFor(() => expect(spacerRow().className).not.toContain('ring-1'));
+      expect(level.getByText('Brackets').closest('[data-folder-id]')?.className).toContain('bg-bambu-green/20');
+    });
+
+    it('keeps the selection\'s count and actions while the selected folder\'s own pane is empty', async () => {
+      const user = userEvent.setup();
+      server.use(
+        http.get('/api/v1/library/files', ({ request }) =>
+          HttpResponse.json(new URL(request.url).searchParams.get('folder_id') === '2' ? [] : filesForRequest(request))
+        )
+      );
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+      const level = within(screen.getByTestId('columns-level-folder-1'));
+      await user.click(await level.findByText('Spacer'));
+
+      // Brackets lists nothing, yet a file ticked one column to the left still
+      // gets its count, its bulk actions and a way to clear it.
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+      await user.click(screen.getByText('Clear'));
+      await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument());
+    });
+
+    it('drops the selection when another folder is selected', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+      const level = within(screen.getByTestId('columns-level-folder-1'));
+      await user.click(await level.findByText('Spacer'));
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+
+      // Move or Delete must never act on rows that have left the screen.
+      await user.click(within(screen.getByTestId('columns-level-root')).getByText('Art Projects'));
+      await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument());
+    });
+
+    it('lists only the files in no folder beside the root column, like every other level', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      // Grid view at the root is "All Files", which descends into the folders.
+      await waitFor(() => expect(screen.getByText('Spacer')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+
+      // The root level's own files, the same ones its column lists once a
+      // folder is selected — nothing from inside a folder.
+      const pane = within(await screen.findByTestId('columns-files-pane'));
+      await waitFor(() => expect(pane.getByText('Benchy')).toBeInTheDocument());
+      expect(pane.queryByText('Spacer')).not.toBeInTheDocument();
+      expect(pane.queryByText('Vase')).not.toBeInTheDocument();
+    });
+
+    it('says the root has no files outside folders, not that the library is empty', async () => {
+      const user = userEvent.setup();
+      // Every file sits in a folder: the root level's own list is empty.
+      server.use(
+        http.get('/api/v1/library/files', ({ request }) =>
+          HttpResponse.json(
+            new URL(request.url).searchParams.get('include_root') === 'true'
+              ? []
+              : Object.values(mockFolderFiles).flat()
+          )
+        )
+      );
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Spacer')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+
+      const pane = within(await screen.findByTestId('columns-files-pane'));
+      expect(await pane.findByText('No files outside folders')).toBeInTheDocument();
+      expect(pane.queryByText('No files yet')).not.toBeInTheDocument();
+    });
+
+    it('offers the toolbar actions for files ticked in an earlier column', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+      const root = within(screen.getByTestId('columns-level-root'));
+      await user.click(await root.findByText('Benchy'));
+
+      // One sliced file ticked in the root column, while the pane lists
+      // Brackets: it can still be previewed and printed.
+      const toolbar = () => screen.getByText(/\d selected/).closest('div') as HTMLElement;
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+      expect(within(toolbar()).getByRole('button', { name: 'Preview' })).toBeInTheDocument();
+      expect(within(toolbar()).getByRole('button', { name: 'Print' })).toBeInTheDocument();
+
+      // A second one makes it one job for either file.
+      await user.click(root.getByText('Cube'));
+      expect(await within(toolbar()).findByRole('button', { name: 'Print (2 alternatives)' })).toBeInTheDocument();
+    });
+
+    it('offers Combine to 3MF for STLs ticked across columns', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+      // bracket.stl sits in the root column, clamp.stl in the Brackets pane.
+      await user.click(await within(screen.getByTestId('columns-level-root')).findByText('bracket.stl'));
+      await user.click(await within(screen.getByTestId('columns-files-pane')).findByText('clamp.stl'));
+
+      expect(await screen.findByText('2 selected')).toBeInTheDocument();
+      const toolbar = screen.getByText('2 selected').closest('div') as HTMLElement;
+      expect(within(toolbar).getByRole('button', { name: 'Combine to 3MF' })).toBeInTheDocument();
+    });
+
+    it('offers Select All until every file of the pane is ticked, and keeps other ticks', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await descendToBrackets(user);
+      await user.click(await within(screen.getByTestId('columns-level-folder-1')).findByText('Spacer'));
+      await user.click(await within(screen.getByTestId('columns-files-pane')).findByText('clamp.stl'));
+
+      // Two ticked, but only one of the pane's two files.
+      expect(await screen.findByText('2 selected')).toBeInTheDocument();
+      expect(screen.getByText('Select All')).toBeInTheDocument();
+
+      await user.click(screen.getByText('Select All'));
+
+      expect(await screen.findByText('3 selected')).toBeInTheDocument();
+      expect(screen.getByText('Deselect All')).toBeInTheDocument();
+    });
+
+    it('shows a document\'s type icon in place of a missing thumbnail', async () => {
+      const user = userEvent.setup();
+      server.use(
+        http.get('/api/v1/library/files', ({ request }) =>
+          HttpResponse.json(
+            new URL(request.url).searchParams.get('folder_id')
+              ? filesForRequest(request)
+              : [...mockFiles, { ...mockFiles[0], id: 99, filename: 'manual.pdf', file_type: 'pdf', print_name: null, thumbnail_path: null }]
+          )
+        )
+      );
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('Column view'));
+      const pane = within(screen.getByTestId('columns-files-pane'));
+      const pdfRow = (await pane.findByText('manual.pdf')).closest('[data-file-id]') as HTMLElement;
+      // The thumbnail slot, not the action strip (whose preview button uses
+      // the same icon for a PDF).
+      const thumbnailSlot = pdfRow.querySelector('.w-10.h-10') as HTMLElement;
+      expect(thumbnailSlot.querySelector('.lucide-file-text')).not.toBeNull();
+      expect(thumbnailSlot.querySelector('.lucide-file-box')).toBeNull();
+    });
   });
 
   describe('search and filter', () => {
@@ -422,6 +1165,26 @@ describe('FileManagerPage', () => {
       });
     });
 
+    it('Select All replaces a selection the search has hidden in the grid', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      const benchyCard = screen.getByText('Benchy').closest('div[class*="cursor-pointer"]');
+      expect(benchyCard).not.toBeNull();
+      await user.click(benchyCard!);
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+
+      // Benchy leaves the screen; Select All must not keep it ticked, or a
+      // bulk Delete would remove a file nobody can see.
+      await user.type(screen.getByPlaceholderText('Search files...'), 'vase');
+      await waitFor(() => expect(screen.queryByText('Benchy')).not.toBeInTheDocument());
+      await user.click(screen.getByText('Select All'));
+
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+      expect(screen.getByText('Deselect All')).toBeInTheDocument();
+    });
+
     it('shows bulk actions when files selected', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);

+ 40 - 0
frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx

@@ -254,6 +254,46 @@ describe('FileManagerPage preview opening', () => {
     });
   });
 
+  describe('the columns view (#3020)', () => {
+    beforeEach(() => {
+      (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
+        key === 'library-view-mode' ? 'columns' : null,
+      );
+    });
+
+    const columnsRow = async (name: string) =>
+      (await within(screen.getByTestId('columns-files-pane')).findByText(name)).closest('[data-file-id]') as HTMLElement;
+
+    it('opens the document preview for a PDF on double-click', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await user.dblClick(await columnsRow('drawing.pdf'));
+
+      expect(await screen.findByTestId('pdf-preview-modal')).toHaveTextContent('drawing.pdf');
+    });
+
+    it('opens the focused file\'s preview on Enter', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await user.click(await columnsRow('parts.csv'));
+      await user.keyboard('{Enter}');
+
+      expect(await screen.findByTestId('sheet-preview-modal')).toHaveTextContent('parts.csv');
+    });
+
+    it('does nothing for a file with no preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await user.dblClick(await columnsRow('notes.md'));
+
+      await expectNoPreviewOpened();
+      expect(mockNavigate).not.toHaveBeenCalled();
+    });
+  });
+
   describe('the toolbar Preview button', () => {
     it('appears for a single previewable selection and opens the preview', async () => {
       const user = userEvent.setup();

+ 42 - 0
frontend/src/__tests__/pages/TouchReachableActions.test.tsx

@@ -132,5 +132,47 @@ describe('actions that were hover-only (#2865)', () => {
       expect(actions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
       expect(actions.className).toContain('can-hover:opacity-0');
     });
+
+    // #3020 follow-up: the columns view reveals its folder kebab and its
+    // per-file icon strip on hover, under the same rule.
+    it('does not hide the columns view folder and file actions from a pointer that cannot hover', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () =>
+          HttpResponse.json([
+            {
+              id: 1,
+              filename: 'benchy.gcode.3mf',
+              file_path: '/library/benchy.gcode.3mf',
+              file_size: 1048576,
+              file_type: 'gcode.3mf',
+              folder_id: null,
+              thumbnail_path: null,
+              print_name: 'Benchy',
+              print_time_seconds: 3600,
+              print_count: 0,
+              duplicate_count: 0,
+              created_at: '2024-01-01T00:00:00Z',
+            },
+          ]),
+        ),
+      );
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Brackets')).toBeInTheDocument());
+
+      const user = userEvent.setup();
+      await user.click(screen.getByTitle('Column view'));
+      const columns = within(screen.getByTestId('columns-view'));
+
+      const folderKebab = within(columns.getByText('Brackets').closest('[data-folder-id]') as HTMLElement).getByTitle('Actions');
+      const folderActions = folderKebab.closest('div.flex-shrink-0')!;
+      expect(folderActions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
+      expect(folderActions.className).toContain('can-hover:opacity-0');
+
+      const fileRow = columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement;
+      const fileActions = fileRow.querySelector('[data-file-actions]')!.parentElement!;
+      expect(fileActions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
+      expect(fileActions.className).toContain('can-hover:opacity-0');
+      expect(within(fileRow).getByTitle('Rename')).toBeInTheDocument();
+    });
   });
 });

+ 9 - 5
frontend/src/components/ContextMenu.tsx

@@ -20,6 +20,10 @@ interface ContextMenuProps {
   y: number;
   items: ContextMenuItem[];
   onClose: () => void;
+  // The element that opened the menu. A mousedown on it is not "outside":
+  // without this the anchor's click handler runs after the menu has already
+  // been dismissed and cannot act as a close toggle.
+  anchorRef?: React.RefObject<HTMLElement | null>;
 }
 
 interface SubmenuPanelProps {
@@ -114,7 +118,7 @@ function SubmenuPanel({
   );
 }
 
-export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
+export function ContextMenu({ x, y, items, onClose, anchorRef }: ContextMenuProps) {
   const menuRef = useRef<HTMLDivElement>(null);
   const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
   const submenuTimeoutRef = useRef<number | null>(null);
@@ -124,9 +128,9 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
 
   useEffect(() => {
     const handleClickOutside = (e: MouseEvent) => {
-      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
-        onClose();
-      }
+      const target = e.target as Node;
+      if (menuRef.current?.contains(target) || anchorRef?.current?.contains(target)) return;
+      onClose();
     };
 
     const handleEscape = (e: KeyboardEvent) => {
@@ -156,7 +160,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
         clearTimeout(submenuTimeoutRef.current);
       }
     };
-  }, [onClose]);
+  }, [onClose, anchorRef]);
 
   // Adjust position to keep menu in viewport - use useLayoutEffect for synchronous measurement
   useLayoutEffect(() => {

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

@@ -4002,6 +4002,7 @@ export default {
     generateThumbnailsForMissing: 'Vorschaubilder für STL- und PDF-Dateien ohne Vorschau generieren',
     gridView: 'Rasteransicht',
     listView: 'Listenansicht',
+    columnsView: 'Spaltenansicht',
     lowDiskSpaceWarning: 'Warnung: Wenig Speicherplatz',
     lowDiskSpaceDetails: 'Nur {{free}} frei von {{total}} gesamt. Schwellenwert ist auf {{threshold}} GB eingestellt.',
     files: 'Dateien',
@@ -4084,6 +4085,7 @@ export default {
     adding: 'Hinzufügen...',
     loadingFiles: 'Dateien werden geladen...',
     folderIsEmpty: 'Ordner ist leer',
+    noFilesOutsideFolders: 'Keine Dateien außerhalb von Ordnern',
     noFilesYet: 'Noch keine Dateien',
     folderEmptyDescription: 'Laden Sie Dateien hoch oder verschieben Sie Dateien in diesen Ordner.',
     noFilesDescription: 'Laden Sie Dateien hoch, um Ihre Druckdateien zu organisieren.',

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

@@ -4032,6 +4032,7 @@ export default {
     generateThumbnailsForMissing: 'Generate thumbnails for STL and PDF files missing them',
     gridView: 'Grid view',
     listView: 'List view',
+    columnsView: 'Column view',
     lowDiskSpaceWarning: 'Low disk space warning',
     lowDiskSpaceDetails: 'Only {{free}} free of {{total}} total. Threshold is set to {{threshold}} GB in settings.',
     files: 'Files',
@@ -4114,6 +4115,7 @@ export default {
     adding: 'Adding...',
     loadingFiles: 'Loading files...',
     folderIsEmpty: 'Folder is empty',
+    noFilesOutsideFolders: 'No files outside folders',
     noFilesYet: 'No files yet',
     folderEmptyDescription: 'Upload files or move files into this folder to get started.',
     noFilesDescription: 'Upload files to start organizing your print-related files.',

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

@@ -4004,6 +4004,7 @@ export default {
     generateThumbnailsForMissing: 'Generar miniaturas para los archivos STL y PDF que no las tienen',
     gridView: 'Vista de cuadrícula',
     listView: 'Vista de lista',
+    columnsView: 'Vista de columnas',
     lowDiskSpaceWarning: 'Advertencia de poco espacio en disco',
     lowDiskSpaceDetails: 'Solo {{free}} libres de {{total}} en total. El umbral está establecido en {{threshold}} GB en los ajustes.',
     files: 'Archivos',
@@ -4086,6 +4087,7 @@ export default {
     adding: 'Añadiendo...',
     loadingFiles: 'Cargando archivos...',
     folderIsEmpty: 'La carpeta está vacía',
+    noFilesOutsideFolders: 'No hay archivos fuera de las carpetas',
     noFilesYet: 'Aún no hay archivos',
     folderEmptyDescription: 'Suba archivos o mueva archivos a esta carpeta para empezar.',
     noFilesDescription: 'Suba archivos para empezar a organizar sus archivos relacionados con la impresión.',

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

@@ -3990,6 +3990,7 @@ export default {
     generateThumbnailsForMissing: 'Vignettes STL et PDF manquantes',
     gridView: 'Grille',
     listView: 'Liste',
+    columnsView: 'Colonnes',
     lowDiskSpaceWarning: 'Espace disque faible',
     lowDiskSpaceDetails: '{{free}} libre sur {{total}}. Seuil : {{threshold}} Go.',
     files: 'Fichiers',
@@ -4072,6 +4073,7 @@ export default {
     adding: 'Ajout...',
     loadingFiles: 'Chargement...',
     folderIsEmpty: 'Dossier vide',
+    noFilesOutsideFolders: 'Aucun fichier hors des dossiers',
     noFilesYet: 'Aucun fichier',
     folderEmptyDescription: 'Téléversez ou déplacez des fichiers ici.',
     noFilesDescription: 'Téléversez des fichiers pour organiser.',

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

@@ -3989,6 +3989,7 @@ export default {
     generateThumbnailsForMissing: 'Genera miniature per STL e PDF senza miniatura',
     gridView: 'Vista griglia',
     listView: 'Vista elenco',
+    columnsView: 'Vista a colonne',
     lowDiskSpaceWarning: 'Avviso spazio disco basso',
     lowDiskSpaceDetails: 'Solo {{free}} liberi su {{total}} totali. La soglia e {{threshold}} GB nelle impostazioni.',
     files: 'File',
@@ -4071,6 +4072,7 @@ export default {
     adding: 'Aggiunta...',
     loadingFiles: 'Caricamento file...',
     folderIsEmpty: 'La cartella e vuota',
+    noFilesOutsideFolders: 'Nessun file fuori dalle cartelle',
     noFilesYet: 'Nessun file ancora',
     folderEmptyDescription: 'Carica file o sposta file in questa cartella per iniziare.',
     noFilesDescription: 'Carica file per iniziare a organizzare i file di stampa.',

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

@@ -4002,6 +4002,7 @@ export default {
     generateThumbnailsForMissing: 'サムネイルのないSTL・PDFファイルのサムネイルを生成',
     gridView: 'グリッド表示',
     listView: 'リスト表示',
+    columnsView: 'カラム表示',
     lowDiskSpaceWarning: 'ディスク容量不足の警告',
     lowDiskSpaceDetails: '{{total}}中{{free}}の空き容量のみ。しきい値は設定で{{threshold}}GBに設定されています。',
     files: 'ファイル',
@@ -4084,6 +4085,7 @@ export default {
     adding: '追加中...',
     loadingFiles: 'ファイルを読み込み中...',
     folderIsEmpty: 'フォルダーは空です',
+    noFilesOutsideFolders: 'フォルダー外のファイルはありません',
     noFilesYet: 'ファイルはまだありません',
     folderEmptyDescription: 'ファイルをアップロードするか、このフォルダーにファイルを移動して開始しましょう。',
     noFilesDescription: '印刷関連ファイルの整理を始めるにはファイルをアップロードしてください。',

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

@@ -3811,6 +3811,7 @@ export default {
     generateThumbnailsForMissing: '썸네일이 없는 STL 및 PDF 파일의 썸네일 생성',
     gridView: '격자 보기',
     listView: '목록 보기',
+    columnsView: '열 보기',
     lowDiskSpaceWarning: '디스크 공간 부족 경고',
     lowDiskSpaceDetails: '전체 {{total}} 중 {{free}}만 남음. 임계값은 설정에서 {{threshold}}GB로 설정됩니다.',
     files: '파일',
@@ -3893,6 +3894,7 @@ export default {
     adding: '추가 중...',
     loadingFiles: '파일 불러오는 중...',
     folderIsEmpty: '폴더가 비어 있음',
+    noFilesOutsideFolders: '폴더 밖에 있는 파일 없음',
     noFilesYet: '파일 없음',
     folderEmptyDescription: '파일을 업로드하거나 이 폴더로 이동하여 시작하세요.',
     noFilesDescription: '파일을 업로드하여 인쇄 관련 파일을 정리하세요.',

+ 2 - 0
frontend/src/i18n/locales/nl.ts

@@ -4032,6 +4032,7 @@ export default {
     generateThumbnailsForMissing: 'Miniaturen genereren voor STL- en PDF-bestanden die ze missen',
     gridView: 'Rasterweergave',
     listView: 'Lijstweergave',
+    columnsView: 'Kolomweergave',
     lowDiskSpaceWarning: 'Waarschuwing weinig schijfruimte',
     lowDiskSpaceDetails: 'Slechts {{free}} vrij van {{total}} totaal. De drempel is in de instellingen ingesteld op {{threshold}} GB.',
     files: 'Bestanden',
@@ -4114,6 +4115,7 @@ export default {
     adding: 'Toevoegen...',
     loadingFiles: 'Bestanden laden...',
     folderIsEmpty: 'Map is leeg',
+    noFilesOutsideFolders: 'Geen bestanden buiten mappen',
     noFilesYet: 'Nog geen bestanden',
     folderEmptyDescription: 'Upload bestanden of verplaats bestanden naar deze map om te beginnen.',
     noFilesDescription: 'Upload bestanden om je afdrukgerelateerde bestanden te organiseren.',

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

@@ -3989,6 +3989,7 @@ export default {
     generateThumbnailsForMissing: 'Gerar miniaturas para arquivos STL e PDF que não possuem',
     gridView: 'Visualização em grade',
     listView: 'Visualização em lista',
+    columnsView: 'Visualização em colunas',
     lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',
     lowDiskSpaceDetails: 'Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.',
     files: 'Arquivos',
@@ -4071,6 +4072,7 @@ export default {
     adding: 'Adicionando...',
     loadingFiles: 'Carregando arquivos...',
     folderIsEmpty: 'A pasta está vazia',
+    noFilesOutsideFolders: 'Nenhum arquivo fora das pastas',
     noFilesYet: 'Nenhum arquivo ainda',
     folderEmptyDescription: 'Envie arquivos ou mova arquivos para esta pasta para começar.',
     noFilesDescription: 'Envie arquivos para começar a organizar seus arquivos relacionados à impressão.',

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

@@ -3803,6 +3803,7 @@ export default {
     generateThumbnailsForMissing: "Создать отсутствующие миниатюры для STL- и PDF-файлов",
     gridView: "Плитка",
     listView: "Список",
+    columnsView: "Колонки",
     lowDiskSpaceWarning: "Мало свободного места",
     lowDiskSpaceDetails: "Свободно только {{free}} из {{total}}. Порог предупреждения в настройках: {{threshold}} ГБ.",
     files: "Файлы",
@@ -3885,6 +3886,7 @@ export default {
     adding: "Добавление...",
     loadingFiles: "Загрузка файлов...",
     folderIsEmpty: "Папка пуста",
+    noFilesOutsideFolders: "Нет файлов вне папок",
     noFilesYet: "Файлов пока нет",
     folderEmptyDescription: "Загрузите или переместите файлы в эту папку.",
     noFilesDescription: "Загрузите файлы, чтобы начать упорядочивать материалы для печати.",

+ 2 - 0
frontend/src/i18n/locales/sv.ts

@@ -4031,6 +4031,7 @@ errors: {
     generateThumbnailsForMissing: 'Generera minibilder för STL- och PDF-filer som saknar dem',
     gridView: 'Rutnätsvy',
     listView: 'Listvy',
+    columnsView: 'Kolumnvy',
     lowDiskSpaceWarning: 'Varning för lågt diskutrymme',
     lowDiskSpaceDetails: 'Endast {{free}} ledigt av {{total}} totalt. Tröskelvärdet är inställt på {{threshold}} GB i inställningar.',
     files: 'Filer',
@@ -4113,6 +4114,7 @@ errors: {
     adding: 'Lägger till...',
     loadingFiles: 'Laddar filer...',
     folderIsEmpty: 'Mappen är tom',
+    noFilesOutsideFolders: 'Inga filer utanför mappar',
     noFilesYet: 'Inga filer än',
     folderEmptyDescription: 'Ladda upp filer eller flytta filer till denna mapp för att komma igång.',
     noFilesDescription: 'Ladda upp filer för att börja organisera dina utskriftsrelaterade filer.',

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

@@ -3996,6 +3996,7 @@ export default {
     generateThumbnailsForMissing: 'Eksik olan STL ve PDF dosyaları için küçük resimler oluştur',
     gridView: 'Izgara görünümü',
     listView: 'Liste görünümü',
+    columnsView: 'Sütun görünümü',
     lowDiskSpaceWarning: 'Düşük disk alanı uyarısı',
     lowDiskSpaceDetails: 'Toplam {{total}} alandan yalnızca {{free}} boş. Eşik ayarlarda {{threshold}} GB olarak ayarlanmış.',
     files: 'Dosyalar',
@@ -4078,6 +4079,7 @@ export default {
     adding: 'Ekleniyor...',
     loadingFiles: 'Dosyalar yükleniyor...',
     folderIsEmpty: 'Klasör boş',
+    noFilesOutsideFolders: 'Klasör dışında dosya yok',
     noFilesYet: 'Henüz dosya yok',
     folderEmptyDescription: 'Başlamak için bu klasöre dosya yükleyin veya dosyaları taşıyın.',
     noFilesDescription: 'Baskıyla ilgili dosyalarınızı organize etmeye başlamak için dosya yükleyin.',

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

@@ -4029,6 +4029,7 @@ export default {
     generateThumbnailsForMissing: "Створити відсутні мініатюри файлів STL і PDF",
     gridView: "Вигляд сітки",
     listView: "Перегляд списку",
+    columnsView: "Вигляд колонок",
     lowDiskSpaceWarning: "Попередження про брак місця на диску",
     lowDiskSpaceDetails: "Вільно лише {{free}} із {{total}}. У налаштуваннях задано поріг {{threshold}} ГБ.",
     files: "Файли",
@@ -4111,6 +4112,7 @@ export default {
     adding: "Додавання...",
     loadingFiles: "Завантаження файлів...",
     folderIsEmpty: "Папка порожня",
+    noFilesOutsideFolders: "Немає файлів поза папками",
     noFilesYet: "Файлів ще немає",
     folderEmptyDescription: "Щоб почати, вивантажте файли або перемістіть їх до цієї папки.",
     noFilesDescription: "Вивантажте файли, щоб упорядковувати матеріали для друку.",

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

@@ -3989,6 +3989,7 @@ export default {
     generateThumbnailsForMissing: '为缺少缩略图的 STL 和 PDF 文件生成缩略图',
     gridView: '网格视图',
     listView: '列表视图',
+    columnsView: '分栏视图',
     lowDiskSpaceWarning: '磁盘空间不足警告',
     lowDiskSpaceDetails: '仅剩 {{free}}(总共 {{total}})。阈值设置为 {{threshold}} GB。',
     files: '文件',
@@ -4071,6 +4072,7 @@ export default {
     adding: '添加中...',
     loadingFiles: '加载文件中...',
     folderIsEmpty: '文件夹为空',
+    noFilesOutsideFolders: '文件夹外没有文件',
     noFilesYet: '暂无文件',
     folderEmptyDescription: '上传文件或将文件移入此文件夹以开始使用。',
     noFilesDescription: '上传文件以开始组织您的打印相关文件。',

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

@@ -3989,6 +3989,7 @@ export default {
     generateThumbnailsForMissing: '為缺少縮圖的 STL 和 PDF 檔案產生縮圖',
     gridView: '網格檢視',
     listView: '列表檢視',
+    columnsView: '分欄檢視',
     lowDiskSpaceWarning: '磁碟空間不足警告',
     lowDiskSpaceDetails: '僅剩 {{free}}(總共 {{total}})。閾值設定為 {{threshold}} GB。',
     files: '檔案',
@@ -4071,6 +4072,7 @@ export default {
     adding: '新增中...',
     loadingFiles: '載入檔案中...',
     folderIsEmpty: '資料夾為空',
+    noFilesOutsideFolders: '資料夾外沒有檔案',
     noFilesYet: '尚無檔案',
     folderEmptyDescription: '上傳檔案或將檔案移入此資料夾以開始使用。',
     noFilesDescription: '上傳檔案以開始組織您的列印相關檔案。',

File diff suppressed because it is too large
+ 903 - 179
frontend/src/pages/FileManagerPage.tsx


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BNS3PW0l.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-BvXcfvYe.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/SpreadsheetPreviewModal-BNJIB5jH.js


+ 0 - 1
static/assets/chunk-aKtaBQYM.js

@@ -1 +0,0 @@
-var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),u=(e=>typeof require<`u`?require:typeof Proxy<`u`?new Proxy(e,{get:(e,t)=>(typeof require<`u`?require:e)[t]}):e)(function(e){if(typeof require<`u`)return require.apply(this,arguments);throw Error('Calling `require` for "'+e+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});export{l as i,s as n,u as r,o as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/index-CV30AFBJ.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-CyW_3l56.css


BIN
static/assets/occt-import-js-BhHfLpto.wasm


File diff suppressed because it is too large
+ 0 - 0
static/assets/papaparse.min-CR43Mv99.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-BsRY7zBD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf.worker.min-0idpi3xP.js


+ 0 - 1
static/assets/pdf.worker.min-DpOxPwGP.js

@@ -1 +0,0 @@
-var e=`/assets/pdf.worker.min-0idpi3xP.js`;export{e as default};

BIN
static/assets/pdfjs/cmaps/78-EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/78-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/78-V.bcmap


BIN
static/assets/pdfjs/cmaps/78ms-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/78ms-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/83pv-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90ms-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90ms-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/90msp-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90msp-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/90pv-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90pv-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/Add-H.bcmap


BIN
static/assets/pdfjs/cmaps/Add-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/Add-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/Add-V.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-6.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-6.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/B5pc-H.bcmap


BIN
static/assets/pdfjs/cmaps/B5pc-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS-EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/CNS-EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS1-H.bcmap


BIN
static/assets/pdfjs/cmaps/CNS1-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS2-H.bcmap


+ 0 - 3
static/assets/pdfjs/cmaps/CNS2-V.bcmap

@@ -1,3 +0,0 @@
-àRCopyright 1990-2009 Adobe Systems Incorporated.
-All rights reserved.
-See ./LICENSEáCNS2-H

BIN
static/assets/pdfjs/cmaps/ETHK-B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/ETHK-B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/ETen-B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/ETen-B5-V.bcmap


+ 0 - 3
static/assets/pdfjs/cmaps/ETenms-B5-H.bcmap

@@ -1,3 +0,0 @@
-àRCopyright 1990-2009 Adobe Systems Incorporated.
-All rights reserved.
-See ./LICENSEá	ETen-B5-H` ^

BIN
static/assets/pdfjs/cmaps/ETenms-B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/Ext-H.bcmap


BIN
static/assets/pdfjs/cmaps/Ext-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/Ext-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/Ext-V.bcmap


BIN
static/assets/pdfjs/cmaps/GB-EUC-H.bcmap


Some files were not shown because too many files changed in this diff