@guowenzhang/dsh-pocket 0.0.0-stage → 2.12.2
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/LICENSE +344 -0
- package/README.en.md +222 -0
- package/README.md +229 -3
- package/bin/dsh-pocket.mjs +216 -0
- package/client/api.js +76 -0
- package/client/build.mjs +51 -0
- package/client/client.js +2765 -0
- package/client/index.jsx +581 -0
- package/client/mobile/LICENSE.dsh-web-mobile +4 -0
- package/client/mobile/MobileDrawerFooter.tsx +69 -0
- package/client/mobile/MobileIcon.tsx +31 -0
- package/client/mobile/MobileNavOverlay.tsx +299 -0
- package/client/mobile/MobileNavToggle.tsx +57 -0
- package/client/mobile/fileGuard.ts +200 -0
- package/client/mobile/icon-compat.mjs +90 -0
- package/client/mobile/layout-mode.mjs +46 -0
- package/client/mobile/locales.ts +23 -0
- package/client/mobile/mobile-apply.tsx +413 -0
- package/client/mobile/mobile.css.ts +1052 -0
- package/client/mobile/nav-targets.mjs +117 -0
- package/client/pocket-locales.js +165 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.js +420 -0
- package/lib/ip.mjs +11 -0
- package/lib/proxy.mjs +1223 -0
- package/lib/restart.js +118 -0
- package/lib/service.mjs +454 -0
- package/lib/settings.mjs +252 -0
- package/lib/tunnel.mjs +636 -0
- package/lib/web-rpc.js +475 -0
- package/package.json +85 -4
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { MOBILE_ICON_FALLBACK, isRenderableIcon } from './icon-compat.mjs'
|
|
2
|
+
|
|
3
|
+
/** Props for the version-tolerant icon slot. */
|
|
4
|
+
export interface MobileIconProps {
|
|
5
|
+
/** Icon component resolved from the host primitives namespace (null when absent). */
|
|
6
|
+
icon: unknown
|
|
7
|
+
/** Which fallback glyph to draw when the running DSH has no such export. */
|
|
8
|
+
name: 'panelLeft' | 'download' | 'folderOpen'
|
|
9
|
+
/** Square edge in px, forwarded to the host icon. */
|
|
10
|
+
size: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders the resolved host icon, or a text glyph when this DSH build no longer
|
|
15
|
+
* exports that name. It must never hand `undefined` to createElement: that is
|
|
16
|
+
* exactly what crashed the whole slot entry (React #130) and left the mobile
|
|
17
|
+
* drawer dead on DSH 0.1.7-rc.2+ (see icon-compat.mjs for the version matrix).
|
|
18
|
+
*/
|
|
19
|
+
export function MobileIcon({ icon, name, size }: MobileIconProps) {
|
|
20
|
+
if (!isRenderableIcon(icon)) {
|
|
21
|
+
return (
|
|
22
|
+
<span data-mobile-nav="icon-fallback" aria-hidden="true" style={{ fontSize: size, lineHeight: 1 }}>
|
|
23
|
+
{MOBILE_ICON_FALLBACK[name]}
|
|
24
|
+
</span>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
// The component type differs per DSH version, so it is only known at runtime;
|
|
28
|
+
// `never` as the return type keeps JSX typing happy without an `any` cast.
|
|
29
|
+
const Icon = icon as (props: { size?: number }) => never
|
|
30
|
+
return <Icon size={size} />
|
|
31
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useState } from 'react'
|
|
2
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
3
|
+
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
4
|
+
import { NS } from './locales.ts'
|
|
5
|
+
import { MobileIcon } from './MobileIcon.tsx'
|
|
6
|
+
import { resolveMobileIcons } from './icon-compat.mjs'
|
|
7
|
+
import {
|
|
8
|
+
findDrawer,
|
|
9
|
+
hasNativeDrawer,
|
|
10
|
+
TOGGLE_SELECTOR,
|
|
11
|
+
isOverlayTap,
|
|
12
|
+
navTargetFor,
|
|
13
|
+
} from './nav-targets.mjs'
|
|
14
|
+
|
|
15
|
+
/** 宿主图标在加载时解析一次:不同 DSH 版本的导出名不同(见 icon-compat.mjs)。 */
|
|
16
|
+
const icons = resolveMobileIcons(primitives)
|
|
17
|
+
|
|
18
|
+
/** Full props for the shell overlay entry. */
|
|
19
|
+
export interface MobileNavOverlayProps extends PropsRuntime<'shell.overlay'>, PropsLocale<typeof NS> {
|
|
20
|
+
/** Bound ctx.layout.toggleSidebar(). */
|
|
21
|
+
toggleSidebar: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Same breakpoint as the shell's SIDEBAR_AUTO_COLLAPSE (viewport < 1024). */
|
|
25
|
+
const MOBILE_QUERY = '(max-width: 1023px)'
|
|
26
|
+
|
|
27
|
+
/** Live matchMedia hook for the narrow breakpoint. */
|
|
28
|
+
function useMobile(): boolean {
|
|
29
|
+
const [mobile, setMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches)
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const query = window.matchMedia(MOBILE_QUERY)
|
|
32
|
+
const onChange = (event: MediaQueryListEvent) => setMobile(event.matches)
|
|
33
|
+
query.addEventListener('change', onChange)
|
|
34
|
+
return () => query.removeEventListener('change', onChange)
|
|
35
|
+
}, [])
|
|
36
|
+
return mobile
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The AppFrame element: direct parent of the shell overlay layer. */
|
|
40
|
+
function findFrame(): HTMLElement | null {
|
|
41
|
+
return document.querySelector('[data-shell-overlay]')?.parentElement ?? null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mobile shell overlay: owns the `data-mobile-nav` marker on the AppFrame
|
|
46
|
+
* element (the CSS restructure keys off it), mirrors the frame's collapsed
|
|
47
|
+
* state into React state, and renders the dimmed backdrop plus a floating
|
|
48
|
+
* directory button for the hero/blank phases that have no session header.
|
|
49
|
+
*/
|
|
50
|
+
export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
|
|
51
|
+
const mobile = useMobile()
|
|
52
|
+
const [open, setOpen] = useState(false)
|
|
53
|
+
const [fabVisible, setFabVisible] = useState(false)
|
|
54
|
+
|
|
55
|
+
// Frame ownership + open-state mirror. On wide screens this effect is inert:
|
|
56
|
+
// the marker is never set, so the layout is untouched.
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (!mobile) {
|
|
59
|
+
setOpen(false)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const frame = findFrame()
|
|
63
|
+
if (frame === null) return
|
|
64
|
+
frame.setAttribute('data-mobile-nav', 'frame')
|
|
65
|
+
const sync = () => setOpen(!frame.hasAttribute('data-sidebar-collapsed'))
|
|
66
|
+
sync()
|
|
67
|
+
const observer = new MutationObserver(sync)
|
|
68
|
+
observer.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed'] })
|
|
69
|
+
return () => {
|
|
70
|
+
observer.disconnect()
|
|
71
|
+
frame.removeAttribute('data-mobile-nav')
|
|
72
|
+
}
|
|
73
|
+
}, [mobile])
|
|
74
|
+
|
|
75
|
+
// The floating button is a fallback for surfaces without a session header:
|
|
76
|
+
// phase "active" means the header (and its toggle) is rendered already.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!mobile) {
|
|
79
|
+
setFabVisible(false)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const sync = () => setFabVisible(document.querySelector('[data-phase="active"]') === null)
|
|
83
|
+
sync()
|
|
84
|
+
const observer = new MutationObserver(sync)
|
|
85
|
+
// childList: the conversation root can be replaced wholesale on session
|
|
86
|
+
// switches, so attribute-only observation would miss the new phase.
|
|
87
|
+
observer.observe(document.documentElement, {
|
|
88
|
+
subtree: true,
|
|
89
|
+
childList: true,
|
|
90
|
+
attributes: true,
|
|
91
|
+
attributeFilter: ['data-phase'],
|
|
92
|
+
})
|
|
93
|
+
return () => observer.disconnect()
|
|
94
|
+
}, [mobile])
|
|
95
|
+
|
|
96
|
+
// Escape closes the drawer — but yields to an open modal dialog (e.g. the
|
|
97
|
+
// settings panel), which owns its own Escape handling.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!mobile || !open) return
|
|
100
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
101
|
+
if (event.key === 'Escape' && document.querySelector('[aria-modal="true"]') === null) toggleSidebar()
|
|
102
|
+
}
|
|
103
|
+
// Capture phase: run before the settings panel's own document-bubble Escape
|
|
104
|
+
// handler, so the modal is still present when we yield to it.
|
|
105
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
106
|
+
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
107
|
+
}, [mobile, open, toggleSidebar])
|
|
108
|
+
|
|
109
|
+
// Navigation inside the drawer closes it: tapping a session row or a
|
|
110
|
+
// plugin takeover entry (task board / ssh) must hand the screen to the
|
|
111
|
+
// content it just opened. Mouse clicks keep the direct close path. Touch
|
|
112
|
+
// session rows close only after aria-selected changes, so live session
|
|
113
|
+
// updates cannot strand a delayed click on a detached DOM target.
|
|
114
|
+
//
|
|
115
|
+
// Deliberately NOT closed by this rule:
|
|
116
|
+
// - Settings / Session log: their dialogs render INSIDE the drawer DOM
|
|
117
|
+
// (portaled into the sidebar); closing the drawer would slide the dialog
|
|
118
|
+
// off-screen with it.
|
|
119
|
+
// - Workspace folder rows (YDXeBa_projectRow), the logo: pure UI toggles,
|
|
120
|
+
// not navigation — see NAV_TARGETS in nav-targets.mjs.
|
|
121
|
+
// - Anything while a modal dialog is open: the dialog owns the screen.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!mobile || !open) return
|
|
124
|
+
let lastTouchNavAt = 0
|
|
125
|
+
let lastTouchX = 0
|
|
126
|
+
let lastTouchY = 0
|
|
127
|
+
let suppressTouchClickUntil = 0
|
|
128
|
+
let pendingTouchRow: Element | null = null
|
|
129
|
+
let selectedRowAtArm: Element | null = null
|
|
130
|
+
let navClickArrived = false
|
|
131
|
+
let navObserver: MutationObserver | null = null
|
|
132
|
+
let navTimer: number | null = null
|
|
133
|
+
|
|
134
|
+
const drawerRoot = (): HTMLElement | null => findDrawer() as HTMLElement | null
|
|
135
|
+
|
|
136
|
+
const disarmNav = (): void => {
|
|
137
|
+
navObserver?.disconnect()
|
|
138
|
+
navObserver = null
|
|
139
|
+
if (navTimer !== null) window.clearTimeout(navTimer)
|
|
140
|
+
navTimer = null
|
|
141
|
+
pendingTouchRow = null
|
|
142
|
+
selectedRowAtArm = null
|
|
143
|
+
navClickArrived = false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const armNav = (row: Element): void => {
|
|
147
|
+
disarmNav()
|
|
148
|
+
pendingTouchRow = row
|
|
149
|
+
const drawer = drawerRoot()
|
|
150
|
+
selectedRowAtArm = drawer?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null
|
|
151
|
+
if (drawer === null) return
|
|
152
|
+
navObserver = new MutationObserver(() => {
|
|
153
|
+
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
154
|
+
if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) {
|
|
155
|
+
disarmNav()
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const selectedRow = drawerRoot()?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null
|
|
159
|
+
if (navClickArrived && selectedRow !== null && selectedRow !== selectedRowAtArm) {
|
|
160
|
+
disarmNav()
|
|
161
|
+
toggleSidebar()
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
navObserver.observe(drawer, {
|
|
165
|
+
childList: true,
|
|
166
|
+
subtree: true,
|
|
167
|
+
attributes: true,
|
|
168
|
+
attributeFilter: ['aria-selected'],
|
|
169
|
+
})
|
|
170
|
+
navTimer = window.setTimeout(disarmNav, 2000)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const navigationTarget = (target: EventTarget | null): Element | null => {
|
|
174
|
+
if (document.querySelector('[aria-modal="true"]') !== null) return null
|
|
175
|
+
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
176
|
+
if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return null
|
|
177
|
+
if (!(target instanceof Element)) return null
|
|
178
|
+
const drawer = drawerRoot()
|
|
179
|
+
if (drawer === null || !drawer.contains(target)) return null
|
|
180
|
+
return navTargetFor(target)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isPendingTouchClick = (event: MouseEvent): boolean => {
|
|
184
|
+
const capabilities = (event as MouseEvent & {
|
|
185
|
+
sourceCapabilities?: { firesTouchEvents?: boolean }
|
|
186
|
+
}).sourceCapabilities
|
|
187
|
+
if (capabilities?.firesTouchEvents === true) return true
|
|
188
|
+
return Math.hypot(event.clientX - lastTouchX, event.clientY - lastTouchY) <= 24
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const onDrawerClick = (event: MouseEvent): void => {
|
|
192
|
+
// A touch row tap owns the close through the selection observer. Let the
|
|
193
|
+
// browser click reach React without a capture-phase close racing it.
|
|
194
|
+
if (performance.now() < suppressTouchClickUntil) return
|
|
195
|
+
if (
|
|
196
|
+
pendingTouchRow !== null
|
|
197
|
+
&& performance.now() - lastTouchNavAt < 500
|
|
198
|
+
&& isPendingTouchClick(event)
|
|
199
|
+
) {
|
|
200
|
+
const target = navigationTarget(event.target)
|
|
201
|
+
const row = target?.closest('[role="treeitem"]')
|
|
202
|
+
if (row !== null && row !== undefined) {
|
|
203
|
+
pendingTouchRow = row
|
|
204
|
+
navClickArrived = true
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (navigationTarget(event.target) !== null) toggleSidebar()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const onDrawerPointerUp = (event: PointerEvent): void => {
|
|
212
|
+
if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return
|
|
213
|
+
const target = navigationTarget(event.target)
|
|
214
|
+
if (target === null) return
|
|
215
|
+
|
|
216
|
+
const row = target.closest('[role="treeitem"]')
|
|
217
|
+
if (row !== null) {
|
|
218
|
+
if (row.getAttribute('aria-selected') === 'true') {
|
|
219
|
+
// Already-selected rows will not navigate; closing now is safe.
|
|
220
|
+
suppressTouchClickUntil = performance.now() + 500
|
|
221
|
+
toggleSidebar()
|
|
222
|
+
} else {
|
|
223
|
+
// Let React navigate first, then close on the selection mutation.
|
|
224
|
+
lastTouchNavAt = performance.now()
|
|
225
|
+
lastTouchX = event.clientX
|
|
226
|
+
lastTouchY = event.clientY
|
|
227
|
+
armNav(row)
|
|
228
|
+
}
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Non-row targets keep their existing click path; only host
|
|
233
|
+
// session/search rows participate in the selected-state hand-off.
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
document.addEventListener('click', onDrawerClick, true)
|
|
237
|
+
document.addEventListener('pointerup', onDrawerPointerUp, true)
|
|
238
|
+
return () => {
|
|
239
|
+
disarmNav()
|
|
240
|
+
document.removeEventListener('click', onDrawerClick, true)
|
|
241
|
+
document.removeEventListener('pointerup', onDrawerPointerUp, true)
|
|
242
|
+
}
|
|
243
|
+
}, [mobile, open, toggleSidebar])
|
|
244
|
+
|
|
245
|
+
// Tap-outside closes the drawer (issue #38). The backdrop is now
|
|
246
|
+
// pointer-events: none (pure dimming layer that never steals clicks), so
|
|
247
|
+
// "tap the dimmed area to close" moves here: any click outside the drawer
|
|
248
|
+
// (and outside the header toggle) closes it, keeping the standard
|
|
249
|
+
// interaction while letting drawer contents receive clicks normally.
|
|
250
|
+
// Capture phase: the close happens before the content processes the tap
|
|
251
|
+
// (same first-tap-closes behaviour as before).
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (!mobile || !open) return
|
|
254
|
+
const onOutsideClick = (event: MouseEvent) => {
|
|
255
|
+
if (document.querySelector('[aria-modal="true"]') !== null) return
|
|
256
|
+
const target = event.target as HTMLElement | null
|
|
257
|
+
if (target === null) return
|
|
258
|
+
// 宿主自带抽屉(DSH 0.2.2 起)时,点遮罩关闭是它自己 drawerMask 的
|
|
259
|
+
// onClick;这里再 toggle 一次会把刚关上的抽屉又推开,手机上看就是
|
|
260
|
+
// 「点了没反应」。自带抽屉一律交给宿主。
|
|
261
|
+
if (hasNativeDrawer(document.querySelector('[data-mobile-nav="frame"]'))) return
|
|
262
|
+
if (target.closest(TOGGLE_SELECTOR) !== null) return
|
|
263
|
+
// Portaled overlays (issue #72): the workspace section's "视图选项" /
|
|
264
|
+
// "添加工作区" and the session row kebab all open menus that live on
|
|
265
|
+
// document.body — outside the drawer DOM. Without this exemption the
|
|
266
|
+
// first tap on a menu item is eaten: the drawer slides away, the menu
|
|
267
|
+
// unmounts with the sidebar, and the item's onClick never runs, so
|
|
268
|
+
// every workspace control reads as "点了没反应" on a phone.
|
|
269
|
+
if (isOverlayTap(target)) return
|
|
270
|
+
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
271
|
+
if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return
|
|
272
|
+
const drawer = findDrawer()
|
|
273
|
+
if (drawer !== null && drawer.contains(target)) return
|
|
274
|
+
toggleSidebar()
|
|
275
|
+
}
|
|
276
|
+
document.addEventListener('click', onOutsideClick, true)
|
|
277
|
+
return () => document.removeEventListener('click', onOutsideClick, true)
|
|
278
|
+
}, [mobile, open, toggleSidebar])
|
|
279
|
+
|
|
280
|
+
if (!mobile) return null
|
|
281
|
+
return (
|
|
282
|
+
<>
|
|
283
|
+
{open && (
|
|
284
|
+
<div data-mobile-nav="backdrop" />
|
|
285
|
+
)}
|
|
286
|
+
{fabVisible && !open && (
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
data-mobile-nav="fab"
|
|
290
|
+
aria-label={t('open')}
|
|
291
|
+
title={t('open')}
|
|
292
|
+
onClick={() => toggleSidebar()}
|
|
293
|
+
>
|
|
294
|
+
<MobileIcon icon={icons.panelLeft} name="panelLeft" size={18} />
|
|
295
|
+
</button>
|
|
296
|
+
)}
|
|
297
|
+
</>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
2
|
+
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
3
|
+
import { NS } from './locales.ts'
|
|
4
|
+
import { MobileIcon } from './MobileIcon.tsx'
|
|
5
|
+
import { resolveMobileIcons } from './icon-compat.mjs'
|
|
6
|
+
|
|
7
|
+
/** 宿主图标在加载时解析一次:不同 DSH 版本的导出名不同(见 icon-compat.mjs)。 */
|
|
8
|
+
const icons = resolveMobileIcons(primitives)
|
|
9
|
+
|
|
10
|
+
/** Full props for the session-header directory toggle. */
|
|
11
|
+
export interface MobileNavToggleProps extends PropsRuntime<'conversation.session.header.actions'>, PropsLocale<typeof NS> {
|
|
12
|
+
/** Bound ctx.layout.toggleSidebar(). */
|
|
13
|
+
toggleSidebar: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mobile-only icon buttons next to the session title:
|
|
18
|
+
* - toggle: opens the directory drawer on narrow screens.
|
|
19
|
+
* - files: toggles the dsh-web-ui explorer sheet directly — one tap opens,
|
|
20
|
+
* a second tap closes it, no drawer round-trip. (The drawer footer keeps
|
|
21
|
+
* a Files entry for the hero/blank phases where this header does not
|
|
22
|
+
* exist.)
|
|
23
|
+
* Hidden entirely on wide screens (CSS media query).
|
|
24
|
+
*/
|
|
25
|
+
export function MobileNavToggle({ toggleSidebar, t }: MobileNavToggleProps) {
|
|
26
|
+
const toggleExplorer = (): void => {
|
|
27
|
+
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
28
|
+
if (frame === null) return
|
|
29
|
+
if (frame.hasAttribute('data-aionui-explorer-open')) {
|
|
30
|
+
frame.removeAttribute('data-aionui-explorer-open')
|
|
31
|
+
} else {
|
|
32
|
+
frame.setAttribute('data-aionui-explorer-open', '')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
data-mobile-nav="toggle"
|
|
40
|
+
aria-label={t('open')}
|
|
41
|
+
title={t('open')}
|
|
42
|
+
onClick={() => toggleSidebar()}
|
|
43
|
+
>
|
|
44
|
+
<MobileIcon icon={icons.panelLeft} name="panelLeft" size={16} />
|
|
45
|
+
</button>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
data-mobile-nav="files"
|
|
49
|
+
aria-label={t('files')}
|
|
50
|
+
title={t('files')}
|
|
51
|
+
onClick={toggleExplorer}
|
|
52
|
+
>
|
|
53
|
+
<MobileIcon icon={icons.folderOpen} name="folderOpen" size={16} />
|
|
54
|
+
</button>
|
|
55
|
+
</>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// 移动端文件链接增强(issue #17,2026-10 修订):新版 DSH 点聊天里的文件链接会
|
|
2
|
+
// 在右侧栏打开文件预览(sidebarRight.openResource('dsh-resource://file/...'),手机
|
|
3
|
+
// 也能看),不再调用桌面端 workspaces.openPath(open <path>)。因此这里**不再拦截
|
|
4
|
+
// 点击**(旧守卫会吞掉现在有效的预览点击),只保留两件事:
|
|
5
|
+
// 1) 在「文案就是文件路径」的链接旁注入一个「复制」按钮,点它经主机 RPC 读文件
|
|
6
|
+
// 正文再写剪贴板(按钮自带内联样式,不依赖 Pocket 旧 mobile.css 是否生效);
|
|
7
|
+
// 2) 隐藏「添加工作区」入口(手机上配工作区无意义)。
|
|
8
|
+
// 移植自 dsh-web-mobile(MIT)。
|
|
9
|
+
//
|
|
10
|
+
// 识别方式:不依赖 dsh-web 的 hash 类名(每次构建都变),只认「文本像文件路径的
|
|
11
|
+
// <button>/<a>」——文件链接按钮的文案就是路径(如 lib/proxy.mjs / /Users/.../x.ts)。
|
|
12
|
+
|
|
13
|
+
/** 「添加工作区」入口的文案(随语言变化),两种都覆盖。 */
|
|
14
|
+
const WS_LABELS = ['添加工作区', '添加工作区…', 'Add workspace', 'Add workspace…']
|
|
15
|
+
/** 复制按钮文案。 */
|
|
16
|
+
const COPY_LABEL = '复制'
|
|
17
|
+
|
|
18
|
+
/** 主机 fileRead 回调返回结构(与 client/api.js 的 FileReadResult 对齐)。 */
|
|
19
|
+
interface ReadFileResponse {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
value?: { content: string; path: string; size: number };
|
|
22
|
+
error?: { message: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 文本是否像文件路径:只认「整段文案就是一条路径」,避免状态行/命令行等长文本误命中。 */
|
|
26
|
+
function looksLikeFilePath(text: string | null): boolean {
|
|
27
|
+
const t = (text ?? '').trim()
|
|
28
|
+
if (t.length < 3 || t.length > 320) return false
|
|
29
|
+
// 真实文件链接的文案就是纯路径:带空格或中文的(过程状态行、命令输出等)一律不算。
|
|
30
|
+
if (/\s/.test(t) || /[\u3400-\u9fff]/.test(t)) return false
|
|
31
|
+
if (/^(\/|~\/|\.\.?\/|[A-Za-z]:\\)/.test(t)) return true
|
|
32
|
+
if (/\/[\w.\-]+\.\w{1,12}$/.test(t)) return true
|
|
33
|
+
if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 写剪贴板:优先 navigator.clipboard,非安全上下文(局域网 http)回退 execCommand。 */
|
|
38
|
+
async function copyText(text: string): Promise<boolean> {
|
|
39
|
+
try {
|
|
40
|
+
if (navigator.clipboard?.writeText) {
|
|
41
|
+
await navigator.clipboard.writeText(text)
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
} catch { /* 回退 */ }
|
|
45
|
+
try {
|
|
46
|
+
const ta = document.createElement('textarea')
|
|
47
|
+
ta.value = text
|
|
48
|
+
ta.style.position = 'fixed'
|
|
49
|
+
ta.style.top = '-9999px'
|
|
50
|
+
ta.style.opacity = '0'
|
|
51
|
+
document.body.appendChild(ta)
|
|
52
|
+
ta.focus()
|
|
53
|
+
ta.select()
|
|
54
|
+
const okCopy = document.execCommand('copy')
|
|
55
|
+
ta.remove()
|
|
56
|
+
return okCopy
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function startFileGuard(
|
|
63
|
+
readFile: (path: string) => Promise<ReadFileResponse>,
|
|
64
|
+
): () => void {
|
|
65
|
+
// 轻量 toast:自包含,不依赖 dsh-pocket 面板的 React 状态。
|
|
66
|
+
let toastEl: HTMLElement | null = null
|
|
67
|
+
let toastTimer: number | null = null
|
|
68
|
+
const showToast = (text: string): void => {
|
|
69
|
+
if (toastEl === null) {
|
|
70
|
+
toastEl = document.createElement('div')
|
|
71
|
+
toastEl.setAttribute('data-mobile-nav', 'file-guard-toast')
|
|
72
|
+
Object.assign(toastEl.style, {
|
|
73
|
+
position: 'fixed',
|
|
74
|
+
left: '50%',
|
|
75
|
+
bottom: '64px',
|
|
76
|
+
transform: 'translateX(-50%)',
|
|
77
|
+
maxWidth: '84vw',
|
|
78
|
+
zIndex: '9999',
|
|
79
|
+
padding: '10px 14px',
|
|
80
|
+
borderRadius: '10px',
|
|
81
|
+
background: 'rgba(20,22,28,.92)',
|
|
82
|
+
color: '#fff',
|
|
83
|
+
fontSize: '13px',
|
|
84
|
+
lineHeight: '1.4',
|
|
85
|
+
textAlign: 'center',
|
|
86
|
+
fontFamily: 'inherit',
|
|
87
|
+
boxShadow: '0 4px 16px rgba(0,0,0,.28)',
|
|
88
|
+
pointerEvents: 'none',
|
|
89
|
+
opacity: '0',
|
|
90
|
+
transition: 'opacity .18s ease',
|
|
91
|
+
} as CSSStyleDeclaration)
|
|
92
|
+
document.body.appendChild(toastEl)
|
|
93
|
+
}
|
|
94
|
+
toastEl.textContent = text
|
|
95
|
+
requestAnimationFrame(() => {
|
|
96
|
+
if (toastEl !== null) toastEl.style.opacity = '1'
|
|
97
|
+
})
|
|
98
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
99
|
+
toastTimer = window.setTimeout(() => {
|
|
100
|
+
if (toastEl !== null) toastEl.style.opacity = '0'
|
|
101
|
+
}, 2600)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 在文件链接旁注入「复制」按钮:点它经主机 RPC 读文件正文再写剪贴板。
|
|
105
|
+
// 用 data-mobile-nav-copy 标记已处理的链接,避免重复注入;React 重渲染会
|
|
106
|
+
// 产生新元素(无标记),MutationObserver 重新补上按钮。
|
|
107
|
+
const injectCopyButtons = (): void => {
|
|
108
|
+
const links = document.querySelectorAll('button, a')
|
|
109
|
+
links.forEach((el) => {
|
|
110
|
+
if (el.getAttribute('data-mobile-nav-copy') === '1') return
|
|
111
|
+
const txt = (el.textContent ?? '').trim()
|
|
112
|
+
if (!looksLikeFilePath(txt)) return
|
|
113
|
+
el.setAttribute('data-mobile-nav-copy', '1')
|
|
114
|
+
const btn = document.createElement('button')
|
|
115
|
+
btn.type = 'button'
|
|
116
|
+
btn.setAttribute('data-mobile-nav', 'copy-file')
|
|
117
|
+
btn.textContent = COPY_LABEL
|
|
118
|
+
// 自带内联样式:不依赖 Pocket 旧 mobile.css 是否生效(那个整表可能被别的
|
|
119
|
+
// 手机布局插件停用),否则深色主题下会退化成浏览器默认的灰色大按钮。
|
|
120
|
+
btn.style.height = '22px'
|
|
121
|
+
btn.style.padding = '0 8px'
|
|
122
|
+
btn.style.marginLeft = '6px'
|
|
123
|
+
btn.style.border = '1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .14))'
|
|
124
|
+
btn.style.borderRadius = '6px'
|
|
125
|
+
btn.style.background = 'var(--dsw-alias-bg-layer-1, #2b2b2e)'
|
|
126
|
+
btn.style.color = 'var(--dsw-alias-label-primary, inherit)'
|
|
127
|
+
btn.style.fontFamily = 'inherit'
|
|
128
|
+
btn.style.fontSize = '11px'
|
|
129
|
+
btn.style.lineHeight = '1'
|
|
130
|
+
btn.style.cursor = 'pointer'
|
|
131
|
+
btn.style.verticalAlign = 'baseline'
|
|
132
|
+
btn.addEventListener('click', async (e) => {
|
|
133
|
+
e.preventDefault()
|
|
134
|
+
e.stopPropagation()
|
|
135
|
+
const filePath = (el.textContent ?? '').trim()
|
|
136
|
+
btn.disabled = true
|
|
137
|
+
btn.textContent = '…'
|
|
138
|
+
try {
|
|
139
|
+
const res = await readFile(filePath)
|
|
140
|
+
if (!res?.ok) {
|
|
141
|
+
showToast(res?.error?.message ?? '复制失败')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const content = res.value?.content ?? ''
|
|
145
|
+
const copied = await copyText(content)
|
|
146
|
+
if (copied) {
|
|
147
|
+
const kb = Math.max(1, Math.round((res.value?.size ?? content.length) / 1024))
|
|
148
|
+
showToast(`已复制文件内容(${kb} KB)`)
|
|
149
|
+
} else {
|
|
150
|
+
showToast('复制失败,请手动选择')
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
showToast(err instanceof Error ? err.message : '复制失败')
|
|
154
|
+
} finally {
|
|
155
|
+
btn.disabled = false
|
|
156
|
+
btn.textContent = COPY_LABEL
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
el.parentElement?.insertBefore(btn, el.nextSibling)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
injectCopyButtons()
|
|
163
|
+
const copyObserver = new MutationObserver(() => injectCopyButtons())
|
|
164
|
+
copyObserver.observe(document.body, { childList: true, subtree: true })
|
|
165
|
+
|
|
166
|
+
// 隐藏「添加工作区」入口:图标按钮由 mobile.css.ts 按 aria-label 隐藏;
|
|
167
|
+
// 下拉菜单里的文本项 CSS 选不到,这里按文案兜底(只在新增节点时检查,省开销)。
|
|
168
|
+
const hideWsEntries = (): (() => void) => {
|
|
169
|
+
const checkOne = (node: Node): void => {
|
|
170
|
+
if (node.nodeType !== 1) return
|
|
171
|
+
const el = node as HTMLElement
|
|
172
|
+
const txt = (el.getAttribute('aria-label') ?? el.textContent ?? '').trim()
|
|
173
|
+
if (WS_LABELS.includes(txt)) {
|
|
174
|
+
el.style.display = 'none'
|
|
175
|
+
el.setAttribute('data-mobile-nav-hide', 'add-workspace')
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const sel = '[role="menuitem"],[role="option"],li,button,a'
|
|
179
|
+
document.querySelectorAll(sel).forEach(checkOne)
|
|
180
|
+
const observer = new MutationObserver((mutations) => {
|
|
181
|
+
for (const m of mutations) {
|
|
182
|
+
m.addedNodes.forEach((n) => {
|
|
183
|
+
if (n.nodeType !== 1) return
|
|
184
|
+
checkOne(n)
|
|
185
|
+
;(n as HTMLElement).querySelectorAll?.(sel).forEach(checkOne)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
190
|
+
return () => observer.disconnect()
|
|
191
|
+
}
|
|
192
|
+
const disconnectWs = hideWsEntries()
|
|
193
|
+
|
|
194
|
+
return () => {
|
|
195
|
+
copyObserver.disconnect()
|
|
196
|
+
disconnectWs()
|
|
197
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
198
|
+
toastEl?.remove()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// DSH primitives 图标导出的跨版本兼容层。
|
|
2
|
+
//
|
|
3
|
+
// 背景(2026-09 实测,dsh 0.1.7-rc.2 + dsh-pocket 2.10.6):
|
|
4
|
+
// 移动端组件原来直接具名导入
|
|
5
|
+
// IconPanelLeftOutline16 / IconDownloadOutline16 / IconFolderOpenOutline16,
|
|
6
|
+
// 而 @deepseek-ai/dsh-client-ui-primitives 在 0.1.7-rc 之后把图标导出改成了
|
|
7
|
+
// Icon<Name>OutlineRegular / Icon<Name>OutlineMedium(`16` 尺寸后缀被移除)。
|
|
8
|
+
// 于是这些导入在运行期变成 undefined —— 组件一渲染就抛 React #130
|
|
9
|
+
// ("Element type is invalid ... got: undefined"),slot 条目整条崩掉:
|
|
10
|
+
// 移动样式表照样注入,但 `data-mobile-nav="frame"` 标记永远挂不上,抽屉彻底失效。
|
|
11
|
+
// 手机上的表现:56px 侧栏图标条一直占位、正文被挤成一列(用户可见症状)。
|
|
12
|
+
//
|
|
13
|
+
// 这里做三件事:
|
|
14
|
+
// 1. 不再按具体名字静态导入 —— 新版名字在旧版不存在、旧版名字在新版不存在;
|
|
15
|
+
// 对真正的 ESM 来说,具名导入不存在的导出是**链接期**错误,比 undefined 更难查;
|
|
16
|
+
// 2. 用候选名列表在运行时解析,先后覆盖新旧两代命名;
|
|
17
|
+
// 3. 全都解析不到时返回 null(绝不返回 undefined),调用方用文字字形兜底 ——
|
|
18
|
+
// 在任何一代 DSH 上都不会再把整条 slot 打崩。
|
|
19
|
+
//
|
|
20
|
+
// 纯数据 + 纯函数、无 DOM 依赖,便于 `node --test` 直接引(test/icon-compat.test.js)。
|
|
21
|
+
|
|
22
|
+
/** 每个图标按「新名优先、旧名兜底」的候选顺序。 */
|
|
23
|
+
export const MOBILE_ICON_CANDIDATES = {
|
|
24
|
+
panelLeft: [
|
|
25
|
+
'IconPanelLeftOutlineRegular',
|
|
26
|
+
'IconPanelLeftOutlineMedium',
|
|
27
|
+
'IconPanelLeftOutline16',
|
|
28
|
+
'IconPanelLeftOutline',
|
|
29
|
+
],
|
|
30
|
+
download: [
|
|
31
|
+
'IconDownloadOutlineRegular',
|
|
32
|
+
'IconDownloadOutlineMedium',
|
|
33
|
+
'IconDownloadOutline16',
|
|
34
|
+
'IconDownloadOutline',
|
|
35
|
+
],
|
|
36
|
+
folderOpen: [
|
|
37
|
+
'IconFolderOpenOutlineRegular',
|
|
38
|
+
'IconFolderOpenOutlineMedium',
|
|
39
|
+
'IconFolderOpenOutline16',
|
|
40
|
+
'IconFolderOpenOutline',
|
|
41
|
+
'IconFolderOpen',
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 解析不到图标时用的文字字形(保证按钮仍然可见、可点、可读)。 */
|
|
46
|
+
export const MOBILE_ICON_FALLBACK = {
|
|
47
|
+
panelLeft: '☰',
|
|
48
|
+
download: '⤓',
|
|
49
|
+
folderOpen: '▤',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 注册表里的某个导出能不能当 React 组件渲染。
|
|
54
|
+
* 组件可能是函数,也可能是对象(memo / forwardRef / class 组件),所以两者都收;
|
|
55
|
+
* 数字、字符串、null、undefined 一律不算 —— 它们正是打崩 slot 的元凶。
|
|
56
|
+
* @param value - 待判定的导出值。
|
|
57
|
+
* @returns 可渲染时为 true。
|
|
58
|
+
*/
|
|
59
|
+
export function isRenderableIcon(value) {
|
|
60
|
+
if (typeof value === 'function') return true
|
|
61
|
+
return typeof value === 'object' && value !== null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 按候选顺序取第一个可渲染的导出。
|
|
66
|
+
* @param registry - 宿主 primitives 命名空间(可能为 null/不是对象)。
|
|
67
|
+
* @param names - 候选导出名,按优先级排列。
|
|
68
|
+
* @returns 命中的组件;一个都没有时返回 null(**绝不返回 undefined**)。
|
|
69
|
+
*/
|
|
70
|
+
export function pickIcon(registry, names) {
|
|
71
|
+
if (registry === null || typeof registry !== 'object') return null
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
const candidate = registry[name]
|
|
74
|
+
if (isRenderableIcon(candidate)) return candidate
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把宿主 primitives 命名空间解析成移动端要用的三个图标。
|
|
81
|
+
* @param registry - 宿主 primitives 命名空间。
|
|
82
|
+
* @returns `{ panelLeft, download, folderOpen }`,缺的为 null(不是 undefined)。
|
|
83
|
+
*/
|
|
84
|
+
export function resolveMobileIcons(registry) {
|
|
85
|
+
const resolved = {}
|
|
86
|
+
for (const [key, names] of Object.entries(MOBILE_ICON_CANDIDATES)) {
|
|
87
|
+
resolved[key] = pickIcon(registry, names)
|
|
88
|
+
}
|
|
89
|
+
return resolved
|
|
90
|
+
}
|