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

Merge pull request #2763 from pascalheidmann/feature/slicer-multi-button

Feature/slicer multi button
MartinNYHC 1 месяц назад
Родитель
Сommit
62f4779564

+ 244 - 3
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -8,6 +8,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
 import { setStreamToken } from '../../api/client';
+import { openInSlicer } from '../../utils/slicer';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
@@ -28,6 +29,15 @@ vi.mock('../../components/GcodeViewer', () => ({
   ),
 }));
 
+// Only the protocol-handler launch is stubbed — it would navigate the jsdom
+// window. Everything else in the module is a pure predicate, so keep the real
+// implementations: re-declaring them here would let the file-type rule these
+// tests assert on drift away from the one the component actually runs.
+vi.mock('../../utils/slicer', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../../utils/slicer')>()),
+  openInSlicer: vi.fn(),
+}));
+
 const mockCapabilities = {
   has_model: true,
   has_gcode: true,
@@ -512,12 +522,12 @@ describe('ModelViewerModal', () => {
       });
     });
 
-    it('disables Open in Slicer for non-3mf library files', async () => {
+    it('disables Open in Slicer for library files that cannot be handed to a slicer', async () => {
       render(
         <ModelViewerModal
           libraryFileId={1}
-          title="Model.stl"
-          fileType="stl"
+          title="Model.gcode"
+          fileType="gcode"
           onClose={mockOnClose}
         />
       );
@@ -528,4 +538,235 @@ describe('ModelViewerModal', () => {
       });
     });
   });
+
+  describe('slicer split button (#2725)', () => {
+    it('shows both slicers in the dropdown when Bambuddy is the default slicer', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ use_slicer_api: true });
+        }),
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.3mf"
+          fileType="3mf"
+          onClose={mockOnClose}
+          onSliceWithBambuddy={vi.fn()}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
+    });
+
+    it('opens the selected local slicer from the Bambuddy dropdown', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ use_slicer_api: true });
+        }),
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.3mf"
+          fileType="3mf"
+          onClose={mockOnClose}
+          onSliceWithBambuddy={vi.fn()}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      const orcaItem = await screen.findByText('Open in OrcaSlicer');
+      fireEvent.click(orcaItem);
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
+      });
+    });
+
+    it('shows only the non-preferred slicer in the dropdown for a desktop handoff', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument();
+    });
+
+    it('offers Bambu Studio when the preferred desktop slicer is OrcaSlicer', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ preferred_slicer: 'orcaslicer' });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Open in OrcaSlicer')).not.toBeInTheDocument();
+    });
+
+    it('closes the split dropdown on Escape without closing the modal', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+      await waitFor(() => {
+        expect(screen.getByRole('menu')).toBeInTheDocument();
+      });
+
+      fireEvent.keyDown(document, { key: 'Escape' });
+
+      expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+      expect(mockOnClose).not.toHaveBeenCalled();
+    });
+
+    it('closes the split dropdown on an outside click without closing the modal', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+      await waitFor(() => {
+        expect(screen.getByRole('menu')).toBeInTheDocument();
+      });
+
+      fireEvent.mouseDown(document.body);
+
+      expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+      expect(mockOnClose).not.toHaveBeenCalled();
+    });
+
+    it('does not render a split chevron when the file cannot open in a slicer', async () => {
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.gcode"
+          fileType="gcode"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeDisabled();
+      });
+
+      expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
+    });
+
+    it('offers the desktop handoff for an STL library file', async () => {
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeEnabled();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
+    });
+
+    it('offers the split Slice button for an STL when the slicer API is enabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ use_slicer_api: true });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+          onSliceWithBambuddy={vi.fn()}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
+    });
+  });
 });

+ 229 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -3,13 +3,30 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { FileManagerPage } from '../../pages/FileManagerPage';
+import { openInSlicer } from '../../utils/slicer';
+import { setAuthToken } from '../../api/client';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
+// Only the protocol-handler launch is stubbed — it would navigate the jsdom
+// window. Everything else in the module is a pure predicate, so keep the real
+// implementations: isSliceableFilename decides which rows even offer the
+// action these tests click.
+vi.mock('../../utils/slicer', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../../utils/slicer')>()),
+  openInSlicer: vi.fn(),
+}));
+
+vi.mock('../../components/SliceModal', () => ({
+  SliceModal: ({ source }: { source: { filename: string } }) => (
+    <div data-testid="slice-modal">{source.filename}</div>
+  ),
+}));
+
 // Mock data
 const mockFolders = [
   {
@@ -1155,4 +1172,215 @@ describe('FileManagerPage', () => {
       });
     });
   });
+
+  describe('slice action', () => {
+    beforeEach(() => {
+      vi.mocked(openInSlicer).mockClear();
+      server.use(
+        http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
+      );
+    });
+
+    afterEach(() => {
+      // Permission tests set a token; clear it so it can't leak into the
+      // list-view tests that follow (mirrors FileManagerFolderDelete.test.tsx).
+      setAuthToken(null);
+    });
+
+    const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
+      const card = screen.getByText(filename).closest('.group') as HTMLElement;
+      // Target the kebab (ellipsis) toggle specifically rather than the card's
+      // first button — a button added ahead of the kebab would otherwise
+      // break the menu-opening assumption.
+      const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.click(kebab);
+      return card;
+    };
+
+    it('opens the desktop slicer when the slicer API is disabled', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      await user.click(within(card).getByText('Slice'));
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(
+          expect.stringContaining('/library/files/2/dl/test-token/'),
+          'bambu_studio',
+        );
+      });
+    });
+
+    it('opens the in-app SliceModal when the slicer API is enabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      await user.click(within(card).getByText('Slice'));
+
+      expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('hides the slice item for already-sliced files', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'Benchy');
+      expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
+    });
+
+    // Permission gating is the security-relevant half of the slice action: the
+    // in-app API path needs library:upload, and the desktop handoff mirrors the
+    // ownership check the slicer-token endpoint runs — library:read_all or
+    // library:read_own. The legacy library:read is deliberately not accepted;
+    // it satisfies neither the token endpoint nor the folder listing that gets
+    // a user to this page at all.
+    const mockAuthUser = (permissions: string[]) => {
+      setAuthToken('test-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () =>
+          HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+        ),
+        http.get('*/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 7,
+            username: 'operator1',
+            is_admin: false,
+            permissions,
+          }),
+        ),
+        http.get('/api/v1/users/', () => HttpResponse.json([])),
+      );
+    };
+
+    it('disables the Slice menu item without library:upload when the slicer API is enabled', async () => {
+      mockAuthUser([]);
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).toBeDisabled();
+
+      await user.click(sliceItem!);
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('enables the Slice menu item with library:upload when the slicer API is enabled', async () => {
+      mockAuthUser(['library:upload']);
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).not.toBeDisabled();
+    });
+
+    it('disables the Slice menu item without any library read permission for the desktop handoff', async () => {
+      mockAuthUser(['library:upload']);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).toBeDisabled();
+
+      await user.click(sliceItem!);
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('enables the Slice menu item with library:read_own for the desktop handoff', async () => {
+      mockAuthUser(['library:read_own']);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).not.toBeDisabled();
+    });
+
+    it('does not accept the legacy library:read for the desktop handoff', async () => {
+      // require_ownership_permission(LIBRARY_READ_ALL, LIBRARY_READ_OWN) does no
+      // legacy expansion, so this group 403s on the slicer-token endpoint.
+      // Enabling the item would offer an action the server refuses, and the
+      // failure would look like "no slicer installed" once the fallback URL is
+      // handed over.
+      mockAuthUser(['library:read']);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).toBeDisabled();
+
+      await user.click(sliceItem!);
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('slices from the list-view button when the slicer API is disabled', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('List view'));
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
+      await user.click(within(row).getByTitle('Slice'));
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(
+          expect.stringContaining('/library/files/2/dl/test-token/'),
+          'bambu_studio',
+        );
+      });
+    });
+
+    it('slices from the list-view button into the in-app modal when the slicer API is enabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('List view'));
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
+      await user.click(within(row).getByTitle('Slice'));
+
+      expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+  });
 });

+ 44 - 0
frontend/src/__tests__/pages/MakerworldPage.test.tsx

@@ -61,6 +61,8 @@ function resolveResponse(overrides: Partial<Record<string, unknown>> = {}) {
 // Individual tests layer extra handlers on top via ``server.use``.
 function useAuthedHandlers(opts: {
   slicer?: 'bambu_studio' | 'orcaslicer';
+  openInSlicer?: 'bambu_studio' | 'orcaslicer' | null;
+  useSlicerApi?: boolean;
   recent?: Array<Record<string, unknown>>;
 } = {}) {
   const slicer = opts.slicer ?? 'bambu_studio';
@@ -74,6 +76,8 @@ function useAuthedHandlers(opts: {
         auto_archive: true,
         save_thumbnails: true,
         preferred_slicer: slicer,
+        ...(opts.openInSlicer !== undefined ? { open_in_slicer: opts.openInSlicer } : {}),
+        ...(opts.useSlicerApi !== undefined ? { use_slicer_api: opts.useSlicerApi } : {}),
       }),
     ),
   );
@@ -190,6 +194,46 @@ describe('MakerworldPage', () => {
     expect(sliceButtons.length).toBe(2);
   });
 
+  it('prefers the open_in_slicer override over preferred_slicer for the desktop handoff', async () => {
+    // The URI-handoff label resolves via resolveDesktopSlicer: open_in_slicer
+    // beats preferred_slicer (#1329) when the slicer API is off.
+    useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer' });
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    const sliceButtons = await screen.findAllByRole('button', {
+      name: /Save & Slice in OrcaSlicer/,
+    });
+    expect(sliceButtons.length).toBe(2);
+  });
+
+  it('keeps preferred_slicer for the in-app API slice label, ignoring open_in_slicer', async () => {
+    // With the slicer API on, the sidecar drives the button, so the
+    // open_in_slicer desktop override must not leak into the label.
+    useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer', useSlicerApi: true });
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    const sliceButtons = await screen.findAllByRole('button', {
+      name: /Save & Slice in Bambu Studio/,
+    });
+    expect(sliceButtons.length).toBe(2);
+  });
+
   it('clears the resolved preview when the URL input is edited after resolve', async () => {
     useAuthedHandlers();
     server.use(

+ 140 - 21
frontend/src/components/ModelViewerModal.tsx

@@ -1,12 +1,13 @@
-import { useState, useEffect, useRef, useMemo } from 'react';
+import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
+import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { api, withStreamToken } from '../api/client';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { useToast } from '../contexts/ToastContext';
+import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 type ViewTab = '3d' | 'gcode';
@@ -32,14 +33,105 @@ interface Capabilities {
   filament_colors: string[];
 }
 
+interface SlicerSplitButtonProps {
+  icon: ReactNode;
+  label: string;
+  dropdownLabel: string;
+  onPrimary: () => void;
+  items: Array<{ key: string; label: string; onClick: () => void }>;
+}
+
+// Split button: the primary part runs the default slicer action, the chevron
+// opens a dropdown with the other slicer options. Outside click or Escape
+// (non-propagating) closes the dropdown. The split only renders when the
+// action is already possible, so there is no disabled state to express.
+function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) {
+  const [open, setOpen] = useState(false);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const handlePointerDown = (e: MouseEvent) => {
+      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+        setOpen(false);
+      }
+    };
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        e.stopPropagation();
+        setOpen(false);
+      }
+    };
+    document.addEventListener('mousedown', handlePointerDown);
+    document.addEventListener('keydown', handleKeyDown);
+    return () => {
+      document.removeEventListener('mousedown', handlePointerDown);
+      document.removeEventListener('keydown', handleKeyDown);
+    };
+  }, [open]);
+
+  return (
+    <div className="relative inline-flex" ref={containerRef}>
+      <div className="flex relative z-50">
+        <Button
+          variant="secondary"
+          size="sm"
+          onClick={() => {
+            setOpen(false);
+            onPrimary();
+          }}
+          className="rounded-r-none"
+        >
+          {icon}
+          {label}
+        </Button>
+        <Button
+          variant="secondary"
+          size="sm"
+          onClick={() => setOpen((prev) => !prev)}
+          aria-label={dropdownLabel}
+          aria-haspopup="menu"
+          aria-expanded={open}
+          className="rounded-l-none border-l border-bambu-dark px-2"
+        >
+          <ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
+        </Button>
+      </div>
+      {open && (
+        <div
+          role="menu"
+          className="absolute right-0 top-full mt-1 w-56 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-50 py-1"
+        >
+          {items.map((item) => (
+            <button
+              key={item.key}
+              type="button"
+              role="menuitem"
+              onClick={() => {
+                setOpen(false);
+                item.onClick();
+              }}
+              className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
+            >
+              <ExternalLink className="w-4 h-4 flex-shrink-0" />
+              {item.label}
+            </button>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
   const { t } = useTranslation();
+  const { showToast } = useToast();
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
   // Desktop "Open in Slicer" target — falls back to preferred_slicer when the
   // user hasn't explicitly chosen a different desktop slicer (#1329). This
   // variable is only used for URI-handoff; sidecar slicing keeps using
   // preferred_slicer directly.
-  const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
+  const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
   const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
@@ -280,7 +372,13 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     };
   }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
 
-  const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
+  // Which file types can be handed to a desktop slicer via the URL protocol
+  // handler — and sliced in-app via the sidecar. Shares its list with
+  // `isSliceableFilename()`, which the File Manager's card menu and list row
+  // use, so a file's "Slice" action and its 3D-preview slicer button can no
+  // longer disagree about the same file.
+  const slicerReadyType = isSliceableFileType(fileType);
+  const canOpenInSlicer = isLibrary ? slicerReadyType : true;
 
   // When the user has the in-app Slicer API enabled (Settings → Workflow →
   // Slicer → Use Slicer API), library-mode previews route the header's slicer
@@ -288,39 +386,49 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   // in the file-row actions. Falls back to the external-slicer launcher when
   // the API is off, when no in-app handler is wired (e.g. archive preview),
   // or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
-  const sliceableType = (() => {
-    const t = (fileType || '').toLowerCase();
-    return t === '3mf' || t === 'stl' || t === 'step' || t === 'stp';
-  })();
   const useBambuddySlicer = Boolean(
-    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType,
+    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && slicerReadyType,
   );
 
-  const handleOpenInSlicer = async () => {
+  const handleOpenInSlicer = async (slicer: SlicerType) => {
     if (!canOpenInSlicer) return;
     const filename = title || 'model';
     try {
       if (isLibrary) {
         const { token } = await api.createLibrarySlicerToken(libraryFileId!);
         const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
-        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+        openInSlicer(`${window.location.origin}${path}`, slicer);
       } else {
         const { token } = await api.createArchiveSlicerToken(archiveId!);
         const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
-        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+        openInSlicer(`${window.location.origin}${path}`, slicer);
       }
     } catch {
-      // Fallback to direct URL (works when auth is disabled)
+      // Fallback to direct URL (works when auth is disabled). With auth on the
+      // slicer may then hit a 401, so surface the failure instead of making a
+      // permission denial look identical to "no slicer installed".
+      showToast(t('modelViewer.openInSlicerFailed'), 'error');
       if (isLibrary) {
         const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-        openInSlicer(downloadUrl, preferredSlicer);
+        openInSlicer(downloadUrl, slicer);
       } else {
         const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
-        openInSlicer(downloadUrl, preferredSlicer);
+        openInSlicer(downloadUrl, slicer);
       }
     }
   };
 
+  const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
+    ? ['bambu_studio', 'orcaslicer']
+    : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
+  const slicerName = (slicer: SlicerType) =>
+    slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
+  const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
+    key: slicer,
+    label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }),
+    onClick: () => handleOpenInSlicer(slicer),
+  }));
+
   return (
     <div
       className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
@@ -344,12 +452,23 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
           </div>
           <div className="flex items-center gap-2">
             {useBambuddySlicer ? (
-              <Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
-                <Cog className="w-4 h-4" />
-                {t('slice.action')}
-              </Button>
+              <SlicerSplitButton
+                icon={<Cog className="w-4 h-4" />}
+                label={t('slice.action')}
+                dropdownLabel={t('modelViewer.moreSlicerOptions')}
+                onPrimary={() => onSliceWithBambuddy?.()}
+                items={slicerDropdownItems}
+              />
+            ) : canOpenInSlicer ? (
+              <SlicerSplitButton
+                icon={<ExternalLink className="w-4 h-4" />}
+                label={t('modelViewer.openInSlicer')}
+                dropdownLabel={t('modelViewer.moreSlicerOptions')}
+                onPrimary={() => handleOpenInSlicer(preferredSlicer)}
+                items={slicerDropdownItems}
+              />
             ) : (
-              <Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
+              <Button variant="secondary" size="sm" disabled>
                 <ExternalLink className="w-4 h-4" />
                 {t('modelViewer.openInSlicer')}
               </Button>

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

@@ -3820,6 +3820,7 @@ export default {
     scanFolder: 'Scannen',
     toast: {
       folderCreated: 'Ordner erstellt',
+      openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
       folderDeleted: 'Ordner gelöscht',
       fileDeleted: 'Datei gelöscht',
       filesDeleted: '{{count}} Dateien gelöscht',
@@ -5358,6 +5359,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Im Slicer öffnen',
+    openInSlicerWith: 'In {{slicer}} öffnen',
+    moreSlicerOptions: 'Weitere Slicer-Optionen',
+    openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
     tabs: {
       model: '3D-Modell',
       gcode: 'G-Code Vorschau',

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

@@ -3849,6 +3849,7 @@ export default {
     scanFolder: 'Scan',
     toast: {
       folderCreated: 'Folder created',
+      openInSlicerFailed: 'Could not open in slicer',
       folderDeleted: 'Folder deleted',
       fileDeleted: 'File deleted',
       filesDeleted: 'Deleted {{count}} files',
@@ -5402,6 +5403,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Open in Slicer',
+    openInSlicerWith: 'Open in {{slicer}}',
+    moreSlicerOptions: 'More slicer options',
+    openInSlicerFailed: 'Could not open in slicer',
     tabs: {
       model: '3D Model',
       gcode: 'G-code Preview',

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

@@ -3823,6 +3823,7 @@ export default {
     scanFolder: 'Escanear',
     toast: {
       folderCreated: 'Carpeta creada',
+      openInSlicerFailed: 'No se pudo abrir en el laminador',
       folderDeleted: 'Carpeta eliminada',
       fileDeleted: 'Archivo eliminado',
       filesDeleted: 'Se eliminaron {{count}} archivos',
@@ -5367,6 +5368,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Abrir en el laminador',
+    openInSlicerWith: 'Abrir en {{slicer}}',
+    moreSlicerOptions: 'Más opciones de laminador',
+    openInSlicerFailed: 'No se pudo abrir en el laminador',
     tabs: {
       model: 'Modelo 3D',
       gcode: 'Vista previa de G-code',

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

@@ -3809,6 +3809,7 @@ export default {
     scanFolder: 'Scanner',
     toast: {
       folderCreated: 'Dossier créé',
+      openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
       folderDeleted: 'Dossier supprimé',
       fileDeleted: 'Fichier supprimé',
       filesDeleted: '{{count}} fichiers supprimés',
@@ -5348,6 +5349,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Ouvrir dans le Slicer',
+    openInSlicerWith: 'Ouvrir dans {{slicer}}',
+    moreSlicerOptions: "Plus d'options de slicer",
+    openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
     tabs: {
       model: 'Modèle 3D',
       gcode: 'Aperçu G-code',

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: 'Scansiona',
     toast: {
       folderCreated: 'Cartella creata',
+      openInSlicerFailed: 'Impossibile aprire nello slicer',
       folderDeleted: 'Cartella eliminata',
       fileDeleted: 'File eliminato',
       filesDeleted: 'Eliminati {{count}} file',
@@ -5347,6 +5348,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Apri nello slicer',
+    openInSlicerWith: 'Apri in {{slicer}}',
+    moreSlicerOptions: 'Altre opzioni dello slicer',
+    openInSlicerFailed: 'Impossibile aprire nello slicer',
     tabs: {
       model: 'Modello 3D',
       gcode: 'Anteprima G-code',

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

@@ -3820,6 +3820,7 @@ export default {
     scanFolder: 'スキャン',
     toast: {
       folderCreated: 'フォルダを作成しました',
+      openInSlicerFailed: 'スライサーで開けませんでした',
       folderDeleted: 'フォルダを削除しました',
       fileDeleted: 'ファイルを削除しました',
       filesDeleted: '{{count}}件のファイルを削除しました',
@@ -5359,6 +5360,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'スライサーで開く',
+    openInSlicerWith: '{{slicer}}で開く',
+    moreSlicerOptions: 'その他のスライサーオプション',
+    openInSlicerFailed: 'スライサーで開けませんでした',
     tabs: {
       model: '3Dモデル',
       gcode: 'G-codeプレビュー',

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

@@ -3631,6 +3631,7 @@ export default {
     scanFolder: '스캔',
     toast: {
       folderCreated: '폴더 생성됨',
+      openInSlicerFailed: '슬라이서에서 열 수 없습니다',
       folderDeleted: '폴더 삭제됨',
       fileDeleted: '파일 삭제됨',
       filesDeleted: '{{count}}개 파일 삭제됨',
@@ -5094,6 +5095,9 @@ export default {
   },
   modelViewer: {
     openInSlicer: '슬라이서에서 열기',
+    openInSlicerWith: '{{slicer}}에서 열기',
+    moreSlicerOptions: '슬라이서 옵션 더 보기',
+    openInSlicerFailed: '슬라이서에서 열 수 없습니다',
     tabs: {
       model: '3D 모델',
       gcode: 'G-code 미리보기'

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: 'Escanear',
     toast: {
       folderCreated: 'Pasta criada',
+      openInSlicerFailed: 'Não foi possível abrir no fatiador',
       folderDeleted: 'Pasta excluída',
       fileDeleted: 'Arquivo excluído',
       filesDeleted: 'Excluídos {{count}} arquivos',
@@ -5347,6 +5348,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Abrir no Slicer',
+    openInSlicerWith: 'Abrir em {{slicer}}',
+    moreSlicerOptions: 'Mais opções de fatiador',
+    openInSlicerFailed: 'Não foi possível abrir no fatiador',
     tabs: {
       model: 'Modelo 3D',
       gcode: 'Pré-visualização G-code',

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

@@ -3623,6 +3623,7 @@ export default {
     scanFolder: "Сканировать",
     toast: {
       folderCreated: "Папка создана",
+      openInSlicerFailed: "Не удалось открыть в слайсере",
       folderDeleted: "Папка удалена",
       fileDeleted: "Файл удалён",
       filesDeleted: "Удалено файлов: {{count}}",
@@ -5082,6 +5083,9 @@ export default {
   },
   modelViewer: {
     openInSlicer: "Открыть в слайсере",
+    openInSlicerWith: "Открыть в {{slicer}}",
+    moreSlicerOptions: "Другие варианты слайсера",
+    openInSlicerFailed: "Не удалось открыть в слайсере",
     tabs: {
       model: "3D-модель",
       gcode: "Предпросмотр G-code",

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

@@ -3816,6 +3816,7 @@ export default {
     scanFolder: 'Tara',
     toast: {
       folderCreated: 'Klasör oluşturuldu',
+      openInSlicerFailed: 'Dilimleyicide açılamadı',
       folderDeleted: 'Klasör silindi',
       fileDeleted: 'Dosya silindi',
       filesDeleted: '{{count}} dosya silindi',
@@ -5323,6 +5324,9 @@ export default {
   // Model Görüntüleyici
   modelViewer: {
     openInSlicer: 'Dilimleyicide Aç',
+    openInSlicerWith: '{{slicer}} ile aç',
+    moreSlicerOptions: 'Diğer dilimleyici seçenekleri',
+    openInSlicerFailed: 'Dilimleyicide açılamadı',
     tabs: {
       model: '3B Model',
       gcode: 'G-kod Önizleme',

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

@@ -3849,6 +3849,7 @@ export default {
     scanFolder: "Сканувати",
     toast: {
       folderCreated: "Папка створена",
+      openInSlicerFailed: "Не вдалося відкрити у слайсері",
       folderDeleted: "Папку видалено",
       fileDeleted: "Файл видалено",
       filesDeleted: "Видалені файли {{count}}.",
@@ -5402,6 +5403,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: "Відкрити у слайсері",
+    openInSlicerWith: "Відкрити у {{slicer}}",
+    moreSlicerOptions: "Більше варіантів слайсера",
+    openInSlicerFailed: "Не вдалося відкрити у слайсері",
     tabs: {
       model: "3D-модель",
       gcode: "Попередній перегляд G-коду",

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: '扫描',
     toast: {
       folderCreated: '文件夹已创建',
+      openInSlicerFailed: '无法在切片软件中打开',
       folderDeleted: '文件夹已删除',
       fileDeleted: '文件已删除',
       filesDeleted: '已删除 {{count}} 个文件',
@@ -5347,6 +5348,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: '在切片软件中打开',
+    openInSlicerWith: '用{{slicer}}打开',
+    moreSlicerOptions: '更多切片软件选项',
+    openInSlicerFailed: '无法在切片软件中打开',
     tabs: {
       model: '3D 模型',
       gcode: 'G-code 预览',

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: '掃描',
     toast: {
       folderCreated: '資料夾已建立',
+      openInSlicerFailed: '無法在切片軟體中開啟',
       folderDeleted: '資料夾已刪除',
       fileDeleted: '檔案已刪除',
       filesDeleted: '已刪除 {{count}} 個檔案',
@@ -5347,6 +5348,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: '在切片軟體中開啟',
+    openInSlicerWith: '用{{slicer}}開啟',
+    moreSlicerOptions: '更多切片軟體選項',
+    openInSlicerFailed: '無法在切片軟體中開啟',
     tabs: {
       model: '3D 模型',
       gcode: 'G-code 預覽',

+ 2 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -64,7 +64,7 @@ import {
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
 import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
@@ -2956,7 +2956,7 @@ export function ArchivesPage() {
   // user hasn't explicitly chosen a different desktop slicer (#1329). This is
   // ONLY the URI-handoff target; the in-app SliceModal still uses
   // preferred_slicer for the sidecar.
-  const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
+  const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
   const useSlicerApi = settings?.use_slicer_api ?? false;
   const currency = getCurrencySymbol(settings?.currency || 'USD');
 

+ 66 - 21
frontend/src/pages/FileManagerPage.tsx

@@ -9,6 +9,7 @@ import {
   Upload,
   Trash2,
   Download,
+  ExternalLink,
   MoreVertical,
   ChevronRight,
   FolderPlus,
@@ -73,6 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
+import { isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -741,14 +743,6 @@ function isSlicedFilename(filename: string): boolean {
   return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
 }
 
-// Files that can be fed to the slicer sidecar (model geometry inputs).
-// Excludes .gcode.* (already sliced) and any other non-model formats.
-function isSliceableFilename(filename: string): boolean {
-  const lower = filename.toLowerCase();
-  if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
-  return lower.endsWith('.stl') || lower.endsWith('.3mf') || lower.endsWith('.step') || lower.endsWith('.stp');
-}
-
 // File Card
 interface FileCardProps {
   file: LibraryFileListItem;
@@ -759,8 +753,10 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onSlice?: (file: LibraryFileListItem) => void;
+  onOpenInSlicer?: (file: LibraryFileListItem) => void;
   onRunPipeline?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
+  canSlice?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
@@ -773,7 +769,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -899,16 +895,21 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('common.print')}
                 </button>
               )}
-              {onSlice && useSlicerApi && isSliceableFilename(file.filename) && (
+              {isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:upload') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    canSlice ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
-                  onClick={() => { if (hasPermission('library:upload')) { onSlice(file); setShowActions(false); } }}
-                  disabled={!hasPermission('library:upload')}
-                  title={!hasPermission('library:upload') ? t('fileManager.noPermissionSlice') : undefined}
+                  onClick={() => {
+                    if (!canSlice) return;
+                    if (useSlicerApi) onSlice?.(file);
+                    else onOpenInSlicer?.(file);
+                    setShowActions(false);
+                  }}
+                  disabled={!canSlice}
+                  title={!canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
                 >
-                  <Cog className="w-3.5 h-3.5" />
+                  {useSlicerApi ? <Cog className="w-3.5 h-3.5" /> : <ExternalLink className="w-3.5 h-3.5" />}
                   {t('slice.action')}
                 </button>
               )}
@@ -1145,6 +1146,45 @@ export function FileManagerPage() {
     queryKey: ['settings'],
     queryFn: () => api.getSettings() as Promise<AppSettings>,
   });
+
+  const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
+
+  const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => {
+    try {
+      const { token } = await api.createLibrarySlicerToken(file.id);
+      const path = api.getLibrarySlicerDownloadUrl(file.id, token, file.filename);
+      openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+    } catch {
+      // Fallback to direct URL (works when auth is disabled). With auth on the
+      // slicer may then hit a 401, so surface the failure instead of making a
+      // permission denial look identical to "no slicer installed".
+      showToast(t('fileManager.toast.openInSlicerFailed'), 'error');
+      const path = api.getLibraryFileDownloadUrl(file.id);
+      openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+    }
+  }, [preferredSlicer, showToast, t]);
+
+  // Slice permission: API mode needs upload rights, the desktop handoff is a
+  // download. Each mirrors what the backend enforces on the endpoint that
+  // branch actually calls, so the UI never offers an action the server refuses.
+  //
+  // Deliberately NOT accepting the legacy `library:read` on the handoff branch.
+  // It looks like the safe back-compat term to include, but the slicer-token
+  // endpoint gates on require_ownership_permission(LIBRARY_READ_ALL,
+  // LIBRARY_READ_OWN), and neither that dependency nor User.has_permission
+  // expands the legacy name — so a group holding only `library:read` gets a 403
+  // there. It cannot reach this page to find out either: GET /library/folders
+  // gates on the same pair. Accepting it here would only enable a menu item
+  // that fails, and the `library:read` -> `library:read_own` migration in
+  // core/database.py runs only over the groups named in DEFAULT_GROUPS, so a
+  // custom role that still carries it is genuinely stuck rather than silently
+  // upgraded.
+  const canSlice = useCallback(() => {
+    if (settings?.use_slicer_api) {
+      return hasPermission('library:upload');
+    }
+    return hasAnyPermission('library:read_all', 'library:read_own');
+  }, [settings?.use_slicer_api, hasPermission, hasAnyPermission]);
   const { data: folders, isLoading: foldersLoading } = useQuery({
     queryKey: ['library-folders'],
     queryFn: () => api.getLibraryFolders(),
@@ -2420,8 +2460,10 @@ export function FileManagerPage() {
                     onDownload={handleDownload}
                     onPrint={setPrintFile}
                     onSlice={setSliceFile}
+                    onOpenInSlicer={handleOpenInSlicer}
                     onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
+                    canSlice={canSlice()}
                     onPreview3d={(f) => {
                       // Sliced files (.gcode / .gcode.3mf) open the same
                       // full-page gcode viewer the archive card uses, so
@@ -2597,18 +2639,21 @@ export function FileManagerPage() {
                           </button>
                         </>
                       )}
-                      {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+                      {isSliceableFilename(file.filename) && (
                         <button
-                          onClick={() => hasPermission('library:upload') && setSliceFile(file)}
+                          onClick={() => {
+                            if (!canSlice()) return;
+                            (settings?.use_slicer_api ? setSliceFile : handleOpenInSlicer)(file);
+                          }}
                           className={`p-1.5 rounded transition-colors ${
-                            hasPermission('library:upload')
+                            canSlice()
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               : 'text-bambu-gray/50 cursor-not-allowed'
                           }`}
-                          title={hasPermission('library:upload') ? t('slice.action') : t('fileManager.noPermissionSlice')}
-                          disabled={!hasPermission('library:upload')}
+                          title={canSlice() ? t('slice.action') : (settings?.use_slicer_api ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload'))}
+                          disabled={!canSlice()}
                         >
-                          <Cog className="w-4 h-4" />
+                          {settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
                         </button>
                       )}
                       {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (

+ 2 - 2
frontend/src/pages/MakerworldPage.tsx

@@ -11,7 +11,7 @@ import {
   type MakerworldRecentImport,
   type MakerworldResolvedModel,
 } from '../api/client';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -185,7 +185,7 @@ export function MakerworldPage() {
   // whichever slicer this button actually drives — depends on useSlicerApi.
   const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
   const apiSlicer: SlicerType = settingsQuery.data?.preferred_slicer || 'bambu_studio';
-  const desktopSlicer: SlicerType = settingsQuery.data?.open_in_slicer || apiSlicer;
+  const desktopSlicer: SlicerType = resolveDesktopSlicer(settingsQuery.data?.open_in_slicer, settingsQuery.data?.preferred_slicer);
   const preferredSlicer: SlicerType = useSlicerApi ? apiSlicer : desktopSlicer;
   const preferredSlicerName =
     preferredSlicer === 'orcaslicer' ? 'OrcaSlicer' : 'Bambu Studio';

+ 50 - 0
frontend/src/utils/slicer.ts

@@ -28,6 +28,56 @@ export type SlicerType = 'bambu_studio' | 'orcaslicer';
 
 type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
 
+/**
+ * Resolve the desktop "Open in Slicer" target. Prefers an explicit
+ * `open_in_slicer` override (#1329), then falls back to the API slicer's
+ * `preferred_slicer`, then Bambu Studio. This is ONLY the URI-handoff target;
+ * the in-app SliceModal keeps using `preferred_slicer` for the sidecar.
+ */
+export function resolveDesktopSlicer(
+  openInSlicer?: SlicerType | null,
+  preferredSlicer?: SlicerType,
+): SlicerType {
+  return openInSlicer ?? preferredSlicer ?? 'bambu_studio';
+}
+
+/**
+ * File types a slicer can be handed — both by the desktop URI handler and by
+ * the in-app sidecar. Source geometry only: a sliced file is an output, and
+ * neither slicer has anything to do with one.
+ *
+ * Lives here rather than beside either caller because both the File Manager
+ * (which has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
+ * decide the same thing about the same file. They used to hold separate lists,
+ * and the two disagreed — a card menu offered a desktop handoff for an STL
+ * whose own 3D preview showed "Open in Slicer" greyed out.
+ */
+export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
+
+/**
+ * Does a `LibraryFile.file_type` name a sliceable source file?
+ *
+ * The backend stores compound extensions whole — a sliced 3MF classifies as
+ * `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
+ * membership alone is enough to exclude sliced output here.
+ */
+export function isSliceableFileType(fileType?: string | null): boolean {
+  const normalized = (fileType || '').toLowerCase();
+  return (SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
+}
+
+/**
+ * Does a filename name a sliceable source file?
+ *
+ * Checked against the name rather than a stored type, so the compound
+ * extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
+ */
+export function isSliceableFilename(filename: string): boolean {
+  const lower = filename.toLowerCase();
+  if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
+  return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
+}
+
 /**
  * Detect the user's operating system
  */

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


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


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


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-yKwaHTh1.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-B3jj6-fz.css">
+    <script type="module" crossorigin src="/assets/index-fpDLI8Il.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DDSj68H5.css">
   </head>
   <body>
     <div id="root"></div>

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