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) && (