diff --git a/plugins/file-manager/src/frontend/components/DiffViewer.tsx b/plugins/file-manager/src/frontend/components/DiffViewer.tsx index c8c16d213..1a05a462b 100644 --- a/plugins/file-manager/src/frontend/components/DiffViewer.tsx +++ b/plugins/file-manager/src/frontend/components/DiffViewer.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect } from "react"; import { DiffEditor } from "@monaco-editor/react"; import { Button } from "@termix/plugin-sdk/ui"; import { toast } from "sonner"; +import { DownloadProgressToast } from "./DownloadProgressToast"; import { useTranslation } from "react-i18next"; import { Download, @@ -14,7 +15,7 @@ import { } from "lucide-react"; import { readSSHFile, - downloadSSHFile, + downloadSSHFileStream, getSSHStatus, connectSSH, } from "../api/ssh-file-operations-api"; @@ -132,34 +133,30 @@ export function DiffViewer({ }; const handleDownloadFile = async (file: FileItem) => { + const toastId = toast.loading( + , + { duration: Infinity }, + ); try { await ensureSSHConnection(); - const response = await downloadSSHFile(sshSessionId, file.path); - - if (response?.content) { - const byteCharacters = atob(response.content); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { - type: response.mimeType || "application/octet-stream", - }); - - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = response.fileName || file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - toast.success( - t("fileManager.downloadFileSuccess", { name: file.name }), - ); - } + await downloadSSHFileStream( + sshSessionId, + file.path, + ({ loaded, total }) => { + toast.loading( + , + { id: toastId, duration: Infinity }, + ); + }, + ); + toast.success(t("fileManager.downloadFileSuccess", { name: file.name }), { + id: toastId, + duration: undefined, + }); } catch (error: unknown) { console.error("Failed to download file:", error); const err = error as { message?: string }; @@ -167,6 +164,7 @@ export function DiffViewer({ t("fileManager.downloadFileFailed") + ": " + (err.message || t("fileManager.unknownError")), + { id: toastId, duration: undefined }, ); } }; diff --git a/plugins/file-manager/src/frontend/components/FileWindow.tsx b/plugins/file-manager/src/frontend/components/FileWindow.tsx index a0cfab6a8..4946a276c 100644 --- a/plugins/file-manager/src/frontend/components/FileWindow.tsx +++ b/plugins/file-manager/src/frontend/components/FileWindow.tsx @@ -4,13 +4,14 @@ import { DraggableWindow } from "./DraggableWindow.tsx"; import { FileViewer } from "./FileViewer.tsx"; import { useWindowManager } from "./WindowManager.tsx"; import { - downloadSSHFile, + downloadSSHFileStream, readSSHFile, writeSSHFile, getSSHStatus, connectSSH, } from "../api/ssh-file-operations-api"; import { toast } from "sonner"; +import { DownloadProgressToast } from "./DownloadProgressToast"; import type { SSHHost } from "../host-types"; import { useTranslation } from "react-i18next"; @@ -452,37 +453,31 @@ export function FileWindow({ }, []); const handleDownload = async () => { + const toastId = toast.loading( + , + { duration: Infinity }, + ); try { await ensureSSHConnection(); - const response = await downloadSSHFile(sshSessionId, file.path); - - if (response?.content) { - const byteCharacters = atob(response.content); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { - type: response.mimeType || "application/octet-stream", - }); - - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = response.fileName || file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - toast.success( - t("fileManager.fileDownloadedSuccessfully", { - name: response.fileName || file.name, - }), - ); - } + await downloadSSHFileStream( + sshSessionId, + file.path, + ({ loaded, total }) => { + toast.loading( + , + { id: toastId, duration: Infinity }, + ); + }, + ); + toast.success( + t("fileManager.fileDownloadedSuccessfully", { name: file.name }), + { id: toastId, duration: undefined }, + ); } catch (error: unknown) { console.error("Failed to download file:", error); @@ -493,10 +488,12 @@ export function FileWindow({ ) { toast.error( `SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`, + { id: toastId, duration: undefined }, ); } else { toast.error( `Failed to download file: ${err.message || "Unknown error"}`, + { id: toastId, duration: undefined }, ); } } diff --git a/plugins/file-manager/tests/frontend/components/download-progress.test.tsx b/plugins/file-manager/tests/frontend/components/download-progress.test.tsx new file mode 100644 index 000000000..67bcdde94 --- /dev/null +++ b/plugins/file-manager/tests/frontend/components/download-progress.test.tsx @@ -0,0 +1,180 @@ +import "@testing-library/jest-dom/vitest"; +import React from "react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "sonner"; +import { FileWindow } from "../../../src/frontend/components/FileWindow"; +import { DiffViewer } from "../../../src/frontend/components/DiffViewer"; +import type { FileItem, SSHHost } from "../../../src/frontend/host-types"; + +const api = vi.hoisted(() => ({ + downloadSSHFileStream: vi.fn(), + downloadSSHFile: vi.fn(async () => ({ content: "eA==" })), + readSSHFile: vi.fn(async () => ({ content: "text", encoding: "utf8" })), + getSSHStatus: vi.fn(async () => ({ connected: true })), + connectSSH: vi.fn(), + writeSSHFile: vi.fn(), +})); +vi.mock("../../../src/frontend/api/ssh-file-operations-api", () => api); +vi.mock("sonner", () => ({ + toast: { loading: vi.fn(), success: vi.fn(), error: vi.fn() }, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, params?: { name?: string }) => + params?.name ? `${key}: ${params.name}` : key, + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("../../../src/frontend/components/WindowManager", () => ({ + useWindowManager: () => ({ + windows: [{ id: "preview" }], + closeWindow: vi.fn(), + maximizeWindow: vi.fn(), + focusWindow: vi.fn(), + }), +})); +vi.mock("../../../src/frontend/components/DraggableWindow", () => ({ + DraggableWindow: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); +vi.mock("../../../src/frontend/components/FileViewer", () => ({ + FileViewer: ({ onDownload }: { onDownload: () => void }) => ( + + ), +})); +vi.mock("@monaco-editor/react", () => ({ DiffEditor: () => null })); + +const file1: FileItem = { name: "one.txt", path: "/one.txt", type: "file" }; +const file2: FileItem = { name: "two.txt", path: "/two.txt", type: "file" }; +const host = { id: 1, name: "host", ip: "127.0.0.1", port: 22 } as SSHHost; +let transfers: Array<{ + progress: (event: { loaded: number; total?: number }) => void; + resolve: () => void; + reject: (error: Error) => void; +}>; + +beforeEach(() => { + vi.clearAllMocks(); + api.connectSSH.mockReset(); + let nextId = 0; + vi.mocked(toast.loading).mockImplementation( + (_node, options) => options?.id ?? ++nextId, + ); + transfers = []; + api.downloadSSHFileStream.mockImplementation( + (_session, _path, progress) => + new Promise((resolve, reject) => { + transfers.push({ progress, resolve, reject }); + }), + ); +}); +afterEach(cleanup); + +async function open(kind: string) { + if (kind === "preview") { + render( + , + ); + return screen.findByRole("button", { name: "download-preview" }); + } + render( + , + ); + return screen.findByRole("button", { name: file1.name }); +} + +describe.each(["preview", "comparison"])("%s download feedback", (kind) => { + it("clears the loading state if the connection check fails", async () => { + const button = await open(kind); + api.getSSHStatus.mockRejectedValueOnce(new Error("connection failed")); + api.connectSSH.mockRejectedValueOnce(new Error("connection failed")); + fireEvent.click(button); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + const id = vi.mocked(toast.loading).mock.results[0].value; + expect(toast.error).toHaveBeenLastCalledWith(expect.any(String), { + id, + duration: undefined, + }); + expect(api.downloadSSHFileStream).not.toHaveBeenCalled(); + }); + + it("updates one progress toast, then replaces it on completion", async () => { + fireEvent.click(await open(kind)); + await waitFor(() => expect(transfers).toHaveLength(1)); + const id = vi.mocked(toast.loading).mock.results[0].value; + expect(toast.loading).toHaveBeenCalledWith(expect.anything(), { + duration: Infinity, + }); + expect(toast.success).not.toHaveBeenCalled(); + act(() => transfers[0].progress({ loaded: 50, total: 100 })); + const last = vi.mocked(toast.loading).mock.calls.at(-1)!; + expect(last[1]).toEqual({ id, duration: Infinity }); + render(<>{last[0] as React.ReactNode}); + expect(screen.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + "50", + ); + await act(async () => transfers[0].resolve()); + expect(toast.success).toHaveBeenCalledWith(expect.any(String), { + id, + duration: undefined, + }); + expect(api.downloadSSHFile).not.toHaveBeenCalled(); + }); + + it("replaces the loading toast with an error when the download fails", async () => { + fireEvent.click(await open(kind)); + await waitFor(() => expect(transfers).toHaveLength(1)); + const id = vi.mocked(toast.loading).mock.results[0].value; + await act(async () => transfers[0].reject(new Error("read failed"))); + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining("read failed"), + { id, duration: undefined }, + ); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); + +it("keeps simultaneous comparison downloads in separate toasts", async () => { + fireEvent.click(await open("comparison")); + fireEvent.click(screen.getByRole("button", { name: file2.name })); + await waitFor(() => expect(transfers).toHaveLength(2)); + const ids = vi + .mocked(toast.loading) + .mock.results.map((result) => result.value); + expect(ids[0]).not.toBe(ids[1]); + act(() => transfers[0].progress({ loaded: 1 })); + expect(toast.loading).toHaveBeenLastCalledWith(expect.anything(), { + id: ids[0], + duration: Infinity, + }); + act(() => transfers[1].progress({ loaded: 2 })); + expect(toast.loading).toHaveBeenLastCalledWith(expect.anything(), { + id: ids[1], + duration: Infinity, + }); + await act(async () => { + transfers[1].resolve(); + transfers[0].resolve(); + }); + expect(toast.success).toHaveBeenCalledTimes(2); +});