From 1a326cc01f3d447f67573bbde70a95af5cf21c93 Mon Sep 17 00:00:00 2001 From: yinjianm Date: Thu, 26 Mar 2026 03:15:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=A0=91=E4=B8=8A=E4=BC=A0=E8=90=BD=E7=82=B9=E4=B8=8E?= =?UTF-8?q?=E6=A0=B9=E7=9B=AE=E5=BD=95=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正外部拖拽上传的目标路径判定,按当前悬停目录上传, 目录拖拽继续走压缩后上传链路,避免文件落到错误位置 补齐新会话初始化时根目录与当前目录并发加载的竞态处理, 先完成 `/` 根树引导再继续目标目录加载,避免根节点缺少 同级目录且不影响当前工作目录状态 --- .helloagents/CHANGELOG.md | 3 + .helloagents/modules/frontend.md | 2 +- .../.status.json | 1 + .../proposal.md | 123 ++++++++++++ .../tasks.md | 41 ++++ .../frontend/src/components/FileManager.vue | 174 +++++++++++++---- .../file-manager/useFileManagerContextMenu.ts | 9 +- .../file-manager/useFileManagerDragAndDrop.ts | 183 +++++++++++------- .../file-manager/useFolderArchiveUpload.ts | 30 ++- .../src/composables/useFileUploader.ts | 12 +- .../src/composables/useSftpActions.ts | 47 ++++- 11 files changed, 497 insertions(+), 128 deletions(-) create mode 100644 .helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/.status.json create mode 100644 .helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/proposal.md create mode 100644 .helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/tasks.md diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 1ed11af..7a35b02 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -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 diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index 2af592b..2e01621 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -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 文件。 ### 仪表盘总览 diff --git a/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/.status.json b/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/.status.json new file mode 100644 index 0000000..8665795 --- /dev/null +++ b/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/.status.json @@ -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"} diff --git a/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/proposal.md b/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/proposal.md new file mode 100644 index 0000000..57803c0 --- /dev/null +++ b/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/proposal.md @@ -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` | 当前操作目录,不应被 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 diff --git a/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/tasks.md b/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/tasks.md new file mode 100644 index 0000000..f191b4e --- /dev/null +++ b/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/tasks.md @@ -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`,方案包通过模板降级方式手工创建。 diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index 6fb856e..b74cd1e 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -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(null); // Ref for the trigger button const explorerExpandedPaths = ref>({}); +const selectedExplorerPath = ref(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(() => { 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) => { + 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(
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
-
+