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

fix(queue): "View in File Manager" opens the file (issue #3336)

- The queue card's link went to /library, which is no route, so the
  page stayed empty. It now opens /files?highlight=<id>: the File
  Manager looks up the file's folder, opens it and marks the file in
  the grid, list and column views. A deleted or unreadable file shows
  a warning.
- The Archives page now reads ?highlight= as well. The queue's archive
  link and the project page's archive tiles (which used a ?search= the
  page never read) open the archive marked, switching from the log or
  calendar view to the grid.
- Both pages look for the file or archive in a freshly loaded list, so
  one moved or created since the page was last open is found.
- Queue cards show a 256px thumbnail on hover, as history rows did at
  192px (now 256px too). The preview is portalled, so a batch group
  no longer clips it (#3313).
maziggy 1 день назад
Родитель
Сommit
048e5164d6

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.0b1] - Unreleased
 ## [1.3.0b1] - Unreleased
 
 
 ### Added
 ### Added
+- **Larger queue thumbnails on hover (#3313, requested by @humplemann)** — A job's thumbnail on a **Queue** card is the quickest way to tell jobs apart, but it is small. Hovering it now shows it at 256x256 next to the card, as the **History** rows already did at 192x192 (now 256x256 too). The preview opens to the left when there is no room on the right, is no longer cut off inside a batch group, and closes when you move away or scroll. Desktop only, as touch screens have no hover. Wiki updated.
 - **The queue says when a job printed in a colour the file didn't ask for (#3279, requested by @Lioncat55)** — With no spool of a slot's colour loaded, the queue can still send the job with another spool of the same material. Nothing showed this until the print came out.
 - **The queue says when a job printed in a colour the file didn't ask for (#3279, requested by @Lioncat55)** — With no spool of a slot's colour loaded, the queue can still send the job with another spool of the same material. Nothing showed this until the print came out.
   - **On the queue row:** an amber line per slot, for example "Slot 1 printed in Black (#000000), the file asks for Silver (#A4AAAC)". It stays on the job in **History**.
   - **On the queue row:** an amber line per slot, for example "Slot 1 printed in Black (#000000), the file asks for Silver (#A4AAAC)". It stays on the job in **History**.
   - **When:** worked out each time the job is sent, from the spool each slot is sent with, whether the queue picked it or it was picked by hand. A similar colour doesn't count, and a colour changed on purpose with a filament override isn't reported.
   - **When:** worked out each time the job is sent, from the spool each slot is sent with, whether the queue picked it or it was picked by hand. A similar colour doesn't count, and a colour changed on purpose with a filament override isn't reported.
@@ -174,6 +175,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **The queue's "View in File Manager" icon opens the file (#3336, reported by @Magical-Penguin)** — On a job added from the File Manager, the link icon next to the name went to `/library`, which is no page, so the screen stayed empty. It now opens the File Manager in the file's folder, with the file marked and scrolled into view, in the grid, list and column views. The folder is looked up when the link is opened, so a file moved since it was queued is still found. A deleted file, or one you can't see, shows a warning. The archive icon on the same cards opened the Archives page without pointing at the archive; it now marks the archive too, switches from the log or calendar view to the grid, and warns when the filters hide it. An archive tile on a project's page did the same and now opens its archive marked as well. Both look in a freshly loaded list, so an archive created since the page was last open is found. Wiki updated.
 - **An H2D Pro reports its chamber and can heat it, and so can an X1E (#3335, reported by @Spegeli)** — A printer stored as "H2D Pro", which is what discovery and the add-printer list store, was checked against the model lists as "H2D PRO", with the space kept, while the lists spell it "H2DPRO". So an H2D Pro got no chamber temperature on the printer card and in the API (Home Assistant included), setting its chamber temperature was refused, and queued prints got no chamber preheat or soak and never switched the airduct to heating, or back to cooling for a PLA print after ABS; the printer card did not offer its airduct toggle either. Every model check now compares the name without spaces, and the card lists the H2D Pro. Separately, the X1E was listed as having only a chamber sensor although it has a chamber heater (up to 60 °C): setting its chamber temperature was refused and the queue never preheated its chamber. It now does both; the X1E has no airduct flap, so none is switched.
 - **An H2D Pro reports its chamber and can heat it, and so can an X1E (#3335, reported by @Spegeli)** — A printer stored as "H2D Pro", which is what discovery and the add-printer list store, was checked against the model lists as "H2D PRO", with the space kept, while the lists spell it "H2DPRO". So an H2D Pro got no chamber temperature on the printer card and in the API (Home Assistant included), setting its chamber temperature was refused, and queued prints got no chamber preheat or soak and never switched the airduct to heating, or back to cooling for a PLA print after ABS; the printer card did not offer its airduct toggle either. Every model check now compares the name without spaces, and the card lists the H2D Pro. Separately, the X1E was listed as having only a chamber sensor although it has a chamber heater (up to 60 °C): setting its chamber temperature was refused and the queue never preheated its chamber. It now does both; the X1E has no airduct flap, so none is switched.
 - **A print started over LAN is linked to its archive, and its print time is no longer short (#3327, reported by @Andrewdlec)** — A print sent from Bambu Studio over LAN, or started from Handy or the printer's screen, reports no job id, and printer status found a running print's archive only by that id, so `current_archive_id` stayed empty for the whole print and the printer card could not name the plate of a multi-plate file. Status now also finds the running print's archive as soon as it exists, also after a Bambuddy restart mid-print, and never names an archive that was deleted. The printer card's cover image and a reloaded skip-objects list of such a print are now read from the archived 3MF instead of being fetched from the busy printer again over FTPS (skip-objects only while Bambuddy knows which archive is the print's, never by name). Separately, an archive's `started_at` was the moment it was written, after the 3MF download, which can take a minute at the start of a print; the print time taken from it at completion was short by that much. It is now the moment the print was seen to start, also for an archive written without a 3MF.
 - **A print started over LAN is linked to its archive, and its print time is no longer short (#3327, reported by @Andrewdlec)** — A print sent from Bambu Studio over LAN, or started from Handy or the printer's screen, reports no job id, and printer status found a running print's archive only by that id, so `current_archive_id` stayed empty for the whole print and the printer card could not name the plate of a multi-plate file. Status now also finds the running print's archive as soon as it exists, also after a Bambuddy restart mid-print, and never names an archive that was deleted. The printer card's cover image and a reloaded skip-objects list of such a print are now read from the archived 3MF instead of being fetched from the busy printer again over FTPS (skip-objects only while Bambuddy knows which archive is the print's, never by name). Separately, an archive's `started_at` was the moment it was written, after the 3MF download, which can take a minute at the start of a print; the print time taken from it at completion was short by that much. It is now the moment the print was seen to start, also for an archive written without a 3MF.
 - **A spool replaced during a print is charged only for its part (#3325, reported by @HarryCoff2)** — When a spool ran out and a new one was loaded in the same slot (an external spool, or an AMS slot without a backup) and assigned in Bambuddy while the print ran, the whole print was charged to the new spool. Bambuddy now notes the layer of the new assignment and, when the print ends, splits the filament between the two spools by what the G-code feeds before and after it, for completed and failed prints, with the built-in inventory and with Spoolman, also after a restart mid-print. A slot that had no spool when the print started is still charged to the spool assigned to it, for the whole print.
 - **A spool replaced during a print is charged only for its part (#3325, reported by @HarryCoff2)** — When a spool ran out and a new one was loaded in the same slot (an external spool, or an AMS slot without a backup) and assigned in Bambuddy while the print ran, the whole print was charged to the new spool. Bambuddy now notes the layer of the new assignment and, when the print ends, splits the filament between the two spools by what the G-code feeds before and after it, for completed and failed prints, with the built-in inventory and with Spoolman, also after a restart mid-print. A slot that had no spool when the print started is still charged to the spool assigned to it, for the whole print.

+ 96 - 0
frontend/src/__tests__/components/ThumbnailHoverPreview.test.tsx

@@ -0,0 +1,96 @@
+/**
+ * The larger thumbnail shown while the pointer rests on a queue thumbnail
+ * (#3313). Desktop only, and portalled so a batch group's overflow:hidden
+ * cannot clip it.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { ThumbnailHoverPreview } from '../../components/ThumbnailHoverPreview';
+
+const realMatchMedia = window.matchMedia;
+
+function pointer(fine: boolean) {
+  window.matchMedia = ((query: string) => ({ ...realMatchMedia(query), matches: fine })) as typeof window.matchMedia;
+}
+
+function renderThumb(src: string | null, rect: Partial<DOMRect> = {}) {
+  render(
+    <div style={{ overflow: 'hidden' }} data-testid="clipping-parent">
+      <ThumbnailHoverPreview src={src} className="thumb">
+        <img src={src ?? ''} alt="thumb" />
+      </ThumbnailHoverPreview>
+    </div>,
+  );
+  const anchor = screen.getByAltText('thumb').parentElement as HTMLElement;
+  anchor.getBoundingClientRect = () =>
+    ({ left: 100, right: 156, top: 200, bottom: 256, width: 56, height: 56, ...rect }) as DOMRect;
+  return anchor;
+}
+
+describe('ThumbnailHoverPreview', () => {
+  afterEach(() => {
+    window.matchMedia = realMatchMedia;
+  });
+
+  it('shows the image enlarged while hovered, outside any clipping parent', () => {
+    pointer(true);
+    const anchor = renderThumb('/thumb.png');
+
+    fireEvent.mouseEnter(anchor);
+
+    const preview = screen.getByTestId('thumbnail-hover-preview');
+    expect(preview).toHaveAttribute('src', '/thumb.png');
+    expect(preview).toHaveStyle({ width: '256px', height: '256px', left: '164px', top: '200px' });
+    expect(screen.getByTestId('clipping-parent')).not.toContainElement(preview);
+
+    fireEvent.mouseLeave(anchor);
+    expect(screen.queryByTestId('thumbnail-hover-preview')).not.toBeInTheDocument();
+  });
+
+  it('opens to the left when there is no room on the right', () => {
+    pointer(true);
+    const anchor = renderThumb('/thumb.png', { left: window.innerWidth - 60, right: window.innerWidth - 4 });
+
+    fireEvent.mouseEnter(anchor);
+
+    expect(screen.getByTestId('thumbnail-hover-preview')).toHaveStyle({ left: `${window.innerWidth - 60 - 8 - 256}px` });
+  });
+
+  it('stays inside the viewport at the bottom edge', () => {
+    pointer(true);
+    const anchor = renderThumb('/thumb.png', { top: window.innerHeight - 30 });
+
+    fireEvent.mouseEnter(anchor);
+
+    expect(screen.getByTestId('thumbnail-hover-preview')).toHaveStyle({ top: `${window.innerHeight - 256 - 8}px` });
+  });
+
+  it('closes when the page scrolls', () => {
+    pointer(true);
+    const anchor = renderThumb('/thumb.png');
+    fireEvent.mouseEnter(anchor);
+
+    fireEvent.scroll(window);
+
+    expect(screen.queryByTestId('thumbnail-hover-preview')).not.toBeInTheDocument();
+  });
+
+  it('shows nothing on a touch screen', () => {
+    pointer(false);
+    const anchor = renderThumb('/thumb.png');
+
+    fireEvent.mouseEnter(anchor);
+
+    expect(screen.queryByTestId('thumbnail-hover-preview')).not.toBeInTheDocument();
+  });
+
+  it('shows nothing without an image', () => {
+    pointer(true);
+    const anchor = renderThumb(null);
+
+    fireEvent.mouseEnter(anchor);
+
+    expect(screen.queryByTestId('thumbnail-hover-preview')).not.toBeInTheDocument();
+  });
+});

+ 129 - 0
frontend/src/__tests__/pages/ArchivesHighlightParam.test.tsx

@@ -0,0 +1,129 @@
+/**
+ * /archives?highlight=<id>, the queue card's archive link (#3336). The page
+ * used to ignore the parameter and just open the list.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { useNavigate } from 'react-router-dom';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { ArchivesPage } from '../../pages/ArchivesPage';
+import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
+
+function archive(id: number, printName: string) {
+  return {
+    id,
+    filename: `${printName}.gcode.3mf`,
+    print_name: printName,
+    printer_id: 1,
+    printer_name: 'X1 Carbon',
+    print_time_seconds: 3600,
+    filament_used_grams: 10,
+    status: 'completed',
+    started_at: '2024-01-01T10:00:00Z',
+    completed_at: '2024-01-01T11:00:00Z',
+    thumbnail_path: null,
+    notes: null,
+    rating: null,
+    project_id: null,
+    project_name: null,
+    project_color: null,
+    print_count: 1,
+    tags: '',
+    created_at: '2024-01-01T09:00:00Z',
+    updated_at: '2024-01-01T11:00:00Z',
+    has_f3d: false,
+  };
+}
+
+// The list without archive 3, as cached before its queued job started.
+let beforeTheJobStarted = false;
+
+/** Follows a link inside the running app, as the queue page's does. */
+function GoTo({ to }: { to: string }) {
+  const navigate = useNavigate();
+  return <button onClick={() => navigate(to)}>go</button>;
+}
+
+function setViewMode(mode: string) {
+  (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
+    key === 'archiveViewMode' ? mode : null,
+  );
+}
+
+function card(printName: string): HTMLElement {
+  return screen.getByText(printName).closest('[data-archive-id]') as HTMLElement;
+}
+
+describe('ArchivesPage ?highlight=', () => {
+  beforeEach(() => {
+    setAuthToken(null);
+    setViewMode('grid');
+    beforeTheJobStarted = false;
+    server.use(
+      http.get('/api/v1/archives/', async () => {
+        if (beforeTheJobStarted) return HttpResponse.json([archive(1, 'Benchy'), archive(2, 'Bracket')]);
+        // Slower than the page's 100 ms scroll check, so nothing can be decided
+        // on a list that is still on its way.
+        await new Promise((resolve) => setTimeout(resolve, 300));
+        return HttpResponse.json([archive(1, 'Benchy'), archive(2, 'Bracket'), archive(3, 'Just started')]);
+      }),
+      http.get('/api/v1/archives/stats', () => HttpResponse.json({ total_archives: 2 })),
+      http.get('/api/v1/printers/', () => HttpResponse.json([{ id: 1, name: 'X1 Carbon' }])),
+      http.get('/api/v1/projects/', () => HttpResponse.json([])),
+      http.get('/api/v1/archives/tags', () => HttpResponse.json([])),
+    );
+  });
+
+  afterEach(() => {
+    window.history.pushState({}, '', '/');
+  });
+
+  it('marks the linked archive', async () => {
+    window.history.pushState({}, '', '/archives?highlight=2');
+    render(<ArchivesPage />);
+
+    await screen.findByText('Bracket');
+    await waitFor(() => expect(card('Bracket').style.outline).toContain('solid'));
+    expect(card('Benchy').style.outline).toBe('');
+  });
+
+  it('leaves the log view for the grid, where the archive can be shown', async () => {
+    setViewMode('log');
+    window.history.pushState({}, '', '/archives?highlight=2');
+    render(<ArchivesPage />);
+
+    await waitFor(() => expect(card('Bracket').style.outline).toContain('solid'));
+  });
+
+  it('warns when the linked archive is not in the list', async () => {
+    window.history.pushState({}, '', '/archives?highlight=99');
+    render(<ArchivesPage />);
+
+    expect(await screen.findByText('Linked archive not visible - try clearing filters')).toBeInTheDocument();
+  });
+
+  it('looks for the archive in a fresh list, not one cached before it existed', async () => {
+    beforeTheJobStarted = true;
+    window.history.pushState({}, '', '/archives');
+    render(<><ArchivesPage /><GoTo to="/archives?highlight=3" /></>);
+    await screen.findByText('Bracket');
+
+    beforeTheJobStarted = false;
+    fireEvent.click(screen.getByText('go'));
+
+    await waitFor(() => expect(card('Just started').style.outline).toContain('solid'));
+    expect(screen.queryByText(/not visible - try clearing filters/)).not.toBeInTheDocument();
+  });
+
+  it('marks nothing without the parameter', async () => {
+    window.history.pushState({}, '', '/archives');
+    render(<ArchivesPage />);
+
+    await screen.findByText('Bracket');
+    expect(card('Bracket').style.outline).toBe('');
+    expect(screen.queryByText(/not visible - try clearing filters/)).not.toBeInTheDocument();
+  });
+});

+ 194 - 0
frontend/src/__tests__/pages/FileManagerHighlight3336.test.tsx

@@ -0,0 +1,194 @@
+/**
+ * /files?highlight=<id> (#3336): the queue card's "View in File Manager" link.
+ * The link carries only the file id; the page looks up the file's folder,
+ * opens it and marks the file. It used to point at /library, which is no route.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { useNavigate } from 'react-router-dom';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { server } from '../mocks/server';
+
+function libraryFile(id: number, filename: string, folderId: number | null) {
+  return {
+    id,
+    filename,
+    file_type: '3mf',
+    file_path: `/library/${filename}`,
+    file_size: 4096,
+    folder_id: folderId,
+    thumbnail_path: null,
+    print_name: null,
+    print_time_seconds: null,
+    print_count: 0,
+    duplicate_count: 0,
+    created_at: '2026-01-01T00:00:00Z',
+  };
+}
+
+const rootFile = libraryFile(1, 'at-the-root.3mf', null);
+const target = libraryFile(5, 'the-queued-one.3mf', 3);
+const sibling = libraryFile(6, 'its-neighbour.3mf', 3);
+
+let listedFolders: (string | null)[] = [];
+// Folder 3's listing without the file, as cached before it was moved there.
+let folder3BeforeTheMove = false;
+
+/** Follows a link inside the running app, as the queue page's does. */
+function GoTo({ to }: { to: string }) {
+  const navigate = useNavigate();
+  return <button onClick={() => navigate(to)}>go</button>;
+}
+
+function setViewMode(mode: 'grid' | 'list' | 'columns') {
+  (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
+    key === 'library-view-mode' ? mode : null,
+  );
+}
+
+function card(name: string): HTMLElement {
+  return screen.getByText(name).closest('[data-file-id]') as HTMLElement;
+}
+
+describe('FileManagerPage ?highlight=', () => {
+  beforeEach(() => {
+    listedFolders = [];
+    folder3BeforeTheMove = false;
+    setViewMode('grid');
+    server.use(
+      http.get('/api/v1/library/folders', () =>
+        HttpResponse.json([
+          { id: 3, name: 'Brackets', parent_id: null, project_id: null, is_external: false, file_count: 2, children: [] },
+          { id: 4, name: 'Elsewhere', parent_id: null, project_id: null, is_external: false, file_count: 0, children: [] },
+        ]),
+      ),
+      http.get('/api/v1/library/files', async ({ request }) => {
+        const folderId = new URL(request.url).searchParams.get('folder_id');
+        listedFolders.push(folderId);
+        if (folderId === '4') return HttpResponse.json([]);
+        if (folderId === '3' && folder3BeforeTheMove) return HttpResponse.json([sibling]);
+        // Slower than the page's own timers, so nothing can be decided on a list
+        // that is still on its way.
+        if (folderId === '3') {
+          await new Promise((resolve) => setTimeout(resolve, 300));
+          return HttpResponse.json([target, sibling]);
+        }
+        return HttpResponse.json([rootFile]);
+      }),
+      http.get('/api/v1/library/files/5', () => HttpResponse.json(target)),
+      // Readable on its own but not in its folder's listing.
+      http.get('/api/v1/library/files/7', () => HttpResponse.json(libraryFile(7, 'unlisted.3mf', 3))),
+      http.get('/api/v1/library/files/404', () => HttpResponse.json({ detail: 'File not found' }, { status: 404 })),
+      http.get('/api/v1/library/stats', () =>
+        HttpResponse.json({
+          total_files: 3,
+          total_folders: 1,
+          total_size_bytes: 1024,
+          disk_free_bytes: 1024 * 1024,
+          disk_total_bytes: 2048 * 1024,
+        }),
+      ),
+      http.get('/api/v1/settings/', () => HttpResponse.json({ check_updates: false })),
+      http.get('/api/v1/projects/', () => HttpResponse.json([])),
+      http.get('/api/v1/archives/', () => HttpResponse.json([])),
+    );
+  });
+
+  afterEach(() => {
+    window.history.pushState({}, '', '/');
+  });
+
+  it("opens the file's folder and marks the file", async () => {
+    window.history.pushState({}, '', '/files?highlight=5');
+    render(<FileManagerPage />);
+
+    await screen.findByText('the-queued-one.3mf');
+    expect(listedFolders).toContain('3');
+    await waitFor(() => expect(card('the-queued-one.3mf').style.outline).toContain('solid'));
+    expect(card('its-neighbour.3mf').style.outline).toBe('');
+  });
+
+  it('marks the row in the list view', async () => {
+    setViewMode('list');
+    window.history.pushState({}, '', '/files?highlight=5');
+    render(<FileManagerPage />);
+
+    await screen.findByText('the-queued-one.3mf');
+    await waitFor(() => expect(card('the-queued-one.3mf').style.outline).toContain('solid'));
+  });
+
+  it('focuses the file in the columns view', async () => {
+    setViewMode('columns');
+    window.history.pushState({}, '', '/files?highlight=5');
+    render(<FileManagerPage />);
+
+    await waitFor(() => expect(screen.getAllByText('the-queued-one.3mf').length).toBeGreaterThan(0));
+    await waitFor(() => {
+      const row = screen.getAllByText('the-queued-one.3mf')[0].closest('[data-file-id]') as HTMLElement;
+      expect(row.style.outline).toContain('solid');
+      expect(row.className).toContain('ring-1');
+    });
+  });
+
+  it('warns when the file is gone or not readable', async () => {
+    window.history.pushState({}, '', '/files?highlight=404');
+    render(<FileManagerPage />);
+
+    expect(await screen.findByText(/File not found - it may have been deleted/)).toBeInTheDocument();
+    expect(await screen.findByText('at-the-root.3mf')).toBeInTheDocument();
+  });
+
+  it("warns when the file is not in its folder's listing", async () => {
+    window.history.pushState({}, '', '/files?highlight=7');
+    render(<FileManagerPage />);
+
+    expect(await screen.findByText(/File not found - it may have been deleted/)).toBeInTheDocument();
+    expect(listedFolders).toContain('3');
+  });
+
+  it('looks for the file in a fresh listing, not one cached before it was moved in', async () => {
+    folder3BeforeTheMove = true;
+    window.history.pushState({}, '', '/files?folder=3');
+    render(<><FileManagerPage /><GoTo to="/files?highlight=5" /></>);
+    await screen.findByText('its-neighbour.3mf');
+
+    folder3BeforeTheMove = false;
+    fireEvent.click(screen.getByText('go'));
+
+    await waitFor(() => expect(card('the-queued-one.3mf').style.outline).toContain('solid'));
+    expect(screen.queryByText(/File not found - it may have been deleted/)).not.toBeInTheDocument();
+  });
+
+  it('does not pull someone back who opened another folder while the file was looked up', async () => {
+    let answer: () => void = () => {};
+    server.use(
+      http.get('/api/v1/library/files/5', async () => {
+        await new Promise<void>((resolve) => { answer = resolve; });
+        return HttpResponse.json(target);
+      }),
+    );
+    window.history.pushState({}, '', '/files?highlight=5');
+    render(<FileManagerPage />);
+    await screen.findByText('at-the-root.3mf');
+
+    fireEvent.click(screen.getAllByText('Elsewhere')[0]);
+    await waitFor(() => expect(listedFolders).toContain('4'));
+    answer();
+
+    await new Promise((resolve) => setTimeout(resolve, 300));
+    expect(listedFolders).not.toContain('3');
+    expect(screen.queryByText('the-queued-one.3mf')).not.toBeInTheDocument();
+  });
+
+  it('marks nothing without the parameter', async () => {
+    window.history.pushState({}, '', '/files');
+    render(<FileManagerPage />);
+
+    await screen.findByText('at-the-root.3mf');
+    expect(card('at-the-root.3mf').style.outline).toBe('');
+    expect(listedFolders).not.toContain('3');
+  });
+});

+ 17 - 0
frontend/src/__tests__/pages/ProjectDetailPage.test.tsx

@@ -90,6 +90,23 @@ describe('ProjectDetailPage', () => {
     );
     );
   });
   });
 
 
+  describe('archive tiles', () => {
+    it('open the archive on the Archives page, marked (#3336)', async () => {
+      server.use(
+        http.get('/api/v1/projects/:id/archives', () =>
+          HttpResponse.json([{ id: 42, print_name: 'Bracket', status: 'completed', thumbnail_path: null }]),
+        ),
+        http.get('/api/v1/library/files', () => HttpResponse.json([])),
+      );
+
+      render(<ProjectDetailPage />);
+
+      // ?search= was never read by the Archives page.
+      const tile = (await screen.findByText('Bracket')).closest('a');
+      expect(tile).toHaveAttribute('href', '/archives?highlight=42');
+    });
+  });
+
   describe('isSlicedFilename — conditional print button', () => {
   describe('isSlicedFilename — conditional print button', () => {
     it('shows print button for .gcode files', async () => {
     it('shows print button for .gcode files', async () => {
       server.use(
       server.use(

+ 58 - 1
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -3,7 +3,7 @@
  */
  */
 
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor, within } from '@testing-library/react';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
 import { QueuePage } from '../../pages/QueuePage';
 import { QueuePage } from '../../pages/QueuePage';
@@ -257,6 +257,63 @@ describe('QueuePage', () => {
       expect(screen.queryByText(/printed in .*, the file asks for/)).not.toBeInTheDocument();
       expect(screen.queryByText(/printed in .*, the file asks for/)).not.toBeInTheDocument();
     });
     });
 
 
+    it('links a File Manager job to the File Manager, pointing at the file (#3336)', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () =>
+          HttpResponse.json([
+            { ...mockQueueItems[0], id: 90, archive_id: null, archive_name: null, library_file_id: 13, library_file_name: 'From the library' },
+          ]),
+        ),
+      );
+
+      render(<QueuePage />);
+      await screen.findByText('From the library');
+
+      // /library is no route at all: the page came up empty.
+      expect(screen.getByTitle('View in File Manager')).toHaveAttribute('href', '/files?highlight=13');
+    });
+
+    it('links an archive job to its archive', async () => {
+      render(<QueuePage />);
+      await screen.findByText('Test Print 1');
+
+      expect(screen.getAllByTitle('View archive').map((a) => a.getAttribute('href'))).toContain('/archives?highlight=1');
+    });
+
+    describe('larger thumbnail on hover (#3313)', () => {
+      const realMatchMedia = window.matchMedia;
+      beforeEach(() => {
+        window.matchMedia = ((query: string) => ({ ...realMatchMedia(query), matches: true })) as typeof window.matchMedia;
+      });
+      afterEach(() => {
+        window.matchMedia = realMatchMedia;
+      });
+
+      function hoverThumbnail(archiveId: number) {
+        const thumb = document.querySelector(`img[src*="/archives/${archiveId}/thumbnail"]`) as HTMLElement;
+        fireEvent.mouseEnter(thumb.parentElement as HTMLElement);
+      }
+
+      it('shows on a queue card', async () => {
+        render(<QueuePage />);
+        await screen.findByText('Test Print 1');
+
+        hoverThumbnail(1);
+
+        expect(screen.getByTestId('thumbnail-hover-preview').getAttribute('src')).toContain('/archives/1/thumbnail');
+      });
+
+      it('shows on a history row', async () => {
+        render(<QueuePage />);
+        await userEvent.setup().click(await screen.findByRole('button', { name: /^History/ }));
+        await screen.findByText('Completed Print');
+
+        hoverThumbnail(3);
+
+        expect(screen.getByTestId('thumbnail-hover-preview').getAttribute('src')).toContain('/archives/3/thumbnail');
+      });
+    });
+
     it('shows the queue override colour instead of the original 3MF colour (#3132)', async () => {
     it('shows the queue override colour instead of the original 3MF colour (#3132)', async () => {
       const item = {
       const item = {
         ...mockQueueItems[0],
         ...mockQueueItems[0],

+ 8 - 22
frontend/src/components/CompactHistoryRow.tsx

@@ -13,6 +13,7 @@ import {
   Weight,
   Weight,
 } from 'lucide-react';
 } from 'lucide-react';
 import { queueItemThumbnailUrl } from '../utils/queueItemThumbnail';
 import { queueItemThumbnailUrl } from '../utils/queueItemThumbnail';
+import { ThumbnailHoverPreview } from './ThumbnailHoverPreview';
 import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
 import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
 import type { PrintQueueItem, Permission } from '../api/client';
 import type { PrintQueueItem, Permission } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -74,30 +75,15 @@ export function CompactHistoryRow({
       <div className="flex items-center gap-2 sm:gap-3">
       <div className="flex items-center gap-2 sm:gap-3">
         <StatusIcon className={`w-4 h-4 shrink-0 ${config.color}`} />
         <StatusIcon className={`w-4 h-4 shrink-0 ${config.color}`} />
 
 
-        <div className="relative shrink-0 history-thumb-hover">
-          <div className="w-8 h-8 bg-bambu-dark rounded overflow-hidden">
-            {thumbnailUrl ? (
-              <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
-            ) : (
-              <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-                <Layers className="w-4 h-4" />
-              </div>
-            )}
-          </div>
-          {/* Hover preview — desktop only via CSS @media. Positioned to the
-              right of the thumbnail; the parent card has no overflow:hidden
-              so the popup escapes its rounded border. pointer-events-none so
-              it doesn't interfere with clicks on rows below. */}
-          {thumbnailUrl && (
-            <div className="history-thumb-preview absolute z-[60] left-full top-0 ml-2 w-48 h-48 pointer-events-none opacity-0 transition-opacity">
-              <img
-                src={thumbnailUrl}
-                alt=""
-                className="w-full h-full object-cover rounded-lg shadow-2xl border-2 border-bambu-dark-tertiary bg-bambu-dark"
-              />
+        <ThumbnailHoverPreview src={thumbnailUrl} className="w-8 h-8 shrink-0 bg-bambu-dark rounded overflow-hidden">
+          {thumbnailUrl ? (
+            <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
+          ) : (
+            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+              <Layers className="w-4 h-4" />
             </div>
             </div>
           )}
           )}
-        </div>
+        </ThumbnailHoverPreview>
 
 
         <span className="text-sm text-white font-medium truncate min-w-0 flex-1">
         <span className="text-sm text-white font-medium truncate min-w-0 flex-1">
           {displayName}
           {displayName}

+ 62 - 0
frontend/src/components/ThumbnailHoverPreview.tsx

@@ -0,0 +1,62 @@
+import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+
+const PREVIEW_SIZE = 256;
+const GAP = 8;
+const EDGE_MARGIN = 8;
+
+interface ThumbnailHoverPreviewProps {
+  /** The image to enlarge; null shows nothing on hover. */
+  src: string | null;
+  children: ReactNode;
+  className?: string;
+}
+
+/**
+ * A thumbnail that shows a larger copy of itself while the pointer rests on it
+ * (#3313). Desktop only: a touch screen has no hover. The preview is portalled
+ * and fixed-positioned, so a parent with overflow:hidden (a batch group's card)
+ * cannot clip it. It sits to the right of the thumbnail, or to the left when
+ * there is no room, and is kept inside the viewport vertically.
+ */
+export function ThumbnailHoverPreview({ src, children, className }: ThumbnailHoverPreviewProps) {
+  const anchorRef = useRef<HTMLDivElement>(null);
+  const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
+
+  const show = useCallback(() => {
+    if (!src || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
+    const rect = anchorRef.current?.getBoundingClientRect();
+    if (!rect) return;
+    const rightOf = rect.right + GAP;
+    const left = rightOf + PREVIEW_SIZE + EDGE_MARGIN <= window.innerWidth
+      ? rightOf
+      : Math.max(EDGE_MARGIN, rect.left - GAP - PREVIEW_SIZE);
+    const top = Math.max(EDGE_MARGIN, Math.min(rect.top, window.innerHeight - PREVIEW_SIZE - EDGE_MARGIN));
+    setPosition({ left, top });
+  }, [src]);
+
+  const hide = useCallback(() => setPosition(null), []);
+
+  // A scroll moves the thumbnail out from under the fixed preview.
+  useEffect(() => {
+    if (!position) return;
+    window.addEventListener('scroll', hide, true);
+    return () => window.removeEventListener('scroll', hide, true);
+  }, [position, hide]);
+
+  return (
+    <div ref={anchorRef} className={className} onMouseEnter={show} onMouseLeave={hide}>
+      {children}
+      {position && src && createPortal(
+        <img
+          src={src}
+          alt=""
+          data-testid="thumbnail-hover-preview"
+          style={{ left: position.left, top: position.top, width: PREVIEW_SIZE, height: PREVIEW_SIZE }}
+          className="fixed z-[60] pointer-events-none object-cover rounded-lg shadow-2xl border-2 border-bambu-dark-tertiary bg-bambu-dark"
+        />,
+        document.body,
+      )}
+    </div>
+  );
+}

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

@@ -1017,6 +1017,7 @@ export default {
     noArchives: 'Keine Archive gefunden',
     noArchives: 'Keine Archive gefunden',
     noArchivesSearch: 'Keine Archive entsprechen Ihrer Suche',
     noArchivesSearch: 'Keine Archive entsprechen Ihrer Suche',
     originalPrintNotVisible: 'Ursprünglicher Druck nicht sichtbar - versuchen Sie, die Filter zu löschen',
     originalPrintNotVisible: 'Ursprünglicher Druck nicht sichtbar - versuchen Sie, die Filter zu löschen',
+    linkedArchiveNotVisible: 'Verknüpftes Archiv nicht sichtbar - versuchen Sie, die Filter zu löschen',
     noArchivesYet: 'Noch keine Archive',
     noArchivesYet: 'Noch keine Archive',
     prints: 'Drucke',
     prints: 'Drucke',
     pagination: {
     pagination: {
@@ -4328,6 +4329,7 @@ export default {
     folderIsEmpty: 'Ordner ist leer',
     folderIsEmpty: 'Ordner ist leer',
     noFilesOutsideFolders: 'Keine Dateien außerhalb von Ordnern',
     noFilesOutsideFolders: 'Keine Dateien außerhalb von Ordnern',
     noFilesYet: 'Noch keine Dateien',
     noFilesYet: 'Noch keine Dateien',
+    highlightedFileNotFound: 'Datei nicht gefunden - sie wurde möglicherweise gelöscht oder Sie haben keinen Zugriff darauf',
     folderEmptyDescription: 'Laden Sie Dateien hoch oder verschieben Sie Dateien in diesen Ordner.',
     folderEmptyDescription: 'Laden Sie Dateien hoch oder verschieben Sie Dateien in diesen Ordner.',
     noFilesDescription: 'Laden Sie Dateien hoch, um Ihre Druckdateien zu organisieren.',
     noFilesDescription: 'Laden Sie Dateien hoch, um Ihre Druckdateien zu organisieren.',
     noMatchingFiles: 'Keine passenden Dateien',
     noMatchingFiles: 'Keine passenden Dateien',

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

@@ -1024,6 +1024,7 @@ export default {
     noArchives: 'No archives found',
     noArchives: 'No archives found',
     noArchivesSearch: 'No archives match your search',
     noArchivesSearch: 'No archives match your search',
     originalPrintNotVisible: 'Original print not visible - try clearing filters',
     originalPrintNotVisible: 'Original print not visible - try clearing filters',
+    linkedArchiveNotVisible: 'Linked archive not visible - try clearing filters',
     noArchivesYet: 'No archives yet',
     noArchivesYet: 'No archives yet',
     prints: 'prints',
     prints: 'prints',
     pagination: {
     pagination: {
@@ -4360,6 +4361,7 @@ export default {
     folderIsEmpty: 'Folder is empty',
     folderIsEmpty: 'Folder is empty',
     noFilesOutsideFolders: 'No files outside folders',
     noFilesOutsideFolders: 'No files outside folders',
     noFilesYet: 'No files yet',
     noFilesYet: 'No files yet',
+    highlightedFileNotFound: 'File not found - it may have been deleted or you may not have access to it',
     folderEmptyDescription: 'Upload files or move files into this folder to get started.',
     folderEmptyDescription: 'Upload files or move files into this folder to get started.',
     noFilesDescription: 'Upload files to start organizing your print-related files.',
     noFilesDescription: 'Upload files to start organizing your print-related files.',
     noMatchingFiles: 'No matching files',
     noMatchingFiles: 'No matching files',

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

@@ -1017,6 +1017,7 @@ export default {
     noArchives: 'No se encontraron archivos',
     noArchives: 'No se encontraron archivos',
     noArchivesSearch: 'Ningún archivo coincide con su búsqueda',
     noArchivesSearch: 'Ningún archivo coincide con su búsqueda',
     originalPrintNotVisible: 'La impresión original no está visible - pruebe a borrar los filtros',
     originalPrintNotVisible: 'La impresión original no está visible - pruebe a borrar los filtros',
+    linkedArchiveNotVisible: 'El archivo vinculado no está visible - pruebe a borrar los filtros',
     noArchivesYet: 'Aún no hay archivos',
     noArchivesYet: 'Aún no hay archivos',
     prints: 'impresiones',
     prints: 'impresiones',
     pagination: {
     pagination: {
@@ -4330,6 +4331,7 @@ export default {
     folderIsEmpty: 'La carpeta está vacía',
     folderIsEmpty: 'La carpeta está vacía',
     noFilesOutsideFolders: 'No hay archivos fuera de las carpetas',
     noFilesOutsideFolders: 'No hay archivos fuera de las carpetas',
     noFilesYet: 'Aún no hay archivos',
     noFilesYet: 'Aún no hay archivos',
+    highlightedFileNotFound: 'Archivo no encontrado - puede que se haya eliminado o que no tenga acceso a él',
     folderEmptyDescription: 'Suba archivos o mueva archivos a esta carpeta para empezar.',
     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.',
     noFilesDescription: 'Suba archivos para empezar a organizar sus archivos relacionados con la impresión.',
     noMatchingFiles: 'No hay archivos coincidentes',
     noMatchingFiles: 'No hay archivos coincidentes',

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

@@ -1017,6 +1017,7 @@ export default {
     noArchives: 'Aucune archive trouvée',
     noArchives: 'Aucune archive trouvée',
     noArchivesSearch: 'Aucune archive ne correspond',
     noArchivesSearch: 'Aucune archive ne correspond',
     originalPrintNotVisible: 'Impression d\'origine non visible - essayez d\'effacer les filtres',
     originalPrintNotVisible: 'Impression d\'origine non visible - essayez d\'effacer les filtres',
+    linkedArchiveNotVisible: 'Archive liée non visible - essayez d\'effacer les filtres',
     noArchivesYet: 'Pas encore d\'archive',
     noArchivesYet: 'Pas encore d\'archive',
     prints: 'impressions',
     prints: 'impressions',
     pagination: {
     pagination: {
@@ -4316,6 +4317,7 @@ export default {
     folderIsEmpty: 'Dossier vide',
     folderIsEmpty: 'Dossier vide',
     noFilesOutsideFolders: 'Aucun fichier hors des dossiers',
     noFilesOutsideFolders: 'Aucun fichier hors des dossiers',
     noFilesYet: 'Aucun fichier',
     noFilesYet: 'Aucun fichier',
+    highlightedFileNotFound: 'Fichier introuvable - il a peut-être été supprimé ou vous n\'y avez pas accès',
     folderEmptyDescription: 'Téléversez ou déplacez des fichiers ici.',
     folderEmptyDescription: 'Téléversez ou déplacez des fichiers ici.',
     noFilesDescription: 'Téléversez des fichiers pour organiser.',
     noFilesDescription: 'Téléversez des fichiers pour organiser.',
     noMatchingFiles: 'Aucun fichier correspondant',
     noMatchingFiles: 'Aucun fichier correspondant',

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

@@ -1017,6 +1017,7 @@ export default {
     noArchives: 'Nessun archivio trovato',
     noArchives: 'Nessun archivio trovato',
     noArchivesSearch: 'Nessun archivio corrisponde alla ricerca',
     noArchivesSearch: 'Nessun archivio corrisponde alla ricerca',
     originalPrintNotVisible: 'Stampa originale non visibile - prova a rimuovere i filtri',
     originalPrintNotVisible: 'Stampa originale non visibile - prova a rimuovere i filtri',
+    linkedArchiveNotVisible: 'Archivio collegato non visibile - prova a rimuovere i filtri',
     noArchivesYet: 'Nessun archivio ancora',
     noArchivesYet: 'Nessun archivio ancora',
     prints: 'stampe',
     prints: 'stampe',
     pagination: {
     pagination: {
@@ -4315,6 +4316,7 @@ export default {
     folderIsEmpty: 'La cartella e vuota',
     folderIsEmpty: 'La cartella e vuota',
     noFilesOutsideFolders: 'Nessun file fuori dalle cartelle',
     noFilesOutsideFolders: 'Nessun file fuori dalle cartelle',
     noFilesYet: 'Nessun file ancora',
     noFilesYet: 'Nessun file ancora',
+    highlightedFileNotFound: 'File non trovato - potrebbe essere stato eliminato o potresti non avervi accesso',
     folderEmptyDescription: 'Carica file o sposta file in questa cartella per iniziare.',
     folderEmptyDescription: 'Carica file o sposta file in questa cartella per iniziare.',
     noFilesDescription: 'Carica file per iniziare a organizzare i file di stampa.',
     noFilesDescription: 'Carica file per iniziare a organizzare i file di stampa.',
     noMatchingFiles: 'Nessun file corrispondente',
     noMatchingFiles: 'Nessun file corrispondente',

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

@@ -1016,6 +1016,7 @@ export default {
     noArchives: 'アーカイブが見つかりません',
     noArchives: 'アーカイブが見つかりません',
     noArchivesSearch: '検索条件に一致するアーカイブがありません',
     noArchivesSearch: '検索条件に一致するアーカイブがありません',
     originalPrintNotVisible: '元の印刷が表示されていません - フィルターをクリアしてみてください',
     originalPrintNotVisible: '元の印刷が表示されていません - フィルターをクリアしてみてください',
+    linkedArchiveNotVisible: 'リンクされたアーカイブが表示されていません - フィルターをクリアしてみてください',
     noArchivesYet: 'アーカイブはまだありません',
     noArchivesYet: 'アーカイブはまだありません',
     prints: '件',
     prints: '件',
     pagination: {
     pagination: {
@@ -4328,6 +4329,7 @@ export default {
     folderIsEmpty: 'フォルダーは空です',
     folderIsEmpty: 'フォルダーは空です',
     noFilesOutsideFolders: 'フォルダー外のファイルはありません',
     noFilesOutsideFolders: 'フォルダー外のファイルはありません',
     noFilesYet: 'ファイルはまだありません',
     noFilesYet: 'ファイルはまだありません',
+    highlightedFileNotFound: 'ファイルが見つかりません - 削除されたか、アクセス権がない可能性があります',
     folderEmptyDescription: 'ファイルをアップロードするか、このフォルダーにファイルを移動して開始しましょう。',
     folderEmptyDescription: 'ファイルをアップロードするか、このフォルダーにファイルを移動して開始しましょう。',
     noFilesDescription: '印刷関連ファイルの整理を始めるにはファイルをアップロードしてください。',
     noFilesDescription: '印刷関連ファイルの整理を始めるにはファイルをアップロードしてください。',
     noMatchingFiles: '一致するファイルがありません',
     noMatchingFiles: '一致するファイルがありません',

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

@@ -973,6 +973,7 @@ export default {
     noArchives: '아카이브를 찾을 수 없습니다',
     noArchives: '아카이브를 찾을 수 없습니다',
     noArchivesSearch: '검색 조건에 맞는 아카이브가 없습니다',
     noArchivesSearch: '검색 조건에 맞는 아카이브가 없습니다',
     originalPrintNotVisible: '원본 인쇄가 보이지 않음 - 필터를 지워보세요',
     originalPrintNotVisible: '원본 인쇄가 보이지 않음 - 필터를 지워보세요',
+    linkedArchiveNotVisible: '연결된 아카이브가 보이지 않음 - 필터를 지워보세요',
     noArchivesYet: '아직 아카이브가 없습니다',
     noArchivesYet: '아직 아카이브가 없습니다',
     prints: '인쇄',
     prints: '인쇄',
     pagination: {
     pagination: {
@@ -4139,6 +4140,7 @@ export default {
     folderIsEmpty: '폴더가 비어 있음',
     folderIsEmpty: '폴더가 비어 있음',
     noFilesOutsideFolders: '폴더 밖에 있는 파일 없음',
     noFilesOutsideFolders: '폴더 밖에 있는 파일 없음',
     noFilesYet: '파일 없음',
     noFilesYet: '파일 없음',
+    highlightedFileNotFound: '파일을 찾을 수 없음 - 삭제되었거나 접근 권한이 없을 수 있습니다',
     folderEmptyDescription: '파일을 업로드하거나 이 폴더로 이동하여 시작하세요.',
     folderEmptyDescription: '파일을 업로드하거나 이 폴더로 이동하여 시작하세요.',
     noFilesDescription: '파일을 업로드하여 인쇄 관련 파일을 정리하세요.',
     noFilesDescription: '파일을 업로드하여 인쇄 관련 파일을 정리하세요.',
     noMatchingFiles: '일치하는 파일 없음',
     noMatchingFiles: '일치하는 파일 없음',

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

@@ -1022,6 +1022,7 @@ export default {
     noArchives: 'Geen archieven gevonden',
     noArchives: 'Geen archieven gevonden',
     noArchivesSearch: 'Geen archieven komen overeen met je zoekopdracht',
     noArchivesSearch: 'Geen archieven komen overeen met je zoekopdracht',
     originalPrintNotVisible: 'Originele afdruk niet zichtbaar - probeer filters te wissen',
     originalPrintNotVisible: 'Originele afdruk niet zichtbaar - probeer filters te wissen',
+    linkedArchiveNotVisible: 'Gekoppeld archief niet zichtbaar - probeer filters te wissen',
     noArchivesYet: 'Nog geen archieven',
     noArchivesYet: 'Nog geen archieven',
     prints: 'afdrukken',
     prints: 'afdrukken',
     pagination: {
     pagination: {
@@ -4358,6 +4359,7 @@ export default {
     folderIsEmpty: 'Map is leeg',
     folderIsEmpty: 'Map is leeg',
     noFilesOutsideFolders: 'Geen bestanden buiten mappen',
     noFilesOutsideFolders: 'Geen bestanden buiten mappen',
     noFilesYet: 'Nog geen bestanden',
     noFilesYet: 'Nog geen bestanden',
+    highlightedFileNotFound: 'Bestand niet gevonden - het is mogelijk verwijderd of je hebt er geen toegang toe',
     folderEmptyDescription: 'Upload bestanden of verplaats bestanden naar deze map om te beginnen.',
     folderEmptyDescription: 'Upload bestanden of verplaats bestanden naar deze map om te beginnen.',
     noFilesDescription: 'Upload bestanden om je afdrukgerelateerde bestanden te organiseren.',
     noFilesDescription: 'Upload bestanden om je afdrukgerelateerde bestanden te organiseren.',
     noMatchingFiles: 'Geen overeenkomende bestanden',
     noMatchingFiles: 'Geen overeenkomende bestanden',

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

@@ -1017,6 +1017,7 @@ export default {
     noArchives: 'Nenhum arquivo encontrado',
     noArchives: 'Nenhum arquivo encontrado',
     noArchivesSearch: 'Nenhum arquivo corresponde à sua pesquisa',
     noArchivesSearch: 'Nenhum arquivo corresponde à sua pesquisa',
     originalPrintNotVisible: 'Impressão original não visível - tente limpar os filtros',
     originalPrintNotVisible: 'Impressão original não visível - tente limpar os filtros',
+    linkedArchiveNotVisible: 'Arquivo vinculado não visível - tente limpar os filtros',
     noArchivesYet: 'Ainda não há arquivos',
     noArchivesYet: 'Ainda não há arquivos',
     prints: 'impressões',
     prints: 'impressões',
     pagination: {
     pagination: {
@@ -4315,6 +4316,7 @@ export default {
     folderIsEmpty: 'A pasta está vazia',
     folderIsEmpty: 'A pasta está vazia',
     noFilesOutsideFolders: 'Nenhum arquivo fora das pastas',
     noFilesOutsideFolders: 'Nenhum arquivo fora das pastas',
     noFilesYet: 'Nenhum arquivo ainda',
     noFilesYet: 'Nenhum arquivo ainda',
+    highlightedFileNotFound: 'Arquivo não encontrado - ele pode ter sido excluído ou você pode não ter acesso a ele',
     folderEmptyDescription: 'Envie arquivos ou mova arquivos para esta pasta para começar.',
     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.',
     noFilesDescription: 'Envie arquivos para começar a organizar seus arquivos relacionados à impressão.',
     noMatchingFiles: 'Nenhum arquivo correspondente',
     noMatchingFiles: 'Nenhum arquivo correspondente',

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

@@ -972,6 +972,7 @@ export default {
     noArchives: "Архивы не найдены",
     noArchives: "Архивы не найдены",
     noArchivesSearch: "По вашему запросу ничего не найдено",
     noArchivesSearch: "По вашему запросу ничего не найдено",
     originalPrintNotVisible: "Исходная печать не отображается — попробуйте сбросить фильтры",
     originalPrintNotVisible: "Исходная печать не отображается — попробуйте сбросить фильтры",
+    linkedArchiveNotVisible: "Связанный архив не отображается — попробуйте сбросить фильтры",
     noArchivesYet: "Архив пока пуст",
     noArchivesYet: "Архив пока пуст",
     prints: "печатей",
     prints: "печатей",
     pagination: {
     pagination: {
@@ -4131,6 +4132,7 @@ export default {
     folderIsEmpty: "Папка пуста",
     folderIsEmpty: "Папка пуста",
     noFilesOutsideFolders: "Нет файлов вне папок",
     noFilesOutsideFolders: "Нет файлов вне папок",
     noFilesYet: "Файлов пока нет",
     noFilesYet: "Файлов пока нет",
+    highlightedFileNotFound: "Файл не найден — возможно, он удалён или у вас нет к нему доступа",
     folderEmptyDescription: "Загрузите или переместите файлы в эту папку.",
     folderEmptyDescription: "Загрузите или переместите файлы в эту папку.",
     noFilesDescription: "Загрузите файлы, чтобы начать упорядочивать материалы для печати.",
     noFilesDescription: "Загрузите файлы, чтобы начать упорядочивать материалы для печати.",
     noMatchingFiles: "Подходящие файлы не найдены",
     noMatchingFiles: "Подходящие файлы не найдены",

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

@@ -1022,6 +1022,7 @@ export default {
     noArchives: 'Inga arkiv hittade',
     noArchives: 'Inga arkiv hittade',
     noArchivesSearch: 'Inga arkiv matchar din sökning',
     noArchivesSearch: 'Inga arkiv matchar din sökning',
     originalPrintNotVisible: 'Originalutskriften syns inte – prova att nollställa filter',
     originalPrintNotVisible: 'Originalutskriften syns inte – prova att nollställa filter',
+    linkedArchiveNotVisible: 'Det länkade arkivet syns inte – prova att nollställa filter',
     noArchivesYet: 'Inga arkiv än',
     noArchivesYet: 'Inga arkiv än',
     prints: 'utskrifter',
     prints: 'utskrifter',
     pagination: {
     pagination: {
@@ -4357,6 +4358,7 @@ errors: {
     folderIsEmpty: 'Mappen är tom',
     folderIsEmpty: 'Mappen är tom',
     noFilesOutsideFolders: 'Inga filer utanför mappar',
     noFilesOutsideFolders: 'Inga filer utanför mappar',
     noFilesYet: 'Inga filer än',
     noFilesYet: 'Inga filer än',
+    highlightedFileNotFound: 'Filen hittades inte – den kan ha tagits bort eller så saknar du åtkomst till den',
     folderEmptyDescription: 'Ladda upp filer eller flytta filer till denna mapp för att komma igång.',
     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.',
     noFilesDescription: 'Ladda upp filer för att börja organisera dina utskriftsrelaterade filer.',
     noMatchingFiles: 'Inga matchande filer',
     noMatchingFiles: 'Inga matchande filer',

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

@@ -1017,6 +1017,7 @@ export default {
     noArchives: 'Arşiv bulunamadı',
     noArchives: 'Arşiv bulunamadı',
     noArchivesSearch: 'Aramanızla eşleşen arşiv yok',
     noArchivesSearch: 'Aramanızla eşleşen arşiv yok',
     originalPrintNotVisible: 'Orijinal baskı görünmüyor - filtreleri temizlemeyi deneyin',
     originalPrintNotVisible: 'Orijinal baskı görünmüyor - filtreleri temizlemeyi deneyin',
+    linkedArchiveNotVisible: 'Bağlı arşiv görünmüyor - filtreleri temizlemeyi deneyin',
     noArchivesYet: 'Henüz arşiv yok',
     noArchivesYet: 'Henüz arşiv yok',
     prints: 'baskı',
     prints: 'baskı',
     pagination: {
     pagination: {
@@ -4323,6 +4324,7 @@ export default {
     folderIsEmpty: 'Klasör boş',
     folderIsEmpty: 'Klasör boş',
     noFilesOutsideFolders: 'Klasör dışında dosya yok',
     noFilesOutsideFolders: 'Klasör dışında dosya yok',
     noFilesYet: 'Henüz dosya yok',
     noFilesYet: 'Henüz dosya yok',
+    highlightedFileNotFound: 'Dosya bulunamadı - silinmiş olabilir veya erişiminiz olmayabilir',
     folderEmptyDescription: 'Başlamak için bu klasöre dosya yükleyin veya dosyaları taşıyın.',
     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.',
     noFilesDescription: 'Baskıyla ilgili dosyalarınızı organize etmeye başlamak için dosya yükleyin.',
     noMatchingFiles: 'Eşleşen dosya yok',
     noMatchingFiles: 'Eşleşen dosya yok',

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

@@ -1021,6 +1021,7 @@ export default {
     noArchives: "Архіви не знайдено",
     noArchives: "Архіви не знайдено",
     noArchivesSearch: "Жоден архів не відповідає вашому пошуку",
     noArchivesSearch: "Жоден архів не відповідає вашому пошуку",
     originalPrintNotVisible: "Оригінальний друк не відображається — спробуйте очистити фільтри",
     originalPrintNotVisible: "Оригінальний друк не відображається — спробуйте очистити фільтри",
+    linkedArchiveNotVisible: "Пов'язаний архів не відображається — спробуйте очистити фільтри",
     noArchivesYet: "Архівів ще немає",
     noArchivesYet: "Архівів ще немає",
     prints: "друків",
     prints: "друків",
     pagination: {
     pagination: {
@@ -4355,6 +4356,7 @@ export default {
     folderIsEmpty: "Папка порожня",
     folderIsEmpty: "Папка порожня",
     noFilesOutsideFolders: "Немає файлів поза папками",
     noFilesOutsideFolders: "Немає файлів поза папками",
     noFilesYet: "Файлів ще немає",
     noFilesYet: "Файлів ще немає",
+    highlightedFileNotFound: "Файл не знайдено — можливо, його видалено або у вас немає до нього доступу",
     folderEmptyDescription: "Щоб почати, вивантажте файли або перемістіть їх до цієї папки.",
     folderEmptyDescription: "Щоб почати, вивантажте файли або перемістіть їх до цієї папки.",
     noFilesDescription: "Вивантажте файли, щоб упорядковувати матеріали для друку.",
     noFilesDescription: "Вивантажте файли, щоб упорядковувати матеріали для друку.",
     noMatchingFiles: "Немає відповідних файлів",
     noMatchingFiles: "Немає відповідних файлів",

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

@@ -1017,6 +1017,7 @@ export default {
     noArchives: '未找到归档',
     noArchives: '未找到归档',
     noArchivesSearch: '没有匹配搜索的归档',
     noArchivesSearch: '没有匹配搜索的归档',
     originalPrintNotVisible: '原始打印不可见 - 请尝试清除筛选条件',
     originalPrintNotVisible: '原始打印不可见 - 请尝试清除筛选条件',
+    linkedArchiveNotVisible: '关联的归档不可见 - 请尝试清除筛选条件',
     noArchivesYet: '暂无归档',
     noArchivesYet: '暂无归档',
     prints: '条打印',
     prints: '条打印',
     pagination: {
     pagination: {
@@ -4315,6 +4316,7 @@ export default {
     folderIsEmpty: '文件夹为空',
     folderIsEmpty: '文件夹为空',
     noFilesOutsideFolders: '文件夹外没有文件',
     noFilesOutsideFolders: '文件夹外没有文件',
     noFilesYet: '暂无文件',
     noFilesYet: '暂无文件',
+    highlightedFileNotFound: '未找到文件 - 它可能已被删除,或您没有访问权限',
     folderEmptyDescription: '上传文件或将文件移入此文件夹以开始使用。',
     folderEmptyDescription: '上传文件或将文件移入此文件夹以开始使用。',
     noFilesDescription: '上传文件以开始组织您的打印相关文件。',
     noFilesDescription: '上传文件以开始组织您的打印相关文件。',
     noMatchingFiles: '没有匹配的文件',
     noMatchingFiles: '没有匹配的文件',

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

@@ -1017,6 +1017,7 @@ export default {
     noArchives: '未找到歸檔',
     noArchives: '未找到歸檔',
     noArchivesSearch: '沒有匹配搜尋的歸檔',
     noArchivesSearch: '沒有匹配搜尋的歸檔',
     originalPrintNotVisible: '原始列印不可見 - 請嘗試清除篩選條件',
     originalPrintNotVisible: '原始列印不可見 - 請嘗試清除篩選條件',
+    linkedArchiveNotVisible: '關聯的封存不可見 - 請嘗試清除篩選條件',
     noArchivesYet: '尚無歸檔',
     noArchivesYet: '尚無歸檔',
     prints: '條列印',
     prints: '條列印',
     pagination: {
     pagination: {
@@ -4315,6 +4316,7 @@ export default {
     folderIsEmpty: '資料夾為空',
     folderIsEmpty: '資料夾為空',
     noFilesOutsideFolders: '資料夾外沒有檔案',
     noFilesOutsideFolders: '資料夾外沒有檔案',
     noFilesYet: '尚無檔案',
     noFilesYet: '尚無檔案',
+    highlightedFileNotFound: '找不到檔案 - 可能已被刪除,或您沒有存取權限',
     folderEmptyDescription: '上傳檔案或將檔案移入此資料夾以開始使用。',
     folderEmptyDescription: '上傳檔案或將檔案移入此資料夾以開始使用。',
     noFilesDescription: '上傳檔案以開始組織您的列印相關檔案。',
     noFilesDescription: '上傳檔案以開始組織您的列印相關檔案。',
     noMatchingFiles: '沒有匹配的檔案',
     noMatchingFiles: '沒有匹配的檔案',

+ 0 - 7
frontend/src/index.css

@@ -583,13 +583,6 @@ body {
   background-color: color-mix(in srgb, var(--text-muted) 80%, transparent);
   background-color: color-mix(in srgb, var(--text-muted) 80%, transparent);
 }
 }
 
 
-/* History row thumbnail hover-to-enlarge — desktop only (no hover on touch). */
-@media (hover: hover) and (pointer: fine) {
-  .history-thumb-hover:hover .history-thumb-preview {
-    opacity: 1;
-  }
-}
-
 /* Per-slot filament colour swatch in the slice modal (#2977). A native
 /* Per-slot filament colour swatch in the slice modal (#2977). A native
    <input type="color"> paints its value inside a fixed inner padding and its
    <input type="color"> paints its value inside a fixed inner padding and its
    own border, so at 14px the colour reads as a dot inside a pale ring rather
    own border, so at 14px the colour reads as a dot inside a pale ring rather

+ 37 - 14
frontend/src/pages/ArchivesPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
 import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
@@ -318,11 +318,6 @@ function ArchiveCard({
    *  the badge names the one the mapping is actually good for. */
    *  the badge names the one the mapping is actually good for. */
   printerMap: Map<number, string>;
   printerMap: Map<number, string>;
 }) {
 }) {
-  // Debug: log when card is highlighted
-  if (isHighlighted) {
-    console.log('ArchiveCard isHighlighted=true for archive:', archive.id);
-  }
-
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { hasPermission, canModify } = useAuth();
   const { hasPermission, canModify } = useAuth();
@@ -3134,7 +3129,9 @@ export function ArchivesPage() {
   const [showTagManagement, setShowTagManagement] = useState(false);
   const [showTagManagement, setShowTagManagement] = useState(false);
   const [showPurgeModal, setShowPurgeModal] = useState(false);
   const [showPurgeModal, setShowPurgeModal] = useState(false);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
-  const [pendingNavigationArchiveId, setPendingNavigationArchiveId] = useState<number | null>(null);
+  // An archive navigated to from elsewhere, and the warning shown when the
+  // current filters hide it.
+  const [pendingNavigation, setPendingNavigation] = useState<{ archiveId: number; notVisibleKey: string } | null>(null);
 
 
   // Log view state
   // Log view state
   const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>
   const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>
@@ -3176,23 +3173,24 @@ export function ArchivesPage() {
   );
   );
 
 
   const handleNavigateToArchive = useCallback((archiveId: number) => {
   const handleNavigateToArchive = useCallback((archiveId: number) => {
-    setPendingNavigationArchiveId(archiveId);
+    setPendingNavigation({ archiveId, notVisibleKey: 'archives.originalPrintNotVisible' });
     setHighlightedArchiveId(archiveId);
     setHighlightedArchiveId(archiveId);
   }, []);
   }, []);
 
 
   // Clear highlight after 5 seconds and scroll to highlighted element
   // Clear highlight after 5 seconds and scroll to highlighted element
   useEffect(() => {
   useEffect(() => {
     if (highlightedArchiveId) {
     if (highlightedArchiveId) {
+      const pending = pendingNavigation?.archiveId === highlightedArchiveId ? pendingNavigation : null;
       // Scroll to highlighted element after a short delay (to let the view render)
       // Scroll to highlighted element after a short delay (to let the view render)
       const scrollTimer = setTimeout(() => {
       const scrollTimer = setTimeout(() => {
         const element = document.querySelector(`[data-archive-id="${highlightedArchiveId}"]`);
         const element = document.querySelector(`[data-archive-id="${highlightedArchiveId}"]`);
         if (element) {
         if (element) {
           element.scrollIntoView({ behavior: 'smooth', block: 'center' });
           element.scrollIntoView({ behavior: 'smooth', block: 'center' });
-        } else if (pendingNavigationArchiveId === highlightedArchiveId) {
-          showToast(t('archives.originalPrintNotVisible'), 'warning');
+        } else if (pending) {
+          showToast(t(pending.notVisibleKey), 'warning');
         }
         }
-        if (pendingNavigationArchiveId === highlightedArchiveId) {
-          setPendingNavigationArchiveId(null);
+        if (pending) {
+          setPendingNavigation(null);
         }
         }
       }, 100);
       }, 100);
 
 
@@ -3203,13 +3201,38 @@ export function ArchivesPage() {
         clearTimeout(clearTimer);
         clearTimeout(clearTimer);
       };
       };
     }
     }
-  }, [highlightedArchiveId, pendingNavigationArchiveId, showToast, t]);
+  }, [highlightedArchiveId, pendingNavigation, showToast, t]);
 
 
-  const { data: archives, isLoading } = useQuery({
+  const { data: archives, isLoading, dataUpdatedAt: archivesUpdatedAt, refetch: refetchArchives } = useQuery({
     queryKey: ['archives', filterPrinter],
     queryKey: ['archives', filterPrinter],
     queryFn: () => api.getArchives(filterPrinter || undefined),
     queryFn: () => api.getArchives(filterPrinter || undefined),
   });
   });
 
 
+  // ?highlight=<archive id> (the queue card's and a project's archive links)
+  // points at one archive. It is looked for in a freshly fetched list: one
+  // cached from an earlier visit may predate the archive, which a queued job
+  // creates when it starts. So the page jump finds it, and the warning fires
+  // only when the filters really hide it.
+  const [searchParams] = useSearchParams();
+  const highlightParam = searchParams.get('highlight');
+  const handledHighlightRef = useRef<string | null>(null);
+  const [highlightRequest, setHighlightRequest] = useState<{ archiveId: number; requestedAt: number } | null>(null);
+  useEffect(() => {
+    if (!highlightParam || handledHighlightRef.current === highlightParam) return;
+    handledHighlightRef.current = highlightParam;
+    const archiveId = parseInt(highlightParam, 10);
+    if (!Number.isFinite(archiveId)) return;
+    setHighlightRequest({ archiveId, requestedAt: Date.now() });
+    refetchArchives();
+  }, [highlightParam, refetchArchives]);
+  useEffect(() => {
+    if (!highlightRequest || !archives || archivesUpdatedAt < highlightRequest.requestedAt) return;
+    setHighlightRequest(null);
+    if (viewMode === 'log' || viewMode === 'calendar') setViewMode('grid');
+    setPendingNavigation({ archiveId: highlightRequest.archiveId, notVisibleKey: 'archives.linkedArchiveNotVisible' });
+    setHighlightedArchiveId(highlightRequest.archiveId);
+  }, [highlightRequest, archives, archivesUpdatedAt, viewMode]);
+
   const { data: printers } = useQuery({
   const { data: printers } = useQuery({
     queryKey: ['printers'],
     queryKey: ['printers'],
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,

+ 76 - 3
frontend/src/pages/FileManagerPage.tsx

@@ -1033,9 +1033,15 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
 }
 }
 
 
 // File Card
 // File Card
+// The mark on a file a ?highlight= link points at; the Archives page's colour.
+const HIGHLIGHT_CARD_STYLE = { outline: '4px solid #facc15', outlineOffset: '2px' } as const;
+const HIGHLIGHT_ROW_STYLE = { outline: '2px solid #facc15', outlineOffset: '-2px' } as const;
+
 interface FileCardProps {
 interface FileCardProps {
   file: LibraryFileListItem;
   file: LibraryFileListItem;
   isSelected: boolean;
   isSelected: boolean;
+  /** The file a ?highlight= link points at, marked for a few seconds. */
+  isHighlighted?: boolean;
   onSelect: (id: number) => void;
   onSelect: (id: number) => void;
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
@@ -1062,7 +1068,7 @@ interface FileCardProps {
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, isHighlighted, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
@@ -1168,6 +1174,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
 
 
   return (
   return (
     <div
     <div
+      data-file-id={file.id}
+      style={isHighlighted ? HIGHLIGHT_CARD_STYLE : undefined}
       className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer ${
       className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer ${
         isSelected
         isSelected
           ? 'border-bambu-green ring-1 ring-bambu-green'
           ? 'border-bambu-green ring-1 ring-bambu-green'
@@ -1530,6 +1538,7 @@ function FileActionStrip({ file, onPrint, onSlice, onOpenInSlicer, onRunPipeline
 interface ColumnFileRowProps {
 interface ColumnFileRowProps {
   file: LibraryFileListItem;
   file: LibraryFileListItem;
   isSelected: boolean;
   isSelected: boolean;
+  isHighlighted?: boolean;
   isFocused: boolean;
   isFocused: boolean;
   showModified: boolean;
   showModified: boolean;
   thumbnailVersion?: number;
   thumbnailVersion?: number;
@@ -1539,11 +1548,12 @@ interface ColumnFileRowProps {
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function ColumnFileRow({ file, isSelected, isFocused, showModified, thumbnailVersion, onSelect, onOpen, actionProps, t }: ColumnFileRowProps) {
+function ColumnFileRow({ file, isSelected, isHighlighted, isFocused, showModified, thumbnailVersion, onSelect, onOpen, actionProps, t }: ColumnFileRowProps) {
   const thumbnailUrl = api.getLibraryFileThumbnailUrl(file.id);
   const thumbnailUrl = api.getLibraryFileThumbnailUrl(file.id);
   return (
   return (
     <div
     <div
       data-file-id={file.id}
       data-file-id={file.id}
+      style={isHighlighted ? HIGHLIGHT_ROW_STYLE : undefined}
       onClick={() => onSelect(file)}
       onClick={() => onSelect(file)}
       onDoubleClick={() => onOpen(file)}
       onDoubleClick={() => onOpen(file)}
       className={`group flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors ${
       className={`group flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors ${
@@ -1745,6 +1755,39 @@ export function FileManagerPage() {
     }
     }
   }, [searchParams]);
   }, [searchParams]);
 
 
+  // ?highlight=<file id> points at one file (the queue card's "View in File
+  // Manager"). The link carries only the id: the folder is looked up here, so
+  // a file moved since it was queued is still found. The folder's listing is
+  // refetched before the file is looked for in it, since a cached one may
+  // predate the move.
+  const highlightParam = searchParams.get('highlight');
+  const [pendingHighlight, setPendingHighlight] = useState<{ fileId: number; folderId: number | null; requestedAt: number } | null>(null);
+  const [highlightedFileId, setHighlightedFileId] = useState<number | null>(null);
+  const selectedFolderIdRef = useRef(selectedFolderId);
+  selectedFolderIdRef.current = selectedFolderId;
+  useEffect(() => {
+    const fileId = highlightParam ? parseInt(highlightParam, 10) : NaN;
+    if (!Number.isFinite(fileId)) return;
+    let cancelled = false;
+    const folderAtRequest = selectedFolderIdRef.current;
+    api.getLibraryFile(fileId).then(
+      (file) => {
+        // Someone who opened another folder meanwhile is not pulled back.
+        if (cancelled || selectedFolderIdRef.current !== folderAtRequest) return;
+        const requestedAt = Date.now();
+        queryClient.invalidateQueries({ queryKey: ['library-files'] });
+        setSelectedFolderId(file.folder_id);
+        setPendingHighlight({ fileId, folderId: file.folder_id, requestedAt });
+      },
+      () => {
+        if (!cancelled) showToast(t('fileManager.highlightedFileNotFound'), 'warning');
+      },
+    );
+    return () => {
+      cancelled = true;
+    };
+  }, [highlightParam, queryClient, showToast, t]);
+
   // Queries
   // Queries
   const { data: settings } = useUiSettings();
   const { data: settings } = useUiSettings();
 
 
@@ -1894,7 +1937,7 @@ export function FileManagerPage() {
   // folder.
   // folder.
   const paneRootOnly = viewMode === 'columns' && selectedFolderId === null && !columnsFilterActive;
   const paneRootOnly = viewMode === 'columns' && selectedFolderId === null && !columnsFilterActive;
 
 
-  const { data: files, isLoading: filesLoading } = useQuery({
+  const { data: files, isLoading: filesLoading, dataUpdatedAt: filesUpdatedAt } = useQuery({
     queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey, paneRootOnly],
     queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey, paneRootOnly],
     // When a specific folder is selected we list its contents directly; when
     // When a specific folder is selected we list its contents directly; when
     // no folder is selected the topLevelView pseudo-node decides whether the
     // no folder is selected the topLevelView pseudo-node decides whether the
@@ -1944,6 +1987,31 @@ export function FileManagerPage() {
     [files, searchQuery, filterType, filterUsername, sortField, sortDirection],
     [files, searchQuery, filterType, filterUsername, sortField, sortDirection],
   );
   );
 
 
+  // Once the file's folder is listed afresh, mark the file; then scroll to it
+  // and drop the mark after a few seconds, as the Archives page does.
+  useEffect(() => {
+    if (!pendingHighlight || filesLoading || !files || selectedFolderId !== pendingHighlight.folderId) return;
+    if (filesUpdatedAt < pendingHighlight.requestedAt) return;
+    if (filteredAndSortedFiles.some((f) => f.id === pendingHighlight.fileId)) {
+      setHighlightedFileId(pendingHighlight.fileId);
+      if (viewMode === 'columns') setColumnsFocusedFileId(pendingHighlight.fileId);
+    } else {
+      showToast(t('fileManager.highlightedFileNotFound'), 'warning');
+    }
+    setPendingHighlight(null);
+  }, [pendingHighlight, filesLoading, files, filesUpdatedAt, selectedFolderId, filteredAndSortedFiles, viewMode, showToast, t]);
+  useEffect(() => {
+    if (highlightedFileId === null) return;
+    const scrollTimer = setTimeout(() => {
+      document.querySelector(`[data-file-id="${highlightedFileId}"]`)?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
+    }, 100);
+    const clearTimer = setTimeout(() => setHighlightedFileId(null), 5000);
+    return () => {
+      clearTimeout(scrollTimer);
+      clearTimeout(clearTimer);
+    };
+  }, [highlightedFileId]);
+
   // Check if disk space is low
   // Check if disk space is low
   const isDiskSpaceLow = useMemo(() => {
   const isDiskSpaceLow = useMemo(() => {
     if (!stats || !settings) return false;
     if (!stats || !settings) return false;
@@ -3546,6 +3614,7 @@ export function FileManagerPage() {
                         key={file.id}
                         key={file.id}
                         file={file}
                         file={file}
                         isSelected={selectedFiles.includes(file.id)}
                         isSelected={selectedFiles.includes(file.id)}
+                        isHighlighted={highlightedFileId === file.id}
                         isFocused={columnsFocusedFileId === file.id}
                         isFocused={columnsFocusedFileId === file.id}
                         showModified={showModified}
                         showModified={showModified}
                         thumbnailVersion={thumbnailVersions[file.id]}
                         thumbnailVersion={thumbnailVersions[file.id]}
@@ -3592,6 +3661,7 @@ export function FileManagerPage() {
                         key={file.id}
                         key={file.id}
                         file={file}
                         file={file}
                         isSelected={selectedFiles.includes(file.id)}
                         isSelected={selectedFiles.includes(file.id)}
+                        isHighlighted={highlightedFileId === file.id}
                         isFocused={columnsFocusedFileId === file.id}
                         isFocused={columnsFocusedFileId === file.id}
                         showModified={showModified}
                         showModified={showModified}
                         thumbnailVersion={thumbnailVersions[file.id]}
                         thumbnailVersion={thumbnailVersions[file.id]}
@@ -3657,6 +3727,7 @@ export function FileManagerPage() {
                     key={file.id}
                     key={file.id}
                     file={file}
                     file={file}
                     isSelected={selectedFiles.includes(file.id)}
                     isSelected={selectedFiles.includes(file.id)}
+                    isHighlighted={highlightedFileId === file.id}
                     t={t}
                     t={t}
                     onSelect={handleFileSelect}
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
@@ -3714,6 +3785,8 @@ export function FileManagerPage() {
                   <div
                   <div
                     key={file.id}
                     key={file.id}
                     data-testid="file-list-grid-row"
                     data-testid="file-list-grid-row"
+                    data-file-id={file.id}
+                    style={highlightedFileId === file.id ? HIGHLIGHT_ROW_STYLE : undefined}
                     className={`grid ${fileListGridColumns(authEnabled)} ${fileListGridMinWidth} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
                     className={`grid ${fileListGridColumns(authEnabled)} ${fileListGridMinWidth} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     }`}

+ 1 - 1
frontend/src/pages/ProjectDetailPage.tsx

@@ -129,7 +129,7 @@ function ArchiveGrid({ archives, t }: { archives: Archive[]; t: TFunction }) {
       {archives.map((archive) => (
       {archives.map((archive) => (
         <Link
         <Link
           key={archive.id}
           key={archive.id}
-          to={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
+          to={`/archives?highlight=${archive.id}`}
           className="group relative aspect-square rounded-lg bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden hover:border-bambu-green transition-colors"
           className="group relative aspect-square rounded-lg bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden hover:border-bambu-green transition-colors"
         >
         >
           {archive.thumbnail_path ? (
           {archive.thumbnail_path ? (

+ 7 - 3
frontend/src/pages/QueuePage.tsx

@@ -88,6 +88,7 @@ import { ColorSubstitutionNotes } from '../components/ColorSubstitutionNotes';
 import { QueueTimelineView } from '../components/QueueTimelineView';
 import { QueueTimelineView } from '../components/QueueTimelineView';
 import { compareQueueOrder } from '../utils/queueOrder';
 import { compareQueueOrder } from '../utils/queueOrder';
 import { BatchOrdersView } from '../components/BatchOrdersView';
 import { BatchOrdersView } from '../components/BatchOrdersView';
+import { ThumbnailHoverPreview } from '../components/ThumbnailHoverPreview';
 import { buildLoadedFilaments, type LoadedFilament } from '../hooks/useFilamentMapping';
 import { buildLoadedFilaments, type LoadedFilament } from '../hooks/useFilamentMapping';
 import { hasGcodeSnippets, useUiSettings } from '../hooks/useUiSettings';
 import { hasGcodeSnippets, useUiSettings } from '../hooks/useUiSettings';
 
 
@@ -805,7 +806,10 @@ function SortableQueueItem({
         )}
         )}
 
 
         {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
         {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
-        <div className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
+        <ThumbnailHoverPreview
+          src={thumbnailUrl}
+          className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden"
+        >
           {thumbnailUrl ? (
           {thumbnailUrl ? (
             <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
             <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
           ) : (
           ) : (
@@ -813,7 +817,7 @@ function SortableQueueItem({
               <Layers className="w-5 h-5 sm:w-6 sm:h-6" />
               <Layers className="w-5 h-5 sm:w-6 sm:h-6" />
             </div>
             </div>
           )}
           )}
-        </div>
+        </ThumbnailHoverPreview>
 
 
         {/* Info */}
         {/* Info */}
         <div className="flex-1 min-w-0">
         <div className="flex-1 min-w-0">
@@ -832,7 +836,7 @@ function SortableQueueItem({
               </Link>
               </Link>
             ) : item.library_file_id ? (
             ) : item.library_file_id ? (
               <Link
               <Link
-                to={`/library?highlight=${item.library_file_id}`}
+                to={`/files?highlight=${item.library_file_id}`}
                 className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
                 className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
                 title={t('queue.viewInFileManager')}
                 title={t('queue.viewInFileManager')}
               >
               >