dsh-pocket-relay 1.0.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/LICENSE +344 -0
- package/README.en.md +219 -0
- package/README.md +258 -0
- package/bin/dsh-pocket-relay.mjs +216 -0
- package/client/api.js +88 -0
- package/client/build.mjs +47 -0
- package/client/client.js +3238 -0
- package/client/index.jsx +979 -0
- package/client/mobile/LICENSE.dsh-web-mobile +4 -0
- package/client/mobile/MobileDrawerFooter.tsx +56 -0
- package/client/mobile/MobileNavOverlay.tsx +290 -0
- package/client/mobile/MobileNavToggle.tsx +52 -0
- package/client/mobile/fileGuard.ts +200 -0
- package/client/mobile/layout-mode.mjs +46 -0
- package/client/mobile/locales.ts +23 -0
- package/client/mobile/mobile-apply.tsx +378 -0
- package/client/mobile/mobile.css.ts +1005 -0
- package/client/mobile/nav-targets.mjs +85 -0
- package/client/pocket-locales.js +331 -0
- package/cordis.patch.yml +11 -0
- package/lib/device-auth-http.mjs +338 -0
- package/lib/device-auth.mjs +485 -0
- package/lib/index.js +414 -0
- package/lib/ip.mjs +11 -0
- package/lib/proxy.mjs +1223 -0
- package/lib/relay-protocol.mjs +101 -0
- package/lib/relay.mjs +355 -0
- package/lib/restart.js +112 -0
- package/lib/service.mjs +578 -0
- package/lib/settings.mjs +392 -0
- package/lib/tunnel.mjs +621 -0
- package/lib/web-rpc.js +622 -0
- package/package.json +89 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
2
|
+
import { IconDownloadOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
3
|
+
import { NS } from './locales.ts'
|
|
4
|
+
|
|
5
|
+
/** Full props for the sidebar footer action entry. */
|
|
6
|
+
export interface MobileDrawerFooterProps extends PropsRuntime<'sidebar.footer.action'>, PropsLocale<typeof NS> {
|
|
7
|
+
/** Bound ctx.sessionLogDownload.download() for the current session. */
|
|
8
|
+
downloadSessionLog: (sessionId: string) => void
|
|
9
|
+
/** Bound ctx.layout.toggleSidebar(): the Files sheet closes the drawer. */
|
|
10
|
+
toggleSidebar: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mobile-only drawer footer actions, relocated from the session header to the
|
|
15
|
+
* drawer footer (beside Settings):
|
|
16
|
+
* - Files: opens the dsh-web-ui aionui explorer as a floating bottom sheet
|
|
17
|
+
* (the explorer column is hidden on mobile until this marker is set, so
|
|
18
|
+
* the suite's own persisted-expanded state can never cover the UI on load).
|
|
19
|
+
* - Session log: the official session-log-export controller, so the
|
|
20
|
+
* progress/result dialog is shared with the desktop flow.
|
|
21
|
+
* Hidden entirely on wide screens (CSS media query).
|
|
22
|
+
*/
|
|
23
|
+
export function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t }: MobileDrawerFooterProps) {
|
|
24
|
+
const sessionId = useSessions((state) => state.current)
|
|
25
|
+
const openExplorer = (): void => {
|
|
26
|
+
document.querySelector('[data-mobile-nav="frame"]')?.setAttribute('data-aionui-explorer-open', '')
|
|
27
|
+
toggleSidebar()
|
|
28
|
+
}
|
|
29
|
+
return (
|
|
30
|
+
<div data-mobile-nav="drawer-actions">
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
data-mobile-nav="explorer"
|
|
34
|
+
aria-label={t('files')}
|
|
35
|
+
title={t('files')}
|
|
36
|
+
onClick={openExplorer}
|
|
37
|
+
>
|
|
38
|
+
<IconPanelLeftOutline16 size={14} />
|
|
39
|
+
<span>{t('files')}</span>
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
data-mobile-nav="session-log"
|
|
44
|
+
aria-label={t('sessionLog')}
|
|
45
|
+
title={t('sessionLog')}
|
|
46
|
+
disabled={sessionId === undefined}
|
|
47
|
+
onClick={() => {
|
|
48
|
+
if (sessionId !== undefined) downloadSessionLog(sessionId)
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<IconDownloadOutline16 size={14} />
|
|
52
|
+
<span>{t('sessionLog')}</span>
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useState } from 'react'
|
|
2
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
3
|
+
import { IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
4
|
+
import { NS } from './locales.ts'
|
|
5
|
+
import {
|
|
6
|
+
DRAWER_SELECTOR,
|
|
7
|
+
TOGGLE_SELECTOR,
|
|
8
|
+
isOverlayTap,
|
|
9
|
+
navTargetFor,
|
|
10
|
+
} from './nav-targets.mjs'
|
|
11
|
+
|
|
12
|
+
/** Full props for the shell overlay entry. */
|
|
13
|
+
export interface MobileNavOverlayProps extends PropsRuntime<'shell.overlay'>, PropsLocale<typeof NS> {
|
|
14
|
+
/** Bound ctx.layout.toggleSidebar(). */
|
|
15
|
+
toggleSidebar: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Same breakpoint as the shell's SIDEBAR_AUTO_COLLAPSE (viewport < 1024). */
|
|
19
|
+
const MOBILE_QUERY = '(max-width: 1023px)'
|
|
20
|
+
|
|
21
|
+
/** Live matchMedia hook for the narrow breakpoint. */
|
|
22
|
+
function useMobile(): boolean {
|
|
23
|
+
const [mobile, setMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches)
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const query = window.matchMedia(MOBILE_QUERY)
|
|
26
|
+
const onChange = (event: MediaQueryListEvent) => setMobile(event.matches)
|
|
27
|
+
query.addEventListener('change', onChange)
|
|
28
|
+
return () => query.removeEventListener('change', onChange)
|
|
29
|
+
}, [])
|
|
30
|
+
return mobile
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The AppFrame element: direct parent of the shell overlay layer. */
|
|
34
|
+
function findFrame(): HTMLElement | null {
|
|
35
|
+
return document.querySelector('[data-shell-overlay]')?.parentElement ?? null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mobile shell overlay: owns the `data-mobile-nav` marker on the AppFrame
|
|
40
|
+
* element (the CSS restructure keys off it), mirrors the frame's collapsed
|
|
41
|
+
* state into React state, and renders the dimmed backdrop plus a floating
|
|
42
|
+
* directory button for the hero/blank phases that have no session header.
|
|
43
|
+
*/
|
|
44
|
+
export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
|
|
45
|
+
const mobile = useMobile()
|
|
46
|
+
const [open, setOpen] = useState(false)
|
|
47
|
+
const [fabVisible, setFabVisible] = useState(false)
|
|
48
|
+
|
|
49
|
+
// Frame ownership + open-state mirror. On wide screens this effect is inert:
|
|
50
|
+
// the marker is never set, so the layout is untouched.
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
if (!mobile) {
|
|
53
|
+
setOpen(false)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const frame = findFrame()
|
|
57
|
+
if (frame === null) return
|
|
58
|
+
frame.setAttribute('data-mobile-nav', 'frame')
|
|
59
|
+
const sync = () => setOpen(!frame.hasAttribute('data-sidebar-collapsed'))
|
|
60
|
+
sync()
|
|
61
|
+
const observer = new MutationObserver(sync)
|
|
62
|
+
observer.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed'] })
|
|
63
|
+
return () => {
|
|
64
|
+
observer.disconnect()
|
|
65
|
+
frame.removeAttribute('data-mobile-nav')
|
|
66
|
+
}
|
|
67
|
+
}, [mobile])
|
|
68
|
+
|
|
69
|
+
// The floating button is a fallback for surfaces without a session header:
|
|
70
|
+
// phase "active" means the header (and its toggle) is rendered already.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!mobile) {
|
|
73
|
+
setFabVisible(false)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const sync = () => setFabVisible(document.querySelector('[data-phase="active"]') === null)
|
|
77
|
+
sync()
|
|
78
|
+
const observer = new MutationObserver(sync)
|
|
79
|
+
// childList: the conversation root can be replaced wholesale on session
|
|
80
|
+
// switches, so attribute-only observation would miss the new phase.
|
|
81
|
+
observer.observe(document.documentElement, {
|
|
82
|
+
subtree: true,
|
|
83
|
+
childList: true,
|
|
84
|
+
attributes: true,
|
|
85
|
+
attributeFilter: ['data-phase'],
|
|
86
|
+
})
|
|
87
|
+
return () => observer.disconnect()
|
|
88
|
+
}, [mobile])
|
|
89
|
+
|
|
90
|
+
// Escape closes the drawer — but yields to an open modal dialog (e.g. the
|
|
91
|
+
// settings panel), which owns its own Escape handling.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!mobile || !open) return
|
|
94
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
95
|
+
if (event.key === 'Escape' && document.querySelector('[aria-modal="true"]') === null) toggleSidebar()
|
|
96
|
+
}
|
|
97
|
+
// Capture phase: run before the settings panel's own document-bubble Escape
|
|
98
|
+
// handler, so the modal is still present when we yield to it.
|
|
99
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
100
|
+
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
101
|
+
}, [mobile, open, toggleSidebar])
|
|
102
|
+
|
|
103
|
+
// Navigation inside the drawer closes it: tapping a session row or a
|
|
104
|
+
// plugin takeover entry (task board / ssh) must hand the screen to the
|
|
105
|
+
// content it just opened. Mouse clicks keep the direct close path. Touch
|
|
106
|
+
// session rows close only after aria-selected changes, so live session
|
|
107
|
+
// updates cannot strand a delayed click on a detached DOM target.
|
|
108
|
+
//
|
|
109
|
+
// Deliberately NOT closed by this rule:
|
|
110
|
+
// - Settings / Session log: their dialogs render INSIDE the drawer DOM
|
|
111
|
+
// (portaled into the sidebar); closing the drawer would slide the dialog
|
|
112
|
+
// off-screen with it.
|
|
113
|
+
// - Workspace folder rows (YDXeBa_projectRow), the logo: pure UI toggles,
|
|
114
|
+
// not navigation — see NAV_TARGETS in nav-targets.mjs.
|
|
115
|
+
// - Anything while a modal dialog is open: the dialog owns the screen.
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!mobile || !open) return
|
|
118
|
+
let lastTouchNavAt = 0
|
|
119
|
+
let lastTouchX = 0
|
|
120
|
+
let lastTouchY = 0
|
|
121
|
+
let suppressTouchClickUntil = 0
|
|
122
|
+
let pendingTouchRow: Element | null = null
|
|
123
|
+
let selectedRowAtArm: Element | null = null
|
|
124
|
+
let navClickArrived = false
|
|
125
|
+
let navObserver: MutationObserver | null = null
|
|
126
|
+
let navTimer: number | null = null
|
|
127
|
+
|
|
128
|
+
const drawerRoot = (): HTMLElement | null =>
|
|
129
|
+
document.querySelector<HTMLElement>(DRAWER_SELECTOR)
|
|
130
|
+
|
|
131
|
+
const disarmNav = (): void => {
|
|
132
|
+
navObserver?.disconnect()
|
|
133
|
+
navObserver = null
|
|
134
|
+
if (navTimer !== null) window.clearTimeout(navTimer)
|
|
135
|
+
navTimer = null
|
|
136
|
+
pendingTouchRow = null
|
|
137
|
+
selectedRowAtArm = null
|
|
138
|
+
navClickArrived = false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const armNav = (row: Element): void => {
|
|
142
|
+
disarmNav()
|
|
143
|
+
pendingTouchRow = row
|
|
144
|
+
const drawer = drawerRoot()
|
|
145
|
+
selectedRowAtArm = drawer?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null
|
|
146
|
+
if (drawer === null) return
|
|
147
|
+
navObserver = new MutationObserver(() => {
|
|
148
|
+
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
149
|
+
if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) {
|
|
150
|
+
disarmNav()
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const selectedRow = drawerRoot()?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null
|
|
154
|
+
if (navClickArrived && selectedRow !== null && selectedRow !== selectedRowAtArm) {
|
|
155
|
+
disarmNav()
|
|
156
|
+
toggleSidebar()
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
navObserver.observe(drawer, {
|
|
160
|
+
childList: true,
|
|
161
|
+
subtree: true,
|
|
162
|
+
attributes: true,
|
|
163
|
+
attributeFilter: ['aria-selected'],
|
|
164
|
+
})
|
|
165
|
+
navTimer = window.setTimeout(disarmNav, 2000)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const navigationTarget = (target: EventTarget | null): Element | null => {
|
|
169
|
+
if (document.querySelector('[aria-modal="true"]') !== null) return null
|
|
170
|
+
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
171
|
+
if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return null
|
|
172
|
+
if (!(target instanceof Element)) return null
|
|
173
|
+
const drawer = drawerRoot()
|
|
174
|
+
if (drawer === null || !drawer.contains(target)) return null
|
|
175
|
+
return navTargetFor(target)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const isPendingTouchClick = (event: MouseEvent): boolean => {
|
|
179
|
+
const capabilities = (event as MouseEvent & {
|
|
180
|
+
sourceCapabilities?: { firesTouchEvents?: boolean }
|
|
181
|
+
}).sourceCapabilities
|
|
182
|
+
if (capabilities?.firesTouchEvents === true) return true
|
|
183
|
+
return Math.hypot(event.clientX - lastTouchX, event.clientY - lastTouchY) <= 24
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const onDrawerClick = (event: MouseEvent): void => {
|
|
187
|
+
// A touch row tap owns the close through the selection observer. Let the
|
|
188
|
+
// browser click reach React without a capture-phase close racing it.
|
|
189
|
+
if (performance.now() < suppressTouchClickUntil) return
|
|
190
|
+
if (
|
|
191
|
+
pendingTouchRow !== null
|
|
192
|
+
&& performance.now() - lastTouchNavAt < 500
|
|
193
|
+
&& isPendingTouchClick(event)
|
|
194
|
+
) {
|
|
195
|
+
const target = navigationTarget(event.target)
|
|
196
|
+
const row = target?.closest('[role="treeitem"]')
|
|
197
|
+
if (row !== null && row !== undefined) {
|
|
198
|
+
pendingTouchRow = row
|
|
199
|
+
navClickArrived = true
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (navigationTarget(event.target) !== null) toggleSidebar()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const onDrawerPointerUp = (event: PointerEvent): void => {
|
|
207
|
+
if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return
|
|
208
|
+
const target = navigationTarget(event.target)
|
|
209
|
+
if (target === null) return
|
|
210
|
+
|
|
211
|
+
const row = target.closest('[role="treeitem"]')
|
|
212
|
+
if (row !== null) {
|
|
213
|
+
if (row.getAttribute('aria-selected') === 'true') {
|
|
214
|
+
// Already-selected rows will not navigate; closing now is safe.
|
|
215
|
+
suppressTouchClickUntil = performance.now() + 500
|
|
216
|
+
toggleSidebar()
|
|
217
|
+
} else {
|
|
218
|
+
// Let React navigate first, then close on the selection mutation.
|
|
219
|
+
lastTouchNavAt = performance.now()
|
|
220
|
+
lastTouchX = event.clientX
|
|
221
|
+
lastTouchY = event.clientY
|
|
222
|
+
armNav(row)
|
|
223
|
+
}
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Non-row targets keep their existing click path; only host
|
|
228
|
+
// session/search rows participate in the selected-state hand-off.
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
document.addEventListener('click', onDrawerClick, true)
|
|
232
|
+
document.addEventListener('pointerup', onDrawerPointerUp, true)
|
|
233
|
+
return () => {
|
|
234
|
+
disarmNav()
|
|
235
|
+
document.removeEventListener('click', onDrawerClick, true)
|
|
236
|
+
document.removeEventListener('pointerup', onDrawerPointerUp, true)
|
|
237
|
+
}
|
|
238
|
+
}, [mobile, open, toggleSidebar])
|
|
239
|
+
|
|
240
|
+
// Tap-outside closes the drawer (issue #38). The backdrop is now
|
|
241
|
+
// pointer-events: none (pure dimming layer that never steals clicks), so
|
|
242
|
+
// "tap the dimmed area to close" moves here: any click outside the drawer
|
|
243
|
+
// (and outside the header toggle) closes it, keeping the standard
|
|
244
|
+
// interaction while letting drawer contents receive clicks normally.
|
|
245
|
+
// Capture phase: the close happens before the content processes the tap
|
|
246
|
+
// (same first-tap-closes behaviour as before).
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (!mobile || !open) return
|
|
249
|
+
const onOutsideClick = (event: MouseEvent) => {
|
|
250
|
+
if (document.querySelector('[aria-modal="true"]') !== null) return
|
|
251
|
+
const target = event.target as HTMLElement | null
|
|
252
|
+
if (target === null) return
|
|
253
|
+
if (target.closest(TOGGLE_SELECTOR) !== null) return
|
|
254
|
+
// Portaled overlays (issue #72): the workspace section's "视图选项" /
|
|
255
|
+
// "添加工作区" and the session row kebab all open menus that live on
|
|
256
|
+
// document.body — outside the drawer DOM. Without this exemption the
|
|
257
|
+
// first tap on a menu item is eaten: the drawer slides away, the menu
|
|
258
|
+
// unmounts with the sidebar, and the item's onClick never runs, so
|
|
259
|
+
// every workspace control reads as "点了没反应" on a phone.
|
|
260
|
+
if (isOverlayTap(target)) return
|
|
261
|
+
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
262
|
+
if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return
|
|
263
|
+
const drawer = document.querySelector<HTMLElement>(DRAWER_SELECTOR)
|
|
264
|
+
if (drawer !== null && drawer.contains(target)) return
|
|
265
|
+
toggleSidebar()
|
|
266
|
+
}
|
|
267
|
+
document.addEventListener('click', onOutsideClick, true)
|
|
268
|
+
return () => document.removeEventListener('click', onOutsideClick, true)
|
|
269
|
+
}, [mobile, open, toggleSidebar])
|
|
270
|
+
|
|
271
|
+
if (!mobile) return null
|
|
272
|
+
return (
|
|
273
|
+
<>
|
|
274
|
+
{open && (
|
|
275
|
+
<div data-mobile-nav="backdrop" />
|
|
276
|
+
)}
|
|
277
|
+
{fabVisible && !open && (
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
data-mobile-nav="fab"
|
|
281
|
+
aria-label={t('open')}
|
|
282
|
+
title={t('open')}
|
|
283
|
+
onClick={() => toggleSidebar()}
|
|
284
|
+
>
|
|
285
|
+
<IconPanelLeftOutline16 size={18} />
|
|
286
|
+
</button>
|
|
287
|
+
)}
|
|
288
|
+
</>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
2
|
+
import { IconFolderOpenOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
3
|
+
import { NS } from './locales.ts'
|
|
4
|
+
|
|
5
|
+
/** Full props for the session-header directory toggle. */
|
|
6
|
+
export interface MobileNavToggleProps extends PropsRuntime<'conversation.session.header.actions'>, PropsLocale<typeof NS> {
|
|
7
|
+
/** Bound ctx.layout.toggleSidebar(). */
|
|
8
|
+
toggleSidebar: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mobile-only icon buttons next to the session title:
|
|
13
|
+
* - toggle: opens the directory drawer on narrow screens.
|
|
14
|
+
* - files: toggles the dsh-web-ui explorer sheet directly — one tap opens,
|
|
15
|
+
* a second tap closes it, no drawer round-trip. (The drawer footer keeps
|
|
16
|
+
* a Files entry for the hero/blank phases where this header does not
|
|
17
|
+
* exist.)
|
|
18
|
+
* Hidden entirely on wide screens (CSS media query).
|
|
19
|
+
*/
|
|
20
|
+
export function MobileNavToggle({ toggleSidebar, t }: MobileNavToggleProps) {
|
|
21
|
+
const toggleExplorer = (): void => {
|
|
22
|
+
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
23
|
+
if (frame === null) return
|
|
24
|
+
if (frame.hasAttribute('data-aionui-explorer-open')) {
|
|
25
|
+
frame.removeAttribute('data-aionui-explorer-open')
|
|
26
|
+
} else {
|
|
27
|
+
frame.setAttribute('data-aionui-explorer-open', '')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
data-mobile-nav="toggle"
|
|
35
|
+
aria-label={t('open')}
|
|
36
|
+
title={t('open')}
|
|
37
|
+
onClick={() => toggleSidebar()}
|
|
38
|
+
>
|
|
39
|
+
<IconPanelLeftOutline16 size={16} />
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
data-mobile-nav="files"
|
|
44
|
+
aria-label={t('files')}
|
|
45
|
+
title={t('files')}
|
|
46
|
+
onClick={toggleExplorer}
|
|
47
|
+
>
|
|
48
|
+
<IconFolderOpenOutline16 size={16} />
|
|
49
|
+
</button>
|
|
50
|
+
</>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// 移动端文件守卫(issue #17):dsh-web 在手机上点「文件链接」会触发桌面端
|
|
2
|
+
// workspaces.openPath(open <path>),既打不开(文件在电脑上),又会抛
|
|
3
|
+
// "path open failed". 这里做两件事:
|
|
4
|
+
// 1) 捕获阶段拦截这类激活(点击 / 键盘),改为弹一个提示;
|
|
5
|
+
// 2) 在每个文件链接旁注入一个「复制」按钮,点它经主机 RPC 读文件正文再写剪贴板。
|
|
6
|
+
// 另外隐藏「添加工作区」入口(手机上配工作区无意义)。
|
|
7
|
+
// 移植自 dsh-web-mobile(MIT)。
|
|
8
|
+
//
|
|
9
|
+
// 识别方式:不依赖 dsh-web 的 hash 类名(每次构建都变),只认「文本像文件路径的
|
|
10
|
+
// <button>/<a>」——文件链接按钮的文案就是路径(如 lib/proxy.mjs / /Users/.../x.ts)。
|
|
11
|
+
|
|
12
|
+
/** 手机上点击文件时弹出的提示。 */
|
|
13
|
+
const GUARD_MSG = '手机上无法直接打开电脑上的文件'
|
|
14
|
+
/** 「添加工作区」入口的文案(随语言变化),两种都覆盖。 */
|
|
15
|
+
const WS_LABELS = ['添加工作区', '添加工作区…', 'Add workspace', 'Add workspace…']
|
|
16
|
+
/** 复制按钮文案。 */
|
|
17
|
+
const COPY_LABEL = '复制'
|
|
18
|
+
|
|
19
|
+
/** 主机 fileRead 回调返回结构(与 client/api.js 的 FileReadResult 对齐)。 */
|
|
20
|
+
interface ReadFileResponse {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
value?: { content: string; path: string; size: number };
|
|
23
|
+
error?: { message: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 文本是否像文件路径:绝对路径 / 相对路径 / 带扩展名的目录路径。 */
|
|
27
|
+
function looksLikeFilePath(text: string | null): boolean {
|
|
28
|
+
const t = (text ?? '').trim()
|
|
29
|
+
if (t.length < 3 || t.length > 320) return false
|
|
30
|
+
if (/^(\/|~\/|\.\.?\/|[A-Za-z]:\\)/.test(t)) return true
|
|
31
|
+
if (/\/[\w.\-]+\.\w{1,12}$/.test(t)) return true
|
|
32
|
+
if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 写剪贴板:优先 navigator.clipboard,非安全上下文(局域网 http)回退 execCommand。 */
|
|
37
|
+
async function copyText(text: string): Promise<boolean> {
|
|
38
|
+
try {
|
|
39
|
+
if (navigator.clipboard?.writeText) {
|
|
40
|
+
await navigator.clipboard.writeText(text)
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
} catch { /* 回退 */ }
|
|
44
|
+
try {
|
|
45
|
+
const ta = document.createElement('textarea')
|
|
46
|
+
ta.value = text
|
|
47
|
+
ta.style.position = 'fixed'
|
|
48
|
+
ta.style.top = '-9999px'
|
|
49
|
+
ta.style.opacity = '0'
|
|
50
|
+
document.body.appendChild(ta)
|
|
51
|
+
ta.focus()
|
|
52
|
+
ta.select()
|
|
53
|
+
const okCopy = document.execCommand('copy')
|
|
54
|
+
ta.remove()
|
|
55
|
+
return okCopy
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function startFileGuard(
|
|
62
|
+
readFile: (path: string) => Promise<ReadFileResponse>,
|
|
63
|
+
): () => void {
|
|
64
|
+
// 轻量 toast:自包含,不依赖 dsh-pocket 面板的 React 状态。
|
|
65
|
+
let toastEl: HTMLElement | null = null
|
|
66
|
+
let toastTimer: number | null = null
|
|
67
|
+
const showToast = (text: string): void => {
|
|
68
|
+
if (toastEl === null) {
|
|
69
|
+
toastEl = document.createElement('div')
|
|
70
|
+
toastEl.setAttribute('data-mobile-nav', 'file-guard-toast')
|
|
71
|
+
Object.assign(toastEl.style, {
|
|
72
|
+
position: 'fixed',
|
|
73
|
+
left: '50%',
|
|
74
|
+
bottom: '64px',
|
|
75
|
+
transform: 'translateX(-50%)',
|
|
76
|
+
maxWidth: '84vw',
|
|
77
|
+
zIndex: '9999',
|
|
78
|
+
padding: '10px 14px',
|
|
79
|
+
borderRadius: '10px',
|
|
80
|
+
background: 'rgba(20,22,28,.92)',
|
|
81
|
+
color: '#fff',
|
|
82
|
+
fontSize: '13px',
|
|
83
|
+
lineHeight: '1.4',
|
|
84
|
+
textAlign: 'center',
|
|
85
|
+
fontFamily: 'inherit',
|
|
86
|
+
boxShadow: '0 4px 16px rgba(0,0,0,.28)',
|
|
87
|
+
pointerEvents: 'none',
|
|
88
|
+
opacity: '0',
|
|
89
|
+
transition: 'opacity .18s ease',
|
|
90
|
+
} as CSSStyleDeclaration)
|
|
91
|
+
document.body.appendChild(toastEl)
|
|
92
|
+
}
|
|
93
|
+
toastEl.textContent = text
|
|
94
|
+
requestAnimationFrame(() => {
|
|
95
|
+
if (toastEl !== null) toastEl.style.opacity = '1'
|
|
96
|
+
})
|
|
97
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
98
|
+
toastTimer = window.setTimeout(() => {
|
|
99
|
+
if (toastEl !== null) toastEl.style.opacity = '0'
|
|
100
|
+
}, 2600)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 捕获阶段拦截文件链接的激活。按钮的键盘激活(Enter/Space)会派发 click,
|
|
104
|
+
// 因此只拦 click 即可同时覆盖鼠标与键盘,避免重复处理。
|
|
105
|
+
const onClick = (event: MouseEvent): void => {
|
|
106
|
+
const target = event.target as HTMLElement | null
|
|
107
|
+
if (target === null) return
|
|
108
|
+
const el = target.closest('button, a') as HTMLElement | null
|
|
109
|
+
if (el === null) return
|
|
110
|
+
if (!looksLikeFilePath(el.textContent)) return
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
event.stopImmediatePropagation()
|
|
113
|
+
showToast(GUARD_MSG)
|
|
114
|
+
}
|
|
115
|
+
document.addEventListener('click', onClick, true)
|
|
116
|
+
|
|
117
|
+
// 在文件链接旁注入「复制」按钮:点它经主机 RPC 读文件正文再写剪贴板。
|
|
118
|
+
// 用 data-mobile-nav-copy 标记已处理的链接,避免重复注入;React 重渲染会
|
|
119
|
+
// 产生新元素(无标记),MutationObserver 重新补上按钮。
|
|
120
|
+
const injectCopyButtons = (): void => {
|
|
121
|
+
const links = document.querySelectorAll('button, a')
|
|
122
|
+
links.forEach((el) => {
|
|
123
|
+
if (el.getAttribute('data-mobile-nav-copy') === '1') return
|
|
124
|
+
const txt = (el.textContent ?? '').trim()
|
|
125
|
+
if (!looksLikeFilePath(txt)) return
|
|
126
|
+
el.setAttribute('data-mobile-nav-copy', '1')
|
|
127
|
+
const btn = document.createElement('button')
|
|
128
|
+
btn.type = 'button'
|
|
129
|
+
btn.setAttribute('data-mobile-nav', 'copy-file')
|
|
130
|
+
btn.textContent = COPY_LABEL
|
|
131
|
+
btn.addEventListener('click', async (e) => {
|
|
132
|
+
e.preventDefault()
|
|
133
|
+
e.stopPropagation()
|
|
134
|
+
const filePath = (el.textContent ?? '').trim()
|
|
135
|
+
btn.disabled = true
|
|
136
|
+
btn.textContent = '…'
|
|
137
|
+
try {
|
|
138
|
+
const res = await readFile(filePath)
|
|
139
|
+
if (!res?.ok) {
|
|
140
|
+
showToast(res?.error?.message ?? '复制失败')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const content = res.value?.content ?? ''
|
|
144
|
+
const copied = await copyText(content)
|
|
145
|
+
if (copied) {
|
|
146
|
+
const kb = Math.max(1, Math.round((res.value?.size ?? content.length) / 1024))
|
|
147
|
+
showToast(`已复制文件内容(${kb} KB)`)
|
|
148
|
+
} else {
|
|
149
|
+
showToast('复制失败,请手动选择')
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
showToast(err instanceof Error ? err.message : '复制失败')
|
|
153
|
+
} finally {
|
|
154
|
+
btn.disabled = false
|
|
155
|
+
btn.textContent = COPY_LABEL
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
el.parentElement?.insertBefore(btn, el.nextSibling)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
injectCopyButtons()
|
|
162
|
+
const copyObserver = new MutationObserver(() => injectCopyButtons())
|
|
163
|
+
copyObserver.observe(document.body, { childList: true, subtree: true })
|
|
164
|
+
|
|
165
|
+
// 隐藏「添加工作区」入口:图标按钮由 mobile.css.ts 按 aria-label 隐藏;
|
|
166
|
+
// 下拉菜单里的文本项 CSS 选不到,这里按文案兜底(只在新增节点时检查,省开销)。
|
|
167
|
+
const hideWsEntries = (): (() => void) => {
|
|
168
|
+
const checkOne = (node: Node): void => {
|
|
169
|
+
if (node.nodeType !== 1) return
|
|
170
|
+
const el = node as HTMLElement
|
|
171
|
+
const txt = (el.getAttribute('aria-label') ?? el.textContent ?? '').trim()
|
|
172
|
+
if (WS_LABELS.includes(txt)) {
|
|
173
|
+
el.style.display = 'none'
|
|
174
|
+
el.setAttribute('data-mobile-nav-hide', 'add-workspace')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const sel = '[role="menuitem"],[role="option"],li,button,a'
|
|
178
|
+
document.querySelectorAll(sel).forEach(checkOne)
|
|
179
|
+
const observer = new MutationObserver((mutations) => {
|
|
180
|
+
for (const m of mutations) {
|
|
181
|
+
m.addedNodes.forEach((n) => {
|
|
182
|
+
if (n.nodeType !== 1) return
|
|
183
|
+
checkOne(n)
|
|
184
|
+
;(n as HTMLElement).querySelectorAll?.(sel).forEach(checkOne)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
189
|
+
return () => observer.disconnect()
|
|
190
|
+
}
|
|
191
|
+
const disconnectWs = hideWsEntries()
|
|
192
|
+
|
|
193
|
+
return () => {
|
|
194
|
+
document.removeEventListener('click', onClick, true)
|
|
195
|
+
copyObserver.disconnect()
|
|
196
|
+
disconnectWs()
|
|
197
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
198
|
+
toastEl?.remove()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// 布局模式判定(issue #74:宽屏手机/Pad 可选电脑布局)
|
|
2
|
+
//
|
|
3
|
+
// 优先级:URL 参数 (?dsh-layout=desktop|mobile|auto) > localStorage > 默认 auto。
|
|
4
|
+
// - 'auto' 不写 localStorage,仅按 matchMedia 走;
|
|
5
|
+
// - 显式 'desktop' / 'mobile' 同步到 localStorage,下次无 URL 参数也走同样布局。
|
|
6
|
+
//
|
|
7
|
+
// 纯函数,便于单测;mobile-apply.tsx 读 DOM/localStorage 喂进来。
|
|
8
|
+
|
|
9
|
+
/** @typedef {'mobile' | 'desktop'} ForcedLayout */
|
|
10
|
+
|
|
11
|
+
/** 解析输入。 */
|
|
12
|
+
export function resolveLayout({ urlValue, stored, narrowMatch }) {
|
|
13
|
+
const url = String(urlValue ?? '').trim();
|
|
14
|
+
if (url === 'desktop') return 'desktop';
|
|
15
|
+
if (url === 'mobile') return 'mobile';
|
|
16
|
+
// 'auto' / 空 / 非法值 → 用 localStorage / matchMedia
|
|
17
|
+
if (stored === 'desktop' || stored === 'mobile') return stored;
|
|
18
|
+
return narrowMatch ? 'mobile' : 'desktop';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 同步 localStorage 的副作用(在 mobileApply 入口跑一次)。返回最终存储值。 */
|
|
22
|
+
export function persistLayoutFromUrl(urlValue) {
|
|
23
|
+
if (typeof localStorage === 'undefined') return '';
|
|
24
|
+
const v = String(urlValue ?? '').trim();
|
|
25
|
+
try {
|
|
26
|
+
if (v === 'desktop' || v === 'mobile') localStorage.setItem('dsh-pocket.layout', v);
|
|
27
|
+
else if (v === 'auto' || v === '') localStorage.removeItem('dsh-pocket.layout');
|
|
28
|
+
} catch { /* 隐私模式/无 storage → 静默 */ }
|
|
29
|
+
try {
|
|
30
|
+
const s = localStorage.getItem('dsh-pocket.layout');
|
|
31
|
+
return s === 'desktop' || s === 'mobile' ? s : '';
|
|
32
|
+
} catch {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 读 localStorage 当前的 layout 值('desktop' | 'mobile' | '')。 */
|
|
38
|
+
export function readStoredLayout() {
|
|
39
|
+
if (typeof localStorage === 'undefined') return '';
|
|
40
|
+
try {
|
|
41
|
+
const v = localStorage.getItem('dsh-pocket.layout');
|
|
42
|
+
return v === 'desktop' || v === 'mobile' ? v : '';
|
|
43
|
+
} catch {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** `mobileNav` namespace dictionaries: drawer controls. */
|
|
2
|
+
export const NS = 'mobileNav'
|
|
3
|
+
|
|
4
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
5
|
+
export const zh = {
|
|
6
|
+
'open': '打开目录',
|
|
7
|
+
'close': '收起目录',
|
|
8
|
+
'backdrop': '点击关闭目录',
|
|
9
|
+
'sessionLog': '导出会话日志',
|
|
10
|
+
'files': '文件浏览',
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
/** English dictionary, key-identical to the Chinese source of truth. */
|
|
14
|
+
export const en: Record<MobileNavKey, string> = {
|
|
15
|
+
'open': 'Open directory',
|
|
16
|
+
'close': 'Close directory',
|
|
17
|
+
'backdrop': 'Click to close directory',
|
|
18
|
+
'sessionLog': 'Session log',
|
|
19
|
+
'files': 'Files',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Key domain of the `mobileNav` namespace (zh is the source of truth). */
|
|
23
|
+
export type MobileNavKey = keyof typeof zh
|