Files
nexus-terminal/packages/frontend/src/components/FileManager.vue
T
yinjianm b1f036fdc6 fix(frontend): 调整工作台文件总览与快捷指令菜单
将文件管理区从单目录文件表格修正为多根目录常驻的文件夹总览,
点击目录时仅展开和聚焦,不再切换为单独目录列表。

同时修复快捷指令右键菜单的透明背景与粘贴语义,
统一为“粘贴到命令输入框”且不自动发送,并同步多语言文案。

顺带收紧快捷指令编辑弹窗的最小尺寸、初始尺寸与视口上限,
降低小分辨率下的弹窗溢出概率。
2026-03-26 02:04:07 +08:00

2397 lines
105 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect, type PropType, readonly, defineExpose, shallowRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import { createSftpActionsManager, type WebSocketDependencies, type FileTreeNode } from '../composables/useSftpActions';
import { useFileUploader } from '../composables/useFileUploader';
import { useFileEditorStore, type FileInfo } from '../stores/fileEditor.store';
import { useSessionStore } from '../stores/session.store';
import { useSettingsStore } from '../stores/settings.store';
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store';
import { useFavoritePathsStore, type FavoritePathItem } from '../stores/favoritePaths.store';
import { useFileManagerContextMenu, type ClipboardState, type CompressFormat } from '../composables/file-manager/useFileManagerContextMenu';
import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection';
import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop';
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
import FileUploadPopup from './FileUploadPopup.vue';
import FileManagerContextMenu from './FileManagerContextMenu.vue';
import FileManagerActionModal from './FileManagerActionModal.vue';
import type { FileListItem } from '../types/sftp.types';
import type { WebSocketMessage } from '../types/websocket.types';
import PathHistoryDropdown from './PathHistoryDropdown.vue';
import { usePathHistoryStore } from '../stores/pathHistory.store';
import FavoritePathsModal from './FavoritePathsModal.vue';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
type ExplorerRootSource = 'favorite' | 'current';
interface ExplorerRootItem {
id: string;
path: string;
label: string;
description: string;
source: ExplorerRootSource;
}
interface ExplorerTreeRow {
id: string;
path: string;
name: string;
description?: string;
depth: number;
isDirectory: boolean;
isRoot: boolean;
loaded: boolean;
expanded: boolean;
source: ExplorerRootSource | 'tree';
item: FileListItem;
}
interface ExplorerOverviewRow {
id: string;
path: string;
name: string;
depth: number;
description?: string;
expanded: boolean;
loaded: boolean;
childDirectoryCount: number;
isRootChild: boolean;
}
interface ExplorerOverviewSection {
id: string;
path: string;
label: string;
description: string;
loaded: boolean;
rowCount: number;
rows: ExplorerOverviewRow[];
}
// --- Props ---
const props = defineProps({
sessionId: {
type: String,
required: true,
},
// 文件管理器实例 ID
instanceId: {
type: String,
required: true,
},
// 注入数据库连接 ID
dbConnectionId: {
type: String,
required: true,
},
// 注入此组件及其子 composables 所需的 WebSocket 依赖项
wsDeps: {
type: Object as PropType<WebSocketDependencies>,
required: true,
},
isMobile: {
type: Boolean,
default: false
}
});
// --- 核心 Composables ---
const { t } = useI18n();
const route = useRoute(); // Keep for download URL generation for now
const sessionStore = useSessionStore(); // 实例化 Session Store
const favoritePathsStore = useFavoritePathsStore();
// --- 获取并存储 SFTP 管理器实例 ---
// 使用 shallowRef 存储管理器实例,以便在 sessionId 变化时切换
const currentSftpManager = shallowRef<SftpManagerInstance | null>(null);
const initializeSftpManager = (sessionId: string, instanceId: string) => {
const manager = sessionStore.getOrCreateSftpManager(sessionId, instanceId);
if (!manager) {
// 抛出错误或显示错误消息,阻止组件进一步渲染
console.error(`[FileManager ${sessionId}-${instanceId}] Failed to get or create SFTP manager instance.`);
// 可以设置一个错误状态 ref 在模板中显示
// managerError.value = `Failed to get SFTP manager for instance ${instanceId}`;
currentSftpManager.value = null; // 确保设置为 null
// 抛出错误会阻止组件渲染,可能不是最佳用户体验
// throw new Error(`[FileManager ${sessionId}-${instanceId}] Failed to get or create SFTP manager instance.`);
} else {
currentSftpManager.value = manager;
console.log(`[FileManager ${sessionId}-${instanceId}] SFTP Manager initialized/retrieved.`);
}
};
// 初始加载管理器
initializeSftpManager(props.sessionId, props.instanceId);
// --- 文件上传模块 ---
// 修改:依赖 currentSftpManager 的状态
const {
uploads,
startFileUpload,
cancelUpload,
} = useFileUploader(
computed(() => props.sessionId),
// 传递 manager 的 currentPath 和 fileList ref
computed(() => currentSftpManager.value?.currentPath.value ?? '/'),
computed(() => currentSftpManager.value?.fileList.value ?? []),
computed(() => props.wsDeps)
);
// 实例化其他 Stores
const fileEditorStore = useFileEditorStore(); // 实例化 File Editor Store
// const sessionStore = useSessionStore(); // 已在上面实例化
const settingsStore = useSettingsStore(); // +++ 实例化 Settings Store +++
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
const pathHistoryStore = usePathHistoryStore(); // +++ 实例化 PathHistoryStore +++
const uiNotificationsStore = useUiNotificationsStore(); // +++ 实例化通知 store +++
// 从 Settings Store 获取共享设置
const {
shareFileEditorTabsBoolean,
fileManagerRowSizeMultiplierNumber, // +++ 获取行大小 getter +++
fileManagerColWidthsObject, // +++ 获取列宽 getter +++
showPopupFileEditorBoolean, // +++ 获取弹窗设置状态 +++
fileManagerShowDeleteConfirmationBoolean, // +++ 获取删除确认设置状态 +++
} = storeToRefs(settingsStore); // 使用 storeToRefs 保持响应性
const { favoritePaths } = storeToRefs(favoritePathsStore);
// --- UI 状态 Refs (Remain mostly the same) ---
const fileInputRef = ref<HTMLInputElement | null>(null);
const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('filename');
const sortDirection = ref<'asc' | 'desc'>('asc');
const isEditingPath = ref(false);
const searchQuery = ref(''); // 搜索查询 ref
const isMultiSelectMode = ref(false); // 多选模式状态 (主要用于移动端)
const isSearchActive = ref(false); // 控制搜索框激活状态
const searchInputRef = ref<HTMLInputElement | null>(null); // 搜索输入框 ref
const pathInputRef = ref<HTMLInputElement | null>(null);
const editablePath = ref('');
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 文件列表容器引用
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引用 +++
// +++ Favorite Paths Modal State +++
const showFavoritePathsModal = ref(false);
const favoritePathsButtonRef = ref<HTMLButtonElement | null>(null); // Ref for the trigger button
const explorerExpandedPaths = ref<Record<string, boolean>>({});
// +++ Path History Refs +++
const showPathHistoryDropdown = ref(false);
const pathInputWrapperRef = ref<HTMLDivElement | null>(null); // Wrapper for path input and dropdown
const pathHistoryDropdownRef = ref<InstanceType<typeof PathHistoryDropdown> | null>(null);
const { selectedIndex: pathSelectedIndex, filteredHistory: filteredPathHistory } = storeToRefs(pathHistoryStore); // Reactive store state
// +++ 操作模态框状态 +++
const isActionModalVisible = ref(false);
const currentActionType = ref<'delete' | 'rename' | 'chmod' | 'newFile' | 'newFolder' | null>(null);
const actionItem = ref<FileListItem | null>(null); // For single item operations
const actionItems = ref<FileListItem[]>([]); // For multi-item operations (e.g., delete)
const actionInitialValue = ref(''); // For pre-filling input in modal
// +++ 剪贴板状态 +++
const clipboardState = ref<ClipboardState>({ hasContent: false });
const clipboardSourcePaths = ref<string[]>([]); // 存储源完整路径
const clipboardSourceBaseDir = ref<string>(''); // 存储源目录
const rowSizeMultiplier = ref(1.0); // 行大小(字体)乘数, 默认值会被 store 覆盖
// --- 键盘导航状态 (移至 useFileManagerKeyboardNavigation) ---
// const selectedIndex = ref<number>(-1);
// --- Column Resizing State (Remains the same) ---
const tableRef = ref<HTMLTableElement | null>(null);
const colWidths = ref({ // 默认值会被 store 覆盖
type: 50,
name: 300,
size: 100,
permissions: 120,
modified: 180,
});
const isResizing = ref(false);
const resizingColumnIndex = ref(-1);
const startX = ref(0);
const startWidth = ref(0);
// --- 辅助函数 ---
const generateRequestId = (): string => `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// UI 格式化函数保持不变
const formatSize = (size: number): string => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
};
const formatMode = (mode: number): string => {
const perm = mode & 0o777; let str = '';
str += (perm & 0o400) ? 'r' : '-'; str += (perm & 0o200) ? 'w' : '-'; str += (perm & 0o100) ? 'x' : '-';
str += (perm & 0o040) ? 'r' : '-'; str += (perm & 0o020) ? 'w' : '-'; str += (perm & 0o010) ? 'x' : '-';
str += (perm & 0o004) ? 'r' : '-'; str += (perm & 0o002) ? 'w' : '-'; str += (perm & 0o001) ? 'x' : '-';
return str;
};
const getPathName = (path: string): string => {
if (!path || path === '/') {
return '/';
}
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
return normalized.substring(normalized.lastIndexOf('/') + 1) || normalized;
};
const sortTreeItems = (items: FileListItem[]): FileListItem[] => {
return [...items].sort((left, right) => {
if (left.attrs.isDirectory && !right.attrs.isDirectory) return -1;
if (!left.attrs.isDirectory && right.attrs.isDirectory) return 1;
return left.filename.localeCompare(right.filename);
});
};
const findTreeNodeByPath = (path: string): FileTreeNode | null => {
const root = currentSftpManager.value?.fileTree;
if (!root) {
return null;
}
if (path === '/') {
return root;
}
const segments = path.split('/').filter(Boolean);
let currentNode: FileTreeNode | null = root;
for (const segment of segments) {
if (!currentNode?.children) {
return null;
}
currentNode = currentNode.children.find((child) => child.filename === segment) ?? null;
}
return currentNode;
};
const toFileListItem = (node: FileTreeNode): FileListItem => ({
filename: node.filename,
longname: node.longname,
attrs: node.attrs,
});
const getDirectoryChildren = (node: FileTreeNode | null): FileTreeNode[] => {
if (!node?.children?.length) {
return [];
}
return [...node.children]
.filter((child) => child.attrs.isDirectory)
.sort((left, right) => left.filename.localeCompare(right.filename));
};
const openFileInWorkspace = (filePath: string, filename: string) => {
const fileInfo: FileInfo = { name: filename, fullPath: filePath };
if (settingsStore.showPopupFileEditorBoolean) {
fileEditorStore.triggerPopup(filePath, props.sessionId);
}
if (shareFileEditorTabsBoolean.value) {
fileEditorStore.openFile(filePath, props.sessionId, props.instanceId);
} else {
sessionStore.openFileInSession(props.sessionId, fileInfo);
}
};
const explorerRoots = computed<ExplorerRootItem[]>(() => {
const roots = new Map<string, ExplorerRootItem>();
favoritePaths.value.forEach((favorite: FavoritePathItem) => {
const path = favorite.path?.trim();
if (!path) {
return;
}
roots.set(path, {
id: `favorite:${favorite.id}`,
path,
label: favorite.name?.trim() || getPathName(path),
description: path,
source: 'favorite',
});
});
const currentPath = currentSftpManager.value?.currentPath.value?.trim();
if (currentPath && !roots.has(currentPath)) {
roots.set(currentPath, {
id: `current:${currentPath}`,
path: currentPath,
label: getPathName(currentPath),
description: currentPath,
source: 'current',
});
}
return Array.from(roots.values());
});
const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
const rows: ExplorerTreeRow[] = [];
const appendNodeRows = (basePath: string, nodes: FileListItem[], depth: number) => {
sortTreeItems(nodes)
.filter((item) => item.attrs.isDirectory)
.forEach((item) => {
const itemPath = currentSftpManager.value?.joinPath(basePath, item.filename) ?? `${basePath}/${item.filename}`;
const treeNode = findTreeNodeByPath(itemPath);
const expanded = Boolean(explorerExpandedPaths.value[itemPath]);
const loaded = Boolean(treeNode?.childrenLoaded);
rows.push({
id: `tree:${itemPath}`,
path: itemPath,
name: item.filename,
depth,
isDirectory: true,
isRoot: false,
loaded,
expanded,
source: 'tree',
item,
});
if (expanded && treeNode?.children?.length) {
appendNodeRows(itemPath, treeNode.children.map(toFileListItem), depth + 1);
}
});
};
explorerRoots.value.forEach((root) => {
const node = findTreeNodeByPath(root.path);
const rootItem: FileListItem = node
? toFileListItem(node)
: {
filename: getPathName(root.path),
longname: root.path,
attrs: {
isDirectory: true,
isFile: false,
isSymbolicLink: false,
size: 0,
uid: 0,
gid: 0,
mode: 0,
atime: 0,
mtime: 0,
},
};
const expanded = explorerExpandedPaths.value[root.path] ?? true;
const loaded = Boolean(node?.childrenLoaded);
rows.push({
id: root.id,
path: root.path,
name: root.label,
description: root.description,
depth: 0,
isDirectory: true,
isRoot: true,
loaded,
expanded,
source: root.source,
item: rootItem,
});
if (expanded && node?.children?.length) {
appendNodeRows(root.path, node.children.map(toFileListItem), 1);
}
});
return rows;
});
const explorerOverviewSections = computed<ExplorerOverviewSection[]>(() => {
const buildRows = (basePath: string, nodes: FileTreeNode[], depth: number): ExplorerOverviewRow[] => {
const rows: ExplorerOverviewRow[] = [];
nodes.forEach((node) => {
const itemPath = currentSftpManager.value?.joinPath(basePath, node.filename) ?? `${basePath}/${node.filename}`;
const expanded = Boolean(explorerExpandedPaths.value[itemPath]);
const childDirectories = getDirectoryChildren(node);
rows.push({
id: `overview:${itemPath}`,
path: itemPath,
name: node.filename,
depth,
expanded,
loaded: Boolean(node.childrenLoaded),
childDirectoryCount: childDirectories.length,
isRootChild: depth === 0,
});
if (expanded && childDirectories.length) {
rows.push(...buildRows(itemPath, childDirectories, depth + 1));
}
});
return rows;
};
return explorerRoots.value.map((root) => {
const rootNode = findTreeNodeByPath(root.path);
const childDirectories = getDirectoryChildren(rootNode);
return {
id: `section:${root.id}`,
path: root.path,
label: root.label,
description: root.description,
loaded: Boolean(rootNode?.childrenLoaded),
rowCount: childDirectories.length,
rows: buildRows(root.path, childDirectories, 0),
};
});
});
const getFileIconClassBase = (filename: string): string => {
const lowerFilename = filename.toLowerCase();
let extension = '';
const lastDotIndex = lowerFilename.lastIndexOf('.');
if (lastDotIndex > 0 && lastDotIndex < lowerFilename.length - 1) { // e.g. file.txt
extension = lowerFilename.substring(lastDotIndex + 1);
} else if (lastDotIndex === 0 && lowerFilename.length > 1) { // e.g. .bashrc, .gitignore
extension = lowerFilename.substring(1); // use 'bashrc' or 'gitignore' as extension
}
// Handle specific full filenames first for higher precedence
if (lowerFilename === 'makefile') return 'fas fa-cogs';
if (lowerFilename === 'dockerfile') return 'fab fa-docker';
if (lowerFilename.endsWith('docker-compose.yml') || lowerFilename.endsWith('docker-compose.yaml')) return 'fab fa-docker';
if (lowerFilename === 'package.json') return 'fab fa-npm';
if (lowerFilename === 'package-lock.json') return 'fab fa-npm';
if (lowerFilename === 'yarn.lock') return 'fab fa-yarn';
if (lowerFilename === 'composer.json') return 'fab fa-php';
if (lowerFilename === 'composer.lock') return 'fab fa-php';
if (lowerFilename === 'gemfile') return 'fas fa-gem';
if (lowerFilename === 'gemfile.lock') return 'fas fa-gem';
if (lowerFilename.startsWith('.env')) return 'fas fa-shield-alt';
if (lowerFilename === '.git') return 'fab fa-git-alt';
if (lowerFilename === '.gitignore') return 'fab fa-git-alt';
if (lowerFilename === '.gitattributes') return 'fab fa-git-alt';
if (lowerFilename === '.gitmodules') return 'fab fa-git-alt';
if (lowerFilename === 'readme' || lowerFilename.startsWith('readme.')) return 'fas fa-book-reader';
if (lowerFilename === 'license' || lowerFilename.startsWith('license.')) return 'fas fa-balance-scale';
if (lowerFilename === 'contributing' || lowerFilename.startsWith('contributing.')) return 'fas fa-users-cog';
if (lowerFilename === 'code_of_conduct' || lowerFilename.startsWith('code_of_conduct.')) return 'fas fa-gavel';
if (lowerFilename === 'changelog' || lowerFilename.startsWith('changelog.')) return 'fas fa-list-alt';
if (lowerFilename === 'favicon.ico') return 'fas fa-icons';
const iconMap: { [key: string]: string } = {
// Images
'jpg': 'fas fa-file-image', 'jpeg': 'fas fa-file-image', 'png': 'fas fa-file-image',
'gif': 'fas fa-file-image', 'bmp': 'fas fa-file-image', 'svg': 'fas fa-file-image',
'webp': 'fas fa-file-image', 'ico': 'fas fa-file-image', 'tiff': 'fas fa-file-image',
// Videos
'mp4': 'fas fa-file-video', 'mkv': 'fas fa-file-video', 'avi': 'fas fa-file-video',
'mov': 'fas fa-file-video', 'wmv': 'fas fa-file-video', 'flv': 'fas fa-file-video', 'webm': 'fas fa-file-video',
// Audio
'mp3': 'fas fa-file-audio', 'wav': 'fas fa-file-audio', 'ogg': 'fas fa-file-audio',
'flac': 'fas fa-file-audio', 'aac': 'fas fa-file-audio', 'm4a': 'fas fa-file-audio',
// Documents
'doc': 'fas fa-file-word', 'docx': 'fas fa-file-word',
'xls': 'fas fa-file-excel', 'xlsx': 'fas fa-file-excel',
'ppt': 'fas fa-file-powerpoint', 'pptx': 'fas fa-file-powerpoint',
'pdf': 'fas fa-file-pdf', 'odt': 'fas fa-file-alt', 'ods': 'fas fa-file-alt', 'odp': 'fas fa-file-alt',
'rtf': 'fas fa-file-alt',
'csv': 'fas fa-file-csv', 'tsv': 'fas fa-file-csv',
// Archives
'zip': 'fas fa-file-archive', 'rar': 'fas fa-file-archive', 'tar': 'fas fa-file-archive',
'gz': 'fas fa-file-archive', '7z': 'fas fa-file-archive', 'bz2': 'fas fa-file-archive', 'xz': 'fas fa-file-archive',
'iso': 'fas fa-compact-disc',
// Code & Config
'js': 'fab fa-js-square', 'mjs': 'fab fa-js-square', 'cjs': 'fab fa-js-square',
'jsx': 'fab fa-react',
'ts': 'fas fa-file-code',
'tsx': 'fab fa-react',
'vue': 'fab fa-vuejs',
'svelte': 'fas fa-file-code',
'py': 'fab fa-python', 'pyc': 'fab fa-python', 'pyd': 'fab fa-python', 'pyw': 'fab fa-python', 'ipynb': 'fab fa-python',
'java': 'fab fa-java', 'jar': 'fab fa-java', 'class': 'fab fa-java',
'kt': 'fas fa-file-code', 'kts': 'fas fa-file-code',
'cs': 'fas fa-file-code',
'fs': 'fas fa-file-code',
'go': 'fas fa-file-code',
'rs': 'fas fa-file-code',
'c': 'fas fa-file-code', 'h': 'fas fa-file-code',
'cpp': 'fas fa-file-code', 'hpp': 'fas fa-file-code', 'cxx': 'fas fa-file-code', 'hxx': 'fas fa-file-code',
'rb': 'fas fa-gem', 'erb': 'fas fa-gem',
'php': 'fab fa-php',
'swift': 'fab fa-swift',
'scala': 'fas fa-file-code',
'perl': 'fas fa-file-code', 'pl': 'fas fa-file-code',
'lua': 'fas fa-file-code',
'dart': 'fas fa-file-code',
'r': 'fas fa-file-code',
'html': 'fab fa-html5', 'htm': 'fab fa-html5', 'xhtml': 'fab fa-html5',
'css': 'fab fa-css3-alt',
'scss': 'fab fa-sass', 'sass': 'fab fa-sass',
'less': 'fab fa-less',
'styl': 'fas fa-file-code',
'json': 'fas fa-file-code', 'webmanifest': 'fas fa-file-code', 'jsonc': 'fas fa-file-code',
'xml': 'fas fa-file-code', 'xsl': 'fas fa-file-code', 'xsd': 'fas fa-file-code',
'yml': 'fas fa-cog', 'yaml': 'fas fa-cog',
'ini': 'fas fa-cog', 'conf': 'fas fa-cog', 'cfg': 'fas fa-cog', 'config': 'fas fa-cog',
'toml': 'fas fa-cog',
'md': 'fab fa-markdown', 'markdown': 'fab fa-markdown',
'sql': 'fas fa-database', 'ddl': 'fas fa-database',
'db': 'fas fa-database', 'sqlite': 'fas fa-database', 'mdb': 'fas fa-database',
'lock': 'fas fa-lock',
'gitignore': 'fab fa-git-alt', /* 'gitattributes': 'fab fa-git-alt', */ /* 'gitmodules': 'fab fa-git-alt', */ 'gitkeep': 'fab fa-git-alt', // Removed duplicate gitattributes and gitmodules
/* 'dockerfile': 'fab fa-docker', */ 'dockerignore': 'fab fa-docker', // Removed duplicate dockerfile
'npmrc': 'fab fa-npm', 'yarnrc': 'fab fa-yarn', 'pnpmfile.js': 'fas fa-cogs',
'babelrc': 'fas fa-cogs', 'eslintrc': 'fas fa-cogs', 'prettierrc': 'fas fa-cogs', 'stylelintrc': 'fas fa-cogs',
'browserslistrc': 'fas fa-cogs', 'editorconfig': 'fas fa-cog',
'tsconfig.json': 'fas fa-cogs', 'jsconfig.json': 'fas fa-cogs',
'webpack.config.js': 'fas fa-cogs', 'vite.config.js': 'fas fa-cogs', 'vite.config.ts': 'fas fa-cogs',
'rollup.config.js': 'fas fa-cogs', 'postcss.config.js': 'fas fa-cogs',
'jest.config.js': 'fas fa-cogs', 'cypress.json': 'fas fa-cogs', 'playwright.config.ts': 'fas fa-cogs',
// Text & Others
'txt': 'fas fa-file-alt', 'text': 'fas fa-file-alt',
'log': 'fas fa-file-alt', 'out': 'fas fa-file-alt', 'err': 'fas fa-file-alt',
'key': 'fas fa-key', 'pem': 'fas fa-key', 'pub': 'fas fa-key', 'asc': 'fas fa-key',
'crt': 'fas fa-certificate', 'cer': 'fas fa-certificate', 'csr': 'fas fa-certificate', 'pfx': 'fas fa-certificate', 'p12': 'fas fa-certificate',
// Executables & scripts
'exe': 'fas fa-cogs', 'msi': 'fas fa-cogs', 'app': 'fas fa-cogs', 'com': 'fas fa-cogs',
'sh': 'fas fa-terminal', 'bash': 'fas fa-terminal', 'zsh': 'fas fa-terminal', 'fish': 'fas fa-terminal', 'csh': 'fas fa-terminal', 'ksh': 'fas fa-terminal',
'bat': 'fas fa-terminal', 'cmd': 'fas fa-terminal', 'ps1': 'fas fa-terminal', 'psm1': 'fas fa-terminal',
'vb': 'fas fa-file-code', 'vbs': 'fas fa-file-code',
'deb': 'fas fa-archive', 'rpm': 'fas fa-archive', 'pkg': 'fas fa-archive',
'dmg': 'fas fa-compact-disc', 'img': 'fas fa-compact-disc',
// Fonts
'ttf': 'fas fa-font', 'otf': 'fas fa-font', 'woff': 'fas fa-font', 'woff2': 'fas fa-font', 'eot': 'fas fa-font',
// Special hidden files (extension is the part after dot)
'bashrc': 'fas fa-cog', 'zshrc': 'fas fa-cog', 'profile': 'fas fa-cog', 'bash_profile': 'fas fa-cog',
'vimrc': 'fas fa-cog', 'screenrc': 'fas fa-cog', 'tmux.conf': 'fas fa-cog',
'gitconfig': 'fab fa-git-alt', 'npmignore': 'fab fa-npm',
'htaccess': 'fas fa-cog', 'htpasswd': 'fas fa-lock',
// Default
'default': 'far fa-file'
};
return iconMap[extension] || iconMap['default'];
};
// --- 排序与过滤逻辑 ---
// 修改:依赖 currentSftpManager.value.fileList
const sortedFileList = computed(() => {
if (!currentSftpManager.value?.fileList.value) return []; // 检查 manager 和 fileList 是否存在
const list = [...currentSftpManager.value.fileList.value]; // 从 manager 获取列表
const key = sortKey.value;
const direction = sortDirection.value === 'asc' ? 1 : -1;
list.sort((a, b) => {
if (key !== 'type') {
if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1;
if (!a.attrs.isDirectory && b.attrs.isDirectory) return 1;
}
let valA: string | number | boolean;
let valB: string | number | boolean;
switch (key) {
case 'type':
valA = a.attrs.isDirectory ? 0 : (a.attrs.isSymbolicLink ? 1 : 2);
valB = b.attrs.isDirectory ? 0 : (b.attrs.isSymbolicLink ? 1 : 2);
break;
case 'filename': valA = a.filename.toLowerCase(); valB = b.filename.toLowerCase(); break;
case 'size': valA = a.attrs.isFile ? a.attrs.size : -1; valB = b.attrs.isFile ? b.attrs.size : -1; break;
case 'mtime': valA = a.attrs.mtime; valB = b.attrs.mtime; break;
default: valA = a.filename.toLowerCase(); valB = b.filename.toLowerCase();
}
if (valA < valB) return -1 * direction;
if (valA > valB) return 1 * direction;
if (key !== 'filename') return a.filename.localeCompare(b.filename);
return 0;
});
return list;
});
const filteredFileList = computed(() => {
if (!searchQuery.value) {
return sortedFileList.value; // 如果没有搜索查询,返回原始排序列表
}
const lowerCaseQuery = searchQuery.value.toLowerCase();
return sortedFileList.value.filter(item =>
item.filename.toLowerCase().includes(lowerCaseQuery)
);
});
const handleSort = (key: keyof FileListItem | 'type' | 'size' | 'mtime') => {
if (sortKey.value === key) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
} else {
sortKey.value = key;
sortDirection.value = 'asc';
}
};
// --- 列表项点击与选择逻辑 (使用 Composable) ---
// 定义单击时的动作回调 (移到 Selection 实例化之前)
const handleItemAction = (item: FileListItem) => {
if (!currentSftpManager.value) return;
const itemPath = currentSftpManager.value.joinPath(currentSftpManager.value.currentPath.value, item.filename);
if (item.attrs.isSymbolicLink) {
if (currentSftpManager.value.isLoading.value) {
return;
}
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Symbolic link clicked: ${itemPath}. Attempting to resolve with sftp:realpath...`);
const { sendMessage: wsSend, onMessage: wsOnMessage } = props.wsDeps;
const requestId = generateRequestId();
const handleResolvedPath = (realPath: string, targetType: 'file' | 'directory' | 'unknown', originalLinkItem: FileListItem) => {
if (!currentSftpManager.value) return;
if (targetType === 'directory') {
currentSftpManager.value.loadDirectory(realPath);
} else if (targetType === 'file') {
const targetFilename = realPath.substring(realPath.lastIndexOf('/') + 1) || originalLinkItem.filename; // Get filename from realPath
// Preserve mobile multi-select behavior for the original link item
if (props.isMobile && isMultiSelectMode.value) {
if (selectedItems.value.has(originalLinkItem.filename)) {
selectedItems.value.delete(originalLinkItem.filename);
} else {
selectedItems.value.add(originalLinkItem.filename);
}
return;
}
openFileInWorkspace(realPath, targetFilename);
} else { // targetType is 'unknown' or not provided as expected
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Symlink target '${realPath}' has an unknown type from server ('${targetType}'). Defaulting to open as file.`);
// Fallback: attempt to open as file, or display an error
const targetFilename = realPath.substring(realPath.lastIndexOf('/') + 1) || originalLinkItem.filename;
openFileInWorkspace(realPath, targetFilename);
}
};
let unregisterSuccess: (() => void) | undefined;
let unregisterError: (() => void) | undefined;
let timeoutId: NodeJS.Timeout | number | undefined;
const cleanupListeners = () => {
unregisterSuccess?.();
unregisterError?.();
if (timeoutId) clearTimeout(timeoutId as any);
timeoutId = undefined;
};
unregisterSuccess = wsOnMessage('sftp:realpath:success', (payload: any, message: WebSocketMessage) => {
if (message.requestId === requestId && payload.requestedPath === itemPath) {
cleanupListeners();
if (!currentSftpManager.value) return;
// 从 payload 中获取 absolutePath 和 targetType
const absolutePath = payload.absolutePath;
const targetType = payload.targetType as ('file' | 'directory' | 'unknown'); // 类型断言
if (!absolutePath) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] sftp:realpath:success for ${itemPath} missing absolutePath. Payload:`, payload);
return;
}
if (!targetType) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] sftp:realpath:success for ${itemPath} missing targetType. Defaulting to 'file'. Payload:`, payload);
}
handleResolvedPath(absolutePath, targetType || 'unknown', item);
}
});
unregisterError = wsOnMessage('sftp:realpath:error', (payload: any, message: WebSocketMessage) => {
if (message.requestId === requestId && payload?.requestedPath === itemPath) {
cleanupListeners();
// payload.error 可能包含来自后端的具体错误信息
// payload.absolutePath 可能在 stat 失败时仍然存在
const serverErrorMsg = payload.error || 'Unknown error resolving symlink target type';
const resolvedPathInfo = payload.absolutePath ? ` (Resolved path: ${payload.absolutePath})` : '';
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to get realpath or target type for symlink '${itemPath}': ${serverErrorMsg}${resolvedPathInfo}`);
}
});
timeoutId = setTimeout(() => {
cleanupListeners();
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Timeout getting realpath for symlink '${itemPath}' (ID: ${requestId}).`);
}, 10000); // 10 秒超时
wsSend({ type: 'sftp:realpath', requestId: requestId, payload: { path: itemPath } });
return; // Handled by async callbacks
}
if (item.attrs.isDirectory) {
if (currentSftpManager.value.isLoading.value) {
return;
}
const newPath = item.filename === '..'
? currentSftpManager.value.currentPath.value.substring(0, currentSftpManager.value.currentPath.value.lastIndexOf('/')) || '/'
: currentSftpManager.value.joinPath(currentSftpManager.value.currentPath.value, item.filename);
currentSftpManager.value.loadDirectory(newPath);
} else if (item.attrs.isFile) {
// This block now only handles regular files, as symlinks are handled above.
if (props.isMobile && isMultiSelectMode.value) {
if (selectedItems.value.has(item.filename)) {
selectedItems.value.delete(item.filename);
} else {
selectedItems.value.add(item.filename);
}
return;
}
const filePath = itemPath; // itemPath is already calculated
openFileInWorkspace(filePath, item.filename);
}
};
// 切换多选模式 (主要用于移动端)
const toggleMultiSelectMode = () => {
isMultiSelectMode.value = !isMultiSelectMode.value;
if (!isMultiSelectMode.value) {
clearSelection(); // 退出多选模式时清空选择
}
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Multi-select mode: ${isMultiSelectMode.value ? 'enabled' : 'disabled'}`);
};
// 实例化选择 Composable (需要 filteredFileList 和 handleItemAction)
const {
selectedItems, // 使用 Composable 返回的 selectedItems
lastClickedIndex, // 获取 lastClickedIndex 以传递给 ContextMenu
handleItemClick: originalHandleItemClick, // 使用 Composable 返回的 handleItemClick
clearSelection, // 获取清空选择的方法
} = useFileManagerSelection({
// 传递当前显示的列表 (已排序和过滤)
displayedFileList: filteredFileList, // 现在 filteredFileList 已定义
onItemAction: handleItemAction, // 传递动作回调
});
// 自定义 handleItemClick 函数以支持移动端多选模式
const handleItemClick = (event: MouseEvent, item: FileListItem, forceMultiSelect = false) => {
if (props.isMobile && (isMultiSelectMode.value || forceMultiSelect)) {
if (selectedItems.value.has(item.filename)) {
selectedItems.value.delete(item.filename);
} else {
selectedItems.value.add(item.filename);
}
return;
}
originalHandleItemClick(event, item);
};
// +++ 计算属性:获取选中的完整文件对象列表 +++
const computedSelectedFullItems = computed((): FileListItem[] => {
if (!selectedItems.value || selectedItems.value.size === 0) {
return [];
}
return filteredFileList.value.filter(item => selectedItems.value.has(item.filename));
});
// --- 操作模态框辅助函数 ---
const openActionModal = (
type: 'delete' | 'rename' | 'chmod' | 'newFile' | 'newFolder',
item?: FileListItem | null, // For single item operations like rename, chmod
items?: FileListItem[], // For multi-item operations like delete
initialValue?: string // For pre-filling input, e.g., old name for rename
) => {
currentActionType.value = type;
actionItem.value = item || null;
actionItems.value = items || (item ? [item] : []); // Ensure actionItems has the item(s)
actionInitialValue.value = initialValue || '';
isActionModalVisible.value = true;
};
const handleModalClose = () => {
isActionModalVisible.value = false;
// Reset states if needed, though they'll be overwritten on next open
currentActionType.value = null;
actionItem.value = null;
actionItems.value = [];
actionInitialValue.value = '';
};
const handleModalConfirm = (value?: string) => {
if (!currentSftpManager.value || !currentActionType.value) {
handleModalClose();
return;
}
const manager = currentSftpManager.value;
switch (currentActionType.value) {
case 'delete':
if (actionItems.value.length > 0) {
manager.deleteItems(actionItems.value);
selectedItems.value.clear(); // Clear selection after delete
}
break;
case 'rename':
if (actionItem.value && value && value !== actionItem.value.filename) {
manager.renameItem(actionItem.value, value);
}
break;
case 'chmod':
if (actionItem.value && value && /^[0-7]{3,4}$/.test(value)) {
const newMode = parseInt(value, 8);
manager.changePermissions(actionItem.value, newMode);
} else if (value) { // value exists but is invalid
// Optionally, re-open modal with error or use a notification
// For now, just log and close
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Invalid chmod value from modal: ${value}`);
// It might be better to show an error in the modal itself and not close it.
// The modal currently has its own validation, so this path might not be hit often.
}
break;
case 'newFile':
if (value) {
if (manager.fileList.value.some((item: FileListItem) => item.filename === value)) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] File ${value} already exists. Modal should prevent this.`);
return; // Prevent closing if error
}
manager.createFile(value);
}
break;
case 'newFolder':
if (value) {
if (manager.fileList.value.some((item: FileListItem) => item.filename === value)) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Folder ${value} already exists. Modal should prevent this.`);
return; // Prevent closing if error
}
manager.createDirectory(value);
}
break;
}
handleModalClose(); // Close modal after action
};
// --- SFTP 操作处理函数 (定义在此处,供 Composable 使用) ---
const handleDeleteSelectedClick = () => {
// 修改:检查 currentSftpManager 是否存在
if (!currentSftpManager.value) return;
// 使用 props.wsDeps 和 currentSftpManager.value.fileList
if (!props.wsDeps.isConnected.value || selectedItems.value.size === 0) return;
const itemsToDelete = Array.from(selectedItems.value)
.map(filename => currentSftpManager.value?.fileList.value.find((f: FileListItem) => f.filename === filename))
.filter((item): item is FileListItem => item !== undefined);
if (itemsToDelete.length === 0) return;
// 根据设置决定是否显示确认模态框
if (settingsStore.fileManagerShowDeleteConfirmationBoolean) {
openActionModal('delete', null, itemsToDelete);
} else {
// 直接执行删除
if (currentSftpManager.value) {
currentSftpManager.value.deleteItems(itemsToDelete);
selectedItems.value.clear(); // Clear selection after delete
}
}
};
const handleRenameContextMenuClick = (item: FileListItem) => { // item 已有类型
if (!props.wsDeps.isConnected.value || !item) return; // 恢复使用 props.wsDeps
if (!currentSftpManager.value) return;
openActionModal('rename', item, undefined, item.filename);
};
const handleChangePermissionsContextMenuClick = (item: FileListItem) => { // item 已有类型
if (!props.wsDeps.isConnected.value || !item) return; // 恢复使用 props.wsDeps
if (!currentSftpManager.value) return;
const currentModeOctal = (item.attrs.mode & 0o777).toString(8).padStart(3, '0');
openActionModal('chmod', item, undefined, currentModeOctal);
};
const handleNewFolderContextMenuClick = () => {
if (!props.wsDeps.isConnected.value) return; // 恢复使用 props.wsDeps
if (!currentSftpManager.value) return;
openActionModal('newFolder');
};
const handleNewFileContextMenuClick = () => {
if (!props.wsDeps.isConnected.value) return; // 恢复使用 props.wsDeps
if (!currentSftpManager.value) return;
openActionModal('newFile');
};
// +++ 复制、剪切、粘贴处理函数 +++
const handleCopy = () => {
if (!currentSftpManager.value || selectedItems.value.size === 0) return;
const manager = currentSftpManager.value;
clipboardSourcePaths.value = Array.from(selectedItems.value)
.map(filename => manager.joinPath(manager.currentPath.value, filename));
clipboardState.value = { hasContent: true, operation: 'copy' };
clipboardSourceBaseDir.value = manager.currentPath.value; // 记录源目录
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Copied to clipboard:`, clipboardSourcePaths.value);
// 可选:添加 UI 通知
};
const handleCut = () => {
if (!currentSftpManager.value || selectedItems.value.size === 0) return;
const manager = currentSftpManager.value;
clipboardSourcePaths.value = Array.from(selectedItems.value)
.map(filename => manager.joinPath(manager.currentPath.value, filename));
clipboardState.value = { hasContent: true, operation: 'cut' };
clipboardSourceBaseDir.value = manager.currentPath.value; // 记录源目录
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Cut to clipboard:`, clipboardSourcePaths.value);
// 可选:添加 UI 通知
};
const handlePaste = () => {
if (!currentSftpManager.value || !clipboardState.value.hasContent || clipboardSourcePaths.value.length === 0) return;
const manager = currentSftpManager.value;
const destinationDir = manager.currentPath.value;
const operation = clipboardState.value.operation;
const sources = clipboardSourcePaths.value;
const sourceBaseDir = clipboardSourceBaseDir.value; // 获取源目录
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Pasting items. Operation: ${operation}, Sources: ${sources.join(', ')}, Destination: ${destinationDir}`);
if (operation === 'copy') {
// 调用 SFTP 管理器的 copyItems 方法 (稍后添加)
manager.copyItems(sources, destinationDir);
} else if (operation === 'cut') {
// 调用 SFTP 管理器的 moveItems 方法 (稍后添加)
// 检查是否在同一目录下剪切粘贴(无效操作)
if (sourceBaseDir === destinationDir) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot cut and paste in the same directory.`);
// 可选:显示警告通知
return;
}
manager.moveItems(sources, destinationDir);
// 剪切后清空剪贴板
clipboardState.value = { hasContent: false };
clipboardSourcePaths.value = [];
clipboardSourceBaseDir.value = '';
}
// 粘贴后不清空复制的剪贴板,允许重复粘贴
// 清空选择可能不是最佳体验,用户可能想继续操作粘贴后的文件
// clearSelection();
};
// --- 文件上传触发器 (定义在此处,供 Composable 使用) ---
const triggerFileUpload = () => { fileInputRef.value?.click(); };
// --- 下载触发器 (定义在此处,供 Composable 使用) ---
const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileListItem 数组
// 恢复使用 props.wsDeps.isConnected
if (!props.wsDeps.isConnected.value) {
return;
}
// connectionId 仍然从 props 获取
const currentConnectionId = props.dbConnectionId;
if (!currentConnectionId) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot download: Missing connection ID.`);
return;
}
// 修改:简化检查
if (!currentSftpManager.value) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot download: SFTP manager is not available.`);
return;
}
// 遍历数组中的每个文件项
items.forEach(item => {
// 确保只下载文件
if (!item.attrs.isFile) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Skipping download for non-file item: ${item.filename}`);
return;
}
const downloadPath = currentSftpManager.value!.joinPath(currentSftpManager.value!.currentPath.value, item.filename);
const downloadUrl = `/api/v1/sftp/download?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(downloadPath)}`;
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Triggering download for ${item.filename}: ${downloadUrl}`);
// 为每个文件创建一个链接并点击
const link = document.createElement('a');
link.href = downloadUrl;
// --- 修正:移除文件名中的双引号以兼容 Chrome ---
const safeFilename = item.filename.replace(/"/g, ''); // 移除所有双引号
link.setAttribute('download', safeFilename);
// --- 结束修正 ---
document.body.appendChild(link);
link.click();
// 稍微延迟移除链接,以确保下载开始
setTimeout(() => {
document.body.removeChild(link);
}, 100);
});
};
// +++ 文件夹下载触发器 +++
const triggerDownloadDirectory = (item: FileListItem) => {
if (!props.wsDeps.isConnected.value) {
return;
}
const currentConnectionId = props.dbConnectionId;
if (!currentConnectionId) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot download directory: Missing connection ID.`);
return;
}
if (!currentSftpManager.value) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot download directory: SFTP manager is not available.`);
return;
}
// 确保是目录
if (!item.attrs.isDirectory) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Skipping directory download for non-directory item: ${item.filename}`);
return;
}
const directoryPath = currentSftpManager.value.joinPath(currentSftpManager.value.currentPath.value, item.filename);
// 定义新的后端 API 端点 URL (稍后实现)
const downloadUrl = `/api/v1/sftp/download-directory?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(directoryPath)}`;
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Attempting directory download for ${item.filename}: ${downloadUrl}`);
// --- 修改:使用 fetch 尝试下载,并处理后端未实现的情况 ---
fetch(downloadUrl)
.then(async response => {
if (response.ok) {
// 后端实现成功,尝试触发下载
const blob = await response.blob();
// 从 Content-Disposition 头获取文件名 (需要后端设置)
const contentDisposition = response.headers.get('content-disposition');
let filename = `${item.filename}.zip`; // 默认文件名
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
if (filenameMatch && filenameMatch.length > 1) {
filename = filenameMatch[1];
}
}
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
// --- 修正:移除 ZIP 文件名中的双引号以兼容 Chrome ---
const safeZipFilename = filename.replace(/"/g, '');
link.setAttribute('download', safeZipFilename);
// --- 结束修正 ---
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href); // 释放对象 URL
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Directory download triggered for: ${filename}`);
} else {
// 处理错误,例如 404 Not Found
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Directory download failed: ${response.status} ${response.statusText}`);
// 尝试读取错误信息体
let errorMsg = `Server responded with status ${response.status}`;
try {
const errorData = await response.json(); // 假设后端返回 JSON 错误
errorMsg = errorData.message || errorMsg;
} catch (e) {
// 如果响应体不是 JSON 或读取失败
try {
const textError = await response.text();
if (textError) errorMsg = textError;
} catch (e2) { /* ignore */}
}
}
})
.catch(error => {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Network error during directory download:`, error);
});
};
// +++ 压缩/解压处理函数 +++
const handleCompress = (items: FileListItem[], format: CompressFormat) => {
if (!currentSftpManager.value) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot compress: SFTP manager not available.`);
// TODO: Show error notification
return;
}
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Requesting compression for ${items.length} items, format: ${format}`);
// 调用 SFTP 管理器上的新方法 (将在 useSftpActions.ts 中实现)
currentSftpManager.value.compressItems(items, format);
};
const handleDecompress = (item: FileListItem) => {
if (!currentSftpManager.value) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot decompress: SFTP manager not available.`);
// TODO: Show error notification
return;
}
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Requesting decompression for item: ${item.filename}`);
// 调用 SFTP 管理器上的新方法 (将在 useSftpActions.ts 中实现)
currentSftpManager.value.decompressItem(item);
};
// +++ 复制路径到剪贴板 +++
const handleCopyPath = async (item: FileListItem) => {
if (!currentSftpManager.value) return;
const fullPath = currentSftpManager.value.joinPath(currentSftpManager.value.currentPath.value, item.filename);
try {
await navigator.clipboard.writeText(fullPath);
// 可选:显示成功通知
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Copied path to clipboard: ${fullPath}`);
uiNotificationsStore.showSuccess(t('fileManager.notifications.pathCopied', 'Path copied to clipboard'));
} catch (err) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to copy path: `, err);
// 可选:显示错误通知
uiNotificationsStore.showError(t('fileManager.errors.copyPathFailed', 'Failed to copy path'));
}
};
// --- 上下文菜单逻辑 (使用 Composable, 需要 Selection 和 Action Handlers) ---
const {
contextMenuVisible,
contextMenuPosition,
contextMenuItems,
contextMenuRef, // 获取 ref 以传递给子组件
contextTargetItem, // Get the target item from the composable
showContextMenu, // 使用 Composable 提供的函数
hideContextMenu, // <-- 获取 hideContextMenu 函数
} = useFileManagerContextMenu({
selectedItems,
lastClickedIndex,
// 修改:传递 manager 的 fileList 和 currentPath ref (保持 computed)
fileList: computed(() => currentSftpManager.value?.fileList.value ?? []),
currentPath: computed(() => currentSftpManager.value?.currentPath.value ?? '/'),
isConnected: props.wsDeps.isConnected,
isSftpReady: props.wsDeps.isSftpReady,
clipboardState: readonly(clipboardState), // +++ 传递剪贴板状态 (只读) +++
t,
// --- 传递回调函数 ---
// 修改:确保在调用前检查 currentSftpManager.value
onRefresh: () => {
if (currentSftpManager.value) {
currentSftpManager.value.loadDirectory(currentSftpManager.value.currentPath.value, true);
}
},
onUpload: triggerFileUpload,
onDownload: triggerDownload,
onDelete: handleDeleteSelectedClick,
onRename: handleRenameContextMenuClick,
onChangePermissions: handleChangePermissionsContextMenuClick,
onNewFolder: handleNewFolderContextMenuClick,
onNewFile: handleNewFileContextMenuClick,
onCopy: handleCopy, // +++ 传递复制回调 +++
onCut: handleCut, // +++ 传递剪切回调 +++
onPaste: handlePaste, // +++ 传递粘贴回调 +++
onDownloadDirectory: triggerDownloadDirectory, // +++ 传递文件夹下载回调 +++
// +++ 传递压缩/解压回调 +++
onCompressRequest: handleCompress,
onDecompressRequest: handleDecompress,
onCopyPath: handleCopyPath, // +++ 传递复制路径回调 +++
});
// --- 目录加载与导航 ---
// loadDirectory is provided by props.sftpManager
// --- 拖放逻辑 (使用 Composable) ---
const {
// isDraggingOver, // 不再直接使用容器的悬停状态
showExternalDropOverlay, // 控制蒙版显示
dragOverTarget, // 行拖拽悬停目标 (内部)
// draggedItem, // 内部状态,不需要在 FileManager 中直接使用
// --- 事件处理器 ---
handleDragEnter,
handleDragOver, // 容器的 dragover (主要处理内部滚动)
handleDragLeave,
handleDrop, // 容器的 drop (主要用于清理)
handleOverlayDrop, // 蒙版的 drop
handleDragStart,
handleDragEnd,
handleDragOverRow,
handleDragLeaveRow,
handleDropOnRow,
} = useFileManagerDragAndDrop({
isConnected: computed(() => props.wsDeps.isConnected.value),
// 修改:传递 manager 的 currentPath (保持 computed)
currentPath: computed(() => currentSftpManager.value?.currentPath.value ?? '/'),
fileListContainerRef: fileListContainerRef,
// 修改:传递一个包装函数给 joinPath
joinPath: (base: string, target: string): string => {
return currentSftpManager.value?.joinPath(base, target) ?? `${base}/${target}`.replace(/\/+/g, '/'); // 提供简单的默认实现
},
onFileUpload: startFileUpload,
// 修改:确保在调用前检查 currentSftpManager.value
onItemMove: (item, newName) => {
currentSftpManager.value?.renameItem(item, newName);
},
selectedItems: selectedItems,
// 修改:传递 manager 的 fileList ref (保持 computed)
fileList: computed(() => currentSftpManager.value?.fileList.value ?? []),
});
// --- 文件上传逻辑 (handleFileSelected 保持在此处,由 triggerFileUpload 调用) ---
const handleFileSelected = (event: Event) => {
const input = event.target as HTMLInputElement;
// 恢复使用 props.wsDeps.isConnected
if (!input.files || !props.wsDeps.isConnected.value) return;
// --- 修正:使用匿名函数包装 startFileUpload 调用 ---
Array.from(input.files).forEach(file => startFileUpload(file)); // 只传递 file 参数
// --- 结束修正 ---
input.value = '';
};
// --- 键盘导航逻辑 (使用 Composable) ---
const {
selectedIndex, // 使用 Composable 返回的 selectedIndex
handleKeydown, // 使用 Composable 返回的 handleKeydown
} = useFileManagerKeyboardNavigation({
filteredFileList: filteredFileList,
// 修改:传递 manager 的 currentPath ref
currentPath: computed(() => currentSftpManager.value?.currentPath.value ?? '/'),
fileListContainerRef: fileListContainerRef,
// 当 Enter 键按下时,模拟鼠标单击
onEnterPress: (item) => handleItemClick(new MouseEvent('click'), item),
});
// --- 重置选中索引和清空选择的 Watchers ---
// 修改:监听 manager 的 currentPath
watch(() => currentSftpManager.value?.currentPath.value, () => {
selectedIndex.value = -1;
clearSelection();
});
watch(searchQuery, () => {
selectedIndex.value = -1;
clearSelection(); // 清空选择
});
watch(sortKey, () => {
selectedIndex.value = -1;
clearSelection(); // 清空选择
});
watch(sortDirection, () => {
selectedIndex.value = -1;
clearSelection(); // 清空选择
});
// --- 保存设置的函数 ---
const saveLayoutSettings = () => {
// 确保 colWidths.value 是普通对象,而不是 Proxy
const widthsToSave = JSON.parse(JSON.stringify(colWidths.value));
// +++ 日志:记录保存的值 +++
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Triggering saveLayoutSettings: multiplier=${rowSizeMultiplier.value}, widths=${JSON.stringify(widthsToSave)}`);
settingsStore.updateFileManagerLayoutSettings(rowSizeMultiplier.value, widthsToSave);
};
// --- 生命周期钩子 ---
onMounted(() => {
// --- 移除 onMounted 中的加载逻辑 ---
// Initial load logic is handled by watchEffect below and the main sftp loading watchEffect
});
// +++ 使用 watchEffect 响应式地加载和应用布局设置 +++
watchEffect(() => {
// 检查 store 中的值是否有效 (避免在 store 加载完成前使用默认值覆盖本地 ref)
// fileManagerColWidthsObject 初始可能是空对象 {},需要检查其是否有键
const storeMultiplier = fileManagerRowSizeMultiplierNumber.value;
const storeWidths = fileManagerColWidthsObject.value;
// +++ 日志:记录从 store 获取的值 +++
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] watchEffect triggered. Store values: multiplier=${storeMultiplier}, widths=${JSON.stringify(storeWidths)}`);
// 只有当 store 加载完成并提供了有效值时才更新
// 假设 store 加载完成后 multiplier > 0 且 widths 对象有内容
if (storeMultiplier > 0 && Object.keys(storeWidths).length > 0) {
const currentMultiplier = rowSizeMultiplier.value;
const currentWidthsString = JSON.stringify(colWidths.value);
const storeWidthsString = JSON.stringify(storeWidths);
// +++ 日志:记录当前值和 store 值,以及是否更新 +++
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Comparing values: Current Multiplier=${currentMultiplier}, Store Multiplier=${storeMultiplier}. Update needed: ${storeMultiplier !== currentMultiplier}`);
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Comparing values: Current Widths=${currentWidthsString}, Store Widths=${storeWidthsString}. Update needed: ${storeWidthsString !== currentWidthsString}`);
// 仅在值不同时更新,避免不必要的重渲染和潜在的循环更新
if (storeMultiplier !== currentMultiplier) {
rowSizeMultiplier.value = storeMultiplier;
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Row size multiplier updated from store: ${storeMultiplier}`);
}
if (storeWidthsString !== currentWidthsString) {
// --- 修改:合并 storeWidths 到 colWidths.value ---
// 确保 colWidths.value 的所有键都存在,并用 store 的值更新(如果存在且有效)
const updatedWidths = { ...colWidths.value }; // 创建当前值的副本
for (const key in updatedWidths) {
if (storeWidths[key] !== undefined && typeof storeWidths[key] === 'number' && storeWidths[key] > 0) {
updatedWidths[key as keyof typeof updatedWidths] = storeWidths[key];
}
}
colWidths.value = updatedWidths; // 赋值更新后的对象
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Column widths updated from store: ${JSON.stringify(updatedWidths)}`);
}
} else {
// +++ 日志:记录等待 store 加载 +++
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Waiting for valid layout settings from store... Store Multiplier=${storeMultiplier}, Store Widths Keys=${Object.keys(storeWidths).length}`);
}
});
// 使用 watchEffect 监听连接和 SFTP 就绪状态以触发初始加载
// 恢复使用 props.wsDeps
watchEffect((onCleanup) => {
let unregisterSuccess: (() => void) | undefined;
let unregisterError: (() => void) | undefined;
let timeoutId: NodeJS.Timeout | number | undefined; // 修正类型以兼容 Node 和浏览器环境
const cleanupListeners = () => {
unregisterSuccess?.();
unregisterError?.();
if (timeoutId) clearTimeout(timeoutId);
// isFetchingInitialPath 状态移除
};
onCleanup(cleanupListeners);
// 修改:添加 ?. 访问 isLoading, 检查 manager 的 initialLoadDone
// 只有在连接就绪、SFTP 就绪、管理器存在、未加载且 initialLoadDone 为 false 时才获取初始路径
if (currentSftpManager.value && props.wsDeps.isConnected.value && props.wsDeps.isSftpReady.value && !currentSftpManager.value.isLoading.value && !currentSftpManager.value.initialLoadDone.value) {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Connection ready for manager, fetching initial path for the first time (isLoading: ${currentSftpManager.value.isLoading.value}, initialLoadDone: ${currentSftpManager.value.initialLoadDone.value}).`);
// isFetchingInitialPath 状态移除, 使用 isLoading 状态
// 仍然使用 props.wsDeps 中的 sendMessage 和 onMessage
const { sendMessage: wsSend, onMessage: wsOnMessage } = props.wsDeps;
const requestId = generateRequestId(); // 使用本地辅助函数
const requestedPath = '.';
unregisterSuccess = wsOnMessage('sftp:realpath:success', (payload: any, message: WebSocketMessage) => { // message 已有类型
if (message.requestId === requestId && payload.requestedPath === requestedPath) {
// 修改:检查 currentSftpManager 是否存在
if (!currentSftpManager.value) return;
const absolutePath = payload.absolutePath;
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Received initial absolute path for '.': ${absolutePath}. Loading directory.`);
// 修改:添加 ?. 访问 loadDirectory 和 setInitialLoadDone
currentSftpManager.value?.loadDirectory(absolutePath);
currentSftpManager.value?.setInitialLoadDone(true); // 设置 manager 内部状态
cleanupListeners();
}
});
unregisterError = wsOnMessage('sftp:realpath:error', (payload: any, message: WebSocketMessage) => { // message 已有类型
// 修改:使用 payload.requestedPath (如果存在) 或 message.requestId 匹配
if (message.requestId === requestId && payload?.requestedPath === requestedPath) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to get realpath for '${requestedPath}':`, payload);
// TODO: 可以考虑通过 manager instance 暴露错误状态
// 目前仅记录日志。
// 即使获取 realpath 失败,也标记初始加载尝试完成,避免重复尝试
currentSftpManager.value?.setInitialLoadDone(true);
cleanupListeners();
}
});
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Sending initial sftp:realpath request (ID: ${requestId}) for path: ${requestedPath}`);
wsSend({ type: 'sftp:realpath', requestId: requestId, payload: { path: requestedPath } });
timeoutId = setTimeout(() => {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Timeout getting initial realpath for '.' (ID: ${requestId}).`);
// 超时也标记初始加载尝试完成
currentSftpManager.value?.setInitialLoadDone(true);
cleanupListeners();
}, 10000); // 10 秒超时
} else if (currentSftpManager.value && props.wsDeps.isConnected.value && props.wsDeps.isSftpReady.value && currentSftpManager.value.initialLoadDone.value) {
// 连接恢复,并且之前已经加载过 (initialLoadDone is true)
// 显式地重新加载管理器中记录的当前路径,以防内部状态被重置
const pathBeforeReconnect = currentSftpManager.value.currentPath.value;
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Connection re-established. Explicitly reloading previous path: ${pathBeforeReconnect}`);
// 检查是否正在加载,避免并发请求
if (!currentSftpManager.value.isLoading.value) {
// 使用 false 参数可能表示非强制刷新,如果 SFTP 管理器支持的话
// 主要目的是确保视图与管理器状态同步到重连前的路径
currentSftpManager.value.loadDirectory(pathBeforeReconnect, false);
} else {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] SFTP manager is currently loading, skipping explicit path reload on reconnect.`);
}
cleanupListeners(); // 清理可能存在的旧监听器
} else if (!props.wsDeps.isConnected.value && currentSftpManager.value?.initialLoadDone.value) { // 检查 manager 的 initialLoadDone
// 连接丢失,不需要重置 initialLoadDone,因为我们希望在重连时恢复状态
// 只需要清理监听器
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Connection lost (was previously loaded).`);
// clearSelection(); // 可以在连接丢失时不清空选择,看产品需求
// currentSftpManager.value?.setInitialLoadDone(false); // 不再重置,保持状态
cleanupListeners();
}
});
// +++ 监听 Store 中的触发器以激活搜索 +++
watch(() => focusSwitcherStore.activateFileManagerSearchTrigger, (newValue, oldValue) => { // 修改监听器
// 确保只在触发器值增加时执行(避免初始加载或重置时触发)
// 并且当前组件的 sessionId 与活动 sessionId 匹配
// 检查 newValue > oldValue 确保是递增触发,避免重复执行
// 检查是否是当前活动会话的此实例(如果需要区分实例)
// 目前假设搜索触发器对会话内的所有 FileManager 生效
if (newValue > (oldValue ?? 0) && props.sessionId === sessionStore.activeSessionId) {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Received search activation trigger for active session.`);
activateSearch(); // 调用组件内部的激活搜索方法
}
}, { immediate: false }); // 添加 immediate: false 避免初始值为 0 时触发
// --- 监听 sessionId prop 的变化 ---
watch(() => props.sessionId, (newSessionId, oldSessionId) => {
if (newSessionId && newSessionId !== oldSessionId) {
closePathHistory(); // 关闭可能打开的路径历史下拉菜单
pathHistoryStore.setSearchTerm(''); // 清空搜索词
// 1. 重新初始化 SFTP 管理器
initializeSftpManager(newSessionId, props.instanceId);
// 2. 重置 UI 状态
clearSelection();
searchQuery.value = '';
isSearchActive.value = false;
isEditingPath.value = false;
sortKey.value = 'filename'; // 重置排序
sortDirection.value = 'asc';
}
}, { immediate: false }); // immediate: false 避免初始挂载时触发
// +++ 注册/注销自定义聚焦动作 +++
let unregisterSearchFocusAction: (() => void) | null = null; // 搜索框注销函数
let unregisterPathFocusAction: (() => void) | null = null; // 路径编辑框注销函数
onMounted(() => {
// 注册搜索框聚焦动作
const focusSearchActionWrapper = async (): Promise<boolean | undefined> => {
if (props.sessionId === sessionStore.activeSessionId) {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Executing search focus action for active session.`);
closePathHistory(); // Close path history if open
return focusSearchInput();
} else {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Search focus action skipped for inactive session.`);
return undefined;
}
};
unregisterSearchFocusAction = focusSwitcherStore.registerFocusAction('fileManagerSearch', focusSearchActionWrapper);
// 注册路径编辑框聚焦动作
const focusPathActionWrapper = async (): Promise<boolean | undefined> => {
if (props.sessionId === sessionStore.activeSessionId) {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Executing path edit focus action for active session.`);
// startPathEdit 本身不是 async,但注册时需要包装成 async 以匹配类型
startPathEdit(); // 调用暴露的方法
return true;
} else {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Path edit focus action skipped for inactive session.`);
return undefined;
}
};
unregisterPathFocusAction = focusSwitcherStore.registerFocusAction('fileManagerPathInput', focusPathActionWrapper);
document.addEventListener('click', handleClickOutsidePathInput);
});
onBeforeUnmount(() => {
// 注销搜索框动作
if (unregisterSearchFocusAction) {
unregisterSearchFocusAction();
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Unregistered search focus action on unmount.`);
}
unregisterSearchFocusAction = null;
// 注销路径编辑框动作
if (unregisterPathFocusAction) {
unregisterPathFocusAction();
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Unregistered path edit focus action on unmount.`);
}
unregisterPathFocusAction = null;
document.removeEventListener('click', handleClickOutsidePathInput);
sessionStore.removeSftpManager(props.sessionId, props.instanceId);
});
// +++ 监听蒙版可见性,动态调整高度 +++
watch(showExternalDropOverlay, (isVisible) => {
if (isVisible) {
nextTick(() => { // 确保 refs 可用且 scrollHeight 已计算
if (dropOverlayRef.value && fileListContainerRef.value) {
const scrollHeight = fileListContainerRef.value.scrollHeight;
dropOverlayRef.value.style.height = `${scrollHeight}px`;
}
});
} else {
// 蒙版隐藏时重置高度
if (dropOverlayRef.value) {
dropOverlayRef.value.style.height = ''; // 移除内联样式
// console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Overlay hidden. Resetting height.`);
}
}
});
// --- 列宽调整逻辑 (保持不变) ---
const getColumnKeyByIndex = (index: number): keyof typeof colWidths.value | null => {
const keys = Object.keys(colWidths.value) as Array<keyof typeof colWidths.value>;
return keys[index] ?? null;
};
const startResize = (event: MouseEvent, index: number) => {
event.stopPropagation();
event.preventDefault();
isResizing.value = true;
resizingColumnIndex.value = index;
startX.value = event.clientX;
const colKey = getColumnKeyByIndex(index);
if (colKey) {
startWidth.value = colWidths.value[colKey];
} else {
const thElement = (event.target as HTMLElement).closest('th');
startWidth.value = thElement?.offsetWidth ?? 100;
}
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
const handleResize = (event: MouseEvent) => {
if (!isResizing.value || resizingColumnIndex.value < 0) return;
const currentX = event.clientX;
const diffX = currentX - startX.value;
const newWidth = Math.max(30, startWidth.value + diffX);
const colKey = getColumnKeyByIndex(resizingColumnIndex.value);
if (colKey) {
colWidths.value[colKey] = newWidth;
}
};
const stopResize = () => {
if (isResizing.value) {
isResizing.value = false;
resizingColumnIndex.value = -1;
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// +++ 在调整结束后保存列宽 +++
// +++ 日志:记录触发保存 +++
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] stopResize triggered saveLayoutSettings.`);
saveLayoutSettings();
}
};
// --- 路径编辑逻辑 (包含路径历史) ---
const openPathHistory = () => {
showPathHistoryDropdown.value = true; // 总是尝试显示下拉框
// 如果列表为空,则尝试获取历史记录。
// pathHistoryStore.fetchHistory() 应该能够处理未连接时 apiClient 的失败。
if (pathHistoryStore.historyList.length === 0) {
pathHistoryStore.fetchHistory();
}
// 总是设置搜索词,以便即使历史记录是旧的或空的,也能基于当前输入进行过滤或显示。
pathHistoryStore.setSearchTerm(editablePath.value);
};
const closePathHistory = () => {
showPathHistoryDropdown.value = false;
pathHistoryStore.resetSelection();
};
const handlePathInputFocus = () => {
isEditingPath.value = true; // Keep existing behavior
if (!currentSftpManager.value || currentSftpManager.value.isLoading.value || !props.wsDeps.isConnected.value) return;
editablePath.value = currentSftpManager.value.currentPath.value; // Set editable path on focus
openPathHistory();
nextTick(() => {
pathInputRef.value?.select();
});
};
const handlePathInputChange = () => {
if (showPathHistoryDropdown.value) {
pathHistoryStore.setSearchTerm(editablePath.value);
}
};
const navigateToPath = async (path: string) => {
if (!currentSftpManager.value || !path || path.trim().length === 0) return;
const trimmedPath = path.trim();
isEditingPath.value = false;
closePathHistory();
if (trimmedPath === currentSftpManager.value.currentPath.value) {
return;
}
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] 尝试导航到新路径: ${trimmedPath}`);
try {
await currentSftpManager.value.loadDirectory(trimmedPath);
// 如果 loadDirectory 没有抛出错误,我们认为它成功了
pathHistoryStore.addPath(trimmedPath); // 导航成功后添加到历史
editablePath.value = trimmedPath; // 更新输入框内容
} catch (error) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] 导航到路径 ${trimmedPath} 失败:`, error);
// 导航失败,不添加到历史记录,也不更新输入框内容 (除非有特定需求)
}
};
const handlePathInputKeydown = (event: KeyboardEvent) => {
if (!showPathHistoryDropdown.value) {
if (event.key === 'Enter') {
navigateToPath(editablePath.value);
} else if (event.key === 'Escape') {
cancelPathEdit();
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
pathHistoryStore.selectNextPath();
// Dropdown component handles scrolling
break;
case 'ArrowUp':
event.preventDefault();
pathHistoryStore.selectPreviousPath();
// Dropdown component handles scrolling
break;
case 'Enter':
event.preventDefault();
if (pathSelectedIndex.value >= 0 && filteredPathHistory.value[pathSelectedIndex.value]) {
navigateToPath(filteredPathHistory.value[pathSelectedIndex.value].path);
} else {
navigateToPath(editablePath.value);
}
closePathHistory();
break;
case 'Escape':
event.preventDefault();
closePathHistory();
// Keep isEditingPath true to allow user to continue editing or blur
break;
}
};
const handlePathSelectedFromDropdown = (path: string) => {
editablePath.value = path; // Update input field
navigateToPath(path); // Navigate and add to history
closePathHistory();
};
const startPathEdit = () => {
if (!currentSftpManager.value || currentSftpManager.value.isLoading.value || !props.wsDeps.isConnected.value) return;
editablePath.value = currentSftpManager.value.currentPath.value;
isEditingPath.value = true;
openPathHistory(); // 打开历史记录
nextTick(() => {
pathInputRef.value?.focus();
pathInputRef.value?.select();
});
};
// Modified to handle path history logic
const handlePathInput = async (event?: Event | FocusEvent) => {
// This function is now primarily for blur handling or if Enter is pressed outside keydown.
// Most Enter logic is in handlePathInputKeydown.
if (event && event instanceof KeyboardEvent && event.key !== 'Enter') {
// If it's a key event but not Enter, it's handled by keydown or change.
return;
}
if (event && event.type === 'blur') {
setTimeout(() => {
const activeEl = document.activeElement;
const dropdownEl = pathHistoryDropdownRef.value?.$el;
if (dropdownEl && dropdownEl.contains(activeEl)) {
// Focus is within the dropdown, do nothing yet
return;
}
if (pathInputRef.value !== activeEl) {
isEditingPath.value = false;
closePathHistory();
}
}, 150);
return;
}
if (!currentSftpManager.value) return;
const newPath = editablePath.value.trim();
// Check if dropdown has a selection, if so, it should have been handled by Enter in keydown
if (pathSelectedIndex.value >= 0 && filteredPathHistory.value[pathSelectedIndex.value]) {
// This case should ideally not be hit if keydown is working correctly
navigateToPath(filteredPathHistory.value[pathSelectedIndex.value].path);
} else {
navigateToPath(newPath);
}
isEditingPath.value = false; // Ensure editing mode is exited
closePathHistory(); // Ensure dropdown is closed
};
const cancelPathEdit = () => {
isEditingPath.value = false;
closePathHistory();
// Optionally, revert editablePath to currentSftpManager.currentPath.value
if (currentSftpManager.value) {
editablePath.value = currentSftpManager.value.currentPath.value;
}
};
const handleClickOutsidePathInput = (event: MouseEvent) => {
if (pathInputWrapperRef.value && !pathInputWrapperRef.value.contains(event.target as Node)) {
if (isEditingPath.value || showPathHistoryDropdown.value) {
isEditingPath.value = false;
closePathHistory();
}
}
};
// --- 搜索框激活/取消逻辑 ---
const activateSearch = () => {
isSearchActive.value = true;
nextTick(() => {
searchInputRef.value?.focus();
});
};
const deactivateSearch = () => {
isSearchActive.value = false;
};
const cancelSearch = () => {
searchQuery.value = ''; // 按 Esc 清空并失活
isSearchActive.value = false;
};
// --- 发送 CD 命令到终端的方法 ---
const sendCdCommandToTerminal = () => {
if (!currentSftpManager.value || !props.wsDeps.isConnected.value) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot send CD command: SFTP manager not ready or not connected.`);
return;
}
const currentPath = currentSftpManager.value.currentPath.value;
if (!currentPath) {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot send CD command: Current path is empty.`);
return;
}
// 路径可能包含空格,需要用引号括起来以确保在各种 shell 中正确处理
const escapedPath = `"${currentPath}"`;
// 添加换行符以模拟按下 Enter 键执行命令
const command = `cd ${escapedPath}\n`;
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Sending command to terminal: ${command.trim()}`);
try {
// 获取当前活动会话
const activeSession = sessionStore.activeSession;
if (!activeSession) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to send command: No active session found.`);
// 可选:添加 UI 通知
// uiNotificationsStore.addNotification({ message: t('fileManager.errors.noActiveSession', 'No active session found.'), type: 'error' });
return;
}
// 检查 terminalManager 是否存在
if (!activeSession.terminalManager) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to send command: Terminal manager not found for active session.`);
// 可选:添加 UI 通知
// uiNotificationsStore.addNotification({ message: t('fileManager.errors.terminalManagerNotFound', 'Terminal manager not found.'), type: 'error' });
return;
}
// 使用 terminalManager 的 sendData 方法发送命令
activeSession.terminalManager.sendData(command);
} catch (error) {
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to send command to terminal:`, error);
}
};
// --- 打开弹窗编辑器的方法 ---
const openPopupEditor = () => {
if (!props.sessionId) {
console.error('[FileManager] Cannot open popup editor: Missing session ID.');
// 可以添加 UI 通知
return;
}
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Triggering popup editor without specific file.`);
fileEditorStore.triggerPopup('', props.sessionId); // 修复:使用空字符串触发空编辑器
};
// --- 行大小调整逻辑 ---
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey) {
event.preventDefault(); // 阻止页面默认滚动行为
const delta = event.deltaY > 0 ? -0.05 : 0.05; // 滚轮向下减小,向上增大
// 限制字体大小乘数在 0.5 到 2 之间
const newMultiplier = Math.max(0.5, Math.min(2, rowSizeMultiplier.value + delta));
const oldMultiplier = rowSizeMultiplier.value;
rowSizeMultiplier.value = parseFloat(newMultiplier.toFixed(2)); // 保留两位小数避免浮点数问题
if (rowSizeMultiplier.value !== oldMultiplier) {
// +++ 日志:记录触发保存 +++
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] handleWheel triggered saveLayoutSettings.`);
saveLayoutSettings();
}
}
};
// +++ 聚焦搜索框的方法 +++
const focusSearchInput = (): boolean => {
// 检查当前会话是否激活,防止后台实例响应
if (props.sessionId !== sessionStore.activeSessionId) {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Ignoring focus request for inactive session.`);
return false;
}
if (!isSearchActive.value) {
activateSearch(); // Activate search first
// nextTick 确保 DOM 更新后再聚焦
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value.focus();
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Search activated and input focused.`);
} else {
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Search activated but input ref not found after nextTick.`);
}
});
return true; // 假设会成功
} else if (searchInputRef.value) {
searchInputRef.value.focus();
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Search already active, input focused.`);
return true;
}
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Could not focus search input.`);
return false;
};
defineExpose({ focusSearchInput, startPathEdit });
// --- 处理“打开编辑器”按钮点击 ---
const handleOpenEditorClick = () => {
if (!props.sessionId) {
console.error(`[FileManager ${props.instanceId}] Cannot open editor: Missing session ID.`);
// TODO: Show error notification to user
return;
}
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Triggering popup editor directly.`);
fileEditorStore.triggerPopup('', props.sessionId); // 修复:传递空字符串而不是 null
};
// +++ Favorite Paths Modal Logic +++
const toggleFavoritePathsModal = () => {
showFavoritePathsModal.value = !showFavoritePathsModal.value;
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Toggled FavoritePathsModal. Visible: ${showFavoritePathsModal.value}`);
};
const handleNavigateToPathFromFavorites = (path: string) => {
if (currentSftpManager.value) {
currentSftpManager.value.loadDirectory(path);
explorerExpandedPaths.value[path] = true;
}
showFavoritePathsModal.value = false; // Close modal after navigation
};
const toggleDirectoryPath = (path: string, currentExpanded = false) => {
const nextExpanded = !(explorerExpandedPaths.value[path] ?? currentExpanded);
explorerExpandedPaths.value[path] = nextExpanded;
if (nextExpanded && currentSftpManager.value) {
currentSftpManager.value.loadDirectory(path);
}
};
const focusDirectoryPath = (path: string) => {
explorerExpandedPaths.value[path] = true;
currentSftpManager.value?.loadDirectory(path);
};
const isPathActive = (path: string) => {
return currentSftpManager.value?.currentPath.value === path;
};
const handleExplorerToggle = (row: ExplorerTreeRow) => {
if (!row.isDirectory) {
return;
}
toggleDirectoryPath(row.path, row.expanded);
};
const handleExplorerOpen = (row: ExplorerTreeRow) => {
if (row.isDirectory) {
focusDirectoryPath(row.path);
return;
}
openFileInWorkspace(row.path, row.name);
};
const handleOverviewSectionOpen = (section: ExplorerOverviewSection) => {
focusDirectoryPath(section.path);
};
const handleOverviewRowToggle = (row: ExplorerOverviewRow) => {
toggleDirectoryPath(row.path, row.expanded);
};
const handleOverviewRowOpen = (row: ExplorerOverviewRow) => {
focusDirectoryPath(row.path);
};
const handleOverviewRefresh = (section: ExplorerOverviewSection) => {
explorerExpandedPaths.value[section.path] = true;
currentSftpManager.value?.loadDirectory(section.path, true);
};
const isExplorerRowActive = (row: ExplorerTreeRow) => {
return isPathActive(row.path);
};
const isExplorerRowRelated = (row: ExplorerTreeRow) => {
const currentPath = currentSftpManager.value?.currentPath.value;
if (!currentPath) {
return false;
}
if (row.path === '/') {
return true;
}
return currentPath === row.path || currentPath.startsWith(`${row.path}/`);
};
watch(
explorerRoots,
(roots) => {
roots.forEach((root) => {
if (explorerExpandedPaths.value[root.path] === undefined) {
explorerExpandedPaths.value[root.path] = true;
}
});
},
{ immediate: true },
);
</script>
<template>
<div class="flex flex-col h-full overflow-hidden bg-background text-foreground text-sm font-sans">
<div class="flex items-center justify-between flex-wrap gap-2 p-2 bg-header flex-shrink-0">
<!-- Wrapper for Path Actions and Path Bar -->
<div class="flex items-center gap-2 flex-grow min-w-0"> <!-- Added gap-2, flex-grow, min-w-0 -->
<!-- Path Actions -->
<div class="flex items-center flex-shrink-0"> <!-- Removed mr-auto -->
<!-- CD 到终端按钮 -->
<button
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
@click.stop="sendCdCommandToTerminal"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value || isEditingPath"
:title="t('fileManager.actions.cdToTerminal', 'Change terminal directory to current path')"
>
<i class="fas fa-terminal text-base"></i>
</button>
<!-- 刷新按钮 -->
<button
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
@click.stop="currentSftpManager?.loadDirectory(currentSftpManager?.currentPath?.value ?? '/', true)"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value || isEditingPath"
:title="t('fileManager.actions.refresh')"
>
<i class="fas fa-sync-alt text-base"></i>
</button>
<button
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
@click.stop="handleItemClick($event, { filename: '..', longname: '..', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value || currentSftpManager?.currentPath?.value === '/' || isEditingPath"
:title="t('fileManager.actions.parentDirectory')"
>
<i class="fas fa-arrow-up text-base"></i>
</button>
<!-- Search Area -->
<div class="flex items-center flex-shrink-0">
<button
v-if="!isSearchActive"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
@click.stop="activateSearch"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.searchPlaceholder')"
>
<i class="fas fa-search text-base"></i>
</button>
<div v-else class="relative flex items-center min-w-[150px] flex-shrink">
<i class="fas fa-search absolute left-2 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none"></i>
<input
ref="searchInputRef"
type="text"
v-model="searchQuery"
:placeholder="t('fileManager.searchPlaceholder')"
class="flex-grow bg-background border border-border rounded pl-7 pr-2 py-1 text-foreground text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary min-w-[10px] transition-colors duration-200"
data-focus-id="fileManagerSearch"
@blur="deactivateSearch"
@keyup.esc="cancelSearch"
@keydown.up.prevent="handleKeydown"
@keydown.down.prevent="handleKeydown"
@keydown.enter.prevent="handleKeydown"
/>
<!-- Optional: Clear button -->
<!-- <button @click="searchQuery = ''; searchInputRef?.focus()" v-if="searchQuery" class="absolute right-2 top-1/2 -translate-y-1/2 text-text-secondary hover:text-foreground">&times;</button> -->
</div>
</div>
<div class="relative flex-shrink-0">
<!-- Favorite Paths Button -->
<button
ref="favoritePathsButtonRef"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 hover:enabled:bg-black/10 hover:enabled:text-foreground"
@click="toggleFavoritePathsModal"
>
<i class="fas fa-star text-base"></i>
</button>
<!-- Favorite Paths Modal -->
<FavoritePathsModal
:is-visible="showFavoritePathsModal"
:trigger-element="favoritePathsButtonRef"
@close="showFavoritePathsModal = false"
@navigate-to-path="handleNavigateToPathFromFavorites"
/>
</div>
</div>
<div ref="pathInputWrapperRef" class="relative flex items-center bg-background border border-border rounded px-1.5 py-0.5"
:class="{ 'flex-grow min-w-0': isEditingPath || showPathHistoryDropdown, 'w-fit max-w-full': !isEditingPath && !showPathHistoryDropdown }">
<span v-show="!isEditingPath && !showPathHistoryDropdown" @click="startPathEdit" class="text-text-secondary pr-2 cursor-text truncate">
<strong
:title="t('fileManager.editPathTooltip')"
class="font-medium text-link px-1 rounded transition-colors duration-200"
:class="{
'hover:bg-black/5': currentSftpManager && props.wsDeps.isConnected.value,
'opacity-60 cursor-not-allowed': !currentSftpManager || !props.wsDeps.isConnected.value
}"
>
{{ currentSftpManager?.currentPath?.value ?? '/' }}
</strong>
</span>
<input
v-show="isEditingPath || showPathHistoryDropdown"
ref="pathInputRef"
type="text"
v-model="editablePath"
class="flex-grow bg-transparent text-foreground p-0.5 outline-none min-w-[100px]"
data-focus-id="fileManagerPathInput"
@focus="handlePathInputFocus"
@input="handlePathInputChange"
@keydown="handlePathInputKeydown"
@blur="handlePathInput"
/>
<PathHistoryDropdown
v-if="showPathHistoryDropdown"
ref="pathHistoryDropdownRef"
@pathSelected="handlePathSelectedFromDropdown"
@closeDropdown="closePathHistory"
class="left-0 right-0 top-full mt-1"
/>
</div>
</div> <!-- End Wrapper -->
<!-- Main Actions Bar -->
<div class="flex items-center gap-2 flex-shrink-0">
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
<!-- 打开编辑器按钮 -->
<button
v-if="showPopupFileEditorBoolean"
@click="openPopupEditor"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.openEditor', 'Open Popup Editor')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
>
<i class="far fa-edit text-sm"></i> <!-- 使用编辑图标 -->
<span v-if="!props.isMobile">{{ t('fileManager.actions.openEditor', 'Open Editor') }}</span> <!-- 添加 i18n key -->
</button>
<!-- 上传按钮 -->
<button
@click="triggerFileUpload"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.uploadFile')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
>
<i class="fas fa-upload text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.upload') }}</span>
</button>
<button
@click="handleNewFolderContextMenuClick"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.newFolder')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
>
<i class="fas fa-folder-plus text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.newFolder') }}</span>
</button>
<button
@click="handleNewFileContextMenuClick"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.newFile')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
>
<i class="far fa-file-alt text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.newFile') }}</span>
</button>
<!-- 多选模式切换按钮 (仅移动端) -->
<button
v-if="props.isMobile"
@click="toggleMultiSelectMode"
:title="isMultiSelectMode ? t('fileManager.actions.exitMultiSelect', 'Exit Multi-Select Mode') : t('fileManager.actions.multiSelect', 'Enter Multi-Select Mode')"
class="flex items-center gap-1 px-1.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
:class="{
'hover:bg-header hover:border-primary hover:text-primary': !isMultiSelectMode,
'bg-primary text-white border-primary': isMultiSelectMode
}"
>
<i class="fas fa-check-square text-sm"></i>
</button>
</div>
</div>
<div class="flex flex-grow min-h-0 overflow-hidden border-t border-border/60">
<aside class="w-[260px] flex-shrink-0 border-r border-border/60 bg-header/40 flex flex-col min-h-0">
<div class="px-3 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-2">
<div>
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
<div class="mt-1 text-xs text-text-secondary">{{ explorerRoots.length }} {{ t('fileManager.explorer.rootCount', '个根目录') }}</div>
</div>
<button
@click="toggleFavoritePathsModal"
class="w-8 h-8 rounded-lg border border-border bg-background text-text-secondary hover:bg-header hover:text-foreground transition-colors"
:title="t('favoritePaths.addNew', 'Add new favorite path')"
>
<i class="fas fa-plus text-xs"></i>
</button>
</div>
</div>
<div class="flex-1 min-h-0 overflow-y-auto px-2 py-2">
<div v-if="explorerRoots.length === 0" class="px-3 py-6 text-xs text-text-secondary text-center">
{{ t('fileManager.explorer.noRoots', '暂无目录根请先添加收藏路径或连接后浏览当前目录') }}
</div>
<div v-else class="space-y-1">
<div
v-for="row in explorerTreeRows"
:key="row.id"
:class="[
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors cursor-pointer',
isExplorerRowActive(row)
? 'bg-primary text-white border-primary shadow-sm'
: isExplorerRowRelated(row)
? 'border-primary/20 bg-primary/8 text-foreground'
: 'border-transparent text-text-secondary hover:bg-background hover:text-foreground'
]"
:style="{ paddingLeft: `${0.6 + row.depth * 0.85}rem` }"
@click="handleExplorerOpen(row)"
>
<button
v-if="row.isDirectory"
@click.stop="handleExplorerToggle(row)"
class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[10px]"
>
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</button>
<span v-else class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60">
<i class="fas fa-circle"></i>
</span>
<i
:class="[
row.isDirectory
? (row.isRoot ? 'fas fa-folder-tree' : 'fas fa-folder')
: getFileIconClassBase(row.name),
'w-4 text-center flex-shrink-0',
isExplorerRowActive(row) ? 'text-white' : (row.isDirectory ? 'text-primary' : 'text-text-secondary')
]"
></i>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium" :title="row.description || row.path">{{ row.name }}</div>
<div
v-if="row.isRoot"
class="truncate text-[10px]"
:class="isExplorerRowActive(row) ? 'text-white/75' : 'text-text-secondary/80'"
>
{{ row.description }}
</div>
</div>
</div>
</div>
</div>
</aside>
<!-- File List Container -->
<div
ref="fileListContainerRef"
class="flex-grow overflow-y-auto relative outline-none"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@click="fileListContainerRef?.focus()"
@keydown="handleKeydown"
@wheel="handleWheel"
@contextmenu.prevent="showContextMenu($event)"
tabindex="0"
:style="{ '--row-size-multiplier': rowSizeMultiplier }"
>
<!-- 外部文件拖拽蒙版 -->
<div
v-if="showExternalDropOverlay"
ref="dropOverlayRef"
class="absolute inset-0 flex items-center justify-center bg-black/70 text-white text-xl font-semibold rounded z-50 pointer-events-auto"
@dragover.prevent
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleOverlayDrop"
>
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
</div>
<div class="min-h-full p-4 md:p-5 space-y-4" :class="{ 'pointer-events-none': showExternalDropOverlay }">
<div class="rounded-2xl border border-border/60 bg-header/30 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">
{{ t('fileManager.explorer.overviewTitle', '文件夹总览') }}
</div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-foreground">
<span class="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1">
<i class="fas fa-crosshairs text-[11px] text-primary"></i>
<span class="truncate max-w-[420px]">{{ currentSftpManager?.currentPath?.value ?? '/' }}</span>
</span>
<span class="text-text-secondary text-xs">
{{ t('fileManager.explorer.overviewHint', '点击目录只展开和聚焦,不再切成单独目录列表。') }}
</span>
</div>
</div>
<div v-if="!currentSftpManager || currentSftpManager.isLoading.value" class="rounded-2xl border border-border/60 bg-background px-4 py-10 text-center text-text-secondary italic">
{{ t('fileManager.loading') }}
</div>
<div v-else-if="explorerOverviewSections.length === 0" class="rounded-2xl border border-border/60 bg-background px-4 py-10 text-center text-text-secondary italic">
{{ t('fileManager.explorer.noRoots', '暂无目录根请先添加收藏路径或连接后浏览当前目录') }}
</div>
<div v-else class="space-y-4">
<section
v-for="section in explorerOverviewSections"
:key="section.id"
class="rounded-2xl border border-border/60 bg-background/95 shadow-sm overflow-hidden"
>
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-header/35 px-4 py-3">
<button
class="min-w-0 flex items-center gap-3 text-left"
@click="handleOverviewSectionOpen(section)"
>
<i class="fas fa-folder-tree text-primary"></i>
<span class="min-w-0">
<span class="block truncate text-sm font-semibold text-foreground">{{ section.label }}</span>
<span class="block truncate text-[11px] text-text-secondary">{{ section.description }}</span>
</span>
</button>
<div class="flex items-center gap-2">
<span class="inline-flex items-center rounded-full border border-border/60 bg-background px-2.5 py-1 text-xs text-text-secondary">
{{ section.rowCount }} {{ t('fileManager.explorer.folderCount', '个文件夹') }}
</span>
<button
class="w-8 h-8 rounded-lg border border-border bg-background text-text-secondary hover:bg-header hover:text-foreground transition-colors"
:title="t('common.refresh', '刷新')"
@click="handleOverviewRefresh(section)"
>
<i class="fas fa-sync-alt text-xs"></i>
</button>
</div>
</div>
<div v-if="section.rows.length === 0" class="px-4 py-6 text-sm text-text-secondary">
{{ t('fileManager.explorer.emptyFolders', '这个根目录下暂时没有已加载的子文件夹展开左侧目录可继续浏览') }}
</div>
<div v-else class="p-3 space-y-1">
<div
v-for="row in section.rows"
:key="row.id"
class="group flex items-center gap-3 rounded-xl border px-3 py-2 transition-colors"
:class="isPathActive(row.path) ? 'border-primary bg-primary/10 text-foreground' : 'border-transparent text-text-secondary hover:border-border/60 hover:bg-header/40 hover:text-foreground'"
:style="{ paddingLeft: `${0.9 + row.depth * 1.1}rem` }"
>
<button
class="w-5 h-5 flex items-center justify-center flex-shrink-0 text-[10px]"
@click.stop="handleOverviewRowToggle(row)"
>
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</button>
<button class="min-w-0 flex items-center gap-3 flex-1 text-left" @click="handleOverviewRowOpen(row)">
<i class="fas fa-folder w-4 text-center text-primary flex-shrink-0"></i>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ row.name }}</span>
<span class="block truncate text-[11px]" :class="isPathActive(row.path) ? 'text-primary/80' : 'text-text-secondary/80'">
{{ row.path }}
</span>
</span>
</button>
<span class="inline-flex items-center rounded-full border border-current/10 bg-black/5 px-2 py-0.5 text-[11px] flex-shrink-0">
{{ row.childDirectoryCount }}
</span>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
<!-- 使用 FileUploadPopup 组件 -->
<FileUploadPopup :uploads="uploads" @cancel-upload="cancelUpload" />
<FileManagerContextMenu
ref="contextMenuRef"
:is-visible="contextMenuVisible"
:position="contextMenuPosition"
:items="contextMenuItems"
:active-context-item="contextTargetItem"
:selected-file-items="computedSelectedFullItems"
:current-directory-path="currentSftpManager?.currentPath?.value ?? '/'"
@close-request="hideContextMenu"
/>
<!-- Action Modal -->
<FileManagerActionModal
:is-visible="isActionModalVisible"
:action-type="currentActionType"
:item="actionItem"
:items="actionItems"
:initial-value="actionInitialValue"
@close="handleModalClose"
@confirm="handleModalConfirm"
/>
<!-- Favorite Paths Modal is now positioned near its button -->
</div>
</template>
<style scoped>
/* Scoped styles removed for Tailwind CSS refactoring */
</style>