@electerm/electerm-react 5.5.15 → 5.5.35

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 (64) hide show
  1. package/README.md +14 -4
  2. package/client/common/bookmark-schemas.js +3 -0
  3. package/client/common/constants.js +16 -0
  4. package/client/common/create-title.jsx +5 -1
  5. package/client/common/default-setting.js +4 -2
  6. package/client/common/parse-vv.js +407 -0
  7. package/client/common/theme-defaults.js +5 -2
  8. package/client/common/update-check.js +5 -4
  9. package/client/common/vv-to-tab.js +120 -0
  10. package/client/components/ai/agent-tool-call-card.jsx +19 -4
  11. package/client/components/ai/agent.js +33 -16
  12. package/client/components/ai/ai-chat-history-item.jsx +12 -48
  13. package/client/components/ai/ai-chat.jsx +82 -25
  14. package/client/components/ai/ai-config-props.js +12 -1
  15. package/client/components/ai/ai-config.jsx +14 -0
  16. package/client/components/ai/ai-context-indicator.jsx +83 -0
  17. package/client/components/ai/ai-context.js +272 -0
  18. package/client/components/ai/ai-output.jsx +4 -0
  19. package/client/components/ai/ai-presets.js +13 -2
  20. package/client/components/ai/ai.styl +105 -6
  21. package/client/components/ai/copy-icon.jsx +31 -0
  22. package/client/components/bg/css-overwrite.jsx +16 -1
  23. package/client/components/bookmark-form/common/fields.jsx +3 -0
  24. package/client/components/bookmark-form/common/vv-file-field.jsx +138 -0
  25. package/client/components/bookmark-form/config/spice.js +22 -0
  26. package/client/components/footer/cmd-history.jsx +279 -65
  27. package/client/components/footer/cmd-history.styl +68 -1
  28. package/client/components/footer/footer-entry.jsx +46 -15
  29. package/client/components/icons/ai-icon.styl +4 -1
  30. package/client/components/layout/layout.jsx +12 -13
  31. package/client/components/main/main.jsx +23 -3
  32. package/client/components/main/term-fullscreen.styl +7 -0
  33. package/client/components/quick-commands/qm.styl +58 -0
  34. package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
  35. package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
  36. package/client/components/quick-commands/quick-commands-box.jsx +197 -82
  37. package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
  38. package/client/components/quick-commands/quick-commands-select.jsx +11 -0
  39. package/client/components/setting-panel/list.jsx +2 -2
  40. package/client/components/setting-panel/setting-common.jsx +2 -0
  41. package/client/components/side-panel-r/right-side-panel.styl +11 -1
  42. package/client/components/side-panel-r/side-panel-r.jsx +56 -13
  43. package/client/components/sidebar/bookmark-select.jsx +17 -3
  44. package/client/components/sidebar/history-item.jsx +20 -4
  45. package/client/components/sidebar/history.jsx +7 -0
  46. package/client/components/sidebar/index.jsx +13 -4
  47. package/client/components/sidebar/side-panel.jsx +8 -3
  48. package/client/components/sidebar/sidebar-panel.jsx +20 -7
  49. package/client/components/terminal/shortcut-bar.styl +14 -0
  50. package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
  51. package/client/components/terminal-info/terminal-info.styl +3 -0
  52. package/client/components/theme/terminal-theme-list.styl +3 -0
  53. package/client/components/theme/theme-ai-editor.jsx +2 -2
  54. package/client/components/theme/theme-form.jsx +2 -2
  55. package/client/components/theme/theme-list-item.jsx +59 -25
  56. package/client/components/tree-list/tree-list.jsx +31 -3
  57. package/client/css/mobile.styl +28 -0
  58. package/client/store/common.js +164 -32
  59. package/client/store/init-state.js +10 -1
  60. package/client/store/load-data.js +72 -0
  61. package/client/store/sidebar.js +5 -1
  62. package/client/store/store.js +15 -2
  63. package/client/store/watch.js +3 -1
  64. package/package.json +1 -1
@@ -158,7 +158,11 @@ export default class ItemListTree extends Component {
158
158
  : matchedRowKeys[0]
159
159
  const row = rows.find(item => item.key === rowKey)
160
160
  if (row?.item?.id) {
161
- this.selectBookmarkById(row.item.id)
161
+ // Enter is a deliberate activation, so it opens the bookmark even when
162
+ // doubleClickToOpenBookmark makes a single click only mark the row. The
163
+ // settings bookmarks tab passes no onDoubleClickItem, so there Enter
164
+ // still selects for editing, exactly like a click.
165
+ this.selectBookmarkById(row.item.id, this.props.onDoubleClickItem)
162
166
  }
163
167
  }
164
168
  }
@@ -404,7 +408,9 @@ export default class ItemListTree extends Component {
404
408
  }
405
409
  }
406
410
 
407
- selectBookmarkById = (id) => {
411
+ // `act` overrides what a selection does; only the Enter path passes it, so a
412
+ // click keeps whatever meaning the consumer gave onClickItem.
413
+ selectBookmarkById = (id, act) => {
408
414
  const { store } = window
409
415
  store.storeAssign({
410
416
  currentBookmarkGroupId: findBookmarkGroupId(store.bookmarkGroups, id)
@@ -414,7 +420,28 @@ export default class ItemListTree extends Component {
414
420
  d => d.id === id
415
421
  )
416
422
  if (bookmark) {
417
- this.props.onClickItem(bookmark)
423
+ (act || this.props.onClickItem)(bookmark)
424
+ }
425
+ }
426
+
427
+ handleDoubleClick = (e) => {
428
+ // only the static list (the sidebar) passes this, so the bookmark edit page
429
+ // keeps its single click to select behaviour
430
+ const { onDoubleClickItem, bookmarks } = this.props
431
+ if (!onDoubleClickItem) {
432
+ return
433
+ }
434
+ const item = findParentBySel(e.target, '.tree-item')
435
+ // a group toggles on a single click, so a double click would just toggle it
436
+ // twice and land back where it started
437
+ if (!item || item.getAttribute('data-is-group') === 'true') {
438
+ return
439
+ }
440
+ const bookmark = bookmarks.find(
441
+ d => d.id === item.getAttribute('data-item-id')
442
+ )
443
+ if (bookmark) {
444
+ onDoubleClickItem(bookmark)
418
445
  }
419
446
  }
420
447
 
@@ -917,6 +944,7 @@ export default class ItemListTree extends Component {
917
944
  className='item-list-wrap'
918
945
  style={listStyle}
919
946
  ref={this.listRef}
947
+ onDoubleClick={this.handleDoubleClick}
920
948
  >
921
949
  {this.renderVirtualTreeContent(rows, editor)}
922
950
  {this.renderEditorOverlay(editor)}
@@ -11,6 +11,34 @@
11
11
  .remote-monitor-bar
12
12
  position fixed
13
13
 
14
+ // Side panels are full-screen drawers on mobile: they start at the viewport
15
+ // edge (the left one covers the icon bar too, so no strip of terminal is left
16
+ // showing) and run to the bottom edge — footer included. On desktop the
17
+ // footer is reserved *beside* the panel because it holds the panel's own
18
+ // trigger; on mobile the drawer is dismissed from its header (✕ / the
19
+ // bookmark/history tabs) instead, so there is nothing to keep clear. The
20
+ // inline width already comes from store.leftSidePanelWidth / rightPanelWidth,
21
+ // which are the viewport width here.
22
+ // Both are on the fixed frame (the right panel explicitly: its bottom edge
23
+ // must not drift from the fixed footer when the visual viewport changes), and
24
+ // both have to outrank the footer's 200 — it comes later in the DOM, so at an
25
+ // equal rank it would paint over the drawer. The left drawer lives inside
26
+ // .sidebar, which is its own stacking context (z-index 100), so lifting the
27
+ // drawer alone is not enough: the whole sidebar column has to clear the
28
+ // footer. The right panel is a root-level child, so its own z-index suffices.
29
+ // The icon bar takes no extra room from this: it is 43px wide and the footer
30
+ // starts at 44px.
31
+ .sidebar
32
+ z-index 250
33
+ .sidebar-list
34
+ left 0
35
+ bottom 0
36
+ z-index 250
37
+ .right-side-panel
38
+ position fixed
39
+ bottom 0
40
+ z-index 250
41
+
14
42
  // ---- 1. Tabs: show only current tab + dropdown, close icon always visible
15
43
  .tabs.mobile-tabs
16
44
  display flex
@@ -5,11 +5,15 @@
5
5
  import handleError from '../common/error-handler'
6
6
  import Modal from '../components/common/modal'
7
7
  import { appendMandatoryGuardrails } from '../components/ai/ai-guardrails'
8
+ import { buildSessionMessages } from '../components/ai/ai-context'
8
9
  import { debounce, some, get, pickBy } from 'lodash-es'
9
10
  import {
10
11
  leftSidePanelWidthKey,
11
12
  leftSideBarOpenKey,
12
13
  rightSidebarWidthKey,
14
+ rightPanelPinnedKey,
15
+ cmdHistoryInRightPanelKey,
16
+ quickCommandsInRightPanelKey,
13
17
  addPanelWidthLsKey,
14
18
  connectionMap,
15
19
  lastAiChatSessionIdKey,
@@ -24,7 +28,7 @@ import { requireTermOfUse } from '../common/term-of-use'
24
28
  import { action } from 'manate'
25
29
  import uid from '../common/uid'
26
30
  import deepCopy from 'json-deep-copy'
27
- import { aiConfigsArr } from '../components/ai/ai-config-props'
31
+ import { aiConfigsArr, optionalAIConfigsArr } from '../components/ai/ai-config-props'
28
32
 
29
33
  const e = window.translate
30
34
  const { assign } = Object
@@ -42,6 +46,18 @@ export default Store => {
42
46
  window.store.setConfig(ext)
43
47
  }
44
48
 
49
+ // The footer info icon is a toggle: the same click opens and closes the
50
+ // panel, so the trigger never has to be hunted down in the panel header.
51
+ Store.prototype.toggleInfoPanel = action(function () {
52
+ const { store } = window
53
+ const isOpen = store.rightPanelVisible && store.rightPanelTab === 'info'
54
+ store.rightPanelVisible = !isOpen
55
+ store.rightPanelTab = 'info'
56
+ if (!isOpen) {
57
+ store.openInfoPanelAction()
58
+ }
59
+ })
60
+
45
61
  Store.prototype.openInfoPanel = action(function () {
46
62
  const { store } = window
47
63
  store.rightPanelVisible = true
@@ -122,7 +138,13 @@ export default Store => {
122
138
  Object.assign(window.store._termSearchOptions, update)
123
139
  }
124
140
 
141
+ // Both panel widths are desktop-only preferences: on mobile the panel is a
142
+ // full-width drawer whose width is fixed, so a drag (or any other caller)
143
+ // must not overwrite the desktop value that is still in localStorage.
125
144
  Store.prototype.setLeftSidePanelWidth = function (v) {
145
+ if (window.store.isMobile) {
146
+ return
147
+ }
126
148
  ls.setItem(leftSidePanelWidthKey, v)
127
149
  window.store._leftSidePanelWidth = v
128
150
  }
@@ -144,9 +166,116 @@ export default Store => {
144
166
  }
145
167
 
146
168
  Store.prototype.setRightSidePanelWidth = function (v) {
169
+ if (window.store.isMobile) {
170
+ return
171
+ }
147
172
  ls.setItem(rightSidebarWidthKey, v)
148
173
  window.store._rightPanelWidth = v
149
174
  }
175
+
176
+ // Persist the pin the same way the left sidebar does (sidebarPinnedKey):
177
+ // pinned is a durable layout preference, not a per-session toggle. It matters
178
+ // more here because pinned and unpinned are visibly different modes — the
179
+ // unpinned panel is an overlay that clears the footer, the pinned one is a
180
+ // full-height dock.
181
+ Store.prototype.setRightPanelPinned = function (v) {
182
+ ls.setItem(rightPanelPinnedKey, v + '')
183
+ window.store.rightPanelPinned = v
184
+ }
185
+
186
+ // The cmd history panel has two homes: the footer popover (the default) and
187
+ // the right side panel. Which one it is in is a durable preference, persisted
188
+ // like the right panel pin — the footer trigger has to keep landing where the
189
+ // user last put the panel, or the choice would only survive until the next
190
+ // reload.
191
+ Store.prototype.setCmdHistoryInRightPanel = function (v) {
192
+ ls.setItem(cmdHistoryInRightPanelKey, v + '')
193
+ window.store.cmdHistoryInRightPanel = v
194
+ }
195
+
196
+ Store.prototype.openCmdHistoryPanel = function () {
197
+ const { store } = window
198
+ store.rightPanelVisible = true
199
+ store.rightPanelTab = 'cmdHistory'
200
+ }
201
+
202
+ // Same toggle contract as toggleInfoPanel/toggleAIPanel: while the history
203
+ // lives in the right panel, the footer trigger opens and closes it.
204
+ Store.prototype.toggleCmdHistoryPanel = function () {
205
+ const { store } = window
206
+ if (store.rightPanelVisible && store.rightPanelTab === 'cmdHistory') {
207
+ store.rightPanelVisible = false
208
+ return
209
+ }
210
+ store.openCmdHistoryPanel()
211
+ }
212
+
213
+ Store.prototype.moveCmdHistoryToRightPanel = function () {
214
+ const { store } = window
215
+ store.setCmdHistoryInRightPanel(true)
216
+ store.openCmdHistoryPanel()
217
+ }
218
+
219
+ // Hand the panel back to the footer popover and open it there: the move has
220
+ // to be visible, otherwise the panel just looks like it vanished.
221
+ Store.prototype.moveCmdHistoryToFooter = function () {
222
+ const { store } = window
223
+ store.setCmdHistoryInRightPanel(false)
224
+ if (store.rightPanelTab === 'cmdHistory') {
225
+ store.rightPanelVisible = false
226
+ }
227
+ refsStatic.get('CmdHistory')?.openPopover()
228
+ }
229
+
230
+ // The quick command panel has the same two homes as the cmd history one, and
231
+ // the same durable preference: the footer popup (the default) and the right
232
+ // side panel. The footer popup is not a popover but a floating box driven by
233
+ // store.openQuickCommandBar, so "docked" is expressed by the preference alone
234
+ // and the box itself decides which of its two forms to render.
235
+ Store.prototype.setQuickCommandsInRightPanel = function (v) {
236
+ ls.setItem(quickCommandsInRightPanelKey, v + '')
237
+ window.store.quickCommandsInRightPanel = v
238
+ }
239
+
240
+ Store.prototype.openQuickCommandsPanel = function () {
241
+ const { store } = window
242
+ store.rightPanelVisible = true
243
+ store.rightPanelTab = 'quickCommands'
244
+ }
245
+
246
+ // Same toggle contract as toggleInfoPanel/toggleAIPanel/toggleCmdHistoryPanel:
247
+ // while the panel lives in the right panel, the footer Q opens and closes it.
248
+ Store.prototype.toggleQuickCommandsPanel = function () {
249
+ const { store } = window
250
+ if (store.rightPanelVisible && store.rightPanelTab === 'quickCommands') {
251
+ store.rightPanelVisible = false
252
+ return
253
+ }
254
+ store.openQuickCommandsPanel()
255
+ }
256
+
257
+ Store.prototype.moveQuickCommandsToRightPanel = function () {
258
+ const { store } = window
259
+ store.setQuickCommandsInRightPanel(true)
260
+ // the footer box would otherwise be left floating over a terminal it no
261
+ // longer owns; the pin is deliberately kept, so handing the panel back
262
+ // restores the exact shape it had before
263
+ store.openQuickCommandBar = false
264
+ store.openQuickCommandsPanel()
265
+ }
266
+
267
+ // Hand the panel back to the footer and open it there: the move has to be
268
+ // visible, otherwise the panel just looks like it vanished. A pin that was
269
+ // left on while docked brings the box back pinned, which is what the user
270
+ // last asked for.
271
+ Store.prototype.moveQuickCommandsToFooter = function () {
272
+ const { store } = window
273
+ store.setQuickCommandsInRightPanel(false)
274
+ if (store.rightPanelTab === 'quickCommands') {
275
+ store.rightPanelVisible = false
276
+ }
277
+ store.openQuickCommandBar = true
278
+ }
150
279
  Store.prototype.beforeExit = function (evt) {
151
280
  const { confirmBeforeExit } = window.store.config
152
281
  if (
@@ -286,6 +415,18 @@ export default Store => {
286
415
  }
287
416
  }
288
417
 
418
+ // Same toggle contract as toggleInfoPanel: the footer AI button opens and
419
+ // closes its own panel. The open path must go through handleOpenAIPanel so
420
+ // the missing-config prompt still fires; closing must not touch config.
421
+ Store.prototype.toggleAIPanel = function () {
422
+ const { store } = window
423
+ if (store.rightPanelVisible && store.rightPanelTab === 'ai') {
424
+ store.rightPanelVisible = false
425
+ return
426
+ }
427
+ store.handleOpenAIPanel()
428
+ }
429
+
289
430
  Store.prototype.explainWithAi = function (txt) {
290
431
  const { store } = window
291
432
  store.handleOpenAIPanel()
@@ -309,7 +450,10 @@ export default Store => {
309
450
  if (index === -1) {
310
451
  return
311
452
  }
312
- window.store.aiChatHistory.splice(index, 1)
453
+ // Reassign instead of splice: the store only notifies subscribers on a
454
+ // property write, so an in-place mutation leaves the panel rendering the
455
+ // list it already had.
456
+ window.store.aiChatHistory = store.aiChatHistory.filter(d => d.id !== id)
313
457
  }
314
458
 
315
459
  Store.prototype.startNewChat = action(function () {
@@ -344,6 +488,9 @@ export default Store => {
344
488
 
345
489
  Store.prototype.compressChatSession = async function (sessionId) {
346
490
  const { store } = window
491
+ if (!sessionId) {
492
+ return
493
+ }
347
494
  const sessionEntries = store.aiChatHistory
348
495
  .filter(h => h.chatSessionId === sessionId)
349
496
  .sort((a, b) => a.timestamp - b.timestamp)
@@ -367,33 +514,14 @@ export default Store => {
367
514
 
368
515
  const firstEntry = sessionEntries[0]
369
516
  const lang = firstEntry.languageAI || store.getLangName()
370
- const messages = [
371
- { role: 'system', content: appendMandatoryGuardrails(firstEntry.roleAI + `;用[${lang}]回复`) }
372
- ]
373
-
374
- // Start from the last compress entry to include its summary as context
375
- const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
376
- for (let i = startIndex; i < sessionEntries.length; i++) {
377
- const entry = sessionEntries[i]
378
- if (entry.compressed) {
379
- messages.push({
380
- role: 'user',
381
- content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
382
- })
383
- messages.push({
384
- role: 'assistant',
385
- content: 'Understood. I will use this context as we continue.'
386
- })
387
- } else {
388
- messages.push({
389
- role: 'user',
390
- content: entry.promptWithAttachments || entry.prompt
391
- })
392
- if (entry.response) {
393
- messages.push({ role: 'assistant', content: entry.response })
394
- }
395
- }
396
- }
517
+ // Same message list the chat turn would send (including the response of
518
+ // every entry), so the summary describes the conversation the model
519
+ // actually saw.
520
+ const messages = buildSessionMessages({
521
+ history: store.aiChatHistory,
522
+ chatSessionId: sessionId,
523
+ role: appendMandatoryGuardrails(firstEntry.roleAI + `;用[${lang}]回复`)
524
+ })
397
525
 
398
526
  const summaryPrompt = 'Please summarize the above conversation concisely. Include key information, decisions, context, and any important details that would be needed to continue this conversation effectively.'
399
527
  messages.push({ role: 'user', content: summaryPrompt })
@@ -440,8 +568,10 @@ export default Store => {
440
568
  compressed: true
441
569
  }
442
570
 
443
- // Append compress entry, preserve existing history
444
- store.aiChatHistory.push(compressedEntry)
571
+ // Append compress entry, preserve existing history. Reassign rather than
572
+ // push for the same reason as removeAiHistory -- the panel has to see the
573
+ // new context size, which is the whole point of compressing.
574
+ store.aiChatHistory = [...store.aiChatHistory, compressedEntry]
445
575
  }
446
576
 
447
577
  Store.prototype.toggleChatSessions = action(function () {
@@ -522,7 +652,9 @@ export default Store => {
522
652
  }
523
653
 
524
654
  Store.prototype.aiConfigMissing = function () {
525
- return aiConfigsArr.filter(k => k !== 'apiKeyAI' && k !== 'proxyAI' && k !== 'nameAI').some(k => !window.store.config[k])
655
+ return aiConfigsArr
656
+ .filter(k => !optionalAIConfigsArr.includes(k))
657
+ .some(k => !window.store.config[k])
526
658
  }
527
659
 
528
660
  Store.prototype.clearHistory = function () {
@@ -18,6 +18,9 @@ import {
18
18
  localAddrBookmarkLsKey,
19
19
  leftSidePanelWidthKey,
20
20
  rightSidebarWidthKey,
21
+ rightPanelPinnedKey,
22
+ cmdHistoryInRightPanelKey,
23
+ quickCommandsInRightPanelKey,
21
24
  addPanelWidthLsKey,
22
25
  qmSortByFrequencyKey,
23
26
  resolutionsLsKey,
@@ -94,6 +97,10 @@ export default () => {
94
97
  _batchInputSelectedTabIds: new Set(),
95
98
  aiChatHistory: [],
96
99
  agentRunning: false,
100
+ // live context accounting while an agent loop is running: the agent
101
+ // carries tool schemas and tool results that the session history does
102
+ // not, so it publishes its own figure here (see components/ai/agent.js)
103
+ aiContextInfo: null,
97
104
  currentChatSessionId: window.localStorage.getItem(lastAiChatSessionIdKey) || '',
98
105
  showChatSessions: false,
99
106
 
@@ -125,7 +132,9 @@ export default () => {
125
132
  terminalInfoProps: {},
126
133
  rightPanelVisible: false,
127
134
  rightPanelTab: 'info',
128
- rightPanelPinned: false,
135
+ rightPanelPinned: ls.getItem(rightPanelPinnedKey) === 'true',
136
+ cmdHistoryInRightPanel: ls.getItem(cmdHistoryInRightPanelKey) === 'true',
137
+ quickCommandsInRightPanel: ls.getItem(quickCommandsInRightPanelKey) === 'true',
129
138
  _rightPanelWidth: parseInt(ls.getItem(rightSidebarWidthKey), 10) || 500,
130
139
  showAIConfigModal: false,
131
140
 
@@ -14,6 +14,10 @@ import { initWsCommon } from '../common/fetch-from-server'
14
14
  import safeParse from '../common/parse-json-safe'
15
15
  import initWatch from './watch'
16
16
  import { parseQuickConnect } from '../common/parse-quick-connect'
17
+ import message from '../components/common/message'
18
+ import fs from '../common/fs'
19
+ import { parseVv, describeVv } from '../common/parse-vv'
20
+ import { vvToTab } from '../common/vv-to-tab'
17
21
 
18
22
  function getHost (argv, opts) {
19
23
  const arr = argv
@@ -35,6 +39,56 @@ function getHost (argv, opts) {
35
39
  return {}
36
40
  }
37
41
 
42
+ /**
43
+ * Open a .vv (virt-viewer) connection file as a new Spice session.
44
+ *
45
+ * Used when electerm is handed a file from outside:
46
+ * electerm /path/to/console.vv
47
+ *
48
+ * This opens a session, it does not import a bookmark. For a Proxmox file that
49
+ * is the only shape that can work at all: the signed proxy ticket it carries is
50
+ * refused by the node's :3128 daemon once it is more than about 40 seconds old,
51
+ * so a bookmark saved from one could never connect.
52
+ *
53
+ * The parse itself is shared with the bookmark form's "Load .vv file" button --
54
+ * src/client/common/parse-vv.js is the single implementation.
55
+ *
56
+ * @param {Object} store
57
+ * @param {string} filePath - absolute path, resolved in the main process
58
+ * @returns {Promise<Object|null>} the parse result, or null on failure
59
+ */
60
+ export async function openVvFile (store, filePath) {
61
+ console.debug('opening .vv file', filePath)
62
+ let text
63
+ try {
64
+ // fs.readFile resolves the file as a utf8 string (src/app/lib/fs.js)
65
+ text = await fs.readFile(filePath)
66
+ } catch (err) {
67
+ message.error('cannot read ' + filePath + ': ' + err.message)
68
+ return null
69
+ }
70
+ const parsed = parseVv(text)
71
+ if (!parsed.ok) {
72
+ message.error(describeVv(parsed) + ' (' + filePath + ')')
73
+ return null
74
+ }
75
+ const tab = vvToTab(parsed, { filePath })
76
+ console.debug('.vv file -> tab', tab)
77
+ // A file that did not come from a person clicking through a form gets no
78
+ // chance to read a hint, so anything dropped from it has to be said out loud.
79
+ if (parsed.warnings.length) {
80
+ console.debug('.vv warnings', parsed.warnings)
81
+ }
82
+ if (parsed.ignored.length) {
83
+ message.warning(
84
+ parsed.ignored.length + ' option(s) in this .vv were skipped: ' +
85
+ parsed.ignored.map(d => d.key).join(', ')
86
+ )
87
+ }
88
+ store.ipcOpenTab(tab)
89
+ return parsed
90
+ }
91
+
38
92
  export async function addTabFromCommandLine (store, opts) {
39
93
  console.debug('command line params', opts)
40
94
  if (!opts) {
@@ -52,6 +106,12 @@ export async function addTabFromCommandLine (store, opts) {
52
106
  if (isHelp) {
53
107
  return store.openAbout(infoTabs.cmd)
54
108
  }
109
+ // A .vv connection file, e.g. `electerm /path/to/console.vv`.
110
+ // Checked before the protocol-URL scan below because the two cannot overlap:
111
+ // a path ending in .vv never matches a `scheme://` prefix.
112
+ if (opts.vvFile) {
113
+ return openVvFile(store, opts.vvFile)
114
+ }
55
115
  // Check if argv contains a protocol URL (e.g., ssh://user@host)
56
116
  // and use parseQuickConnect for proper parsing
57
117
  if (argv && argv.length) {
@@ -297,6 +357,18 @@ export default (Store) => {
297
357
  window.store.ipcOpenTab(pending)
298
358
  }
299
359
  }
360
+ /**
361
+ * A .vv file that arrived before the window existed -- macOS delivers
362
+ * 'open-file' during launch, so a cold double-click always lands here. The
363
+ * payload is the same shape initCommandLine() returns, so it goes through the
364
+ * same entry point as `electerm /path/to/console.vv`.
365
+ */
366
+ Store.prototype.checkPendingVvFile = async function () {
367
+ const pending = await window.pre.runGlobalAsync('getPendingVvFile')
368
+ if (pending) {
369
+ addTabFromCommandLine(window.store, pending)
370
+ }
371
+ }
300
372
  Store.prototype.parseQuickConnect = function (url) {
301
373
  return parseQuickConnect(url)
302
374
  }
@@ -25,7 +25,11 @@ export default Store => {
25
25
  // through this one method.
26
26
  Store.prototype.openLeftSidePanel = function (field = 'bookmarks') {
27
27
  const { store } = window
28
- if (store.pinned) {
28
+ // A pinned panel is already open, so there is nothing to toggle — except
29
+ // on mobile, where pinning is not a reachable mode (no pin control) and
30
+ // the panel is a full-width overlay. Honouring a stale desktop pin here
31
+ // would leave the bookmark button dead on arrival.
32
+ if (store.pinned && !store.isMobile) {
29
33
  return
30
34
  }
31
35
  store.setOpenedSideBar(store.openedSideBar ? '' : field)
@@ -61,10 +61,15 @@ class Store {
61
61
  return window.store.innerWidth
62
62
  }
63
63
 
64
+ // Mobile turns a side panel into a full-screen drawer: it spans the whole
65
+ // viewport and its width is not user-adjustable. So the stored desktop width
66
+ // is ignored here, and so is the icon bar (the drawer covers it) — and the
67
+ // old 500px cap, which left a strip of terminal showing on windows between
68
+ // 500 and 600px, i.e. the top of the mobile range.
64
69
  get leftSidePanelWidth () {
65
70
  const { store } = window
66
71
  if (store.isMobile) {
67
- return Math.min(store.innerWidth - store.leftSideBarWidth, 500)
72
+ return store.innerWidth
68
73
  }
69
74
  return store._leftSidePanelWidth
70
75
  }
@@ -78,10 +83,12 @@ class Store {
78
83
  return store._leftSideBarOpen ? sidebarWidth : 0
79
84
  }
80
85
 
86
+ // Same as leftSidePanelWidth: full-screen drawer on mobile, width not
87
+ // user-adjustable.
81
88
  get rightPanelWidth () {
82
89
  const { store } = window
83
90
  if (store.isMobile) {
84
- return Math.min(store.innerWidth, 500)
91
+ return store.innerWidth
85
92
  }
86
93
  return store._rightPanelWidth
87
94
  }
@@ -138,6 +145,12 @@ class Store {
138
145
  if (rightPanelTab === 'ai') {
139
146
  return getBrand(baseURLAI).brand || 'Custom AI Model'
140
147
  }
148
+ if (rightPanelTab === 'cmdHistory') {
149
+ return window.translate('cmdHistory')
150
+ }
151
+ if (rightPanelTab === 'quickCommands') {
152
+ return window.translate('quickCommands')
153
+ }
141
154
  return createTitle(window.store.currentTab)
142
155
  }
143
156
 
@@ -151,7 +151,9 @@ export default store => {
151
151
  window.pre.runGlobalAsync('setTitle', title)
152
152
  window.store.currentLayoutBatch = tab.batch
153
153
  }
154
- if (tab && store.rightPanelVisible) {
154
+ // refreshing the info panel on tab switch only concerns the info tab: the
155
+ // right panel can also be showing the AI chat or the cmd history
156
+ if (tab && store.rightPanelVisible && store.rightPanelTab === 'info') {
155
157
  window.store.openInfoPanelAction()
156
158
  }
157
159
  return store.activeTabId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "5.5.15",
3
+ "version": "5.5.35",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",