mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-30 02:41:34 +00:00
feat: initial ui redesign from demo
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ConfirmationOptions {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
interface ToastConfirmOptions {
|
||||
confirmOnEnter?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export function useConfirmation() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
|
||||
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
|
||||
const [activeToastId, setActiveToastId] = useState<string | number | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingConfirmCallback, setPendingConfirmCallback] = useState<
|
||||
(() => void) | null
|
||||
>(null);
|
||||
const [pendingResolve, setPendingResolve] = useState<
|
||||
((value: boolean) => void) | null
|
||||
>(null);
|
||||
|
||||
const handleEnterKey = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Enter" && activeToastId !== null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (pendingConfirmCallback) {
|
||||
pendingConfirmCallback();
|
||||
}
|
||||
if (pendingResolve) {
|
||||
pendingResolve(true);
|
||||
}
|
||||
|
||||
toast.dismiss(activeToastId);
|
||||
setActiveToastId(null);
|
||||
setPendingConfirmCallback(null);
|
||||
setPendingResolve(null);
|
||||
}
|
||||
},
|
||||
[activeToastId, pendingConfirmCallback, pendingResolve],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeToastId !== null) {
|
||||
// Use capture phase to intercept Enter before terminal receives it
|
||||
window.addEventListener("keydown", handleEnterKey, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleEnterKey, true);
|
||||
};
|
||||
}
|
||||
}, [activeToastId, handleEnterKey]);
|
||||
|
||||
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
|
||||
setOptions(opts);
|
||||
setOnConfirm(() => callback);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
}
|
||||
setIsOpen(false);
|
||||
setOptions(null);
|
||||
setOnConfirm(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
setOptions(null);
|
||||
setOnConfirm(null);
|
||||
};
|
||||
|
||||
const confirmWithToast = (
|
||||
opts: ConfirmationOptions | string,
|
||||
callback?: () => void,
|
||||
variantOrConfirmLabel: "default" | "destructive" | string = "Confirm",
|
||||
cancelLabel: string = "Cancel",
|
||||
toastOptions: ToastConfirmOptions = { confirmOnEnter: false },
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const isVariant =
|
||||
variantOrConfirmLabel === "default" ||
|
||||
variantOrConfirmLabel === "destructive";
|
||||
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
|
||||
|
||||
const { confirmOnEnter = false, duration = 8000 } = toastOptions;
|
||||
|
||||
const handleToastConfirm = () => {
|
||||
if (callback) callback();
|
||||
resolve(true);
|
||||
setActiveToastId(null);
|
||||
setPendingConfirmCallback(null);
|
||||
setPendingResolve(null);
|
||||
};
|
||||
|
||||
const handleToastCancel = () => {
|
||||
resolve(false);
|
||||
setActiveToastId(null);
|
||||
setPendingConfirmCallback(null);
|
||||
setPendingResolve(null);
|
||||
};
|
||||
|
||||
const message = typeof opts === "string" ? opts : opts.description;
|
||||
const actualConfirmLabel =
|
||||
typeof opts === "object" && opts.confirmText
|
||||
? opts.confirmText
|
||||
: confirmLabel;
|
||||
const actualCancelLabel =
|
||||
typeof opts === "object" && opts.cancelText
|
||||
? opts.cancelText
|
||||
: cancelLabel;
|
||||
|
||||
const toastId = toast(message, {
|
||||
duration,
|
||||
action: {
|
||||
label: confirmOnEnter
|
||||
? `${actualConfirmLabel} ↵`
|
||||
: actualConfirmLabel,
|
||||
onClick: handleToastConfirm,
|
||||
},
|
||||
cancel: {
|
||||
label: actualCancelLabel,
|
||||
onClick: handleToastCancel,
|
||||
},
|
||||
onDismiss: () => {
|
||||
setActiveToastId(null);
|
||||
setPendingConfirmCallback(null);
|
||||
setPendingResolve(null);
|
||||
},
|
||||
onAutoClose: () => {
|
||||
resolve(false);
|
||||
setActiveToastId(null);
|
||||
setPendingConfirmCallback(null);
|
||||
setPendingResolve(null);
|
||||
},
|
||||
} as NonNullable<Parameters<typeof toast>[1]>);
|
||||
|
||||
if (confirmOnEnter) {
|
||||
setActiveToastId(toastId);
|
||||
setPendingConfirmCallback(() => () => {
|
||||
if (callback) callback();
|
||||
});
|
||||
setPendingResolve(() => resolve);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
options,
|
||||
confirm,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
confirmWithToast,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
|
||||
interface ServiceWorkerState {
|
||||
isSupported: boolean;
|
||||
isRegistered: boolean;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
export function useServiceWorker(): ServiceWorkerState {
|
||||
const [state, setState] = useState<ServiceWorkerState>({
|
||||
isSupported: false,
|
||||
isRegistered: false,
|
||||
updateAvailable: false,
|
||||
});
|
||||
|
||||
const handleUpdateFound = useCallback(
|
||||
(registration: ServiceWorkerRegistration) => {
|
||||
const newWorker = registration.installing;
|
||||
if (!newWorker) return;
|
||||
|
||||
newWorker.addEventListener("statechange", () => {
|
||||
if (
|
||||
newWorker.state === "installed" &&
|
||||
navigator.serviceWorker.controller
|
||||
) {
|
||||
setState((prev) => ({ ...prev, updateAvailable: true }));
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const isSupported =
|
||||
"serviceWorker" in navigator && !isElectron() && import.meta.env.PROD;
|
||||
|
||||
setState((prev) => ({ ...prev, isSupported }));
|
||||
|
||||
if (!isSupported) return;
|
||||
|
||||
const shouldReloadOnControllerChange = Boolean(
|
||||
navigator.serviceWorker.controller,
|
||||
);
|
||||
let hasReloadedForUpdate = false;
|
||||
const handleControllerChange = () => {
|
||||
if (!shouldReloadOnControllerChange || hasReloadedForUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasReloadedForUpdate = true;
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const registerSW = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
`${getBasePath()}/sw.js`,
|
||||
{ updateViaCache: "none" },
|
||||
);
|
||||
setState((prev) => ({ ...prev, isRegistered: true }));
|
||||
|
||||
registration.addEventListener("updatefound", () =>
|
||||
handleUpdateFound(registration),
|
||||
);
|
||||
await registration.update();
|
||||
} catch (error) {
|
||||
console.error("[SW] Registration failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker.addEventListener(
|
||||
"controllerchange",
|
||||
handleControllerChange,
|
||||
);
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
registerSW();
|
||||
} else {
|
||||
window.addEventListener("load", registerSW);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("load", registerSW);
|
||||
navigator.serviceWorker.removeEventListener(
|
||||
"controllerchange",
|
||||
handleControllerChange,
|
||||
);
|
||||
};
|
||||
}, [handleUpdateFound]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { getCommandHistory, saveCommandToHistory } from "@/ui/main-axios.ts";
|
||||
|
||||
interface UseCommandHistoryOptions {
|
||||
hostId?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface CommandHistoryResult {
|
||||
suggestions: string[];
|
||||
getSuggestions: (input: string) => string[];
|
||||
saveCommand: (command: string) => Promise<void>;
|
||||
clearSuggestions: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useCommandHistory({
|
||||
hostId,
|
||||
enabled = true,
|
||||
}: UseCommandHistoryOptions): CommandHistoryResult {
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const historyCache = useRef<Map<number, string[]>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !hostId) {
|
||||
setCommandHistory([]);
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = historyCache.current.get(hostId);
|
||||
if (cached) {
|
||||
setCommandHistory(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchHistory = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const history = await getCommandHistory(hostId);
|
||||
setCommandHistory(history);
|
||||
historyCache.current.set(hostId, history);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch command history:", error);
|
||||
setCommandHistory([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchHistory();
|
||||
}, [hostId, enabled]);
|
||||
|
||||
const getSuggestions = useCallback(
|
||||
(input: string): string[] => {
|
||||
if (!input || input.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
const matches = commandHistory.filter((cmd) =>
|
||||
cmd.startsWith(trimmedInput),
|
||||
);
|
||||
|
||||
const filtered = matches
|
||||
.filter((cmd) => cmd !== trimmedInput)
|
||||
.slice(0, 10);
|
||||
|
||||
setSuggestions(filtered);
|
||||
return filtered;
|
||||
},
|
||||
[commandHistory],
|
||||
);
|
||||
|
||||
const saveCommand = useCallback(
|
||||
async (command: string) => {
|
||||
if (!enabled || !hostId || !command || command.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedCommand = command.trim();
|
||||
|
||||
if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveCommandToHistory(hostId, trimmedCommand);
|
||||
|
||||
setCommandHistory((prev) => {
|
||||
const newHistory = [
|
||||
trimmedCommand,
|
||||
...prev.filter((c) => c !== trimmedCommand),
|
||||
];
|
||||
const limited = newHistory.slice(0, 500);
|
||||
historyCache.current.set(hostId, limited);
|
||||
return limited;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save command to history:", error);
|
||||
setCommandHistory((prev) => {
|
||||
const newHistory = [
|
||||
trimmedCommand,
|
||||
...prev.filter((c) => c !== trimmedCommand),
|
||||
];
|
||||
return newHistory.slice(0, 500);
|
||||
});
|
||||
}
|
||||
},
|
||||
[enabled, hostId, commandHistory],
|
||||
);
|
||||
|
||||
const clearSuggestions = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
getSuggestions,
|
||||
saveCommand,
|
||||
clearSuggestions,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
import { saveCommandToHistory } from "@/ui/main-axios.ts";
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
/\bpassw(or)?d\b/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/\bPASS(WORD)?=/i,
|
||||
/\bAWS_SECRET/i,
|
||||
/\bmysql\b.*-p/i,
|
||||
/\bsudo\s+-S\b/,
|
||||
/\bhtpasswd\b/i,
|
||||
/\bsshpass\b/i,
|
||||
/\bcurl\b.*-u\s/i,
|
||||
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
interface UseCommandTrackerOptions {
|
||||
hostId?: number;
|
||||
enabled?: boolean;
|
||||
onCommandExecuted?: (command: string) => void;
|
||||
}
|
||||
|
||||
interface CommandTrackerResult {
|
||||
trackInput: (data: string) => void;
|
||||
getCurrentCommand: () => string;
|
||||
clearCurrentCommand: () => void;
|
||||
updateCurrentCommand: (command: string) => void;
|
||||
}
|
||||
|
||||
export function useCommandTracker({
|
||||
hostId,
|
||||
enabled = true,
|
||||
onCommandExecuted,
|
||||
}: UseCommandTrackerOptions): CommandTrackerResult {
|
||||
const currentCommandRef = useRef<string>("");
|
||||
const isInEscapeSequenceRef = useRef<boolean>(false);
|
||||
|
||||
const trackInput = useCallback(
|
||||
(data: string) => {
|
||||
if (!enabled || !hostId) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data[i];
|
||||
const charCode = char.charCodeAt(0);
|
||||
|
||||
if (charCode === 27) {
|
||||
isInEscapeSequenceRef.current = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isInEscapeSequenceRef.current) {
|
||||
if (
|
||||
(charCode >= 65 && charCode <= 90) ||
|
||||
(charCode >= 97 && charCode <= 122) ||
|
||||
charCode === 126
|
||||
) {
|
||||
isInEscapeSequenceRef.current = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 13 || charCode === 10) {
|
||||
const command = currentCommandRef.current.trim();
|
||||
|
||||
if (command.length > 0) {
|
||||
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
|
||||
|
||||
if (!isSensitive) {
|
||||
saveCommandToHistory(hostId, command).catch((error) => {
|
||||
console.error("Failed to save command to history:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (onCommandExecuted) {
|
||||
onCommandExecuted(command);
|
||||
}
|
||||
}
|
||||
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 8 || charCode === 127) {
|
||||
if (currentCommandRef.current.length > 0) {
|
||||
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 3 || charCode === 4) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 21) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode >= 32 && charCode <= 126) {
|
||||
currentCommandRef.current += char;
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, hostId, onCommandExecuted],
|
||||
);
|
||||
|
||||
const getCurrentCommand = useCallback(() => {
|
||||
return currentCommandRef.current;
|
||||
}, []);
|
||||
|
||||
const clearCurrentCommand = useCallback(() => {
|
||||
currentCommandRef.current = "";
|
||||
}, []);
|
||||
|
||||
const updateCurrentCommand = useCallback((command: string) => {
|
||||
currentCommandRef.current = command;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
trackInput,
|
||||
getCurrentCommand,
|
||||
clearCurrentCommand,
|
||||
updateCurrentCommand,
|
||||
};
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { downloadSSHFile } from "@/ui/main-axios";
|
||||
import type { FileItem, SSHHost } from "../../types/index.js";
|
||||
|
||||
interface DragToDesktopState {
|
||||
isDragging: boolean;
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseDragToDesktopProps {
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
}
|
||||
|
||||
interface DragToDesktopOptions {
|
||||
enableToast?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function useDragToDesktop({ sshSessionId }: UseDragToDesktopProps) {
|
||||
const [state, setState] = useState<DragToDesktopState>({
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const isElectron = () => {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.electronAPI &&
|
||||
window.electronAPI.isElectron
|
||||
);
|
||||
};
|
||||
|
||||
const dragFileToDesktop = useCallback(
|
||||
async (file: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.type !== "file") {
|
||||
const error = "Only files can be dragged to desktop";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (!response?.content) {
|
||||
throw new Error("Unable to get file content");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 50 }));
|
||||
|
||||
const tempResult = await window.electronAPI.createTempFile({
|
||||
fileName: file.name,
|
||||
content: response.content,
|
||||
encoding: "base64",
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(
|
||||
tempResult.error || "Failed to create temporary file",
|
||||
);
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
if (!dragResult.success) {
|
||||
throw new Error(dragResult.error || "Failed to start dragging");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(`Dragging ${file.name} to desktop`);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 10000);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to drag to desktop:", error);
|
||||
const err = error as { message?: string };
|
||||
const errorMessage = err.message || "Drag failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
isDragging: false,
|
||||
progress: 0,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`Drag failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
const dragFilesToDesktop = useCallback(
|
||||
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileList = files.filter((f) => f.type === "file");
|
||||
if (fileList.length === 0) {
|
||||
const error = "No files available for dragging";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fileList.length === 1) {
|
||||
return dragFileToDesktop(fileList[0], options);
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const downloadPromises = fileList.map((file) =>
|
||||
downloadSSHFile(sshSessionId, file.path),
|
||||
);
|
||||
|
||||
const responses = await Promise.all(downloadPromises);
|
||||
setState((prev) => ({ ...prev, progress: 40 }));
|
||||
|
||||
const folderName = `Files_${Date.now()}`;
|
||||
const filesData = fileList.map((file, index) => ({
|
||||
relativePath: file.name,
|
||||
content: responses[index]?.content || "",
|
||||
encoding: "base64",
|
||||
}));
|
||||
|
||||
const tempResult = await window.electronAPI.createTempFolder({
|
||||
folderName,
|
||||
files: filesData,
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(
|
||||
tempResult.error || "Failed to create temporary folder",
|
||||
);
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: folderName,
|
||||
});
|
||||
|
||||
if (!dragResult.success) {
|
||||
throw new Error(dragResult.error || "Failed to start dragging");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(`Dragging ${fileList.length} files to desktop`);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 15000);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to batch drag to desktop:", error);
|
||||
const err = error as { message?: string };
|
||||
const errorMessage = err.message || "Batch drag failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
isDragging: false,
|
||||
progress: 0,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`Batch drag failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId, dragFileToDesktop],
|
||||
);
|
||||
|
||||
const dragFolderToDesktop = useCallback(
|
||||
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (folder.type !== "directory") {
|
||||
const error = "Only folder types can be dragged";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enableToast) {
|
||||
toast.info("Folder drag functionality is under development...");
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isElectron: isElectron(),
|
||||
dragFileToDesktop,
|
||||
dragFilesToDesktop,
|
||||
dragFolderToDesktop,
|
||||
};
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { downloadSSHFile } from "@/ui/main-axios";
|
||||
import type { FileItem, SSHHost } from "../../types/index.js";
|
||||
|
||||
interface DragToSystemState {
|
||||
isDragging: boolean;
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseDragToSystemProps {
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
}
|
||||
|
||||
interface DragToSystemOptions {
|
||||
enableToast?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) {
|
||||
const [state, setState] = useState<DragToSystemState>({
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const dragDataRef = useRef<{
|
||||
files: FileItem[];
|
||||
options: DragToSystemOptions;
|
||||
} | null>(null);
|
||||
|
||||
const saveLastDirectory = async (fileHandle: {
|
||||
getParent?: () => Promise<unknown>;
|
||||
}) => {
|
||||
try {
|
||||
if ("indexedDB" in window && fileHandle.getParent) {
|
||||
const dirHandle = await fileHandle.getParent();
|
||||
const request = indexedDB.open("termix-dirs", 1);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction(["directories"], "readwrite");
|
||||
const store = transaction.objectStore("directories");
|
||||
store.put({ handle: dirHandle }, "lastSaveDir");
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Drag operation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const isFileSystemAPISupported = () => {
|
||||
return "showSaveFilePicker" in window;
|
||||
};
|
||||
|
||||
const isDraggedOutsideWindow = (e: DragEvent) => {
|
||||
const margin = 50;
|
||||
return (
|
||||
e.clientX < margin ||
|
||||
e.clientX > window.innerWidth - margin ||
|
||||
e.clientY < margin ||
|
||||
e.clientY > window.innerHeight - margin
|
||||
);
|
||||
};
|
||||
|
||||
const createFileBlob = async (file: FileItem): Promise<Blob> => {
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
if (!response?.content) {
|
||||
throw new Error(`Unable to get content for file ${file.name}`);
|
||||
}
|
||||
|
||||
const binaryString = atob(response.content);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([bytes]);
|
||||
};
|
||||
|
||||
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const file of files) {
|
||||
const blob = await createFileBlob(file);
|
||||
zip.file(file.name, blob);
|
||||
}
|
||||
|
||||
return await zip.generateAsync({ type: "blob" });
|
||||
};
|
||||
|
||||
const fallbackDownload = (blob: Blob, fileName: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDragToSystem = useCallback(
|
||||
async (files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (files.length === 0) {
|
||||
const error = "No files available for dragging";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileList = files.filter((f) => f.type === "file");
|
||||
if (fileList.length === 0) {
|
||||
const error = "Only files can be dragged to desktop";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const fileName =
|
||||
fileList.length === 1 ? fileList[0].name : `files_${Date.now()}.zip`;
|
||||
|
||||
let fileHandle: {
|
||||
createWritable?: () => Promise<{
|
||||
write: (data: Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
getParent?: () => Promise<unknown>;
|
||||
} | null = null;
|
||||
if (isFileSystemAPISupported()) {
|
||||
try {
|
||||
fileHandle = await (
|
||||
window as Window & {
|
||||
showSaveFilePicker?: (options: {
|
||||
suggestedName: string;
|
||||
startIn: string;
|
||||
types: Array<{
|
||||
description: string;
|
||||
accept: Record<string, string[]>;
|
||||
}>;
|
||||
}) => Promise<{
|
||||
createWritable?: () => Promise<{
|
||||
write: (data: Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
getParent?: () => Promise<unknown>;
|
||||
}>;
|
||||
}
|
||||
).showSaveFilePicker!({
|
||||
suggestedName: fileName,
|
||||
startIn: "desktop",
|
||||
types: [
|
||||
{
|
||||
description: "Files",
|
||||
accept: {
|
||||
"*/*": [
|
||||
".txt",
|
||||
".jpg",
|
||||
".png",
|
||||
".pdf",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { name?: string };
|
||||
if (err.name === "AbortError") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let blob: Blob;
|
||||
if (fileList.length === 1) {
|
||||
blob = await createFileBlob(fileList[0]);
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
} else {
|
||||
blob = await createZipBlob(fileList);
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 90 }));
|
||||
|
||||
if (fileHandle) {
|
||||
await saveLastDirectory(fileHandle);
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} else {
|
||||
fallbackDownload(blob, fileName);
|
||||
if (enableToast) {
|
||||
toast.info(
|
||||
"Due to browser limitations, file will be downloaded to default download directory",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(
|
||||
fileList.length === 1
|
||||
? `${fileName} saved to specified location`
|
||||
: `${fileList.length} files packaged and saved`,
|
||||
);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
setTimeout(() => {
|
||||
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
const errorMessage = err.message || "Save failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`Save failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
const startDragToSystem = useCallback(
|
||||
(files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
dragDataRef.current = { files, options };
|
||||
setState((prev) => ({ ...prev, isDragging: true, error: null }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!dragDataRef.current) return;
|
||||
|
||||
const { files, options } = dragDataRef.current;
|
||||
|
||||
if (isDraggedOutsideWindow(e)) {
|
||||
handleDragToSystem(files, options);
|
||||
}
|
||||
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false }));
|
||||
},
|
||||
[handleDragToSystem],
|
||||
);
|
||||
|
||||
const cancelDragToSystem = useCallback(() => {
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false, error: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isFileSystemAPISupported: isFileSystemAPISupported(),
|
||||
startDragToSystem,
|
||||
handleDragEnd,
|
||||
cancelDragToSystem,
|
||||
handleDragToSystem,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user