@electerm/electerm-react 5.1.25 → 5.3.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.
Files changed (55) hide show
  1. package/client/common/build-map.js +12 -0
  2. package/client/common/constants.js +12 -0
  3. package/client/common/db.js +7 -5
  4. package/client/common/import-task.js +174 -0
  5. package/client/common/term-of-use.jsx +69 -0
  6. package/client/components/ai/ai-config-props.js +8 -0
  7. package/client/components/bookmark-form/ai-bookmark-form.jsx +18 -15
  8. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  9. package/client/components/bookmark-form/common/bookmark-select.jsx +6 -5
  10. package/client/components/bookmark-form/common/fields.jsx +1 -1
  11. package/client/components/bookmark-form/common/profile-item.jsx +12 -2
  12. package/client/components/bookmark-form/config/common-fields.js +11 -0
  13. package/client/components/bookmark-form/config/rdp.js +1 -1
  14. package/client/components/bookmark-form/tree-select.jsx +5 -16
  15. package/client/components/common/import-progress.jsx +90 -0
  16. package/client/components/footer/footer-entry.jsx +1 -1
  17. package/client/components/main/main.jsx +2 -0
  18. package/client/components/profile/profile-form-rdp.jsx +1 -1
  19. package/client/components/quick-commands/quick-command-transport-mod.jsx +24 -18
  20. package/client/components/rdp/rdp-cred-form.jsx +55 -0
  21. package/client/components/rdp/rdp-session.jsx +97 -6
  22. package/client/components/setting-panel/keywords-transport.jsx +1 -4
  23. package/client/components/setting-panel/setting-modal.jsx +8 -2
  24. package/client/components/setting-panel/start-session-select.jsx +5 -16
  25. package/client/components/setting-sync/setting-sync.jsx +15 -7
  26. package/client/components/sidebar/info-modal.jsx +7 -0
  27. package/client/components/spice/spice-session.jsx +40 -0
  28. package/client/components/terminal/attach-addon-custom.js +12 -10
  29. package/client/components/terminal/shell.js +22 -11
  30. package/client/components/terminal/shortcut-bar-entry.jsx +52 -1
  31. package/client/components/terminal/terminal-color-query.mjs +32 -27
  32. package/client/components/terminal/terminal-interactive.jsx +35 -0
  33. package/client/components/terminal/terminal.jsx +24 -15
  34. package/client/components/terminal-info/activity.jsx +5 -1
  35. package/client/components/terminal-info/base.jsx +37 -22
  36. package/client/components/terminal-info/disk.jsx +5 -1
  37. package/client/components/terminal-info/local-info-resolver.js +89 -0
  38. package/client/components/terminal-info/network.jsx +5 -1
  39. package/client/components/terminal-info/run-cmd.jsx +42 -3
  40. package/client/components/terminal-info/sys-info.jsx +56 -0
  41. package/client/components/terminal-info/terminal-info.jsx +4 -1
  42. package/client/components/terminal-info/terminal-info.styl +22 -7
  43. package/client/components/tree-list/bookmark-transport.jsx +2 -1
  44. package/client/components/tree-list/bookmark-upload.js +56 -41
  45. package/client/components/tree-list/tree-list-row.jsx +11 -4
  46. package/client/components/tree-list/tree-list-rows.js +6 -3
  47. package/client/components/tree-list/tree-list.jsx +4 -2
  48. package/client/store/common.js +38 -3
  49. package/client/store/load-data.js +6 -1
  50. package/client/store/setting.js +7 -4
  51. package/client/store/store.js +3 -12
  52. package/client/store/sync.js +31 -2
  53. package/client/store/tab.js +16 -11
  54. package/client/store/watch.js +10 -1
  55. package/package.json +1 -1
@@ -1,10 +1,13 @@
1
1
  import BookmarkTransport from '../tree-list/bookmark-transport'
2
2
  import download from '../../common/download'
3
3
  import time from '../../common/time'
4
- import copy from 'json-deep-copy'
4
+ import { runImportTask } from '../../common/import-task'
5
+
6
+ const e = window.translate
5
7
 
6
8
  export default class QmTransport extends BookmarkTransport {
7
9
  name = 'quickCommands'
10
+
8
11
  beforeUpload = async (file) => {
9
12
  const { store } = this.props
10
13
  const txt = file.fileContent !== undefined
@@ -13,28 +16,31 @@ export default class QmTransport extends BookmarkTransport {
13
16
  try {
14
17
  const arr = JSON.parse(txt)
15
18
  const state = store[this.name]
16
- const arrOld = copy(state)
17
- const bmTreeOld = arrOld.reduce((p, v) => {
18
- return {
19
- ...p,
20
- [v.id]: v
21
- }
22
- }, {})
23
- arr.forEach(bg => {
24
- if (!bmTreeOld[bg.id]) {
25
- state.push(bg)
26
- }
19
+ const existing = new Set(state.map(v => v.id))
20
+ const fresh = arr.filter(bg => !existing.has(bg.id))
21
+ if (!fresh.length) {
22
+ return false
23
+ }
24
+ await runImportTask({
25
+ title: e('import'),
26
+ batch: 200,
27
+ stopWatchers: [this.name],
28
+ steps: [
29
+ {
30
+ label: e('quickCommands'),
31
+ items: fresh,
32
+ process: (chunk) => {
33
+ state.push(...chunk)
34
+ }
35
+ }
36
+ ]
27
37
  })
28
- } catch (e) {
29
- store.onError(e)
38
+ } catch (err) {
39
+ store.onError(err)
30
40
  }
31
41
  return false
32
42
  }
33
43
 
34
- renderEdit () {
35
- return null
36
- }
37
-
38
44
  handleDownload = () => {
39
45
  const { store } = this.props
40
46
  const arr = store[this.name]
@@ -0,0 +1,55 @@
1
+ /**
2
+ * RDP credentials prompt form, shown at connect time
3
+ * when username/password are missing from the bookmark
4
+ */
5
+
6
+ import {
7
+ Input,
8
+ Form,
9
+ Button
10
+ } from 'antd'
11
+ import { formItemLayout, tailFormItemLayout } from '../../common/form-layout'
12
+ import Password from '../common/password'
13
+
14
+ const FormItem = Form.Item
15
+ const e = window.translate
16
+
17
+ export default function RdpCredForm (props) {
18
+ const [form] = Form.useForm()
19
+
20
+ return (
21
+ <Form
22
+ form={form}
23
+ onFinish={props.handleFinish}
24
+ initialValues={props.initialValues || {}}
25
+ name='rdp-cred-form'
26
+ >
27
+ <div className='pd3t pd1b'>
28
+ <FormItem
29
+ {...formItemLayout}
30
+ label={e('username')}
31
+ name='username'
32
+ >
33
+ <Input autoFocus />
34
+ </FormItem>
35
+ <FormItem
36
+ {...formItemLayout}
37
+ label={e('password')}
38
+ name='password'
39
+ >
40
+ <Password />
41
+ </FormItem>
42
+ <FormItem
43
+ {...tailFormItemLayout}
44
+ >
45
+ <Button
46
+ type='primary'
47
+ htmlType='submit'
48
+ >
49
+ {e('connect')}
50
+ </Button>
51
+ </FormItem>
52
+ </div>
53
+ </Form>
54
+ )
55
+ }
@@ -1,6 +1,7 @@
1
1
  import { PureComponent, createRef } from 'react'
2
2
  import { createTerm } from '../terminal/terminal-apis'
3
3
  import deepCopy from 'json-deep-copy'
4
+ import { pick } from 'lodash-es'
4
5
  import clone from '../../common/to-simple-obj'
5
6
  import { handleErr } from '../../common/fetch'
6
7
  import {
@@ -24,6 +25,8 @@ import { readClipboardAsync } from '../../common/clipboard'
24
25
  import RemoteFloatControl from '../common/remote-float-control'
25
26
  import SwitchLabel from '../common/switch'
26
27
  import HelpIcon from '../common/help-icon'
28
+ import Modal from '../common/modal'
29
+ import RdpCredForm from './rdp-cred-form'
27
30
  import { FileTransferManager, createFileLogger } from './file-transfer'
28
31
  import { notification } from '../common/notification'
29
32
  import message from '../common/message'
@@ -65,7 +68,8 @@ export default class RdpSession extends PureComponent {
65
68
  ...resObj,
66
69
  hasRemoteFiles: false,
67
70
  downloadBtnDisabled: true,
68
- uploadReady: false
71
+ uploadReady: false,
72
+ showCredPrompt: false
69
73
  }
70
74
  this.session = null
71
75
  this.fileTransfer = null
@@ -121,13 +125,25 @@ export default class RdpSession extends PureComponent {
121
125
  return `ws://${host}:${port}/${type}/${id}?token=${tokenElecterm}${extra}`
122
126
  }
123
127
 
124
- remoteInit = async () => {
128
+ remoteInit = async (credOverride) => {
125
129
  this.setState({
126
130
  loading: true
127
131
  })
128
132
  const { config } = this.props
129
133
  const { id } = this.props
130
- const tab = window.store.applyProfile(deepCopy(this.props.tab || {}))
134
+ const tab = {
135
+ ...window.store.applyProfile(deepCopy(this.props.tab || {})),
136
+ ...credOverride
137
+ }
138
+ // keep resolved credentials for the connect-time prompt
139
+ this.tab = tab
140
+ if (!tab.username) {
141
+ this.setState({
142
+ loading: false,
143
+ showCredPrompt: true
144
+ })
145
+ return
146
+ }
131
147
  const {
132
148
  type,
133
149
  term: terminalType
@@ -192,6 +208,9 @@ export default class RdpSession extends PureComponent {
192
208
  const builder = new window.ironRdp.SessionBuilder()
193
209
  builder.username(username)
194
210
  builder.password(password)
211
+ if (tab.domain) {
212
+ builder.serverDomain(tab.domain)
213
+ }
195
214
  builder.destination(destination)
196
215
  builder.proxyAddress(proxyAddress)
197
216
  builder.authToken('none')
@@ -277,12 +296,16 @@ export default class RdpSession extends PureComponent {
277
296
  this.log(`Session ended: ${info.reason()}`, 'info')
278
297
  this.onSessionEnd()
279
298
  }).catch((e) => {
280
- this.log(`Session error: ${this.formatError(e)}`, 'error')
299
+ this.showError(e)
281
300
  this.onSessionEnd()
282
301
  })
283
302
  } catch (e) {
284
- this.log(`Connection failed: ${this.formatError(e)}`, 'error')
285
- this.setState({ loading: false })
303
+ this.showError(e)
304
+ this.setState({
305
+ loading: false,
306
+ // let the user retry with different credentials on auth failure
307
+ showCredPrompt: this.isAuthError(e) ? true : this.state.showCredPrompt
308
+ })
286
309
  this.setStatus(statusMap.error)
287
310
  }
288
311
  }
@@ -307,6 +330,73 @@ export default class RdpSession extends PureComponent {
307
330
  return e?.message || e?.toString() || String(e)
308
331
  }
309
332
 
333
+ // WrongPassword(1) / LogonFailure(2)
334
+ isAuthError = (err) => {
335
+ if (err && typeof err === 'object' && '__wbg_ptr' in err && err.kind) {
336
+ try {
337
+ const kind = err.kind()
338
+ return kind === 1 || kind === 2
339
+ } catch (_) { }
340
+ }
341
+ return false
342
+ }
343
+
344
+ // map known IronErrorKind codes to a user-friendly message
345
+ errText = (err) => {
346
+ if (this.isAuthError(err)) {
347
+ return window.translate('loginFail') + ` (${this.formatError(err)})`
348
+ }
349
+ return this.formatError(err)
350
+ }
351
+
352
+ showError = (err) => {
353
+ const text = this.errText(err)
354
+ this.log(`Connection failed: ${this.formatError(err)}`, 'error')
355
+ message.error(text, 10)
356
+ }
357
+
358
+ onCredSubmit = (res) => {
359
+ this.setState({
360
+ showCredPrompt: false
361
+ })
362
+ // tear down the failed attempt before reconnecting
363
+ this.cleanup()
364
+ // reconnect with the provided credentials, not persisted to the bookmark
365
+ this.remoteInit(res)
366
+ }
367
+
368
+ onCredCancel = () => {
369
+ this.setState({
370
+ showCredPrompt: false
371
+ })
372
+ this.setStatus(statusMap.error)
373
+ }
374
+
375
+ renderCredPrompt = () => {
376
+ const {
377
+ showCredPrompt
378
+ } = this.state
379
+ if (!showCredPrompt) {
380
+ return null
381
+ }
382
+ const confirmProps = {
383
+ title: window.translate('credentialsRequired'),
384
+ footer: null,
385
+ open: true,
386
+ onCancel: this.onCredCancel
387
+ }
388
+ return (
389
+ <Modal
390
+ {...confirmProps}
391
+ >
392
+ <RdpCredForm
393
+ initialValues={pick(this.tab, ['username', 'password'])}
394
+ handleFinish={this.onCredSubmit}
395
+ />
396
+ </Modal>
397
+ )
398
+ }
399
+
310
400
  syncLocalToRemote = async () => {
311
401
  if (!this.session || this.fileUploadInProgress) {
312
402
  return
@@ -751,6 +841,7 @@ export default class RdpSession extends PureComponent {
751
841
  ref={this.canvasRef}
752
842
  />
753
843
  </div>
844
+ {this.renderCredPrompt()}
754
845
  </div>
755
846
  </Spin>
756
847
  )
@@ -4,6 +4,7 @@ import time from '../../common/time'
4
4
 
5
5
  export default class KeywordsTransport extends BookmarkTransport {
6
6
  name = 'keywords-highlight'
7
+
7
8
  beforeUpload = async (file) => {
8
9
  const { store } = this.props
9
10
  const txt = file.fileContent !== undefined
@@ -20,10 +21,6 @@ export default class KeywordsTransport extends BookmarkTransport {
20
21
  return false
21
22
  }
22
23
 
23
- renderEdit () {
24
- return null
25
- }
26
-
27
24
  handleDownload = () => {
28
25
  const { store } = this.props
29
26
  const arr = store.config.keywords || []
@@ -33,14 +33,20 @@ export default auto(function SettingModalWrap (props) {
33
33
  settingMap.bookmarks,
34
34
  settingMap.terminalThemes
35
35
  ]
36
- const { settingTab, settingItem, settingSidebarList, bookmarkSelectMode } = store
36
+ const { settingTab, settingItem, bookmarkSelectMode } = store
37
+ // settingSidebarList deep-copies the whole collection; the bookmarks tab
38
+ // ignores `list` (it renders from treeProps), so skip it there — with
39
+ // thousands of bookmarks that copy is the most expensive part of the render
40
+ const list = settingTab === settingMap.bookmarks
41
+ ? []
42
+ : store.settingSidebarList
37
43
  const props0 = {
38
44
  store,
39
45
  activeItemId: settingItem.id,
40
46
  type: settingTab,
41
47
  onClickItem: selectItem,
42
48
  shouldConfirmDel: tabsShouldConfirmDel.includes(settingTab),
43
- list: settingSidebarList
49
+ list
44
50
  }
45
51
  const { bookmarks, bookmarkGroups, widgetInstances } = store
46
52
  const formProps = {
@@ -7,6 +7,7 @@ import {
7
7
  BookOutlined
8
8
  } from '@ant-design/icons'
9
9
  import HelpIcon from '../common/help-icon'
10
+ import buildMap from '../../common/build-map'
10
11
 
11
12
  const e = window.translate
12
13
  const { SHOW_CHILD } = TreeSelect
@@ -21,22 +22,10 @@ function BookmarkSelect (props) {
21
22
 
22
23
  const buildData = () => {
23
24
  const cats = bookmarkGroups
24
- const tree = bookmarks
25
- .reduce((p, k) => {
26
- return {
27
- ...p,
28
- [k.id]: k
29
- }
30
- }, {})
31
- const btree = cats
32
- .reduce((p, k) => {
33
- return {
34
- ...p,
35
- [k.id]: k
36
- }
37
- }, {})
25
+ const tree = buildMap(bookmarks)
26
+ const btree = buildMap(cats)
38
27
  function buildSubCats (id) {
39
- const x = btree[id]
28
+ const x = btree.get(id)
40
29
  if (!x) {
41
30
  return ''
42
31
  }
@@ -55,7 +44,7 @@ function BookmarkSelect (props) {
55
44
  return y
56
45
  }
57
46
  function buildLeaf (id) {
58
- const x = tree[id]
47
+ const x = tree.get(id)
59
48
  if (!x) {
60
49
  return ''
61
50
  }
@@ -3,8 +3,9 @@
3
3
  */
4
4
 
5
5
  import { Tabs, Spin } from 'antd'
6
+ import { useEffect } from 'react'
6
7
  import SyncForm from './setting-sync-form'
7
- import { syncTypes, syncDataMaps } from '../../common/constants'
8
+ import { allowedSyncTypes, syncDataMaps } from '../../common/constants'
8
9
  import DataTransport from './data-import'
9
10
  import DataSelect from './data-select'
10
11
  import { pick } from 'lodash-es'
@@ -24,6 +25,15 @@ export default auto(function SyncSettingEntry (props) {
24
25
  const {
25
26
  store
26
27
  } = window
28
+ const types = allowedSyncTypes()
29
+ const type = types.includes(props.syncType)
30
+ ? props.syncType
31
+ : types[0]
32
+ useEffect(() => {
33
+ if (store.syncType !== type) {
34
+ store.syncType = type
35
+ }
36
+ }, [type])
27
37
  function renderForm () {
28
38
  const syncProps = {
29
39
  ...syncSetting,
@@ -31,12 +41,11 @@ export default auto(function SyncSettingEntry (props) {
31
41
  'isSyncingSetting',
32
42
  'isSyncDownload',
33
43
  'isSyncUpload',
34
- 'syncType',
35
44
  'serverStatus'
36
45
  ]),
37
- serverStatus: deepCopy(store.syncServerStatus[props.syncType])
46
+ syncType: type,
47
+ serverStatus: deepCopy(store.syncServerStatus[type])
38
48
  }
39
- const type = props.syncType
40
49
  const formData = {
41
50
  gistId: syncSetting[type + 'GistId'],
42
51
  token: syncSetting[type + 'AccessToken'],
@@ -59,8 +68,7 @@ export default auto(function SyncSettingEntry (props) {
59
68
  />
60
69
  )
61
70
  }
62
-
63
- const syncItems = Object.keys(syncTypes).map(type => {
71
+ const syncItems = types.map(type => {
64
72
  return {
65
73
  key: type,
66
74
  label: type,
@@ -84,7 +92,7 @@ export default auto(function SyncSettingEntry (props) {
84
92
  <DataTransport {...dataImportProps} />
85
93
  <Spin spinning={store.isSyncingSetting}>
86
94
  <Tabs
87
- activeKey={store.syncType}
95
+ activeKey={type}
88
96
  onChange={handleChange}
89
97
  items={syncItems}
90
98
  />
@@ -127,6 +127,7 @@ export default auto(function InfoModal (props) {
127
127
  devDependencies,
128
128
  dependencies,
129
129
  languageRepo,
130
+ privacyNoticeLink,
130
131
  author: {
131
132
  name: authorName,
132
133
  email,
@@ -203,6 +204,12 @@ export default auto(function InfoModal (props) {
203
204
  {bugReportLink}
204
205
  </Link>
205
206
  </p>
207
+ <p className='mg1b'>
208
+ <InfoCircleOutlined /> <b className='mg1r'>{e('privacyNotice')} ➾</b>
209
+ <Link to={privacyNoticeLink} className='mg1l'>
210
+ {privacyNoticeLink}
211
+ </Link>
212
+ </p>
206
213
  <p className='mg1b'>
207
214
  <HighlightOutlined /> <b className='mg1r'>{e('changeLog')} ➾</b>
208
215
  <Link to={releaseLink} className='mg1l'>
@@ -44,12 +44,52 @@ export default class SpiceSession extends PureComponent {
44
44
 
45
45
  componentDidMount () {
46
46
  this.remoteInit()
47
+ this.attachMouseScaleFix()
47
48
  }
48
49
 
49
50
  componentWillUnmount () {
51
+ this.detachMouseScaleFix()
50
52
  this.cleanup()
51
53
  }
52
54
 
55
+ // The spice client sends event.offsetX/offsetY to the remote as-is, but when
56
+ // the canvas is CSS-scaled (scaleViewport or the lib's maxWidth/maxHeight),
57
+ // those are display pixels instead of remote surface pixels. On high
58
+ // resolution remote screens the pointer then only reaches the top-left
59
+ // region. Convert display coords to surface coords (the canvas box is
60
+ // letterboxed by object-fit: contain) before the lib's handlers run.
61
+ handleMouseScaleFix = (e) => {
62
+ const canvas = this.domRef.current?.querySelector('canvas')
63
+ if (!canvas || !canvas.width || !canvas.height) return
64
+ const rect = canvas.getBoundingClientRect()
65
+ if (!rect.width || !rect.height) return
66
+ const scale = Math.min(rect.width / canvas.width, rect.height / canvas.height)
67
+ const x = (e.clientX - rect.left - (rect.width - canvas.width * scale) / 2) / scale
68
+ const y = (e.clientY - rect.top - (rect.height - canvas.height * scale) / 2) / scale
69
+ Object.defineProperty(e, 'offsetX', {
70
+ value: Math.max(0, Math.min(x, canvas.width - 1))
71
+ })
72
+ Object.defineProperty(e, 'offsetY', {
73
+ value: Math.max(0, Math.min(y, canvas.height - 1))
74
+ })
75
+ }
76
+
77
+ attachMouseScaleFix = () => {
78
+ this.domRef.current?.addEventListener(
79
+ 'mousemove',
80
+ this.handleMouseScaleFix,
81
+ true
82
+ )
83
+ }
84
+
85
+ detachMouseScaleFix = () => {
86
+ this.domRef.current?.removeEventListener(
87
+ 'mousemove',
88
+ this.handleMouseScaleFix,
89
+ true
90
+ )
91
+ }
92
+
53
93
  cleanup = () => {
54
94
  if (this.spiceConn) {
55
95
  try {
@@ -98,12 +98,6 @@ export default class AttachAddonCustom {
98
98
  }
99
99
  }
100
100
 
101
- onShellIntegrationDetected = () => {
102
- if (this.outputSuppressed) {
103
- this.stopOutputSuppression(true)
104
- }
105
- }
106
-
107
101
  activate = async (terminal = this.term) => {
108
102
  await this._initBase()
109
103
  this.addSocketListener(this._socket, 'message', this.onMsg)
@@ -177,9 +171,10 @@ export default class AttachAddonCustom {
177
171
  }
178
172
  }
179
173
 
180
- checkForShellIntegration = (str) => {
174
+ // Index of the first OSC 633 sequence in the chunk, or -1.
175
+ indexOfShellIntegration = (str) => {
181
176
  const ESC = String.fromCharCode(27)
182
- return str.includes(ESC + ']633;')
177
+ return str.indexOf(ESC + ']633;')
183
178
  }
184
179
 
185
180
  writeToTerminalDirect = (data) => {
@@ -232,8 +227,15 @@ export default class AttachAddonCustom {
232
227
  }
233
228
 
234
229
  if (this.outputSuppressed) {
235
- if (this.checkForShellIntegration(str)) {
236
- this.onShellIntegrationDetected()
230
+ const oscIdx = this.indexOfShellIntegration(str)
231
+ if (oscIdx !== -1) {
232
+ // Shell integration is confirmed active. The pty often coalesces the
233
+ // tail of the echoed injection command and the first OSC 633 output
234
+ // into one chunk, so write only from the first OSC sequence on —
235
+ // everything before it is echo and stays hidden. A leading newline
236
+ // keeps the fresh prompt off the injection-time prompt line.
237
+ this.stopOutputSuppression(true)
238
+ this._enqueueWrite('\r\n' + str.slice(oscIdx))
237
239
  return
238
240
  }
239
241
  this.suppressedData.push(data)
@@ -27,7 +27,7 @@ function getBashInlineIntegration () {
27
27
  'if [[ $- == *i* ]] && [[ -z "${ELECTERM_SHELL_INTEGRATION:-}" ]]',
28
28
  'then export ELECTERM_SHELL_INTEGRATION=1',
29
29
  '__e_esc() { local v="$1"; v="${v//\\\\/\\\\\\\\}"; v="${v//;/\\\\x3b}"; printf \'%s\' "$v"; }',
30
- '__e_pre() { [[ "$BASH_COMMAND" == "$PROMPT_COMMAND" ]] && return; [[ "$BASH_COMMAND" == "__e_"* ]] && return; [[ "${__e_in:-0}" == "0" ]] && { __e_in=1; printf \'\\e]633;E;%s\\a\\e]633;C\\a\' "$(__e_esc "$BASH_COMMAND")"; }; }',
30
+ '__e_pre() { local p; [[ "$BASH_COMMAND" == __e_* || "$BASH_COMMAND" == PROMPT_COMMAND=* ]] && return; [[ "$BASH_COMMAND" == "$PROMPT_COMMAND" ]] && return; if [[ -n "${PROMPT_COMMAND:-}" ]]; then local IFS=\';\'; for p in $PROMPT_COMMAND; do p="${p#"${p%%[! ]*}"}"; [[ "$BASH_COMMAND" == "$p" ]] && return; done; fi; [[ "${__e_in:-0}" == "0" ]] && { __e_in=1; printf \'\\e]633;E;%s\\a\\e]633;C\\a\' "$(__e_esc "$BASH_COMMAND")"; }; }',
31
31
  '__e_cmd() { local c="$?"; [[ "${__e_in:-0}" == "1" ]] && { printf \'\\e]633;D;%s\\a\' "$c"; __e_in=0; }; printf \'\\e]633;P;Cwd=%s\\a\\e]633;A\\a\' "$(__e_esc "$PWD")"; return "$c"; }',
32
32
  'trap \'__e_pre\' DEBUG',
33
33
  'PROMPT_COMMAND="__e_cmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"',
@@ -40,17 +40,19 @@ function getBashInlineIntegration () {
40
40
  * Properly formatted for semicolon joining
41
41
  */
42
42
  function getZshInlineIntegration () {
43
- // Each statement is complete and can be joined with semicolons
44
- // Note: 'then' must have a space/newline before the next command, not semicolon
43
+ // Register hooks by appending to the precmd/preexec hook arrays directly.
44
+ // add-zsh-hook is an autoloadable shell function: on systems like NixOS
45
+ // fpath may not include the standard functions directory, the autoload
46
+ // fails silently (stderr is suppressed) and the hooks never run, breaking
47
+ // path following. Appending to the arrays works on every zsh.
45
48
  return [
46
49
  'if [[ -o interactive ]] && [[ -z "${ELECTERM_SHELL_INTEGRATION:-}" ]]',
47
50
  'then export ELECTERM_SHELL_INTEGRATION=1',
48
51
  '__e_esc() { local v="$1"; v="${v//\\\\/\\\\\\\\}"; v="${v//;/\\\\x3b}"; builtin printf \'%s\' "$v"; }',
49
52
  '__e_preexec() { __e_cmd="$1"; builtin printf \'\\e]633;E;%s\\a\\e]633;C\\a\' "$(__e_esc "$1")"; }',
50
53
  '__e_precmd() { local c="$?"; [[ -n "$__e_cmd" ]] && builtin printf \'\\e]633;D;%s\\a\' "$c"; __e_cmd=""; builtin printf \'\\e]633;P;Cwd=%s\\a\\e]633;A\\a\' "$(__e_esc "$PWD")"; }',
51
- 'autoload -Uz add-zsh-hook',
52
- 'add-zsh-hook precmd __e_precmd',
53
- 'add-zsh-hook preexec __e_preexec',
54
+ 'precmd_functions+=(__e_precmd)',
55
+ 'preexec_functions+=(__e_preexec)',
54
56
  'fi'
55
57
  ].join('; ')
56
58
  }
@@ -78,11 +80,13 @@ function getShInlineIntegration () {
78
80
  return [
79
81
  'if [ -z "$ELECTERM_SHELL_INTEGRATION" ]',
80
82
  'then export ELECTERM_SHELL_INTEGRATION=1',
81
- '__e_esc() { printf "%s" "$1" | sed "s/\\\\/\\\\\\\\/g; s/;/\\\\x3b/g"; }',
82
- // We wrap the current PS1 with OSC 633 sequences.
83
- // \033]633;P;Cwd=... \007 marks the directory
84
- // \033]633;A \007 marks the start of the prompt
85
- 'export PS1="\\e]633;P;Cwd=$(__e_esc "$PWD")\\a\\e]633;A\\a${PS1:-# }"',
83
+ '__e_esc() { printf \'%s\' "$1" | sed \'s/\\\\/\\\\\\\\/g; s/;/\\\\x3b/g\'; }',
84
+ // sh/dash/ash re-evaluate $(...) inside PS1 every time the prompt is
85
+ // shown, but only if the $() stays literal — so PS1 must be assigned
86
+ // single-quoted. printf interprets \033/\a, unlike PS1 backslash
87
+ // escapes which dash does not expand.
88
+ '__e_ps1() { printf \'\\033]633;P;Cwd=%s\\a\\033]633;A\\a\' "$(__e_esc "$PWD")"; }',
89
+ 'PS1=\'$(__e_ps1)\'"${PS1:-# }"',
86
90
  'fi'
87
91
  ].join('; ')
88
92
  }
@@ -139,6 +143,13 @@ export function wrapSilent (cmd, shellType) {
139
143
  * @returns {string} Complete command to send to terminal
140
144
  */
141
145
  export function getShellIntegrationCommand (shellType = 'bash') {
146
+ if (shellType === 'fish') {
147
+ // fish is not a Bourne shell: the eval '\''...'\'' wrapper of wrapSilent
148
+ // is a parse error in fish (nested single quotes stay unbalanced), so
149
+ // the whole injection silently fails. Send the script raw instead —
150
+ // it is already valid fish syntax as-is.
151
+ return ' ' + getInlineShellIntegration(shellType) + '\r'
152
+ }
142
153
  const cmd = getInlineShellIntegration(shellType)
143
154
  return wrapSilent(cmd, shellType)
144
155
  }