Skip to content

Commit 5946e80

Browse files
committed
Add WSL support to move files to Windows Recycle Bin
Fixes #102
1 parent d55ea00 commit 5946e80

File tree

6 files changed

+216
-5
lines changed

6 files changed

+216
-5
lines changed

index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ async function platformSpecificImplementation() {
7676
return import('./lib/windows.js');
7777
}
7878

79+
case 'linux': {
80+
const {default: isWsl} = await import('is-wsl');
81+
if (isWsl) {
82+
return import('./lib/wsl.js');
83+
}
84+
85+
return import('./lib/linux.js');
86+
}
87+
7988
default: {
8089
return import('./lib/linux.js');
8190
}

lib/linux.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,11 @@ export default async function linux(paths) {
5454

5555
const trashPathsCache = new Map();
5656

57-
const getDeviceTrashPaths = async developmentId => {
58-
let trashPathsPromise = trashPathsCache.get(developmentId);
57+
const getDeviceTrashPaths = async deviceId => {
58+
let trashPathsPromise = trashPathsCache.get(deviceId);
5959
if (!trashPathsPromise) {
6060
trashPathsPromise = (async () => {
61-
const trashPath = await xdgTrashdir(mountPointMap.get(developmentId));
61+
const trashPath = await xdgTrashdir(mountPointMap.get(deviceId));
6262
const paths = {
6363
filesPath: path.join(trashPath, 'files'),
6464
infoPath: path.join(trashPath, 'info'),
@@ -67,7 +67,7 @@ export default async function linux(paths) {
6767
await fs.promises.mkdir(paths.infoPath, {mode: 0o700, recursive: true});
6868
return paths;
6969
})();
70-
trashPathsCache.set(developmentId, trashPathsPromise);
70+
trashPathsCache.set(deviceId, trashPathsPromise);
7171
}
7272

7373
return trashPathsPromise;

lib/wsl.js

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import {Buffer} from 'node:buffer';
2+
import {execFile} from 'node:child_process';
3+
import {promisify} from 'node:util';
4+
import chunkify from '@sindresorhus/chunkify';
5+
6+
const execFilePromise = promisify(execFile);
7+
8+
// TODO: I plan to move these to wsl-extras at some point.
9+
10+
/**
11+
Convert WSL paths to Windows paths using `wslpath`.
12+
13+
@param {string[]} paths - Array of WSL/Linux paths..
14+
@returns {Promise<string[]>} Array of Windows paths.
15+
*/
16+
async function wslPathToWindows(paths) {
17+
try {
18+
const {stdout} = await execFilePromise('wslpath', ['-w', '-a', ...paths]);
19+
return stdout.split(/\r?\n/).filter(Boolean);
20+
} catch (error) {
21+
error.message = `Failed to convert paths with "wslpath": ${error.message}`;
22+
throw error;
23+
}
24+
}
25+
26+
/**
27+
Check if a Windows path is a UNC path (\\wsl$\...).
28+
29+
@param {string} path - Windows path to check.
30+
@returns {boolean} True if path is UNC.
31+
*/
32+
function isUncPath(path) {
33+
return /^\\\\/u.test(path);
34+
}
35+
36+
/**
37+
Partition paths into local Windows paths and UNC WSL paths.
38+
39+
@param {string[]} windowsPaths - Array of Windows paths.
40+
@param {string[]} originalPaths - Corresponding original Linux paths.g
41+
@returns {{localPaths: string[], uncPaths: string[]}} Partitioned paths.
42+
*/
43+
function partitionWindowsPaths(windowsPaths, originalPaths) {
44+
const localPaths = [];
45+
const uncPaths = [];
46+
47+
for (const [index, windowsPath] of windowsPaths.entries()) {
48+
if (isUncPath(windowsPath)) {
49+
uncPaths.push(originalPaths[index]);
50+
} else {
51+
localPaths.push(windowsPath);
52+
}
53+
}
54+
55+
return {localPaths, uncPaths};
56+
}
57+
58+
/**
59+
Execute a PowerShell script using -EncodedCommand for safety.
60+
61+
@param {string} script - PowerShell script to execute.
62+
@returns {Promise<{stdout: string, stderr: string}>} Execution result.
63+
*/
64+
async function executePowerShellScript(script) {
65+
const encodedCommand = Buffer.from(script, 'utf16le').toString('base64');
66+
return execFilePromise('powershell.exe', [
67+
'-NoProfile',
68+
'-NonInteractive',
69+
'-ExecutionPolicy',
70+
'Bypass',
71+
'-EncodedCommand',
72+
encodedCommand,
73+
]);
74+
}
75+
76+
/**
77+
Check if WSL interop is enabled by testing PowerShell availability.
78+
79+
@returns {Promise<boolean>} True if interop is enabled
80+
*/
81+
async function isWslInteropEnabled() {
82+
try {
83+
await execFilePromise('powershell.exe', [
84+
'-NoProfile',
85+
'-NonInteractive',
86+
'-Command',
87+
'$PSVersionTable.PSVersion',
88+
]);
89+
return true;
90+
} catch (error) {
91+
if (error.code === 'ENOENT') {
92+
return false;
93+
}
94+
95+
throw error;
96+
}
97+
}
98+
99+
/**
100+
WSL implementation:
101+
- Converts WSL paths to Windows paths with `wslpath -w -a`
102+
- For Windows-local paths (e.g., `C:\…`), uses PowerShell to send to Recycle Bin
103+
- For UNC `\\wsl$\…` paths (Linux filesystem), falls back to the Linux trash implementation
104+
- Processes inputs in chunks to avoid command-line length limits
105+
- Uses `-LiteralPath` to avoid wildcard expansion
106+
- Uses `-EncodedCommand` with UTF-16LE to avoid quoting/length issues
107+
*/
108+
export default async function wsl(paths) {
109+
// Check interop availability once
110+
const interopEnabled = await isWslInteropEnabled();
111+
if (!interopEnabled) {
112+
const error = new Error('WSL interop is disabled. Enable it or use Linux trash implementation.');
113+
error.code = 'WSL_INTEROP_DISABLED';
114+
throw error;
115+
}
116+
117+
for (const chunk of chunkify(paths, 400)) {
118+
// Resolve to Windows paths
119+
// eslint-disable-next-line no-await-in-loop
120+
const windowsPathsRaw = await wslPathToWindows(chunk);
121+
122+
// Partition into local drive paths and UNC \\wsl$ paths
123+
const {localPaths: localWindowsPaths, uncPaths: uncLinuxPaths} = partitionWindowsPaths(windowsPathsRaw, chunk);
124+
125+
// Fallback to Linux trash for files that live on the Linux filesystem (UNC \\wsl$)
126+
if (uncLinuxPaths.length > 0) {
127+
// eslint-disable-next-line no-await-in-loop
128+
const {default: linuxTrash} = await import('./linux.js');
129+
// eslint-disable-next-line no-await-in-loop
130+
await linuxTrash(uncLinuxPaths);
131+
}
132+
133+
// Nothing to recycle on Windows side for this chunk
134+
if (localWindowsPaths.length === 0) {
135+
continue;
136+
}
137+
138+
// Build a PowerShell script that:
139+
// - Decodes a Base64 JSON array of paths
140+
// - Uses LiteralPath to avoid wildcard expansion
141+
// - Sends files/dirs to Recycle Bin
142+
const json = JSON.stringify(localWindowsPaths);
143+
const base64Json = Buffer.from(json, 'utf8').toString('base64');
144+
145+
const psScript = `
146+
$ErrorActionPreference = 'Stop'
147+
Add-Type -AssemblyName Microsoft.VisualBasic
148+
$paths = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64Json}')) | ConvertFrom-Json
149+
foreach ($p in $paths) {
150+
if (Test-Path -LiteralPath $p) {
151+
if (Test-Path -LiteralPath $p -PathType Container) {
152+
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($p, 'OnlyErrorDialogs', 'SendToRecycleBin')
153+
} else {
154+
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($p, 'OnlyErrorDialogs', 'SendToRecycleBin')
155+
}
156+
}
157+
}
158+
`.trim();
159+
160+
// Execute PowerShell
161+
// eslint-disable-next-line no-await-in-loop
162+
await executePowerShellScript(psScript);
163+
}
164+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@stroncium/procfs": "^1.2.1",
5151
"globby": "^14.1.0",
5252
"is-path-inside": "^4.0.0",
53+
"is-wsl": "^3.1.0",
5354
"move-file": "^3.1.0",
5455
"p-map": "^7.0.3",
5556
"xdg-trashdir": "^3.1.0"

readme.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ npm install --global trash-cli
5757

5858
On macOS, [`macos-trash`](https://github.com/sindresorhus/macos-trash) is used.\
5959
On Linux, the [XDG spec](https://standards.freedesktop.org/trash-spec/trashspec-1.0.html) is followed.\
60-
On Windows, [`recycle-bin`](https://github.com/sindresorhus/recycle-bin) is used.
60+
On Windows, [`recycle-bin`](https://github.com/sindresorhus/recycle-bin) is used.\
61+
On WSL (Windows Subsystem for Linux), files are moved to the Windows Recycle Bin.
6162

6263
## FAQ
6364

test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,39 @@ test('glob with nested directories', async () => {
195195
assert.ok(!fs.existsSync(directory2));
196196
assert.ok(!fs.existsSync(directory3));
197197
});
198+
199+
test('empty array', async () => {
200+
await assert.doesNotReject(trash([]));
201+
});
202+
203+
test('mixed existing and non-existing files', async () => {
204+
fs.writeFileSync('exists1', '');
205+
fs.writeFileSync('exists2', '');
206+
assert.ok(fs.existsSync('exists1'));
207+
assert.ok(fs.existsSync('exists2'));
208+
assert.ok(!fs.existsSync('does-not-exist'));
209+
210+
await trash(['exists1', 'does-not-exist', 'exists2']);
211+
212+
assert.ok(!fs.existsSync('exists1'));
213+
assert.ok(!fs.existsSync('exists2'));
214+
assert.ok(!fs.existsSync('does-not-exist'));
215+
});
216+
217+
test('single file path', async () => {
218+
fs.writeFileSync('single-file', '');
219+
assert.ok(fs.existsSync('single-file'));
220+
221+
await trash('single-file');
222+
223+
assert.ok(!fs.existsSync('single-file'));
224+
});
225+
226+
test('empty directory', async () => {
227+
fs.mkdirSync('empty-dir');
228+
assert.ok(fs.existsSync('empty-dir'));
229+
230+
await trash('empty-dir');
231+
232+
assert.ok(!fs.existsSync('empty-dir'));
233+
});

0 commit comments

Comments
 (0)