fix(frontend): 修复文件树上传落点与根目录补全

修正外部拖拽上传的目标路径判定,按当前悬停目录上传,
目录拖拽继续走压缩后上传链路,避免文件落到错误位置

补齐新会话初始化时根目录与当前目录并发加载的竞态处理,
先完成 `/` 根树引导再继续目标目录加载,避免根节点缺少
同级目录且不影响当前工作目录状态
This commit is contained in:
yinjianm
2026-03-26 03:15:18 +08:00
parent 3d26bffc99
commit 1a326cc01f
11 changed files with 497 additions and 128 deletions
+3
View File
@@ -26,6 +26,9 @@
- 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/)
### 快速修改
- **[frontend]**: 修正外部拖拽上传的落点路径判定,拖到哪个目录就上传到哪个目录,拖拽目录仍沿用先压缩再上传 — by yinjianm
- 类型: 快速修改(无方案包)
- 文件: packages/frontend/src/components/FileManager.vue, packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts, packages/frontend/src/composables/file-manager/useFolderArchiveUpload.ts, packages/frontend/src/composables/useFileUploader.ts
- **[backend]**: 将后端包版本元数据同步提升到 `1.0.0`,与根工作区和其余主包保持一致 — by yinjianm
- 类型: 快速修改(无方案包)
- 文件: packages/backend/package.json, packages/backend/package-lock.json, package-lock.json
+1 -1
View File
@@ -36,7 +36,7 @@
### 工作区交互
**条件**: 用户进入 `/workspace` 或相关管理页面。
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue``QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}``${{time}}``${{timestamp}}``${{week}}``${{uuid}}``${{random:8}}``${{clipboard}}``${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button``text-button-text``hover:bg-button-hover``hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复,并在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex``TerminalTabBar.vue` 则进一步把连续同连接会话渲染成“服务器组头 + 终端子标签 + 组尾新增按钮”,全局 `+` 只负责选择其他服务器,从而让“单连接默认 1 个终端、可继续追加多个终端”的关系在顶部标签栏里更接近参考图;`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);`FileManager.vue` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包,从而显著降低小文件很多时的扫描与上传耗时;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue``QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}``${{time}}``${{timestamp}}``${{week}}``${{uuid}}``${{random:8}}``${{clipboard}}``${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button``text-button-text``hover:bg-button-hover``hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复,并在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex``TerminalTabBar.vue` 则进一步把连续同连接会话渲染成“服务器组头 + 终端子标签 + 组尾新增按钮”,全局 `+` 只负责选择其他服务器,从而让“单连接默认 1 个终端、可继续追加多个终端”的关系在顶部标签栏里更接近参考图;`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);`FileManager.vue` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包;外部拖拽文件或目录上传时,则会按鼠标当前悬停的目录作为目标路径,其中目录同样走“先压缩再上传”的路径,从而显著降低小文件很多时的扫描与上传耗时;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
**结果**: 页面逻辑分散在 `views/``components/``stores/``composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts``session/actions/sessionActions.ts``session/getters.ts``TerminalTabBar.vue``WorkspaceView.vue``Terminal.vue` 与相关 locale 文件。
### 仪表盘总览
@@ -0,0 +1 @@
{"status":"in_progress","completed":0,"failed":0,"pending":3,"total":3,"done":0,"percent":0,"current":"准备进入开发实施:修复新会话下根目录同级树补全","updated_at":"2026-03-26 03:10:00"}
@@ -0,0 +1,123 @@
# 变更提案: file-manager-root-sibling-bootstrap
## 元信息
```yaml
类型: 修复
方案类型: implementation
优先级: P1
状态: 草稿
创建: 2026-03-26
```
---
## 1. 需求
### 背景
工作台文件区近期已调整为固定 `/` 根节点树,但用户反馈在新开终端连接后,`/` 展开时仍只显示 `root`,没有补齐 `/` 下其它同级目录。结合现场代码,文件区启动时会同时涉及 `/` 根目录预加载和当前工作目录(如 `/root`)加载,这可能导致 `/` 的目录响应被当作过时请求忽略,从而树里只保留当前路径链路。
### 目标
- 保持当前工作目录(如 `/root`)仍是当前操作目录。
- 在新会话初始化后,`/` 展开时补齐 `/` 下其它同级目录,而不只显示 `root`
- 避免为了补树而把当前目录错误切回 `/`
### 约束条件
```yaml
范围约束: 优先限制在 FileManager.vue / useSftpActions.ts,避免牵涉后端接口
交互约束: 当前工作目录保持不变,文件树补全属于后台树状态修正
兼容约束: 已有目录展开、路径输入、右键菜单和上传能力不回退
风险约束: 不应把所有过时 readdir 响应都重新接受,只修正根目录初始化场景
```
### 验收标准
- [ ] 新开终端连接后,`/` 根节点展开能显示 `root` 外的同级目录
- [ ] 当前操作目录仍保持在实际工作目录(如 `/root`),不会因补树自动跳回 `/`
- [ ] 前端构建通过
---
## 2. 方案
### 技术方案
`useSftpActions.ts``readdir` 成功处理里,为 `/` 根目录引入一次性“后台树补全”容忍逻辑:若 `/` 的响应因为当前路径请求更晚而变成 stale,但根树尚未加载,则仍允许把 `/` 的子节点合并进 `fileTree`,同时不改 `currentPath``isLoading` 和当前请求指针。这样既保留 `/root` 作为当前工作目录,又能把 `/` 下的一级同级目录补齐到树中。
### 影响范围
```yaml
涉及模块:
- frontend: `packages/frontend/src/composables/useSftpActions.ts`
预计变更文件: 1-3
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 误把真正过时的目录响应写回树,造成状态回退 | 中 | 仅对 `path === '/'` 且根树尚未加载的初始化场景放行 |
| 根树补全后 currentPath 被错误覆盖为 `/` | 中 | stale 根响应只合并树,不更新 currentPath / isLoading |
| 修复逻辑不足以覆盖其它层级目录的竞态 | 低 | 本轮只解决用户明确报告的 `/` 同级目录缺失问题 |
---
## 3. 技术设计(可选)
### 架构设计
```mermaid
flowchart LR
A[loadDirectory('/')] --> B[readdir success for '/']
C[loadDirectory('/root')] --> D[readdir success for '/root']
B --> E[background root tree bootstrap]
D --> F[currentPath stays /root]
```
### 数据模型
| 字段 | 类型 | 说明 |
|------|------|------|
| `loadingRequestId` | `string \| null` | 当前前台加载请求 ID |
| `fileTree.childrenLoaded` | `boolean` | `/` 根节点是否已完成首轮树加载 |
| `currentPathRef` | `Ref<string>` | 当前操作目录,不应被 stale 根响应覆盖 |
---
## 4. 核心场景
### 场景: 新会话进入 `/root`,同时补齐 `/` 的一级兄弟目录
**模块**: frontend
**条件**: 新终端连接建立后,文件区需要同时知道当前工作目录和 `/` 根树。
**行为**: `/root` 继续作为当前工作目录加载;若 `/` 的目录响应稍后到达且根树尚未完成初始化,仍允许把 `/` 的一级目录合并进树,但不改变当前目录。
**结果**: 用户在树里能看到 `/` 下多个同级目录,同时右上方当前路径仍维持在 `/root`
---
## 5. 技术决策
### file-manager-root-sibling-bootstrap#D001: 仅对 stale 的 `/` 根目录响应做树补全放行,而不是全面接收所有 stale readdir 响应
**日期**: 2026-03-26
**状态**: ✅采纳
**背景**: 用户问题集中在新会话下 `/` 根树只剩当前路径链路;广义接收所有 stale 响应会带来状态回退风险。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 只对 `/` 且根树未加载的 stale 响应放行 | 改动最小,直接覆盖当前缺陷,风险可控 | 只修复根目录初始化场景 |
| B: 接收所有 stale readdir 响应并更新树 | 可能覆盖更多竞态 | 容易把旧目录状态写回,风险高 |
**决策**: 选择方案A
**理由**: 这次是已知初始化竞态,不需要把整个目录加载协议重写。先做可验证、可控的定点修复更稳妥。
**影响**: frontend
---
## 6. 成果设计
### 设计方向
- **美学基调**: N/A
- **记忆点**: N/A
- **参考**: 用户提供的新终端文件树截图
### 视觉要素
- **配色**: N/A
- **字体**: N/A
- **布局**: N/A
- **动效**: N/A
- **氛围**: N/A
### 技术约束
- **可访问性**: N/A
- **响应式**: N/A
@@ -0,0 +1,41 @@
# 任务清单: file-manager-root-sibling-bootstrap
```yaml
@feature: file-manager-root-sibling-bootstrap
@created: 2026-03-26
@status: pending
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 0 | 0 | 0 | 3 |
---
## 任务列表
### 1. 根目录同级补全修复
- [ ] 1.1 在 `packages/frontend/src/composables/useSftpActions.ts` 中定位新会话初始化下 `/` 与当前目录并发加载的竞态点 | depends_on: []
- [ ] 1.2 在 `packages/frontend/src/composables/useSftpActions.ts` 中实现 stale `/` 根目录响应的安全树补全逻辑,避免 currentPath 被改回 `/` | depends_on: [1.1]
### 2. 验证与同步
- [ ] 2.1 执行 `packages/frontend` 的构建验证,并同步知识库记录 | depends_on: [1.2]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-26 03:10 | DESIGN | completed | 已确认问题集中在 `/` 根树补全而非当前工作目录切换,修复以最小协议变更为主 |
---
## 执行备注
> 当前环境缺少可直接调用的 `python/py`,方案包通过模板降级方式手工创建。
+132 -42
View File
@@ -13,7 +13,7 @@ import { useFileManagerContextMenu, type ClipboardState, type CompressFormat } f
import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection';
import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop';
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
import { createFolderArchive } from '../composables/file-manager/useFolderArchiveUpload';
import { createFolderArchive, type FolderArchiveSource } from '../composables/file-manager/useFolderArchiveUpload';
import FileUploadPopup from './FileUploadPopup.vue';
import FileManagerContextMenu from './FileManagerContextMenu.vue';
import FileManagerActionModal from './FileManagerActionModal.vue';
@@ -153,6 +153,7 @@ const isFolderUploadBusy = ref(false);
const showFavoritePathsModal = ref(false);
const favoritePathsButtonRef = ref<HTMLButtonElement | null>(null); // Ref for the trigger button
const explorerExpandedPaths = ref<Record<string, boolean>>({});
const selectedExplorerPath = ref<string | null>(null);
// +++ Path History Refs +++
const showPathHistoryDropdown = ref(false);
@@ -284,6 +285,30 @@ const openFileInWorkspace = (filePath: string, filename: string) => {
}
};
const getItemAbsolutePath = (item: FileListItem): string => {
if (typeof item.longname === 'string' && item.longname.startsWith('/')) {
return item.longname;
}
return currentSftpManager.value?.joinPath(currentSftpManager.value.currentPath.value, item.filename) ?? item.filename;
};
const getParentPath = (path: string): string => {
if (!path || path === '/') {
return '/';
}
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
const lastSlashIndex = normalized.lastIndexOf('/');
return lastSlashIndex <= 0 ? '/' : normalized.slice(0, lastSlashIndex);
};
const createContextMenuItemFromTreeRow = (row: ExplorerTreeRow): FileListItem => ({
filename: row.name,
longname: row.path,
attrs: row.item.attrs,
});
const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
const rows: ExplorerTreeRow[] = [];
const rootNode = findTreeNodeByPath('/');
@@ -725,19 +750,36 @@ const handleModalConfirm = (value?: string) => {
switch (currentActionType.value) {
case 'delete':
if (actionItems.value.length > 0) {
manager.deleteItems(actionItems.value);
actionItems.value.forEach((item) => {
const targetPath = getItemAbsolutePath(item);
props.wsDeps.sendMessage({
type: item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink',
requestId: generateRequestId(),
payload: { path: targetPath },
});
});
selectedItems.value.clear(); // Clear selection after delete
}
break;
case 'rename':
if (actionItem.value && value && value !== actionItem.value.filename) {
manager.renameItem(actionItem.value, value);
const oldPath = getItemAbsolutePath(actionItem.value);
const newPath = value.startsWith('/') ? value : `${getParentPath(oldPath)}/${value}`.replace(/\/+/g, '/');
props.wsDeps.sendMessage({
type: 'sftp:rename',
requestId: generateRequestId(),
payload: { oldPath, newPath },
});
}
break;
case 'chmod':
if (actionItem.value && value && /^[0-7]{3,4}$/.test(value)) {
const newMode = parseInt(value, 8);
manager.changePermissions(actionItem.value, newMode);
props.wsDeps.sendMessage({
type: 'sftp:chmod',
requestId: generateRequestId(),
payload: { path: getItemAbsolutePath(actionItem.value), mode: 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
@@ -774,10 +816,14 @@ 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)
if (!props.wsDeps.isConnected.value) return;
const selectedListItems = Array.from(selectedItems.value)
.map(filename => currentSftpManager.value?.fileList.value.find((f: FileListItem) => f.filename === filename))
.filter((item): item is FileListItem => item !== undefined);
const itemsToDelete =
selectedListItems.length > 0
? selectedListItems
: (contextTargetItem.value ? [contextTargetItem.value] : []);
if (itemsToDelete.length === 0) return;
// 根据设置决定是否显示确认模态框
@@ -786,7 +832,14 @@ const handleDeleteSelectedClick = () => {
} else {
// 直接执行删除
if (currentSftpManager.value) {
currentSftpManager.value.deleteItems(itemsToDelete);
itemsToDelete.forEach((item) => {
const targetPath = getItemAbsolutePath(item);
props.wsDeps.sendMessage({
type: item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink',
requestId: generateRequestId(),
payload: { path: targetPath },
});
});
selectedItems.value.clear(); // Clear selection after delete
}
}
@@ -819,23 +872,33 @@ const handleNewFileContextMenuClick = () => {
// +++ 复制、剪切、粘贴处理函数 +++
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));
if (!currentSftpManager.value) return;
const selectedFileItems =
selectedItems.value.size > 0
? Array.from(selectedItems.value)
.map(filename => currentSftpManager.value?.fileList.value.find((f: FileListItem) => f.filename === filename))
.filter((item): item is FileListItem => item !== undefined)
: (contextTargetItem.value ? [contextTargetItem.value] : []);
if (selectedFileItems.length === 0) return;
clipboardSourcePaths.value = selectedFileItems.map((item) => getItemAbsolutePath(item));
clipboardState.value = { hasContent: true, operation: 'copy' };
clipboardSourceBaseDir.value = manager.currentPath.value; // 记录源目录
clipboardSourceBaseDir.value = getParentPath(clipboardSourcePaths.value[0] || currentSftpManager.value.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));
if (!currentSftpManager.value) return;
const selectedFileItems =
selectedItems.value.size > 0
? Array.from(selectedItems.value)
.map(filename => currentSftpManager.value?.fileList.value.find((f: FileListItem) => f.filename === filename))
.filter((item): item is FileListItem => item !== undefined)
: (contextTargetItem.value ? [contextTargetItem.value] : []);
if (selectedFileItems.length === 0) return;
clipboardSourcePaths.value = selectedFileItems.map((item) => getItemAbsolutePath(item));
clipboardState.value = { hasContent: true, operation: 'cut' };
clipboardSourceBaseDir.value = manager.currentPath.value; // 记录源目录
clipboardSourceBaseDir.value = getParentPath(clipboardSourcePaths.value[0] || currentSftpManager.value.currentPath.value); // 记录源目录
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Cut to clipboard:`, clipboardSourcePaths.value);
// 可选:添加 UI 通知
};
@@ -882,20 +945,18 @@ const triggerFolderUpload = () => {
folderInputRef.value?.click();
};
const getFolderUploadName = (files: File[]) => {
const firstRelativePath = files[0]?.webkitRelativePath || files[0]?.name || 'folder';
const getFolderUploadName = (files: Array<File | FolderArchiveSource>) => {
const firstItem = files[0];
const firstRelativePath = firstItem instanceof File
? firstItem.webkitRelativePath || firstItem.name || 'folder'
: firstItem?.relativePath || 'folder';
return firstRelativePath.split('/').filter(Boolean)[0] || 'folder';
};
const handleFolderSelected = async (event: Event) => {
const input = event.target as HTMLInputElement;
const files = input.files ? Array.from(input.files) : [];
input.value = '';
if (files.length === 0) {
return;
}
const startFolderArchiveUpload = async (
files: File[] | FolderArchiveSource[],
targetPath?: string,
) => {
if (!currentSftpManager.value || !props.wsDeps.isConnected.value) {
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'));
return;
@@ -929,6 +990,7 @@ const handleFolderSelected = async (event: Event) => {
displayName: folderName,
mode: 'folder-archive',
detail: t('fileManager.notifications.folderArchiveUploading', { count: entryCount }),
targetPath,
afterUpload: async ({ remotePath }) => {
if (!currentSftpManager.value) {
throw new Error(t('fileManager.errors.sftpManagerNotFound'));
@@ -960,6 +1022,18 @@ const handleFolderSelected = async (event: Event) => {
}
};
const handleFolderSelected = async (event: Event) => {
const input = event.target as HTMLInputElement;
const files = input.files ? Array.from(input.files) : [];
input.value = '';
if (files.length === 0) {
return;
}
await startFolderArchiveUpload(files);
};
// --- 下载触发器 (定义在此处,供 Composable 使用) ---
const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileListItem 数组
// 恢复使用 props.wsDeps.isConnected
@@ -986,7 +1060,7 @@ const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileList
return;
}
const downloadPath = currentSftpManager.value!.joinPath(currentSftpManager.value!.currentPath.value, item.filename);
const downloadPath = getItemAbsolutePath(item);
const downloadUrl = `/api/v1/sftp/download?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(downloadPath)}`;
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Triggering download for ${item.filename}: ${downloadUrl}`);
@@ -1029,7 +1103,7 @@ const triggerDownloadDirectory = (item: FileListItem) => {
return;
}
const directoryPath = currentSftpManager.value.joinPath(currentSftpManager.value.currentPath.value, item.filename);
const directoryPath = getItemAbsolutePath(item);
// 定义新的后端 API 端点 URL (稍后实现)
const downloadUrl = `/api/v1/sftp/download-directory?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(directoryPath)}`;
@@ -1115,7 +1189,7 @@ const handleDecompress = (item: FileListItem) => {
// +++ 复制路径到剪贴板 +++
const handleCopyPath = async (item: FileListItem) => {
if (!currentSftpManager.value) return;
const fullPath = currentSftpManager.value.joinPath(currentSftpManager.value.currentPath.value, item.filename);
const fullPath = getItemAbsolutePath(item);
try {
await navigator.clipboard.writeText(fullPath);
// 可选:显示成功通知
@@ -1149,10 +1223,10 @@ const getTargetPathForItem = (item?: FileListItem | null): string | null => {
}
if (item.attrs.isDirectory) {
return currentSftpManager.value.joinPath(currentPath, item.filename);
return getItemAbsolutePath(item);
}
return currentPath;
return getParentPath(getItemAbsolutePath(item));
};
const sendCdCommandToPath = (targetPath: string, sessionId?: string) => {
@@ -1267,13 +1341,13 @@ const {
// isDraggingOver, // 不再直接使用容器的悬停状态
showExternalDropOverlay, // 控制蒙版显示
dragOverTarget, // 行拖拽悬停目标 (内部)
externalDropTargetPath,
// draggedItem, // 内部状态,不需要在 FileManager 中直接使用
// --- 事件处理器 ---
handleDragEnter,
handleDragOver, // 容器的 dragover (主要处理内部滚动)
handleDragLeave,
handleDrop, // 容器的 drop (主要用于清理)
handleOverlayDrop, // 蒙版的 drop
handleDragStart,
handleDragEnd,
handleDragOverRow,
@@ -1288,7 +1362,8 @@ const {
joinPath: (base: string, target: string): string => {
return currentSftpManager.value?.joinPath(base, target) ?? `${base}/${target}`.replace(/\/+/g, '/'); // 提供简单的默认实现
},
onFileUpload: startFileUpload,
onFileUpload: (file, relativePath, targetPath) => startFileUpload(file, relativePath, { targetPath }),
onFolderUpload: startFolderArchiveUpload,
// 修改:确保在调用前检查 currentSftpManager.value
onItemMove: (item, newName) => {
currentSftpManager.value?.renameItem(item, newName);
@@ -1984,6 +2059,10 @@ const handleExplorerToggle = (row: ExplorerTreeRow) => {
toggleDirectoryPath(row.path, row.expanded);
};
const handleExplorerSelect = (row: ExplorerTreeRow) => {
selectedExplorerPath.value = row.path;
};
const handleExplorerOpen = (row: ExplorerTreeRow) => {
if (row.isDirectory) {
focusDirectoryPath(row.path);
@@ -1993,8 +2072,15 @@ const handleExplorerOpen = (row: ExplorerTreeRow) => {
openFileInWorkspace(row.path, row.name);
};
const handleExplorerContextMenu = (event: MouseEvent, row: ExplorerTreeRow) => {
selectedExplorerPath.value = row.path;
selectedItems.value.clear();
lastClickedIndex.value = -1;
showContextMenu(event, createContextMenuItemFromTreeRow(row));
};
const isExplorerRowActive = (row: ExplorerTreeRow) => {
return isPathActive(row.path);
return selectedExplorerPath.value === row.path;
};
const isExplorerRowRelated = (row: ExplorerTreeRow) => {
@@ -2250,20 +2336,22 @@ watch(
<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"
class="absolute inset-0 flex items-center justify-center bg-black/70 text-white text-xl font-semibold rounded z-50 pointer-events-none"
>
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
</div>
<div class="p-2 space-y-1" :class="{ 'pointer-events-none': showExternalDropOverlay }">
<div class="p-2 space-y-1">
<div
v-for="row in explorerTreeRows"
:key="row.id"
:data-drop-path="row.path"
:data-is-directory="row.isDirectory"
:class="[
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors cursor-pointer',
showExternalDropOverlay && externalDropTargetPath === row.path
? 'border-primary bg-primary/15 text-foreground'
: '',
isExplorerRowActive(row)
? 'bg-primary text-white border-primary shadow-sm'
: isExplorerRowRelated(row)
@@ -2271,7 +2359,9 @@ watch(
: 'border-transparent text-text-secondary hover:bg-background hover:text-foreground'
]"
:style="{ paddingLeft: `${0.6 + row.depth * 0.85}rem` }"
@click="handleExplorerOpen(row)"
@click="handleExplorerSelect(row)"
@dblclick="handleExplorerOpen(row)"
@contextmenu.prevent.stop="handleExplorerContextMenu($event, row)"
>
<button
v-if="row.isDirectory"
@@ -106,14 +106,17 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
event.preventDefault();
const targetItem = item || null;
const targetItemIndex = targetItem
? fileList.value.findIndex((f: FileListItem) => f.filename === targetItem.filename)
: -1;
const isExternalTreeItem = Boolean(targetItem && targetItemIndex === -1 && typeof targetItem.longname === 'string' && targetItem.longname.startsWith('/'));
// Adjust selection based on right-click target (逻辑保持不变)
if (targetItem && !event.ctrlKey && !event.metaKey && !event.shiftKey && !selectedItems.value.has(targetItem.filename)) {
if (targetItem && !isExternalTreeItem && !event.ctrlKey && !event.metaKey && !event.shiftKey && !selectedItems.value.has(targetItem.filename)) {
selectedItems.value.clear();
selectedItems.value.add(targetItem.filename);
// 使用传入的 fileList ref
const index = fileList.value.findIndex((f: FileListItem) => f.filename === targetItem.filename); // 添加类型
lastClickedIndex.value = index;
lastClickedIndex.value = targetItemIndex;
} else if (!targetItem) {
selectedItems.value.clear();
lastClickedIndex.value = -1;
@@ -1,5 +1,6 @@
import { ref, type Ref } from 'vue';
import type { FileListItem } from '../../types/sftp.types'; // 确保路径正确
import type { FolderArchiveSource } from './useFolderArchiveUpload';
// 定义 Composable 的输入参数类型
export interface UseFileManagerDragAndDropOptions {
@@ -12,7 +13,8 @@ export interface UseFileManagerDragAndDropOptions {
// 函数依赖
joinPath: (base: string, target: string) => string; // 路径拼接函数
onFileUpload: (file: File, relativePath?: string) => void; // 修改:触发文件上传的回调,增加相对路径
onFileUpload: (file: File, relativePath?: string, targetPath?: string) => void; // 修改:触发文件上传的回调,增加相对路径
onFolderUpload: (files: FolderArchiveSource[], targetPath?: string) => void | Promise<void>;
onItemMove: (sourceItem: FileListItem, newFullPath: string) => void; // 触发文件/文件夹移动的回调
}
@@ -23,6 +25,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
fileListContainerRef,
joinPath,
onFileUpload,
onFolderUpload,
onItemMove,
selectedItems, // 获取传入的 selectedItems
fileList, // 获取传入的 fileList
@@ -33,6 +36,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
const showExternalDropOverlay = ref(false); // 控制外部文件拖拽蒙版的显示
const draggedItem = ref<FileListItem | null>(null); // 内部拖拽时,被拖拽的项
const dragOverTarget = ref<string | null>(null); // 内部拖拽时,悬停的目标文件夹名称 (用于行高亮)
const externalDropTargetPath = ref<string | null>(null);
const scrollIntervalId = ref<number | null>(null); // 自动滚动计时器 ID
// --- 自动滚动常量 ---
@@ -47,6 +51,56 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
}
};
const resolveExternalDropTargetPath = (event: DragEvent): string => {
const hoveredElement = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
const dropTargetElement = hoveredElement?.closest('[data-drop-path]') as HTMLElement | null;
const targetPath = dropTargetElement?.dataset.dropPath;
const isDirectory = dropTargetElement?.dataset.isDirectory === 'true';
if (targetPath && isDirectory) {
return targetPath;
}
return currentPath.value;
};
const readAllDirectoryEntries = async (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> => {
const allEntries: FileSystemEntry[] = [];
while (true) {
const entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
if (entries.length === 0) {
break;
}
allEntries.push(...entries);
}
return allEntries;
};
const collectDroppedEntry = async (entry: FileSystemEntry, path = ''): Promise<FolderArchiveSource[]> => {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject);
});
return [{
file,
relativePath: `${path}${file.name}`,
}];
}
const directoryEntry = entry as FileSystemDirectoryEntry;
const childEntries = await readAllDirectoryEntries(directoryEntry.createReader());
const childBasePath = `${path}${directoryEntry.name}/`;
const nestedFiles = await Promise.all(childEntries.map((childEntry) => collectDroppedEntry(childEntry, childBasePath)));
return nestedFiles.flat();
};
// --- 事件处理函数 ---
const handleDragEnter = (event: DragEvent) => {
// 检查是否是外部文件拖拽
@@ -72,6 +126,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
showExternalDropOverlay.value = true; // 确保蒙版显示
event.preventDefault(); // 必须阻止默认行为以允许 drop
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; // 指示效果
externalDropTargetPath.value = resolveExternalDropTargetPath(event);
// 外部拖拽时,不处理容器的自动滚动,因为鼠标在蒙版上
stopAutoScroll();
return; // 外部拖拽不由容器 handleDragOver 处理滚动
@@ -146,6 +201,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
if (showExternalDropOverlay.value) {
// console.log("[DragDrop] Hiding external drop overlay due to leaving container.");
showExternalDropOverlay.value = false; // 隐藏蒙版
externalDropTargetPath.value = null;
}
// isDraggingOver.value = false; // 不再使用
dragOverTarget.value = null; // 清除行高亮
@@ -163,72 +219,67 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
}
};
// --- 递归遍历文件树的辅助函数 ---
const traverseFileTree = (item: FileSystemEntry, path = '') => {
path = path || '';
if (item.isFile) {
// 文件处理
(item as FileSystemFileEntry).file((file) => {
// 调用上传函数,传递文件和相对路径
console.log(`[DragDrop] Uploading file: ${path}${file.name}`);
onFileUpload(file, path); // 传递相对路径
}, (err) => {
console.error(`[DragDrop] Error getting file from entry: ${path}${item.name}`, err);
});
} else if (item.isDirectory) {
// 目录处理
const dirReader = (item as FileSystemDirectoryEntry).createReader();
dirReader.readEntries((entries) => {
console.log(`[DragDrop] Traversing directory: ${path}${item.name}, found ${entries.length} entries.`);
// 递归遍历目录中的每个条目
entries.forEach((entry) => {
traverseFileTree(entry, path + item.name + '/'); // 更新相对路径
});
}, (err) => {
console.error(`[DragDrop] Error reading directory entries: ${path}${item.name}`, err);
});
}
};
// 处理蒙版上的 Drop 事件
const handleOverlayDrop = (event: DragEvent) => {
event.preventDefault(); // 必须阻止,以防浏览器打开文件
showExternalDropOverlay.value = false; // 隐藏蒙版
stopAutoScroll(); // 停止滚动
const items = event.dataTransfer?.items;
if (!items || items.length === 0 || !isConnected.value) {
console.log("[DragDrop] Overlay drop ignored: No items or not connected.");
return;
}
console.log(`[DragDrop] Processing ${items.length} items from overlay drop.`);
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry();
if (entry) {
traverseFileTree(entry); // 处理文件/文件夹
} else {
console.warn(`[DragDrop] Could not get entry for item ${i} from overlay.`);
}
}
}
};
// 原有的 handleDrop (容器的 drop) 现在基本不需要了,
// 因为外部 drop 由蒙版处理,内部 drop 由行处理并阻止冒泡。
// 保留一个空的或只做清理的函数以防万一。
const handleDrop = (event: DragEvent) => {
// console.log("[DragDrop] Container drop event triggered (should be rare).");
// 清理所有状态以防异常情况
showExternalDropOverlay.value = false;
draggedItem.value = null; // 清理内部拖拽状态
dragOverTarget.value = null; // 清理行高亮
stopAutoScroll(); // 停止滚动
// 阻止默认行为以防万一
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
const isExternalFileDrop = (event.dataTransfer?.types.includes('Files') ?? false) && !draggedItem.value;
const targetPath = externalDropTargetPath.value || currentPath.value;
showExternalDropOverlay.value = false;
draggedItem.value = null;
dragOverTarget.value = null;
externalDropTargetPath.value = null;
stopAutoScroll();
if (!isExternalFileDrop || !isConnected.value) {
return;
}
const items = event.dataTransfer?.items;
if (!items || items.length === 0) {
return;
}
console.log(`[DragDrop] Processing ${items.length} dropped items for target path ${targetPath}.`);
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== 'file') {
continue;
}
const entry = item.webkitGetAsEntry?.();
if (!entry) {
const file = item.getAsFile();
if (file) {
onFileUpload(file, undefined, targetPath);
}
continue;
}
if (entry.isDirectory) {
try {
const files = await collectDroppedEntry(entry);
if (files.length > 0) {
await onFolderUpload(files, targetPath);
}
} catch (error) {
console.error(`[DragDrop] Failed to collect dropped directory ${entry.name}:`, error);
}
continue;
}
try {
const [fileSource] = await collectDroppedEntry(entry);
if (fileSource) {
onFileUpload(fileSource.file, undefined, targetPath);
}
} catch (error) {
console.error(`[DragDrop] Failed to process dropped file ${entry.name}:`, error);
}
}
};
const handleDragStart = (item: FileListItem) => {
@@ -367,13 +418,13 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
return {
showExternalDropOverlay,
dragOverTarget,
externalDropTargetPath,
draggedItem, // 需要暴露以供 handleDragOverRow 等函数内部判断
// --- 事件处理器 ---
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop, // 容器的 drop (主要用于清理)
handleOverlayDrop,
handleDrop, // 容器同时处理外部上传与内部拖拽清理
handleDragStart,
handleDragEnd,
handleDragOverRow,
@@ -1,5 +1,10 @@
import JSZip from 'jszip';
export interface FolderArchiveSource {
file: File;
relativePath: string;
}
export interface FolderArchiveResult {
archiveFile: File;
folderName: string;
@@ -23,22 +28,33 @@ const getFolderNameFromRelativePath = (relativePath: string): string => {
};
export const createFolderArchive = async (
selectedFiles: File[] | FileList,
selectedFiles: File[] | FileList | FolderArchiveSource[],
onProgress?: (percent: number) => void,
): Promise<FolderArchiveResult> => {
const files = Array.from(selectedFiles).filter((file) => {
return Boolean(file.webkitRelativePath && file.webkitRelativePath.includes('/'));
});
const entries: Array<File | FolderArchiveSource> = Array.isArray(selectedFiles)
? selectedFiles
: Array.from(selectedFiles);
const files = entries
.map<FolderArchiveSource | null>((entry) => {
if (entry instanceof File) {
const relativePath = entry.webkitRelativePath?.replace(/\\/g, '/');
return relativePath && relativePath.includes('/') ? { file: entry, relativePath } : null;
}
const relativePath = entry.relativePath.replace(/\\/g, '/');
return relativePath && relativePath.includes('/') ? { file: entry.file, relativePath } : null;
})
.filter((entry): entry is FolderArchiveSource => entry !== null);
if (files.length === 0) {
throw new Error('No folder files were selected.');
}
const folderName = getFolderNameFromRelativePath(files[0].webkitRelativePath);
const folderName = getFolderNameFromRelativePath(files[0].relativePath);
const zip = new JSZip();
files.forEach((file) => {
const relativePath = file.webkitRelativePath.replace(/\\/g, '/');
files.forEach(({ file, relativePath }) => {
zip.file(relativePath, file);
});
@@ -93,15 +93,16 @@ export function useFileUploader(
Object.assign(upload, patch);
};
const buildRemotePath = (file: File, relativePath?: string) => {
const buildRemotePath = (file: File, relativePath?: string, targetPath?: string) => {
const uploadBasePath = targetPath || currentPathRef.value;
let finalRemotePath: string;
if (relativePath) {
const basePath = currentPathRef.value.endsWith('/') ? currentPathRef.value : `${currentPathRef.value}/`;
const basePath = uploadBasePath.endsWith('/') ? uploadBasePath : `${uploadBasePath}/`;
let cleanRelativePath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
cleanRelativePath = cleanRelativePath.endsWith('/') ? cleanRelativePath.slice(0, -1) : cleanRelativePath;
finalRemotePath = `${basePath}${cleanRelativePath ? `${cleanRelativePath}/` : ''}${file.name}`;
} else {
finalRemotePath = joinPath(currentPathRef.value, file.name);
finalRemotePath = joinPath(uploadBasePath, file.name);
}
return finalRemotePath.replace(/\/+/g, '/');
@@ -199,6 +200,7 @@ export function useFileUploader(
displayName?: string;
mode?: UploadTaskMode;
detail?: string;
targetPath?: string;
afterUpload?: (context: { uploadId: string; remotePath: string; item: UploadItem; }) => Promise<void>;
}
) => {
@@ -215,8 +217,8 @@ export function useFileUploader(
}
const uploadId = options?.uploadId ?? generateUploadId();
const finalRemotePath = buildRemotePath(file, relativePath);
console.log(`[FileUploader ${sessionIdForLog.value}] Calculated finalRemotePath: ${finalRemotePath} (current: ${currentPathRef.value}, relative: ${relativePath}, filename: ${file.name}) // wsDeps.isSftpReady: ${wsDeps.value.isSftpReady.value}`);
const finalRemotePath = buildRemotePath(file, relativePath, options?.targetPath);
console.log(`[FileUploader ${sessionIdForLog.value}] Calculated finalRemotePath: ${finalRemotePath} (current: ${currentPathRef.value}, relative: ${relativePath}, target: ${options?.targetPath}, filename: ${file.name}) // wsDeps.isSftpReady: ${wsDeps.value.isSftpReady.value}`);
uploads[uploadId] = {
id: uploadId,
@@ -112,6 +112,8 @@ export function createSftpActionsManager(
// const fileList = ref<FileListItem[]>([]); // 不再直接使用 fileList ref
const isLoading = ref<boolean>(false);
const loadingRequestId = ref<string | null>(null); // 跟踪当前加载请求 ID
const loadingPath = ref<string | null>(null);
const pendingPathAfterRootBootstrap = ref<string | null>(null);
// const error = ref<string | null>(null); // 不再使用本地 error ref
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
const uiNotificationsStore = useUiNotificationsStore(); // 初始化 UI 通知 store
@@ -242,6 +244,24 @@ export function createSftpActionsManager(
};
const loadDirectory = (path: string, forceRefresh: boolean = false) => { // 添加 forceRefresh 参数
const needsRootBootstrap = path !== '/' && !fileTree.childrenLoaded;
if (needsRootBootstrap) {
pendingPathAfterRootBootstrap.value = path;
if (isLoading.value) {
if (loadingPath.value === '/') {
console.log(`[SFTP ${instanceSessionId}] Root bootstrap already in progress. Queued target path: ${path}`);
return;
}
console.warn(`[SFTP ${instanceSessionId}] Tried to queue target path ${path} while loading ${loadingPath.value}.`);
return;
}
console.log(`[SFTP ${instanceSessionId}] Bootstrapping root tree before loading target path: ${path}`);
path = '/';
forceRefresh = false;
}
// *** 修改:检查文件树 ***
const targetNode = findNodeByPath(fileTree, path);
@@ -285,6 +305,7 @@ export function createSftpActionsManager(
// currentPathRef.value = path; // <-- 移除此行,延迟更新
const requestId = generateRequestId();
loadingRequestId.value = requestId; // 记录当前加载请求 ID
loadingPath.value = path;
sendMessage({ type: 'sftp:readdir', requestId: requestId, payload: { path } });
};
@@ -700,8 +721,10 @@ export function createSftpActionsManager(
return;
}
const isLatestRequest = message.requestId === loadingRequestId.value;
// 检查请求 ID 是否匹配当前加载请求
if (message.requestId !== loadingRequestId.value) {
if (!isLatestRequest) {
console.log(`[SFTP ${instanceSessionId}] Received stale readdir success for ${path} (ID: ${message.requestId}, expected: ${loadingRequestId.value}). Ignoring.`);
return; // 忽略过时的响应
}
@@ -772,14 +795,29 @@ export function createSftpActionsManager(
targetNode.childrenLoaded = true;
console.log(`[SFTP ${instanceSessionId}] File tree node ${path}'s children updated after merge.`);
// *** 在成功加载并更新树之后,才更新当前路径 ***
currentPathRef.value = path;
console.log(`[SFTP ${instanceSessionId}] currentPathRef updated to ${path} after successful readdir.`);
const queuedPath = path === '/' ? pendingPathAfterRootBootstrap.value : null;
const shouldContinueToQueuedPath = Boolean(queuedPath && queuedPath !== '/');
if (shouldContinueToQueuedPath) {
pendingPathAfterRootBootstrap.value = null;
}
if (!shouldContinueToQueuedPath) {
// *** 在成功加载并更新树之后,才更新当前路径 ***
currentPathRef.value = path;
console.log(`[SFTP ${instanceSessionId}] currentPathRef updated to ${path} after successful readdir.`);
} else {
console.log(`[SFTP ${instanceSessionId}] Root tree bootstrap completed. Will continue loading queued path: ${queuedPath}`);
}
// 重置加载状态,因为这是匹配的响应
isLoading.value = false;
loadingRequestId.value = null;
loadingPath.value = null;
console.log(`[SFTP ${instanceSessionId}] isLoading reset after successful readdir for ${path}.`);
if (shouldContinueToQueuedPath && queuedPath) {
loadDirectory(queuedPath);
}
};
const onSftpReaddirError = (payload: MessagePayload, message: WebSocketMessage) => {
@@ -800,6 +838,7 @@ export function createSftpActionsManager(
// 重置加载状态,因为这是匹配的响应
isLoading.value = false;
loadingRequestId.value = null;
loadingPath.value = null;
console.log(`[SFTP ${instanceSessionId}] isLoading reset after failed readdir for ${errorPath}.`);
};