dsh-users-open-source-workspace 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/lib/focus.cs +44 -0
- package/lib/index.js +423 -0
- package/lib/open-folder.ps1 +56 -0
- package/package.json +45 -0
- package/tests/smoke.mjs +238 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KAIROSLLL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# dsh-users-open-source-workspace
|
|
2
|
+
|
|
3
|
+
`dsh-users-open-source` 的伴生插件:受理 `POST /dsh-users-open-source/open-workspace`,在系统文件管理器里
|
|
4
|
+
打开**用户当前正在用的那个工作区目录**(Web 界面上就是设置页「开源用户」里的 **云端备份** 按钮)。
|
|
5
|
+
|
|
6
|
+
## 目录是怎么定的
|
|
7
|
+
|
|
8
|
+
按可靠性依次尝试,任一命中即用:
|
|
9
|
+
|
|
10
|
+
1. 请求里带 `sessionId` → 命中该会话所属的工作区(最准);
|
|
11
|
+
2. 会话日志分桶目录里**最新写入**的那个桶 → 编码回工作区路径
|
|
12
|
+
(例如 `D:\work\my-project` ⇄ `--D-work-my-project--`)。当前会话一直在写日志,所以这条能顶住 id 缺失;
|
|
13
|
+
3. 会话投影缓存 `$DSH_HOME/storages/session_projcache/sessions/*.json` 里最新那份的 `cwd`;
|
|
14
|
+
4. 注册表里 `updatedAt` 最新的工作区;
|
|
15
|
+
5. 全落空 → 注册表第一个工作区。
|
|
16
|
+
|
|
17
|
+
客户端只能递 `sessionId`,**路径永远由宿主从 DSH 自己的账本里取**,所以不存在
|
|
18
|
+
「递个路径就打开任意目录」的面。打开动作走宿主自己的 `ctx.subprocess`,目录作为 argv
|
|
19
|
+
里的一项直传、不经 shell,所以空格、中文、`&` 都不会被当成命令。
|
|
20
|
+
|
|
21
|
+
## 为什么 Windows 上要绕一圈 PowerShell
|
|
22
|
+
|
|
23
|
+
DSH 以**隐藏方式**启动插件的子进程 —— 直接 `spawn('explorer.exe', [dir])` 得到的窗口
|
|
24
|
+
确实存在(COM 能枚举到 `CabinetWClass`),但 `IsWindowVisible` 是 `false`:**看不见**。
|
|
25
|
+
后台进程再直接调 `SetForegroundWindow` 也会被 Windows 拒绝。
|
|
26
|
+
|
|
27
|
+
所以 Windows 上改走包内的 `open-folder.ps1` + `focus.cs`:用 `Shell.Application.Explore`
|
|
28
|
+
打开目录,再用 `AttachThreadInput` + `ShowWindow(SW_SHOW/SW_RESTORE)` +
|
|
29
|
+
`BringWindowToTop` + `SetForegroundWindow` 把窗口真正显示并顶到前台(实测:窗口
|
|
30
|
+
`visible` 由 false 变 true,前台窗口变成「工作区名 - 文件资源管理器」)。
|
|
31
|
+
|
|
32
|
+
## 为什么是独立的包
|
|
33
|
+
|
|
34
|
+
`dsh-users-open-source` 带 `dsh.client` 客户端半边,而 `dsh-client-modules` 按「模块最近的
|
|
35
|
+
package.json」判定来源包 —— 同一个包里再挂第二个 loader 入口:
|
|
36
|
+
|
|
37
|
+
- name 写包内子路径 → loader 解析不了,entry 报 `failed to import`;
|
|
38
|
+
- name 写 file: URL → 被算成本包第二个 Loader 源,启动直接崩:
|
|
39
|
+
`client-modules: package dsh-users-open-source resolves from multiple active Loader sources`。
|
|
40
|
+
|
|
41
|
+
独立成包后 entry name 就是包名,两边都干净,宿主侧改动也不必等 dsh web 重启。
|
|
42
|
+
|
|
43
|
+
## 安装
|
|
44
|
+
|
|
45
|
+
```powershell
|
|
46
|
+
# 通常不需要单独装:它由主包 dsh-users-open-source 作为 dependency 带上来
|
|
47
|
+
dsh plugin --profile desktop add "link:<本仓库>\packages\dsh-users-open-source-workspace"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 自检
|
|
51
|
+
|
|
52
|
+
```powershell
|
|
53
|
+
node tests/smoke.mjs
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
9 项:路由注册、sessionId 命中、三条兜底线索各自命中、全落空退第一个、
|
|
57
|
+
目录缺失、无工作区、非 POST 405。
|
package/lib/focus.cs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Text;
|
|
3
|
+
using System.Runtime.InteropServices;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// Win32 helpers for "open a folder and make sure its window is actually visible".
|
|
7
|
+
/// DSH starts plugin subprocesses hidden, so an explorer window spawned that way is
|
|
8
|
+
/// created with visible = false; plain SetForegroundWindow from a background process
|
|
9
|
+
/// is refused by Windows, hence the AttachThreadInput trick.
|
|
10
|
+
/// </summary>
|
|
11
|
+
public class FolderFocus
|
|
12
|
+
{
|
|
13
|
+
public delegate bool EnumProc(IntPtr hwnd, IntPtr lparam);
|
|
14
|
+
|
|
15
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lparam);
|
|
16
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hwnd, StringBuilder text, int max);
|
|
17
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(IntPtr hwnd, StringBuilder text, int max);
|
|
18
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hwnd);
|
|
19
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hwnd);
|
|
20
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hwnd, int cmd);
|
|
21
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hwnd);
|
|
22
|
+
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hwnd);
|
|
23
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
24
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, IntPtr pid);
|
|
25
|
+
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint attach, uint attachTo, bool attachFlag);
|
|
26
|
+
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hwnd, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags);
|
|
27
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo);
|
|
28
|
+
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
|
29
|
+
|
|
30
|
+
/// <summary>VK_MENU (ALT). A synthetic tap makes Windows treat the focus change as user-driven.</summary>
|
|
31
|
+
public const byte VK_MENU = 0x12;
|
|
32
|
+
|
|
33
|
+
/// <summary>KEYEVENTF_KEYUP.</summary>
|
|
34
|
+
public const uint KEYEVENTF_KEYUP = 0x0002;
|
|
35
|
+
|
|
36
|
+
/// <summary>HWND_TOPMOST: keep the window above non-topmost windows.</summary>
|
|
37
|
+
public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
|
|
38
|
+
|
|
39
|
+
/// <summary>HWND_NOTOPMOST: return the window to the normal band.</summary>
|
|
40
|
+
public static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2);
|
|
41
|
+
|
|
42
|
+
/// <summary>SWP_NOSIZE | SWP_NOMOVE | SWP_SHOWWINDOW.</summary>
|
|
43
|
+
public const uint SWP_KEEP = 0x0001 | 0x0002 | 0x0040;
|
|
44
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-users-open-source-workspace — 「云端备份」的宿主半边(打开本地工作区目录)。
|
|
3
|
+
*
|
|
4
|
+
* 干什么:受理 `POST /dsh-users-open-source/open-workspace`,在系统文件管理器里打开
|
|
5
|
+
* **用户当前正在用的那个工作区目录**。
|
|
6
|
+
*
|
|
7
|
+
* 目录怎么定的(按可靠性排序,任何一步都只从 DSH 自己的账本里取路径):
|
|
8
|
+
* 1. 客户端递来的 sessionId 命中的工作区(最准,但客户端不一定拿得到 id);
|
|
9
|
+
* 2. 最近有写入的会话目录 → 反解它所属的 workspace bucket(`$DSH_HOME/sessions/<bucket>/`)
|
|
10
|
+
* → 匹配已注册工作区。当前正在跑的会话日志一直在写,所以这一条能顶住 id 缺失;
|
|
11
|
+
* 3. 注册表里的第一个工作区。
|
|
12
|
+
* 客户端永远只能递 sessionId,**路径不由客户端决定**,所以不存在「递个路径就打开任意目录」。
|
|
13
|
+
*
|
|
14
|
+
* 为什么是**独立的包**(而不是 dsh-users-open-source 包里的第二个 entry):
|
|
15
|
+
* dsh-users-open-source 带 `dsh.client` 客户端半边,`dsh-client-modules` 按「模块最近的
|
|
16
|
+
* package.json」判定来源包 —— 同包内再挂第二个 loader 入口,无论 name 写包内子路径
|
|
17
|
+
* (loader 解析不了,报 failed to import)还是写 file: URL(被算成第二个 Loader 源,
|
|
18
|
+
* `package dsh-users-open-source resolves from multiple active Loader sources`,启动崩),
|
|
19
|
+
* 都走不通。独立成包后 entry name 就是包名,两边都干净。
|
|
20
|
+
* 顺带:宿主的「云端备份」按钮改用本条路由,所以它不必等 dsh web 重启。
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-users-open-source-workspace
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync } from 'node:fs'
|
|
25
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
26
|
+
import { homedir } from 'node:os'
|
|
27
|
+
import { dirname, join } from 'node:path'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
|
|
30
|
+
/** 插件名(= 包名 = cordis loader 行 id)。 */
|
|
31
|
+
export const name = 'dsh-users-open-source-workspace'
|
|
32
|
+
/** 依赖宿主 webServer(挂路由)与 subprocess(起文件管理器)。 */
|
|
33
|
+
export const inject = ['webServer', 'subprocess']
|
|
34
|
+
|
|
35
|
+
/** 路由路径(客户端只认这一个口)。 */
|
|
36
|
+
const OPEN_PATH = '/dsh-users-open-source/open-workspace'
|
|
37
|
+
|
|
38
|
+
/** 本文件所在目录(`open-folder.ps1` 与 `focus.cs` 都放在这里)。 */
|
|
39
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 系统自带 PowerShell 的绝对路径(不走 PATH:DSH 给子进程的 PATH 可能是清理过的)。
|
|
43
|
+
* @returns {string} powershell.exe 的绝对路径。
|
|
44
|
+
*/
|
|
45
|
+
function powershellPath() {
|
|
46
|
+
const root = typeof process.env.SystemRoot === 'string' && process.env.SystemRoot !== ''
|
|
47
|
+
? process.env.SystemRoot
|
|
48
|
+
: 'C:\\Windows'
|
|
49
|
+
return `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 解析 DSH home(与内核 `dshHomePath()` 同优先级:$DSH_HOME 优先,否则 ~/.dsh)。
|
|
54
|
+
* @returns {string} 绝对的 DSH home 路径。
|
|
55
|
+
*/
|
|
56
|
+
function dshHome() {
|
|
57
|
+
const configured = process.env.DSH_HOME
|
|
58
|
+
if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
|
|
59
|
+
return join(homedir(), '.dsh')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 写一个 JSON 响应。
|
|
64
|
+
* @param {import('node:http').ServerResponse} res - 响应对象。
|
|
65
|
+
* @param {number} status - HTTP 状态码。
|
|
66
|
+
* @param {unknown} payload - 要序列化的值。
|
|
67
|
+
*/
|
|
68
|
+
function sendJson(res, status, payload) {
|
|
69
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
|
70
|
+
res.writeHead(status, {
|
|
71
|
+
'content-type': 'application/json; charset=utf-8',
|
|
72
|
+
'cache-control': 'no-store',
|
|
73
|
+
'content-length': String(body.length)
|
|
74
|
+
})
|
|
75
|
+
res.end(body)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 读取请求体并解析 JSON,带大小上限。
|
|
80
|
+
* @param {import('node:http').IncomingMessage} req - 请求对象。
|
|
81
|
+
* @param {number} [limit] - 最大字节数。
|
|
82
|
+
* @returns {Promise<Record<string, unknown>>} 解析后的对象。
|
|
83
|
+
*/
|
|
84
|
+
function readJsonBody(req, limit = 8192) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const chunks = []
|
|
87
|
+
let size = 0
|
|
88
|
+
req.on('data', (chunk) => {
|
|
89
|
+
size += chunk.length
|
|
90
|
+
if (size > limit) {
|
|
91
|
+
reject(new Error('body too large'))
|
|
92
|
+
req.destroy()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
chunks.push(chunk)
|
|
96
|
+
})
|
|
97
|
+
req.on('end', () => {
|
|
98
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
99
|
+
if (text.trim() === '') {
|
|
100
|
+
resolve({})
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(text)
|
|
105
|
+
resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
|
|
106
|
+
} catch (error) {
|
|
107
|
+
reject(new Error(`invalid JSON body: ${String(error && error.message)}`))
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
req.on('error', reject)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 在系统文件管理器里打开一个目录。
|
|
116
|
+
*
|
|
117
|
+
* Windows:走包内的 `open-folder.ps1`。**不能**直接 spawn `explorer.exe` ——
|
|
118
|
+
* DSH 以隐藏方式启动插件的子进程,explorer 派生出来的窗口会带着 `visible=false`
|
|
119
|
+
* (实测:窗口存在、COM 能枚举到,但就是看不见),后台进程直接调
|
|
120
|
+
* SetForegroundWindow 也会被 Windows 拒绝。脚本里用
|
|
121
|
+
* Shell.Application.Explore + AttachThreadInput/ShowWindow/SetForegroundWindow
|
|
122
|
+
* 把窗口真正显出来并顶到前台。
|
|
123
|
+
*
|
|
124
|
+
* 其它平台:直接用系统自带的打开器,目录走 argv,不经 shell。
|
|
125
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - 宿主上下文。
|
|
126
|
+
* @param {string} directory - 要打开的绝对目录。
|
|
127
|
+
* @returns {string} 实际用的方式(写进日志与响应)。
|
|
128
|
+
*/
|
|
129
|
+
function openInFileManager(ctx, directory) {
|
|
130
|
+
if (process.platform === 'win32') {
|
|
131
|
+
const script = join(HERE, 'open-folder.ps1')
|
|
132
|
+
if (process.env.DSH_NET_SPEED_DRY_OPEN === '1') return 'powershell:open-folder.ps1'
|
|
133
|
+
const handle = ctx.subprocess.spawn({
|
|
134
|
+
argv: [
|
|
135
|
+
powershellPath(),
|
|
136
|
+
'-NoProfile',
|
|
137
|
+
'-NonInteractive',
|
|
138
|
+
'-ExecutionPolicy',
|
|
139
|
+
'Bypass',
|
|
140
|
+
'-File',
|
|
141
|
+
script,
|
|
142
|
+
directory
|
|
143
|
+
],
|
|
144
|
+
cwd: directory,
|
|
145
|
+
stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' },
|
|
146
|
+
graceMs: 20000
|
|
147
|
+
})
|
|
148
|
+
void handle.done.catch(() => {})
|
|
149
|
+
return 'powershell:open-folder.ps1'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const command = process.platform === 'darwin' ? '/usr/bin/open' : '/usr/bin/xdg-open'
|
|
153
|
+
if (process.env.DSH_NET_SPEED_DRY_OPEN === '1') return command
|
|
154
|
+
const handle = ctx.subprocess.spawn({
|
|
155
|
+
argv: [command, directory],
|
|
156
|
+
cwd: directory,
|
|
157
|
+
stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' },
|
|
158
|
+
graceMs: 3000
|
|
159
|
+
})
|
|
160
|
+
void handle.done.catch(() => {})
|
|
161
|
+
return command
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 把一个工作区路径编码成 DSH 的会话分桶目录名。
|
|
166
|
+
*
|
|
167
|
+
* 观察到的规则:`D:\work\my-project` → `--D-work-my-project--`(`:` 与 `\` `/` 都变 `-`,
|
|
168
|
+
* 原有的 `-` 保留,两端各包 `--`)。用于把会话目录反查回工作区。
|
|
169
|
+
* @param {string} path - 工作区绝对路径。
|
|
170
|
+
* @returns {string} 分桶目录名。
|
|
171
|
+
*/
|
|
172
|
+
function encodeWorkspaceBucket(path) {
|
|
173
|
+
return `--${path.replace(/[:\\/]/g, '-')}--`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 读一个会话目录里最新的文件写入时间。
|
|
178
|
+
* @param {string} directory - 会话目录。
|
|
179
|
+
* @returns {Promise<number>} 最新 mtime(毫秒),读不到时 0。
|
|
180
|
+
*/
|
|
181
|
+
async function newestWriteIn(directory) {
|
|
182
|
+
let newest = 0
|
|
183
|
+
let entries = []
|
|
184
|
+
try {
|
|
185
|
+
entries = await readdir(directory, { withFileTypes: true })
|
|
186
|
+
} catch {
|
|
187
|
+
return 0
|
|
188
|
+
}
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
if (!entry.isFile()) continue
|
|
191
|
+
try {
|
|
192
|
+
const info = await stat(join(directory, entry.name))
|
|
193
|
+
if (info.mtimeMs > newest) newest = info.mtimeMs
|
|
194
|
+
} catch {
|
|
195
|
+
/* 单个文件读不到不影响整体判断 */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return newest
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 两个路径是否指同一个目录(Windows 大小写不敏感、容忍尾部分隔符)。
|
|
203
|
+
* @param {unknown} a - 路径一。
|
|
204
|
+
* @param {unknown} b - 路径二。
|
|
205
|
+
* @returns {boolean} 相同则 true。
|
|
206
|
+
*/
|
|
207
|
+
function samePath(a, b) {
|
|
208
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false
|
|
209
|
+
const norm = (value) => value.replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase()
|
|
210
|
+
return norm(a) === norm(b)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 在一份 JSON 里找第一个非空 `cwd` 字符串。
|
|
215
|
+
* 投影缓存的结构随内核版本会变,所以不写死层级,只限深度。
|
|
216
|
+
* @param {unknown} value - 任意 JSON 值。
|
|
217
|
+
* @param {number} [depth] - 递归深度上限。
|
|
218
|
+
* @returns {string} 命中的 cwd,或空串。
|
|
219
|
+
*/
|
|
220
|
+
function findCwdDeep(value, depth = 0) {
|
|
221
|
+
if (depth > 5 || value === null || typeof value !== 'object') return ''
|
|
222
|
+
if (Array.isArray(value)) {
|
|
223
|
+
for (const item of value) {
|
|
224
|
+
const hit = findCwdDeep(item, depth + 1)
|
|
225
|
+
if (hit !== '') return hit
|
|
226
|
+
}
|
|
227
|
+
return ''
|
|
228
|
+
}
|
|
229
|
+
const record = value
|
|
230
|
+
if (typeof record.cwd === 'string' && record.cwd !== '') return record.cwd
|
|
231
|
+
for (const item of Object.values(record)) {
|
|
232
|
+
const hit = findCwdDeep(item, depth + 1)
|
|
233
|
+
if (hit !== '') return hit
|
|
234
|
+
}
|
|
235
|
+
return ''
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 线索 1:会话日志分桶 —— 当前会话一直在写,最新 mtime 的桶就是它。
|
|
240
|
+
* @param {object[]} workspaces - 已注册工作区。
|
|
241
|
+
* @returns {Promise<{path:string,title:string,source:'recent'}|null>} 命中结果。
|
|
242
|
+
*/
|
|
243
|
+
async function recentFromSessionBuckets(workspaces) {
|
|
244
|
+
const root = join(dshHome(), 'sessions')
|
|
245
|
+
let buckets = []
|
|
246
|
+
try {
|
|
247
|
+
buckets = await readdir(root, { withFileTypes: true })
|
|
248
|
+
} catch {
|
|
249
|
+
return null
|
|
250
|
+
}
|
|
251
|
+
let best = null
|
|
252
|
+
for (const bucket of buckets) {
|
|
253
|
+
if (!bucket.isDirectory()) continue
|
|
254
|
+
const bucketDir = join(root, bucket.name)
|
|
255
|
+
let sessions = []
|
|
256
|
+
try {
|
|
257
|
+
sessions = await readdir(bucketDir, { withFileTypes: true })
|
|
258
|
+
} catch {
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
for (const session of sessions) {
|
|
262
|
+
if (!session.isDirectory()) continue
|
|
263
|
+
const mtime = await newestWriteIn(join(bucketDir, session.name))
|
|
264
|
+
if (mtime > 0 && (best === null || mtime > best.mtime)) {
|
|
265
|
+
best = { bucket: bucket.name, mtime }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (best === null) return null
|
|
270
|
+
const hit = workspaces.find((workspace) => {
|
|
271
|
+
const path = workspace === null || workspace === undefined ? undefined : workspace.path
|
|
272
|
+
return typeof path === 'string' && encodeWorkspaceBucket(path) === best.bucket
|
|
273
|
+
})
|
|
274
|
+
if (hit === undefined || hit === null || typeof hit.path !== 'string') return null
|
|
275
|
+
return { path: hit.path, title: String(hit.title ?? ''), source: 'recent' }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 线索 2:会话投影缓存 —— 最新那个文件里带着明确的 cwd。
|
|
280
|
+
* @param {object[]} workspaces - 已注册工作区。
|
|
281
|
+
* @returns {Promise<{path:string,title:string,source:'recent'}|null>} 命中结果。
|
|
282
|
+
*/
|
|
283
|
+
async function recentFromProjectionCache(workspaces) {
|
|
284
|
+
const dir = join(dshHome(), 'storages', 'session_projcache', 'sessions')
|
|
285
|
+
let entries = []
|
|
286
|
+
try {
|
|
287
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
288
|
+
} catch {
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
let best = null
|
|
292
|
+
for (const entry of entries) {
|
|
293
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
|
294
|
+
const file = join(dir, entry.name)
|
|
295
|
+
try {
|
|
296
|
+
const info = await stat(file)
|
|
297
|
+
if (best === null || info.mtimeMs > best.mtime) best = { file, mtime: info.mtimeMs }
|
|
298
|
+
} catch {
|
|
299
|
+
/* 单个文件读不到不影响整体判断 */
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (best === null) return null
|
|
303
|
+
let cwd = ''
|
|
304
|
+
try {
|
|
305
|
+
cwd = findCwdDeep(JSON.parse(await readFile(best.file, 'utf8')))
|
|
306
|
+
} catch {
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
if (cwd === '') return null
|
|
310
|
+
const hit = workspaces.find((workspace) => {
|
|
311
|
+
const path = workspace === null || workspace === undefined ? undefined : workspace.path
|
|
312
|
+
return samePath(path, cwd)
|
|
313
|
+
})
|
|
314
|
+
if (hit !== undefined && hit !== null && typeof hit.path === 'string') {
|
|
315
|
+
return { path: hit.path, title: String(hit.title ?? ''), source: 'recent' }
|
|
316
|
+
}
|
|
317
|
+
// 缓存里的目录不在注册表里也认(他可能刚加进来还没入库)
|
|
318
|
+
if (existsSync(cwd)) return { path: cwd, title: '', source: 'recent' }
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 找"用户当前正在用"的工作区(客户端拿不到 sessionId 时的救命绳)。
|
|
324
|
+
*
|
|
325
|
+
* 三条线索依次尝试,任一命中即返回:
|
|
326
|
+
* 1. 会话日志分桶(当前会话一直在写,最新的桶就是它);
|
|
327
|
+
* 2. 会话投影缓存(文件里带明确的 cwd);
|
|
328
|
+
* 3. 注册表里 updatedAt 最新的工作区。
|
|
329
|
+
* 全落空时返回 null,调用方退到"注册表里第一个工作区"。
|
|
330
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - 宿主上下文。
|
|
331
|
+
* @param {object[]} workspaces - 已注册工作区。
|
|
332
|
+
* @returns {Promise<{path:string,title:string,source:'recent'}|null>} 命中结果。
|
|
333
|
+
*/
|
|
334
|
+
async function findRecentlyActiveWorkspace(ctx, workspaces) {
|
|
335
|
+
const fromBuckets = await recentFromSessionBuckets(workspaces)
|
|
336
|
+
if (fromBuckets !== null) return fromBuckets
|
|
337
|
+
|
|
338
|
+
const fromCache = await recentFromProjectionCache(workspaces)
|
|
339
|
+
if (fromCache !== null) return fromCache
|
|
340
|
+
|
|
341
|
+
let newest = null
|
|
342
|
+
for (const workspace of workspaces) {
|
|
343
|
+
if (workspace === null || workspace === undefined) continue
|
|
344
|
+
const stamp = typeof workspace.updatedAt === 'string' ? workspace.updatedAt : ''
|
|
345
|
+
if (stamp === '') continue
|
|
346
|
+
if (newest === null || stamp > newest.stamp) newest = { workspace, stamp }
|
|
347
|
+
}
|
|
348
|
+
if (newest !== null && typeof newest.workspace.path === 'string') {
|
|
349
|
+
return { path: newest.workspace.path, title: String(newest.workspace.title ?? ''), source: 'recent' }
|
|
350
|
+
}
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 定出要打开的工作区目录(三步兜底,见模块头)。
|
|
356
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - 宿主上下文。
|
|
357
|
+
* @param {string} sessionId - 客户端报的当前会话 id(可为空)。
|
|
358
|
+
* @returns {Promise<{path:string,title:string,source:'session'|'recent'|'first'}|null>} 结果。
|
|
359
|
+
*/
|
|
360
|
+
async function resolveWorkspaceDirectory(ctx, sessionId) {
|
|
361
|
+
const registry = ctx.get('workspaceRegistry')
|
|
362
|
+
if (registry === undefined || typeof registry.list !== 'function') return null
|
|
363
|
+
let workspaces = []
|
|
364
|
+
try {
|
|
365
|
+
workspaces = registry.list()
|
|
366
|
+
} catch {
|
|
367
|
+
return null
|
|
368
|
+
}
|
|
369
|
+
if (!Array.isArray(workspaces) || workspaces.length === 0) return null
|
|
370
|
+
|
|
371
|
+
if (sessionId !== '') {
|
|
372
|
+
const hit = workspaces.find((workspace) => {
|
|
373
|
+
const ids = workspace === null || workspace === undefined ? undefined : workspace.sessionIds
|
|
374
|
+
return Array.isArray(ids) ? ids.includes(sessionId) : false
|
|
375
|
+
})
|
|
376
|
+
if (hit !== undefined && hit !== null && typeof hit.path === 'string') {
|
|
377
|
+
return { path: hit.path, title: String(hit.title ?? ''), source: 'session' }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const recent = await findRecentlyActiveWorkspace(ctx, workspaces)
|
|
382
|
+
if (recent !== null) return recent
|
|
383
|
+
|
|
384
|
+
const first = workspaces[0]
|
|
385
|
+
if (first === undefined || first === null || typeof first.path !== 'string') return null
|
|
386
|
+
return { path: first.path, title: String(first.title ?? ''), source: 'first' }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 插件入口:挂上「打开工作区目录」这一条路由。
|
|
391
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - 宿主上下文。
|
|
392
|
+
* @returns {void}
|
|
393
|
+
*/
|
|
394
|
+
export function apply(ctx) {
|
|
395
|
+
ctx.effect(() => ctx.webServer.register({
|
|
396
|
+
kind: 'exact',
|
|
397
|
+
path: OPEN_PATH,
|
|
398
|
+
handler: async (req, res) => {
|
|
399
|
+
if (req.method !== 'POST') {
|
|
400
|
+
sendJson(res, 405, { ok: false, error: 'POST only' })
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const body = await readJsonBody(req)
|
|
405
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
406
|
+
const target = await resolveWorkspaceDirectory(ctx, sessionId)
|
|
407
|
+
if (target === null) {
|
|
408
|
+
sendJson(res, 200, { ok: false, error: 'no-workspace' })
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
if (!existsSync(target.path)) {
|
|
412
|
+
sendJson(res, 200, { ok: false, error: 'workspace-missing', path: target.path })
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
const command = openInFileManager(ctx, target.path)
|
|
416
|
+
ctx.logger.info(`net-speed-open: 打开工作区目录 ${target.path}(${target.source} / ${command})`)
|
|
417
|
+
sendJson(res, 200, { ok: true, path: target.path, title: target.title, source: target.source, command })
|
|
418
|
+
} catch (error) {
|
|
419
|
+
sendJson(res, 500, { ok: false, error: String((error && error.message) || error) })
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}), 'net-speed-open: /open-workspace')
|
|
423
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Opens a folder in Explorer and makes the window actually visible, then lets go.
|
|
2
|
+
#
|
|
3
|
+
# Why this exists: DSH starts plugin subprocesses hidden, so an explorer window spawned from
|
|
4
|
+
# here is created with visible = false - it exists (COM enumerates it) but the user cannot
|
|
5
|
+
# see it. Showing it once and handing it the foreground once is the whole job.
|
|
6
|
+
#
|
|
7
|
+
# Deliberately NOT done here: HWND_TOPMOST, and a loop of SetForegroundWindow calls. Those
|
|
8
|
+
# keep the window glued on top for seconds and fight the app the user was just using, which
|
|
9
|
+
# feels like being hijacked ("did I just get a virus?"). If the other window takes the focus
|
|
10
|
+
# back, the folder window simply stays in the taskbar like any normal window.
|
|
11
|
+
# A synthetic ALT tap was also tried and made things worse - do not add keybd_event back.
|
|
12
|
+
#
|
|
13
|
+
# ASCII only on purpose: PowerShell 5.1 reads script files as ANSI unless they carry a BOM.
|
|
14
|
+
param([Parameter(Mandatory = $true)][string]$Path)
|
|
15
|
+
|
|
16
|
+
$ErrorActionPreference = 'Stop'
|
|
17
|
+
Add-Type -Path (Join-Path $PSScriptRoot 'focus.cs')
|
|
18
|
+
|
|
19
|
+
$leaf = Split-Path -Leaf $Path
|
|
20
|
+
$shell = New-Object -ComObject Shell.Application
|
|
21
|
+
$shell.Explore($Path)
|
|
22
|
+
Start-Sleep -Milliseconds 1200
|
|
23
|
+
|
|
24
|
+
$script:target = [IntPtr]::Zero
|
|
25
|
+
$callback = [FolderFocus+EnumProc] {
|
|
26
|
+
param($hwnd, $lparam)
|
|
27
|
+
$cls = New-Object System.Text.StringBuilder 256
|
|
28
|
+
[void][FolderFocus]::GetClassName($hwnd, $cls, 256)
|
|
29
|
+
if ($cls.ToString() -ne 'CabinetWClass') { return $true }
|
|
30
|
+
$title = New-Object System.Text.StringBuilder 512
|
|
31
|
+
[void][FolderFocus]::GetWindowText($hwnd, $title, 512)
|
|
32
|
+
if ($title.ToString() -like "$leaf*") {
|
|
33
|
+
$script:target = $hwnd
|
|
34
|
+
return $false
|
|
35
|
+
}
|
|
36
|
+
return $true
|
|
37
|
+
}
|
|
38
|
+
[void][FolderFocus]::EnumWindows($callback, [IntPtr]::Zero)
|
|
39
|
+
|
|
40
|
+
if ($script:target -eq [IntPtr]::Zero) {
|
|
41
|
+
Write-Output "no-window:$leaf"
|
|
42
|
+
exit 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# One shot: make it visible, bring it forward, give it the foreground. Then stop.
|
|
46
|
+
$foreground = [FolderFocus]::GetForegroundWindow()
|
|
47
|
+
$foregroundThread = [FolderFocus]::GetWindowThreadProcessId($foreground, [IntPtr]::Zero)
|
|
48
|
+
$myThread = [FolderFocus]::GetCurrentThreadId()
|
|
49
|
+
[void][FolderFocus]::AttachThreadInput($myThread, $foregroundThread, $true)
|
|
50
|
+
[void][FolderFocus]::ShowWindow($script:target, 5) # SW_SHOW
|
|
51
|
+
[void][FolderFocus]::ShowWindow($script:target, 9) # SW_RESTORE
|
|
52
|
+
[void][FolderFocus]::BringWindowToTop($script:target)
|
|
53
|
+
$focused = [FolderFocus]::SetForegroundWindow($script:target)
|
|
54
|
+
[void][FolderFocus]::AttachThreadInput($myThread, $foregroundThread, $false)
|
|
55
|
+
|
|
56
|
+
Write-Output ("shown:{0}:{1}" -f $leaf, $focused)
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-users-open-source-workspace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh-users-open-source 的伴生包:受理 POST /dsh-users-open-source/open-workspace,在系统文件管理器里打开用户当前工作区目录(设置页「开源用户」里那颗「云端备份」按钮)。目录只从 DSH 自己的工作区账本里取,客户端只能递 sessionId;Windows 上用包内 PowerShell 脚本打开并显形(DSH 以隐藏方式启动子进程,直接 spawn explorer.exe 只会得到一个 visible=false 的隐身窗口)。它不是独立 bundle —— loader entry 由 dsh-users-open-source 的 cordis.patch.yml 插入。",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"workspace",
|
|
10
|
+
"open-folder",
|
|
11
|
+
"explorer"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"default": "./lib/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"lib",
|
|
23
|
+
"tests",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.13.0"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/KAIROSLLL/dsh-users-open-source.git",
|
|
35
|
+
"directory": "packages/dsh-users-open-source-workspace"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/KAIROSLLL/dsh-users-open-source#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/KAIROSLLL/dsh-users-open-source/issues"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node tests/smoke.mjs"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/tests/smoke.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-users-open-source-workspace 冒烟测试:不需要 DSH 运行时,node 直接跑。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:路由注册、sessionId 命中工作区、以及三条"当前工作区"兜底线索
|
|
5
|
+
* (会话日志分桶 → 投影缓存 cwd → updatedAt 最新)与各种失败分支。
|
|
6
|
+
*
|
|
7
|
+
* 用法:node tests/smoke.mjs
|
|
8
|
+
*/
|
|
9
|
+
import { strict as assert } from 'node:assert'
|
|
10
|
+
import { createServer } from 'node:http'
|
|
11
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** 测试期间把 DSH home 指到临时目录,别污染真身。 */
|
|
16
|
+
process.env.DSH_HOME = join(tmpdir(), 'dsh-users-open-source-ws-smoke')
|
|
17
|
+
/** 只解析、不真的弹资源管理器。 */
|
|
18
|
+
process.env.DSH_NET_SPEED_DRY_OPEN = '1'
|
|
19
|
+
|
|
20
|
+
const mod = await import(new URL('../lib/index.js', import.meta.url).href)
|
|
21
|
+
|
|
22
|
+
/** 收集失败,最后统一决定退出码。 */
|
|
23
|
+
const failures = []
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 跑一个测试用例,失败不中断。
|
|
27
|
+
* @param {string} title - 用例名。
|
|
28
|
+
* @param {() => Promise<void>|void} body - 用例体。
|
|
29
|
+
* @returns {Promise<void>} 结束。
|
|
30
|
+
*/
|
|
31
|
+
async function test(title, body) {
|
|
32
|
+
try {
|
|
33
|
+
await body()
|
|
34
|
+
console.log(` ✓ ${title}`)
|
|
35
|
+
} catch (error) {
|
|
36
|
+
failures.push(`${title}: ${String((error && error.stack) || error)}`)
|
|
37
|
+
console.log(` ✗ ${title}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 路由表:path → handler。 */
|
|
42
|
+
const routes = new Map()
|
|
43
|
+
/** effect 清理函数。 */
|
|
44
|
+
const disposers = []
|
|
45
|
+
const logs = []
|
|
46
|
+
/** 假的宿主服务表。 */
|
|
47
|
+
const services = {}
|
|
48
|
+
|
|
49
|
+
const ctx = {
|
|
50
|
+
get: (name) => services[name],
|
|
51
|
+
logger: {
|
|
52
|
+
info: (message) => logs.push(`[info] ${message}`),
|
|
53
|
+
warn: (message) => logs.push(`[warn] ${message}`)
|
|
54
|
+
},
|
|
55
|
+
effect: (factory) => {
|
|
56
|
+
const dispose = factory()
|
|
57
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
58
|
+
return () => {}
|
|
59
|
+
},
|
|
60
|
+
webServer: {
|
|
61
|
+
register: (route) => {
|
|
62
|
+
routes.set(route.path, route.handler)
|
|
63
|
+
return () => routes.delete(route.path)
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
subprocess: {
|
|
67
|
+
spawn: () => ({ done: Promise.resolve({ exitCode: 0, signal: null }) })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const home = process.env.DSH_HOME
|
|
72
|
+
|
|
73
|
+
await test('模块导出 name / inject / apply', () => {
|
|
74
|
+
assert.equal(mod.name, 'dsh-users-open-source-workspace')
|
|
75
|
+
assert.deepEqual(Array.from(mod.inject), ['webServer', 'subprocess'])
|
|
76
|
+
assert.equal(typeof mod.apply, 'function')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await test('apply() 挂上 /dsh-users-open-source/open-workspace', () => {
|
|
80
|
+
mod.apply(ctx)
|
|
81
|
+
assert.deepEqual(Array.from(routes.keys()), ['/dsh-users-open-source/open-workspace'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/** 把路由挂到一个真 HTTP 口上,方便用 fetch 打。 */
|
|
85
|
+
const bridge = createServer((req, res) => {
|
|
86
|
+
const url = new URL(typeof req.url === 'string' ? req.url : '/', 'http://127.0.0.1')
|
|
87
|
+
const handler = routes.get(url.pathname)
|
|
88
|
+
if (handler === undefined) {
|
|
89
|
+
res.writeHead(404, { 'content-type': 'text/plain' })
|
|
90
|
+
res.end('no route')
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
Promise.resolve(handler(req, res)).catch(() => {
|
|
94
|
+
try {
|
|
95
|
+
res.destroy()
|
|
96
|
+
} catch {
|
|
97
|
+
/* 忽略 */
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
await new Promise((resolve) => bridge.listen(0, '127.0.0.1', resolve))
|
|
102
|
+
const base = `http://127.0.0.1:${bridge.address().port}`
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 打一个接口。
|
|
106
|
+
* @param {string} path - 路径。
|
|
107
|
+
* @param {object} [init] - fetch 选项。
|
|
108
|
+
* @returns {Promise<{status:number, body:any}>} 状态码与 JSON。
|
|
109
|
+
*/
|
|
110
|
+
async function call(path, init) {
|
|
111
|
+
const res = await fetch(base + path, init)
|
|
112
|
+
let body = null
|
|
113
|
+
try {
|
|
114
|
+
body = await res.json()
|
|
115
|
+
} catch {
|
|
116
|
+
body = null
|
|
117
|
+
}
|
|
118
|
+
return { status: res.status, body }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 发一个 JSON POST。
|
|
123
|
+
* @param {string} path - 路径。
|
|
124
|
+
* @param {object} payload - 请求体。
|
|
125
|
+
* @returns {Promise<{status:number, body:any}>} 结果。
|
|
126
|
+
*/
|
|
127
|
+
function open(payload) {
|
|
128
|
+
return call('/dsh-users-open-source/open-workspace', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'content-type': 'application/json' },
|
|
131
|
+
body: JSON.stringify(payload ?? {})
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 一个"真实存在"的工作区目录。 */
|
|
136
|
+
const wsDir = join(tmpdir(), 'dsh-ns-ws-a')
|
|
137
|
+
/** 另一个"真实存在"的工作区目录。 */
|
|
138
|
+
const otherDir = join(tmpdir(), 'dsh-ns-ws-b')
|
|
139
|
+
|
|
140
|
+
await test('sessionId 命中工作区(最准的一条)', async () => {
|
|
141
|
+
await mkdir(wsDir, { recursive: true })
|
|
142
|
+
await mkdir(otherDir, { recursive: true })
|
|
143
|
+
services.workspaceRegistry = {
|
|
144
|
+
list: () => [
|
|
145
|
+
{ id: 'w-a', path: wsDir, title: 'A', sessionIds: ['session-a'], updatedAt: '2026-01-01T00:00:00.000Z' },
|
|
146
|
+
{ id: 'w-b', path: otherDir, title: 'B', sessionIds: ['session-b'], updatedAt: '2026-01-02T00:00:00.000Z' }
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
const result = await open({ sessionId: 'session-a' })
|
|
150
|
+
assert.equal(result.status, 200)
|
|
151
|
+
assert.equal(result.body.ok, true, JSON.stringify(result.body))
|
|
152
|
+
assert.equal(result.body.path, wsDir)
|
|
153
|
+
assert.equal(result.body.source, 'session')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
await test('线索 1:会话日志分桶(客户端没给 id 时)', async () => {
|
|
157
|
+
const bucket = `--${wsDir.replace(/[:\\/]/g, '-')}--`
|
|
158
|
+
const sessionDir = join(home, 'sessions', bucket, 'session-recent')
|
|
159
|
+
await mkdir(sessionDir, { recursive: true })
|
|
160
|
+
await writeFile(join(sessionDir, 'session.v4.jsonl.zstd'), 'x')
|
|
161
|
+
const result = await open({ sessionId: 'session-unknown' })
|
|
162
|
+
assert.equal(result.body.ok, true, JSON.stringify(result.body))
|
|
163
|
+
assert.equal(result.body.path, wsDir)
|
|
164
|
+
assert.equal(result.body.source, 'recent')
|
|
165
|
+
await rm(join(home, 'sessions'), { recursive: true, force: true })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
await test('线索 2:会话投影缓存里的 cwd', async () => {
|
|
169
|
+
const cacheDir = join(home, 'storages', 'session_projcache', 'sessions')
|
|
170
|
+
await mkdir(cacheDir, { recursive: true })
|
|
171
|
+
await writeFile(
|
|
172
|
+
join(cacheDir, 'session-x.json'),
|
|
173
|
+
JSON.stringify({ record: { header: { cwd: otherDir }, rows: {} } })
|
|
174
|
+
)
|
|
175
|
+
const result = await open({ sessionId: 'session-unknown' })
|
|
176
|
+
assert.equal(result.body.ok, true, JSON.stringify(result.body))
|
|
177
|
+
assert.equal(result.body.path, otherDir)
|
|
178
|
+
assert.equal(result.body.source, 'recent')
|
|
179
|
+
await rm(join(home, 'storages'), { recursive: true, force: true })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await test('线索 3:updatedAt 最新的工作区', async () => {
|
|
183
|
+
const result = await open({ sessionId: 'session-unknown' })
|
|
184
|
+
assert.equal(result.body.ok, true, JSON.stringify(result.body))
|
|
185
|
+
assert.equal(result.body.path, otherDir, 'updatedAt 更新的是 B')
|
|
186
|
+
assert.equal(result.body.source, 'recent')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
await test('三条线索都落空时退到第一个工作区', async () => {
|
|
190
|
+
services.workspaceRegistry = {
|
|
191
|
+
list: () => [
|
|
192
|
+
{ id: 'w-a', path: wsDir, title: 'A', sessionIds: [] },
|
|
193
|
+
{ id: 'w-b', path: otherDir, title: 'B', sessionIds: [] }
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
const result = await open({})
|
|
197
|
+
assert.equal(result.body.path, wsDir)
|
|
198
|
+
assert.equal(result.body.source, 'first')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
await test('工作区目录不在了 → workspace-missing', async () => {
|
|
202
|
+
services.workspaceRegistry = {
|
|
203
|
+
list: () => [{ id: 'w-x', path: join(tmpdir(), 'dsh-ns-definitely-missing'), title: 'X', sessionIds: [] }]
|
|
204
|
+
}
|
|
205
|
+
const result = await open({})
|
|
206
|
+
assert.equal(result.body.ok, false)
|
|
207
|
+
assert.equal(result.body.error, 'workspace-missing')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
await test('一个工作区都没有 → no-workspace;GET → 405', async () => {
|
|
211
|
+
services.workspaceRegistry = { list: () => [] }
|
|
212
|
+
const none = await open({})
|
|
213
|
+
assert.equal(none.body.ok, false)
|
|
214
|
+
assert.equal(none.body.error, 'no-workspace')
|
|
215
|
+
|
|
216
|
+
const wrongMethod = await call('/dsh-users-open-source/open-workspace')
|
|
217
|
+
assert.equal(wrongMethod.status, 405)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// 收尾
|
|
221
|
+
for (const dispose of disposers) {
|
|
222
|
+
try {
|
|
223
|
+
dispose()
|
|
224
|
+
} catch {
|
|
225
|
+
/* 忽略 */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
bridge.close()
|
|
229
|
+
await rm(home, { recursive: true, force: true })
|
|
230
|
+
|
|
231
|
+
console.log('')
|
|
232
|
+
if (failures.length > 0) {
|
|
233
|
+
console.error(`✗ ${failures.length} 个用例失败:`)
|
|
234
|
+
for (const failure of failures) console.error(`\n--- ${failure}`)
|
|
235
|
+
process.exit(1)
|
|
236
|
+
}
|
|
237
|
+
console.log(`✓ 全部用例通过(宿主日志 ${logs.length} 条)`)
|
|
238
|
+
for (const line of logs) console.log(` ${line}`)
|