dsh-multi-folder 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -5
- package/README.zh.md +24 -5
- package/SECURITY.md +21 -19
- package/docs/design.md +184 -29
- package/lib/client.js +290 -32
- package/lib/index.js +654 -30
- package/lib/native-picker.ps1 +290 -0
- package/package.json +5 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#requires -Version 5.1
|
|
2
|
+
<#
|
|
3
|
+
native-picker.ps1 -- a modern Windows folder chooser for non-GUI processes.
|
|
4
|
+
|
|
5
|
+
Why this exists: `System.Windows.Forms.FolderBrowserDialog` is the LEGACY
|
|
6
|
+
tree-view dialog (SHBrowseForFolder) and `System.Windows.Forms.OpenFolderDialog`
|
|
7
|
+
(.NET 8+) is not present in every PowerShell build, so neither can be relied on
|
|
8
|
+
when the goal is the SAME dialog Explorer and other apps show -- the Vista+
|
|
9
|
+
common item dialog (`IFileOpenDialog` with `FOS_PICKFOLDERS`). Shell helpers
|
|
10
|
+
such as Listary hook that dialog, not the legacy tree.
|
|
11
|
+
|
|
12
|
+
The dialog runs on a dedicated STA thread (PowerShell 7 starts MTA, where the
|
|
13
|
+
common dialog is not guaranteed to work), gets a bounded foreground assist
|
|
14
|
+
(the caller is usually a background host process, so a fresh window would
|
|
15
|
+
otherwise open behind the user's windows), and can auto-close after a timeout
|
|
16
|
+
so a request can never be blocked forever by an unanswered dialog.
|
|
17
|
+
|
|
18
|
+
Output: the selected absolute path on stdout, or NOTHING when the user
|
|
19
|
+
cancelled or the timeout dismissed the dialog; both of those exit 0, so the
|
|
20
|
+
caller can tell them from a helper that never ran. A non-zero exit therefore
|
|
21
|
+
means the helper itself failed (an ExecutionPolicy block, a failed Add-Type)
|
|
22
|
+
and must be reported instead of read as a cancellation.
|
|
23
|
+
#>
|
|
24
|
+
[CmdletBinding()]
|
|
25
|
+
param(
|
|
26
|
+
[string]$InitialDirectory = '',
|
|
27
|
+
[int]$TimeoutMs = 0,
|
|
28
|
+
[switch]$DryRun
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
$ErrorActionPreference = 'Stop'
|
|
32
|
+
|
|
33
|
+
$source = @'
|
|
34
|
+
using System;
|
|
35
|
+
using System.Runtime.InteropServices;
|
|
36
|
+
using System.Text;
|
|
37
|
+
using System.Threading;
|
|
38
|
+
|
|
39
|
+
public static class DshFolderPicker
|
|
40
|
+
{
|
|
41
|
+
[ComImport, Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
|
|
42
|
+
private class FileOpenDialogRCW
|
|
43
|
+
{
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
[ComImport, Guid("D57C7288-D4AD-4768-BE02-9D969532D960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
47
|
+
private interface IFileOpenDialog
|
|
48
|
+
{
|
|
49
|
+
[PreserveSig]
|
|
50
|
+
int Show(IntPtr hwndParent);
|
|
51
|
+
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
|
|
52
|
+
void SetFileTypeIndex(uint iFileType);
|
|
53
|
+
void GetFileTypeIndex(out uint piFileType);
|
|
54
|
+
void Advise(IntPtr pfde, out uint pdwCookie);
|
|
55
|
+
void Unadvise(uint dwCookie);
|
|
56
|
+
void SetOptions(uint fos);
|
|
57
|
+
void GetOptions(out uint pfos);
|
|
58
|
+
void SetDefaultFolder(IShellItem psi);
|
|
59
|
+
void SetFolder(IShellItem psi);
|
|
60
|
+
void GetFolder(out IShellItem ppsi);
|
|
61
|
+
void GetCurrentSelection(out IShellItem ppsi);
|
|
62
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
|
63
|
+
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
|
|
64
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
|
|
65
|
+
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
|
|
66
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
|
|
67
|
+
void GetResult(out IShellItem ppsi);
|
|
68
|
+
void AddPlace(IShellItem psi, int fdap);
|
|
69
|
+
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
|
|
70
|
+
void Close([MarshalAs(UnmanagedType.Error)] int hr);
|
|
71
|
+
void SetClientGuid(ref Guid guid);
|
|
72
|
+
void ClearClientData();
|
|
73
|
+
void SetFilter(IntPtr pFilter);
|
|
74
|
+
void GetResults(out IntPtr ppenum);
|
|
75
|
+
void GetSelectedItems(out IntPtr ppsai);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
[ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
79
|
+
private interface IShellItem
|
|
80
|
+
{
|
|
81
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
82
|
+
void GetParent(out IShellItem ppsi);
|
|
83
|
+
void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
|
|
84
|
+
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
|
|
85
|
+
void Compare(IShellItem psi, uint hint, out int piOrder);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
|
|
89
|
+
private static extern void SHCreateItemFromParsingName(
|
|
90
|
+
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
|
|
91
|
+
IntPtr pbc,
|
|
92
|
+
ref Guid riid,
|
|
93
|
+
[MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
|
|
94
|
+
|
|
95
|
+
private delegate bool EnumProc(IntPtr hWnd, IntPtr param);
|
|
96
|
+
|
|
97
|
+
[DllImport("kernel32.dll")]
|
|
98
|
+
private static extern uint GetCurrentProcessId();
|
|
99
|
+
|
|
100
|
+
[DllImport("user32.dll")]
|
|
101
|
+
private static extern bool EnumWindows(EnumProc callback, IntPtr param);
|
|
102
|
+
|
|
103
|
+
[DllImport("user32.dll")]
|
|
104
|
+
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
105
|
+
|
|
106
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
107
|
+
private static extern int GetClassName(IntPtr hWnd, StringBuilder text, int count);
|
|
108
|
+
|
|
109
|
+
[DllImport("user32.dll")]
|
|
110
|
+
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
111
|
+
|
|
112
|
+
[DllImport("user32.dll")]
|
|
113
|
+
private static extern bool BringWindowToTop(IntPtr hWnd);
|
|
114
|
+
|
|
115
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
116
|
+
private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
|
117
|
+
|
|
118
|
+
[DllImport("user32.dll")]
|
|
119
|
+
private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
|
|
120
|
+
|
|
121
|
+
private const uint FOS_PICKFOLDERS = 0x00000020;
|
|
122
|
+
private const uint FOS_FORCEFILESYSTEM = 0x00000040;
|
|
123
|
+
private const uint FOS_PATHMUSTEXIST = 0x00000800;
|
|
124
|
+
private const uint SIGDN_FILESYSPATH = 0x80058000;
|
|
125
|
+
private const uint WM_CLOSE = 0x0010;
|
|
126
|
+
/** DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 / _PER_MONITOR_AWARE. */
|
|
127
|
+
private static readonly IntPtr PER_MONITOR_AWARE_V2 = new IntPtr(-4);
|
|
128
|
+
private static readonly IntPtr PER_MONITOR_AWARE = new IntPtr(-3);
|
|
129
|
+
|
|
130
|
+
/// The folder dialog is a plain #32770 window created BY THIS PROCESS, so it
|
|
131
|
+
/// is found by pid + class rather than by its title: a title is optional
|
|
132
|
+
/// (Windows then supplies its own localized one), and matching on one could
|
|
133
|
+
/// pick up a window that is not ours.
|
|
134
|
+
private static IntPtr FindOwnDialog()
|
|
135
|
+
{
|
|
136
|
+
uint me = GetCurrentProcessId();
|
|
137
|
+
IntPtr found = IntPtr.Zero;
|
|
138
|
+
EnumWindows(delegate(IntPtr hWnd, IntPtr param)
|
|
139
|
+
{
|
|
140
|
+
uint pid;
|
|
141
|
+
GetWindowThreadProcessId(hWnd, out pid);
|
|
142
|
+
if (pid != me) return true;
|
|
143
|
+
StringBuilder cls = new StringBuilder(64);
|
|
144
|
+
GetClassName(hWnd, cls, cls.Capacity);
|
|
145
|
+
if (cls.ToString() != "#32770") return true;
|
|
146
|
+
found = hWnd;
|
|
147
|
+
return false;
|
|
148
|
+
}, IntPtr.Zero);
|
|
149
|
+
return found;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public static string Pick(string initial, int timeoutMs)
|
|
153
|
+
{
|
|
154
|
+
string result = null;
|
|
155
|
+
Exception failure = null;
|
|
156
|
+
Thread thread = new Thread(delegate()
|
|
157
|
+
{
|
|
158
|
+
IntPtr previous = IntPtr.Zero;
|
|
159
|
+
try
|
|
160
|
+
{
|
|
161
|
+
// Render at the monitor's REAL DPI. The PowerShell host is
|
|
162
|
+
// DPI-UNAWARE, so an untouched dialog is drawn at 96 DPI and then
|
|
163
|
+
// BITMAP-STRETCHED by Windows to the display scaling (150% on the
|
|
164
|
+
// machine this was written on) — which is exactly the soft, fuzzy
|
|
165
|
+
// text users notice. Thread-level awareness is the fix that always
|
|
166
|
+
// applies: a process manifest may already have fixed the PROCESS
|
|
167
|
+
// context (SetProcessDpiAwarenessContext would then be refused), but
|
|
168
|
+
// a thread context may be set at any time, and the dialog is created
|
|
169
|
+
// on this thread.
|
|
170
|
+
previous = SetThreadDpiAwarenessContext(PER_MONITOR_AWARE_V2);
|
|
171
|
+
if (previous == IntPtr.Zero) previous = SetThreadDpiAwarenessContext(PER_MONITOR_AWARE);
|
|
172
|
+
}
|
|
173
|
+
catch
|
|
174
|
+
{
|
|
175
|
+
// A Windows build without the thread-scoped DPI API still shows the
|
|
176
|
+
// dialog, only bitmap-stretched at the process DPI — not worth
|
|
177
|
+
// failing the pick over, and an exception here would have escaped
|
|
178
|
+
// this thread and killed the helper outright.
|
|
179
|
+
}
|
|
180
|
+
try { result = Show(initial, timeoutMs); }
|
|
181
|
+
catch (Exception error) { failure = error; }
|
|
182
|
+
finally
|
|
183
|
+
{
|
|
184
|
+
if (previous != IntPtr.Zero) SetThreadDpiAwarenessContext(previous);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
thread.SetApartmentState(ApartmentState.STA);
|
|
188
|
+
thread.IsBackground = true;
|
|
189
|
+
thread.Start();
|
|
190
|
+
bool finished = timeoutMs > 0
|
|
191
|
+
? thread.Join(timeoutMs + 5000)
|
|
192
|
+
: thread.Join(Timeout.Infinite);
|
|
193
|
+
if (failure != null) throw failure;
|
|
194
|
+
return finished ? result : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private static string Show(string initial, int timeoutMs)
|
|
198
|
+
{
|
|
199
|
+
IFileOpenDialog dialog = (IFileOpenDialog)new FileOpenDialogRCW();
|
|
200
|
+
uint options;
|
|
201
|
+
dialog.GetOptions(out options);
|
|
202
|
+
dialog.SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
|
|
203
|
+
if (!String.IsNullOrEmpty(initial))
|
|
204
|
+
{
|
|
205
|
+
try
|
|
206
|
+
{
|
|
207
|
+
Guid iid = typeof(IShellItem).GUID;
|
|
208
|
+
IShellItem folder;
|
|
209
|
+
SHCreateItemFromParsingName(initial, IntPtr.Zero, ref iid, out folder);
|
|
210
|
+
dialog.SetFolder(folder);
|
|
211
|
+
}
|
|
212
|
+
catch
|
|
213
|
+
{
|
|
214
|
+
// An unusable start directory is not worth failing the pick over.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// One watchdog owns both jobs, because they need the same window handle
|
|
218
|
+
// but NOT the same apartment: `IFileDialog::Close` cannot be called on
|
|
219
|
+
// the dialog from another thread (COM refuses the cross-apartment call
|
|
220
|
+
// and the dialog simply stayed on screen until the process died), while
|
|
221
|
+
// the plain Win32 calls below work from any thread. WM_CLOSE is what the
|
|
222
|
+
// dialog's own X button sends, so Show() returns the ordinary cancel
|
|
223
|
+
// HRESULT afterwards.
|
|
224
|
+
// `watchdog` is declared first and assigned second on purpose: a lambda
|
|
225
|
+
// that captures a local must find it definitely assigned when the
|
|
226
|
+
// delegate is created, so the single-statement form does not compile.
|
|
227
|
+
Timer watchdog = null;
|
|
228
|
+
DateTime deadline = timeoutMs > 0 ? DateTime.UtcNow.AddMilliseconds(timeoutMs) : DateTime.MinValue;
|
|
229
|
+
// The foreground assist is BOUNDED: it exists to bring a fresh dialog in
|
|
230
|
+
// front of a user whose host process is in the background, not to fight
|
|
231
|
+
// that user for the foreground for the dialog's whole life. Roughly three
|
|
232
|
+
// seconds of nudges, after which this timer only watches the deadline.
|
|
233
|
+
int assistTicks = 12;
|
|
234
|
+
int windowWaits = 240;
|
|
235
|
+
watchdog = new Timer(delegate(object state)
|
|
236
|
+
{
|
|
237
|
+
IntPtr window = FindOwnDialog();
|
|
238
|
+
if (window == IntPtr.Zero)
|
|
239
|
+
{
|
|
240
|
+
// A dialog that never appears leaves nothing to watch.
|
|
241
|
+
if (--windowWaits <= 0 && watchdog != null) watchdog.Dispose();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (deadline != DateTime.MinValue && DateTime.UtcNow >= deadline)
|
|
245
|
+
{
|
|
246
|
+
PostMessage(window, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
|
|
247
|
+
if (watchdog != null) watchdog.Dispose();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (assistTicks > 0)
|
|
251
|
+
{
|
|
252
|
+
SetForegroundWindow(window);
|
|
253
|
+
BringWindowToTop(window);
|
|
254
|
+
assistTicks--;
|
|
255
|
+
}
|
|
256
|
+
}, null, 250, 250);
|
|
257
|
+
int hr;
|
|
258
|
+
try { hr = dialog.Show(IntPtr.Zero); }
|
|
259
|
+
finally
|
|
260
|
+
{
|
|
261
|
+
if (watchdog != null) watchdog.Dispose();
|
|
262
|
+
}
|
|
263
|
+
if (hr != 0)
|
|
264
|
+
{
|
|
265
|
+
// 0x800704C7 (HRESULT_FROM_WIN32(ERROR_CANCELLED)) is the ordinary
|
|
266
|
+
// dismiss path; every other HRESULT means the dialog could not be
|
|
267
|
+
// shown at all, which the caller needs to hear about.
|
|
268
|
+
if (hr == unchecked((int)0x800704C7)) return null;
|
|
269
|
+
throw new COMException("dsh-native-picker: dialog returned 0x" + hr.ToString("X8"), hr);
|
|
270
|
+
}
|
|
271
|
+
IShellItem item;
|
|
272
|
+
dialog.GetResult(out item);
|
|
273
|
+
string path;
|
|
274
|
+
item.GetDisplayName(SIGDN_FILESYSPATH, out path);
|
|
275
|
+
return path;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
'@
|
|
279
|
+
|
|
280
|
+
if ($DryRun) {
|
|
281
|
+
# Compile only: proves the helper builds in this PowerShell without showing UI.
|
|
282
|
+
if (-not ('DshFolderPicker' -as [type])) { Add-Type -TypeDefinition $source -Language CSharp }
|
|
283
|
+
Write-Output 'compiled'
|
|
284
|
+
exit 0
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (-not ('DshFolderPicker' -as [type])) { Add-Type -TypeDefinition $source -Language CSharp }
|
|
288
|
+
$selected = [DshFolderPicker]::Pick($InitialDirectory, $TimeoutMs)
|
|
289
|
+
if ($selected) { Write-Output $selected }
|
|
290
|
+
exit 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multi-folder",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "DeepSeek Harness plugin: secondary working directories for a project. The agent keeps the primary workspace as cwd, gains equal write/exec permissions on configured secondary directories under workspace-write mode, and is notified of configuration changes at the next message boundary. Configurable from the session header AND from the session-creation page (before the first message) through a sessionless multiFolder remote API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
"compatibility": {
|
|
43
43
|
"node": ">=20",
|
|
44
44
|
"dshReleases": {
|
|
45
|
+
"0.2.1-alpha.2": "compatible",
|
|
46
|
+
"0.2.1-alpha.1": "compatible",
|
|
45
47
|
"0.2.0-rc.1": "compatible",
|
|
46
48
|
"0.1.7-rc.2": "compatible",
|
|
47
49
|
"0.1.6-alpha.1": "compatible",
|
|
@@ -62,7 +64,8 @@
|
|
|
62
64
|
"@deepseek-ai/dsh-api-gateway",
|
|
63
65
|
"@deepseek-ai/dsh-api-remotes",
|
|
64
66
|
"@deepseek-ai/dsh-client-connection",
|
|
65
|
-
"@deepseek-ai/dsh-client-locale"
|
|
67
|
+
"@deepseek-ai/dsh-client-locale",
|
|
68
|
+
"@deepseek-ai/dsh-client-ui-input-trigger"
|
|
66
69
|
]
|
|
67
70
|
}
|
|
68
71
|
},
|