@lijian-ui/dsh-term 0.2.0 → 0.3.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/lib/client.js +475 -94
- package/lib/client.js.map +1 -1
- package/lib/index.js +454 -29
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/client-i18n.d.ts +29 -0
- package/lib/types/client/client-i18n.d.ts.map +1 -0
- package/lib/types/client/i18n-seat.d.ts +15 -0
- package/lib/types/client/i18n-seat.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/term/AnimatedDock.d.ts.map +1 -1
- package/lib/types/client/term/TerminalPanel.d.ts +8 -3
- package/lib/types/client/term/TerminalPanel.d.ts.map +1 -1
- package/lib/types/core/types.d.ts +1 -0
- package/lib/types/core/types.d.ts.map +1 -1
- package/lib/types/gateway/i18n.d.ts +17 -0
- package/lib/types/gateway/i18n.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +3 -1
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/client-i18n.ts +68 -0
- package/src/client/i18n-seat.ts +27 -0
- package/src/client/index.ts +22 -3
- package/src/client/term/AnimatedDock.tsx +3 -1
- package/src/client/term/DockItem.tsx +1 -1
- package/src/client/term/TerminalPanel.tsx +333 -50
- package/src/client/term/animated-dock.module.css +3 -3
- package/src/client/term/api.ts +17 -2
- package/src/client/term/chat-helper.ts +28 -0
- package/src/client/term/term.module.css +155 -18
- package/src/core/types.ts +22 -4
- package/src/gateway/i18n.ts +67 -0
- package/src/host/pty-service.ts +160 -17
- package/src/host/routes.ts +44 -4
- package/src/index.ts +31 -1
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
height: 100%;
|
|
11
11
|
display: flex;
|
|
12
12
|
flex-direction: column;
|
|
13
|
-
border-left: 1px solid var(--
|
|
14
|
-
background: var(--
|
|
13
|
+
border-left: 1px solid var(--dsw-alias-border-l2);
|
|
14
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
15
15
|
overflow: hidden;
|
|
16
16
|
font-family: var(--aion-font-sans, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif);
|
|
17
17
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
padding: 0 8px;
|
|
22
22
|
font-size: 12px;
|
|
23
23
|
font-weight: 600;
|
|
24
|
-
color: var(--
|
|
24
|
+
color: var(--dsw-alias-label-primary);
|
|
25
25
|
user-select: none;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
width: 1px;
|
|
31
31
|
height: 16px;
|
|
32
32
|
margin: 0 4px;
|
|
33
|
-
background: var(--
|
|
33
|
+
background: var(--dsw-alias-border-l2);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
.toolbar {
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
gap: 4px;
|
|
40
40
|
height: 32px;
|
|
41
41
|
padding: 0 6px;
|
|
42
|
-
border-bottom: 1px solid var(--
|
|
43
|
-
background: var(--
|
|
42
|
+
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
|
43
|
+
background: var(--dsw-alias-bg-base);
|
|
44
44
|
flex: none;
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -53,15 +53,15 @@
|
|
|
53
53
|
border-radius: 5px;
|
|
54
54
|
border: none;
|
|
55
55
|
background: transparent;
|
|
56
|
-
color: var(--
|
|
56
|
+
color: var(--dsw-alias-label-secondary);
|
|
57
57
|
font-size: 12px;
|
|
58
58
|
cursor: pointer;
|
|
59
59
|
max-width: 200px;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
.tabActive {
|
|
63
|
-
background: var(--
|
|
64
|
-
color: var(--
|
|
63
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
64
|
+
color: var(--dsw-alias-label-primary);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
.tabClose {
|
|
@@ -73,15 +73,15 @@
|
|
|
73
73
|
border: none;
|
|
74
74
|
border-radius: 3px;
|
|
75
75
|
background: transparent;
|
|
76
|
-
color: var(--
|
|
76
|
+
color: var(--dsw-alias-label-tertiary);
|
|
77
77
|
font-size: 11px;
|
|
78
78
|
line-height: 1;
|
|
79
79
|
cursor: pointer;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
.tabClose:hover {
|
|
83
|
-
background: var(--
|
|
84
|
-
color: var(--
|
|
83
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
84
|
+
color: var(--dsw-alias-label-primary);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
.addTab {
|
|
@@ -93,13 +93,13 @@
|
|
|
93
93
|
border: none;
|
|
94
94
|
border-radius: 5px;
|
|
95
95
|
background: transparent;
|
|
96
|
-
color: var(--
|
|
96
|
+
color: var(--dsw-alias-label-secondary);
|
|
97
97
|
font-size: 14px;
|
|
98
98
|
cursor: pointer;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
.addTab:hover {
|
|
102
|
-
background: var(--
|
|
102
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
.spacer {
|
|
@@ -115,20 +115,20 @@
|
|
|
115
115
|
border: none;
|
|
116
116
|
border-radius: 5px;
|
|
117
117
|
background: transparent;
|
|
118
|
-
color: var(--
|
|
118
|
+
color: var(--dsw-alias-label-secondary);
|
|
119
119
|
font-size: 13px;
|
|
120
120
|
cursor: pointer;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
.collapse:hover {
|
|
124
|
-
background: var(--
|
|
124
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
.stage {
|
|
128
128
|
position: relative;
|
|
129
129
|
flex: 1;
|
|
130
130
|
min-height: 0;
|
|
131
|
-
background: var(--
|
|
131
|
+
background: var(--dsw-alias-bg-base);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
.termWrap {
|
|
@@ -148,6 +148,143 @@
|
|
|
148
148
|
display: flex;
|
|
149
149
|
align-items: center;
|
|
150
150
|
justify-content: center;
|
|
151
|
-
color: var(--
|
|
151
|
+
color: var(--dsw-alias-label-tertiary);
|
|
152
152
|
font-size: 13px;
|
|
153
153
|
}
|
|
154
|
+
|
|
155
|
+
.shellSelect {
|
|
156
|
+
flex: none;
|
|
157
|
+
height: 22px;
|
|
158
|
+
padding: 0 6px;
|
|
159
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
160
|
+
border-radius: 5px;
|
|
161
|
+
background: var(--dsw-alias-bg-base);
|
|
162
|
+
color: var(--dsw-alias-label-primary);
|
|
163
|
+
font-size: 12px;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
outline: none;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.shellSelect:hover {
|
|
169
|
+
border-color: var(--dsw-alias-border-l1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.shellSelect:focus {
|
|
173
|
+
border-color: var(--dsw-alias-state-business-primary);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.reopenBtn {
|
|
177
|
+
display: inline-flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
justify-content: center;
|
|
180
|
+
height: 22px;
|
|
181
|
+
padding: 0 6px;
|
|
182
|
+
border: none;
|
|
183
|
+
border-radius: 5px;
|
|
184
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
185
|
+
color: var(--dsw-alias-label-secondary);
|
|
186
|
+
font-size: 12px;
|
|
187
|
+
cursor: pointer;
|
|
188
|
+
gap: 2px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.reopenBtn:hover {
|
|
192
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
193
|
+
color: var(--dsw-alias-label-primary);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.reopenDropdown {
|
|
197
|
+
position: absolute;
|
|
198
|
+
top: 32px;
|
|
199
|
+
left: 6px;
|
|
200
|
+
z-index: 50;
|
|
201
|
+
min-width: 180px;
|
|
202
|
+
max-width: 280px;
|
|
203
|
+
padding: 4px 0;
|
|
204
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
205
|
+
border-radius: 8px;
|
|
206
|
+
background: var(--dsw-alias-bg-base);
|
|
207
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.reopenHeader {
|
|
211
|
+
padding: 4px 10px;
|
|
212
|
+
font-size: 11px;
|
|
213
|
+
font-weight: 600;
|
|
214
|
+
color: var(--dsw-alias-label-tertiary);
|
|
215
|
+
user-select: none;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.reopenItem {
|
|
219
|
+
display: flex;
|
|
220
|
+
align-items: center;
|
|
221
|
+
gap: 4px;
|
|
222
|
+
padding: 0 6px 0 4px;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.reopenItemBtn {
|
|
226
|
+
flex: 1;
|
|
227
|
+
display: flex;
|
|
228
|
+
align-items: center;
|
|
229
|
+
height: 28px;
|
|
230
|
+
padding: 0 8px;
|
|
231
|
+
border: none;
|
|
232
|
+
border-radius: 5px;
|
|
233
|
+
background: transparent;
|
|
234
|
+
color: var(--dsw-alias-label-primary);
|
|
235
|
+
font-size: 12px;
|
|
236
|
+
cursor: pointer;
|
|
237
|
+
text-align: left;
|
|
238
|
+
overflow: hidden;
|
|
239
|
+
text-overflow: ellipsis;
|
|
240
|
+
white-space: nowrap;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.reopenItemBtn:hover {
|
|
244
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.reopenItemKill {
|
|
248
|
+
flex: none;
|
|
249
|
+
display: inline-flex;
|
|
250
|
+
align-items: center;
|
|
251
|
+
justify-content: center;
|
|
252
|
+
width: 20px;
|
|
253
|
+
height: 20px;
|
|
254
|
+
border: none;
|
|
255
|
+
border-radius: 4px;
|
|
256
|
+
background: transparent;
|
|
257
|
+
color: var(--dsw-alias-label-tertiary);
|
|
258
|
+
font-size: 13px;
|
|
259
|
+
cursor: pointer;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.reopenItemKill:hover {
|
|
263
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
264
|
+
color: var(--dsw-alias-label-primary);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.addToChatBtn {
|
|
268
|
+
position: absolute;
|
|
269
|
+
z-index: 60;
|
|
270
|
+
display: inline-flex;
|
|
271
|
+
align-items: center;
|
|
272
|
+
gap: 4px;
|
|
273
|
+
height: 28px;
|
|
274
|
+
padding: 0 10px;
|
|
275
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
276
|
+
border-radius: 14px;
|
|
277
|
+
background: var(--dsw-alias-bg-base);
|
|
278
|
+
color: var(--dsw-alias-label-primary);
|
|
279
|
+
font-size: 12px;
|
|
280
|
+
font-weight: 500;
|
|
281
|
+
cursor: pointer;
|
|
282
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
283
|
+
white-space: nowrap;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.addToChatBtn:hover {
|
|
287
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
288
|
+
border-color: var(--dsw-alias-state-business-primary);
|
|
289
|
+
color: var(--dsw-alias-state-business-primary);
|
|
290
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
* @module dsh-term/core/types
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/** Shell kinds the user can pick from in the panel dropdown. */
|
|
8
|
+
export type ShellType = 'bash' | 'zsh' | 'powershell' | 'cmd' | 'gitbash'
|
|
9
|
+
|
|
10
|
+
/** One shell option surfaced to the browser (id + display label key). */
|
|
11
|
+
export interface ShellInfo {
|
|
12
|
+
readonly id: ShellType
|
|
13
|
+
/** i18n key suffix, e.g. 'bash' → t('ui.shell.bash'). */
|
|
14
|
+
readonly labelKey: string
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
/** One live PTY session as the browser knows it. */
|
|
8
18
|
export interface TermSessionInfo {
|
|
9
19
|
/** Stable session id (the wire handle). */
|
|
@@ -19,21 +29,27 @@ export interface TermSessionInfo {
|
|
|
19
29
|
readonly alive: boolean
|
|
20
30
|
/** POSIX exit code when the session ended (null while alive). */
|
|
21
31
|
readonly exitCode: number | null
|
|
32
|
+
/** Shell kind this session was spawned with. */
|
|
33
|
+
readonly shell: ShellType
|
|
34
|
+
/** Whether the session is detached (tab closed but PTY still running). */
|
|
35
|
+
readonly detached: boolean
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
/** Request: open a new PTY session. */
|
|
25
39
|
export interface TermSpawnRequest {
|
|
26
40
|
/** Optional session display name. */
|
|
27
41
|
name?: string
|
|
28
|
-
/** Working directory (defaults to the session's workspace cwd). */
|
|
42
|
+
/** Working directory (defaults to the session's workspace cwd, fallback homedir). */
|
|
29
43
|
cwd?: string
|
|
30
|
-
/** Shell
|
|
31
|
-
shell?:
|
|
44
|
+
/** Shell kind (defaults to the platform default). */
|
|
45
|
+
shell?: ShellType
|
|
32
46
|
/** Shell arguments (defaults to an interactive login-less profile). */
|
|
33
47
|
args?: string[]
|
|
34
48
|
/** Initial terminal size. */
|
|
35
49
|
cols?: number
|
|
36
50
|
rows?: number
|
|
51
|
+
/** Extra environment variables merged into the PTY env. */
|
|
52
|
+
env?: Record<string, string>
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
/** Request: deliver terminal input. */
|
|
@@ -65,9 +81,11 @@ export interface TermListResponse {
|
|
|
65
81
|
/** One output chunk pushed over the SSE change stream. */
|
|
66
82
|
export type TermEvent =
|
|
67
83
|
| { readonly kind: 'output'; readonly id: string; readonly data: string }
|
|
68
|
-
| { readonly kind: 'exit'; readonly id: string; readonly exitCode: number }
|
|
84
|
+
| { readonly kind: 'exit'; readonly id: string; readonly exitCode: number; readonly message?: string }
|
|
69
85
|
| { readonly kind: 'start'; readonly session: TermSessionInfo }
|
|
70
86
|
| { readonly kind: 'closed'; readonly id: string }
|
|
87
|
+
| { readonly kind: 'detached'; readonly id: string }
|
|
88
|
+
| { readonly kind: 'reattached'; readonly session: TermSessionInfo }
|
|
71
89
|
|
|
72
90
|
/** The workspace-gated session id (host-minted; the wire carries only this). */
|
|
73
91
|
export type TermSessionId = string
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 国际化模块:dsh-term Host 端中英文翻译。
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* const t = createTranslator('en')
|
|
6
|
+
* t('msg.sessionExited', 0) // → "Session exited (code 0)"
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-term/gateway/i18n
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type Lang = 'zh' | 'en'
|
|
12
|
+
|
|
13
|
+
type Dict = Record<string, string>
|
|
14
|
+
|
|
15
|
+
const zh: Dict = {
|
|
16
|
+
// ── pty-service.ts / TerminalPanel.tsx ──
|
|
17
|
+
'msg.sessionExited': '[dsh-term] 进程已退出(code {0})',
|
|
18
|
+
'msg.spawnFailed': '[dsh-term] 启动失败: {0}',
|
|
19
|
+
'ui.panel.title': '终端',
|
|
20
|
+
'ui.panel.addTabTitle': '新建终端',
|
|
21
|
+
'ui.panel.collapseTitle': '收起',
|
|
22
|
+
'ui.panel.emptyHint': '点击 + 新建终端',
|
|
23
|
+
'ui.dock.label': '终端',
|
|
24
|
+
'ui.tab.closeAria': '关闭 {0}',
|
|
25
|
+
'ui.panel.shellTitle': 'Shell',
|
|
26
|
+
'ui.shell.bash': 'Bash',
|
|
27
|
+
'ui.shell.zsh': 'Zsh',
|
|
28
|
+
'ui.shell.powershell': 'PowerShell',
|
|
29
|
+
'ui.shell.cmd': '命令提示符',
|
|
30
|
+
'ui.shell.gitbash': 'Git Bash',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const en: Dict = {
|
|
34
|
+
'msg.sessionExited': '[dsh-term] Process exited (code {0})',
|
|
35
|
+
'msg.spawnFailed': '[dsh-term] Spawn failed: {0}',
|
|
36
|
+
'ui.panel.title': 'Terminal',
|
|
37
|
+
'ui.panel.addTabTitle': 'New Terminal',
|
|
38
|
+
'ui.panel.collapseTitle': 'Collapse',
|
|
39
|
+
'ui.panel.emptyHint': 'Click + to create a terminal',
|
|
40
|
+
'ui.dock.label': 'Terminal',
|
|
41
|
+
'ui.tab.closeAria': 'Close {0}',
|
|
42
|
+
'ui.panel.shellTitle': 'Shell',
|
|
43
|
+
'ui.shell.bash': 'Bash',
|
|
44
|
+
'ui.shell.zsh': 'Zsh',
|
|
45
|
+
'ui.shell.powershell': 'PowerShell',
|
|
46
|
+
'ui.shell.cmd': 'Command Prompt',
|
|
47
|
+
'ui.shell.gitbash': 'Git Bash',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dicts: Record<Lang, Dict> = { zh, en }
|
|
51
|
+
|
|
52
|
+
export class Translator {
|
|
53
|
+
constructor(readonly lang: Lang) {}
|
|
54
|
+
|
|
55
|
+
t(key: string, ...args: (string | number)[]): string {
|
|
56
|
+
const dict = dicts[this.lang] ?? zh
|
|
57
|
+
let s = dict[key] ?? zh[key] ?? key
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
s = s.replaceAll(`{${i}}`, String(args[i]))
|
|
60
|
+
}
|
|
61
|
+
return s
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createTranslator(lang: Lang): Translator {
|
|
66
|
+
return new Translator(lang)
|
|
67
|
+
}
|
package/src/host/pty-service.ts
CHANGED
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { existsSync, statSync } from 'node:fs'
|
|
17
|
+
import { exec } from 'node:child_process'
|
|
18
|
+
import { resolve } from 'node:path'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
16
20
|
import * as nodePty from 'node-pty'
|
|
17
|
-
import type { TermSessionInfo, TermSpawnRequest } from '../core/types.ts'
|
|
21
|
+
import type { ShellInfo, ShellType, TermSessionInfo, TermSpawnRequest } from '../core/types.ts'
|
|
18
22
|
|
|
19
23
|
/** Live session plus its pty handle (service-private). */
|
|
20
24
|
interface LiveSession {
|
|
@@ -22,17 +26,89 @@ interface LiveSession {
|
|
|
22
26
|
readonly pty: nodePty.IPty
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
const IS_WIN = process.platform === 'win32'
|
|
30
|
+
|
|
31
|
+
/** Windows-only Git Bash locations. */
|
|
32
|
+
const GIT_BASH_CANDIDATES_WIN = [
|
|
33
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
34
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/** macOS / Linux stock shells (always present on a normal desktop install). */
|
|
38
|
+
const UNIX_SHELL_PATHS: Record<'zsh' | 'bash', string> = {
|
|
39
|
+
zsh: '/bin/zsh',
|
|
40
|
+
bash: '/bin/bash',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** All shell kinds in display order. */
|
|
44
|
+
const ALL_SHELLS: readonly ShellType[] = ['zsh', 'bash', 'gitbash', 'powershell', 'cmd']
|
|
45
|
+
|
|
46
|
+
/** True if `name` resolves on PATH. Uses `where` on Windows, `command -v` on Unix. */
|
|
47
|
+
function commandExistsAsync(name: string): Promise<boolean> {
|
|
48
|
+
const probe = IS_WIN ? `where ${name}` : `command -v ${name}`
|
|
49
|
+
return new Promise((r) => {
|
|
50
|
+
exec(probe, { windowsHide: true, timeout: 5000 }, (err) => { r(!err) })
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Git Bash is only relevant on Windows (probed via launcher / bash / git). */
|
|
55
|
+
async function hasGitBash(): Promise<boolean> {
|
|
56
|
+
if (!IS_WIN) return false
|
|
57
|
+
if (await commandExistsAsync('git-bash.exe')) return true
|
|
58
|
+
if (
|
|
59
|
+
(await commandExistsAsync('bash.exe')) &&
|
|
60
|
+
((await commandExistsAsync('git.exe')) ||
|
|
61
|
+
GIT_BASH_CANDIDATES_WIN.some((p) => existsSync(p)))
|
|
62
|
+
) {
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
return GIT_BASH_CANDIDATES_WIN.some((p) => existsSync(p))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Map a ShellType to { command, args } for node-pty.spawn. */
|
|
69
|
+
function resolveShell(shell: ShellType): { command: string; args: string[] } {
|
|
70
|
+
switch (shell) {
|
|
71
|
+
case 'powershell':
|
|
72
|
+
return { command: 'powershell.exe', args: ['-NoLogo'] }
|
|
73
|
+
case 'cmd':
|
|
74
|
+
return { command: 'cmd.exe', args: [] }
|
|
75
|
+
case 'bash':
|
|
76
|
+
return { command: IS_WIN ? 'bash.exe' : UNIX_SHELL_PATHS.bash, args: ['--login', '-i'] }
|
|
77
|
+
case 'zsh':
|
|
78
|
+
return { command: UNIX_SHELL_PATHS.zsh, args: ['-l', '-i'] }
|
|
79
|
+
case 'gitbash':
|
|
80
|
+
default: {
|
|
81
|
+
if (IS_WIN) {
|
|
82
|
+
for (const c of GIT_BASH_CANDIDATES_WIN) {
|
|
83
|
+
if (existsSync(c)) return { command: c, args: ['--login', '-i'] }
|
|
84
|
+
}
|
|
85
|
+
return { command: 'bash', args: ['--login', '-i'] }
|
|
86
|
+
}
|
|
87
|
+
return { command: UNIX_SHELL_PATHS.bash, args: ['--login', '-i'] }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Default shell kind for the platform. */
|
|
93
|
+
export function defaultShellType(): ShellType {
|
|
94
|
+
if (IS_WIN) return 'powershell'
|
|
95
|
+
return existsSync(UNIX_SHELL_PATHS.zsh) ? 'zsh' : 'bash'
|
|
29
96
|
}
|
|
30
97
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a spawn cwd that is guaranteed to be an existing directory.
|
|
100
|
+
* node-pty fails at spawn time (macOS: "posix_spawnp failed") if the cwd
|
|
101
|
+
* does not exist — which happens on a cross-platform machine when a stale
|
|
102
|
+
* Windows-style workspace path is still in the store, or when the renderer
|
|
103
|
+
* sends an empty/null cwd. Fall back to $HOME so the PTY always spawns.
|
|
104
|
+
*/
|
|
105
|
+
function resolveCwd(cwd?: string): string {
|
|
106
|
+
const candidate = cwd && cwd.trim().length > 0 ? cwd.trim() : process.cwd()
|
|
107
|
+
try {
|
|
108
|
+
const abs = resolve(candidate)
|
|
109
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) return abs
|
|
110
|
+
} catch { /* fall through to home */ }
|
|
111
|
+
return homedir()
|
|
36
112
|
}
|
|
37
113
|
|
|
38
114
|
/**
|
|
@@ -42,34 +118,66 @@ export function defaultArgs(shell: string): string[] {
|
|
|
42
118
|
*/
|
|
43
119
|
export class PtyService {
|
|
44
120
|
private readonly sessions = new Map<string, LiveSession>()
|
|
121
|
+
/** Cached available shells (computed once on first query). */
|
|
122
|
+
private availableShellsCache: readonly ShellInfo[] | null = null
|
|
45
123
|
|
|
46
124
|
/** Fired with raw PTY output chunks (UTF-8). Bound by the route layer. */
|
|
47
125
|
onOutput: (sessionId: string, data: string) => void = () => {}
|
|
48
126
|
/** Fired once when a session exits. Bound by the route layer. */
|
|
49
127
|
onExit: (sessionId: string, exitCode: number) => void = () => {}
|
|
128
|
+
/** Fired when a session is detached (tab closed, PTY kept alive). */
|
|
129
|
+
onDetach: (sessionId: string) => void = () => {}
|
|
130
|
+
/** Fired when a session is reattached. */
|
|
131
|
+
onReattach: (session: TermSessionInfo) => void = () => {}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Returns the shells actually available on this machine, so the browser
|
|
135
|
+
* can hide options the user never installed (Git Bash) instead of letting
|
|
136
|
+
* node-pty fail at spawn time. Results are cached after the first async
|
|
137
|
+
* detection to avoid repeated `where` calls.
|
|
138
|
+
*/
|
|
139
|
+
async detectShells(): Promise<readonly ShellInfo[]> {
|
|
140
|
+
if (this.availableShellsCache !== null) return this.availableShellsCache
|
|
141
|
+
const available: ShellType[] = []
|
|
142
|
+
if (await hasGitBash()) available.push('gitbash')
|
|
143
|
+
if (IS_WIN) {
|
|
144
|
+
if (await commandExistsAsync('powershell.exe') || await commandExistsAsync('pwsh.exe')) available.push('powershell')
|
|
145
|
+
if (await commandExistsAsync('cmd.exe')) available.push('cmd')
|
|
146
|
+
} else {
|
|
147
|
+
if (existsSync(UNIX_SHELL_PATHS.zsh)) available.push('zsh')
|
|
148
|
+
if (existsSync(UNIX_SHELL_PATHS.bash)) available.push('bash')
|
|
149
|
+
}
|
|
150
|
+
this.availableShellsCache = available.map((id) => ({ id, labelKey: `ui.shell.${id}` }))
|
|
151
|
+
return this.availableShellsCache
|
|
152
|
+
}
|
|
50
153
|
|
|
51
154
|
/** Open one session; returns the wire info immediately (output streams async). */
|
|
52
155
|
spawn(req: TermSpawnRequest): TermSessionInfo {
|
|
53
156
|
const id = randomUUID()
|
|
54
|
-
const
|
|
55
|
-
const
|
|
157
|
+
const shellType: ShellType = req.shell ?? defaultShellType()
|
|
158
|
+
const { command, args } = resolveShell(shellType)
|
|
159
|
+
const finalArgs = req.args ?? args
|
|
56
160
|
const cols = req.cols ?? 80
|
|
57
161
|
const rows = req.rows ?? 24
|
|
58
|
-
const cwd = req.cwd
|
|
59
|
-
const
|
|
162
|
+
const cwd = resolveCwd(req.cwd)
|
|
163
|
+
const env = { ...process.env, TERM: 'xterm-256color', FORCE_COLOR: '1', ...(req.env ?? {}) }
|
|
164
|
+
const pty = nodePty.spawn(command, finalArgs, {
|
|
60
165
|
name: 'xterm-256color',
|
|
61
166
|
cols,
|
|
62
167
|
rows,
|
|
63
168
|
cwd,
|
|
169
|
+
env,
|
|
64
170
|
})
|
|
65
171
|
const info: TermSessionInfo = {
|
|
66
172
|
id,
|
|
67
|
-
title: req.name ??
|
|
173
|
+
title: req.name ?? shellType,
|
|
68
174
|
cwd,
|
|
69
175
|
cols,
|
|
70
176
|
rows,
|
|
71
177
|
alive: true,
|
|
72
178
|
exitCode: null,
|
|
179
|
+
shell: shellType,
|
|
180
|
+
detached: false,
|
|
73
181
|
}
|
|
74
182
|
this.sessions.set(id, { info, pty })
|
|
75
183
|
pty.onData((data) => { this.onOutput(id, data) })
|
|
@@ -108,19 +216,54 @@ export class PtyService {
|
|
|
108
216
|
return true
|
|
109
217
|
}
|
|
110
218
|
|
|
111
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* Detach a session: mark it as detached but keep the PTY process alive.
|
|
221
|
+
* This lets the user close a tab without losing a running command (e.g.
|
|
222
|
+
* `npm install`); reopening re-attaches to the same session.
|
|
223
|
+
*/
|
|
224
|
+
detach(id: string): boolean {
|
|
225
|
+
const live = this.sessions.get(id)
|
|
226
|
+
if (live === undefined) return false
|
|
227
|
+
const newInfo: TermSessionInfo = { ...live.info, detached: true }
|
|
228
|
+
this.sessions.set(id, { info: newInfo, pty: live.pty })
|
|
229
|
+
this.onDetach(id)
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Reattach to a detached session: mark it as attached and return the info.
|
|
235
|
+
* The caller creates a fresh xterm and starts routing SSE output to it.
|
|
236
|
+
*/
|
|
237
|
+
reattach(id: string): TermSessionInfo | null {
|
|
238
|
+
const live = this.sessions.get(id)
|
|
239
|
+
if (live === undefined) return null
|
|
240
|
+
const newInfo: TermSessionInfo = { ...live.info, detached: false }
|
|
241
|
+
this.sessions.set(id, { info: newInfo, pty: live.pty })
|
|
242
|
+
this.onReattach(newInfo)
|
|
243
|
+
return newInfo
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Close a session forcefully (kill the PTY). Returns false when unknown. */
|
|
112
247
|
close(id: string): boolean {
|
|
113
248
|
const live = this.sessions.get(id)
|
|
114
249
|
if (live === undefined) return false
|
|
115
250
|
try { live.pty.kill() } catch { /* already dead */ }
|
|
251
|
+
this.sessions.delete(id)
|
|
116
252
|
return true
|
|
117
253
|
}
|
|
118
254
|
|
|
119
|
-
/** The full session listing snapshot. */
|
|
255
|
+
/** The full session listing snapshot (including detached sessions). */
|
|
120
256
|
list(): readonly TermSessionInfo[] {
|
|
121
257
|
return [...this.sessions.values()].map(({ info }) => ({ ...info }))
|
|
122
258
|
}
|
|
123
259
|
|
|
260
|
+
/** Only the detached sessions (for the "reopen" dropdown). */
|
|
261
|
+
detachedList(): readonly TermSessionInfo[] {
|
|
262
|
+
return [...this.sessions.values()]
|
|
263
|
+
.filter(({ info }) => info.detached)
|
|
264
|
+
.map(({ info }) => ({ ...info }))
|
|
265
|
+
}
|
|
266
|
+
|
|
124
267
|
/** Close every session (route teardown). */
|
|
125
268
|
dispose(): void {
|
|
126
269
|
for (const live of this.sessions.values()) {
|