From af7908ca1ef3c103d54308f0f49a244a51da1e6c Mon Sep 17 00:00:00 2001 From: Pascal Heidmann Date: Tue, 4 Aug 2026 21:42:23 +0200 Subject: [PATCH] Improve local slicer selection with code review suggestions, adding fail toast and handle (legacy) read permissions --- .../components/ModelViewerModal.test.tsx | 92 +++++++++++- .../__tests__/pages/FileManagerPage.test.tsx | 139 +++++++++++++++++- .../__tests__/pages/MakerworldPage.test.tsx | 44 ++++++ frontend/src/components/ModelViewerModal.tsx | 78 +++++----- frontend/src/i18n/locales/de.ts | 2 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/es.ts | 2 + frontend/src/i18n/locales/fr.ts | 2 + frontend/src/i18n/locales/it.ts | 2 + frontend/src/i18n/locales/ja.ts | 2 + frontend/src/i18n/locales/ko.ts | 2 + frontend/src/i18n/locales/pt-BR.ts | 2 + frontend/src/i18n/locales/ru.ts | 2 + frontend/src/i18n/locales/tr.ts | 2 + frontend/src/i18n/locales/uk.ts | 2 + frontend/src/i18n/locales/zh-CN.ts | 2 + frontend/src/i18n/locales/zh-TW.ts | 2 + frontend/src/pages/ArchivesPage.tsx | 4 +- frontend/src/pages/FileManagerPage.tsx | 34 +++-- frontend/src/pages/MakerworldPage.tsx | 4 +- frontend/src/utils/slicer.ts | 13 ++ 21 files changed, 369 insertions(+), 65 deletions(-) diff --git a/frontend/src/__tests__/components/ModelViewerModal.test.tsx b/frontend/src/__tests__/components/ModelViewerModal.test.tsx index ae8838682..e41eadab0 100644 --- a/frontend/src/__tests__/components/ModelViewerModal.test.tsx +++ b/frontend/src/__tests__/components/ModelViewerModal.test.tsx @@ -31,6 +31,10 @@ vi.mock('../../components/GcodeViewer', () => ({ vi.mock('../../utils/slicer', () => ({ openInSlicer: vi.fn(), + resolveDesktopSlicer: vi.fn( + (openInSlicer?: string, preferredSlicer?: string) => + (openInSlicer ?? preferredSlicer ?? 'bambu_studio') as 'bambu_studio' | 'orcaslicer', + ), })); const mockCapabilities = { @@ -517,12 +521,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( ); @@ -649,12 +653,60 @@ describe('ModelViewerModal', () => { expect(screen.queryByText('Open in OrcaSlicer')).not.toBeInTheDocument(); }); + it('closes the split dropdown on Escape without closing the modal', async () => { + render( + + ); + + 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( + + ); + + 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( ); @@ -666,7 +718,28 @@ describe('ModelViewerModal', () => { expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument(); }); - it('renders a plain Slice button without a split chevron for non-handoff files', async () => { + it('offers the desktop handoff for an STL library file', async () => { + render( + + ); + + 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 }); @@ -687,7 +760,12 @@ describe('ModelViewerModal', () => { expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument(); }); - expect(screen.queryByRole('button', { name: 'More slicer options' })).not.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(); + }); }); }); }); diff --git a/frontend/src/__tests__/pages/FileManagerPage.test.tsx b/frontend/src/__tests__/pages/FileManagerPage.test.tsx index 648164a33..080230936 100644 --- a/frontend/src/__tests__/pages/FileManagerPage.test.tsx +++ b/frontend/src/__tests__/pages/FileManagerPage.test.tsx @@ -8,11 +8,16 @@ 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'; vi.mock('../../utils/slicer', () => ({ openInSlicer: vi.fn(), + resolveDesktopSlicer: vi.fn( + (openInSlicer?: string, preferredSlicer?: string) => + (openInSlicer ?? preferredSlicer ?? 'bambu_studio') as 'bambu_studio' | 'orcaslicer', + ), })); vi.mock('../../components/SliceModal', () => ({ @@ -1175,9 +1180,19 @@ describe('FileManagerPage', () => { ); }); + 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, filename: string) => { const card = screen.getByText(filename).closest('.group') as HTMLElement; - await user.click(card.querySelector('button')!); + // 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; }; @@ -1223,5 +1238,127 @@ describe('FileManagerPage', () => { 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 (the permission the backend + // enforces on the slicer-token endpoint), the desktop handoff mirrors the + // backend's ownership check and needs library:read_all / library:read_own + // (legacy library:read also accepted). + 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(); + + 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(); + + 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(); + + 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(); + + 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('slices from the list-view button when the slicer API is disabled', async () => { + const user = userEvent.setup(); + render(); + + 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(); + + 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(); + }); }); }); diff --git a/frontend/src/__tests__/pages/MakerworldPage.test.tsx b/frontend/src/__tests__/pages/MakerworldPage.test.tsx index 0808b2c67..385643ac5 100644 --- a/frontend/src/__tests__/pages/MakerworldPage.test.tsx +++ b/frontend/src/__tests__/pages/MakerworldPage.test.tsx @@ -61,6 +61,8 @@ function resolveResponse(overrides: Partial> = {}) { // 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>; } = {}) { 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(); + 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(); + 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( diff --git a/frontend/src/components/ModelViewerModal.tsx b/frontend/src/components/ModelViewerModal.tsx index 79ef7046f..2d91a81e0 100644 --- a/frontend/src/components/ModelViewerModal.tsx +++ b/frontend/src/components/ModelViewerModal.tsx @@ -6,7 +6,8 @@ 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 { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer'; import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates'; type ViewTab = '3d' | 'gcode'; @@ -37,14 +38,14 @@ interface SlicerSplitButtonProps { label: string; dropdownLabel: string; onPrimary: () => void; - disabled?: boolean; 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. -function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = false, items }: SlicerSplitButtonProps) { +// (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(null); @@ -79,7 +80,6 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = f setOpen(false); onPrimary(); }} - disabled={disabled} className="rounded-r-none" > {icon} @@ -89,7 +89,6 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = f variant="secondary" size="sm" onClick={() => setOpen((prev) => !prev)} - disabled={disabled} aria-label={dropdownLabel} aria-haspopup="menu" aria-expanded={open} @@ -126,12 +125,13 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = f 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(null); const [capabilities, setCapabilities] = useState(null); @@ -372,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. Kept in step with + // `isSliceableFilename()` in FileManagerPage so a file's card-menu "Slice" + // and its 3D-preview slicer button never disagree on the same type. + const normalizedFileType = (fileType || '').toLowerCase(); + const slicerReadyType = ['3mf', 'stl', 'step', 'stp'].includes(normalizedFileType); + 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 @@ -380,25 +386,10 @@ 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 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), - })); - const handleOpenInSlicer = async (slicer: SlicerType) => { if (!canOpenInSlicer) return; const filename = title || 'model'; @@ -413,7 +404,10 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on 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, slicer); @@ -424,6 +418,17 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on } }; + 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 (
{useBambuddySlicer ? ( - canOpenInSlicer ? ( - } - label={t('slice.action')} - dropdownLabel={t('modelViewer.moreSlicerOptions')} - onPrimary={() => onSliceWithBambuddy?.()} - items={slicerDropdownItems} - /> - ) : ( - - ) + } + label={t('slice.action')} + dropdownLabel={t('modelViewer.moreSlicerOptions')} + onPrimary={() => onSliceWithBambuddy?.()} + items={slicerDropdownItems} + /> ) : canOpenInSlicer ? ( } @@ -470,7 +468,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on items={slicerDropdownItems} /> ) : ( - diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 46a45b603..aa5f3bcbd 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', @@ -5360,6 +5361,7 @@ export default { 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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e3d826cf3..afff384cc 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', @@ -5404,6 +5405,7 @@ export default { 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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 177294e71..da472ba88 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/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', @@ -5369,6 +5370,7 @@ export default { 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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 0515d102c..3b3ffc646 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', @@ -5350,6 +5351,7 @@ export default { 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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index ae2d3d7ad..529eca28e 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', @@ -5349,6 +5350,7 @@ export default { 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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index f2b98b414..d8d4249d3 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -3820,6 +3820,7 @@ export default { scanFolder: 'スキャン', toast: { folderCreated: 'フォルダを作成しました', + openInSlicerFailed: 'スライサーで開けませんでした', folderDeleted: 'フォルダを削除しました', fileDeleted: 'ファイルを削除しました', filesDeleted: '{{count}}件のファイルを削除しました', @@ -5361,6 +5362,7 @@ export default { openInSlicer: 'スライサーで開く', openInSlicerWith: '{{slicer}}で開く', moreSlicerOptions: 'その他のスライサーオプション', + openInSlicerFailed: 'スライサーで開けませんでした', tabs: { model: '3Dモデル', gcode: 'G-codeプレビュー', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index e7b95b68b..c5a2e64b0 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -3631,6 +3631,7 @@ export default { scanFolder: '스캔', toast: { folderCreated: '폴더 생성됨', + openInSlicerFailed: '슬라이서에서 열 수 없습니다', folderDeleted: '폴더 삭제됨', fileDeleted: '파일 삭제됨', filesDeleted: '{{count}}개 파일 삭제됨', @@ -5096,6 +5097,7 @@ export default { openInSlicer: '슬라이서에서 열기', openInSlicerWith: '{{slicer}}에서 열기', moreSlicerOptions: '슬라이서 옵션 더 보기', + openInSlicerFailed: '슬라이서에서 열 수 없습니다', tabs: { model: '3D 모델', gcode: 'G-code 미리보기' diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 878a1990e..b3fb96c58 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', @@ -5349,6 +5350,7 @@ export default { 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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index eba7e6062..bdf92dc89 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -3623,6 +3623,7 @@ export default { scanFolder: "Сканировать", toast: { folderCreated: "Папка создана", + openInSlicerFailed: "Не удалось открыть в слайсере", folderDeleted: "Папка удалена", fileDeleted: "Файл удалён", filesDeleted: "Удалено файлов: {{count}}", @@ -5084,6 +5085,7 @@ export default { openInSlicer: "Открыть в слайсере", openInSlicerWith: "Открыть в {{slicer}}", moreSlicerOptions: "Другие варианты слайсера", + openInSlicerFailed: "Не удалось открыть в слайсере", tabs: { model: "3D-модель", gcode: "Предпросмотр G-code", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 64cb8f116..81cc8dd6c 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/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', @@ -5325,6 +5326,7 @@ export default { 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', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index ed8e2c2b7..6ba614fb8 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -3849,6 +3849,7 @@ export default { scanFolder: "Сканувати", toast: { folderCreated: "Папка створена", + openInSlicerFailed: "Не вдалося відкрити у слайсері", folderDeleted: "Папку видалено", fileDeleted: "Файл видалено", filesDeleted: "Видалені файли {{count}}.", @@ -5404,6 +5405,7 @@ export default { openInSlicer: "Відкрити у слайсері", openInSlicerWith: "Відкрити у {{slicer}}", moreSlicerOptions: "Більше варіантів слайсера", + openInSlicerFailed: "Не вдалося відкрити у слайсері", tabs: { model: "3D-модель", gcode: "Попередній перегляд G-коду", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 23b61b98e..8fa4a0f67 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3808,6 +3808,7 @@ export default { scanFolder: '扫描', toast: { folderCreated: '文件夹已创建', + openInSlicerFailed: '无法在切片软件中打开', folderDeleted: '文件夹已删除', fileDeleted: '文件已删除', filesDeleted: '已删除 {{count}} 个文件', @@ -5349,6 +5350,7 @@ export default { openInSlicer: '在切片软件中打开', openInSlicerWith: '用{{slicer}}打开', moreSlicerOptions: '更多切片软件选项', + openInSlicerFailed: '无法在切片软件中打开', tabs: { model: '3D 模型', gcode: 'G-code 预览', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index cdc5e2e76..716823f0d 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -3808,6 +3808,7 @@ export default { scanFolder: '掃描', toast: { folderCreated: '資料夾已建立', + openInSlicerFailed: '無法在切片軟體中開啟', folderDeleted: '資料夾已刪除', fileDeleted: '檔案已刪除', filesDeleted: '已刪除 {{count}} 個檔案', @@ -5349,6 +5350,7 @@ export default { openInSlicer: '在切片軟體中開啟', openInSlicerWith: '用{{slicer}}開啟', moreSlicerOptions: '更多切片軟體選項', + openInSlicerFailed: '無法在切片軟體中開啟', tabs: { model: '3D 模型', gcode: 'G-code 預覽', diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index b0de19ba4..a6b5b5629 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/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'); diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index c0ee7d511..c3425bac7 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -74,7 +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 { openInSlicer, type SlicerType } from '../utils/slicer'; +import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer'; type SortField = 'name' | 'date' | 'size' | 'type' | 'prints'; type SortDirection = 'asc' | 'desc'; @@ -764,6 +764,7 @@ interface FileCardProps { onOpenInSlicer?: (file: LibraryFileListItem) => void; onRunPipeline?: (file: LibraryFileListItem) => void; useSlicerApi?: boolean; + canSlice?: boolean; onPreview3d?: (file: LibraryFileListItem) => void; onRename?: (file: LibraryFileListItem) => void; onGenerateThumbnail?: (file: LibraryFileListItem) => void; @@ -776,11 +777,9 @@ interface FileCardProps { t: TFunction; } -function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, 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); - const sliceDisabled = useSlicerApi ? !hasPermission('library:upload') : !hasPermission('library:read'); - return (
)} - {isSliceableFilename(file.filename) && (onSlice || onOpenInSlicer) && ( + {isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (