Always show slice option in filemanager context menu but fallback to local slicer

This commit is contained in:
maziggy
2026-08-15 14:04:52 +02:00
3 changed files with 120 additions and 14 deletions
@@ -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 } }) => (
<div data-testid="slice-modal">{source.filename}</div>
),
}));
// 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<typeof userEvent.setup>, 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(<FileManagerPage />);
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(<FileManagerPage />);
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(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
const card = await openMenu(user, 'Benchy');
expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
});
});
});
+2
View File
@@ -5337,6 +5337,8 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: "Відкрити у слайсері",
openInSlicerWith: "Відкрити у {{slicer}}",
moreSlicerOptions: "Більше варіантів слайсера",
tabs: {
model: "3D-модель",
gcode: "Попередній перегляд G-коду",
+48 -13
View File
@@ -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 (
<div
className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer overflow-hidden ${
@@ -899,16 +904,21 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
{t('common.print')}
</button>
)}
{onSlice && useSlicerApi && isSliceableFilename(file.filename) && (
{isSliceableFilename(file.filename) && (onSlice || onOpenInSlicer) && (
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:upload') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
!sliceDisabled ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:upload')) { onSlice(file); setShowActions(false); } }}
disabled={!hasPermission('library:upload')}
title={!hasPermission('library:upload') ? t('fileManager.noPermissionSlice') : undefined}
onClick={() => {
if (sliceDisabled) return;
if (useSlicerApi) onSlice?.(file);
else onOpenInSlicer?.(file);
setShowActions(false);
}}
disabled={sliceDisabled}
title={sliceDisabled ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
>
<Cog className="w-3.5 h-3.5" />
{useSlicerApi ? <Cog className="w-3.5 h-3.5" /> : <ExternalLink className="w-3.5 h-3.5" />}
{t('slice.action')}
</button>
)}
@@ -1145,6 +1155,27 @@ export function FileManagerPage() {
queryKey: ['settings'],
queryFn: () => api.getSettings() as Promise<AppSettings>,
});
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() {
</button>
</>
)}
{(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
{isSliceableFilename(file.filename) && (
<button
onClick={() => hasPermission('library:upload') && setSliceFile(file)}
onClick={() => {
if (!canSlice()) return;
(settings?.use_slicer_api ? setSliceFile : handleOpenInSlicer)(file);
}}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:upload')
canSlice()
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:upload') ? t('slice.action') : t('fileManager.noPermissionSlice')}
disabled={!hasPermission('library:upload')}
title={canSlice() ? t('slice.action') : (settings?.use_slicer_api ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload'))}
disabled={!canSlice()}
>
<Cog className="w-4 h-4" />
{settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
</button>
)}
{(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (