dsh-config-manager 0.1.85 → 0.2.0
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/lib/cli/web/page.js +2 -2
- package/lib/client.d.ts +86 -1
- package/lib/client.js +2365 -772
- package/lib/core/native-path-picker.d.ts +49 -0
- package/lib/core/native-path-picker.js +475 -0
- package/lib/core/path-browse.d.ts +42 -0
- package/lib/core/path-browse.js +134 -0
- package/lib/foreign/claude-sessions.d.ts +1 -1
- package/lib/foreign/claude-sessions.js +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.js +3 -3
- package/lib/market/security.js +1 -1
- package/lib/market/types.d.ts +1 -1
- package/lib/market/types.js +1 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/index.js +2 -0
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/path-browse.d.ts +3 -0
- package/lib/routes/path-browse.js +63 -0
- package/lib/security/secret-scanner.js +1 -1
- package/lib/sync/git/git-transport.js +18 -3
- package/lib/ui/sync-settings-view.d.ts +18 -0
- package/lib/ui/sync-settings-view.js +20 -0
- package/lib/utils/workdir-lock.d.ts +26 -2
- package/lib/utils/workdir-lock.js +133 -5
- package/package.json +2 -2
- package/src/cli/web/page.ts +2 -2
- package/src/client/ConfigManagerSection.tsx +20 -17
- package/src/client/api.ts +60 -1
- package/src/client/bundle-selfcontained.test.ts +9 -0
- package/src/client/chat/chat-entry-view.test.ts +1 -1
- package/src/client/chat/rollback-entry-view.test.ts +14 -0
- package/src/client/chat/rollback-entry-view.ts +2 -1
- package/src/client/common/CopyButton.tsx +21 -2
- package/src/client/common/Icon.tsx +7 -0
- package/src/client/common/routes.ts +5 -0
- package/src/client/config-manager.module.css +70 -0
- package/src/client/import/ImportWizardView.tsx +1 -0
- package/src/client/import/PathMappingForm.tsx +62 -8
- package/src/client/import/PathPickerDialog.tsx +172 -0
- package/src/client/import/dsh-directory-pick.test.ts +55 -0
- package/src/client/import/dsh-directory-pick.ts +55 -0
- package/src/client/import/import-wizard-steps.tsx +5 -2
- package/src/client/index.ts +12 -1
- package/src/client/locales.ts +25 -0
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/sync/SyncPanel.tsx +4 -4
- package/src/client/sync/sync-view.ts +1 -1
- package/src/core/native-path-picker.test.ts +118 -0
- package/src/core/native-path-picker.ts +517 -0
- package/src/core/path-browse.test.ts +88 -0
- package/src/core/path-browse.ts +166 -0
- package/src/foreign/claude-sessions.ts +3 -3
- package/src/index.ts +3 -3
- package/src/market/security.ts +1 -1
- package/src/market/types.ts +1 -1
- package/src/routes/index.ts +3 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/path-browse.ts +70 -0
- package/src/security/secret-scanner.test.ts +1 -1
- package/src/security/secret-scanner.ts +1 -1
- package/src/sync/git/git-transport.test.ts +32 -0
- package/src/sync/git/git-transport.ts +20 -4
- package/src/ui/session-issue-labels.ts +1 -1
- package/src/ui/sync-settings-view.test.ts +18 -1
- package/src/ui/sync-settings-view.ts +21 -0
- package/src/utils/workdir-lock.test.ts +77 -4
- package/src/utils/workdir-lock.ts +122 -5
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原生「选择本机路径」弹窗(导入向导 → 路径映射 → 输入框右侧的文件夹图标)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要绕到操作系统:浏览器的 File System Access API 只给 handle、`<input type="file">`
|
|
5
|
+
* 只给文件名,都拿不到绝对路径;而路径映射要的正是**绝对路径**。DSH 桌面端的宿主进程虽然由
|
|
6
|
+
* Electron 拉起,但用的是 `ELECTRON_RUN_AS_NODE=1`(见 src/utils/desktop-carrier.ts),
|
|
7
|
+
* 进程里没有 BrowserWindow,`require('electron').dialog` 不可用。所以只能请操作系统的
|
|
8
|
+
* 标准文件夹选择器出场:
|
|
9
|
+
*
|
|
10
|
+
* - **win32**:PowerShell + `IFileOpenDialog`(`FOS_PICKFOLDERS`)= 资源管理器同款
|
|
11
|
+
* 「选择文件夹」窗口(tree 侧栏 + 文件名输入框),不是 Win7 那种 SHBrowseForFolder 老树。
|
|
12
|
+
* - **darwin**:`osascript` 的 `choose folder` = Finder 的标准选择面板。
|
|
13
|
+
* - **linux**:`zenity` / `kdialog`(GTK / KDE 原生对话框);都没有则如实回 `unsupported`。
|
|
14
|
+
*
|
|
15
|
+
* 边界
|
|
16
|
+
* ----
|
|
17
|
+
* - **只弹窗、不写盘**:返回值就是用户选中的绝对路径,路径合法性由调用方(映射表单)判定;
|
|
18
|
+
* - 用户取消 = `canceled`(调用方静默处理,不当错误弹红条);
|
|
19
|
+
* - 能力缺失(无 PowerShell / 无 zenity / 非三大平台)= `unsupported`,调用方回落到内置目录浏览器;
|
|
20
|
+
* - 子进程失败 = `failed`(带 detail,便于排查),同样回落而不是把用户堵住;
|
|
21
|
+
* - 用户要浏览多久就等多久:默认 10 分钟上限远大于实际操作时间,超时才判 failed。
|
|
22
|
+
*
|
|
23
|
+
* 用户给的起始路径与标题都经**环境变量**传给子进程(不进命令行),因此不存在拼进脚本被解释的风险。
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from 'node:child_process'
|
|
26
|
+
|
|
27
|
+
export type NativePickReason = 'canceled' | 'unsupported' | 'failed'
|
|
28
|
+
|
|
29
|
+
export type NativePickResult =
|
|
30
|
+
| { ok: true; path: string }
|
|
31
|
+
| { ok: false; reason: NativePickReason; detail?: string }
|
|
32
|
+
|
|
33
|
+
/** 一次子进程调用的完整描述(便于单测注入假实现)。 */
|
|
34
|
+
export interface NativePickRun {
|
|
35
|
+
file: string
|
|
36
|
+
args: string[]
|
|
37
|
+
env: Record<string, string>
|
|
38
|
+
timeoutMs: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 子进程结果:`missing` 表示解释器/程序本身不存在(能力缺失,不是失败)。 */
|
|
42
|
+
export interface NativePickExec {
|
|
43
|
+
code: number | null
|
|
44
|
+
stdout: string
|
|
45
|
+
stderr: string
|
|
46
|
+
missing: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type NativePickSpawner = (run: NativePickRun) => Promise<NativePickExec>
|
|
50
|
+
|
|
51
|
+
export interface NativePickOptions {
|
|
52
|
+
platform?: NodeJS.Platform
|
|
53
|
+
startPath?: string | undefined
|
|
54
|
+
title?: string | undefined
|
|
55
|
+
timeoutMs?: number
|
|
56
|
+
spawner?: NativePickSpawner
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 用户可能在对话框里逛很久 —— 上限只用来兜住「进程卡死」,不是操作时限。 */
|
|
60
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000
|
|
61
|
+
|
|
62
|
+
/** 起始路径 / 标题的环境变量名(不进 argv,见文件头)。 */
|
|
63
|
+
export const PICK_ENV_START = 'DCM_PICK_START'
|
|
64
|
+
export const PICK_ENV_TITLE = 'DCM_PICK_TITLE'
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Windows:`IFileOpenDialog` 的 C# 互操作 + 一句 PowerShell 装配。
|
|
68
|
+
*
|
|
69
|
+
* 手写 vtable 顺序是这里唯一的硬骨头:`IFileDialog` 的方法必须**按声明顺序**逐一列出,
|
|
70
|
+
* 少一个/错一个都会在 Show 时炸成 HRESULT 0x80004002 之类;下面的顺序与 shobjidl.h 一致。
|
|
71
|
+
* 起始目录用 `SHCreateItemFromParsingName` 转 IShellItem(不存在的路径就静默忽略,仍能弹窗)。
|
|
72
|
+
*/
|
|
73
|
+
const WINDOWS_SCRIPT = `$ErrorActionPreference = 'Stop'
|
|
74
|
+
$src = @"
|
|
75
|
+
using System;
|
|
76
|
+
using System.Runtime.InteropServices;
|
|
77
|
+
using System.IO;
|
|
78
|
+
using System.Text;
|
|
79
|
+
using System.Threading;
|
|
80
|
+
|
|
81
|
+
[ComImport, Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
|
|
82
|
+
public class FileOpenDialogRCW { }
|
|
83
|
+
|
|
84
|
+
[ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
85
|
+
public interface IFileDialog {
|
|
86
|
+
[PreserveSig] int Show(IntPtr hwndParent);
|
|
87
|
+
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
|
|
88
|
+
void SetFileTypeIndex(uint iFileType);
|
|
89
|
+
[PreserveSig] uint GetFileTypeIndex();
|
|
90
|
+
[PreserveSig] int Advise(IntPtr pfde, out uint pdwCookie);
|
|
91
|
+
[PreserveSig] int Unadvise(uint dwCookie);
|
|
92
|
+
void SetOptions(uint fos);
|
|
93
|
+
[PreserveSig] int GetOptions(out uint pfos);
|
|
94
|
+
void SetDefaultFolder(IShellItem psi);
|
|
95
|
+
void SetFolder(IShellItem psi);
|
|
96
|
+
void GetFolder(out IShellItem ppsi);
|
|
97
|
+
void GetCurrentSelection(out IShellItem ppsi);
|
|
98
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
|
99
|
+
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
|
|
100
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
|
|
101
|
+
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
|
|
102
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
|
|
103
|
+
void GetResult(out IShellItem ppsi);
|
|
104
|
+
void AddPlace(IShellItem psi, int fdap);
|
|
105
|
+
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
|
|
106
|
+
void Close(int hr);
|
|
107
|
+
void SetClientGuid(ref Guid guid);
|
|
108
|
+
void ClearClientData();
|
|
109
|
+
void SetFilter(IntPtr pFilter);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
[ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
113
|
+
public interface IShellItem {
|
|
114
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
115
|
+
void GetParent(out IShellItem ppsi);
|
|
116
|
+
void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
|
|
117
|
+
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
|
|
118
|
+
void Compare(IShellItem psi, uint hint, out int piOrder);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public static class DcmNativePick {
|
|
122
|
+
const uint FOS_PICKFOLDERS = 0x20;
|
|
123
|
+
const uint FOS_FORCEFILESYSTEM = 0x40;
|
|
124
|
+
const uint FOS_PATHMUSTEXIST = 0x800;
|
|
125
|
+
const uint SIGDN_FILESYSPATH = 0x80058000;
|
|
126
|
+
|
|
127
|
+
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
|
|
128
|
+
static extern void SHCreateItemFromParsingName(string pszPath, IntPtr pbc, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
|
|
129
|
+
|
|
130
|
+
static IShellItem Item(string path) {
|
|
131
|
+
Guid iid = typeof(IShellItem).GUID;
|
|
132
|
+
IShellItem item;
|
|
133
|
+
SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out item);
|
|
134
|
+
return item;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
static IFileDialog Create(string startPath) {
|
|
138
|
+
IFileDialog dlg = (IFileDialog)new FileOpenDialogRCW();
|
|
139
|
+
uint opts;
|
|
140
|
+
dlg.GetOptions(out opts);
|
|
141
|
+
dlg.SetOptions(opts | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
|
|
142
|
+
if (!string.IsNullOrEmpty(startPath)) {
|
|
143
|
+
try { dlg.SetFolder(Item(startPath)); } catch { }
|
|
144
|
+
}
|
|
145
|
+
return dlg;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Interop smoke check: builds the dialog and reads the start folder without showing it. */
|
|
149
|
+
public static string Probe(string startPath) {
|
|
150
|
+
IFileDialog dlg = Create(startPath);
|
|
151
|
+
if (string.IsNullOrEmpty(startPath)) return "ready";
|
|
152
|
+
IShellItem folder;
|
|
153
|
+
dlg.GetFolder(out folder);
|
|
154
|
+
string name;
|
|
155
|
+
folder.GetDisplayName(SIGDN_FILESYSPATH, out name);
|
|
156
|
+
return name;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
160
|
+
|
|
161
|
+
[DllImport("user32.dll")]
|
|
162
|
+
static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
|
163
|
+
|
|
164
|
+
[DllImport("user32.dll")]
|
|
165
|
+
static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
|
166
|
+
|
|
167
|
+
[DllImport("user32.dll")]
|
|
168
|
+
static extern bool IsWindowVisible(IntPtr hWnd);
|
|
169
|
+
|
|
170
|
+
[DllImport("user32.dll")]
|
|
171
|
+
static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
|
|
172
|
+
|
|
173
|
+
[DllImport("user32.dll")]
|
|
174
|
+
static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
|
175
|
+
|
|
176
|
+
[DllImport("user32.dll")]
|
|
177
|
+
static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
178
|
+
|
|
179
|
+
[DllImport("user32.dll")]
|
|
180
|
+
static extern IntPtr GetForegroundWindow();
|
|
181
|
+
|
|
182
|
+
[DllImport("user32.dll")]
|
|
183
|
+
static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
|
184
|
+
|
|
185
|
+
[DllImport("kernel32.dll")]
|
|
186
|
+
static extern uint GetCurrentProcessId();
|
|
187
|
+
|
|
188
|
+
[DllImport("kernel32.dll")]
|
|
189
|
+
static extern uint GetCurrentThreadId();
|
|
190
|
+
|
|
191
|
+
const uint GW_OWNER = 4;
|
|
192
|
+
const uint SWP_NOSIZE = 0x1;
|
|
193
|
+
const uint SWP_NOMOVE = 0x2;
|
|
194
|
+
const uint SWP_NOZORDER = 0x4;
|
|
195
|
+
const uint SWP_SHOWWINDOW = 0x40;
|
|
196
|
+
const uint SPI_GETWORKAREA = 0x30;
|
|
197
|
+
const int GWL_EXSTYLE = -20;
|
|
198
|
+
const int WS_EX_TOOLWINDOW = 0x80;
|
|
199
|
+
const int WS_EX_APPWINDOW = 0x40000;
|
|
200
|
+
const int SW_HIDE = 0;
|
|
201
|
+
const byte VK_MENU = 0x12;
|
|
202
|
+
const uint KEYEVENTF_KEYUP = 0x2;
|
|
203
|
+
const int SW_SHOW = 5;
|
|
204
|
+
|
|
205
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
206
|
+
struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
|
207
|
+
|
|
208
|
+
[DllImport("user32.dll")]
|
|
209
|
+
static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
|
210
|
+
|
|
211
|
+
[DllImport("user32.dll")]
|
|
212
|
+
static extern bool SystemParametersInfo(uint action, uint param, out RECT rect, uint winIni);
|
|
213
|
+
|
|
214
|
+
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
|
215
|
+
static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int index);
|
|
216
|
+
|
|
217
|
+
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
|
218
|
+
static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int index, IntPtr value);
|
|
219
|
+
|
|
220
|
+
[DllImport("user32.dll")]
|
|
221
|
+
static extern bool ShowWindow(IntPtr hWnd, int cmd);
|
|
222
|
+
|
|
223
|
+
[DllImport("user32.dll")]
|
|
224
|
+
static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
|
|
225
|
+
|
|
226
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
227
|
+
static extern int GetClassName(IntPtr hWnd, StringBuilder name, int count);
|
|
228
|
+
|
|
229
|
+
static void Log(string message) {
|
|
230
|
+
// Debug output goes to a file: a killed child loses its stderr, and with it the only clue.
|
|
231
|
+
string path = Environment.GetEnvironmentVariable("DCM_PICK_DEBUG");
|
|
232
|
+
if (string.IsNullOrEmpty(path)) return;
|
|
233
|
+
try { using (StreamWriter writer = File.AppendText(path)) { writer.WriteLine(message); } } catch { }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The dialog is a #32770 window owned by this process. Ours is the only visible top-level
|
|
238
|
+
* window here, but walk the class name first so a future console/helper window cannot win.
|
|
239
|
+
*/
|
|
240
|
+
static IntPtr FindOwnDialog() {
|
|
241
|
+
uint self = GetCurrentProcessId();
|
|
242
|
+
IntPtr fallback = IntPtr.Zero;
|
|
243
|
+
IntPtr dialog = IntPtr.Zero;
|
|
244
|
+
EnumWindows((h, l) => {
|
|
245
|
+
uint pid;
|
|
246
|
+
GetWindowThreadProcessId(h, out pid);
|
|
247
|
+
if (pid != self || !IsWindowVisible(h) || GetWindow(h, GW_OWNER) != IntPtr.Zero) return true;
|
|
248
|
+
StringBuilder name = new StringBuilder(64);
|
|
249
|
+
GetClassName(h, name, 64);
|
|
250
|
+
if (name.ToString() == "#32770") { dialog = h; return false; }
|
|
251
|
+
if (fallback == IntPtr.Zero) fallback = h;
|
|
252
|
+
return true;
|
|
253
|
+
}, IntPtr.Zero);
|
|
254
|
+
return dialog != IntPtr.Zero ? dialog : fallback;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* IFileOpenDialog is created by a windowless helper process, so Windows gives it no owner and
|
|
259
|
+
* remembers no position: it lands wherever the default puts it (often half under the taskbar)
|
|
260
|
+
* and adds a second taskbar entry that the user has to click before the dialog is usable.
|
|
261
|
+
* Fix all three here: drop the taskbar entry (WS_EX_TOOLWINDOW), center the dialog inside the
|
|
262
|
+
* work area, keep it topmost, then borrow the foreground thread's input queue to activate it.
|
|
263
|
+
*/
|
|
264
|
+
static void BringToFront() {
|
|
265
|
+
for (int i = 0; i < 400; i++) {
|
|
266
|
+
IntPtr h = FindOwnDialog();
|
|
267
|
+
if (h != IntPtr.Zero) {
|
|
268
|
+
Log("dialog hwnd=" + h.ToInt64().ToString() + " attempt=" + i.ToString());
|
|
269
|
+
HideFromTaskbar(h);
|
|
270
|
+
CenterOnWorkArea(h);
|
|
271
|
+
Activate(h);
|
|
272
|
+
bool top = SetWindowPos(h, new IntPtr(-1), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
|
|
273
|
+
ShowWindow(h, SW_SHOW);
|
|
274
|
+
Log("topmost=" + top.ToString() + " ex=0x" + GetWindowLongPtr(h, GWL_EXSTYLE).ToInt64().ToString("X") + " prev=" + GetWindow(h, 3).ToInt64().ToString());
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
Thread.Sleep(50);
|
|
278
|
+
}
|
|
279
|
+
Log("dialog window never appeared");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Re-parent the dialog into the tool window band so it stops advertising itself as a new app. */
|
|
283
|
+
static void HideFromTaskbar(IntPtr h) {
|
|
284
|
+
try {
|
|
285
|
+
IntPtr style = GetWindowLongPtr(h, GWL_EXSTYLE);
|
|
286
|
+
long value = style.ToInt64() & ~(long)WS_EX_APPWINDOW | (long)WS_EX_TOOLWINDOW;
|
|
287
|
+
SetWindowLongPtr(h, GWL_EXSTYLE, new IntPtr(value));
|
|
288
|
+
Log("exstyle=" + value.ToString());
|
|
289
|
+
} catch (Exception error) {
|
|
290
|
+
Log("exstyle failed: " + error.Message);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Center inside the work area so the dialog never opens half under the taskbar. */
|
|
295
|
+
static void CenterOnWorkArea(IntPtr h) {
|
|
296
|
+
RECT window;
|
|
297
|
+
RECT area;
|
|
298
|
+
if (!GetWindowRect(h, out window)) return;
|
|
299
|
+
if (!SystemParametersInfo(SPI_GETWORKAREA, 0, out area, 0)) return;
|
|
300
|
+
int width = window.Right - window.Left;
|
|
301
|
+
int height = window.Bottom - window.Top;
|
|
302
|
+
if (width <= 0 || height <= 0) return;
|
|
303
|
+
int x = area.Left + (area.Right - area.Left - width) / 2;
|
|
304
|
+
int y = area.Top + (area.Bottom - area.Top - height) / 2;
|
|
305
|
+
SetWindowPos(h, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_SHOWWINDOW);
|
|
306
|
+
Log("centered " + x.ToString() + "," + y.ToString() + " size=" + width.ToString() + "x" + height.ToString());
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** SetForegroundWindow can silently fail; retry with the input queue attached until it sticks. */
|
|
310
|
+
static void Activate(IntPtr h) {
|
|
311
|
+
// A background process has no input history, so SetForegroundWindow is refused silently; pulse Alt first to unlock it.
|
|
312
|
+
keybd_event(VK_MENU, 0, 0, UIntPtr.Zero);
|
|
313
|
+
keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, UIntPtr.Zero);
|
|
314
|
+
for (int i = 0; i < 20; i++) {
|
|
315
|
+
uint pid;
|
|
316
|
+
IntPtr front = GetForegroundWindow();
|
|
317
|
+
if (front == h) { Log("activated attempt=" + i.ToString()); return; }
|
|
318
|
+
uint fgThread = GetWindowThreadProcessId(front, out pid);
|
|
319
|
+
uint selfThread = GetCurrentThreadId();
|
|
320
|
+
bool attached = AttachThreadInput(selfThread, fgThread, true);
|
|
321
|
+
bool done = SetForegroundWindow(h);
|
|
322
|
+
AttachThreadInput(selfThread, fgThread, false);
|
|
323
|
+
Log("activate attempt=" + i.ToString() + " attach=" + attached.ToString() + " ok=" + done.ToString() + " fg=" + GetForegroundWindow().ToInt64().ToString());
|
|
324
|
+
if (GetForegroundWindow() == h) return;
|
|
325
|
+
Thread.Sleep(100);
|
|
326
|
+
}
|
|
327
|
+
Log("activation never stuck; window stays topmost");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Shows the native picker: returns the path, or "CANCEL" / "ERR:<hr>". */
|
|
331
|
+
public static string Show(string title, string startPath) {
|
|
332
|
+
IFileDialog dlg = Create(startPath);
|
|
333
|
+
if (!string.IsNullOrEmpty(title)) dlg.SetTitle(title);
|
|
334
|
+
Thread helper = new Thread(BringToFront);
|
|
335
|
+
helper.IsBackground = true;
|
|
336
|
+
helper.Start();
|
|
337
|
+
int hr = dlg.Show(IntPtr.Zero);
|
|
338
|
+
if (hr == unchecked((int)0x800704C7)) return "CANCEL";
|
|
339
|
+
if (hr != 0) return "ERR:" + hr;
|
|
340
|
+
IShellItem item;
|
|
341
|
+
dlg.GetResult(out item);
|
|
342
|
+
string path;
|
|
343
|
+
item.GetDisplayName(SIGDN_FILESYSPATH, out path);
|
|
344
|
+
return path;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
"@
|
|
348
|
+
Add-Type -TypeDefinition $src -Language CSharp
|
|
349
|
+
if ($env:DCM_PICK_PROBE -eq '1') {
|
|
350
|
+
$probe = [DcmNativePick]::Probe($env:DCM_PICK_START)
|
|
351
|
+
Write-Output ('PROBE:' + $probe)
|
|
352
|
+
exit 0
|
|
353
|
+
}
|
|
354
|
+
$out = [DcmNativePick]::Show($env:DCM_PICK_TITLE, $env:DCM_PICK_START)
|
|
355
|
+
if ($out -eq 'CANCEL') { exit 2 }
|
|
356
|
+
if ($out.StartsWith('ERR:')) { [Console]::Error.WriteLine($out); exit 3 }
|
|
357
|
+
Write-Output $out
|
|
358
|
+
exit 0`
|
|
359
|
+
|
|
360
|
+
/** macOS:Finder 的标准选择面板;取消时 osascript 退出码 1。 */
|
|
361
|
+
const MAC_SCRIPT = `on run argv
|
|
362
|
+
try
|
|
363
|
+
set thePath to (POSIX path of (choose folder with prompt (item 1 of argv) default location (POSIX file (item 2 of argv))))
|
|
364
|
+
on error number -128
|
|
365
|
+
error number -128
|
|
366
|
+
end try
|
|
367
|
+
return thePath
|
|
368
|
+
end run`
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 子进程执行器:不经过 shell,纯 argv。
|
|
372
|
+
*
|
|
373
|
+
* `ENOENT` 必须和「退出码非 0」分开 —— 前者是**这台机器没有这个程序**(能力缺失 ⇒ 回落内置
|
|
374
|
+
* 浏览器),后者是对话框自身出错(如实报告但仍回落)。两者混起来会让「Linux 没装 zenity」
|
|
375
|
+
* 被当成失败弹红条。
|
|
376
|
+
*/
|
|
377
|
+
export function defaultSpawner(run: NativePickRun): Promise<NativePickExec> {
|
|
378
|
+
return new Promise<NativePickExec>((resolve) => {
|
|
379
|
+
let settled = false
|
|
380
|
+
const finish = (value: NativePickExec): void => {
|
|
381
|
+
if (settled) return
|
|
382
|
+
settled = true
|
|
383
|
+
resolve(value)
|
|
384
|
+
}
|
|
385
|
+
let child: ReturnType<typeof spawn>
|
|
386
|
+
try {
|
|
387
|
+
child = spawn(run.file, run.args, {
|
|
388
|
+
env: { ...process.env, ...run.env },
|
|
389
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
390
|
+
windowsHide: true,
|
|
391
|
+
})
|
|
392
|
+
} catch {
|
|
393
|
+
finish({ code: null, stdout: '', stderr: '', missing: true })
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
let stdout = ''
|
|
397
|
+
let stderr = ''
|
|
398
|
+
const timer = setTimeout(() => {
|
|
399
|
+
child.kill()
|
|
400
|
+
// 超时也保留子进程已经吐出的 stderr:调试标志的日志全在那里,丢掉就无法定位。
|
|
401
|
+
const tail = stderr.trim()
|
|
402
|
+
finish({ code: null, stdout, stderr: tail === '' ? 'timed out' : `timed out\n${tail}`, missing: false })
|
|
403
|
+
}, run.timeoutMs)
|
|
404
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
405
|
+
stdout += chunk.toString('utf8')
|
|
406
|
+
})
|
|
407
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
408
|
+
stderr += chunk.toString('utf8')
|
|
409
|
+
})
|
|
410
|
+
child.on('error', (error: NodeJS.ErrnoException) => {
|
|
411
|
+
clearTimeout(timer)
|
|
412
|
+
finish({ code: null, stdout, stderr, missing: error.code === 'ENOENT' })
|
|
413
|
+
})
|
|
414
|
+
child.on('close', (code: number | null) => {
|
|
415
|
+
clearTimeout(timer)
|
|
416
|
+
finish({ code, stdout, stderr, missing: false })
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
interface Attempt {
|
|
422
|
+
file: string
|
|
423
|
+
args: string[]
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function win32Attempt(): Attempt {
|
|
427
|
+
// -STA:PowerShell 7 默认 MTA,COM 模态对话框必须在 STA 线程里跑。
|
|
428
|
+
return { file: 'powershell', args: ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_SCRIPT] }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function darwinAttempt(startPath: string, title: string): Attempt {
|
|
432
|
+
return { file: 'osascript', args: ['-e', MAC_SCRIPT, title, startPath] }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Linux 候选:先 GTK(GNOME 系),再 KDE。两个都没有就是 unsupported。 */
|
|
436
|
+
function linuxAttempts(startPath: string, title: string): Attempt[] {
|
|
437
|
+
return [
|
|
438
|
+
{ file: 'zenity', args: ['--file-selection', '--directory', '--title=' + title, '--filename=' + startPath] },
|
|
439
|
+
{ file: 'kdialog', args: ['--getexistingdirectory', startPath, '--title', title] },
|
|
440
|
+
]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function firstLine(text: string): string {
|
|
444
|
+
return text.trim().split(/\r?\n/)[0] ?? ''
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 归一化用户选中的路径:剥掉多余的尾部分隔符(反斜杠或斜杠)。
|
|
449
|
+
*
|
|
450
|
+
* 两个必须原样保留的形状:Windows 盘符根与 POSIX 根 —— 盘符根剥掉尾部分隔符就成了相对路径,
|
|
451
|
+
* POSIX 根剥掉就成了空串。其余尾分隔符一律去掉:映射表里带与不带尾分隔符的前缀会被当成两个
|
|
452
|
+
* 不同前缀,用户看不出区别却导致漏映射。
|
|
453
|
+
*/
|
|
454
|
+
function normalizePickedPath(raw: string): string {
|
|
455
|
+
const trimmed = raw.trim()
|
|
456
|
+
if (trimmed === '') return ''
|
|
457
|
+
const driveRoot = /^([A-Za-z]:)[\\/]+$/.exec(trimmed)
|
|
458
|
+
if (driveRoot !== null) return `${driveRoot[1] as string}\\`
|
|
459
|
+
const stripped = trimmed.replace(/[\\/]+$/, '')
|
|
460
|
+
return stripped === '' ? trimmed : stripped
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 弹原生目录选择器。
|
|
465
|
+
*
|
|
466
|
+
* 返回三态:`ok`(用户选了)/ `canceled`(用户关了窗口)/ `unsupported`、`failed`(回落内置浏览器)。
|
|
467
|
+
* 绝不让「对话框不可用」变成用户的操作死路。
|
|
468
|
+
*/
|
|
469
|
+
export async function pickNativeDirectory(options: NativePickOptions = {}): Promise<NativePickResult> {
|
|
470
|
+
const platform = options.platform ?? process.platform
|
|
471
|
+
const spawner = options.spawner ?? defaultSpawner
|
|
472
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
473
|
+
const title = options.title !== undefined && options.title !== '' ? options.title : 'Select folder'
|
|
474
|
+
const startPath = options.startPath !== undefined ? options.startPath : ''
|
|
475
|
+
|
|
476
|
+
const env: Record<string, string> = { [PICK_ENV_START]: startPath, [PICK_ENV_TITLE]: title }
|
|
477
|
+
|
|
478
|
+
if (platform === 'win32') {
|
|
479
|
+
const exec = await spawner({ ...win32Attempt(), env, timeoutMs })
|
|
480
|
+
if (exec.missing) return { ok: false, reason: 'unsupported', detail: 'powershell not found' }
|
|
481
|
+
if (exec.code === 2) return { ok: false, reason: 'canceled' }
|
|
482
|
+
if (exec.code !== 0) {
|
|
483
|
+
return { ok: false, reason: 'failed', detail: firstLine(exec.stderr) || `exit ${String(exec.code)}` }
|
|
484
|
+
}
|
|
485
|
+
const path = normalizePickedPath(firstLine(exec.stdout))
|
|
486
|
+
return path === '' ? { ok: false, reason: 'canceled' } : { ok: true, path }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (platform === 'darwin') {
|
|
490
|
+
const exec = await spawner({ ...darwinAttempt(startPath, title), env, timeoutMs })
|
|
491
|
+
if (exec.missing) return { ok: false, reason: 'unsupported', detail: 'osascript not found' }
|
|
492
|
+
if (exec.code !== 0) {
|
|
493
|
+
// osascript 的「用户取消」= error number -128,统一表现在 stderr 里。
|
|
494
|
+
if (/[-]128/.test(exec.stderr) || exec.code === 1) return { ok: false, reason: 'canceled' }
|
|
495
|
+
return { ok: false, reason: 'failed', detail: firstLine(exec.stderr) || `exit ${String(exec.code)}` }
|
|
496
|
+
}
|
|
497
|
+
const path = normalizePickedPath(firstLine(exec.stdout))
|
|
498
|
+
return path === '' ? { ok: false, reason: 'canceled' } : { ok: true, path }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (platform === 'linux') {
|
|
502
|
+
const attempts = linuxAttempts(startPath, title)
|
|
503
|
+
for (const attempt of attempts) {
|
|
504
|
+
const exec = await spawner({ ...attempt, env, timeoutMs })
|
|
505
|
+
if (exec.missing) continue // 没装这个工具,换下一个
|
|
506
|
+
if (exec.code === 1) return { ok: false, reason: 'canceled' }
|
|
507
|
+
if (exec.code !== 0) {
|
|
508
|
+
return { ok: false, reason: 'failed', detail: firstLine(exec.stderr) || `exit ${String(exec.code)}` }
|
|
509
|
+
}
|
|
510
|
+
const path = normalizePickedPath(firstLine(exec.stdout))
|
|
511
|
+
return path === '' ? { ok: false, reason: 'canceled' } : { ok: true, path }
|
|
512
|
+
}
|
|
513
|
+
return { ok: false, reason: 'unsupported', detail: 'neither zenity nor kdialog found' }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return { ok: false, reason: 'unsupported', detail: `platform ${platform}` }
|
|
517
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { browseDirectory, listRoots } from './path-browse.ts'
|
|
7
|
+
|
|
8
|
+
/** 建一棵一次性目录树:b-dir/ a-dir/ b.txt a.txt(故意让名字序与目录序相反)。 */
|
|
9
|
+
async function withTree(run: (root: string) => Promise<void>): Promise<void> {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'dcm-browse-'))
|
|
11
|
+
try {
|
|
12
|
+
await mkdir(join(root, 'a-dir'))
|
|
13
|
+
await mkdir(join(root, 'b-dir'))
|
|
14
|
+
await writeFile(join(root, 'a.txt'), 'a')
|
|
15
|
+
await writeFile(join(root, 'b.txt'), 'b')
|
|
16
|
+
await run(root)
|
|
17
|
+
} finally {
|
|
18
|
+
await rm(root, { recursive: true, force: true })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('browseDirectory: 目录在前、同类按名字排序,并回填父目录', async () => {
|
|
23
|
+
await withTree(async (root) => {
|
|
24
|
+
const result = await browseDirectory(root)
|
|
25
|
+
assert.equal(result.error, null)
|
|
26
|
+
assert.equal(result.path, root)
|
|
27
|
+
assert.equal(result.parent, dirname(root))
|
|
28
|
+
assert.equal(result.truncated, false)
|
|
29
|
+
assert.deepEqual(result.entries.map((entry) => entry.name), ['a-dir', 'b-dir', 'a.txt', 'b.txt'])
|
|
30
|
+
assert.deepEqual(result.entries.map((entry) => entry.kind), ['dir', 'dir', 'file', 'file'])
|
|
31
|
+
// 条目路径必须是可直接回填输入框的绝对路径
|
|
32
|
+
assert.equal(result.entries[0]?.path, join(root, 'a-dir'))
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('browseDirectory: 非绝对路径不猜基准目录', async () => {
|
|
37
|
+
const result = await browseDirectory('relative/path')
|
|
38
|
+
assert.equal(result.error, 'not-absolute')
|
|
39
|
+
assert.deepEqual(result.entries, [])
|
|
40
|
+
assert.equal(result.parent, null)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('browseDirectory: 不存在的目录是 not-found(不是 failed)', async () => {
|
|
44
|
+
await withTree(async (root) => {
|
|
45
|
+
const result = await browseDirectory(join(root, 'nope'))
|
|
46
|
+
assert.equal(result.error, 'not-found')
|
|
47
|
+
assert.deepEqual(result.entries, [])
|
|
48
|
+
assert.equal(result.parent, root)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('browseDirectory: 末尾分隔符被归一化,与无分隔符结果一致', async () => {
|
|
53
|
+
await withTree(async (root) => {
|
|
54
|
+
const withSlash = await browseDirectory(root + (process.platform === 'win32' ? '\\' : '/'))
|
|
55
|
+
const plain = await browseDirectory(root)
|
|
56
|
+
assert.equal(withSlash.path, plain.path)
|
|
57
|
+
assert.deepEqual(withSlash.entries, plain.entries)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('browseDirectory: 命中上限即截断并回报 truncated', async () => {
|
|
62
|
+
await withTree(async (root) => {
|
|
63
|
+
const result = await browseDirectory(root, { limit: 2 })
|
|
64
|
+
assert.equal(result.entries.length, 2)
|
|
65
|
+
assert.equal(result.truncated, true)
|
|
66
|
+
assert.deepEqual(result.entries.map((entry) => entry.name), ['a-dir', 'b-dir'])
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('browseDirectory: 空路径返回根列表(path 为空、无可进入的父级)', async () => {
|
|
71
|
+
const result = await browseDirectory(undefined)
|
|
72
|
+
assert.equal(result.error, null)
|
|
73
|
+
assert.equal(result.path, '')
|
|
74
|
+
assert.equal(result.parent, null)
|
|
75
|
+
assert.equal(result.truncated, false)
|
|
76
|
+
assert.ok(result.entries.length > 0)
|
|
77
|
+
assert.ok(result.entries.every((entry) => entry.kind === 'dir'))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('listRoots: 至少给一条可用的根,且都在本机绝对路径形态', async () => {
|
|
81
|
+
const roots = await listRoots()
|
|
82
|
+
assert.ok(roots.length > 0)
|
|
83
|
+
for (const root of roots) {
|
|
84
|
+
assert.equal(root.kind, 'dir')
|
|
85
|
+
assert.equal(root.path, root.name)
|
|
86
|
+
}
|
|
87
|
+
if (process.platform !== 'win32') assert.deepEqual(roots.map((root) => root.path), ['/'])
|
|
88
|
+
})
|