Added option to open/slice files in local slicers when slicer api is enabled

This commit is contained in:
Pascal
2026-08-03 23:41:22 +02:00
committed by Pascal Heidmann
parent 8dde48587e
commit eda2e04599
14 changed files with 319 additions and 12 deletions
@@ -8,6 +8,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { ModelViewerModal } from '../../components/ModelViewerModal';
import { setStreamToken } from '../../api/client';
import { openInSlicer } from '../../utils/slicer';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
@@ -28,6 +29,10 @@ vi.mock('../../components/GcodeViewer', () => ({
),
}));
vi.mock('../../utils/slicer', () => ({
openInSlicer: vi.fn(),
}));
const mockCapabilities = {
has_model: true,
has_gcode: true,
@@ -528,4 +533,161 @@ describe('ModelViewerModal', () => {
});
});
});
describe('slicer split button (#2725)', () => {
it('shows both slicers in the dropdown when Bambuddy is the default slicer', async () => {
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json({ use_slicer_api: true });
}),
http.get('/api/v1/library/files/:id/plates', () => {
return HttpResponse.json(mockSinglePlateResponse);
})
);
render(
<ModelViewerModal
libraryFileId={1}
title="Model.3mf"
fileType="3mf"
onClose={mockOnClose}
onSliceWithBambuddy={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
});
});
it('opens the selected local slicer from the Bambuddy dropdown', async () => {
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json({ use_slicer_api: true });
}),
http.get('/api/v1/library/files/:id/plates', () => {
return HttpResponse.json(mockSinglePlateResponse);
})
);
render(
<ModelViewerModal
libraryFileId={1}
title="Model.3mf"
fileType="3mf"
onClose={mockOnClose}
onSliceWithBambuddy={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
const orcaItem = await screen.findByText('Open in OrcaSlicer');
fireEvent.click(orcaItem);
await waitFor(() => {
expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
});
});
it('shows only the non-preferred slicer in the dropdown for a desktop handoff', async () => {
render(
<ModelViewerModal
archiveId={1}
title="Test Model"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
});
expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument();
});
it('offers Bambu Studio when the preferred desktop slicer is OrcaSlicer', async () => {
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json({ preferred_slicer: 'orcaslicer' });
})
);
render(
<ModelViewerModal
archiveId={1}
title="Test Model"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
});
expect(screen.queryByText('Open in OrcaSlicer')).not.toBeInTheDocument();
});
it('does not render a split chevron when the file cannot open in a slicer', async () => {
render(
<ModelViewerModal
libraryFileId={1}
title="Model.stl"
fileType="stl"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeDisabled();
});
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 () => {
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json({ use_slicer_api: true });
})
);
render(
<ModelViewerModal
libraryFileId={1}
title="Model.stl"
fileType="stl"
onClose={mockOnClose}
onSliceWithBambuddy={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
});
});
});
+133 -12
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
import { ModelViewer } from './ModelViewer';
import { GcodeViewer } from './GcodeViewer';
import { Button } from './Button';
@@ -32,6 +32,98 @@ interface Capabilities {
filament_colors: string[];
}
interface SlicerSplitButtonProps {
icon: ReactNode;
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) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handlePointerDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
setOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
return (
<div className="relative inline-flex" ref={containerRef}>
<div className="flex relative z-50">
<Button
variant="secondary"
size="sm"
onClick={() => {
setOpen(false);
onPrimary();
}}
disabled={disabled}
className="rounded-r-none"
>
{icon}
{label}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setOpen((prev) => !prev)}
disabled={disabled}
aria-label={dropdownLabel}
aria-haspopup="menu"
aria-expanded={open}
className="rounded-l-none border-l border-bambu-dark px-2"
>
<ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
</Button>
</div>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 w-56 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-50 py-1"
>
{items.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
item.onClick();
}}
className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
>
<ExternalLink className="w-4 h-4 flex-shrink-0" />
{item.label}
</button>
))}
</div>
)}
</div>
);
}
export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
const { t } = useTranslation();
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
@@ -296,27 +388,38 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType,
);
const handleOpenInSlicer = async () => {
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';
try {
if (isLibrary) {
const { token } = await api.createLibrarySlicerToken(libraryFileId!);
const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
openInSlicer(`${window.location.origin}${path}`, slicer);
} else {
const { token } = await api.createArchiveSlicerToken(archiveId!);
const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
openInSlicer(`${window.location.origin}${path}`, slicer);
}
} catch {
// Fallback to direct URL (works when auth is disabled)
if (isLibrary) {
const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicer(downloadUrl, slicer);
} else {
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicer(downloadUrl, slicer);
}
}
};
@@ -344,12 +447,30 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
</div>
<div className="flex items-center gap-2">
{useBambuddySlicer ? (
<Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
<Cog className="w-4 h-4" />
{t('slice.action')}
</Button>
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>
)
) : canOpenInSlicer ? (
<SlicerSplitButton
icon={<ExternalLink className="w-4 h-4" />}
label={t('modelViewer.openInSlicer')}
dropdownLabel={t('modelViewer.moreSlicerOptions')}
onPrimary={() => handleOpenInSlicer(preferredSlicer)}
items={slicerDropdownItems}
/>
) : (
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
<Button variant="secondary" size="sm" onClick={() => handleOpenInSlicer(preferredSlicer)} disabled>
<ExternalLink className="w-4 h-4" />
{t('modelViewer.openInSlicer')}
</Button>
+2
View File
@@ -5293,6 +5293,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Im Slicer öffnen',
openInSlicerWith: 'In {{slicer}} öffnen',
moreSlicerOptions: 'Weitere Slicer-Optionen',
tabs: {
model: '3D-Modell',
gcode: 'G-Code Vorschau',
+2
View File
@@ -5337,6 +5337,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Open in Slicer',
openInSlicerWith: 'Open in {{slicer}}',
moreSlicerOptions: 'More slicer options',
tabs: {
model: '3D Model',
gcode: 'G-code Preview',
+2
View File
@@ -5302,6 +5302,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Abrir en el laminador',
openInSlicerWith: 'Abrir en {{slicer}}',
moreSlicerOptions: 'Más opciones de laminador',
tabs: {
model: 'Modelo 3D',
gcode: 'Vista previa de G-code',
+2
View File
@@ -5283,6 +5283,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Ouvrir dans le Slicer',
openInSlicerWith: 'Ouvrir dans {{slicer}}',
moreSlicerOptions: "Plus d'options de slicer",
tabs: {
model: 'Modèle 3D',
gcode: 'Aperçu G-code',
+2
View File
@@ -5282,6 +5282,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Apri nello slicer',
openInSlicerWith: 'Apri in {{slicer}}',
moreSlicerOptions: 'Altre opzioni dello slicer',
tabs: {
model: 'Modello 3D',
gcode: 'Anteprima G-code',
+2
View File
@@ -5294,6 +5294,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'スライサーで開く',
openInSlicerWith: '{{slicer}}で開く',
moreSlicerOptions: 'その他のスライサーオプション',
tabs: {
model: '3Dモデル',
gcode: 'G-codeプレビュー',
+2
View File
@@ -5029,6 +5029,8 @@ export default {
},
modelViewer: {
openInSlicer: '슬라이서에서 열기',
openInSlicerWith: '{{slicer}}에서 열기',
moreSlicerOptions: '슬라이서 옵션 더 보기',
tabs: {
model: '3D 모델',
gcode: 'G-code 미리보기'
+2
View File
@@ -5282,6 +5282,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Abrir no Slicer',
openInSlicerWith: 'Abrir em {{slicer}}',
moreSlicerOptions: 'Mais opções de fatiador',
tabs: {
model: 'Modelo 3D',
gcode: 'Pré-visualização G-code',
+2
View File
@@ -5017,6 +5017,8 @@ export default {
},
modelViewer: {
openInSlicer: "Открыть в слайсере",
openInSlicerWith: "Открыть в {{slicer}}",
moreSlicerOptions: "Другие варианты слайсера",
tabs: {
model: "3D-модель",
gcode: "Предпросмотр G-code",
+2
View File
@@ -5258,6 +5258,8 @@ export default {
// Model Görüntüleyici
modelViewer: {
openInSlicer: 'Dilimleyicide Aç',
openInSlicerWith: '{{slicer}} ile aç',
moreSlicerOptions: 'Diğer dilimleyici seçenekleri',
tabs: {
model: '3B Model',
gcode: 'G-kod Önizleme',
+2
View File
@@ -5282,6 +5282,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: '在切片软件中打开',
openInSlicerWith: '用{{slicer}}打开',
moreSlicerOptions: '更多切片软件选项',
tabs: {
model: '3D 模型',
gcode: 'G-code 预览',
+2
View File
@@ -5282,6 +5282,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: '在切片軟體中開啟',
openInSlicerWith: '用{{slicer}}開啟',
moreSlicerOptions: '更多切片軟體選項',
tabs: {
model: '3D 模型',
gcode: 'G-code 預覽',