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:
@@ -17,6 +17,7 @@ import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
|||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||||
|
import { DatabaseSaveTrigger } from "../db/index.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -1156,6 +1157,9 @@ router.put(
|
|||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
// Trigger database save after folder rename
|
||||||
|
DatabaseSaveTrigger.triggerSave("folder_rename");
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Folder renamed successfully",
|
message: "Folder renamed successfully",
|
||||||
updatedHosts: updatedHosts.length,
|
updatedHosts: updatedHosts.length,
|
||||||
|
|||||||
162
src/backend/utils/database-save-trigger.ts
Normal file
162
src/backend/utils/database-save-trigger.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { DataCrypto } from "./data-crypto.js";
|
||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||||
@@ -42,6 +42,9 @@ class SimpleDBOps {
|
|||||||
// Insert into database
|
// Insert into database
|
||||||
const result = await getDb().insert(table).values(encryptedData).returning();
|
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
|
// Decrypt return result using the same key - FieldCrypto will use stored recordId
|
||||||
const decryptedResult = DataCrypto.decryptRecord(
|
const decryptedResult = DataCrypto.decryptRecord(
|
||||||
tableName,
|
tableName,
|
||||||
@@ -170,6 +173,9 @@ class SimpleDBOps {
|
|||||||
): Promise<any[]> {
|
): Promise<any[]> {
|
||||||
const result = await getDb().delete(table).where(where).returning();
|
const result = await getDb().delete(table).where(where).returning();
|
||||||
|
|
||||||
|
// Trigger database save after delete
|
||||||
|
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -651,7 +651,10 @@
|
|||||||
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
|
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
|
||||||
"reconnected": "Reconnected successfully",
|
"reconnected": "Reconnected successfully",
|
||||||
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
|
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
|
||||||
"connectionTimeout": "Connection timeout"
|
"connectionTimeout": "Connection timeout",
|
||||||
|
"terminalTitle": "Terminal - {{host}}",
|
||||||
|
"terminalWithPath": "Terminal - {{host}}:{{path}}",
|
||||||
|
"runTitle": "Running {{command}} - {{host}}"
|
||||||
},
|
},
|
||||||
"fileManager": {
|
"fileManager": {
|
||||||
"title": "File Manager",
|
"title": "File Manager",
|
||||||
@@ -659,7 +662,7 @@
|
|||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"connectToSsh": "Connect to SSH to use file operations",
|
"connectToSsh": "Connect to SSH to use file operations",
|
||||||
"uploadFile": "Upload File",
|
"uploadFile": "Upload File",
|
||||||
"downloadFile": "Download to Browser",
|
"downloadFile": "Download",
|
||||||
"newFile": "New File",
|
"newFile": "New File",
|
||||||
"newFolder": "New Folder",
|
"newFolder": "New Folder",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
|
|||||||
@@ -639,6 +639,9 @@
|
|||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "终端",
|
"title": "终端",
|
||||||
|
"terminalTitle": "终端 - {{host}}",
|
||||||
|
"terminalWithPath": "终端 - {{host}}:{{path}}",
|
||||||
|
"runTitle": "运行 {{command}} - {{host}}",
|
||||||
"connect": "连接主机",
|
"connect": "连接主机",
|
||||||
"disconnect": "断开连接",
|
"disconnect": "断开连接",
|
||||||
"clear": "清屏",
|
"clear": "清屏",
|
||||||
@@ -674,7 +677,7 @@
|
|||||||
"folder": "文件夹",
|
"folder": "文件夹",
|
||||||
"connectToSsh": "连接 SSH 以使用文件操作",
|
"connectToSsh": "连接 SSH 以使用文件操作",
|
||||||
"uploadFile": "上传文件",
|
"uploadFile": "上传文件",
|
||||||
"downloadFile": "下载到浏览器",
|
"downloadFile": "下载",
|
||||||
"newFile": "新建文件",
|
"newFile": "新建文件",
|
||||||
"newFolder": "新建文件夹",
|
"newFolder": "新建文件夹",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
@@ -741,7 +744,7 @@
|
|||||||
"properties": "属性",
|
"properties": "属性",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"downloadFiles": "下载 {{count}} 个文件到浏览器",
|
"downloadFiles": "下载 {{count}} 个文件",
|
||||||
"copyFiles": "复制 {{count}} 个项目",
|
"copyFiles": "复制 {{count}} 个项目",
|
||||||
"cutFiles": "剪切 {{count}} 个项目",
|
"cutFiles": "剪切 {{count}} 个项目",
|
||||||
"deleteFiles": "删除 {{count}} 个项目",
|
"deleteFiles": "删除 {{count}} 个项目",
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export function FileManagerContextMenu({
|
|||||||
? t("fileManager.openTerminalInFolder")
|
? t("fileManager.openTerminalInFolder")
|
||||||
: t("fileManager.openTerminalInFileLocation"),
|
: t("fileManager.openTerminalInFileLocation"),
|
||||||
action: () => onOpenTerminal(targetPath),
|
action: () => onOpenTerminal(targetPath),
|
||||||
shortcut: "Ctrl+T",
|
shortcut: "Ctrl+Shift+T",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,30 +257,15 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download function
|
// Download function - unified download that uses best available method
|
||||||
if (hasFiles && onDownload) {
|
if (hasFiles && onDragToDesktop) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Download className="w-4 h-4" />,
|
icon: <Download className="w-4 h-4" />,
|
||||||
label: isMultipleFiles
|
label: isMultipleFiles
|
||||||
? t("fileManager.downloadFiles", { count: files.length })
|
? t("fileManager.downloadFiles", { count: files.length })
|
||||||
: t("fileManager.downloadFile"),
|
: 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(),
|
action: () => onDragToDesktop(),
|
||||||
shortcut: isModernBrowser
|
shortcut: "Ctrl+D",
|
||||||
? t("fileManager.selectLocationToSave")
|
|
||||||
: t("fileManager.downloadToDefaultLocation"),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +299,7 @@ export function FileManagerContextMenu({
|
|||||||
|
|
||||||
// Add separator (if above functions exist)
|
// Add separator (if above functions exist)
|
||||||
if (
|
if (
|
||||||
(hasFiles && (onPreview || onDownload || onDragToDesktop)) ||
|
(hasFiles && (onPreview || onDragToDesktop)) ||
|
||||||
(isSingleFile &&
|
(isSingleFile &&
|
||||||
files[0].type === "file" &&
|
files[0].type === "file" &&
|
||||||
(onPinFile || onUnpinFile)) ||
|
(onPinFile || onUnpinFile)) ||
|
||||||
@@ -329,7 +314,7 @@ export function FileManagerContextMenu({
|
|||||||
icon: <Edit3 className="w-4 h-4" />,
|
icon: <Edit3 className="w-4 h-4" />,
|
||||||
label: t("fileManager.rename"),
|
label: t("fileManager.rename"),
|
||||||
action: () => onRename(files[0]),
|
action: () => onRename(files[0]),
|
||||||
shortcut: "F2",
|
shortcut: "F6",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +382,7 @@ export function FileManagerContextMenu({
|
|||||||
icon: <Terminal className="w-4 h-4" />,
|
icon: <Terminal className="w-4 h-4" />,
|
||||||
label: t("fileManager.openTerminalHere"),
|
label: t("fileManager.openTerminalHere"),
|
||||||
action: () => onOpenTerminal(currentPath),
|
action: () => onOpenTerminal(currentPath),
|
||||||
shortcut: "Ctrl+T",
|
shortcut: "Ctrl+Shift+T",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +432,7 @@ export function FileManagerContextMenu({
|
|||||||
icon: <RefreshCw className="w-4 h-4" />,
|
icon: <RefreshCw className="w-4 h-4" />,
|
||||||
label: t("fileManager.refresh"),
|
label: t("fileManager.refresh"),
|
||||||
action: onRefresh,
|
action: onRefresh,
|
||||||
shortcut: "F5",
|
shortcut: "Ctrl+Y",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,12 +472,12 @@ export function FileManagerContextMenu({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Transparent overlay to capture click events */}
|
{/* Transparent overlay to capture click events */}
|
||||||
<div className="fixed inset-0 z-40" />
|
<div className="fixed inset-0 z-[99990]" />
|
||||||
|
|
||||||
{/* Menu body */}
|
{/* Menu body */}
|
||||||
<div
|
<div
|
||||||
data-context-menu
|
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={{
|
style={{
|
||||||
left: menuPosition.x,
|
left: menuPosition.x,
|
||||||
top: menuPosition.y,
|
top: menuPosition.y,
|
||||||
|
|||||||
@@ -744,13 +744,10 @@ export function FileManagerGrid({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (dragState.type === "internal") {
|
if (dragState.type === "internal") {
|
||||||
// Internal drag to empty area: trigger download
|
// Internal drag to empty area: just cancel the drag operation
|
||||||
console.log(
|
console.log("Internal drag to empty area - cancelling drag operation");
|
||||||
"Internal drag to empty area detected, triggering download",
|
// Do not trigger download here - system drag end will handle it if truly outside window
|
||||||
);
|
setDragState({ type: "none", files: [], counter: 0 });
|
||||||
if (onDownload && dragState.files.length > 0) {
|
|
||||||
onDownload(dragState.files);
|
|
||||||
}
|
|
||||||
} else if (dragState.type === "external") {
|
} else if (dragState.type === "external") {
|
||||||
// External drag: handle file upload
|
// External drag: handle file upload
|
||||||
if (onUpload && e.dataTransfer.files.length > 0) {
|
if (onUpload && e.dataTransfer.files.length > 0) {
|
||||||
@@ -914,15 +911,18 @@ export function FileManagerGrid({
|
|||||||
onDelete(selectedFiles);
|
onDelete(selectedFiles);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "F2":
|
case "F6":
|
||||||
if (selectedFiles.length === 1 && onStartEdit) {
|
if (selectedFiles.length === 1 && onStartEdit) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onStartEdit(selectedFiles[0]);
|
onStartEdit(selectedFiles[0]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "F5":
|
case "y":
|
||||||
event.preventDefault();
|
case "Y":
|
||||||
onRefresh();
|
if ((event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
onRefresh();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1148,7 +1148,7 @@ export function FileManagerGrid({
|
|||||||
"hover:bg-accent hover:text-accent-foreground border-2 border-transparent",
|
"hover:bg-accent hover:text-accent-foreground border-2 border-transparent",
|
||||||
isSelected && "bg-primary/20 border-primary",
|
isSelected && "bg-primary/20 border-primary",
|
||||||
dragState.target?.path === file.path &&
|
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) &&
|
dragState.files.some((f) => f.path === file.path) &&
|
||||||
"opacity-50",
|
"opacity-50",
|
||||||
)}
|
)}
|
||||||
@@ -1188,15 +1188,8 @@ export function FileManagerGrid({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p
|
<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"
|
className="text-xs text-foreground truncate px-1 py-0.5 rounded w-fit max-w-full text-center"
|
||||||
title={`${file.name} (click to rename)`}
|
title={file.name}
|
||||||
onClick={(e) => {
|
|
||||||
// Prevent file selection event
|
|
||||||
if (onStartEdit) {
|
|
||||||
e.stopPropagation();
|
|
||||||
onStartEdit(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{file.name}
|
{file.name}
|
||||||
</p>
|
</p>
|
||||||
@@ -1248,7 +1241,7 @@ export function FileManagerGrid({
|
|||||||
"hover:bg-accent hover:text-accent-foreground",
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
isSelected && "bg-primary/20",
|
isSelected && "bg-primary/20",
|
||||||
dragState.target?.path === file.path &&
|
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) &&
|
dragState.files.some((f) => f.path === file.path) &&
|
||||||
"opacity-50",
|
"opacity-50",
|
||||||
)}
|
)}
|
||||||
@@ -1288,15 +1281,8 @@ export function FileManagerGrid({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p
|
<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"
|
className="text-sm text-foreground truncate px-1 py-0.5 rounded w-fit max-w-full"
|
||||||
title={`${file.name} (click to rename)`}
|
title={file.name}
|
||||||
onClick={(e) => {
|
|
||||||
// Prevent file selection event
|
|
||||||
if (onStartEdit) {
|
|
||||||
e.stopPropagation();
|
|
||||||
onStartEdit(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{file.name}
|
{file.name}
|
||||||
</p>
|
</p>
|
||||||
@@ -1373,10 +1359,10 @@ export function FileManagerGrid({
|
|||||||
dragState.files.length > 0 &&
|
dragState.files.length > 0 &&
|
||||||
dragState.mousePosition && (
|
dragState.mousePosition && (
|
||||||
<div
|
<div
|
||||||
className="fixed z-50 pointer-events-none"
|
className="fixed z-[99999] pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: dragState.mousePosition.x + 16,
|
left: dragState.mousePosition.x + 24,
|
||||||
top: dragState.mousePosition.y - 8,
|
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">
|
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
|
||||||
|
|||||||
@@ -206,45 +206,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
// SSH keepalive timer
|
// SSH keepalive timer
|
||||||
const keepaliveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
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
|
// Handle file drag to external
|
||||||
const handleFileDragStart = useCallback(
|
const handleFileDragStart = useCallback(
|
||||||
(files: FileItem[]) => {
|
(files: FileItem[]) => {
|
||||||
@@ -273,10 +234,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
e.clientY > window.innerHeight - margin;
|
e.clientY > window.innerHeight - margin;
|
||||||
|
|
||||||
if (isOutside) {
|
if (isOutside) {
|
||||||
// Delay execution to avoid conflicts with other events
|
// Execute immediately to preserve user gesture context
|
||||||
setTimeout(() => {
|
systemDrag.handleDragEnd(e);
|
||||||
systemDrag.handleDragEnd(e);
|
|
||||||
}, 100);
|
|
||||||
} else {
|
} else {
|
||||||
// Cancel drag
|
// Cancel drag
|
||||||
systemDrag.cancelDragToSystem();
|
systemDrag.cancelDragToSystem();
|
||||||
@@ -386,6 +345,45 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
}
|
}
|
||||||
}, [sshSessionId, isLoading, clearSelection, t]);
|
}, [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
|
// Debounced refresh function - prevent excessive clicking
|
||||||
const handleRefreshDirectory = useCallback(() => {
|
const handleRefreshDirectory = useCallback(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -400,6 +398,31 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
loadDirectory(currentPath);
|
loadDirectory(currentPath);
|
||||||
}, [currentPath, lastRefreshTime, loadDirectory]);
|
}, [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) {
|
function handleFilesDropped(fileList: FileList) {
|
||||||
if (!sshSessionId) {
|
if (!sshSessionId) {
|
||||||
toast.error(t("fileManager.noSSHConnection"));
|
toast.error(t("fileManager.noSSHConnection"));
|
||||||
@@ -1382,7 +1405,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("fileManager.terminalWithPath", { host: currentHost.name, path }),
|
t("terminal.terminalWithPath", { host: currentHost.name, path }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function DiffWindow({
|
|||||||
initialY = 100,
|
initialY = 100,
|
||||||
}: DiffWindowProps) {
|
}: DiffWindowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
|
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||||
useWindowManager();
|
useWindowManager();
|
||||||
|
|
||||||
const currentWindow = windows.find((w) => w.id === windowId);
|
const currentWindow = windows.find((w) => w.id === windowId);
|
||||||
@@ -35,10 +35,6 @@ export function DiffWindow({
|
|||||||
closeWindow(windowId);
|
closeWindow(windowId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMinimize = () => {
|
|
||||||
minimizeWindow(windowId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMaximize = () => {
|
const handleMaximize = () => {
|
||||||
maximizeWindow(windowId);
|
maximizeWindow(windowId);
|
||||||
};
|
};
|
||||||
@@ -61,7 +57,6 @@ export function DiffWindow({
|
|||||||
minWidth={800}
|
minWidth={800}
|
||||||
minHeight={500}
|
minHeight={500}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onMinimize={handleMinimize}
|
|
||||||
onMaximize={handleMaximize}
|
onMaximize={handleMaximize}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
isMaximized={currentWindow.isMaximized}
|
isMaximized={currentWindow.isMaximized}
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export function FileWindow({
|
|||||||
}: FileWindowProps) {
|
}: FileWindowProps) {
|
||||||
const {
|
const {
|
||||||
closeWindow,
|
closeWindow,
|
||||||
minimizeWindow,
|
|
||||||
maximizeWindow,
|
maximizeWindow,
|
||||||
focusWindow,
|
focusWindow,
|
||||||
updateWindow,
|
updateWindow,
|
||||||
@@ -329,10 +328,6 @@ export function FileWindow({
|
|||||||
closeWindow(windowId);
|
closeWindow(windowId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMinimize = () => {
|
|
||||||
minimizeWindow(windowId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMaximize = () => {
|
const handleMaximize = () => {
|
||||||
maximizeWindow(windowId);
|
maximizeWindow(windowId);
|
||||||
};
|
};
|
||||||
@@ -355,7 +350,6 @@ export function FileWindow({
|
|||||||
minWidth={400}
|
minWidth={400}
|
||||||
minHeight={300}
|
minHeight={300}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onMinimize={handleMinimize}
|
|
||||||
onMaximize={handleMaximize}
|
onMaximize={handleMaximize}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
isMaximized={currentWindow.isMaximized}
|
isMaximized={currentWindow.isMaximized}
|
||||||
|
|||||||
@@ -130,40 +130,6 @@ export function useDragToSystemDesktop({
|
|||||||
return await zip.generateAsync({ type: "blob" });
|
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
|
// Fallback solution: traditional download
|
||||||
const fallbackDownload = (blob: Blob, fileName: string) => {
|
const fallbackDownload = (blob: Blob, fileName: string) => {
|
||||||
@@ -206,35 +172,62 @@ export function useDragToSystemDesktop({
|
|||||||
error: null,
|
error: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let blob: Blob;
|
// Determine file name first (synchronously)
|
||||||
let fileName: string;
|
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) {
|
if (fileList.length === 1) {
|
||||||
// Single file
|
// Single file
|
||||||
blob = await createFileBlob(fileList[0]);
|
blob = await createFileBlob(fileList[0]);
|
||||||
fileName = fileList[0].name;
|
|
||||||
setState((prev) => ({ ...prev, progress: 70 }));
|
setState((prev) => ({ ...prev, progress: 70 }));
|
||||||
} else {
|
} else {
|
||||||
// Package multiple files into ZIP
|
// Package multiple files into ZIP
|
||||||
blob = await createZipBlob(fileList);
|
blob = await createZipBlob(fileList);
|
||||||
fileName = `files_${Date.now()}.zip`;
|
|
||||||
setState((prev) => ({ ...prev, progress: 70 }));
|
setState((prev) => ({ ...prev, progress: 70 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, progress: 90 }));
|
setState((prev) => ({ ...prev, progress: 90 }));
|
||||||
|
|
||||||
// Prefer File System Access API
|
// Save the file
|
||||||
if (isFileSystemAPISupported()) {
|
if (fileHandle) {
|
||||||
const saved = await saveFileWithSystemAPI(blob, fileName);
|
// Use File System Access API with pre-obtained handle
|
||||||
if (!saved) {
|
await saveLastDirectory(fileHandle);
|
||||||
// User cancelled
|
const writable = await fileHandle.createWritable();
|
||||||
setState((prev) => ({
|
await writable.write(blob);
|
||||||
...prev,
|
await writable.close();
|
||||||
isDownloading: false,
|
|
||||||
progress: 0,
|
|
||||||
}));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to traditional download
|
// Fallback to traditional download
|
||||||
fallbackDownload(blob, fileName);
|
fallbackDownload(blob, fileName);
|
||||||
@@ -301,10 +294,8 @@ export function useDragToSystemDesktop({
|
|||||||
|
|
||||||
// Check if dragged outside window
|
// Check if dragged outside window
|
||||||
if (isDraggedOutsideWindow(e)) {
|
if (isDraggedOutsideWindow(e)) {
|
||||||
// Delayed execution to avoid conflicts with other drag events
|
// Execute immediately to preserve user gesture context for showSaveFilePicker
|
||||||
setTimeout(() => {
|
handleDragToSystem(files, options);
|
||||||
handleDragToSystem(files, options);
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up drag state
|
// Clean up drag state
|
||||||
|
|||||||
Reference in New Issue
Block a user