Skip to content

Commit c3aa28f

Browse files
committed
Add WSL support to move files to Windows Recycle Bin
Fixes #102
1 parent 8fe9f43 commit c3aa28f

File tree

4 files changed

+175
-0
lines changed

4 files changed

+175
-0
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/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
"chunkify": "^5.0.0",
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ npm install --global trash-cli
5858
On macOS, [`macos-trash`](https://github.com/sindresorhus/macos-trash) is used.\
5959
On Linux, the [XDG spec](https://specifications.freedesktop.org/trash/1.0/) is followed.\
6060
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

0 commit comments

Comments
 (0)