@lijian-ui/dsh-term 0.1.2 → 0.2.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.
@@ -42,8 +42,36 @@ const EV = {
42
42
  terminalState: 'dsh-dock:terminal-state',
43
43
  } as const
44
44
 
45
- /** Width of the docked terminal column, in px (the 6th grid track). */
46
- const TERMINAL_WIDTH = 300
45
+ /** Terminal column width bounds and persistence. */
46
+ const MIN_TERM_WIDTH = 200
47
+ const MAX_TERM_WIDTH = 600
48
+ const DEFAULT_TERM_WIDTH = 300
49
+ const TERM_WIDTH_KEY = 'dsh-term-width-px'
50
+ const TERM_HANDLE_WIDTH = 8
51
+
52
+ function readTermWidth(): number {
53
+ try {
54
+ const raw = localStorage.getItem(TERM_WIDTH_KEY)
55
+ if (raw === null) return DEFAULT_TERM_WIDTH
56
+ const v = Number(raw)
57
+ if (!Number.isFinite(v) || v < MIN_TERM_WIDTH || v > MAX_TERM_WIDTH) return DEFAULT_TERM_WIDTH
58
+ return v
59
+ } catch { return DEFAULT_TERM_WIDTH }
60
+ }
61
+
62
+ function writeTermWidth(v: number): void {
63
+ try { localStorage.setItem(TERM_WIDTH_KEY, String(Math.round(v))) } catch { /* best-effort */ }
64
+ }
65
+
66
+ /** Inject the drag-handle visual styles once. */
67
+ function adoptHandleStyles(): void {
68
+ const id = 'dsh-term-handle-style'
69
+ if (document.getElementById(id) !== null) return
70
+ const tag = document.createElement('style')
71
+ tag.id = id
72
+ tag.textContent = '.dsh-term-handle{touch-action:none;cursor:col-resize}'
73
+ document.head.appendChild(tag)
74
+ }
47
75
 
48
76
  /** Locate the frame grid (same heuristic file-manager uses). */
49
77
  function findFrame(): HTMLElement | null {
@@ -73,34 +101,6 @@ function whenFrameReady(cb: (frame: HTMLElement) => void): () => void {
73
101
  return () => obs.disconnect()
74
102
  }
75
103
 
76
- /** Inject the launcher button styles once (the button is a raw DOM node). */
77
- function adoptLauncherStyles(): void {
78
- const id = 'dsh-term-launcher-style'
79
- if (document.getElementById(id) !== null) return
80
- const tag = document.createElement('style')
81
- tag.id = id
82
- tag.textContent = `
83
- .dsh-term-launcher {
84
- position: fixed;
85
- right: 12px;
86
- bottom: 12px;
87
- z-index: 40;
88
- height: 30px;
89
- padding: 0 14px;
90
- border-radius: 6px;
91
- border: 1px solid var(--aion-bg-3, #e5e6eb);
92
- background: var(--aion-bg-1, #ffffff);
93
- color: var(--aion-fg-1, #1f2329);
94
- font-size: 13px;
95
- font-family: var(--aion-font-sans, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif);
96
- cursor: pointer;
97
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
98
- }
99
- .dsh-term-launcher:hover {
100
- background: var(--aion-bg-2, #f2f3f5);
101
- }`
102
- document.head.appendChild(tag)
103
- }
104
104
 
105
105
  /**
106
106
  * Parse a `grid-template-columns` string into raw tokens. Named lines
@@ -165,6 +165,7 @@ export function apply(ctx: ClientContext): void {
165
165
  col.dataset.dshTermCol = ''
166
166
  col.style.minWidth = '0'
167
167
  col.style.height = '100%'
168
+ col.style.display = 'none'
168
169
  frame.appendChild(col)
169
170
 
170
171
  // Keep the terminal the rightmost column even if file-manager appends
@@ -174,18 +175,24 @@ export function apply(ctx: ClientContext): void {
174
175
  })
175
176
  keepLast.observe(frame, { childList: true })
176
177
 
177
- // Re-open button (after a collapse). Hidden while the column is open.
178
- adoptLauncherStyles()
179
- const launcher = document.createElement('button')
180
- launcher.type = 'button'
181
- launcher.className = 'dsh-term-launcher'
182
- launcher.textContent = '终端'
183
- launcher.setAttribute('aria-label', '打开终端')
184
- launcher.style.display = 'none'
185
- document.body.appendChild(launcher)
186
178
 
187
179
  // Open/closed state. Closed by default (the column is a docked track).
188
180
  let terminalOpen = false
181
+ let terminalWidth = readTermWidth()
182
+
183
+ // The drag handle on the terminal column's left edge (drag left = wider).
184
+ adoptHandleStyles()
185
+ const handle = document.createElement('div')
186
+ handle.className = 'dsh-term-handle'
187
+ handle.style.position = 'absolute'
188
+ handle.style.top = '0'
189
+ handle.style.bottom = '0'
190
+ handle.style.width = `${TERM_HANDLE_WIDTH}px`
191
+ handle.style.marginLeft = `${-TERM_HANDLE_WIDTH / 2}px`
192
+ handle.style.zIndex = '30'
193
+ handle.style.cursor = 'col-resize'
194
+ handle.style.display = 'none'
195
+ frame.appendChild(handle)
189
196
 
190
197
  /**
191
198
  * Re-assert the frame grid: keep file-manager's tracks, append our
@@ -205,11 +212,19 @@ export function apply(ctx: ClientContext): void {
205
212
  // (e.g. the shell's own 3-track write landed before file-manager
206
213
  // re-applied). Touching it now would desync file-manager's width math.
207
214
  if (tokens.length !== baseLen) return
208
- if (terminalOpen) tokens.push('[dsh-term]', `${TERMINAL_WIDTH}px`)
215
+ if (terminalOpen) tokens.push('[dsh-term]', `${terminalWidth}px`)
209
216
  const next = tokens.join(' ')
210
217
  if (next !== frame.style.gridTemplateColumns) {
211
218
  frame.style.gridTemplateColumns = next
212
219
  }
220
+ // Keep the drag handle glued to the terminal column's left edge.
221
+ if (terminalOpen) {
222
+ const frameRect = frame.getBoundingClientRect()
223
+ const colRect = col.getBoundingClientRect()
224
+ const leftEdge = colRect.left - frameRect.left
225
+ handle.style.left = `${Math.round(leftEdge)}px`
226
+ }
227
+ handle.style.display = terminalOpen ? 'block' : 'none'
213
228
  }
214
229
 
215
230
  // Re-assert after file-manager rewrites the grid (drag / resize / collapse).
@@ -223,13 +238,72 @@ export function apply(ctx: ClientContext): void {
223
238
  const setVisible = (open: boolean): void => {
224
239
  terminalOpen = open
225
240
  col.style.display = open ? 'flex' : 'none'
226
- launcher.style.display = open ? 'none' : 'flex'
227
241
  reconcileGrid()
228
242
  window.dispatchEvent(new CustomEvent(EV.terminalState, { detail: open }))
229
243
  }
230
244
  const onToggleTerminal = (): void => setVisible(!terminalOpen)
231
245
  window.addEventListener(EV.toggleTerminal, onToggleTerminal)
232
- launcher.addEventListener('click', () => setVisible(true))
246
+
247
+ // Drag the handle to resize the terminal column (left = wider).
248
+ handle.addEventListener('pointerdown', (event: PointerEvent): void => {
249
+ if (event.button !== 0) return
250
+ event.preventDefault()
251
+ handle.setPointerCapture(event.pointerId)
252
+ const startX = event.clientX
253
+ const startWidth = terminalWidth
254
+ let rafId: number | null = null
255
+ let pendingWidth: number | null = null
256
+ let latestWidth = startWidth
257
+
258
+ document.body.style.userSelect = 'none'
259
+ document.body.style.cursor = 'col-resize'
260
+ frame.setAttribute('data-dragging', '')
261
+ handle.setAttribute('data-dragging', '')
262
+
263
+ const flush = (): void => {
264
+ if (pendingWidth === null) return
265
+ latestWidth = pendingWidth
266
+ terminalWidth = pendingWidth
267
+ reconcileGrid()
268
+ }
269
+
270
+ const computeWidth = (clientX: number): number => {
271
+ const deltaX = startX - clientX
272
+ return Math.min(MAX_TERM_WIDTH, Math.max(MIN_TERM_WIDTH, startWidth + deltaX))
273
+ }
274
+
275
+ const finish = (clientX: number | null): void => {
276
+ handle.removeEventListener('pointermove', onMove)
277
+ handle.removeEventListener('pointerup', onUp)
278
+ handle.removeEventListener('pointercancel', onCancel)
279
+ try { handle.releasePointerCapture(event.pointerId) } catch { /* already released */ }
280
+ document.body.style.userSelect = ''
281
+ document.body.style.cursor = ''
282
+ frame.removeAttribute('data-dragging')
283
+ handle.removeAttribute('data-dragging')
284
+ if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
285
+ flush()
286
+ const finalWidth = clientX === null ? latestWidth : computeWidth(clientX)
287
+ terminalWidth = finalWidth
288
+ reconcileGrid()
289
+ writeTermWidth(finalWidth)
290
+ }
291
+
292
+ const onMove = (e: PointerEvent): void => {
293
+ if (e.buttons === 0) { finish(e.clientX); return }
294
+ pendingWidth = computeWidth(e.clientX)
295
+ if (rafId === null) {
296
+ rafId = requestAnimationFrame(() => { rafId = null; flush() })
297
+ }
298
+ }
299
+ const onUp = (e: PointerEvent): void => finish(e.clientX)
300
+ const onCancel = (): void => finish(null)
301
+
302
+ handle.addEventListener('pointermove', onMove)
303
+ handle.addEventListener('pointerup', onUp)
304
+ handle.addEventListener('pointercancel', onCancel)
305
+ })
306
+
233
307
  root.render(createElement(TerminalPanel, { ctx, api, onClose: () => setVisible(false) }))
234
308
 
235
309
  disposeFrame = () => {
@@ -238,7 +312,7 @@ export function apply(ctx: ClientContext): void {
238
312
  window.removeEventListener(EV.toggleTerminal, onToggleTerminal)
239
313
  root.unmount()
240
314
  col.remove()
241
- launcher.remove()
315
+ handle.remove()
242
316
  }
243
317
  })
244
318
  return () => {
@@ -1,132 +1,43 @@
1
1
  /**
2
- * AnimatedDock — a macOS-style magnification button group rendered into the
3
- * conversation session header (the `conversation.session.header.utilities`
4
- * slot, ordered to the LEFT of the official "Session log" button).
2
+ * AnimatedDock — the terminal dock button rendered into the conversation
3
+ * session header (the `conversation.session.header.utilities` slot, ordered to
4
+ * the LEFT of the official "Session log" button).
5
5
  *
6
- * This is a faithful port of the 21st.dev "Animated Dock" component: it uses
7
- * framer-motion's `useMotionValue` + `useSpring` + `useTransform` so each
8
- * circular icon scales toward the cursor with real spring physics the
9
- * buttery feel you can't get from a CSS transition. Magnification is driven by
10
- * `transform: scale()` (NOT `width`), so it never reflows the layout and the
11
- * dock stays perfectly stable under the cursor.
12
- * Icons come from lucide-react. The dock itself is a frosted-glass pill.
6
+ * Each plugin owns its own dock button independently: dsh-term renders this
7
+ * terminal button, dsh-file-manager renders its own file-panel button. The two
8
+ * are fully decoupled install either alone and you get just its button;
9
+ * install both and both buttons appear side by side with shared magnification
10
+ * (coordinated through window CustomEvents, see DockItem.tsx).
13
11
  *
14
- * Two entries wire to the two right-side panels through window CustomEvents
15
- * (a cross-plugin bridge so this component never imports the file-manager
16
- * bundle):
17
- *
18
- * - terminal -> `dsh-dock:toggle-terminal` (handled in dsh-term index)
19
- * - file -> `dsh-dock:toggle-filepanel` (handled in dsh-file-manager)
20
- *
21
- * Active (open) state is mirrored back through `dsh-dock:terminal-state` /
22
- * `dsh-dock:filepanel-state` so the icons highlight in lockstep with the
23
- * actual panels.
12
+ * Clicking broadcasts `dsh-dock:toggle-terminal` (handled in dsh-term index);
13
+ * the open state is mirrored back through `dsh-dock:terminal-state`.
24
14
  * @module dsh-term/client/AnimatedDock
25
15
  */
16
+ import { useEffect, useState, type ReactElement } from 'react'
17
+ import { DockItem } from './DockItem.tsx'
26
18
 
27
- import { useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
28
- import { motion, useMotionValue, useSpring, useTransform, type MotionValue } from 'framer-motion'
29
- import { Terminal, FolderOpen } from 'lucide-react'
30
- import styles from './animated-dock.module.css'
31
-
32
- /** Cross-plugin event names (kept as literals to avoid cross-bundle imports). */
33
19
  const EV = {
34
20
  toggleTerminal: 'dsh-dock:toggle-terminal',
35
- toggleFile: 'dsh-dock:toggle-filepanel',
36
21
  terminalState: 'dsh-dock:terminal-state',
37
- fileState: 'dsh-dock:filepanel-state',
38
22
  } as const
39
23
 
40
- /** Resting / peak icon size (px). */
41
- const BASE = 40
42
- const MAX = 58
43
- /** Magnification factor at the cursor (MAX / BASE). Driven via CSS `scale()`
44
- * so it never reflows the layout — this is what removed the old "jitter". */
45
- const MAX_SCALE = MAX / BASE
46
- /** Cursor distance (px) over which magnification falls to zero. */
47
- const DIST = 120
48
- /** Spring tuning. Near-critically damped (ratio ≈ 0.94) so the icon settles
49
- * smoothly with essentially no overshoot. The old `damping: 0.35` was
50
- * effectively zero damping and caused the wild oscillation. */
51
- const DAMPING = 28
52
- const STIFFNESS = 220
53
-
54
24
  export function AnimatedDock(): ReactElement {
55
- const mouseX = useMotionValue<number>(Infinity)
56
- const [terminalActive, setTerminalActive] = useState(false)
57
- const [fileActive, setFileActive] = useState(false)
58
-
59
- // Mirror the real panel open-state back into the icon highlight.
25
+ const [active, setActive] = useState(false)
60
26
  useEffect(() => {
61
- const onTerm = (e: Event): void => setTerminalActive(Boolean((e as CustomEvent).detail))
62
- const onFile = (e: Event): void => setFileActive(Boolean((e as CustomEvent).detail))
63
- window.addEventListener(EV.terminalState, onTerm)
64
- window.addEventListener(EV.fileState, onFile)
65
- return () => {
66
- window.removeEventListener(EV.terminalState, onTerm)
67
- window.removeEventListener(EV.fileState, onFile)
68
- }
27
+ const onState = (e: Event): void => setActive(Boolean((e as CustomEvent).detail))
28
+ window.addEventListener(EV.terminalState, onState)
29
+ return () => window.removeEventListener(EV.terminalState, onState)
69
30
  }, [])
70
-
71
- return (
72
- <motion.div
73
- className={styles.dock}
74
- onMouseMove={(e) => mouseX.set(e.clientX)}
75
- onMouseLeave={() => mouseX.set(Infinity)}
76
- aria-label="工具坞"
77
- >
78
- <DockItem
79
- mouseX={mouseX}
80
- active={terminalActive}
81
- label="终端"
82
- onClick={() => window.dispatchEvent(new CustomEvent(EV.toggleTerminal))}
83
- >
84
- <Terminal size={20} strokeWidth={2} />
85
- </DockItem>
86
- <DockItem
87
- mouseX={mouseX}
88
- active={fileActive}
89
- label="文件面板"
90
- onClick={() => window.dispatchEvent(new CustomEvent(EV.toggleFile))}
91
- >
92
- <FolderOpen size={20} strokeWidth={2} />
93
- </DockItem>
94
- </motion.div>
95
- )
96
- }
97
-
98
- /** A single magnifying icon. Its `scale` is a spring driven by cursor distance.
99
- * Because we animate `transform` (not `width`), the layout never reflows, so
100
- * there is no neighbour-push feedback loop and the dock stays rock-steady. */
101
- function DockItem(props: {
102
- mouseX: MotionValue<number>
103
- active: boolean
104
- label: string
105
- onClick: () => void
106
- children: ReactNode
107
- }): ReactElement {
108
- const ref = useRef<HTMLButtonElement>(null)
109
- const distance = useTransform(props.mouseX, (val) => {
110
- const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: BASE }
111
- return val - bounds.x - bounds.width / 2
112
- })
113
- const scale = useSpring(
114
- useTransform(distance, [-DIST, 0, DIST], [1, MAX_SCALE, 1]),
115
- { damping: DAMPING, stiffness: STIFFNESS },
116
- )
117
31
  return (
118
- <motion.button
119
- ref={ref}
120
- type="button"
121
- style={{ scale }}
122
- className={`${styles.item}${props.active ? ` ${styles.active}` : ''}`}
123
- onClick={props.onClick}
124
- aria-label={props.label}
125
- aria-pressed={props.active}
126
- title={props.label}
32
+ <DockItem
33
+ active={active}
34
+ label="终端"
35
+ onClick={() => window.dispatchEvent(new CustomEvent(EV.toggleTerminal))}
127
36
  >
128
- <span className={styles.tooltip}>{props.label}</span>
129
- {props.children}
130
- </motion.button>
37
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
38
+ <polyline points="4 17 10 11 4 5" />
39
+ <line x1="12" y1="19" x2="20" y2="19" />
40
+ </svg>
41
+ </DockItem>
131
42
  )
132
43
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * DockItem — a round dock button for the conversation session header.
3
+ * Each plugin owns its own DockItem copy (terminal in dsh-term, file-panel in
4
+ * dsh-file-manager). Hover scales the button up via pure CSS — no JS event
5
+ * coordination, no shared motion value.
6
+ * @module dsh-term/client/DockItem
7
+ */
8
+ import { type ReactElement, type ReactNode } from 'react'
9
+
10
+ const STYLE_ID = 'dsh-dock-item-style'
11
+
12
+ function ensureStyle(): void {
13
+ if (typeof document === 'undefined') return
14
+ if (document.getElementById(STYLE_ID) !== null) return
15
+ const tag = document.createElement('style')
16
+ tag.id = STYLE_ID
17
+ tag.textContent = '.dsh-dock-item{width:32px;height:32px;border:none;border-radius:9999px;background:#000;color:#fff;cursor:pointer;position:relative;transition:transform .15s ease,background .18s ease;display:inline-flex;align-items:center;justify-content:center}.dsh-dock-item:hover{transform:scale(1.15);background:#1a1a1a}.dsh-dock-item-active{background:var(--aion-primary,#6ea0ff)}.dsh-dock-tooltip{position:absolute;top:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:2px 8px;font-size:12px;line-height:1.4;white-space:nowrap;color:#fff;background:rgba(20,20,30,.9);border:1px solid rgba(255,255,255,.12);border-radius:6px;opacity:0;pointer-events:none;transition:opacity .15s}.dsh-dock-item:hover .dsh-dock-tooltip{opacity:1}'
18
+ document.head.appendChild(tag)
19
+ }
20
+
21
+ ensureStyle()
22
+
23
+ export function DockItem(props: {
24
+ active: boolean
25
+ label: string
26
+ onClick: () => void
27
+ children: ReactNode
28
+ }): ReactElement {
29
+ return (
30
+ <button
31
+ type="button"
32
+ className={`dsh-dock-item${props.active ? ' dsh-dock-item-active' : ''}`}
33
+ onClick={props.onClick}
34
+ aria-label={props.label}
35
+ aria-pressed={props.active}
36
+ title={props.label}
37
+ >
38
+ <span className="dsh-dock-tooltip">{props.label}</span>
39
+ {props.children}
40
+ </button>
41
+ )
42
+ }
@@ -45,11 +45,35 @@ function adoptXtermStyles(): void {
45
45
  /** Terminal theme matching the shell's light/dark marker. */
46
46
  function themeOf(): Record<string, string> {
47
47
  const dark = document.body.dataset.dsDarkTheme !== undefined
48
+ if (dark) {
49
+ return {
50
+ background: '#0e0e0e',
51
+ foreground: '#ced3da',
52
+ cursor: '#ced3da',
53
+ selectionBackground: 'rgba(255,255,255,0.2)',
54
+ }
55
+ }
48
56
  return {
49
- background: dark ? '#0e0e0e' : '#ffffff',
50
- foreground: dark ? '#ced3da' : '#1f2329',
51
- cursor: dark ? '#ced3da' : '#1f2329',
52
- selectionBackground: dark ? 'rgba(255,255,255,0.2)' : 'rgba(22,93,255,0.25)',
57
+ background: '#ffffff',
58
+ foreground: '#1f2329',
59
+ cursor: '#1f2329',
60
+ selectionBackground: 'rgba(22,93,255,0.25)',
61
+ black: '#1f2329',
62
+ red: '#d4393b',
63
+ green: '#167c2e',
64
+ yellow: '#b58900',
65
+ blue: '#0a4d8c',
66
+ magenta: '#a020a0',
67
+ cyan: '#0379a6',
68
+ white: '#5a5f66',
69
+ brightBlack: '#6a6f76',
70
+ brightRed: '#e5585a',
71
+ brightGreen: '#1a9c3a',
72
+ brightYellow: '#d6a200',
73
+ brightBlue: '#1a6fcf',
74
+ brightMagenta: '#c040c0',
75
+ brightCyan: '#1aa0d6',
76
+ brightWhite: '#2f353b',
53
77
  }
54
78
  }
55
79