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.
@@ -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.1",
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
  },