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);
+});