diff --git a/frontend/src/__tests__/pages/FileManagerPage.test.tsx b/frontend/src/__tests__/pages/FileManagerPage.test.tsx
index 2f354f817..648164a33 100644
--- a/frontend/src/__tests__/pages/FileManagerPage.test.tsx
+++ b/frontend/src/__tests__/pages/FileManagerPage.test.tsx
@@ -3,13 +3,24 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { FileManagerPage } from '../../pages/FileManagerPage';
+import { openInSlicer } from '../../utils/slicer';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
+vi.mock('../../utils/slicer', () => ({
+ openInSlicer: vi.fn(),
+}));
+
+vi.mock('../../components/SliceModal', () => ({
+ SliceModal: ({ source }: { source: { filename: string } }) => (
+
{source.filename}
+ ),
+}));
+
// Mock data
const mockFolders = [
{
@@ -1155,4 +1166,62 @@ describe('FileManagerPage', () => {
});
});
});
+
+ describe('slice action', () => {
+ beforeEach(() => {
+ vi.mocked(openInSlicer).mockClear();
+ server.use(
+ http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
+ );
+ });
+
+ const openMenu = async (user: ReturnType, filename: string) => {
+ const card = screen.getByText(filename).closest('.group') as HTMLElement;
+ await user.click(card.querySelector('button')!);
+ return card;
+ };
+
+ it('opens the desktop slicer when the slicer API is disabled', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+ const card = await openMenu(user, 'bracket.stl');
+ await user.click(within(card).getByText('Slice'));
+
+ await waitFor(() => {
+ expect(openInSlicer).toHaveBeenCalledWith(
+ expect.stringContaining('/library/files/2/dl/test-token/'),
+ 'bambu_studio',
+ );
+ });
+ });
+
+ it('opens the in-app SliceModal when the slicer API is enabled', async () => {
+ server.use(
+ http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+ );
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+ const card = await openMenu(user, 'bracket.stl');
+ await user.click(within(card).getByText('Slice'));
+
+ expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
+ expect(openInSlicer).not.toHaveBeenCalled();
+ });
+
+ it('hides the slice item for already-sliced files', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+ const card = await openMenu(user, 'Benchy');
+ expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts
index 0094c7718..318114904 100644
--- a/frontend/src/i18n/locales/uk.ts
+++ b/frontend/src/i18n/locales/uk.ts
@@ -5337,6 +5337,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: "Відкрити у слайсері",
+ openInSlicerWith: "Відкрити у {{slicer}}",
+ moreSlicerOptions: "Більше варіантів слайсера",
tabs: {
model: "3D-модель",
gcode: "Попередній перегляд G-коду",
diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx
index 4d6b91130..c0ee7d511 100644
--- a/frontend/src/pages/FileManagerPage.tsx
+++ b/frontend/src/pages/FileManagerPage.tsx
@@ -9,6 +9,7 @@ import {
Upload,
Trash2,
Download,
+ ExternalLink,
MoreVertical,
ChevronRight,
FolderPlus,
@@ -73,6 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
import { useAuth } from '../contexts/AuthContext';
import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
import { formatFileSize } from '../utils/file';
+import { openInSlicer, type SlicerType } from '../utils/slicer';
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
type SortDirection = 'asc' | 'desc';
@@ -759,6 +761,7 @@ interface FileCardProps {
onDownload: (id: number) => void;
onPrint?: (file: LibraryFileListItem) => void;
onSlice?: (file: LibraryFileListItem) => void;
+ onOpenInSlicer?: (file: LibraryFileListItem) => void;
onRunPipeline?: (file: LibraryFileListItem) => void;
useSlicerApi?: boolean;
onPreview3d?: (file: LibraryFileListItem) => void;
@@ -773,9 +776,11 @@ interface FileCardProps {
t: TFunction;
}
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, 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 (
)}
- {onSlice && useSlicerApi && isSliceableFilename(file.filename) && (
+ {isSliceableFilename(file.filename) && (onSlice || onOpenInSlicer) && (
)}
@@ -1145,6 +1155,27 @@ export function FileManagerPage() {
queryKey: ['settings'],
queryFn: () => api.getSettings() as Promise
,
});
+
+ const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
+
+ const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => {
+ try {
+ const { token } = await api.createLibrarySlicerToken(file.id);
+ const path = api.getLibrarySlicerDownloadUrl(file.id, token, file.filename);
+ openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+ } catch {
+ const path = api.getLibraryFileDownloadUrl(file.id);
+ openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+ }
+ }, [preferredSlicer]);
+
+ // Slice permission: API mode needs upload rights, desktop handoff is a download.
+ const canSlice = useCallback(() => {
+ if (settings?.use_slicer_api) {
+ return hasPermission('library:upload');
+ }
+ return hasPermission('library:read');
+ }, [settings?.use_slicer_api, hasPermission]);
const { data: folders, isLoading: foldersLoading } = useQuery({
queryKey: ['library-folders'],
queryFn: () => api.getLibraryFolders(),
@@ -2420,6 +2451,7 @@ export function FileManagerPage() {
onDownload={handleDownload}
onPrint={setPrintFile}
onSlice={setSliceFile}
+ onOpenInSlicer={handleOpenInSlicer}
onRunPipeline={setRunPipelineFile}
useSlicerApi={settings?.use_slicer_api ?? false}
onPreview3d={(f) => {
@@ -2597,18 +2629,21 @@ export function FileManagerPage() {
>
)}
- {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+ {isSliceableFilename(file.filename) && (
)}
{(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (