mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
Improve local slicer selection with code review suggestions, adding fail toast and handle (legacy) read permissions
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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プレビュー',
|
||||
|
||||
@@ -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 미리보기'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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-коду",
|
||||
|
||||
@@ -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 预览',
|
||||
|
||||
@@ -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 預覽',
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user