diff --git a/electron/main.cjs b/electron/main.cjs index 94a33678..e9d3c434 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1654,7 +1654,7 @@ ipcMain.handle("clear-remote-sync-config", async () => { return result; }); -ipcMain.handle("save-remote-sync-jwt", (_event, token) => { +ipcMain.handle("save-remote-sync-jwt", async (_event, token) => { const result = remoteSync.saveRemoteSyncJwt(token); if (result.success) { remoteSync.getRemoteSyncEngine()?.updateStatus({ @@ -1662,7 +1662,8 @@ ipcMain.handle("save-remote-sync-jwt", (_event, token) => { needsReauth: false, lastError: null, }); - remoteSync.getRemoteSyncEngine()?.syncNow(); + const status = await remoteSync.getRemoteSyncEngine()?.syncNow(); + return { ...result, status: status || null }; } return result; }); diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 38e8fe1d..e3de72d4 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -333,6 +333,8 @@ export function AppShell({ const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState< string | undefined >(undefined); + const [remoteSyncReconnectRequested, setRemoteSyncReconnectRequested] = + useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("termix_sidebarWidth"); return saved ? parseInt(saved, 10) : 291; @@ -2714,6 +2716,10 @@ export function AppShell({ setUserPrefs((current) => ({ ...current, ...updates })) } remoteSyncInitialServerUrl={remoteSyncInitialServerUrl} + remoteSyncReconnectRequested={remoteSyncReconnectRequested} + onRemoteSyncReconnectHandled={() => + setRemoteSyncReconnectRequested(false) + } /> )} @@ -2854,6 +2860,7 @@ export function AppShell({ onReconnect={() => { setRailView("user-profile"); if (!sidebarOpen) setSidebarOpen(true); + setRemoteSyncReconnectRequested(true); }} /> void; } -export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) { +export function RemoteSyncPanel({ + initialServerUrl, + reconnectRequested = false, + onReconnectRequestHandled, +}: RemoteSyncPanelProps) { const { t } = useTranslation(); const [config, setConfig] = useState(null); const [status, setStatus] = useState(null); @@ -79,8 +86,21 @@ export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) { setStep("picker"); }, [initialServerUrl, config]); + useEffect(() => { + if (!reconnectRequested || !config?.serverUrl) return; + setPendingServerUrl(config.serverUrl); + setStep("login"); + onReconnectRequestHandled?.(); + }, [config?.serverUrl, onReconnectRequestHandled, reconnectRequested]); + const handleConnectClick = () => setStep("picker"); + const handleReconnectClick = () => { + if (!config?.serverUrl) return; + setPendingServerUrl(config.serverUrl); + setStep("login"); + }; + const handleServerConfigured = (serverUrl: string) => { setPendingServerUrl(serverUrl); setStep("login"); @@ -99,7 +119,19 @@ export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) { const handleSyncNow = async () => { setSyncingNow(true); try { - await window.electronAPI?.invoke?.("remote-sync-now"); + const result = (await window.electronAPI?.invoke?.("remote-sync-now")) as + RemoteSyncStatus | null | undefined; + if (!result || result.needsReauth || result.lastError) { + toast.error(result?.lastError || t("remoteSync.syncUnavailable")); + } else { + toast.success(t("remoteSync.syncSuccess")); + } + } catch (error) { + toast.error( + error instanceof Error && error.message + ? error.message + : t("remoteSync.syncUnavailable"), + ); } finally { setSyncingNow(false); await refresh(); @@ -171,7 +203,7 @@ export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) { type="button" size="sm" className="text-[10px] h-7" - onClick={handleConnectClick} + onClick={handleReconnectClick} > {t("remoteSync.bannerReconnect")} diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 18560c9e..9a55f8c4 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -495,10 +495,14 @@ export function UserProfilePanel({ userPrefs, onPrefsChange, remoteSyncInitialServerUrl, + remoteSyncReconnectRequested, + onRemoteSyncReconnectHandled, }: { username?: string; onLogout?: () => void; remoteSyncInitialServerUrl?: string; + remoteSyncReconnectRequested?: boolean; + onRemoteSyncReconnectHandled?: () => void; userPrefs?: { reopenTabsOnLogin: boolean; storageMode?: string | null; @@ -1667,7 +1671,11 @@ export function UserProfilePanel({ {isElectron() && (
- +
)} diff --git a/src/ui/tests/settings/RemoteSyncPanel.test.tsx b/src/ui/tests/settings/RemoteSyncPanel.test.tsx new file mode 100644 index 00000000..1059c694 --- /dev/null +++ b/src/ui/tests/settings/RemoteSyncPanel.test.tsx @@ -0,0 +1,87 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { toast, invoke } = vi.hoisted(() => ({ + toast: { error: vi.fn(), success: vi.fn() }, + invoke: vi.fn(), +})); + +vi.mock("sonner", () => ({ toast })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/auth/ElectronLoginForm.tsx", () => ({ + ElectronLoginForm: ({ serverUrl }: { serverUrl: string }) => ( +
{serverUrl}
+ ), +})); + +import { RemoteSyncPanel } from "../../settings/RemoteSyncPanel"; + +const config = { + serverUrl: "http://192.168.3.175:6060", + connectedAt: "2026-08-27T00:00:00.000Z", +}; +const status = { + connected: true, + syncing: false, + lastSyncedAt: null, + lastError: "Remote session expired", + needsReauth: true, +}; + +beforeEach(() => { + invoke.mockImplementation((channel: string) => { + if (channel === "get-remote-sync-config") return Promise.resolve(config); + if (channel === "get-remote-sync-status") return Promise.resolve(status); + if (channel === "get-desktop-settings") { + return Promise.resolve({ defaultConnectionOrigin: "local" }); + } + if (channel === "remote-sync-now") return Promise.resolve(status); + return Promise.resolve(null); + }); + Object.defineProperty(window, "electronAPI", { + configurable: true, + value: { invoke, onRemoteSyncStatusChanged: vi.fn() }, + }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("RemoteSyncPanel", () => { + it("opens the configured server login when the banner requests reconnect", async () => { + const onHandled = vi.fn(); + render( + , + ); + + expect((await screen.findByTestId("remote-login")).textContent).toBe( + config.serverUrl, + ); + expect(onHandled).toHaveBeenCalledOnce(); + }); + + it("shows the sync failure returned by the main process", async () => { + render(); + const syncButton = await screen.findByRole("button", { + name: /remoteSync.syncNowButton/, + }); + fireEvent.click(syncButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Remote session expired"); + }); + }); +});