@lijian-ui/dsh-term 0.1.2 → 0.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.
- package/lib/client.js +698 -43875
- package/lib/client.js.map +1 -1
- package/lib/index.js +455 -30
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/client-i18n.d.ts +29 -0
- package/lib/types/client/client-i18n.d.ts.map +1 -0
- package/lib/types/client/i18n-seat.d.ts +15 -0
- package/lib/types/client/i18n-seat.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/term/AnimatedDock.d.ts +10 -20
- package/lib/types/client/term/AnimatedDock.d.ts.map +1 -1
- package/lib/types/client/term/DockItem.d.ts +15 -0
- package/lib/types/client/term/DockItem.d.ts.map +1 -0
- package/lib/types/client/term/TerminalPanel.d.ts +8 -3
- package/lib/types/client/term/TerminalPanel.d.ts.map +1 -1
- package/lib/types/core/types.d.ts +1 -0
- package/lib/types/core/types.d.ts.map +1 -1
- package/lib/types/gateway/i18n.d.ts +17 -0
- package/lib/types/gateway/i18n.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +3 -1
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/client-i18n.ts +68 -0
- package/src/client/i18n-seat.ts +27 -0
- package/src/client/index.ts +139 -46
- package/src/client/term/AnimatedDock.tsx +27 -114
- package/src/client/term/DockItem.tsx +42 -0
- package/src/client/term/TerminalPanel.tsx +339 -32
- package/src/client/term/api.ts +17 -2
- package/src/client/term/chat-helper.ts +28 -0
- package/src/client/term/term.module.css +137 -0
- package/src/core/types.ts +22 -4
- package/src/gateway/i18n.ts +67 -0
- package/src/host/pty-service.ts +160 -17
- package/src/host/routes.ts +44 -4
- package/src/index.ts +31 -1
package/src/client/index.ts
CHANGED
|
@@ -29,12 +29,22 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
|
29
29
|
// `conversation.session.header.utilities`) onto the SlotMap so we can register
|
|
30
30
|
// the header tool-dock against the official, typed slot.
|
|
31
31
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
32
|
+
|
|
33
|
+
/** Augment the locale namespace map so ctx.locale.register/bind accept 'dsh-term'. */
|
|
34
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
35
|
+
interface LocaleNamespaceMap {
|
|
36
|
+
/** Terminal panel copy. */
|
|
37
|
+
'dsh-term': 'ui.panel.title' | 'ui.panel.addTabTitle' | 'ui.panel.collapseTitle' | 'ui.panel.emptyHint' | 'ui.tab.closeAria' | 'msg.sessionExited' | 'msg.spawnFailed' | 'ui.dock.label' | 'ui.panel.shellTitle' | 'ui.shell.bash' | 'ui.shell.zsh' | 'ui.shell.powershell' | 'ui.shell.cmd' | 'ui.shell.gitbash' | 'ui.panel.reopenTitle' | 'ui.panel.backgroundSessions' | 'ui.panel.noBackground' | 'ui.panel.addToChat'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
import { zh as clientZh, en as clientEn } from './client-i18n.ts'
|
|
32
41
|
import { TermApi } from './term/api.ts'
|
|
33
42
|
import { TerminalPanel } from './term/TerminalPanel.tsx'
|
|
34
43
|
import { AnimatedDock } from './term/AnimatedDock.tsx'
|
|
44
|
+
import { bindI18n } from './i18n-seat.ts'
|
|
35
45
|
|
|
36
|
-
/** Required services: sessions (for the workspace cwd). */
|
|
37
|
-
export const inject = ['sessions']
|
|
46
|
+
/** Required services: sessions (for the workspace cwd), conversation (for add-to-chat). */
|
|
47
|
+
export const inject = ['sessions', 'locale', 'slots', 'conversation']
|
|
38
48
|
|
|
39
49
|
/** Cross-plugin event names shared with the AnimatedDock header group. */
|
|
40
50
|
const EV = {
|
|
@@ -42,8 +52,36 @@ const EV = {
|
|
|
42
52
|
terminalState: 'dsh-dock:terminal-state',
|
|
43
53
|
} as const
|
|
44
54
|
|
|
45
|
-
/**
|
|
46
|
-
const
|
|
55
|
+
/** Terminal column width bounds and persistence. */
|
|
56
|
+
const MIN_TERM_WIDTH = 200
|
|
57
|
+
const MAX_TERM_WIDTH = 600
|
|
58
|
+
const DEFAULT_TERM_WIDTH = 300
|
|
59
|
+
const TERM_WIDTH_KEY = 'dsh-term-width-px'
|
|
60
|
+
const TERM_HANDLE_WIDTH = 8
|
|
61
|
+
|
|
62
|
+
function readTermWidth(): number {
|
|
63
|
+
try {
|
|
64
|
+
const raw = localStorage.getItem(TERM_WIDTH_KEY)
|
|
65
|
+
if (raw === null) return DEFAULT_TERM_WIDTH
|
|
66
|
+
const v = Number(raw)
|
|
67
|
+
if (!Number.isFinite(v) || v < MIN_TERM_WIDTH || v > MAX_TERM_WIDTH) return DEFAULT_TERM_WIDTH
|
|
68
|
+
return v
|
|
69
|
+
} catch { return DEFAULT_TERM_WIDTH }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeTermWidth(v: number): void {
|
|
73
|
+
try { localStorage.setItem(TERM_WIDTH_KEY, String(Math.round(v))) } catch { /* best-effort */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Inject the drag-handle visual styles once. */
|
|
77
|
+
function adoptHandleStyles(): void {
|
|
78
|
+
const id = 'dsh-term-handle-style'
|
|
79
|
+
if (document.getElementById(id) !== null) return
|
|
80
|
+
const tag = document.createElement('style')
|
|
81
|
+
tag.id = id
|
|
82
|
+
tag.textContent = '.dsh-term-handle{touch-action:none;cursor:col-resize}'
|
|
83
|
+
document.head.appendChild(tag)
|
|
84
|
+
}
|
|
47
85
|
|
|
48
86
|
/** Locate the frame grid (same heuristic file-manager uses). */
|
|
49
87
|
function findFrame(): HTMLElement | null {
|
|
@@ -73,34 +111,6 @@ function whenFrameReady(cb: (frame: HTMLElement) => void): () => void {
|
|
|
73
111
|
return () => obs.disconnect()
|
|
74
112
|
}
|
|
75
113
|
|
|
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
114
|
|
|
105
115
|
/**
|
|
106
116
|
* Parse a `grid-template-columns` string into raw tokens. Named lines
|
|
@@ -143,6 +153,15 @@ function stripTerminalTrack(tokens: readonly string[]): string[] {
|
|
|
143
153
|
|
|
144
154
|
/** Apply the browser half. */
|
|
145
155
|
export function apply(ctx: ClientContext): void {
|
|
156
|
+
// Register client-side bilingual dictionaries.
|
|
157
|
+
const I18N_NS = 'dsh-term'
|
|
158
|
+
ctx.effect(
|
|
159
|
+
() => ctx.locale.register(I18N_NS, { zh: clientZh, en: clientEn }),
|
|
160
|
+
'dsh-term: client dictionaries'
|
|
161
|
+
)
|
|
162
|
+
const t = ctx.locale.bind(I18N_NS)
|
|
163
|
+
bindI18n(t) // publish to module-level seat for use by components outside slots
|
|
164
|
+
|
|
146
165
|
// 会话页 header 工具坞:紧贴「Session log」左侧的放大按钮组
|
|
147
166
|
ctx.inject(['slots'], (scope: ClientContext) => {
|
|
148
167
|
scope.slots.inject('conversation.session.header.utilities', () =>
|
|
@@ -165,6 +184,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
165
184
|
col.dataset.dshTermCol = ''
|
|
166
185
|
col.style.minWidth = '0'
|
|
167
186
|
col.style.height = '100%'
|
|
187
|
+
col.style.display = 'none'
|
|
168
188
|
frame.appendChild(col)
|
|
169
189
|
|
|
170
190
|
// Keep the terminal the rightmost column even if file-manager appends
|
|
@@ -174,18 +194,24 @@ export function apply(ctx: ClientContext): void {
|
|
|
174
194
|
})
|
|
175
195
|
keepLast.observe(frame, { childList: true })
|
|
176
196
|
|
|
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
197
|
|
|
187
198
|
// Open/closed state. Closed by default (the column is a docked track).
|
|
188
199
|
let terminalOpen = false
|
|
200
|
+
let terminalWidth = readTermWidth()
|
|
201
|
+
|
|
202
|
+
// The drag handle on the terminal column's left edge (drag left = wider).
|
|
203
|
+
adoptHandleStyles()
|
|
204
|
+
const handle = document.createElement('div')
|
|
205
|
+
handle.className = 'dsh-term-handle'
|
|
206
|
+
handle.style.position = 'absolute'
|
|
207
|
+
handle.style.top = '0'
|
|
208
|
+
handle.style.bottom = '0'
|
|
209
|
+
handle.style.width = `${TERM_HANDLE_WIDTH}px`
|
|
210
|
+
handle.style.marginLeft = `${-TERM_HANDLE_WIDTH / 2}px`
|
|
211
|
+
handle.style.zIndex = '30'
|
|
212
|
+
handle.style.cursor = 'col-resize'
|
|
213
|
+
handle.style.display = 'none'
|
|
214
|
+
frame.appendChild(handle)
|
|
189
215
|
|
|
190
216
|
/**
|
|
191
217
|
* Re-assert the frame grid: keep file-manager's tracks, append our
|
|
@@ -205,11 +231,19 @@ export function apply(ctx: ClientContext): void {
|
|
|
205
231
|
// (e.g. the shell's own 3-track write landed before file-manager
|
|
206
232
|
// re-applied). Touching it now would desync file-manager's width math.
|
|
207
233
|
if (tokens.length !== baseLen) return
|
|
208
|
-
if (terminalOpen) tokens.push('[dsh-term]', `${
|
|
234
|
+
if (terminalOpen) tokens.push('[dsh-term]', `${terminalWidth}px`)
|
|
209
235
|
const next = tokens.join(' ')
|
|
210
236
|
if (next !== frame.style.gridTemplateColumns) {
|
|
211
237
|
frame.style.gridTemplateColumns = next
|
|
212
238
|
}
|
|
239
|
+
// Keep the drag handle glued to the terminal column's left edge.
|
|
240
|
+
if (terminalOpen) {
|
|
241
|
+
const frameRect = frame.getBoundingClientRect()
|
|
242
|
+
const colRect = col.getBoundingClientRect()
|
|
243
|
+
const leftEdge = colRect.left - frameRect.left
|
|
244
|
+
handle.style.left = `${Math.round(leftEdge)}px`
|
|
245
|
+
}
|
|
246
|
+
handle.style.display = terminalOpen ? 'block' : 'none'
|
|
213
247
|
}
|
|
214
248
|
|
|
215
249
|
// Re-assert after file-manager rewrites the grid (drag / resize / collapse).
|
|
@@ -223,14 +257,73 @@ export function apply(ctx: ClientContext): void {
|
|
|
223
257
|
const setVisible = (open: boolean): void => {
|
|
224
258
|
terminalOpen = open
|
|
225
259
|
col.style.display = open ? 'flex' : 'none'
|
|
226
|
-
launcher.style.display = open ? 'none' : 'flex'
|
|
227
260
|
reconcileGrid()
|
|
228
261
|
window.dispatchEvent(new CustomEvent(EV.terminalState, { detail: open }))
|
|
229
262
|
}
|
|
230
263
|
const onToggleTerminal = (): void => setVisible(!terminalOpen)
|
|
231
264
|
window.addEventListener(EV.toggleTerminal, onToggleTerminal)
|
|
232
|
-
|
|
233
|
-
|
|
265
|
+
|
|
266
|
+
// Drag the handle to resize the terminal column (left = wider).
|
|
267
|
+
handle.addEventListener('pointerdown', (event: PointerEvent): void => {
|
|
268
|
+
if (event.button !== 0) return
|
|
269
|
+
event.preventDefault()
|
|
270
|
+
handle.setPointerCapture(event.pointerId)
|
|
271
|
+
const startX = event.clientX
|
|
272
|
+
const startWidth = terminalWidth
|
|
273
|
+
let rafId: number | null = null
|
|
274
|
+
let pendingWidth: number | null = null
|
|
275
|
+
let latestWidth = startWidth
|
|
276
|
+
|
|
277
|
+
document.body.style.userSelect = 'none'
|
|
278
|
+
document.body.style.cursor = 'col-resize'
|
|
279
|
+
frame.setAttribute('data-dragging', '')
|
|
280
|
+
handle.setAttribute('data-dragging', '')
|
|
281
|
+
|
|
282
|
+
const flush = (): void => {
|
|
283
|
+
if (pendingWidth === null) return
|
|
284
|
+
latestWidth = pendingWidth
|
|
285
|
+
terminalWidth = pendingWidth
|
|
286
|
+
reconcileGrid()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const computeWidth = (clientX: number): number => {
|
|
290
|
+
const deltaX = startX - clientX
|
|
291
|
+
return Math.min(MAX_TERM_WIDTH, Math.max(MIN_TERM_WIDTH, startWidth + deltaX))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const finish = (clientX: number | null): void => {
|
|
295
|
+
handle.removeEventListener('pointermove', onMove)
|
|
296
|
+
handle.removeEventListener('pointerup', onUp)
|
|
297
|
+
handle.removeEventListener('pointercancel', onCancel)
|
|
298
|
+
try { handle.releasePointerCapture(event.pointerId) } catch { /* already released */ }
|
|
299
|
+
document.body.style.userSelect = ''
|
|
300
|
+
document.body.style.cursor = ''
|
|
301
|
+
frame.removeAttribute('data-dragging')
|
|
302
|
+
handle.removeAttribute('data-dragging')
|
|
303
|
+
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
|
304
|
+
flush()
|
|
305
|
+
const finalWidth = clientX === null ? latestWidth : computeWidth(clientX)
|
|
306
|
+
terminalWidth = finalWidth
|
|
307
|
+
reconcileGrid()
|
|
308
|
+
writeTermWidth(finalWidth)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const onMove = (e: PointerEvent): void => {
|
|
312
|
+
if (e.buttons === 0) { finish(e.clientX); return }
|
|
313
|
+
pendingWidth = computeWidth(e.clientX)
|
|
314
|
+
if (rafId === null) {
|
|
315
|
+
rafId = requestAnimationFrame(() => { rafId = null; flush() })
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const onUp = (e: PointerEvent): void => finish(e.clientX)
|
|
319
|
+
const onCancel = (): void => finish(null)
|
|
320
|
+
|
|
321
|
+
handle.addEventListener('pointermove', onMove)
|
|
322
|
+
handle.addEventListener('pointerup', onUp)
|
|
323
|
+
handle.addEventListener('pointercancel', onCancel)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
root.render(createElement(TerminalPanel, { ctx, api, onClose: () => setVisible(false), t }))
|
|
234
327
|
|
|
235
328
|
disposeFrame = () => {
|
|
236
329
|
keepLast.disconnect()
|
|
@@ -238,7 +331,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
238
331
|
window.removeEventListener(EV.toggleTerminal, onToggleTerminal)
|
|
239
332
|
root.unmount()
|
|
240
333
|
col.remove()
|
|
241
|
-
|
|
334
|
+
handle.remove()
|
|
242
335
|
}
|
|
243
336
|
})
|
|
244
337
|
return () => {
|
|
@@ -1,132 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AnimatedDock —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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'
|
|
18
|
+
import { getT } from '../i18n-seat.ts'
|
|
26
19
|
|
|
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
20
|
const EV = {
|
|
34
21
|
toggleTerminal: 'dsh-dock:toggle-terminal',
|
|
35
|
-
toggleFile: 'dsh-dock:toggle-filepanel',
|
|
36
22
|
terminalState: 'dsh-dock:terminal-state',
|
|
37
|
-
fileState: 'dsh-dock:filepanel-state',
|
|
38
23
|
} as const
|
|
39
24
|
|
|
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
25
|
export function AnimatedDock(): ReactElement {
|
|
55
|
-
const
|
|
56
|
-
const [
|
|
57
|
-
const [fileActive, setFileActive] = useState(false)
|
|
58
|
-
|
|
59
|
-
// Mirror the real panel open-state back into the icon highlight.
|
|
26
|
+
const t = getT()
|
|
27
|
+
const [active, setActive] = useState(false)
|
|
60
28
|
useEffect(() => {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
window.
|
|
64
|
-
window.addEventListener(EV.fileState, onFile)
|
|
65
|
-
return () => {
|
|
66
|
-
window.removeEventListener(EV.terminalState, onTerm)
|
|
67
|
-
window.removeEventListener(EV.fileState, onFile)
|
|
68
|
-
}
|
|
29
|
+
const onState = (e: Event): void => setActive(Boolean((e as CustomEvent).detail))
|
|
30
|
+
window.addEventListener(EV.terminalState, onState)
|
|
31
|
+
return () => window.removeEventListener(EV.terminalState, onState)
|
|
69
32
|
}, [])
|
|
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
33
|
return (
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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}
|
|
34
|
+
<DockItem
|
|
35
|
+
active={active}
|
|
36
|
+
label={t('ui.dock.label')}
|
|
37
|
+
onClick={() => window.dispatchEvent(new CustomEvent(EV.toggleTerminal))}
|
|
127
38
|
>
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
39
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
40
|
+
<polyline points="4 17 10 11 4 5" />
|
|
41
|
+
<line x1="12" y1="19" x2="20" y2="19" />
|
|
42
|
+
</svg>
|
|
43
|
+
</DockItem>
|
|
131
44
|
)
|
|
132
45
|
}
|
|
@@ -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
|
+
}
|