@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
@@ -1,10 +1,12 @@
1
- import React, { memo, useRef } from 'react'
1
+ import React, { memo, useRef, useEffect } from 'react'
2
2
  import DragHandle from '../common/drag-handle'
3
3
  import './right-side-panel.styl'
4
4
  import {
5
5
  CloseCircleOutlined,
6
6
  PushpinOutlined,
7
- BarChartOutlined
7
+ BarChartOutlined,
8
+ HistoryOutlined,
9
+ ThunderboltOutlined
8
10
  } from '@ant-design/icons'
9
11
  import {
10
12
  Typography,
@@ -19,17 +21,52 @@ export default memo(function RightSidePanel (
19
21
  rightPanelWidth,
20
22
  children,
21
23
  title,
22
- rightPanelTab
24
+ rightPanelTab,
25
+ isMobile
23
26
  }
24
27
  ) {
25
28
  const panelRef = useRef(null)
26
29
 
30
+ // Esc closes the panel, but never at the terminal's expense: xterm forwards
31
+ // Esc to the remote shell, so stealing it there would break vim, less, etc.
32
+ // The guard checks the event target rather than focus state, because the
33
+ // keydown bubbles up from xterm's textarea inside .term-wrap — the same
34
+ // selector shortcut-bar-entry.jsx uses to detect terminal focus. A modal owns
35
+ // Esc while one is open. This hook has to stay above the early return below
36
+ // so the hook order never changes.
37
+ useEffect(() => {
38
+ if (!rightPanelVisible) {
39
+ return
40
+ }
41
+ function onKeyDown (event) {
42
+ if (event.key !== 'Escape' || window.store.showModal) {
43
+ return
44
+ }
45
+ const { target } = event
46
+ if (target && typeof target.closest === 'function' && target.closest('.term-wrap')) {
47
+ return
48
+ }
49
+ window.store.rightPanelVisible = false
50
+ }
51
+ document.addEventListener('keydown', onKeyDown)
52
+ return () => document.removeEventListener('keydown', onKeyDown)
53
+ }, [rightPanelVisible])
54
+
27
55
  if (!rightPanelVisible) {
28
56
  return null
29
57
  }
30
- const tag = rightPanelTab === 'ai'
31
- ? <Tag className='mg1r'>AI</Tag>
32
- : <BarChartOutlined className='mg1r' />
58
+ // Mobile has no pin control (it is not rendered below), so a pin persisted
59
+ // from a desktop session must not turn the panel into a dock here — that
60
+ // would make it full height over the footer for no reachable reason.
61
+ const pinned = rightPanelPinned && !isMobile
62
+ // one glyph per tab, so the panel says what it is showing; the info tab is
63
+ // the fallback, since a stale or unknown tab falls back to it everywhere else
64
+ const tagMap = {
65
+ ai: <Tag className='mg1r'>AI</Tag>,
66
+ cmdHistory: <HistoryOutlined className='mg1r' />,
67
+ quickCommands: <ThunderboltOutlined className='mg1r' />
68
+ }
69
+ const tag = tagMap[rightPanelTab] || <BarChartOutlined className='mg1r' />
33
70
 
34
71
  function onDragEnd (nw) {
35
72
  window.store.setRightSidePanelWidth(nw)
@@ -46,11 +83,11 @@ export default memo(function RightSidePanel (
46
83
  }
47
84
 
48
85
  function togglePin () {
49
- window.store.rightPanelPinned = !window.store.rightPanelPinned
86
+ window.store.setRightPanelPinned(!window.store.rightPanelPinned)
50
87
  }
51
88
 
52
89
  const panelProps = {
53
- className: 'right-side-panel animate-fast' + (rightPanelPinned ? ' right-side-panel-pinned' : ''),
90
+ className: 'right-side-panel animate-fast' + (pinned ? ' right-side-panel-pinned' : ''),
54
91
  ref: panelRef,
55
92
  style: {
56
93
  width: `${rightPanelWidth}px`
@@ -58,7 +95,7 @@ export default memo(function RightSidePanel (
58
95
  }
59
96
 
60
97
  const pinProps = {
61
- className: 'right-side-panel-pin right-side-panel-controls' + (rightPanelPinned ? ' pinned' : ''),
98
+ className: 'right-side-panel-pin right-side-panel-controls' + (pinned ? ' pinned' : ''),
62
99
  onClick: togglePin
63
100
  }
64
101
  const dragProps = {
@@ -73,7 +110,11 @@ export default memo(function RightSidePanel (
73
110
  <div
74
111
  {...panelProps}
75
112
  >
76
- <DragHandle {...dragProps} />
113
+ {/* Mobile panels are full-screen drawers (see store.rightPanelWidth):
114
+ their width is the viewport and is not adjustable, so the resize
115
+ handle is not rendered — it would otherwise sit on the screen's left
116
+ edge and drag a size the panel never uses. */}
117
+ {!isMobile && <DragHandle {...dragProps} />}
77
118
  <Flex
78
119
  className='right-panel-title pd2'
79
120
  justify='space-between'
@@ -83,9 +124,11 @@ export default memo(function RightSidePanel (
83
124
  {tag} {title}
84
125
  </Typography.Text>
85
126
  <Flex>
86
- <PushpinOutlined
87
- {...pinProps}
88
- />
127
+ {!isMobile && (
128
+ <PushpinOutlined
129
+ {...pinProps}
130
+ />
131
+ )}
89
132
  <CloseCircleOutlined
90
133
  className='right-side-panel-close right-side-panel-controls mg1l'
91
134
  onClick={onClose}
@@ -2,11 +2,13 @@
2
2
  * bookmark select
3
3
  */
4
4
 
5
+ import { useState } from 'react'
5
6
  import { auto } from 'manate/react'
6
7
  import TreeList from '../tree-list/tree-list'
7
8
 
8
9
  export default auto(function BookmarkSelect (props) {
9
10
  const { store, from, autoFocus } = props
11
+ const [activeItemId, setActiveItemId] = useState('')
10
12
  const {
11
13
  listStyle,
12
14
  openedSideBar,
@@ -19,16 +21,28 @@ export default auto(function BookmarkSelect (props) {
19
21
  if (from === 'sidebar' && openedSideBar !== 'bookmarks') {
20
22
  return null
21
23
  }
22
- const onClickItem = (item) => {
23
- if (!store.pinned) {
24
+ const openBookmark = (item) => {
25
+ // A pinned (docked) panel stays open; anything else — including mobile,
26
+ // where pinning is not a reachable mode — dismisses on selection.
27
+ if (!(store.pinned && !store.isMobile)) {
24
28
  store.setOpenedSideBar('')
25
29
  }
26
30
  store.onSelectBookmark(item.id)
27
31
  }
32
+ // doubleClickToOpenBookmark: a single click only marks the row, the session
33
+ // opens on a double click. Touch devices have no double click, so the setting
34
+ // is ignored there. This is the sidebar list only — the settings bookmarks tab
35
+ // keeps selecting for editing on a single click.
36
+ const openOnDoubleClick = !!store.config.doubleClickToOpenBookmark &&
37
+ !store.isTouchDevice
28
38
  const base = {
29
39
  bookmarks: bookmarks || [],
30
40
  type: 'bookmarks',
31
- onClickItem,
41
+ activeItemId: openOnDoubleClick ? activeItemId : undefined,
42
+ onClickItem: openOnDoubleClick
43
+ ? item => setActiveItemId(item.id)
44
+ : openBookmark,
45
+ onDoubleClickItem: openOnDoubleClick ? openBookmark : undefined,
32
46
  listStyle,
33
47
  staticList: true
34
48
  }
@@ -1,4 +1,5 @@
1
- import React, { useCallback, useEffect, useRef } from 'react'
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
+ import classnames from 'classnames'
2
3
  import createTitle, { createTitleWithTag } from '../../common/create-title'
3
4
  import { DeleteOutlined, BookFilled } from '@ant-design/icons'
4
5
  import { refsStatic } from '../common/ref'
@@ -6,18 +7,32 @@ import { refsStatic } from '../common/ref'
6
7
  export default function HistoryItem (props) {
7
8
  const { store } = window
8
9
  const {
9
- item
10
+ item,
11
+ openOnDoubleClick
10
12
  } = props
13
+ const [active, setActive] = useState(false)
11
14
  const timeoutRef = useRef(null)
12
15
 
13
16
  const handleClick = useCallback(() => {
17
+ // with doubleClickToOpenBookmark on, a click only marks the row — the
18
+ // session opens on the double click below
19
+ if (openOnDoubleClick) {
20
+ setActive(true)
21
+ return
22
+ }
14
23
  if (timeoutRef.current) {
15
24
  clearTimeout(timeoutRef.current)
16
25
  }
17
26
  timeoutRef.current = setTimeout(() => {
18
27
  store.onSelectHistory(item.tab)
19
28
  }, 10)
20
- }, [item.tab])
29
+ }, [item.tab, openOnDoubleClick])
30
+
31
+ const handleDoubleClick = useCallback(() => {
32
+ if (openOnDoubleClick) {
33
+ store.onSelectHistory(item.tab)
34
+ }
35
+ }, [item.tab, openOnDoubleClick])
21
36
 
22
37
  useEffect(() => {
23
38
  return () => {
@@ -47,9 +62,10 @@ export default function HistoryItem (props) {
47
62
  const tt = createTitle(item.tab)
48
63
  return (
49
64
  <div
50
- className='item-list-unit'
65
+ className={classnames('item-list-unit', { active })}
51
66
  title={tt}
52
67
  onClick={handleClick}
68
+ onDoubleClick={handleDoubleClick}
53
69
  >
54
70
  <div className='elli pd1y pd2x'>
55
71
  {title}
@@ -25,6 +25,12 @@ export default auto(function HistoryPanel (props) {
25
25
  const {
26
26
  history
27
27
  } = store
28
+ // doubleClickToOpenBookmark: a single click only marks the row, the session
29
+ // opens on a double click. Touch devices have no double click, so the setting
30
+ // is ignored there. Read here rather than in HistoryItem because this
31
+ // component is `auto` — it re-renders when the input modality flips.
32
+ const openOnDoubleClick = !!store.config.doubleClickToOpenBookmark &&
33
+ !store.isTouchDevice
28
34
  let arr = store.config.disableConnectionHistory ? [] : history
29
35
  if (sortByFrequency) {
30
36
  arr = [...arr].sort((a, b) => { return b.count - a.count })
@@ -75,6 +81,7 @@ export default auto(function HistoryPanel (props) {
75
81
  <HistoryItem
76
82
  key={item.id}
77
83
  item={item}
84
+ openOnDoubleClick={openOnDoubleClick}
78
85
  />
79
86
  )
80
87
  })
@@ -43,17 +43,24 @@ export default function Sidebar (props) {
43
43
  openWidgetsModal,
44
44
  zoom,
45
45
  leftSideBarIcons,
46
- widgetInstancesLength
46
+ widgetInstancesLength,
47
+ isMobile
47
48
  } = props
48
49
 
49
50
  const { store } = window
50
51
 
52
+ // pinned is desktop-only, like the right panel's: on mobile the panel spans
53
+ // the viewport and there is no pin control to unpin it, so a pin persisted
54
+ // from a desktop session must not turn into a mode the user cannot leave.
55
+ const pinnedActive = pinned && !isMobile
56
+
51
57
  const handleClickOutside = (event) => {
52
58
  const { store } = window
53
59
  // Nothing to dismiss when the panel is closed or pinned; also ignore
54
60
  // clicks while typing in an input so the panel is not yanked away
55
- // mid-interaction.
56
- if (!store.openedSideBar || store.pinned || hasActiveInput()) {
61
+ // mid-interaction. Read the pin live rather than from the render scope:
62
+ // this listener is attached once, so a captured value would go stale.
63
+ if (!store.openedSideBar || (store.pinned && !store.isMobile) || hasActiveInput()) {
57
64
  return
58
65
  }
59
66
  const target = event.target
@@ -221,10 +228,12 @@ export default function Sidebar (props) {
221
228
  setLeftSidePanelWidth={setLeftSidePanelWidth}
222
229
  leftSidePanelWidth={leftSidePanelWidth}
223
230
  leftSideBarWidth={leftSideBarWidth}
231
+ isMobile={isMobile}
224
232
  >
225
233
  <SideBarPanel
226
- pinned={pinned}
234
+ pinned={pinnedActive}
227
235
  sidebarPanelTab={sidebarPanelTab}
236
+ isMobile={isMobile}
228
237
  />
229
238
  </SidePanel>
230
239
  </div>
@@ -26,15 +26,20 @@ export default function SidePanel (props) {
26
26
  onDragMove,
27
27
  left: true
28
28
  }
29
+ // Mobile panels are full-screen drawers: the width is the viewport and is not
30
+ // adjustable, so the resize handle is not rendered at all — a handle there
31
+ // would sit on the screen edge and drag a size the panel does not use (see
32
+ // store.leftSidePanelWidth, which ignores the stored width on mobile).
33
+ const dragHandle = props.isMobile
34
+ ? null
35
+ : <DragHandle {...dragProps} />
29
36
  return (
30
37
  <div
31
38
  {...props.sideProps}
32
39
  ref={panelRef}
33
40
  draggable={false}
34
41
  >
35
- <DragHandle
36
- {...dragProps}
37
- />
42
+ {dragHandle}
38
43
  {props.children}
39
44
  </div>
40
45
  )
@@ -20,7 +20,7 @@ import {
20
20
  const e = window.translate
21
21
 
22
22
  export default memo(function SidebarPanel (props) {
23
- const { sidebarPanelTab, pinned } = props
23
+ const { sidebarPanelTab, pinned, isMobile } = props
24
24
  const { store } = window
25
25
  const [openSelectModal, setOpenSelectModal] = useState(false)
26
26
  const prps = {
@@ -81,6 +81,22 @@ export default memo(function SidebarPanel (props) {
81
81
  </Tooltip>
82
82
  ]
83
83
  }
84
+ // No pin control on mobile: the panel spans the viewport there, so there is
85
+ // nothing to dock it beside, and the control row is tight enough that the
86
+ // pin would crowd the icons it shares it with.
87
+ function renderPinIcon () {
88
+ if (isMobile) {
89
+ return null
90
+ }
91
+ return (
92
+ <Tooltip title={e('pin')}>
93
+ <PushpinOutlined
94
+ {...prps1}
95
+ onClick={store.handlePin}
96
+ />
97
+ </Tooltip>
98
+ )
99
+ }
84
100
  return (
85
101
  <div
86
102
  className='sidebar-panel bookmarks-panel animate-fast'
@@ -100,12 +116,9 @@ export default memo(function SidebarPanel (props) {
100
116
  {
101
117
  renderExpandIcons()
102
118
  }
103
- <Tooltip title={e('pin')}>
104
- <PushpinOutlined
105
- {...prps1}
106
- onClick={store.handlePin}
107
- />
108
- </Tooltip>
119
+ {
120
+ renderPinIcon()
121
+ }
109
122
  <CloseOutlined
110
123
  {...prps}
111
124
  onClick={store.handleCloseSidebar}
@@ -81,6 +81,20 @@
81
81
  bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px))
82
82
  .remote-monitor-bar
83
83
  bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px) + 36px)
84
+ // the right panel stacks on the footer exactly like the monitor bar, so it
85
+ // needs the same lift. Pinned is excluded: a pinned panel is a full-height
86
+ // dock and owns bottom 0. Mobile is excluded as well: there the panel is a
87
+ // full-screen drawer that covers the footer on purpose (mobile.styl).
88
+ &:not(.is-mobile)
89
+ .right-side-panel:not(.right-side-panel-pinned)
90
+ bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px) + 36px)
91
+ // A pinned panel is still a dock, but it yields to the shortcut bar rather
92
+ // than the other way round: the bar is z-index 400 and spans the full
93
+ // viewport width by design (it ignores the left sidebar too), so it would
94
+ // otherwise paint over the dock's bottom edge. The dock stops above the bar
95
+ // only — the footer sits beside the dock, not below it.
96
+ .right-side-panel.right-side-panel-pinned
97
+ bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px))
84
98
  .qm-wrap-tooltip
85
99
  bottom calc(var(--footer-stack-height, 36px) + var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px))
86
100
 
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Global tooltip that detects Unix timestamps in terminal selections
3
3
  * and displays their human-readable date/time near the cursor.
4
+ * Clicking the tooltip (or its copy icon) copies the formatted string.
4
5
  * Registered via refsStatic as 'unix-timestamp-tooltip'.
5
6
  */
6
7
 
7
8
  import { Component } from 'react'
9
+ import { CopyOutlined } from '@ant-design/icons'
8
10
  import { refsStatic } from '../common/ref'
11
+ import { copy } from '../../common/clipboard'
9
12
 
10
13
  export default class UnixTimestampTooltip extends Component {
11
14
  state = {
@@ -56,6 +59,24 @@ export default class UnixTimestampTooltip extends Component {
56
59
  }
57
60
  }
58
61
 
62
+ handleHide = () => {
63
+ this.setState({ visible: false })
64
+ }
65
+
66
+ /**
67
+ * Copy on mousedown instead of click: pressing the tooltip collapses the
68
+ * document selection, and the terminal drops its (DOM backed) selection on
69
+ * that change, which would unmount this tooltip before a click could land.
70
+ * preventDefault also keeps the terminal selection and focus untouched.
71
+ * The whole tooltip is the hit area, the icon is just the affordance.
72
+ */
73
+ handleCopy = (e) => {
74
+ e.preventDefault()
75
+ e.stopPropagation()
76
+ copy(this.state.text)
77
+ this.handleHide()
78
+ }
79
+
59
80
  render () {
60
81
  const { visible, x, y, text } = this.state
61
82
  if (!visible) {
@@ -63,6 +84,10 @@ export default class UnixTimestampTooltip extends Component {
63
84
  }
64
85
  return (
65
86
  <div
87
+ className='unix-timestamp-tooltip pointer'
88
+ title={window.translate('copy')}
89
+ onMouseDown={this.handleCopy}
90
+ onMouseLeave={this.handleHide}
66
91
  style={{
67
92
  position: 'fixed',
68
93
  left: x,
@@ -73,12 +98,13 @@ export default class UnixTimestampTooltip extends Component {
73
98
  borderRadius: 4,
74
99
  fontSize: 12,
75
100
  whiteSpace: 'nowrap',
76
- pointerEvents: 'none',
77
101
  transform: 'translateX(-50%)',
102
+ userSelect: 'none',
78
103
  zIndex: 9999
79
104
  }}
80
105
  >
81
106
  {text}
107
+ <CopyOutlined style={{ marginLeft: 6 }} />
82
108
  </div>
83
109
  )
84
110
  }
@@ -2,6 +2,9 @@
2
2
  color var(--text-dark)
3
3
  &:hover
4
4
  color var(--text)
5
+ // panel open: stay lit (after :hover so the "on" state survives hovering)
6
+ &.active
7
+ color var(--success)
5
8
  .terminal-info-section,
6
9
  .right-side-panel-content > .remote-monitor-details
7
10
  padding 10px 0
@@ -1,3 +1,6 @@
1
1
 
2
2
  .theme-item.current
3
3
  border-left 3px solid var(--success)
4
+
5
+ .list-item-apply-previewing
6
+ color var(--success)
@@ -190,7 +190,7 @@ export default function ThemeAiEditor (props) {
190
190
  const textAreaProps = {
191
191
  value: description,
192
192
  onChange: ev => setDescription(ev.target.value),
193
- placeholder: e('createThemeByAI'),
193
+ placeholder: e('description'),
194
194
  autoSize: { minRows: 4, maxRows: 8 },
195
195
  disabled: loading || disabled
196
196
  }
@@ -223,7 +223,7 @@ export default function ThemeAiEditor (props) {
223
223
  <div className='pd1t'>
224
224
  <Space>
225
225
  <Button {...generateBtnProps}>
226
- {e('generate')}
226
+ {e('submit')}
227
227
  </Button>
228
228
  <HelpIcon link='https://github.com/electerm/electerm/wiki/Create-theme-by-AI' />
229
229
  </Space>
@@ -14,7 +14,7 @@ import {
14
14
  validThemeProps,
15
15
  requiredThemeProps
16
16
  } from '../../common/terminal-theme'
17
- import { defaultTheme, defaultThemeLight } from '../../common/theme-defaults'
17
+ import { defaultThemeId, defaultThemeLightId } from '../../common/theme-defaults'
18
18
  import generate from '../../common/uid'
19
19
  import { isAIDisabled } from '../../common/ai-feature'
20
20
  import Link from '../common/external-link'
@@ -272,7 +272,7 @@ export default function ThemeForm (props) {
272
272
  themeName,
273
273
  themeText: convertThemeToText(props.formData)
274
274
  }
275
- const isDefaultTheme = id === defaultTheme().id || id === defaultThemeLight().id
275
+ const isDefaultTheme = id === defaultThemeId || id === defaultThemeLightId
276
276
  const disabled = readonly || isDefaultTheme
277
277
  const pickerProps = {
278
278
  onChange: onPickerChange,
@@ -2,13 +2,13 @@
2
2
  * theme list render
3
3
  */
4
4
 
5
- import { useState } from 'react'
5
+ import { useEffect, useState } from 'react'
6
6
  import {
7
7
  CheckCircleOutlined,
8
+ CloseOutlined,
8
9
  PlusOutlined,
9
10
  SunOutlined,
10
- MoonOutlined,
11
- EyeOutlined
11
+ MoonOutlined
12
12
  } from '@ant-design/icons'
13
13
  import { Tag, Tooltip, Button, Space } from 'antd'
14
14
  import classnames from 'classnames'
@@ -30,46 +30,72 @@ export default function ThemeListItem (props) {
30
30
  const [tooltipVisible, setTooltipVisible] = useState(false)
31
31
  const [isPreviewing, setIsPreviewing] = useState(false)
32
32
 
33
- function handleClickApply () {
34
- setTooltipVisible(false)
35
- setIsPreviewing(false)
36
- delete window.originalTheme
33
+ function startPreview () {
34
+ // keep the very first original, hopping between themes must never
35
+ // overwrite it with a theme that is itself only a preview
36
+ if (!window.originalTheme) {
37
+ window.originalTheme = store.config.theme
38
+ }
37
39
  store.setTheme(item.id)
40
+ setIsPreviewing(true)
38
41
  }
39
42
 
40
- function handleClickPreview () {
41
- if (!isPreviewing) {
42
- // Store current theme ID before changing
43
- const currentTheme = window.store.config.theme
44
- window.originalTheme = currentTheme
45
- // Apply the preview theme
46
- store.setTheme(item.id)
47
- setIsPreviewing(true)
43
+ function cancelPreview () {
44
+ if (window.originalTheme) {
45
+ store.setTheme(window.originalTheme)
46
+ delete window.originalTheme
48
47
  }
48
+ setIsPreviewing(false)
49
49
  }
50
50
 
51
- function handleTooltipVisibleChange (visible) {
52
- setTooltipVisible(visible)
53
- if (!visible && isPreviewing) {
54
- // Restore original theme when tooltip closes during preview
51
+ useEffect(() => {
52
+ return () => {
53
+ // leaving the list mid preview (switching settings tab, filtering the
54
+ // list, ...) must not leave the user stuck on a theme never applied
55
55
  if (window.originalTheme) {
56
56
  store.setTheme(window.originalTheme)
57
57
  delete window.originalTheme
58
58
  }
59
- setIsPreviewing(false)
59
+ }
60
+ }, [])
61
+
62
+ function handleClickApply () {
63
+ delete window.originalTheme
64
+ setIsPreviewing(false)
65
+ setTooltipVisible(false)
66
+ store.setTheme(item.id)
67
+ }
68
+
69
+ function handleClickCancel () {
70
+ cancelPreview()
71
+ setTooltipVisible(false)
72
+ }
73
+
74
+ function handleTooltipVisibleChange (visible) {
75
+ setTooltipVisible(visible)
76
+ // opening the popup is the preview itself; every other way of closing it
77
+ // (click elsewhere, esc, clicking the icon again) rolls the preview back
78
+ if (visible) {
79
+ startPreview()
80
+ } else {
81
+ cancelPreview()
60
82
  }
61
83
  }
62
84
 
85
+ function handleClickApplyIcon (e) {
86
+ // previewing must not also load the theme into the edit form
87
+ e.stopPropagation()
88
+ }
89
+
63
90
  function renderTooltipContent () {
64
91
  return (
65
92
  <Space.Compact>
66
93
  <Button
67
94
  size='small'
68
- icon={<EyeOutlined />}
69
- onClick={handleClickPreview}
70
- type={isPreviewing ? 'primary' : 'default'}
95
+ icon={<CloseOutlined />}
96
+ onClick={handleClickCancel}
71
97
  >
72
- {e('preview')}
98
+ {e('cancel')}
73
99
  </Button>
74
100
  <Button
75
101
  size='small'
@@ -96,7 +122,15 @@ export default function ThemeListItem (props) {
96
122
  placement='top'
97
123
  >
98
124
  <CheckCircleOutlined
99
- className='pointer list-item-apply'
125
+ className={
126
+ classnames(
127
+ 'pointer list-item-apply',
128
+ {
129
+ 'list-item-apply-previewing': isPreviewing
130
+ }
131
+ )
132
+ }
133
+ onClick={handleClickApplyIcon}
100
134
  />
101
135
  </Tooltip>
102
136
  )