Merge branch 'dev' into feature/p2s-x2d-accessory-fans

This commit is contained in:
MartinNYHC
2026-07-30 17:37:48 +02:00
committed by GitHub
79 changed files with 6263 additions and 436 deletions
+2
View File
@@ -26,6 +26,7 @@ import { SetupPage } from './pages/SetupPage';
import { NotificationsPage } from './pages/NotificationsPage';
import { GCodeViewerPage } from './pages/GCodeViewerPage';
import { useWebSocket } from './hooks/useWebSocket';
import { usePrintProgressTitle } from './hooks/usePrintProgressTitle';
import { useStreamTokenSync } from './hooks/useCameraStreamToken';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
@@ -89,6 +90,7 @@ function StreamTokenSync() {
function WebSocketProvider({ children }: { children: React.ReactNode }) {
useWebSocket();
usePrintProgressTitle();
return <>{children}</>;
}
@@ -571,3 +571,93 @@ describe('AddNotificationModal — Bark provider (#1495)', () => {
});
});
});
describe('AddNotificationModal — Telegram forum topic (#1518)', () => {
const telegramProvider = (config: Record<string, unknown> = { bot_token: 'x', chat_id: '-100123' }) =>
buildProvider({ provider_type: 'telegram', config });
it('offers the Forum Topic ID field as optional for telegram', async () => {
render(<AddNotificationModal provider={telegramProvider()} onClose={() => undefined} />);
const label = await screen.findByText(/forum topic id/i);
// Required fields are marked with a trailing asterisk — this one must not be.
expect(label.textContent).not.toContain('*');
expect(screen.getByText(/leave empty for the general topic/i)).toBeInTheDocument();
});
it('does not offer the field for other providers', async () => {
render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
await screen.findByDisplayValue('My ntfy');
expect(screen.queryByText(/forum topic id/i)).not.toBeInTheDocument();
});
it('round-trips the topic id into config on save', async () => {
let captured: { config: Record<string, unknown> } | null = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = (await request.json()) as { config: Record<string, unknown> };
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
await user.type(await screen.findByPlaceholderText('123'), '25');
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(captured).not.toBeNull();
expect(captured!.config).toMatchObject({ chat_id: '-100123', message_thread_id: '25' });
});
it('keeps the config free of the key when the field is left empty', async () => {
let captured: { config: Record<string, unknown> } | null = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = (await request.json()) as { config: Record<string, unknown> };
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
await screen.findByPlaceholderText('123');
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(captured!.config).not.toHaveProperty('message_thread_id');
});
it('blocks save on a non-numeric topic id', async () => {
// Reaches the form via a config written by the API rather than the picker —
// the number input itself already filters most junk out.
let patched = false;
server.use(
http.patch('*/api/v1/notifications/1', async () => {
patched = true;
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(
<AddNotificationModal
provider={telegramProvider({ bot_token: 'x', chat_id: '-100123', message_thread_id: 'General' })}
onClose={onClose}
/>,
);
await screen.findByText(/forum topic id/i);
await user.click(screen.getByRole('button', { name: /^save$/i }));
expect(await screen.findByText(/forum topic id must be a number/i)).toBeInTheDocument();
expect(patched).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -1242,6 +1242,102 @@ describe('SliceModal', () => {
});
});
// #2712: the filament list is positional the whole way down — index 0 is
// slot 1, and the backend forwards it as filament_1.json..filament_N.json.
// A MakerWorld source that ships slice_info and paints with slot 4 alone
// used to yield a one-row list, so the user's only pick was bound to slot 1
// and slot 4 sliced with the source's embedded default: picking PETG gave a
// PLA print. The modal now asks for every project slot so the positions
// line up.
it('requests every project slot, not just the ones the plate prints with', async () => {
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Tunnel.3mf' },
onClose: vi.fn(),
});
await waitFor(() => expect(mockApi.getLibraryFileFilamentRequirements).toHaveBeenCalled());
const [, plateArg, , fullSlots] = mockApi.getLibraryFileFilamentRequirements.mock.calls[0];
expect(plateArg).toBe(1);
expect(fullSlots).toBe(true);
});
it('sends the pick for a high-numbered slot at its own index', async () => {
// Four project slots, only slot 4 printed. The pick must arrive as the
// FOURTH entry; anywhere else and the slicer binds it to the wrong slot.
mockApi.getLibraryFilePlates.mockResolvedValue({
file_id: 100,
filename: 'Tunnel.3mf',
is_multi_plate: false,
plates: [
{
index: 1,
name: 'Plate 1',
objects: ['Tunnel'],
has_thumbnail: false,
thumbnail_url: null,
print_time_seconds: 1200,
filament_used_grams: 105,
filaments: [],
},
],
});
mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
file_id: 100,
filename: 'Tunnel.3mf',
plate_id: 1,
filaments: [
{ slot_id: 1, type: 'PLA', color: '#38CC0A', used_grams: 0, used_meters: 0, used_in_plate: false },
{ slot_id: 2, type: 'PLA', color: '#161616', used_grams: 0, used_meters: 0, used_in_plate: false },
{ slot_id: 3, type: 'PLA', color: '#898989', used_grams: 0, used_meters: 0, used_in_plate: false },
{ slot_id: 4, type: 'PLA', color: '#898989', used_grams: 105, used_meters: 35, used_in_plate: true },
],
});
mockApi.getSlicerPresets.mockResolvedValue({
cloud: {
printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
filament: [
{ id: 'F-PLA', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#898989' },
{ id: 'F-PETG', name: 'Cloud PETG', source: 'cloud', filament_type: 'PETG', filament_colour: '#00FF00' },
],
},
local: { printer: [], process: [], filament: [] },
standard: { printer: [], process: [], filament: [] },
cloud_status: 'ok',
orca_cloud: { printer: [], process: [], filament: [] },
orca_cloud_status: 'ok',
});
mockApi.sliceLibraryFile.mockResolvedValue({
job_id: 51,
status: 'pending',
status_url: '/api/v1/slice-jobs/51',
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Tunnel.3mf' },
onClose: vi.fn(),
});
await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
// 1 printer + 1 process + 1 bed-type + 4 filament rows.
const selects = presetSelects();
expect(selects).toHaveLength(7);
// Only slot 4 is selectable — the other three are the padding.
expect([selects[3].disabled, selects[4].disabled, selects[5].disabled]).toEqual([true, true, true]);
expect(selects[6].disabled).toBe(false);
const user = userEvent.setup();
await user.selectOptions(selects[6], 'cloud:F-PETG');
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
await waitFor(() => {
const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
expect(body.filament_presets).toHaveLength(4);
expect(body.filament_presets[3]).toEqual({ source: 'cloud', id: 'F-PETG' });
});
});
// ------------------------------------------------------------------
// Slicer Pipelines (#1425) — Apply / Save integration in SliceModal
// ------------------------------------------------------------------
@@ -482,3 +482,188 @@ describe('SliceJobTrackerProvider — persistent progress toast', () => {
expect(screen.queryByText(/%/)).toBeNull();
});
});
describe('SliceJobTrackerProvider — one completion per job', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
/** A QueryClient whose invalidateQueries is counted. completeJob calls it
* twice (library-files + archives), so the counter divided by two is the
* number of times the terminal state was handled — a direct count that
* doesn't depend on how long a transient toast happens to stay on screen. */
function countingWrapper(counter: { n: number }) {
return function Counting({ children }: { children: ReactNode }) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const original = queryClient.invalidateQueries.bind(queryClient);
queryClient.invalidateQueries = ((...args: Parameters<typeof original>) => {
counter.n += 1;
return original(...args);
}) as typeof queryClient.invalidateQueries;
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<SliceJobTrackerProvider>{children}</SliceJobTrackerProvider>
</ToastProvider>
</QueryClientProvider>
);
};
}
it('handles completion once when the backend stalls for many poll intervals', async () => {
// The reported symptom: a single slice produced a stream of "Sliced X"
// toasts, more than ten of them, arriving one poll interval apart.
//
// setInterval does not await an async callback. Slicing a large project
// blocks the backend for seconds (zip parsing and output assembly are
// synchronous), so poll ticks piled up behind one stalled request —
// each holding a snapshot taken while the job was still active. When
// the backend recovered they all resolved 'completed' at once and each
// one ran the completion path. A 20s stall against the 1.5s interval
// stacks ~14 of them, which is the observed magnitude.
const STALL_MS = 20_000;
let polls = 0;
mockApi.getSliceJob.mockImplementation(async () => {
polls += 1;
await new Promise((resolve) => setTimeout(resolve, STALL_MS));
return {
job_id: 30,
status: 'completed',
kind: 'library_file',
source_id: 300,
source_name: 'Stalled.3mf',
created_at: new Date().toISOString(),
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
};
});
const counter = { n: 0 };
const Counting = countingWrapper(counter);
render(
<Counting>
<TrackTrigger id={30} name="Stalled.3mf" />
</Counting>,
);
act(() => {
screen.getByText('track-30').click();
});
// Well past the stall, so every tick that could have piled up has had
// its chance to resolve.
for (let i = 0; i < 200; i += 1) {
await act(async () => {
vi.advanceTimersByTime(200);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
expect(counter.n / 2).toBe(1);
// The in-flight guard also has to stop the pile-up itself, not just its
// visible consequence: a stalled backend must not be handed a fresh
// request every 1.5s. One poll starts, one more can start after it
// resolves and the job is already gone.
expect(polls).toBeLessThanOrEqual(2);
});
it('handles the slower of two jobs once when the first one restarts the poller', async () => {
// The in-flight guard alone does not cover this. Two jobs are tracked;
// the first completes while the second's request is still open. That
// completion changes the tracked count, so the polling effect tears
// down and starts a fresh interval — with its own in-flight flag —
// while the previous round is still parked on the second job's await.
// Both rounds then see 'completed' for it, and the job reports itself
// twice unless the abandoned round notices it was cancelled or the
// completion path refuses the repeat. Either guard alone closes this;
// both are kept, so removing one still passes and removing both fails.
mockApi.getSliceJob.mockImplementation(async (id: number) => {
const base = {
kind: 'library_file' as const,
created_at: new Date().toISOString(),
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
};
if (id === 40) {
return { ...base, job_id: 40, status: 'completed', source_id: 400, source_name: 'Fast.3mf' };
}
await new Promise((resolve) => setTimeout(resolve, 6000));
return { ...base, job_id: 41, status: 'completed', source_id: 401, source_name: 'Slow.3mf' };
});
const counter = { n: 0 };
const Counting = countingWrapper(counter);
render(
<Counting>
<TrackTrigger id={40} name="Fast.3mf" />
<TrackTrigger id={41} name="Slow.3mf" />
</Counting>,
);
act(() => {
screen.getByText('track-40').click();
screen.getByText('track-41').click();
});
for (let i = 0; i < 100; i += 1) {
await act(async () => {
vi.advanceTimersByTime(200);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
// Exactly two completions: one per job, neither repeated.
expect(counter.n / 2).toBe(2);
});
it('still completes a job tracked again under the same id', async () => {
// The finished-id set must not turn into a permanent block list: a
// re-tracked id has to reach the completion path again.
mockApi.getSliceJob.mockResolvedValue({
job_id: 32,
status: 'completed',
kind: 'library_file',
source_id: 302,
source_name: 'Again.3mf',
created_at: new Date().toISOString(),
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
});
const counter = { n: 0 };
const Counting = countingWrapper(counter);
render(
<Counting>
<TrackTrigger id={32} name="Again.3mf" />
</Counting>,
);
for (let round = 0; round < 2; round += 1) {
act(() => {
screen.getByText('track-32').click();
});
for (let i = 0; i < 3; i += 1) {
await act(async () => {
vi.advanceTimersByTime(1500);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
}
expect(counter.n / 2).toBe(2);
});
});
@@ -1287,3 +1287,113 @@ describe('useFilamentMapping — no [-1] mapping during a status-load race (#258
expect(result.current.hasTypeMismatch).toBe(true);
});
});
describe('colour verdict is independent of how the tray was found (#2687)', () => {
// tray_info_idx names the filament *variant*, not an individual spool:
// GFA00 = PLA Basic, GFA01 = PLA Matte, GFA17 = PLA Translucent. A user with
// exactly one Matte spool loaded therefore idx-matches every Matte
// requirement no matter what colour it is.
const MATTE_DARK_GREEN = createPrinterStatus([
{ id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' }] },
]);
const wantRedMatte = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#9D432C', used_grams: 31, tray_info_idx: 'GFA01' },
],
};
it('reports a unique-idx tray of the wrong colour as type_only, not match', () => {
const [item] = buildFilamentComparison(
wantRedMatte,
buildLoadedFilaments(MATTE_DARK_GREEN),
{},
);
// The tray is still selected — it is the right variant (#2650) ...
expect(item.loaded?.globalTrayId).toBe(0);
// ... but red-on-dark-green is not a colour match.
expect(item.colorMatch).toBe(false);
expect(item.status).toBe('type_only');
});
it('auto and manual agree on the same tray', () => {
const loaded = buildLoadedFilaments(MATTE_DARK_GREEN);
const auto = buildFilamentComparison(wantRedMatte, loaded, {})[0];
const manual = buildFilamentComparison(wantRedMatte, loaded, { 1: 0 })[0];
// The original report: auto said "match", manually picking that very tray
// said "mismatch". Both paths must now reach the same verdict.
expect(manual.isManual).toBe(true);
expect(auto.status).toBe(manual.status);
expect(auto.colorMatch).toBe(manual.colorMatch);
});
it('surfaces the mismatch through the hook so the panel stops saying Ready', () => {
const { result } = renderHook(() => useFilamentMapping(wantRedMatte, MATTE_DARK_GREEN, {}));
// hasColorMismatch drives the yellow "(Color mismatch)" header; the tray is
// still mapped, so this is not a type mismatch.
expect(result.current.hasColorMismatch).toBe(true);
expect(result.current.hasTypeMismatch).toBe(false);
expect(result.current.amsMapping).toEqual([0]);
});
it('still reports a match when the unique-idx tray does carry the right colour', () => {
const [item] = buildFilamentComparison(
{ filaments: [{ slot_id: 1, type: 'PLA', color: '#004225', used_grams: 31, tray_info_idx: 'GFA01' }] },
buildLoadedFilaments(MATTE_DARK_GREEN),
{},
);
expect(item.status).toBe('match');
expect(item.colorMatch).toBe(true);
});
it('accepts a near-enough shade on the idx path', () => {
// Within colorsAreSimilar's per-channel tolerance — the printer reporting a
// spool a shade off must not become a mismatch.
const [item] = buildFilamentComparison(
{ filaments: [{ slot_id: 1, type: 'PLA', color: '#0A4A2A', used_grams: 31, tray_info_idx: 'GFA01' }] },
buildLoadedFilaments(MATTE_DARK_GREEN),
{},
);
expect(item.status).toBe('match');
});
it('treats a colourless requirement as satisfied by any colour', () => {
// 3MFs that omit the colour parse to "" (filament_requirements.py); there is
// nothing to disagree with, so this must not read as a colour mismatch.
const [item] = buildFilamentComparison(
{ filaments: [{ slot_id: 1, type: 'PLA', color: '', used_grams: 31, tray_info_idx: 'GFA01' }] },
buildLoadedFilaments(MATTE_DARK_GREEN),
{},
);
expect(item.status).toBe('match');
expect(item.colorMatch).toBe(true);
});
it('keeps the multi-idx path intact — same idx, several colours picks the right one', () => {
// Two Matte spools: the branch that already compared colours must be
// unaffected, and the exact-colour tray still wins.
const twoMatte = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' },
{ id: 1, tray_type: 'PLA', tray_color: '9D432C', tray_info_idx: 'GFA01' },
],
},
]);
const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(twoMatte), {});
expect(item.loaded?.globalTrayId).toBe(1);
expect(item.status).toBe('match');
});
it('a type-only fallback with no idx candidate is still type_only', () => {
// Regression guard: the pre-existing "type matches, colour does not" path.
const basicOnly = createPrinterStatus([
{ id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA00' }] },
]);
const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(basicOnly), {});
expect(item.status).toBe('type_only');
expect(item.colorMatch).toBe(false);
});
});
@@ -0,0 +1,157 @@
import type { ReactNode } from 'react';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { renderHook, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock the theme pref and the API the hook reads, so the effect can be exercised
// without the real providers. `theme.value` is swapped per test.
const h = vi.hoisted(() => ({
theme: {
value: {
progressInTitle: false,
resolvedMode: 'dark',
darkAccent: 'green',
lightAccent: 'green',
} as { progressInTitle: boolean; resolvedMode: string; darkAccent: string; lightAccent: string },
},
getPrinters: vi.fn(),
getPrinterStatus: vi.fn(),
}));
vi.mock('../../contexts/ThemeContext', () => ({ useTheme: () => h.theme.value }));
vi.mock('../../api/client', () => ({
api: { getPrinters: h.getPrinters, getPrinterStatus: h.getPrinterStatus },
}));
import { pickActivePrint, usePrintProgressTitle, type ProgressStatus } from '../../hooks/usePrintProgressTitle';
const running = (progress: number, remaining_time: number | null): ProgressStatus => ({
state: 'RUNNING',
progress,
remaining_time,
});
describe('pickActivePrint', () => {
it('returns null when nothing is printing', () => {
expect(pickActivePrint([])).toBeNull();
expect(pickActivePrint([undefined])).toBeNull();
expect(pickActivePrint([{ state: 'IDLE', progress: 0, remaining_time: null }])).toBeNull();
// The real paused state is 'PAUSE', not 'PAUSED'.
expect(pickActivePrint([{ state: 'PAUSE', progress: 40, remaining_time: 10 }])).toBeNull();
});
it('ignores RUNNING prints with no progress value', () => {
expect(pickActivePrint([{ state: 'RUNNING', progress: null, remaining_time: 5 }])).toBeNull();
});
it('picks the soonest-finishing print among several running', () => {
const soonest = running(20, 12);
expect(pickActivePrint([running(80, 45), soonest, running(50, 30)])).toBe(soonest);
});
it('tie-breaks equal ETAs by highest progress', () => {
const further = running(70, 15);
expect(pickActivePrint([running(30, 15), further])).toBe(further);
});
it('treats a null remaining_time as furthest away', () => {
const withEta = running(10, 60);
expect(pickActivePrint([running(90, null), withEta])).toBe(withEta);
});
it('treats remaining_time <= 0 as unknown — a just-started print must not win', () => {
// The backend serialises "ETA not known yet" as 0 (not null). A printer that
// just started (0) must not steal the tab from one that is nearly done.
const almostDone = running(95, 180);
expect(pickActivePrint([running(2, 0), almostDone])).toBe(almostDone);
expect(pickActivePrint([running(2, -1), almostDone])).toBe(almostDone);
});
});
function wrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
// jsdom has no canvas backend — calling getContext('2d') logs a "Not implemented"
// jsdomError (with a full React stack) into the suite output on every run, and
// leaves the favicon path untested because it bails on the null context. Stub
// both canvas calls so the ring code actually executes and the swap is assertable.
const RING_URL = 'data:image/png;base64,ring';
const fakeCtx = {
beginPath: vi.fn(),
arc: vi.fn(),
stroke: vi.fn(),
lineWidth: 0,
strokeStyle: '',
lineCap: 'butt',
} as unknown as CanvasRenderingContext2D;
const realGetContext = HTMLCanvasElement.prototype.getContext;
const realToDataURL = HTMLCanvasElement.prototype.toDataURL;
function faviconHref(): string {
return document.querySelector<HTMLLinkElement>('link[rel~="icon"]')!.href;
}
describe('usePrintProgressTitle effect', () => {
beforeEach(() => {
h.getPrinters.mockReset();
h.getPrinterStatus.mockReset();
HTMLCanvasElement.prototype.getContext = (() =>
fakeCtx) as typeof HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.toDataURL = (() =>
RING_URL) as typeof HTMLCanvasElement.prototype.toDataURL;
// Replaces <title> too, so set the title after wiring the head up — the hook
// captures document.title at mount.
document.head.innerHTML = '<link rel="icon" href="/favicon.svg">';
document.title = 'Bambuddy';
});
afterEach(() => {
cleanup();
HTMLCanvasElement.prototype.getContext = realGetContext;
HTMLCanvasElement.prototype.toDataURL = realToDataURL;
});
it('is inert while the pref is off — never touches the tab title', async () => {
h.theme.value = { progressInTitle: false, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
document.title = 'Something Else';
renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
await new Promise((r) => setTimeout(r, 20));
expect(document.title).toBe('Something Else');
expect(faviconHref()).toContain('/favicon.svg');
expect(h.getPrinters).not.toHaveBeenCalled();
});
it('shows the active print percentage in the title and swaps the favicon', async () => {
h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
h.getPrinters.mockResolvedValue([{ id: 1 }]);
h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
await waitFor(() => expect(document.title).toBe('42% · Bambuddy'));
expect(faviconHref()).toBe(RING_URL);
});
it('restores the original title and favicon when the pref is switched off', async () => {
h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
h.getPrinters.mockResolvedValue([{ id: 1 }]);
h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
const { rerender } = renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
await waitFor(() => expect(document.title).toBe('42% · Bambuddy'));
h.theme.value = { ...h.theme.value, progressInTitle: false };
rerender();
await waitFor(() => expect(document.title).toBe('Bambuddy'));
expect(faviconHref()).toContain('/favicon.svg');
});
});
@@ -71,7 +71,10 @@ function mockAuthUser(permissions: string[]) {
}
async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
const row = screen.getByText(folderName).parentElement!;
// Walk up to the row itself rather than assuming the name is its direct
// child — the name sits in a wrapper that also holds the optional
// last-activity line (#2680).
const row = screen.getByText(folderName).closest('div.group')!;
const buttons = within(row).getAllByRole('button');
// The kebab (MoreVertical) menu toggle is the last button in the row
await user.click(buttons[buttons.length - 1]);
@@ -2,7 +2,7 @@
* Tests for the FileManagerPage component.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
@@ -21,6 +21,9 @@ const mockFolders = [
archive_id: null,
project_name: null,
archive_name: null,
// #2680: distinctive year so the folder-pane display test can assert on it
// without colliding with the file mtimes below.
latest_activity_at: '2031-04-05T10:00:00Z',
children: [
{
id: 2,
@@ -31,6 +34,7 @@ const mockFolders = [
archive_id: null,
project_name: null,
archive_name: null,
latest_activity_at: '2032-06-07T10:00:00Z',
children: [],
},
],
@@ -44,6 +48,9 @@ const mockFolders = [
archive_id: null,
project_name: 'My Art Project',
archive_name: null,
// No activity timestamp — must render no date line rather than an
// "Invalid Date" placeholder.
latest_activity_at: null,
children: [],
},
];
@@ -880,6 +887,13 @@ describe('FileManagerPage', () => {
setItemMock.mockReset();
});
// The mock is module-global, so an implementation left behind here would
// silently change every later describe (e.g. collapsing the folder tree).
afterEach(() => {
getItemMock.mockReset();
setItemMock.mockReset();
});
it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
getItemMock.mockReturnValue(null);
render(<FileManagerPage />);
@@ -1111,5 +1125,34 @@ describe('FileManagerPage', () => {
expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
});
});
it('the same toggle reveals latest activity on folder rows, including nested ones', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
});
expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
await user.click(screen.getByTitle('Show modified dates'));
await waitFor(() => {
expect(screen.getByText(/2031/)).toBeInTheDocument();
});
// Nested folders get it too — the prop must survive the recursion.
expect(screen.getByText(/2032/)).toBeInTheDocument();
// A folder with no activity timestamp renders nothing rather than an
// "Invalid Date" string.
const artRow = screen.getByText('Art Projects').closest('div.group')!;
expect(artRow.textContent).not.toMatch(/Invalid/);
await user.click(screen.getByTitle('Hide modified dates'));
await waitFor(() => {
expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
});
});
});
});
+14
View File
@@ -4693,10 +4693,17 @@ export const api = {
archiveId: number,
plateId?: number,
requestId?: string,
/** Ask for one entry per project slot instead of only the slots this
* plate consumes. The slice modal needs it: its filament list is
* positional, so a source whose only used slot is 4 must still present
* four rows or the user's pick is bound to slot 1 (#2712). Print-time
* AMS matching must NOT set this — it wants the used-only list. */
fullSlots?: boolean,
) => {
const qs = new URLSearchParams();
if (plateId !== undefined) qs.set('plate_id', String(plateId));
if (requestId) qs.set('request_id', requestId);
if (fullSlots) qs.set('full_slots', 'true');
return request<{
archive_id: number;
filename: string;
@@ -6397,10 +6404,17 @@ export const api = {
fileId: number,
plateId?: number,
requestId?: string,
/** Ask for one entry per project slot instead of only the slots this
* plate consumes. The slice modal needs it: its filament list is
* positional, so a source whose only used slot is 4 must still present
* four rows or the user's pick is bound to slot 1 (#2712). Print-time
* AMS matching must NOT set this — it wants the used-only list. */
fullSlots?: boolean,
) => {
const qs = new URLSearchParams();
if (plateId !== undefined) qs.set('plate_id', String(plateId));
if (requestId) qs.set('request_id', requestId);
if (fullSlots) qs.set('full_slots', 'true');
return request<{
file_id: number;
filename: string;
@@ -160,6 +160,15 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
}
}
// Telegram forum topic must be a plain integer (#1518) — type="number"
// still lets "1e5" and "-" through, and Telegram would 400 on those.
if (providerType === 'telegram' && config.message_thread_id?.trim()) {
if (!/^\d+$/.test(config.message_thread_id.trim())) {
setError(t('notifications.telegramThreadIdInvalid'));
return;
}
}
const finalConfig: Record<string, unknown> =
providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
? { ...config, event_priorities: eventPriorities }
@@ -245,6 +254,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
return [
{ key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
{ key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
// Optional forum topic (#1518). Left empty, Telegram posts to the
// group's General topic exactly as before.
{
key: 'message_thread_id',
label: t('notifications.telegramThreadId'),
placeholder: '123',
type: 'number',
required: false,
help: t('notifications.telegramThreadIdHelp'),
},
];
case 'email':
return [
@@ -423,6 +442,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
)}
{'help' in field && (field as { help?: string }).help && (
<p className="text-xs text-bambu-gray mt-1">{(field as { help?: string }).help}</p>
)}
</div>
))}
</div>
+8 -2
View File
@@ -316,10 +316,16 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
const filamentReqsQuery = useQuery({
queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
queryFn: async () => {
// `fullSlots`: one row per project slot, not only the ones this plate
// prints with. The list below is positional all the way to the CLI's
// filament_N.json parts, so a source whose only used slot is 4 has to
// present four rows — otherwise the single pick binds to slot 1 and
// slot 4 slices with whatever the source had baked in (#2712). The
// unused rows stay disabled exactly as before.
if (source.kind === 'libraryFile') {
return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId);
return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
}
return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId);
return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
},
enabled: !needsPlatePicker,
staleTime: 60_000,
@@ -85,6 +85,12 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
const phaseRef = useRef<Map<number, SliceJobStatus>>(new Map());
const progressRef = useRef<Map<number, SliceJobProgress | null>>(new Map());
// Job ids whose terminal state has already been handled. `completeJob`
// shows a toast and invalidates two query keys, so it has to be exactly
// once per job no matter how many callers reach it — see the poll loop
// below for how more than one used to.
const finishedRef = useRef<Set<number>>(new Set());
const renderProgressToast = useCallback(
(job: TrackedJob) => {
const startedAt = startedAtRef.current.get(job.id);
@@ -151,6 +157,10 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
const trackJob = useCallback(
(id: number, kind: 'libraryFile' | 'archive', sourceName: string) => {
setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
// Re-tracking an id re-arms it. Ids come from a database sequence so
// this can't collide in practice; clearing here is what keeps the set
// from being a permanent record of every job the session ever saw.
finishedRef.current.delete(id);
startedAtRef.current.set(id, Date.now());
phaseRef.current.set(id, 'pending');
progressRef.current.set(id, null);
@@ -163,6 +173,11 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
const completeJob = useCallback(
(job: TrackedJob, state: SliceJobState) => {
// Guard, not an optimisation: everything below is a side effect the
// user sees, and a second call would repeat all of it.
if (finishedRef.current.has(job.id)) return;
finishedRef.current.add(job.id);
setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
startedAtRef.current.delete(job.id);
phaseRef.current.delete(job.id);
@@ -201,24 +216,41 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (activeJobs.length === 0) return;
let cancelled = false;
// setInterval does not await an async callback, so a tick fires whether
// or not the previous one came back. Slicing a large project blocks the
// backend for seconds at a time (zip parsing and output assembly are
// synchronous), and every tick that piled up during the stall had
// already captured a snapshot naming the job as active. They all
// resolved `completed` together and each called completeJob, which is
// how one slice produced a stream of a dozen "Sliced X" toasts. Letting
// only one poll round be in flight fixes that at the source, and stops
// queueing requests against a backend that is already saturated.
let polling = false;
const interval = setInterval(async () => {
if (cancelled) return;
const snapshot = [...activeJobsRef.current];
for (const job of snapshot) {
try {
const state = await api.getSliceJob(job.id);
phaseRef.current.set(job.id, state.status);
// Capture the latest progress snapshot if the sidecar fed
// one through. The 1s tick re-renders the toast off this ref.
if (state.progress) {
progressRef.current.set(job.id, state.progress);
if (cancelled || polling) return;
polling = true;
try {
const snapshot = [...activeJobsRef.current];
for (const job of snapshot) {
try {
const state = await api.getSliceJob(job.id);
// The tracker may have been torn down while this was in flight.
if (cancelled) return;
phaseRef.current.set(job.id, state.status);
// Capture the latest progress snapshot if the sidecar fed
// one through. The 1s tick re-renders the toast off this ref.
if (state.progress) {
progressRef.current.set(job.id, state.progress);
}
if (state.status === 'completed' || state.status === 'failed') {
completeJob(job, state);
}
} catch {
// Transient poll failure — stay tracked, retry next tick.
}
if (state.status === 'completed' || state.status === 'failed') {
completeJob(job, state);
}
} catch {
// Transient poll failure — stay tracked, retry next tick.
}
} finally {
polling = false;
}
}, POLL_INTERVAL_MS);
return () => {
+15
View File
@@ -19,6 +19,9 @@ interface ThemeContextType {
lightStyle: ThemeStyle;
lightBackground: LightBackground;
lightAccent: ThemeAccent;
// Show live print progress (% + accent-coloured ring favicon) in the browser tab
progressInTitle: boolean;
setProgressInTitle: (v: boolean) => void;
// Actions
toggleMode: () => void;
setMode: (mode: ThemeMode) => void;
@@ -87,6 +90,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
});
// Client-only pref (localStorage), no api.updateSettings sync — the tab
// title/favicon is per-browser behaviour. Move to server settings if it
// ever needs to follow the user across devices. Default off.
const [progressInTitle, setProgressInTitleState] = useState<boolean>(() => {
return localStorage.getItem('progress-in-title') === 'true';
});
const setProgressInTitle = (v: boolean) => {
setProgressInTitleState(v);
localStorage.setItem('progress-in-title', String(v));
};
// Sync from API once auth state is known. Same gate shape as
// useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to
// settle, then only fetch when we can actually expect a 200 (auth
@@ -202,6 +216,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
resolvedMode,
darkStyle, darkBackground, darkAccent,
lightStyle, lightBackground, lightAccent,
progressInTitle, setProgressInTitle,
toggleMode, setMode,
setDarkStyle, setDarkBackground, setDarkAccent,
setLightStyle, setLightBackground, setLightAccent,
+38 -11
View File
@@ -203,6 +203,27 @@ export function useLoadedFilaments(
}, [printerStatus]);
}
/**
* Does the tray we picked actually carry the colour the slice asked for?
*
* Shared by the manual and auto branches below so the two can never disagree
* about the same tray again (#2687). Exact hex first, then the perceptual
* tolerance, so a spool the printer reports one shade off still reads as a
* match.
*
* A requirement with no colour at all is not a mismatch — the 3MF simply
* didn't ask for one (`filament_requirements.py` defaults it to `""`), so any
* loaded colour satisfies it. Loaded trays always have a colour: buildLoaded-
* Filaments falls back to grey when MQTT reports none.
*/
function coloursMatch(loadedColor: string | undefined, requiredColor: string | undefined): boolean {
const required = normalizeColorForCompare(requiredColor);
if (!required) return true;
return (
normalizeColorForCompare(loadedColor) === required || colorsAreSimilar(loadedColor, requiredColor)
);
}
/**
* Compare required filaments with loaded filaments (non-hook version).
*
@@ -236,9 +257,7 @@ export function buildFilamentComparison(
if (manualLoaded) {
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch =
normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(manualLoaded.color, req.color);
const colorMatch = coloursMatch(manualLoaded.color, req.color);
let status: FilamentStatus;
if (typeMatch && colorMatch) {
@@ -358,17 +377,25 @@ export function buildFilamentComparison(
const hasFilament = !!loaded;
const typeMatch = hasFilament;
// idxMatch is always considered a color match (same spool = same color)
const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
// #2687: judge the colour on the tray we actually picked, never on which
// branch found it. tray_info_idx identifies the filament *variant* — GFA00
// is PLA Basic, GFA01 PLA Matte, GFA17 PLA Translucent — not an individual
// spool, so one Matte spool idx-matches every Matte requirement whatever
// colour it is. The old rule ("same spool = same color") therefore reported
// red-required-on-green-loaded as a match, while manually picking that same
// tray reported the mismatch honestly. Variant still decides *selection*
// (#2650: Basic is not Matte) — it just no longer decides the verdict.
const colorMatch = hasFilament && coloursMatch(loaded.color, req.color);
// Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
// No tray of the required type at all is a type mismatch; otherwise the
// colour decides between a full match and type-only.
let status: FilamentStatus;
if (idxMatch || exactMatch || similarMatch) {
status = 'match';
} else if (typeOnlyMatch) {
status = 'type_only';
} else {
if (!hasFilament) {
status = 'mismatch';
} else if (colorMatch) {
status = 'match';
} else {
status = 'type_only';
}
return {
+162
View File
@@ -0,0 +1,162 @@
import { useQueries, useQuery } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { api } from '../api/client';
import { useTheme } from '../contexts/ThemeContext';
const FALLBACK_ACCENT = '#00ae42'; // Bambuddy green, if --accent can't be read (e.g. jsdom)
// A remaining_time <= 0 means "ETA not known yet" (the backend defaults it to 0,
// not null), so treat it as unknown rather than "finishes now".
const eta = (t: number | null): number => (t != null && t > 0 ? t : Infinity);
// Only the fields we need — keeps pickActivePrint decoupled from the full
// PrinterStatus type so the test can pass plain objects.
export interface ProgressStatus {
state: string | null;
progress: number | null;
remaining_time: number | null;
}
/**
* Of all connected printers, pick the RUNNING print to surface in the tab:
* the one finishing soonest (smallest remaining_time), tie-broken by highest
* progress. Returns null when nothing is actively printing.
*/
export function pickActivePrint<T extends ProgressStatus>(statuses: (T | undefined)[]): T | null {
let best: T | null = null;
for (const s of statuses) {
if (!s || s.state !== 'RUNNING' || s.progress == null) continue;
if (best === null) {
best = s;
continue;
}
const sr = eta(s.remaining_time);
const br = eta(best.remaining_time);
if (sr < br || (sr === br && (s.progress ?? 0) > (best.progress ?? 0))) {
best = s;
}
}
return best;
}
// Draw a 32x32 progress ring in the current theme accent colour, return a PNG
// data URL (or null if the browser has no 2d canvas, e.g. under jsdom — the
// caller then falls back to updating the title only).
function drawProgressFavicon(pct: number): string | null {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const accent =
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() ||
FALLBACK_ACCENT;
const cx = 16;
const cy = 16;
const r = 13;
const frac = Math.max(0, Math.min(100, pct)) / 100;
const start = -Math.PI / 2; // 12 o'clock
ctx.lineWidth = 4;
// Track
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(128,128,128,0.3)';
ctx.stroke();
// Progress arc, clockwise from the top
ctx.beginPath();
ctx.arc(cx, cy, r, start, start + frac * Math.PI * 2);
ctx.strokeStyle = accent;
ctx.lineCap = 'round';
ctx.stroke();
return canvas.toDataURL('image/png');
}
// Point the <link rel="icon"> tags at the ring (remembering originals), or
// restore them when dataUrl is null. apple-touch-icon is a different rel token
// so the `rel~="icon"` selector leaves it alone.
function setFavicon(dataUrl: string | null, originals: Map<HTMLLinkElement, string>) {
const links = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
links.forEach((link) => {
if (dataUrl) {
if (!originals.has(link)) originals.set(link, link.href);
link.href = dataUrl;
} else {
const orig = originals.get(link);
if (orig !== undefined) link.href = orig;
}
});
if (!dataUrl) originals.clear();
}
/**
* When the "progress in tab" preference is on, reflect the soonest-finishing
* print's percentage in document.title and draw a progress ring favicon in the
* theme accent colour. Stays fully inert until enabled, and hands the tab back
* to its defaults once disabled, idle, or unmounted.
* Mounted once, globally, inside WebSocketProvider.
*/
export function usePrintProgressTitle() {
const { progressInTitle, resolvedMode, darkAccent, lightAccent } = useTheme();
// Re-draw the ring when the active accent changes.
const accent = resolvedMode === 'dark' ? darkAccent : lightAccent;
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
enabled: progressInTitle,
});
// No refetchInterval here on purpose. This hook is mounted globally, so a
// poll would add one request per printer every interval on every page — the
// Printers page already runs its own 30s fallback on this exact key. The
// WebSocket writes ['printerStatus', id] directly (useWebSocket), which keeps
// the tab live; a cosmetic title going stale during a WS outage is fine.
const statusQueries = useQueries({
queries: (progressInTitle ? printers ?? [] : []).map((p) => ({
queryKey: ['printerStatus', p.id],
queryFn: () => api.getPrinterStatus(p.id),
})),
});
const originalsRef = useRef<Map<HTMLLinkElement, string>>(new Map());
// The tab's own title, captured before we ever touch it, so restoring doesn't
// depend on a constant matching index.html.
const defaultTitleRef = useRef(document.title);
// Whether we currently own the tab title/favicon. Lets us stay inert while
// off (never touch the tab) yet still restore once if we ever took it over.
const ownsRef = useRef(false);
const active = progressInTitle ? pickActivePrint(statusQueries.map((q) => q.data)) : null;
const pct = active && active.progress != null ? Math.round(active.progress) : null;
useEffect(() => {
if (progressInTitle && pct != null) {
document.title = `${pct}% · ${defaultTitleRef.current}`;
setFavicon(drawProgressFavicon(pct), originalsRef.current);
ownsRef.current = true;
} else if (ownsRef.current) {
// Disabled or idle after having taken over — hand the tab back.
document.title = defaultTitleRef.current;
setFavicon(null, originalsRef.current);
ownsRef.current = false;
}
// else: never owned the tab → leave it entirely alone.
}, [progressInTitle, pct, accent]);
// Restore the tab to defaults on unmount, but only if we own it.
useEffect(() => {
const originals = originalsRef.current;
const owns = ownsRef;
const defaultTitle = defaultTitleRef.current;
return () => {
if (owns.current) {
document.title = defaultTitle;
setFavicon(null, originals);
}
};
}, []);
}
+3 -3
View File
@@ -14,8 +14,8 @@ import ptBR from './locales/pt-BR';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
import tr from './locales/tr';
import uk from './locales/uk';
import ru from './locales/ru';
import uk from './locales/uk';
const resources = {
en: { translation: en },
@@ -29,8 +29,8 @@ const resources = {
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
tr: { translation: tr },
uk: { translation: uk },
ru: { translation: ru },
uk: { translation: uk },
};
const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'ru', 'tr', 'uk', 'zh-CN', 'zh-TW'];
@@ -111,6 +111,6 @@ export const availableLanguages = [
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
{ code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe' },
{ code: 'uk', name: 'Ukrainian', nativeName: 'Українська' },
{ code: 'ru', name: 'Russian', nativeName: 'Русский' },
{ code: 'uk', name: 'Ukrainian', nativeName: 'Українська' },
];
+6
View File
@@ -2438,6 +2438,8 @@ export default {
styleGlow: 'Leuchtend',
styleVibrant: 'Lebendig',
themeToggleHint: 'Zwischen Dunkel-, Hell- und Systemmodus mit dem Symbol in der Seitenleiste wechseln.',
progressInTitle: 'Druckfortschritt im Tab',
progressInTitleDescription: 'Zeigt den Prozentsatz des aktiven Drucks und einen Fortschrittsring im Browser-Tab an.',
// Archive
autoArchivePrints: 'Drucke automatisch archivieren',
autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
@@ -3670,6 +3672,7 @@ export default {
showModified: 'Änderungsdatum anzeigen',
hideModified: 'Änderungsdatum ausblenden',
lastModified: 'Zuletzt geändert',
lastActivity: 'Letzte Aktivität',
resultsCount: '{{showing}} von {{total}} Dateien',
selectAll: 'Alle auswählen',
deselectAll: 'Auswahl aufheben',
@@ -5617,6 +5620,9 @@ export default {
pushoverExpire: 'Notfall-Ablauf (s)',
botToken: 'Bot-Token',
chatId: 'Chat-ID',
telegramThreadId: 'Forum-Themen-ID',
telegramThreadIdHelp: 'Optional. Sendet in ein einzelnes Thema einer Forum-Gruppe — die letzte Zahl im Themen-Link (t.me/c/.../25). Leer lassen für das allgemeine Thema.',
telegramThreadIdInvalid: 'Die Forum-Themen-ID muss eine Zahl sein.',
smtpServer: 'SMTP-Server',
smtpPort: 'SMTP-Port',
security: 'Sicherheit',
+6
View File
@@ -2457,6 +2457,8 @@ export default {
styleGlow: 'Glow',
styleVibrant: 'Vibrant',
themeToggleHint: 'Toggle between dark, light, and system mode using the icon in the sidebar.',
progressInTitle: 'Print progress in tab',
progressInTitleDescription: 'Show the active print\'s percentage and a progress ring in the browser tab.',
// Archive
autoArchivePrints: 'Auto-archive prints',
autoArchiveDescription: 'Automatically save 3MF files when prints complete',
@@ -3699,6 +3701,7 @@ export default {
showModified: 'Show modified dates',
hideModified: 'Hide modified dates',
lastModified: 'Last modified',
lastActivity: 'Last activity',
resultsCount: '{{showing}} of {{total}} files',
selectAll: 'Select All',
deselectAll: 'Deselect All',
@@ -5661,6 +5664,9 @@ export default {
pushoverExpire: 'Emergency Expire (s)',
botToken: 'Bot Token',
chatId: 'Chat ID',
telegramThreadId: 'Forum Topic ID',
telegramThreadIdHelp: 'Optional. Posts into a single topic of a forum group — the last number in the topic link (t.me/c/.../25). Leave empty for the General topic.',
telegramThreadIdInvalid: 'Forum Topic ID must be a number.',
smtpServer: 'SMTP Server',
smtpPort: 'SMTP Port',
security: 'Security',
+6
View File
@@ -2441,6 +2441,8 @@ export default {
styleGlow: 'Resplandor',
styleVibrant: 'Vibrante',
themeToggleHint: 'Alterne entre modo oscuro, claro y sistema con el icono en la barra lateral.',
progressInTitle: 'Progreso en la pestaña',
progressInTitleDescription: 'Muestra el porcentaje de la impresión activa y un anillo de progreso en la pestaña del navegador.',
// Archive
autoArchivePrints: 'Archivar impresiones automáticamente',
autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
@@ -3673,6 +3675,7 @@ export default {
showModified: 'Mostrar fechas de modificación',
hideModified: 'Ocultar fechas de modificación',
lastModified: 'Última modificación',
lastActivity: 'Última actividad',
resultsCount: '{{showing}} de {{total}} archivos',
selectAll: 'Seleccionar todo',
deselectAll: 'Deseleccionar todo',
@@ -5626,6 +5629,9 @@ export default {
pushoverExpire: 'Expiración de emergencia (s)',
botToken: 'Token del bot',
chatId: 'ID del chat',
telegramThreadId: 'ID del tema del foro',
telegramThreadIdHelp: 'Opcional. Envía a un único tema de un grupo de foro: el último número del enlace del tema (t.me/c/.../25). Déjalo vacío para el tema General.',
telegramThreadIdInvalid: 'El ID del tema del foro debe ser un número.',
smtpServer: 'Servidor SMTP',
smtpPort: 'Puerto SMTP',
security: 'Seguridad',
+6
View File
@@ -2393,6 +2393,8 @@ export default {
styleGlow: 'Lumineux',
styleVibrant: 'Vif',
themeToggleHint: 'Basculer entre le mode sombre, clair et système avec l\'icône dans la barre latérale.',
progressInTitle: 'Progression dans l\'onglet',
progressInTitleDescription: 'Affiche le pourcentage de l\'impression en cours et un anneau de progression dans l\'onglet du navigateur.',
autoArchivePrints: 'Archiver automatiquement les impressions',
autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',
@@ -3659,6 +3661,7 @@ export default {
showModified: 'Afficher les dates de modification',
hideModified: 'Masquer les dates de modification',
lastModified: 'Dernière modification',
lastActivity: 'Dernière activité',
resultsCount: '{{showing}} sur {{total}} fichiers',
selectAll: 'Tout sélectionner',
deselectAll: 'Tout désélectionner',
@@ -5607,6 +5610,9 @@ export default {
pushoverExpire: 'Expiration urgence (s)',
botToken: 'Jeton du bot',
chatId: 'ID du chat',
telegramThreadId: 'ID du sujet de forum',
telegramThreadIdHelp: 'Facultatif. Envoie dans un seul sujet d\'un groupe forum : le dernier nombre du lien du sujet (t.me/c/.../25). Laisser vide pour le sujet General.',
telegramThreadIdInvalid: 'L\'ID du sujet de forum doit être un nombre.',
smtpServer: 'Serveur SMTP',
smtpPort: 'Port SMTP',
security: 'Sécurité',
+6
View File
@@ -2392,6 +2392,8 @@ export default {
styleGlow: 'Luminoso',
styleVibrant: 'Vibrante',
themeToggleHint: 'Passa tra modalità scura, chiara e sistema con l\'icona nella barra laterale.',
progressInTitle: 'Avanzamento nella scheda',
progressInTitleDescription: 'Mostra la percentuale della stampa attiva e un anello di avanzamento nella scheda del browser.',
autoArchivePrints: 'Archiviazione automatica stampe',
autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',
@@ -3658,6 +3660,7 @@ export default {
showModified: 'Mostra date di modifica',
hideModified: 'Nascondi date di modifica',
lastModified: 'Ultima modifica',
lastActivity: 'Ultima attività',
resultsCount: '{{showing}} di {{total}} file',
selectAll: 'Seleziona tutto',
deselectAll: 'Deseleziona tutto',
@@ -5606,6 +5609,9 @@ export default {
pushoverExpire: 'Scadenza emergenza (s)',
botToken: 'Token del bot',
chatId: 'ID chat',
telegramThreadId: 'ID argomento forum',
telegramThreadIdHelp: 'Opzionale. Invia in un singolo argomento di un gruppo forum: l\'ultimo numero nel link dell\'argomento (t.me/c/.../25). Lascia vuoto per l\'argomento Generale.',
telegramThreadIdInvalid: 'L\'ID argomento forum deve essere un numero.',
smtpServer: 'Server SMTP',
smtpPort: 'Porta SMTP',
security: 'Sicurezza',
+6
View File
@@ -2437,6 +2437,8 @@ export default {
styleGlow: 'グロー',
styleVibrant: 'ビビッド',
themeToggleHint: 'サイドバーのアイコンでダーク、ライト、システムモードを切り替えます。',
progressInTitle: 'タブに印刷の進捗を表示',
progressInTitleDescription: 'ブラウザのタブに進行中の印刷の進捗率と進捗リングを表示します。',
// Archive
autoArchivePrints: '印刷を自動アーカイブ',
autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
@@ -3670,6 +3672,7 @@ export default {
showModified: '更新日時を表示',
hideModified: '更新日時を非表示',
lastModified: '最終更新',
lastActivity: '最終アクティビティ',
resultsCount: '{{total}}件中{{showing}}件',
selectAll: 'すべて選択',
deselectAll: 'すべて選択解除',
@@ -5618,6 +5621,9 @@ export default {
pushoverExpire: '緊急有効期限 (秒)',
botToken: 'ボットトークン',
chatId: 'チャットID',
telegramThreadId: 'フォーラムトピック ID',
telegramThreadIdHelp: '任意。フォーラムグループ内の特定のトピックに送信します。トピックリンクの末尾の数字です (t.me/c/.../25)。空欄の場合は General トピックに送信されます。',
telegramThreadIdInvalid: 'フォーラムトピック ID は数値で入力してください。',
smtpServer: 'SMTPサーバー',
smtpPort: 'SMTPポート',
security: 'セキュリティ',
+6
View File
@@ -2308,6 +2308,8 @@ export default {
styleGlow: '글로우',
styleVibrant: '비브런트',
themeToggleHint: '사이드바의 태양/달 아이콘으로 다크 모드와 라이트 모드를 전환하세요.',
progressInTitle: '탭에 인쇄 진행률 표시',
progressInTitleDescription: '브라우저 탭에 진행 중인 인쇄의 백분율과 진행 링을 표시합니다.',
autoArchivePrints: '인쇄 자동 아카이브',
autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장',
saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장',
@@ -3482,6 +3484,7 @@ export default {
showModified: '수정 날짜 표시',
hideModified: '수정 날짜 숨기기',
lastModified: '마지막 수정',
lastActivity: '마지막 활동',
resultsCount: '전체 {{total}}개 중 {{showing}}개',
selectAll: '모두 선택',
deselectAll: '모두 선택 해제',
@@ -5330,6 +5333,9 @@ export default {
pushoverExpire: '긴급 만료 (초)',
botToken: '봇 토큰',
chatId: '채팅 ID',
telegramThreadId: '포럼 주제 ID',
telegramThreadIdHelp: '선택 사항. 포럼 그룹의 특정 주제로 전송합니다. 주제 링크의 마지막 숫자입니다 (t.me/c/.../25). 비워 두면 General 주제로 전송됩니다.',
telegramThreadIdInvalid: '포럼 주제 ID는 숫자여야 합니다.',
smtpServer: 'SMTP 서버',
smtpPort: 'SMTP 포트',
security: '보안',
+6
View File
@@ -2392,6 +2392,8 @@ export default {
styleGlow: 'Brilhante',
styleVibrant: 'Vibrante',
themeToggleHint: 'Alternar entre modo escuro, claro e sistema usando o ícone na barra lateral.',
progressInTitle: 'Progresso na aba',
progressInTitleDescription: 'Mostra a porcentagem da impressão ativa e um anel de progresso na aba do navegador.',
autoArchivePrints: 'Arquivar impressões automaticamente',
autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',
@@ -3658,6 +3660,7 @@ export default {
showModified: 'Mostrar datas de modificação',
hideModified: 'Ocultar datas de modificação',
lastModified: 'Última modificação',
lastActivity: 'Última atividade',
resultsCount: '{{showing}} de {{total}} arquivos',
selectAll: 'Selecionar tudo',
deselectAll: 'Desmarcar tudo',
@@ -5606,6 +5609,9 @@ export default {
pushoverExpire: 'Expiração de emergência (s)',
botToken: 'Token do Bot',
chatId: 'ID do Chat',
telegramThreadId: 'ID do tópico do fórum',
telegramThreadIdHelp: 'Opcional. Envia para um único tópico de um grupo de fórum: o último número no link do tópico (t.me/c/.../25). Deixe vazio para o tópico Geral.',
telegramThreadIdInvalid: 'O ID do tópico do fórum deve ser um número.',
smtpServer: 'Servidor SMTP',
smtpPort: 'Porta SMTP',
security: 'Segurança',
+6
View File
@@ -2309,6 +2309,8 @@ export default {
styleGlow: "Свечение",
styleVibrant: "Насыщенный",
themeToggleHint: "Переключайте тёмную, светлую и системную тему значком в боковой панели.",
progressInTitle: "Прогресс во вкладке",
progressInTitleDescription: "Показывает процент текущей печати и кольцо прогресса во вкладке браузера.",
autoArchivePrints: "Автоматически архивировать печать",
autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати",
saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF",
@@ -3474,6 +3476,7 @@ export default {
showModified: "Показать даты изменения",
hideModified: "Скрыть даты изменения",
lastModified: "Изменено",
lastActivity: "Последняя активность",
resultsCount: "Показано {{showing}} из {{total}} файлов",
selectAll: "Выбрать всё",
deselectAll: "Снять выделение",
@@ -5317,6 +5320,9 @@ export default {
pushoverExpire: "Срок действия экстренного уведомления (с)",
botToken: "Токен бота",
chatId: "ID чата",
telegramThreadId: "ID темы форума",
telegramThreadIdHelp: "Необязательно. Отправляет в конкретную тему форум-группы — последнее число в ссылке на тему (t.me/c/.../25). Оставьте пустым для темы General.",
telegramThreadIdInvalid: "ID темы форума должен быть числом.",
smtpServer: "SMTP-сервер",
smtpPort: "Порт SMTP",
security: "Защита соединения",
+6
View File
@@ -2442,6 +2442,8 @@ export default {
styleGlow: 'Parıltı',
styleVibrant: 'Canlı',
themeToggleHint: 'Kenar çubuğundaki güneş/ay simgesini kullanarak koyu ve açık mod arasında geçiş yapın.',
progressInTitle: 'Sekmede baskı ilerlemesi',
progressInTitleDescription: 'Tarayıcı sekmesinde etkin baskının yüzdesini ve bir ilerleme halkası gösterir.',
// Arşiv
autoArchivePrints: 'Baskıları otomatik arşivle',
autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet',
@@ -3666,6 +3668,7 @@ export default {
showModified: 'Değiştirme tarihlerini göster',
hideModified: 'Değiştirme tarihlerini gizle',
lastModified: 'Son değiştirme',
lastActivity: 'Son etkinlik',
resultsCount: '{{total}} dosyadan {{showing}} tanesi',
selectAll: 'Tümünü Seç',
deselectAll: 'Seçimi Kaldır',
@@ -5566,6 +5569,9 @@ export default {
pushoverExpire: 'Acil sona erme (sn)',
botToken: 'Bot Belirteci',
chatId: 'Sohbet ID',
telegramThreadId: 'Forum Konu Kimliği',
telegramThreadIdHelp: 'İsteğe bağlı. Forum grubundaki tek bir konuya gönderir: konu bağlantısındaki son sayı (t.me/c/.../25). Genel konu için boş bırakın.',
telegramThreadIdInvalid: 'Forum konu kimliği bir sayı olmalıdır.',
smtpServer: 'SMTP Sunucusu',
smtpPort: 'SMTP Portu',
security: 'Güvenlik',
+6
View File
@@ -2457,6 +2457,8 @@ export default {
styleGlow: "Світіння",
styleVibrant: "Яскравий",
themeToggleHint: "Перемикайтеся між темним, світлим і системним режимами за допомогою значка на бічній панелі.",
progressInTitle: "Прогрес у вкладці",
progressInTitleDescription: "Показує відсоток активного друку та кільце прогресу на вкладці браузера.",
// Archive
autoArchivePrints: "Автоматично архівувати друки",
autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку",
@@ -3699,6 +3701,7 @@ export default {
showModified: "Показати змінені дати",
hideModified: "Приховати змінені дати",
lastModified: "Востаннє змінено",
lastActivity: "Остання активність",
resultsCount: "{{showing}} з {{total}} файлів",
selectAll: "Вибрати усі",
deselectAll: "Зняти вибір із усіх",
@@ -5661,6 +5664,9 @@ export default {
pushoverExpire: "Термін дії екстреного сповіщення (с)",
botToken: "Токен бота",
chatId: "Ідентифікатор чату",
telegramThreadId: "ID теми форуму",
telegramThreadIdHelp: "Необов'язково. Надсилає в конкретну тему форум-групи — останнє число у посиланні на тему (t.me/c/.../25). Залиште порожнім для теми General.",
telegramThreadIdInvalid: "ID теми форуму має бути числом.",
smtpServer: "Сервер SMTP",
smtpPort: "Порт SMTP",
security: "Безпека",
+6
View File
@@ -2437,6 +2437,8 @@ export default {
styleGlow: '发光',
styleVibrant: '鲜艳',
themeToggleHint: '使用侧边栏中的图标在深色、浅色和系统模式之间切换。',
progressInTitle: '在标签页显示打印进度',
progressInTitleDescription: '在浏览器标签页中显示当前打印的百分比和进度环。',
autoArchivePrints: '自动归档打印',
autoArchiveDescription: '打印完成时自动保存3MF文件',
saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
@@ -3658,6 +3660,7 @@ export default {
showModified: '显示修改日期',
hideModified: '隐藏修改日期',
lastModified: '最后修改',
lastActivity: '最近活动',
resultsCount: '{{showing}} / {{total}} 个文件',
selectAll: '全选',
deselectAll: '取消全选',
@@ -5606,6 +5609,9 @@ export default {
pushoverExpire: '紧急过期 (秒)',
botToken: '机器人令牌',
chatId: '聊天 ID',
telegramThreadId: '论坛话题 ID',
telegramThreadIdHelp: '可选。发送到论坛群组中的指定话题,即话题链接末尾的数字 (t.me/c/.../25)。留空则发送到常规话题。',
telegramThreadIdInvalid: '论坛话题 ID 必须是数字。',
smtpServer: 'SMTP 服务器',
smtpPort: 'SMTP 端口',
security: '安全',
+6
View File
@@ -2437,6 +2437,8 @@ export default {
styleGlow: '發光',
styleVibrant: '鮮豔',
themeToggleHint: '使用側邊欄中的圖示在深色、淺色和系統模式之間切換。',
progressInTitle: '在分頁顯示列印進度',
progressInTitleDescription: '在瀏覽器分頁中顯示目前列印的百分比和進度環。',
autoArchivePrints: '自動歸檔列印',
autoArchiveDescription: '列印完成時自動儲存3MF檔案',
saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
@@ -3658,6 +3660,7 @@ export default {
showModified: '顯示修改日期',
hideModified: '隱藏修改日期',
lastModified: '最後修改',
lastActivity: '最近活動',
resultsCount: '{{showing}} / {{total}} 個檔案',
selectAll: '全選',
deselectAll: '取消全選',
@@ -5606,6 +5609,9 @@ export default {
pushoverExpire: '緊急逾時 (秒)',
botToken: '機器人權杖',
chatId: '聊天 ID',
telegramThreadId: '論壇主題 ID',
telegramThreadIdHelp: '選填。傳送到論壇群組中的指定主題,即主題連結結尾的數字 (t.me/c/.../25)。留空則傳送到一般主題。',
telegramThreadIdInvalid: '論壇主題 ID 必須是數字。',
smtpServer: 'SMTP 伺服器',
smtpPort: 'SMTP 連接埠',
security: '安全',
+18 -2
View File
@@ -558,11 +558,12 @@ interface FolderTreeItemProps {
depth?: number;
wrapNames?: boolean;
defaultExpanded?: boolean;
showModified?: boolean;
hasPermission: (permission: Permission) => boolean;
t: TFunction;
}
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, hasPermission, t }: FolderTreeItemProps) {
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [showActions, setShowActions] = useState(false);
const hasChildren = folder.children.length > 0;
@@ -609,7 +610,20 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
) : (
<FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
<span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
<div className="flex-1 min-w-0">
<span className={`block text-sm ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
{/* #2680 follow-up: the same toolbar toggle that shows dates on file
cards also shows them here. This is `latest_activity_at` — the
newest timestamp among the folder itself, its files and its
subfolders (the value "sort by recent activity" orders on) — not
the folder's own on-disk mtime, hence the distinct label. */}
{showModified && folder.latest_activity_at && (
<span className="mt-0.5 flex items-center gap-1 text-xs text-bambu-gray" title={t('fileManager.lastActivity')}>
<CalendarClock className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{formatDate(folder.latest_activity_at)}</span>
</span>
)}
</div>
{/* Link indicator - clickable to change link */}
{isLinked && (
<button
@@ -709,6 +723,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
depth={depth + 1}
wrapNames={wrapNames}
defaultExpanded={defaultExpanded}
showModified={showModified}
hasPermission={hasPermission}
t={t}
/>
@@ -1957,6 +1972,7 @@ export function FileManagerPage() {
onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
wrapNames={wrapFolderNames}
defaultExpanded={!collapseFoldersByDefault}
showModified={showModified}
hasPermission={hasPermission}
t={t}
/>
+19
View File
@@ -172,6 +172,7 @@ export function SettingsPage() {
setMode,
setDarkStyle, setDarkBackground, setDarkAccent,
setLightStyle, setLightBackground, setLightAccent,
progressInTitle, setProgressInTitle,
} = useTheme();
const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
// Transient typed strings for the per-filament humidity threshold inputs
@@ -1811,6 +1812,24 @@ export function SettingsPage() {
<p className="text-xs text-bambu-gray">
{t('settings.themeToggleHint')}
</p>
<div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary">
<div>
<p className="text-white">{t('settings.progressInTitle')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.progressInTitleDescription')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={progressInTitle}
onChange={(e) => { setProgressInTitle(e.target.checked); showToast(t('settings.toast.settingsSaved'), 'success'); }}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</CardContent>
</Card>