Skip to content

Commit d91be76

Browse files
committed
feat: 优化回收站逻辑,废除剪贴板 Hacking,支持快捷键多选删除回退机制与高精度物理判定
1 parent df7d36a commit d91be76

10 files changed

Lines changed: 212 additions & 72 deletions

File tree

.agents/rules/README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# 项目架构规范与设计原则 (.agents/rules)
2+
3+
为了保证项目的健壮性、可读性以及跨语言数据契约的一致性,特制定以下开发规范与技术决策规范。后续无论是人类开发者还是 AI 代理,均应严格遵守以下准则。
4+
5+
---
6+
7+
## 1. VS Code 插件命令与快捷键设计规范
8+
9+
- **智能混合路由与剪贴板回退(Hybrid Routing & Clipboard Fallback)**
10+
- **原则**:因为 VS Code 自身架构限制,当通过键盘快捷键(如 `Delete` 键)触发命令时,VS Code **不会**在参数中向命令处理函数传入当前侧边栏的多选 `uris` 数组;只有通过右键上下文菜单触发时,才会传入 `uris` 数组。
11+
- **规范做法**
12+
1. **右键菜单路径**:直接读取原生传入的 `uris` 数组。这种方式最快、最安全,不产生任何剪贴板读写和时序延时。
13+
2. **快捷键路径**:若 `uris` 数组为 `undefined` 或为空,则说明是通过快捷键触发的。此时程序必须安全回退到**“读取并暂存剪贴板 -> 执行 copyFilePath 命令复制选中项 -> 延时等待写入 -> 读取解析选中项 -> 立即恢复剪贴板”**的 Hack 逻辑以取回侧边栏选中的多路径列表。
14+
3. **兜底路径**:如果依然为空,则依次尝试获取单个 `activeUri` 或是当前活动编辑器(`activeTextEditor`)中的文档路径。
15+
- **实现模板**
16+
```typescript
17+
export async function myCommand(activeUri?: vscode.Uri, uris?: vscode.Uri[]) {
18+
let targetUris: vscode.Uri[] = [];
19+
20+
if (uris && uris.length > 0) {
21+
// 右键菜单直接使用原生 uris
22+
targetUris = uris;
23+
} else {
24+
// 快捷键触发回退到剪贴板 Hack
25+
const originalClipboard = await vscode.env.clipboard.readText();
26+
await vscode.commands.executeCommand("copyFilePath");
27+
await delay(100);
28+
const selectedPaths = await vscode.env.clipboard.readText();
29+
await vscode.env.clipboard.writeText(originalClipboard);
30+
if (selectedPaths.trim()) {
31+
targetUris = selectedPaths.split(/\r?\n/).map(p => vscode.Uri.file(p));
32+
}
33+
}
34+
35+
// 执行兜底与后续的包含路径去重过滤...
36+
}
37+
```
38+
39+
- **统一快捷键与右键菜单指令**
40+
-`package.json` 中,将右键菜单与快捷键的 `command` 统一指向该混合路由命令,简化注册并在底层保证多选删除在两个入口下均能完美兼容。
41+
42+
---
43+
44+
## 2. 嵌套路径的安全删除规范
45+
46+
- **原理**:当执行批量删除(特别是在 Rust 端的 `delete_all`)时,如果入参路径数组中同时包含父目录(如 `/a`)和子目录或文件(如 `/a/b.txt`),先删除父目录会导致子目录在物理磁盘上瞬间消失。后面对子目录的删除操作会因“路径不存在”而抛出底层 IO 异常,导致整个批处理操作崩溃中断。
47+
- **安全过滤规范**:在任何需要执行批量删除的接口前,必须对路径列表执行**子路径去重过滤**。如果某个路径是另一个待删路径的子集,则在前端剔除该子路径。
48+
- **实现算法**
49+
```typescript
50+
export function filterNestedPaths(paths: string[]): string[] {
51+
const uniquePaths = Array.from(new Set(paths));
52+
return uniquePaths.filter(path => {
53+
return !uniquePaths.some(other => {
54+
if (path === other) return false;
55+
const parentWithSlash1 = other.endsWith("/") ? other : other + "/";
56+
const parentWithSlash2 = other.endsWith("\\") ? other : other + "\\";
57+
return path.startsWith(parentWithSlash1) || path.startsWith(parentWithSlash2);
58+
});
59+
});
60+
}
61+
```
62+
63+
---
64+
65+
## 3. 回收站物理类型的判定规范
66+
67+
- **废弃启发式正则判断**
68+
- **缺陷**:严禁在前端使用文件名正则(如“是否有点号后缀”)来启发式猜测回收站的项目是文件还是文件夹。这会导致 `LICENSE``Makefile` 等无后缀文件以及 `v1.0` 等带点号文件夹被完全判定错误。
69+
- **规范做法**
70+
- **跨语言传输**:由 Rust 核心层通过真实的操作系统回收站物理状态进行检查,在数据结构中透出 `is_dir` 物理类型布尔值。
71+
- **Rust 后端判定算法**
72+
Linux (FreeDesktop) 环境中,每一个删除的文件对应 `~/.local/share/Trash/info/[Name].trashinfo`(元数据)与 `~/.local/share/Trash/files/[Name]`(真实被删物理内容)。
73+
因此可以通过 `info` 的绝对路径,逆向推导其在 `files/` 目录中的真实路径,利用 Rust`std::path::Path::is_dir()` 进行高精度检查:
74+
```rust
75+
fn is_trash_item_dir(id_str: &str) -> bool {
76+
let info_path = Path::new(id_str);
77+
if let Some(parent) = info_path.parent().and_then(|p| p.parent()) {
78+
if let Some(stem) = info_path.file_stem() {
79+
let files_path = parent.join("files").join(stem);
80+
return files_path.is_dir();
81+
}
82+
}
83+
false
84+
}
85+
```
86+
87+
---
88+
89+
## 4. 跨语言编译与发布规范
90+
91+
- **N-API 桥接契约**
92+
-Rust 端导出的任何结构体或函数变动,必须同步更新 [`src/core/trashBackend.d.ts`](file:///wsl.localhost/Ubuntu/home/finnwsl/repos/trash4wsl-in-vscode/src/core/trashBackend.d.ts) 中的 TypeScript 类型契约定义,并保证前端映射字段(如 `timeDeleted` -> `time_deleted` 等驼峰转换)完全正确。
93+
- **多架构分发**
94+
- 核心层基于 Linux 系统进行编译(WSL / SSH),分发包必须包含 `linux-x64-gnu``linux-arm64-gnu` 两套原生 `.node` 模块,并通过 CIGitHub Actions)的交叉编译工具链(如 `gcc-aarch64-linux-gnu`)进行构建打包。
-8.92 MB
Binary file not shown.

package.json

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
{
2-
"version": "2.0.1",
2+
"version": "2.1.0",
33
"contributes": {
44
"commands": [
55
{
6-
"command": "trash4wsl-in-vscode.trashPutViaContextMenu",
6+
"command": "trash4wsl-in-vscode.trashPut",
77
"title": "丢入回收站(Trash It)"
88
},
9-
{
10-
"command": "trash4wsl-in-vscode.trashPutViaShortcut",
11-
"title": "通过快捷键丢入回收站"
12-
},
139
{
1410
"command": "trash4wsl-in-vscode.browseTrash",
1511
"title": "浏览回收站(历史记录、恢复、清空)",
@@ -19,7 +15,7 @@
1915
],
2016
"keybindings": [
2117
{
22-
"command": "trash4wsl-in-vscode.trashPutViaShortcut",
18+
"command": "trash4wsl-in-vscode.trashPut",
2319
"key": "delete",
2420
"mac": "delete",
2521
"when": "resourceScheme == 'vscode-remote' && filesExplorerFocus && !explorerResourceIsRoot && resourceSet && !inputFocus"
@@ -34,18 +30,14 @@
3430
"menus": {
3531
"explorer/context": [
3632
{
37-
"command": "trash4wsl-in-vscode.trashPutViaContextMenu",
33+
"command": "trash4wsl-in-vscode.trashPut",
3834
"when": "resourceScheme == 'vscode-remote' && !explorerResourceIsRoot",
3935
"group": "7_modification@0"
4036
}
4137
],
4238
"commandPalette": [
4339
{
44-
"command": "trash4wsl-in-vscode.trashPutViaContextMenu",
45-
"when": "false"
46-
},
47-
{
48-
"command": "trash4wsl-in-vscode.trashPutViaShortcut",
40+
"command": "trash4wsl-in-vscode.trashPut",
4941
"when": "false"
5042
}
5143
]

rust-core/src/lib.rs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
use napi::bindgen_prelude::*;
44
use napi_derive::napi; // 直接导入具体的宏
5+
use std::path::Path;
56
use trash::delete_all;
67
use trash::os_limited::{list, purge_all, restore_all};
78

@@ -11,6 +12,18 @@ pub struct TrashItemNode {
1112
pub name: String,
1213
pub original_parent: String,
1314
pub time_deleted: f64, // not i64, use f64 for JS number compatibility (timestamps)
15+
pub is_dir: bool,
16+
}
17+
18+
fn is_trash_item_dir(id_str: &str) -> bool {
19+
let info_path = Path::new(id_str);
20+
if let Some(parent) = info_path.parent().and_then(|p| p.parent()) {
21+
if let Some(stem) = info_path.file_stem() {
22+
let files_path = parent.join("files").join(stem);
23+
return files_path.is_dir();
24+
}
25+
}
26+
false
1427
}
1528

1629
#[napi]
@@ -24,11 +37,16 @@ pub fn list_trash() -> Result<Vec<TrashItemNode>> {
2437
let items = list().map_err(|e| Error::from_reason(e.to_string()))?;
2538
Ok(items
2639
.into_iter()
27-
.map(|item| TrashItemNode {
28-
id: item.id.clone().into_string().unwrap_or_default(),
29-
name: item.name.clone(),
30-
original_parent: item.original_parent.to_string_lossy().into_owned(),
31-
time_deleted: item.time_deleted as f64,
40+
.map(|item| {
41+
let id_str = item.id.clone().into_string().unwrap_or_default();
42+
let is_dir = is_trash_item_dir(&id_str);
43+
TrashItemNode {
44+
id: id_str,
45+
name: item.name.clone(),
46+
original_parent: item.original_parent.to_string_lossy().into_owned(),
47+
time_deleted: item.time_deleted as f64,
48+
is_dir,
49+
}
3250
})
3351
.collect())
3452
}
@@ -48,11 +66,14 @@ pub fn list_workspace_trash(path_prefix: String) -> Result<Vec<TrashItemNode>> {
4866
|| parent.starts_with(&path_with_slash)
4967
|| full_path == path_prefix
5068
{
69+
let id_str = item.id.clone().into_string().unwrap_or_default();
70+
let is_dir = is_trash_item_dir(&id_str);
5171
Some(TrashItemNode {
52-
id: item.id.clone().into_string().unwrap_or_default(),
72+
id: id_str,
5373
name: item.name.clone(),
5474
original_parent: parent,
5575
time_deleted: item.time_deleted as f64,
76+
is_dir,
5677
})
5778
} else {
5879
None

src/core/fsUtils.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,31 @@ export function getWorkspacePaths(): string[] {
1616
}
1717

1818
/**
19-
* 根据输入参数或当前选择确定要处理的文件列表
19+
* 根据输入参数或当前选择确定要处理的文件列表并进行去重与路径包含过滤
2020
* @param uris 所有要处理的文件/文件夹的 URI 数组
21-
* @returns 要处理的文件/文件夹的路径,转义后的数组
21+
* @returns 过滤与去重后、要处理的文件/文件夹的绝对路径数组
2222
*/
2323
export function Uris2Paths(uris: vscode.Uri[]): string[] {
24-
return uris // 让父目录永远排在子目录/文件前面。这样删除的时候,父目录会先被删除,子目录/文件后被删除。便于恢复。
25-
.map((f) => f.fsPath)
26-
.sort((a, b) => {
27-
if (a.startsWith(b + "/")) {
28-
return 1;
29-
}
30-
if (b.startsWith(a + "/")) {
31-
return -1;
24+
// 1. 获取所有 fsPath 并去重
25+
const uniquePaths = Array.from(new Set(uris.map((f) => f.fsPath)));
26+
27+
// 2. 过滤掉已被父目录包含的冗余子路径
28+
// 例如,如果同时存在 "/a" 和 "/a/b",因后者属于前者的子孙路径,移动父目录时子路径会被一并带走。
29+
// 过滤掉子路径能彻底避免 Rust 端 delete_all 因找不到已被移走的子路径而抛出 Entity not found 错误。
30+
return uniquePaths.filter((path) => {
31+
return !uniquePaths.some((other) => {
32+
if (path === other) {
33+
return false;
3234
}
33-
// 其他情况按字母顺序排序
34-
return a.localeCompare(b);
35+
const parentWithSlash1 = other.endsWith("/") ? other : other + "/";
36+
const parentWithSlash2 = other.endsWith("\\") ? other : other + "\\";
37+
return path.startsWith(parentWithSlash1) || path.startsWith(parentWithSlash2);
3538
});
39+
});
3640
}
3741

38-
/** 判断给定的path是文件还是目录*/
42+
/** 判断给定的path是文件还是目录 - 已废弃:存在启发式精度问题,新版本中应直接使用来自后端的物理 item.isDir 属性 */
3943
export function isFile(path: string): boolean {
40-
// 不能使用 fsUtils.isFile(path) 因为这些文件已经被删除到回收站了
4144
const hasExtension = /\.[^/\.]+$/.test(path);
4245
const endsWithSlash = path.endsWith("/") || path.endsWith("\\");
4346
return hasExtension && !endsWithSlash;

src/core/trashBackend.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface TrashItemNode {
88
name: string;
99
originalParent: string;
1010
timeDeleted: number;
11+
isDir: boolean;
1112
}
1213

1314
export interface TrashBackend {

src/core/trashPutCommand.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,70 @@ import * as vscode from "vscode";
22
import { normalizePaths, Uris2Paths } from "./fsUtils.js";
33
import * as trashService from "./trashService.js";
44

5-
/** 在explorer的右键上下文菜单点击,丢入回收站 */
6-
export async function trashPutViaContextMenu(
7-
_: vscode.Uri,
8-
uris: vscode.Uri[],
9-
) {
10-
// 处理参数:uris已经包含了所有的文件/文件夹,第一个参数是右键单击的那个文件/文件夹uri,不必理会。
11-
let paths = Uris2Paths(uris);
12-
paths = normalizePaths(paths);
13-
await execTrashPut(paths);
14-
}
15-
165
const CLIPBOARD_DELAY_MS = 100;
176
function delay(ms: number): Promise<void> {
187
return new Promise((resolve) => setTimeout(resolve, ms));
198
}
209

21-
/**在explorer选中,通过快捷键执行trash-put */
22-
export async function trashPutViaShortcut() {
23-
const originalClipboard = await vscode.env.clipboard.readText();
24-
await vscode.commands.executeCommand("copyFilePath");
25-
await delay(CLIPBOARD_DELAY_MS); // Required delay for VS Code to fill the clipboard asynchronously
26-
const selectedPaths = await vscode.env.clipboard.readText();
27-
await vscode.env.clipboard.writeText(originalClipboard);
10+
/**
11+
* 统一的删除到回收站入口函数。
12+
* 无论是通过右键上下文菜单还是快捷键触发,VS Code 在侧边栏有焦点时都会传入参数:
13+
* @param activeUri 当前激活/聚焦的项的 URI
14+
* @param uris 当前选中的所有项 of URI 数组
15+
*/
16+
export async function trashPut(
17+
activeUri?: vscode.Uri,
18+
uris?: vscode.Uri[],
19+
) {
20+
console.log("执行 trashPut 命令, activeUri:", activeUri?.fsPath, "uris count:", uris?.length);
21+
22+
let targetUris: vscode.Uri[] = [];
23+
24+
// 1. 如果是通过右键菜单触发,VS Code 会在 arguments 中直接传入选中的 uris 数组
25+
if (uris && uris.length > 0) {
26+
targetUris = uris;
27+
} else {
28+
// 2. 如果是通过键盘快捷键(Delete)触发,VS Code 不会在参数中传入选中列表。
29+
// 此时我们必须使用剪贴板 Hack 来安全获取侧边栏的当前选中项目。
30+
try {
31+
const originalClipboard = await vscode.env.clipboard.readText();
32+
await vscode.commands.executeCommand("copyFilePath");
33+
await delay(CLIPBOARD_DELAY_MS); // 等待异步剪贴板写入完成
34+
const selectedPaths = await vscode.env.clipboard.readText();
35+
await vscode.env.clipboard.writeText(originalClipboard); // 立即恢复用户原剪贴板内容
2836

29-
if (!selectedPaths.trim()) {
37+
if (selectedPaths && selectedPaths.trim()) {
38+
const paths = selectedPaths.split(/\r?\n/);
39+
const normalized = normalizePaths(paths);
40+
targetUris = normalized.map((p) => vscode.Uri.file(p));
41+
}
42+
} catch (err) {
43+
console.error("通过剪贴板获取侧边栏选中文件失败:", err);
44+
}
45+
}
46+
47+
// 3. 兜底策略:如果以上机制均未获取到,则尝试使用 activeUri 或当前活动编辑器对应的文件
48+
if (targetUris.length === 0) {
49+
if (activeUri) {
50+
targetUris = [activeUri];
51+
} else if (vscode.window.activeTextEditor) {
52+
targetUris = [vscode.window.activeTextEditor.document.uri];
53+
}
54+
}
55+
56+
if (targetUris.length === 0) {
57+
console.log("未检测到待删除的有效目标");
3058
return;
3159
}
3260

33-
let paths = selectedPaths.split("\n");
61+
// 转换成绝对路径并进行包含去重过滤
62+
let paths = Uris2Paths(targetUris);
3463
paths = normalizePaths(paths);
64+
65+
if (paths.length === 0) {
66+
return;
67+
}
68+
3569
await execTrashPut(paths);
3670
}
3771

src/core/trashService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface TrashItem {
4545
path: string;
4646
deletionDate: string;
4747
_id: string; // Internal native ID tracking
48+
isDir: boolean;
4849
}
4950

5051
/**
@@ -86,6 +87,7 @@ export async function listRestorableTrashItems(
8687
path: `${i.originalParent}/${i.name}`,
8788
deletionDate: dateStr,
8889
_id: i.id, // Track the native ID for restoring
90+
isDir: i.isDir,
8991
};
9092
});
9193

0 commit comments

Comments
 (0)