dsh-gufa-code 0.0.0-stage → 1.0.1
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 +93 -2
- package/client.js +11548 -0
- package/cordis.patch.yml +8 -0
- package/docs/icon-180.png +0 -0
- package/docs/icon.svg +18 -0
- package/docs/market-submission.md +32 -0
- package/docs/screenshot-ide.jpg +0 -0
- package/host/assets.js +31 -0
- package/host/chat.js +523 -0
- package/host/context.js +438 -0
- package/host/dev.js +35 -0
- package/host/fs.js +306 -0
- package/host/git.js +532 -0
- package/host/project.js +305 -0
- package/host/ssh.js +54 -0
- package/host/terminal-pty.js +237 -0
- package/host/terminal.js +947 -0
- package/icon.svg +18 -0
- package/index.js +63 -0
- package/locale/en.json +121 -0
- package/locale/zh.json +121 -0
- package/package.json +67 -4
- package/screenshots.json +3 -0
- package/scripts/build-client.mjs +38 -0
- package/scripts/selftest-client.mjs +457 -0
- package/scripts/selftest-host.mjs +197 -0
- package/scripts/selftest-http.mjs +284 -0
- package/scripts/selftest-router.mjs +101 -0
- package/scripts/selftest-terminal-client.mjs +314 -0
- package/scripts/selftest-terminal-pty.mjs +190 -0
- package/scripts/selftest-terminal.mjs +351 -0
- package/scripts/test-chat-render.mjs +702 -0
- package/scripts/test-chat.mjs +761 -0
- package/scripts/test-editor.mjs +516 -0
- package/scripts/verify-workspace-sessions.mjs +135 -0
- package/src/client/00-core.js +1686 -0
- package/src/client/10-icons.js +205 -0
- package/src/client/20-editor.js +1318 -0
- package/src/client/30-terminal.js +1510 -0
- package/src/client/40-chat.js +1184 -0
- package/src/client/46-gitgraph.js +278 -0
- package/src/client/50-panels.js +916 -0
- package/src/client/55-backdrop.js +508 -0
- package/src/client/60-dock.js +662 -0
- package/src/client/85-shortcuts.js +516 -0
- package/src/client/90-app.js +2272 -0
- package/src/client/99-main.js +449 -0
- package/vendor/monaco/LICENSE +21 -0
- package/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/vendor/monaco/vs/loader.js +11 -0
- package/vendor/monaco/vs/nls.messages.de.js +21 -0
- package/vendor/monaco/vs/nls.messages.es.js +21 -0
- package/vendor/monaco/vs/nls.messages.fr.js +19 -0
- package/vendor/monaco/vs/nls.messages.it.js +19 -0
- package/vendor/monaco/vs/nls.messages.ja.js +21 -0
- package/vendor/monaco/vs/nls.messages.ko.js +19 -0
- package/vendor/monaco/vs/nls.messages.ru.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- package/vendor/xterm/LICENSE +21 -0
- package/vendor/xterm/LICENSE.addon-fit +19 -0
- package/vendor/xterm/addon-fit.js +2 -0
- package/vendor/xterm/xterm.css +285 -0
- package/vendor/xterm/xterm.js +2 -0
package/host/project.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 项目:打开 / 新建 / 校验。只有这里登记过的目录,fs/git/terminal 才允许访问。
|
|
3
|
+
*/
|
|
4
|
+
import { mkdir, realpath, stat } from 'node:fs/promises'
|
|
5
|
+
import { isAbsolute, resolve, sep } from 'node:path'
|
|
6
|
+
import { ApiError, badRequest } from './context.js'
|
|
7
|
+
import { debugLog } from './dev.js'
|
|
8
|
+
|
|
9
|
+
async function describeDirectory(rawPath) {
|
|
10
|
+
if (typeof rawPath !== 'string' || rawPath.trim() === '') throw badRequest('path is required')
|
|
11
|
+
if (!isAbsolute(rawPath)) throw badRequest('path must be absolute')
|
|
12
|
+
const absolute = resolve(rawPath)
|
|
13
|
+
let canonical
|
|
14
|
+
try {
|
|
15
|
+
canonical = await realpath(absolute)
|
|
16
|
+
} catch {
|
|
17
|
+
throw new ApiError('not-found', `目录不存在:${absolute}`, 404)
|
|
18
|
+
}
|
|
19
|
+
const info = await stat(canonical)
|
|
20
|
+
if (!info.isDirectory()) throw new ApiError('not-directory', `不是目录:${canonical}`, 400)
|
|
21
|
+
return { canonical, info }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function register(router, env) {
|
|
25
|
+
router.method('project.open', async (body) => {
|
|
26
|
+
const project = await env.projects.open(body.path)
|
|
27
|
+
return project
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
router.method('project.validate', async (body) => {
|
|
31
|
+
const { canonical, info } = await describeDirectory(body.path)
|
|
32
|
+
const isGit = await stat(resolve(canonical, '.git')).then(() => true, () => false)
|
|
33
|
+
return { root: canonical, name: canonical.split(sep).filter(Boolean).at(-1) ?? canonical, isGit, mtimeMs: info.mtimeMs }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
router.method('project.create', async (body) => {
|
|
37
|
+
if (typeof body.name !== 'string' || body.name.trim() === '') throw badRequest('项目名不能为空')
|
|
38
|
+
const name = body.name.trim()
|
|
39
|
+
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') throw badRequest('项目名包含非法字符')
|
|
40
|
+
if (typeof body.parent !== 'string' || !isAbsolute(body.parent)) throw badRequest('parent must be an absolute path')
|
|
41
|
+
const { canonical: parentCanonical } = await describeDirectory(body.parent)
|
|
42
|
+
const target = resolve(parentCanonical, name)
|
|
43
|
+
try {
|
|
44
|
+
await mkdir(target)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error !== null && typeof error === 'object' && error.code === 'EEXIST') throw new ApiError('exists', '同名目录已存在', 409)
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
return await env.projects.open(target)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
router.method('project.list', async () => ({ projects: env.projects.list() }))
|
|
53
|
+
|
|
54
|
+
router.method('project.forget', async (body) => {
|
|
55
|
+
if (typeof body.root === 'string') env.projects.forget(body.root)
|
|
56
|
+
return { projects: env.projects.list() }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** 在 Host 上下文中惰性取服务(兼容 ctx.get 与属性两种访问方式)。 */
|
|
60
|
+
function serviceOf(name) {
|
|
61
|
+
const ctx = env.ctx
|
|
62
|
+
if (ctx === null || ctx === undefined) return undefined
|
|
63
|
+
try {
|
|
64
|
+
if (typeof ctx.get === 'function') {
|
|
65
|
+
const value = ctx.get(name)
|
|
66
|
+
if (value !== undefined && value !== null) return value
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
/* 未注入时 ctx.get 可能抛错,继续尝试属性 */
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return ctx[name]
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** DSH 原生工作区列表(与经典模式同一份 workspaceRegistry 数据)。 */
|
|
79
|
+
router.method('workspaces.list', async () => {
|
|
80
|
+
const registry = serviceOf('workspaceRegistry')
|
|
81
|
+
if (registry === undefined || registry === null || typeof registry.list !== 'function') {
|
|
82
|
+
return { available: false, workspaces: [] }
|
|
83
|
+
}
|
|
84
|
+
let list = []
|
|
85
|
+
try {
|
|
86
|
+
list = registry.list()
|
|
87
|
+
} catch {
|
|
88
|
+
list = []
|
|
89
|
+
}
|
|
90
|
+
const workspaces = (Array.isArray(list) ? list : [])
|
|
91
|
+
.map((item) => {
|
|
92
|
+
const value = item !== null && typeof item === 'object' ? item : {}
|
|
93
|
+
return {
|
|
94
|
+
id: typeof value.id === 'string' ? value.id : '',
|
|
95
|
+
path: typeof value.path === 'string' ? value.path : '',
|
|
96
|
+
title: typeof value.title === 'string' && value.title !== '' ? value.title : '',
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
.filter((item) => item.id !== '')
|
|
100
|
+
return { available: true, workspaces }
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** 当前工作区的会话列表(标题/时间),复刻经典侧边栏的可见性规则:
|
|
104
|
+
* 工作区 sessionIds 成员 → 排除归档 → 排除 subagent 子会话 → 隐藏 blank(空会话,当前会话除外)
|
|
105
|
+
* → 标题取自 sessionQuery.readTitleSnapshots / sessionTitle → 按更新时间倒序。 */
|
|
106
|
+
router.method('workspaces.sessions', async (body) => {
|
|
107
|
+
try {
|
|
108
|
+
const controller = serviceOf('workspaceController')
|
|
109
|
+
const registry = serviceOf('workspaceRegistry')
|
|
110
|
+
const query = serviceOf('sessionQuery')
|
|
111
|
+
const sessions = serviceOf('sessions')
|
|
112
|
+
const titles = serviceOf('sessionTitle')
|
|
113
|
+
|
|
114
|
+
let views = []
|
|
115
|
+
let archivedSessionIds = []
|
|
116
|
+
let source = 'controller'
|
|
117
|
+
|
|
118
|
+
if (controller !== undefined && controller !== null && typeof controller.follow === 'function') {
|
|
119
|
+
const abort = new AbortController()
|
|
120
|
+
try {
|
|
121
|
+
const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]()
|
|
122
|
+
const first = await iterator.next()
|
|
123
|
+
try {
|
|
124
|
+
await iterator.return?.()
|
|
125
|
+
} catch {
|
|
126
|
+
/* ignore */
|
|
127
|
+
}
|
|
128
|
+
const frame = first?.value
|
|
129
|
+
if (frame !== null && typeof frame === 'object' && (frame.type === 'baseline' || frame.type === 'replace') && frame.value !== null && typeof frame.value === 'object') {
|
|
130
|
+
if (Array.isArray(frame.value.items)) views = frame.value.items
|
|
131
|
+
if (Array.isArray(frame.value.archivedSessionIds)) archivedSessionIds = frame.value.archivedSessionIds
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
views = []
|
|
135
|
+
} finally {
|
|
136
|
+
abort.abort()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (views.length === 0 && registry !== undefined && registry !== null && typeof registry.list === 'function') {
|
|
140
|
+
source = 'registry'
|
|
141
|
+
try {
|
|
142
|
+
views = registry.list().map((item) => ({
|
|
143
|
+
workspaceId: item.id,
|
|
144
|
+
path: item.path,
|
|
145
|
+
title: item.title,
|
|
146
|
+
sessionIds: Array.isArray(item.sessionIds) ? item.sessionIds : [],
|
|
147
|
+
}))
|
|
148
|
+
} catch {
|
|
149
|
+
views = []
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const workspaceId = typeof body.workspaceId === 'string' ? body.workspaceId : ''
|
|
154
|
+
const path = typeof body.path === 'string' ? body.path : ''
|
|
155
|
+
const currentSessionId = typeof body.currentSessionId === 'string' ? body.currentSessionId : ''
|
|
156
|
+
const target = views.find((item) => (workspaceId !== '' ? item.workspaceId === workspaceId : item.path === path))
|
|
157
|
+
const membership = target !== undefined && Array.isArray(target.sessionIds) ? target.sessionIds.filter((id) => typeof id === 'string') : []
|
|
158
|
+
const archivedSet = new Set(archivedSessionIds)
|
|
159
|
+
|
|
160
|
+
/* 记录元信息:父子关系(subagent)与创建时间 */
|
|
161
|
+
let records = []
|
|
162
|
+
try {
|
|
163
|
+
if (query !== undefined && query !== null && typeof query.listSessions === 'function') {
|
|
164
|
+
records = await query.listSessions(new AbortController().signal)
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
records = []
|
|
168
|
+
}
|
|
169
|
+
const metaById = new Map()
|
|
170
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
171
|
+
if (record === null || typeof record !== 'object') continue
|
|
172
|
+
const header = record.header !== null && typeof record.header === 'object' ? record.header : null
|
|
173
|
+
const id = typeof header?.id === 'string' ? header.id : typeof record.id === 'string' ? record.id : ''
|
|
174
|
+
if (id === '') continue
|
|
175
|
+
const parentRaw = header?.parentSession ?? record.parentSession ?? record.parentSessionId ?? record.parentId
|
|
176
|
+
const parent = typeof parentRaw === 'string' ? parentRaw : parentRaw !== null && typeof parentRaw === 'object' && typeof parentRaw.id === 'string' ? parentRaw.id : ''
|
|
177
|
+
const depth = typeof header?.delegationDepth === 'number' ? header.delegationDepth : 0
|
|
178
|
+
const createdAt = (() => {
|
|
179
|
+
for (const value of [header?.createdAt, record.updatedAt, record.createdAt]) {
|
|
180
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
181
|
+
if (typeof value === 'string') {
|
|
182
|
+
const parsed = Date.parse(value)
|
|
183
|
+
if (Number.isFinite(parsed)) return parsed
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return 0
|
|
187
|
+
})()
|
|
188
|
+
metaById.set(id, { parent, depth, createdAt })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* 标题:批量快照 → 逐个 readTitle → 会话服务兜底;记录哪些是"解析到了" */
|
|
192
|
+
const titleById = new Map()
|
|
193
|
+
const updatedAtById = new Map()
|
|
194
|
+
const titleKnown = new Set()
|
|
195
|
+
let titleError = ''
|
|
196
|
+
let batchResolved = false
|
|
197
|
+
try {
|
|
198
|
+
if (query !== undefined && query !== null && typeof query.readTitleSnapshots === 'function' && membership.length > 0) {
|
|
199
|
+
const snapshots = await query.readTitleSnapshots(membership, new AbortController().signal)
|
|
200
|
+
if (snapshots !== null && typeof snapshots === 'object' && Object.keys(snapshots).length > 0) {
|
|
201
|
+
batchResolved = true
|
|
202
|
+
for (const id of membership) {
|
|
203
|
+
const snapshot = snapshots[id] ?? snapshots.get?.(id)
|
|
204
|
+
if (snapshot === null || snapshot === undefined) continue
|
|
205
|
+
titleKnown.add(id)
|
|
206
|
+
if (typeof snapshot.title === 'string') titleById.set(id, snapshot.title)
|
|
207
|
+
if (typeof snapshot.updatedAt === 'number') updatedAtById.set(id, snapshot.updatedAt)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
titleError = error instanceof Error ? error.message : String(error)
|
|
213
|
+
}
|
|
214
|
+
if (titleKnown.size === 0 && query !== undefined && query !== null && typeof query.readTitle === 'function') {
|
|
215
|
+
for (const id of membership) {
|
|
216
|
+
try {
|
|
217
|
+
const snapshot = await query.readTitle(id, new AbortController().signal)
|
|
218
|
+
if (snapshot === null || snapshot === undefined) continue
|
|
219
|
+
titleKnown.add(id)
|
|
220
|
+
if (typeof snapshot.title === 'string') titleById.set(id, snapshot.title)
|
|
221
|
+
if (typeof snapshot.updatedAt === 'number') updatedAtById.set(id, snapshot.updatedAt)
|
|
222
|
+
} catch (error) {
|
|
223
|
+
titleError = titleError === '' ? (error instanceof Error ? error.message : String(error)) : titleError
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const id of membership) {
|
|
228
|
+
if (titleById.get(id) !== undefined && titleById.get(id) !== '') continue
|
|
229
|
+
try {
|
|
230
|
+
if (sessions !== undefined && sessions !== null && typeof sessions.get === 'function') {
|
|
231
|
+
const session = sessions.get(id)
|
|
232
|
+
if (session !== null && session !== undefined) {
|
|
233
|
+
if (titles !== undefined && titles !== null && typeof titles.get === 'function') {
|
|
234
|
+
const snapshot = titles.get(session)
|
|
235
|
+
if (snapshot !== null && snapshot !== undefined && typeof snapshot.title === 'string') titleById.set(id, snapshot.title)
|
|
236
|
+
}
|
|
237
|
+
if (titleById.get(id) === undefined && typeof session.title === 'string') titleById.set(id, session.title)
|
|
238
|
+
if (updatedAtById.get(id) === undefined && typeof session.updatedAt === 'number') updatedAtById.set(id, session.updatedAt)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
/* ignore */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const rows = []
|
|
247
|
+
for (const id of membership) {
|
|
248
|
+
if (archivedSet.has(id)) continue
|
|
249
|
+
const meta = metaById.get(id)
|
|
250
|
+
if (meta !== undefined && (meta.parent !== '' || meta.depth > 0)) continue
|
|
251
|
+
const title = titleById.get(id) ?? ''
|
|
252
|
+
const known = titleKnown.has(id)
|
|
253
|
+
// blank 会话不会出现在标题快照结果里:批量解析成功但缺少该项 = blank;当前会话例外
|
|
254
|
+
const blank = (known && title.trim() === '') || (batchResolved && !known)
|
|
255
|
+
if (blank && id !== currentSessionId) continue
|
|
256
|
+
rows.push({
|
|
257
|
+
id,
|
|
258
|
+
title,
|
|
259
|
+
updatedAt: updatedAtById.get(id) ?? meta?.createdAt ?? 0,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
rows.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
263
|
+
|
|
264
|
+
debugLog(`sessions path=${path} membership=${membership.length} visible=${rows.length} titleKnown=${titleKnown.size} batch=${batchResolved} titleError=${titleError === '' ? '-' : titleError.slice(0, 120)} rows=${rows.map((row) => `${row.id.slice(8, 12)}:${String(row.title).slice(0, 10)}`).join('|')}`)
|
|
265
|
+
return {
|
|
266
|
+
available: true,
|
|
267
|
+
source: source + '+membership',
|
|
268
|
+
workspaceId: target !== undefined && typeof target.workspaceId === 'string' ? target.workspaceId : workspaceId,
|
|
269
|
+
title: target !== undefined && typeof target.title === 'string' ? target.title : '',
|
|
270
|
+
path: target !== undefined && typeof target.path === 'string' ? target.path : path,
|
|
271
|
+
sessionIds: membership,
|
|
272
|
+
archivedSessionIds,
|
|
273
|
+
sessions: rows,
|
|
274
|
+
groups: [{ workspaceId: target !== undefined && typeof target.workspaceId === 'string' ? target.workspaceId : workspaceId, title: target !== undefined && typeof target.title === 'string' ? target.title : '', path: target !== undefined && typeof target.path === 'string' ? target.path : path, sessions: rows }],
|
|
275
|
+
debug: { membership: membership.length, visible: rows.length, archived: archivedSessionIds.length, records: Array.isArray(records) ? records.length : 0 },
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
return { available: false, error: error instanceof Error ? error.message : String(error), sessions: [], groups: [], sessionIds: [], archivedSessionIds: [] }
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
/** 新建工作区时的默认目录(用于选择器起点)。 */
|
|
283
|
+
router.method('workspaces.defaultDirectory', async () => {
|
|
284
|
+
let directory = ''
|
|
285
|
+
try {
|
|
286
|
+
const working = serviceOf('workingDirectory')
|
|
287
|
+
if (working !== undefined && working !== null && typeof working.defaultDirectory === 'string') directory = working.defaultDirectory
|
|
288
|
+
} catch {
|
|
289
|
+
directory = ''
|
|
290
|
+
}
|
|
291
|
+
if (directory === '') {
|
|
292
|
+
try {
|
|
293
|
+
directory = (await import('node:os')).homedir()
|
|
294
|
+
} catch {
|
|
295
|
+
directory = ''
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { directory }
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
router.method('project.home', async () => {
|
|
302
|
+
const home = (await import('node:os')).homedir()
|
|
303
|
+
return { home }
|
|
304
|
+
})
|
|
305
|
+
}
|
package/host/ssh.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH:读取 ~/.ssh/config 主机列表,供左侧 SSH 面板一键连接(连接在终端里执行 ssh <alias>)。
|
|
3
|
+
*/
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
/** 解析 ssh_config 的 Host 块(忽略通配 Host *)。 */
|
|
9
|
+
export function parseSshConfig(text) {
|
|
10
|
+
const hosts = []
|
|
11
|
+
let current = null
|
|
12
|
+
let inBlock = false
|
|
13
|
+
for (const rawLine of text.split('\n')) {
|
|
14
|
+
const line = rawLine.trim()
|
|
15
|
+
if (line === '' || line.startsWith('#')) continue
|
|
16
|
+
const match = /^(\w+)\s+(.*)$/.exec(line)
|
|
17
|
+
if (match === null) continue
|
|
18
|
+
const key = match[1].toLowerCase()
|
|
19
|
+
const value = match[2].split('#')[0].trim()
|
|
20
|
+
if (key === 'host') {
|
|
21
|
+
if (current !== null) hosts.push(current)
|
|
22
|
+
const aliases = value.split(/\s+/).filter((item) => item !== '' && !item.includes('*') && !item.includes('?'))
|
|
23
|
+
current = aliases.length === 0
|
|
24
|
+
? null
|
|
25
|
+
: { alias: aliases[0], aliases, host: aliases[0], user: '', port: '' }
|
|
26
|
+
inBlock = current !== null
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
if (!inBlock || current === null) continue
|
|
30
|
+
if (key === 'hostname') current.host = value
|
|
31
|
+
else if (key === 'user') current.user = value
|
|
32
|
+
else if (key === 'port') current.port = value
|
|
33
|
+
}
|
|
34
|
+
if (current !== null) hosts.push(current)
|
|
35
|
+
const seen = new Set()
|
|
36
|
+
return hosts.filter((host) => {
|
|
37
|
+
if (seen.has(host.alias)) return false
|
|
38
|
+
seen.add(host.alias)
|
|
39
|
+
return true
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function register(router) {
|
|
44
|
+
router.method('ssh.hosts', async () => {
|
|
45
|
+
const candidates = [join(homedir(), '.ssh', 'config'), join(homedir(), '.ssh', 'config.d')]
|
|
46
|
+
let text = ''
|
|
47
|
+
try {
|
|
48
|
+
text = await readFile(candidates[0], 'utf8')
|
|
49
|
+
} catch {
|
|
50
|
+
text = ''
|
|
51
|
+
}
|
|
52
|
+
return { hosts: parseSshConfig(text), configPath: candidates[0] }
|
|
53
|
+
})
|
|
54
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 终端 PTY 的纯工具层:与 Cordis / node-pty 无关,便于单测。
|
|
3
|
+
*
|
|
4
|
+
* 提供:
|
|
5
|
+
* - filteredEnvironment() 过滤敏感环境变量后交给 PTY(KEY/TOKEN/SECRET/…)
|
|
6
|
+
* - ReplayBuffer 按 UTF-8 字节计量的输出回放缓冲(默认 2MB),支持 since 增量续传
|
|
7
|
+
* - clampInteger() cols / rows 规范化
|
|
8
|
+
* - resolveShellPath() 选择登录 shell(显式参数 → provider 默认 → $SHELL → 平台默认)
|
|
9
|
+
*
|
|
10
|
+
* 这些逻辑被 host/terminal.js 使用,并由 scripts/selftest-terminal.mjs 覆盖。
|
|
11
|
+
*/
|
|
12
|
+
import { stat } from 'node:fs/promises'
|
|
13
|
+
import { isAbsolute } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** 传给 PTY 的 TERM;subprocess 会把同一值写入终端环境。 */
|
|
16
|
+
export const TERMINAL_TYPE = 'xterm-256color'
|
|
17
|
+
/** 每个项目根目录下最多保留的终端数。 */
|
|
18
|
+
export const MAX_TERMINALS_PER_ROOT = 8
|
|
19
|
+
/** 每个终端的输出回放缓冲上限(字节,按解码后的 UTF-8 计)。 */
|
|
20
|
+
export const REPLAY_LIMIT_BYTES = 2 * 1024 * 1024
|
|
21
|
+
/** PTY 终止宽限(TERM → KILL)。 */
|
|
22
|
+
export const TERMINAL_GRACE_MS = 5000
|
|
23
|
+
/** 终端创建请求的默认 / 边界尺寸。 */
|
|
24
|
+
export const DEFAULT_COLS = 80
|
|
25
|
+
export const DEFAULT_ROWS = 24
|
|
26
|
+
export const MIN_COLS = 2
|
|
27
|
+
export const MAX_COLS = 1000
|
|
28
|
+
export const MIN_ROWS = 2
|
|
29
|
+
export const MAX_ROWS = 500
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 敏感环境变量名:不传给终端 shell。
|
|
33
|
+
* 按「名字边界」匹配,避免误伤 MONKEY / KEYBOARD 之类普通变量;
|
|
34
|
+
* 同时保留 SSH_AUTH_SOCK、GIT_AUTHOR_* 等交互式 shell 常用变量。
|
|
35
|
+
*/
|
|
36
|
+
export const SENSITIVE_ENV_PATTERN = /(^|_)(API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY)(_|$)/i
|
|
37
|
+
/** DSH 内部变量不暴露给终端 shell。 */
|
|
38
|
+
export const DSH_ENV_PREFIX = 'DSH_'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 生成传给 PTY 的环境变量:过滤敏感名与 DSH_*,其余原样保留(HOME/PATH/LANG/SHELL…)。
|
|
42
|
+
* @param {NodeJS.ProcessEnv} [base]
|
|
43
|
+
* @returns {Record<string, string>}
|
|
44
|
+
*/
|
|
45
|
+
export function filteredEnvironment(base = process.env) {
|
|
46
|
+
/** @type {Record<string, string>} */
|
|
47
|
+
const env = {}
|
|
48
|
+
for (const [name, value] of Object.entries(base)) {
|
|
49
|
+
if (typeof value !== 'string') continue
|
|
50
|
+
if (name.toUpperCase().startsWith(DSH_ENV_PREFIX)) continue
|
|
51
|
+
if (SENSITIVE_ENV_PATTERN.test(name)) continue
|
|
52
|
+
env[name] = value
|
|
53
|
+
}
|
|
54
|
+
env.TERM = TERMINAL_TYPE
|
|
55
|
+
if (env.COLORTERM === undefined) env.COLORTERM = 'truecolor'
|
|
56
|
+
return env
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 把任意输入规范成 [min, max] 内的整数;非法值返回 fallback。 */
|
|
60
|
+
export function clampInteger(value, fallback, min, max) {
|
|
61
|
+
const number = typeof value === 'number' ? value : Number(value)
|
|
62
|
+
if (!Number.isFinite(number)) return fallback
|
|
63
|
+
return Math.min(max, Math.max(min, Math.trunc(number)))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 判断一个码元是否为 UTF-16 低代理项(避免按字节截断时切坏字符)。 */
|
|
67
|
+
function isLowSurrogate(code) {
|
|
68
|
+
return code >= 0xdc00 && code <= 0xdfff
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 取字符串尾部不超过 limit 字节的最长片段(按 UTF-8,尽量不切坏字符)。 */
|
|
72
|
+
export function tailByBytes(text, limit) {
|
|
73
|
+
if (limit <= 0) return ''
|
|
74
|
+
if (Buffer.byteLength(text, 'utf8') <= limit) return text
|
|
75
|
+
let low = 0
|
|
76
|
+
let high = text.length
|
|
77
|
+
while (low < high) {
|
|
78
|
+
const middle = (low + high) >> 1
|
|
79
|
+
if (Buffer.byteLength(text.slice(middle), 'utf8') > limit) low = middle + 1
|
|
80
|
+
else high = middle
|
|
81
|
+
}
|
|
82
|
+
if (low > 0 && isLowSurrogate(text.charCodeAt(low))) low += 1
|
|
83
|
+
return text.slice(low)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 按 UTF-8 字节计量的输出回放缓冲。
|
|
88
|
+
*
|
|
89
|
+
* 语义:
|
|
90
|
+
* - totalBytes 是「历史上写入过的全部文本字节数」,即绝对偏移;
|
|
91
|
+
* - 只保留最新 limitBytes 字节;retainedStart 是保留窗口的绝对起始偏移;
|
|
92
|
+
* - sliceFrom(offset) 返回从绝对偏移 offset 开始的可续传文本;若 offset 已滑出窗口
|
|
93
|
+
* 或落点非法,返回 { lossy: true, text: 完整保留窗口 },调用方应先让客户端 reset。
|
|
94
|
+
*/
|
|
95
|
+
export class ReplayBuffer {
|
|
96
|
+
/** @type {{ text: string, bytes: number, start: number }[]} */
|
|
97
|
+
#entries = []
|
|
98
|
+
#bytes = 0
|
|
99
|
+
#totalBytes = 0
|
|
100
|
+
|
|
101
|
+
constructor(limitBytes = REPLAY_LIMIT_BYTES) {
|
|
102
|
+
this.limitBytes = Number.isSafeInteger(limitBytes) && limitBytes > 0 ? limitBytes : REPLAY_LIMIT_BYTES
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 保留窗口的字节数。 */
|
|
106
|
+
get retainedBytes() {
|
|
107
|
+
return this.#bytes
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 保留窗口的绝对起始偏移。 */
|
|
111
|
+
get retainedStart() {
|
|
112
|
+
return this.#entries.length === 0 ? this.#totalBytes : this.#entries[0].start
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 历史写入的总字节数(绝对偏移上界)。 */
|
|
116
|
+
get totalBytes() {
|
|
117
|
+
return this.#totalBytes
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get size() {
|
|
121
|
+
return this.#entries.length
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 追加一段解码后的输出。 */
|
|
125
|
+
push(text) {
|
|
126
|
+
if (typeof text !== 'string' || text === '') return
|
|
127
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
128
|
+
this.#totalBytes += bytes
|
|
129
|
+
if (bytes >= this.limitBytes) {
|
|
130
|
+
const tail = tailByBytes(text, this.limitBytes)
|
|
131
|
+
const tailBytes = Buffer.byteLength(tail, 'utf8')
|
|
132
|
+
this.#entries = [{ text: tail, bytes: tailBytes, start: this.#totalBytes - tailBytes }]
|
|
133
|
+
this.#bytes = tailBytes
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
this.#entries.push({ text, bytes, start: this.#totalBytes - bytes })
|
|
137
|
+
this.#bytes += bytes
|
|
138
|
+
while (this.#bytes > this.limitBytes && this.#entries.length > 1) {
|
|
139
|
+
const removed = this.#entries.shift()
|
|
140
|
+
this.#bytes -= removed.bytes
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 返回保留窗口内的完整文本。 */
|
|
145
|
+
toString() {
|
|
146
|
+
return this.#entries.map((entry) => entry.text).join('')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 从绝对偏移 offset 续传。
|
|
151
|
+
* @param {number} offset
|
|
152
|
+
* @returns {{ text: string, lossy: boolean }}
|
|
153
|
+
*/
|
|
154
|
+
sliceFrom(offset) {
|
|
155
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > this.#totalBytes) {
|
|
156
|
+
return { text: this.toString(), lossy: true }
|
|
157
|
+
}
|
|
158
|
+
if (offset === this.#totalBytes) return { text: '', lossy: false }
|
|
159
|
+
const start = this.retainedStart
|
|
160
|
+
if (offset < start) return { text: this.toString(), lossy: true }
|
|
161
|
+
for (let index = 0; index < this.#entries.length; index += 1) {
|
|
162
|
+
const entry = this.#entries[index]
|
|
163
|
+
if (entry.start !== offset) continue
|
|
164
|
+
return { text: this.#entries.slice(index).map((item) => item.text).join(''), lossy: false }
|
|
165
|
+
}
|
|
166
|
+
// 偏移落在某个 chunk 内部(正常流式下不会出现;仅当调用方计数与写入边界不一致时兜底)。
|
|
167
|
+
for (let index = 0; index < this.#entries.length; index += 1) {
|
|
168
|
+
const entry = this.#entries[index]
|
|
169
|
+
if (offset < entry.start || offset > entry.start + entry.bytes) continue
|
|
170
|
+
const within = Buffer.from(entry.text, 'utf8')
|
|
171
|
+
const tail = within.subarray(offset - entry.start).toString('utf8')
|
|
172
|
+
return { text: [tail, ...this.#entries.slice(index + 1).map((item) => item.text)].join(''), lossy: false }
|
|
173
|
+
}
|
|
174
|
+
return { text: this.toString(), lossy: true }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 清空(历史绝对偏移保持不变,避免正在重连的客户端错位)。 */
|
|
178
|
+
clear() {
|
|
179
|
+
this.#entries = []
|
|
180
|
+
this.#bytes = 0
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 默认的「文件存在且是普通文件」判断。 */
|
|
185
|
+
async function defaultFileExists(path) {
|
|
186
|
+
try {
|
|
187
|
+
const info = await stat(path)
|
|
188
|
+
return info.isFile()
|
|
189
|
+
} catch {
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 选择交互式登录 shell:显式参数 → provider 的默认 shell → $SHELL → 平台默认。
|
|
196
|
+
* @param {{ explicit?: unknown, preferred?: unknown, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (path: string) => Promise<boolean> }} [options]
|
|
197
|
+
* @returns {Promise<string>}
|
|
198
|
+
*/
|
|
199
|
+
export async function resolveShellPath(options = {}) {
|
|
200
|
+
const {
|
|
201
|
+
explicit,
|
|
202
|
+
preferred,
|
|
203
|
+
env = process.env,
|
|
204
|
+
platform = process.platform,
|
|
205
|
+
exists = defaultFileExists,
|
|
206
|
+
} = options
|
|
207
|
+
const usable = async (value) => {
|
|
208
|
+
if (typeof value !== 'string') return false
|
|
209
|
+
const candidate = value.trim()
|
|
210
|
+
if (candidate === '' || !isAbsolute(candidate)) return false
|
|
211
|
+
try {
|
|
212
|
+
return await exists(candidate)
|
|
213
|
+
} catch {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (await usable(explicit)) return explicit.trim()
|
|
218
|
+
if (await usable(preferred)) return preferred.trim()
|
|
219
|
+
if (await usable(env.SHELL)) return env.SHELL.trim()
|
|
220
|
+
if (platform === 'win32') {
|
|
221
|
+
const comspec = typeof env.COMSPEC === 'string' && env.COMSPEC !== '' ? env.COMSPEC : 'powershell.exe'
|
|
222
|
+
return comspec
|
|
223
|
+
}
|
|
224
|
+
return platform === 'darwin' ? '/bin/zsh' : '/bin/bash'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 登录 shell 的参数:POSIX 用 `-l`,Windows 关闭 logo。 */
|
|
228
|
+
export function terminalShellArgs(platform = process.platform) {
|
|
229
|
+
return platform === 'win32' ? ['-NoLogo'] : ['-l']
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 终端标签标题:shell 可执行文件名(兼容 PATH 分隔符差异,去掉 .exe)。 */
|
|
233
|
+
export function terminalTitleFor(shellPath) {
|
|
234
|
+
const base = String(shellPath ?? '').split(/[\\/]/).filter(Boolean).at(-1) ?? ''
|
|
235
|
+
const trimmed = base.endsWith('.exe') ? base.slice(0, -4) : base
|
|
236
|
+
return trimmed === '' ? 'shell' : trimmed
|
|
237
|
+
}
|