FIX: Comprehensive file manager UI/UX improvements and bug fixes

- Fix missing i18n for terminal.terminalWithPath translation key
- Update keyboard shortcuts: remove Ctrl+T conflicts, change refresh to Ctrl+Y, rename shortcut to F6
- Remove click-to-rename functionality to prevent accidental renaming
- Fix drag preview z-index and positioning issues during file operations
- Remove false download trigger when dragging files to original position
- Fix 'Must be handling a user gesture' error in drag-to-desktop functionality
- Remove useless minimize button from file editor and diff viewer windows
- Improve context menu z-index hierarchy for better layering
- Add comprehensive drag state management and visual feedback

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-24 05:38:30 +08:00
parent 5f5397b924
commit ece6ec0892
11 changed files with 326 additions and 174 deletions

View File

@@ -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,

View File

@@ -0,0 +1,162 @@
import { databaseLogger } from "./logger.js";
/**
* Database Save Trigger - 自动触发内存数据库保存到磁盘
* 确保数据修改后能持久化保存
*/
export class DatabaseSaveTrigger {
private static saveFunction: (() => Promise<void>) | null = null;
private static isInitialized = false;
private static pendingSave = false;
private static saveTimeout: NodeJS.Timeout | null = null;
/**
* 初始化保存触发器
*/
static initialize(saveFunction: () => Promise<void>): 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<void> {
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<void> {
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",
});
}
}

View File

@@ -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<any[]> {
const result = await getDb().delete(table).where(where).returning();
// Trigger database save after delete
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
return result;
}

View File

@@ -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",

View File

@@ -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}} 个项目",

View File

@@ -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: <Download className="w-4 h-4" />,
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: <ExternalLink className="w-4 h-4" />,
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: <Edit3 className="w-4 h-4" />,
label: t("fileManager.rename"),
action: () => onRename(files[0]),
shortcut: "F2",
shortcut: "F6",
});
}
@@ -397,7 +382,7 @@ export function FileManagerContextMenu({
icon: <Terminal className="w-4 h-4" />,
label: t("fileManager.openTerminalHere"),
action: () => onOpenTerminal(currentPath),
shortcut: "Ctrl+T",
shortcut: "Ctrl+Shift+T",
});
}
@@ -447,7 +432,7 @@ export function FileManagerContextMenu({
icon: <RefreshCw className="w-4 h-4" />,
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 */}
<div className="fixed inset-0 z-40" />
<div className="fixed inset-0 z-[99990]" />
{/* Menu body */}
<div
data-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-50 overflow-hidden"
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"
style={{
left: menuPosition.x,
top: menuPosition.y,

View File

@@ -744,13 +744,10 @@ export function FileManagerGrid({
e.stopPropagation();
if (dragState.type === "internal") {
// Internal drag to empty area: trigger download
console.log(
"Internal drag to empty area detected, triggering download",
);
if (onDownload && dragState.files.length > 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({
/>
) : (
<p
className="text-xs text-foreground truncate cursor-pointer hover:bg-accent px-1 py-0.5 rounded transition-colors duration-150 w-fit max-w-full text-center"
title={`${file.name} (click to rename)`}
onClick={(e) => {
// 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}
</p>
@@ -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({
/>
) : (
<p
className="text-sm text-foreground truncate cursor-pointer hover:bg-accent px-1 py-0.5 rounded transition-colors duration-150 w-fit max-w-full"
title={`${file.name} (click to rename)`}
onClick={(e) => {
// 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}
</p>
@@ -1373,10 +1359,10 @@ export function FileManagerGrid({
dragState.files.length > 0 &&
dragState.mousePosition && (
<div
className="fixed z-50 pointer-events-none"
className="fixed z-[99999] pointer-events-none"
style={{
left: dragState.mousePosition.x + 16,
top: dragState.mousePosition.y - 8,
left: dragState.mousePosition.x + 24,
top: dragState.mousePosition.y - 40,
}}
>
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">

View File

@@ -206,45 +206,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
// SSH keepalive timer
const keepaliveTimerRef = useRef<NodeJS.Timeout | null>(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 }),
);
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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