diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx
index 9e6dab7..7520d24 100644
--- a/frontend/src/components/Toast.tsx
+++ b/frontend/src/components/Toast.tsx
@@ -15,16 +15,22 @@ import { X, CheckCircle2, AlertCircle, Info } from "lucide-react";
type Variant = "success" | "error" | "info";
+interface ToastAction {
+ label: string;
+ onClick: () => void;
+}
+
interface Toast {
id: number;
message: string;
variant: Variant;
+ action?: ToastAction;
}
interface ToastAPI {
- success: (msg: string) => void;
- error: (msg: string) => void;
- info: (msg: string) => void;
+ success: (msg: string, action?: ToastAction) => void;
+ error: (msg: string, action?: ToastAction) => void;
+ info: (msg: string, action?: ToastAction) => void;
}
const ToastContext = createContext
(null);
@@ -40,16 +46,16 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
if (t) { clearTimeout(t); timers.current.delete(id); }
}, []);
- const push = useCallback((message: string, variant: Variant) => {
+ const push = useCallback((message: string, variant: Variant, action?: ToastAction) => {
const id = ++_nextId;
- setToasts((prev) => [...prev.slice(-4), { id, message, variant }]);
+ setToasts((prev) => [...prev.slice(-4), { id, message, variant, action }]);
timers.current.set(id, setTimeout(() => dismiss(id), variant === "error" ? 6000 : 3500));
}, [dismiss]);
const api: ToastAPI = {
- success: (msg) => push(msg, "success"),
- error: (msg) => push(msg, "error"),
- info: (msg) => push(msg, "info"),
+ success: (msg, action) => push(msg, "success", action),
+ error: (msg, action) => push(msg, "error", action),
+ info: (msg, action) => push(msg, "info", action),
};
const icons: Record = {
@@ -78,6 +84,14 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
>
{icons[t.variant]}
{t.message}
+ {t.action && (
+
+ {t.action.label}
+
+ )}
dismiss(t.id)}
className="text-slate-500 hover:text-slate-300 transition-colors ml-1 flex-shrink-0"
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 44ed9f0..c6146dd 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,6 +1,10 @@
import ReactDOM from 'react-dom/client'
+import { registerSW } from 'virtual:pwa-register'
import App from './App.tsx'
import './index.css'
+import { initPwa } from './pwa'
+
+initPwa(registerSW)
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/frontend/src/pwa.ts b/frontend/src/pwa.ts
new file mode 100644
index 0000000..be5c5df
--- /dev/null
+++ b/frontend/src/pwa.ts
@@ -0,0 +1,60 @@
+export type RegisterSW = (options?: {
+ immediate?: boolean;
+ onNeedRefresh?: () => void;
+ onOfflineReady?: () => void;
+ onRegisterError?: (error: unknown) => void;
+}) => unknown;
+
+export const PWA_UPDATE_READY_EVENT = 'cloudshell:pwa-update-ready';
+
+export interface PwaUpdateReadyDetail {
+ applyUpdate: () => void;
+}
+
+export interface PwaInitOptions {
+ isProd?: boolean;
+ hasServiceWorker?: boolean;
+}
+
+/**
+ * Registers the service worker only in production and only when the browser
+ * supports service workers.
+ */
+export function initPwa(
+ registerSW: RegisterSW,
+ options: PwaInitOptions = {}
+): boolean {
+ const isProd = options.isProd ?? import.meta.env.PROD;
+ const hasServiceWorker = options.hasServiceWorker ?? (
+ typeof navigator !== 'undefined' && 'serviceWorker' in navigator
+ );
+
+ if (!isProd || !hasServiceWorker) {
+ return false;
+ }
+
+ const updateSW = registerSW({
+ immediate: true,
+ onNeedRefresh: () => {
+ window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, {
+ detail: {
+ applyUpdate: () => {
+ if (typeof updateSW === 'function') {
+ (updateSW as (reloadPage?: boolean) => Promise | void)(true);
+ } else {
+ window.location.reload();
+ }
+ },
+ },
+ }));
+ },
+ onOfflineReady: () => {
+ console.info('CloudShell is ready to work offline.');
+ },
+ onRegisterError: (error) => {
+ console.error('CloudShell service worker registration failed.', error);
+ },
+ });
+
+ return true;
+}
diff --git a/frontend/src/test/Toast.test.tsx b/frontend/src/test/Toast.test.tsx
index 156c209..383309c 100644
--- a/frontend/src/test/Toast.test.tsx
+++ b/frontend/src/test/Toast.test.tsx
@@ -24,6 +24,7 @@ function ToastTrigger() {
toast.success('Saved!')}>success
toast.error('Boom!')}>error
toast.info('Connecting...')}>info
+ toast.info('Update ready', { label: 'Reload', onClick: () => {} })}>info-action
>
);
}
@@ -65,6 +66,12 @@ describe('ToastProvider', () => {
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
+ it('renders action button when provided', async () => {
+ setup();
+ await userEvent.click(screen.getByText('info-action'));
+ expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument();
+ });
+
it('applies the error border class for error toasts', async () => {
const { container } = render(
@@ -97,14 +104,15 @@ describe('ToastProvider', () => {
});
it('removes the toast when the dismiss button is clicked', async () => {
- setup();
+ const { container } = render(
+
+
+ ,
+ );
await userEvent.click(screen.getByText('success'));
expect(screen.getByText('Saved!')).toBeInTheDocument();
- // The dismiss button is the X icon button inside the toast
- const dismissBtns = screen.getAllByRole('button').filter(
- (b) => !['success', 'error', 'info'].includes(b.textContent ?? ''),
- );
- await userEvent.click(dismissBtns[0]);
+ const dismissBtn = container.querySelector('button.text-slate-500') as HTMLButtonElement;
+ await userEvent.click(dismissBtn);
expect(screen.queryByText('Saved!')).not.toBeInTheDocument();
});
@@ -162,14 +170,16 @@ describe('ToastProvider', () => {
it('handles timeout callback after manual dismiss without crashing', async () => {
vi.useFakeTimers();
- setup();
+ const { container } = render(
+
+
+ ,
+ );
act(() => { fireEvent.click(screen.getByText('success')); });
expect(screen.getByText('Saved!')).toBeInTheDocument();
- const dismissBtns = screen.getAllByRole('button').filter(
- (b) => !['success', 'error', 'info'].includes(b.textContent ?? ''),
- );
- act(() => { fireEvent.click(dismissBtns[0]); });
+ const dismissBtn = container.querySelector('button.text-slate-500') as HTMLButtonElement;
+ act(() => { fireEvent.click(dismissBtn); });
expect(screen.queryByText('Saved!')).not.toBeInTheDocument();
act(() => { vi.advanceTimersByTime(4000); });
@@ -187,16 +197,18 @@ describe('ToastProvider', () => {
}) as unknown as typeof setTimeout);
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {});
- setup();
+ const { container } = render(
+
+
+ ,
+ );
act(() => {
fireEvent.click(screen.getByText('success'));
});
- const dismissBtns = screen.getAllByRole('button').filter(
- (b) => !['success', 'error', 'info'].includes(b.textContent ?? ''),
- );
+ const dismissBtn = container.querySelector('button.text-slate-500') as HTMLButtonElement;
act(() => {
- fireEvent.click(dismissBtns[0]);
+ fireEvent.click(dismissBtn);
});
expect(screen.queryByText('Saved!')).not.toBeInTheDocument();
diff --git a/frontend/src/test/pwa.test.ts b/frontend/src/test/pwa.test.ts
new file mode 100644
index 0000000..2b92abf
--- /dev/null
+++ b/frontend/src/test/pwa.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ initPwa,
+ PWA_UPDATE_READY_EVENT,
+ type PwaUpdateReadyDetail,
+ type RegisterSW,
+} from '../pwa';
+
+describe('initPwa', () => {
+ it('registers service worker in production when supported', () => {
+ const registerSW = vi.fn();
+
+ const enabled = initPwa(registerSW, {
+ isProd: true,
+ hasServiceWorker: true,
+ });
+
+ expect(enabled).toBe(true);
+ expect(registerSW).toHaveBeenCalledTimes(1);
+ expect(registerSW).toHaveBeenCalledWith(
+ expect.objectContaining({
+ immediate: true,
+ })
+ );
+ });
+
+ it('does not register service worker in development', () => {
+ const registerSW = vi.fn();
+
+ const enabled = initPwa(registerSW, {
+ isProd: false,
+ hasServiceWorker: true,
+ });
+
+ expect(enabled).toBe(false);
+ expect(registerSW).not.toHaveBeenCalled();
+ });
+
+ it('does not register when service workers are not supported', () => {
+ const registerSW = vi.fn();
+
+ const enabled = initPwa(registerSW, {
+ isProd: true,
+ hasServiceWorker: false,
+ });
+
+ expect(enabled).toBe(false);
+ expect(registerSW).not.toHaveBeenCalled();
+ });
+
+ it('dispatches update-ready event and applies update callback', () => {
+ let capturedOnNeedRefresh: (() => void) | undefined;
+ const updateSW = vi.fn();
+ const registerSW: RegisterSW = (options) => {
+ capturedOnNeedRefresh = options?.onNeedRefresh;
+ return updateSW;
+ };
+
+ initPwa(registerSW, {
+ isProd: true,
+ hasServiceWorker: true,
+ });
+
+ let eventDetail: PwaUpdateReadyDetail | undefined;
+ window.addEventListener(PWA_UPDATE_READY_EVENT, ((event: Event) => {
+ eventDetail = (event as CustomEvent).detail;
+ }) as EventListener, { once: true });
+
+ expect(capturedOnNeedRefresh).toBeTypeOf('function');
+ capturedOnNeedRefresh?.();
+
+ expect(eventDetail).toBeTruthy();
+ eventDetail?.applyUpdate();
+ expect(updateSW).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index 11f02fe..64251fb 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -1 +1,2 @@
///
+///
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index e355f25..c35df81 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,9 +1,57 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
+import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [
+ react(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ includeAssets: [
+ 'favicon.svg',
+ 'apple-touch-icon.png',
+ 'pwa-192x192.png',
+ 'pwa-512x512.png',
+ 'pwa-512x512-maskable.png',
+ 'vite.svg',
+ ],
+ manifest: {
+ name: 'CloudShell',
+ short_name: 'CloudShell',
+ description: 'Self-hosted web SSH, SFTP and FTP(S) gateway.',
+ theme_color: '#0f172a',
+ background_color: '#0f172a',
+ display: 'standalone',
+ start_url: '/',
+ icons: [
+ {
+ src: '/pwa-192x192.png',
+ sizes: '192x192',
+ type: 'image/png',
+ purpose: 'any',
+ },
+ {
+ src: '/pwa-512x512.png',
+ sizes: '512x512',
+ type: 'image/png',
+ purpose: 'any',
+ },
+ {
+ src: '/pwa-512x512-maskable.png',
+ sizes: '512x512',
+ type: 'image/png',
+ purpose: 'maskable',
+ },
+ ],
+ },
+ workbox: {
+ cleanupOutdatedCaches: true,
+ clientsClaim: true,
+ skipWaiting: true,
+ },
+ }),
+ ],
test: {
environment: 'jsdom',
globals: true,
diff --git a/website/index.html b/website/index.html
index 86634de..affed76 100644
--- a/website/index.html
+++ b/website/index.html
@@ -218,6 +218,14 @@ FTP/FTPS File Manager
Browse and manipulate files on remote servers using FTP or secure FTPS — no external client required.