diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 5d6a94be..b6fc2e11 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -17,6 +17,7 @@ import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { SystemCrypto } from "../../utils/system-crypto.js"; +import { DatabaseSaveTrigger } from "../db/index.js"; const router = express.Router(); @@ -1156,6 +1157,9 @@ router.put( ) .returning(); + // Trigger database save after folder rename + DatabaseSaveTrigger.triggerSave("folder_rename"); + res.json({ message: "Folder renamed successfully", updatedHosts: updatedHosts.length, diff --git a/src/backend/utils/database-save-trigger.ts b/src/backend/utils/database-save-trigger.ts new file mode 100644 index 00000000..8a729860 --- /dev/null +++ b/src/backend/utils/database-save-trigger.ts @@ -0,0 +1,162 @@ +import { databaseLogger } from "./logger.js"; + +/** + * Database Save Trigger - 自动触发内存数据库保存到磁盘 + * 确保数据修改后能持久化保存 + */ +export class DatabaseSaveTrigger { + private static saveFunction: (() => Promise) | null = null; + private static isInitialized = false; + private static pendingSave = false; + private static saveTimeout: NodeJS.Timeout | null = null; + + /** + * 初始化保存触发器 + */ + static initialize(saveFunction: () => Promise): void { + this.saveFunction = saveFunction; + this.isInitialized = true; + + databaseLogger.info("Database save trigger initialized", { + operation: "db_save_trigger_init", + }); + } + + /** + * 触发数据库保存 - 防抖处理,避免频繁保存 + */ + static async triggerSave(reason: string = "data_modification"): Promise { + if (!this.isInitialized || !this.saveFunction) { + databaseLogger.warn("Database save trigger not initialized", { + operation: "db_save_trigger_not_init", + reason, + }); + return; + } + + // 清除之前的定时器 + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + + // 防抖:延迟2秒执行,如果2秒内有新的保存请求,则重新计时 + this.saveTimeout = setTimeout(async () => { + if (this.pendingSave) { + databaseLogger.debug("Database save already in progress, skipping", { + operation: "db_save_trigger_skip", + reason, + }); + return; + } + + this.pendingSave = true; + + try { + databaseLogger.debug("Triggering database save", { + operation: "db_save_trigger_start", + reason, + }); + + await this.saveFunction!(); + + databaseLogger.debug("Database save completed", { + operation: "db_save_trigger_success", + reason, + }); + } catch (error) { + databaseLogger.error("Database save failed", error, { + operation: "db_save_trigger_failed", + reason, + error: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + this.pendingSave = false; + } + }, 2000); // 2秒防抖 + } + + /** + * 立即保存 - 用于关键操作 + */ + static async forceSave(reason: string = "critical_operation"): Promise { + if (!this.isInitialized || !this.saveFunction) { + databaseLogger.warn("Database save trigger not initialized for force save", { + operation: "db_save_trigger_force_not_init", + reason, + }); + return; + } + + // 清除防抖定时器 + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + this.saveTimeout = null; + } + + if (this.pendingSave) { + databaseLogger.debug("Database save already in progress, waiting", { + operation: "db_save_trigger_force_wait", + reason, + }); + return; + } + + this.pendingSave = true; + + try { + databaseLogger.info("Force saving database", { + operation: "db_save_trigger_force_start", + reason, + }); + + await this.saveFunction(); + + databaseLogger.success("Database force save completed", { + operation: "db_save_trigger_force_success", + reason, + }); + } catch (error) { + databaseLogger.error("Database force save failed", error, { + operation: "db_save_trigger_force_failed", + reason, + error: error instanceof Error ? error.message : "Unknown error", + }); + throw error; // 重新抛出错误,因为这是强制保存 + } finally { + this.pendingSave = false; + } + } + + /** + * 获取保存状态 + */ + static getStatus(): { + initialized: boolean; + pendingSave: boolean; + hasPendingTimeout: boolean; + } { + return { + initialized: this.isInitialized, + pendingSave: this.pendingSave, + hasPendingTimeout: this.saveTimeout !== null, + }; + } + + /** + * 清理资源 + */ + static cleanup(): void { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + this.saveTimeout = null; + } + + this.pendingSave = false; + this.isInitialized = false; + this.saveFunction = null; + + databaseLogger.info("Database save trigger cleaned up", { + operation: "db_save_trigger_cleanup", + }); + } +} \ No newline at end of file diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index 1f543170..fcc4b185 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -1,4 +1,4 @@ -import { getDb } from "../database/db/index.js"; +import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; import { DataCrypto } from "./data-crypto.js"; import { databaseLogger } from "./logger.js"; import type { SQLiteTable } from "drizzle-orm/sqlite-core"; @@ -42,6 +42,9 @@ class SimpleDBOps { // Insert into database const result = await getDb().insert(table).values(encryptedData).returning(); + // Trigger database save after insert + DatabaseSaveTrigger.triggerSave(`insert_${tableName}`); + // Decrypt return result using the same key - FieldCrypto will use stored recordId const decryptedResult = DataCrypto.decryptRecord( tableName, @@ -170,6 +173,9 @@ class SimpleDBOps { ): Promise { const result = await getDb().delete(table).where(where).returning(); + // Trigger database save after delete + DatabaseSaveTrigger.triggerSave(`delete_${tableName}`); + return result; } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index cfe0c23b..0996dbbf 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -651,7 +651,10 @@ "reconnecting": "Reconnecting... ({{attempt}}/{{max}})", "reconnected": "Reconnected successfully", "maxReconnectAttemptsReached": "Maximum reconnection attempts reached", - "connectionTimeout": "Connection timeout" + "connectionTimeout": "Connection timeout", + "terminalTitle": "Terminal - {{host}}", + "terminalWithPath": "Terminal - {{host}}:{{path}}", + "runTitle": "Running {{command}} - {{host}}" }, "fileManager": { "title": "File Manager", @@ -659,7 +662,7 @@ "folder": "Folder", "connectToSsh": "Connect to SSH to use file operations", "uploadFile": "Upload File", - "downloadFile": "Download to Browser", + "downloadFile": "Download", "newFile": "New File", "newFolder": "New Folder", "rename": "Rename", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index d225ca44..12b44aa0 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -639,6 +639,9 @@ }, "terminal": { "title": "终端", + "terminalTitle": "终端 - {{host}}", + "terminalWithPath": "终端 - {{host}}:{{path}}", + "runTitle": "运行 {{command}} - {{host}}", "connect": "连接主机", "disconnect": "断开连接", "clear": "清屏", @@ -674,7 +677,7 @@ "folder": "文件夹", "connectToSsh": "连接 SSH 以使用文件操作", "uploadFile": "上传文件", - "downloadFile": "下载到浏览器", + "downloadFile": "下载", "newFile": "新建文件", "newFolder": "新建文件夹", "rename": "重命名", @@ -741,7 +744,7 @@ "properties": "属性", "preview": "预览", "refresh": "刷新", - "downloadFiles": "下载 {{count}} 个文件到浏览器", + "downloadFiles": "下载 {{count}} 个文件", "copyFiles": "复制 {{count}} 个项目", "cutFiles": "剪切 {{count}} 个项目", "deleteFiles": "删除 {{count}} 个项目", diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx index 844a2ec2..89194a97 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx @@ -225,7 +225,7 @@ export function FileManagerContextMenu({ ? t("fileManager.openTerminalInFolder") : t("fileManager.openTerminalInFileLocation"), action: () => onOpenTerminal(targetPath), - shortcut: "Ctrl+T", + shortcut: "Ctrl+Shift+T", }); } @@ -257,30 +257,15 @@ export function FileManagerContextMenu({ }); } - // Download function - if (hasFiles && onDownload) { + // Download function - unified download that uses best available method + if (hasFiles && onDragToDesktop) { menuItems.push({ icon: , label: isMultipleFiles ? t("fileManager.downloadFiles", { count: files.length }) : t("fileManager.downloadFile"), - action: () => onDownload(files), - shortcut: "Ctrl+D", - }); - } - - // Drag to desktop menu item (supports browser and desktop apps) - if (hasFiles && onDragToDesktop) { - const isModernBrowser = "showSaveFilePicker" in window; - menuItems.push({ - icon: , - label: isMultipleFiles - ? t("fileManager.saveFilesToSystem", { count: files.length }) - : t("fileManager.saveToSystem"), action: () => onDragToDesktop(), - shortcut: isModernBrowser - ? t("fileManager.selectLocationToSave") - : t("fileManager.downloadToDefaultLocation"), + shortcut: "Ctrl+D", }); } @@ -314,7 +299,7 @@ export function FileManagerContextMenu({ // Add separator (if above functions exist) if ( - (hasFiles && (onPreview || onDownload || onDragToDesktop)) || + (hasFiles && (onPreview || onDragToDesktop)) || (isSingleFile && files[0].type === "file" && (onPinFile || onUnpinFile)) || @@ -329,7 +314,7 @@ export function FileManagerContextMenu({ icon: , label: t("fileManager.rename"), action: () => onRename(files[0]), - shortcut: "F2", + shortcut: "F6", }); } @@ -397,7 +382,7 @@ export function FileManagerContextMenu({ icon: , label: t("fileManager.openTerminalHere"), action: () => onOpenTerminal(currentPath), - shortcut: "Ctrl+T", + shortcut: "Ctrl+Shift+T", }); } @@ -447,7 +432,7 @@ export function FileManagerContextMenu({ icon: , label: t("fileManager.refresh"), action: onRefresh, - shortcut: "F5", + shortcut: "Ctrl+Y", }); } @@ -487,12 +472,12 @@ export function FileManagerContextMenu({ return ( <> {/* Transparent overlay to capture click events */} -
+
{/* Menu body */}
0) { - onDownload(dragState.files); - } + // Internal drag to empty area: just cancel the drag operation + console.log("Internal drag to empty area - cancelling drag operation"); + // Do not trigger download here - system drag end will handle it if truly outside window + setDragState({ type: "none", files: [], counter: 0 }); } else if (dragState.type === "external") { // External drag: handle file upload if (onUpload && e.dataTransfer.files.length > 0) { @@ -914,15 +911,18 @@ export function FileManagerGrid({ onDelete(selectedFiles); } break; - case "F2": + case "F6": if (selectedFiles.length === 1 && onStartEdit) { event.preventDefault(); onStartEdit(selectedFiles[0]); } break; - case "F5": - event.preventDefault(); - onRefresh(); + case "y": + case "Y": + if ((event.ctrlKey || event.metaKey)) { + event.preventDefault(); + onRefresh(); + } break; } }; @@ -1148,7 +1148,7 @@ export function FileManagerGrid({ "hover:bg-accent hover:text-accent-foreground border-2 border-transparent", isSelected && "bg-primary/20 border-primary", dragState.target?.path === file.path && - "bg-muted border-primary border-dashed", + "bg-muted border-primary border-dashed relative z-10", dragState.files.some((f) => f.path === file.path) && "opacity-50", )} @@ -1188,15 +1188,8 @@ export function FileManagerGrid({ /> ) : (

{ - // Prevent file selection event - if (onStartEdit) { - e.stopPropagation(); - onStartEdit(file); - } - }} + className="text-xs text-foreground truncate px-1 py-0.5 rounded w-fit max-w-full text-center" + title={file.name} > {file.name}

@@ -1248,7 +1241,7 @@ export function FileManagerGrid({ "hover:bg-accent hover:text-accent-foreground", isSelected && "bg-primary/20", dragState.target?.path === file.path && - "bg-muted border-primary border-dashed", + "bg-muted border-primary border-dashed relative z-10", dragState.files.some((f) => f.path === file.path) && "opacity-50", )} @@ -1288,15 +1281,8 @@ export function FileManagerGrid({ /> ) : (

{ - // Prevent file selection event - if (onStartEdit) { - e.stopPropagation(); - onStartEdit(file); - } - }} + className="text-sm text-foreground truncate px-1 py-0.5 rounded w-fit max-w-full" + title={file.name} > {file.name}

@@ -1373,10 +1359,10 @@ export function FileManagerGrid({ dragState.files.length > 0 && dragState.mousePosition && (
diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx index a3007715..7fdf2c90 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx @@ -206,45 +206,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { // SSH keepalive timer const keepaliveTimerRef = useRef(null); - // Debounced directory loading for path changes - const debouncedLoadDirectory = useCallback((path: string) => { - // Clear any existing timer - if (pathChangeTimerRef.current) { - clearTimeout(pathChangeTimerRef.current); - } - - // Set new timer for debounced loading - pathChangeTimerRef.current = setTimeout(() => { - if (path !== lastPathChangeRef.current && sshSessionId) { - console.log("Loading directory after path change:", path); - lastPathChangeRef.current = path; - loadDirectory(path); - } - }, 150); // 150ms debounce for path changes - }, [sshSessionId, loadDirectory]); - - // File list update - only reload when path changes, not on initial connection - useEffect(() => { - if (sshSessionId && currentPath) { - // Skip the first load since it's handled in initializeSSHConnection - if (!initialLoadDoneRef.current) { - initialLoadDoneRef.current = true; - lastPathChangeRef.current = currentPath; - return; - } - - // Use debounced loading for path changes to prevent rapid clicking issues - debouncedLoadDirectory(currentPath); - } - - // Cleanup timer on unmount or dependency change - return () => { - if (pathChangeTimerRef.current) { - clearTimeout(pathChangeTimerRef.current); - } - }; - }, [sshSessionId, currentPath, debouncedLoadDirectory]); - // Handle file drag to external const handleFileDragStart = useCallback( (files: FileItem[]) => { @@ -273,10 +234,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { e.clientY > window.innerHeight - margin; if (isOutside) { - // Delay execution to avoid conflicts with other events - setTimeout(() => { - systemDrag.handleDragEnd(e); - }, 100); + // Execute immediately to preserve user gesture context + systemDrag.handleDragEnd(e); } else { // Cancel drag systemDrag.cancelDragToSystem(); @@ -386,6 +345,45 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { } }, [sshSessionId, isLoading, clearSelection, t]); + // Debounced directory loading for path changes + const debouncedLoadDirectory = useCallback((path: string) => { + // Clear any existing timer + if (pathChangeTimerRef.current) { + clearTimeout(pathChangeTimerRef.current); + } + + // Set new timer for debounced loading + pathChangeTimerRef.current = setTimeout(() => { + if (path !== lastPathChangeRef.current && sshSessionId) { + console.log("Loading directory after path change:", path); + lastPathChangeRef.current = path; + loadDirectory(path); + } + }, 150); // 150ms debounce for path changes + }, [sshSessionId, loadDirectory]); + + // File list update - only reload when path changes, not on initial connection + useEffect(() => { + if (sshSessionId && currentPath) { + // Skip the first load since it's handled in initializeSSHConnection + if (!initialLoadDoneRef.current) { + initialLoadDoneRef.current = true; + lastPathChangeRef.current = currentPath; + return; + } + + // Use debounced loading for path changes to prevent rapid clicking issues + debouncedLoadDirectory(currentPath); + } + + // Cleanup timer on unmount or dependency change + return () => { + if (pathChangeTimerRef.current) { + clearTimeout(pathChangeTimerRef.current); + } + }; + }, [sshSessionId, currentPath, debouncedLoadDirectory]); + // Debounced refresh function - prevent excessive clicking const handleRefreshDirectory = useCallback(() => { const now = Date.now(); @@ -400,6 +398,31 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { loadDirectory(currentPath); }, [currentPath, lastRefreshTime, loadDirectory]); + // Global keyboard shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Check if input box or editable element has focus, skip if so + const activeElement = document.activeElement; + if ( + activeElement && + (activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + activeElement.contentEditable === "true") + ) { + return; + } + + // Handle Ctrl+Shift+T for opening terminal + if (event.key === "T" && event.ctrlKey && event.shiftKey) { + event.preventDefault(); + handleOpenTerminal(currentPath); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [currentPath]); + function handleFilesDropped(fileList: FileList) { if (!sshSessionId) { toast.error(t("fileManager.noSSHConnection")); @@ -1382,7 +1405,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { }); toast.success( - t("fileManager.terminalWithPath", { host: currentHost.name, path }), + t("terminal.terminalWithPath", { host: currentHost.name, path }), ); } diff --git a/src/ui/Desktop/Apps/File Manager/components/DiffWindow.tsx b/src/ui/Desktop/Apps/File Manager/components/DiffWindow.tsx index 76b8ad38..f276090d 100644 --- a/src/ui/Desktop/Apps/File Manager/components/DiffWindow.tsx +++ b/src/ui/Desktop/Apps/File Manager/components/DiffWindow.tsx @@ -25,7 +25,7 @@ export function DiffWindow({ initialY = 100, }: DiffWindowProps) { const { t } = useTranslation(); - const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = + const { closeWindow, maximizeWindow, focusWindow, windows } = useWindowManager(); const currentWindow = windows.find((w) => w.id === windowId); @@ -35,10 +35,6 @@ export function DiffWindow({ closeWindow(windowId); }; - const handleMinimize = () => { - minimizeWindow(windowId); - }; - const handleMaximize = () => { maximizeWindow(windowId); }; @@ -61,7 +57,6 @@ export function DiffWindow({ minWidth={800} minHeight={500} onClose={handleClose} - onMinimize={handleMinimize} onMaximize={handleMaximize} onFocus={handleFocus} isMaximized={currentWindow.isMaximized} diff --git a/src/ui/Desktop/Apps/File Manager/components/FileWindow.tsx b/src/ui/Desktop/Apps/File Manager/components/FileWindow.tsx index b490833a..f2b2e7b9 100644 --- a/src/ui/Desktop/Apps/File Manager/components/FileWindow.tsx +++ b/src/ui/Desktop/Apps/File Manager/components/FileWindow.tsx @@ -56,7 +56,6 @@ export function FileWindow({ }: FileWindowProps) { const { closeWindow, - minimizeWindow, maximizeWindow, focusWindow, updateWindow, @@ -329,10 +328,6 @@ export function FileWindow({ closeWindow(windowId); }; - const handleMinimize = () => { - minimizeWindow(windowId); - }; - const handleMaximize = () => { maximizeWindow(windowId); }; @@ -355,7 +350,6 @@ export function FileWindow({ minWidth={400} minHeight={300} onClose={handleClose} - onMinimize={handleMinimize} onMaximize={handleMaximize} onFocus={handleFocus} isMaximized={currentWindow.isMaximized} diff --git a/src/ui/hooks/useDragToSystemDesktop.ts b/src/ui/hooks/useDragToSystemDesktop.ts index 06143d6f..e006f94c 100644 --- a/src/ui/hooks/useDragToSystemDesktop.ts +++ b/src/ui/hooks/useDragToSystemDesktop.ts @@ -130,40 +130,6 @@ export function useDragToSystemDesktop({ return await zip.generateAsync({ type: "blob" }); }; - // Save file using File System Access API - const saveFileWithSystemAPI = async (blob: Blob, suggestedName: string) => { - try { - // Get last saved directory handle - const lastDirHandle = await getLastSaveDirectory(); - - const fileHandle = await (window as any).showSaveFilePicker({ - suggestedName, - startIn: lastDirHandle || "desktop", // Prefer last directory, otherwise desktop - types: [ - { - description: "Files", - accept: { - "*/*": [".txt", ".jpg", ".png", ".pdf", ".zip", ".tar", ".gz"], - }, - }, - ], - }); - - // Save current directory handle for next use - await saveLastDirectory(fileHandle); - - const writable = await fileHandle.createWritable(); - await writable.write(blob); - await writable.close(); - - return true; - } catch (error: any) { - if (error.name === "AbortError") { - return false; // User cancelled - } - throw error; - } - }; // Fallback solution: traditional download const fallbackDownload = (blob: Blob, fileName: string) => { @@ -206,35 +172,62 @@ export function useDragToSystemDesktop({ error: null, })); - let blob: Blob; - let fileName: string; + // Determine file name first (synchronously) + const fileName = fileList.length === 1 + ? fileList[0].name + : `files_${Date.now()}.zip`; + // For File System Access API, get the file handle FIRST to preserve user gesture + let fileHandle: any = null; + if (isFileSystemAPISupported()) { + try { + fileHandle = await (window as any).showSaveFilePicker({ + suggestedName: fileName, + startIn: "desktop", + types: [ + { + description: "Files", + accept: { + "*/*": [".txt", ".jpg", ".png", ".pdf", ".zip", ".tar", ".gz"], + }, + }, + ], + }); + } catch (error: any) { + if (error.name === "AbortError") { + // User cancelled + setState((prev) => ({ + ...prev, + isDownloading: false, + progress: 0, + })); + return false; + } + throw error; + } + } + + // Now create the blob (after getting file handle) + let blob: Blob; if (fileList.length === 1) { // Single file blob = await createFileBlob(fileList[0]); - fileName = fileList[0].name; setState((prev) => ({ ...prev, progress: 70 })); } else { // Package multiple files into ZIP blob = await createZipBlob(fileList); - fileName = `files_${Date.now()}.zip`; setState((prev) => ({ ...prev, progress: 70 })); } setState((prev) => ({ ...prev, progress: 90 })); - // Prefer File System Access API - if (isFileSystemAPISupported()) { - const saved = await saveFileWithSystemAPI(blob, fileName); - if (!saved) { - // User cancelled - setState((prev) => ({ - ...prev, - isDownloading: false, - progress: 0, - })); - return false; - } + // Save the file + if (fileHandle) { + // Use File System Access API with pre-obtained handle + await saveLastDirectory(fileHandle); + const writable = await fileHandle.createWritable(); + await writable.write(blob); + await writable.close(); } else { // Fallback to traditional download fallbackDownload(blob, fileName); @@ -301,10 +294,8 @@ export function useDragToSystemDesktop({ // Check if dragged outside window if (isDraggedOutsideWindow(e)) { - // Delayed execution to avoid conflicts with other drag events - setTimeout(() => { - handleDragToSystem(files, options); - }, 100); + // Execute immediately to preserve user gesture context for showSaveFilePicker + handleDragToSystem(files, options); } // Clean up drag state