Improve local slicer selection with code review suggestions, adding fail toast and handle (legacy) read permissions

This commit is contained in:
Pascal Heidmann
2026-08-04 23:33:07 +02:00
parent 932a229dea
commit af7908ca1e
21 changed files with 369 additions and 65 deletions
@@ -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(
<ModelViewerModal
libraryFileId={1}
title="Model.stl"
fileType="stl"
title="Model.gcode"
fileType="gcode"
onClose={mockOnClose}
/>
);
@@ -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(
<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.stl"
fileType="stl"
title="Model.gcode"
fileType="gcode"
onClose={mockOnClose}
/>
);
@@ -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(
<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 });
@@ -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();
});
});
});
});
@@ -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<typeof userEvent.setup>, 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(<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('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();
});
});
});
@@ -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(
+38 -40
View File
@@ -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<HTMLDivElement>(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<ViewTab | null>(null);
const [capabilities, setCapabilities] = useState<Capabilities | null>(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 (
<div
className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
@@ -447,20 +452,13 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
</div>
<div className="flex items-center gap-2">
{useBambuddySlicer ? (
canOpenInSlicer ? (
<SlicerSplitButton
icon={<Cog className="w-4 h-4" />}
label={t('slice.action')}
dropdownLabel={t('modelViewer.moreSlicerOptions')}
onPrimary={() => onSliceWithBambuddy?.()}
items={slicerDropdownItems}
/>
) : (
<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" />}
@@ -470,7 +468,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
items={slicerDropdownItems}
/>
) : (
<Button variant="secondary" size="sm" onClick={() => handleOpenInSlicer(preferredSlicer)} disabled>
<Button variant="secondary" size="sm" disabled>
<ExternalLink className="w-4 h-4" />
{t('modelViewer.openInSlicer')}
</Button>
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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プレビュー',
+2
View File
@@ -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 미리보기'
+2
View File
@@ -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',
+2
View File
@@ -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",
+2
View File
@@ -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',
+2
View File
@@ -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-коду",
+2
View File
@@ -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 预览',
+2
View File
@@ -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 預覽',
+2 -2
View File
@@ -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');
+21 -13
View File
@@ -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 (
<div
className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer overflow-hidden ${
@@ -904,19 +903,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
{t('common.print')}
</button>
)}
{isSliceableFilename(file.filename) && (onSlice || onOpenInSlicer) && (
{isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
!sliceDisabled ? '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 (sliceDisabled) return;
if (!canSlice) return;
if (useSlicerApi) onSlice?.(file);
else onOpenInSlicer?.(file);
setShowActions(false);
}}
disabled={sliceDisabled}
title={sliceDisabled ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
disabled={!canSlice}
title={!canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
>
{useSlicerApi ? <Cog className="w-3.5 h-3.5" /> : <ExternalLink className="w-3.5 h-3.5" />}
{t('slice.action')}
@@ -1156,7 +1155,7 @@ export function FileManagerPage() {
queryFn: () => api.getSettings() as Promise<AppSettings>,
});
const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => {
try {
@@ -1164,18 +1163,26 @@ export function FileManagerPage() {
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]);
}, [preferredSlicer, showToast, t]);
// Slice permission: API mode needs upload rights, desktop handoff is a download.
// The handoff mirrors the backend's ownership check on the slicer-token
// endpoint (library:read_all / library:read_own); `library:read` is a legacy
// permission default groups don't carry, so requiring it would disable the
// handoff for Operators and Viewers.
const canSlice = useCallback(() => {
if (settings?.use_slicer_api) {
return hasPermission('library:upload');
}
return hasPermission('library:read');
}, [settings?.use_slicer_api, hasPermission]);
return hasAnyPermission('library:read_all', 'library:read_own', 'library:read');
}, [settings?.use_slicer_api, hasPermission, hasAnyPermission]);
const { data: folders, isLoading: foldersLoading } = useQuery({
queryKey: ['library-folders'],
queryFn: () => api.getLibraryFolders(),
@@ -2454,6 +2461,7 @@ export function FileManagerPage() {
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
+2 -2
View File
@@ -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';
+13
View File
@@ -28,6 +28,19 @@ 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';
}
/**
* Detect the user's operating system
*/