dsh-zen-remote 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/AGENTS.md +51 -0
- package/LICENSE +38 -0
- package/README.md +237 -0
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +17 -0
- package/cordis.patch.yml.example +16 -0
- package/docs/interface.md +200 -0
- package/docs/remote-access.en.md +335 -0
- package/docs/remote-access.md +422 -0
- package/dsh-push.mjs +213 -0
- package/lan-gate.mjs +78 -0
- package/lib/client.js +6615 -0
- package/lib/index.js +310 -0
- package/lib/index.js.map +1 -0
- package/lib/lan-gate-server.cjs +626 -0
- package/lib/types/client/MobileAttachButton.d.ts +25 -0
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
- package/lib/types/client/MobileAttachChips.d.ts +32 -0
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
- package/lib/types/client/MobileHome.d.ts +24 -0
- package/lib/types/client/MobileHome.d.ts.map +1 -0
- package/lib/types/client/MobileHomeChips.d.ts +76 -0
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
- package/lib/types/client/MobileNavOverlay.d.ts +15 -0
- package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
- package/lib/types/client/MobileNavToggle.d.ts +18 -0
- package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
- package/lib/types/client/MobileSessionHeader.d.ts +58 -0
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
- package/lib/types/client/MobileSessionInfo.d.ts +41 -0
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +78 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -0
- package/lib/types/client/chips-store.d.ts +13 -0
- package/lib/types/client/chips-store.d.ts.map +1 -0
- package/lib/types/client/debug.d.ts +9 -0
- package/lib/types/client/debug.d.ts.map +1 -0
- package/lib/types/client/effects/aionui-compat.d.ts +4 -0
- package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
- package/lib/types/client/effects/gestures.d.ts +7 -0
- package/lib/types/client/effects/gestures.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts +15 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -0
- package/lib/types/client/effects/phone-chrome.d.ts +106 -0
- package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +25 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +58 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/nav-store.d.ts +73 -0
- package/lib/types/client/nav-store.d.ts.map +1 -0
- package/lib/types/client/session-dot.d.ts +12 -0
- package/lib/types/client/session-dot.d.ts.map +1 -0
- package/lib/types/client/styles/base.css.d.ts +2 -0
- package/lib/types/client/styles/base.css.d.ts.map +1 -0
- package/lib/types/client/styles/chips.css.d.ts +2 -0
- package/lib/types/client/styles/chips.css.d.ts.map +1 -0
- package/lib/types/client/styles/compat.css.d.ts +2 -0
- package/lib/types/client/styles/compat.css.d.ts.map +1 -0
- package/lib/types/client/styles/composer.css.d.ts +2 -0
- package/lib/types/client/styles/composer.css.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +2 -0
- package/lib/types/client/styles/header.css.d.ts.map +1 -0
- package/lib/types/client/styles/home.css.d.ts +2 -0
- package/lib/types/client/styles/home.css.d.ts.map +1 -0
- package/lib/types/client/styles/index.d.ts +15 -0
- package/lib/types/client/styles/index.d.ts.map +1 -0
- package/lib/types/client/styles/info.css.d.ts +2 -0
- package/lib/types/client/styles/info.css.d.ts.map +1 -0
- package/lib/types/client/styles/layout.css.d.ts +2 -0
- package/lib/types/client/styles/layout.css.d.ts.map +1 -0
- package/lib/types/client/styles/misc.css.d.ts +2 -0
- package/lib/types/client/styles/misc.css.d.ts.map +1 -0
- package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
- package/lib/types/index.d.ts +83 -0
- package/lib/types/index.d.ts.map +1 -0
- package/llms.txt +20 -0
- package/package.json +128 -0
- package/pwa/app.css +95 -0
- package/pwa/icons/icon-192.png +0 -0
- package/pwa/icons/icon-512.png +0 -0
- package/pwa/icons/icon-maskable-512.png +0 -0
- package/pwa/icons/icon-maskable.svg +1 -0
- package/pwa/icons/icon.svg +1 -0
- package/pwa/inject.js +190 -0
- package/pwa/manifest.json +29 -0
- package/pwa/offline.html +38 -0
- package/pwa/sw.js +176 -0
- package/pwa/touch-gestures.js +94 -0
- package/scripts/build-client.mjs +101 -0
- package/scripts/cdp-probe.mjs +64 -0
- package/scripts/check-attach-upload.mjs +99 -0
- package/scripts/check-sunk-viewport.mjs +33 -0
- package/scripts/check-upload-endpoint.mjs +168 -0
- package/src/client/MobileAttachButton.tsx +125 -0
- package/src/client/MobileAttachChips.tsx +85 -0
- package/src/client/MobileDrawerFooter.tsx +56 -0
- package/src/client/MobileHome.tsx +348 -0
- package/src/client/MobileHomeChips.tsx +426 -0
- package/src/client/MobileNavOverlay.tsx +279 -0
- package/src/client/MobileNavToggle.tsx +52 -0
- package/src/client/MobileSessionHeader.tsx +242 -0
- package/src/client/MobileSessionInfo.tsx +303 -0
- package/src/client/attach-upload.ts +140 -0
- package/src/client/chips-store.ts +41 -0
- package/src/client/debug.ts +253 -0
- package/src/client/effects/aionui-compat.ts +183 -0
- package/src/client/effects/gestures.ts +265 -0
- package/src/client/effects/header-status.ts +30 -0
- package/src/client/effects/phone-chrome.ts +265 -0
- package/src/client/effects/turn-fold.ts +258 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +109 -0
- package/src/client/nav-store.ts +78 -0
- package/src/client/session-dot.ts +17 -0
- package/src/client/styles/base.css.ts +167 -0
- package/src/client/styles/chips.css.ts +224 -0
- package/src/client/styles/compat.css.ts +681 -0
- package/src/client/styles/composer.css.ts +504 -0
- package/src/client/styles/header.css.ts +368 -0
- package/src/client/styles/home.css.ts +477 -0
- package/src/client/styles/index.ts +25 -0
- package/src/client/styles/info.css.ts +238 -0
- package/src/client/styles/layout.css.ts +501 -0
- package/src/client/styles/misc.css.ts +128 -0
- package/src/client/styles/turn-fold.css.ts +106 -0
- package/src/index.ts +343 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
IconChecklistOutline14,
|
|
4
|
+
IconCodeOutline16,
|
|
5
|
+
IconCordisPluginOutline14,
|
|
6
|
+
IconDataOutline16,
|
|
7
|
+
IconDownloadOutline16,
|
|
8
|
+
IconEllipsisOutline16,
|
|
9
|
+
IconPanelLeftOutline16,
|
|
10
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
11
|
+
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
|
12
|
+
import type { MobileNavKey } from './locales.ts'
|
|
13
|
+
import { isChipEnabled, toggleChip, useChipsPrefs } from './chips-store.ts'
|
|
14
|
+
|
|
15
|
+
/** One icon component's minimal shared shape (every `@deepseek-ai/dsh-client-ui-primitives` icon accepts `size`, regardless of the fixed number in its own name — see e.g. IconDownloadOutline16 used at size 14 elsewhere in this codebase). */
|
|
16
|
+
type IconFC = (props: { size?: number }) => React.JSX.Element
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One home-screen chip's static registration (S5). `selector` is the
|
|
20
|
+
* stable, non-hashed DOM anchor for the plugin's REAL entry button — the
|
|
21
|
+
* chip never reimplements the target feature, it just "代点" (synthetic
|
|
22
|
+
* `.click()`, bypasses hit-testing per the S2.1 precedent in AGENTS.md) the
|
|
23
|
+
* button the plugin itself already renders elsewhere in the tree, exactly
|
|
24
|
+
* like MobileSessionHeader's Chat/Trajectory tab click and the workbench
|
|
25
|
+
* button's `[data-dsh-better-sidebar] button[class$="_toggleButton"]`.
|
|
26
|
+
* `selector: null` marks the one chip (session log) whose availability and
|
|
27
|
+
* action come from injected props instead of a DOM probe — it is a
|
|
28
|
+
* first-party dsh-session-log-export service call, not a third-party
|
|
29
|
+
* plugin's button.
|
|
30
|
+
*/
|
|
31
|
+
export interface ChipDef {
|
|
32
|
+
id: string
|
|
33
|
+
label: MobileNavKey
|
|
34
|
+
Icon: IconFC
|
|
35
|
+
selector: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Task board / SSH entry buttons: not verified live in this environment (the
|
|
40
|
+
* "web" profile currently installs neither plugin — checked
|
|
41
|
+
* `~/.dsh/profiles/web/pnpm-lock.yaml`), but the exact selectors already
|
|
42
|
+
* appear in MobileNavOverlay.tsx's drawer-close-on-navigate matcher
|
|
43
|
+
* (`button[data-dsh-taskboard-entry]` / `button[data-dsh-ssh-entry]`), which
|
|
44
|
+
* an earlier slice already confirmed live against those plugins. Reused
|
|
45
|
+
* verbatim rather than re-derived. Both entries render inside the sidebar
|
|
46
|
+
* tree (same as the settings trigger below) but need no portal fix of their
|
|
47
|
+
* own: MobileNavOverlay's own comment classes them as "navigation" — they
|
|
48
|
+
* take over the MAIN content area rather than opening a dialog nested in
|
|
49
|
+
* the sidebar, so a plain `.click()` is enough once the sidebar's
|
|
50
|
+
* display:none stops mattering (only the SOURCE button needs to exist in
|
|
51
|
+
* the DOM for the synthetic click to dispatch, not be painted).
|
|
52
|
+
*/
|
|
53
|
+
const TASKBOARD_SELECTOR = 'button[data-dsh-taskboard-entry]'
|
|
54
|
+
const SSH_SELECTOR = 'button[data-dsh-ssh-entry]'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Better-sidebar's own workbench toggle — the exact selector
|
|
58
|
+
* MobileSessionHeader.tsx's header workbench button already clicks. Its
|
|
59
|
+
* panel is better-sidebar's own top-level mount (`[data-dsh-better-sidebar]`),
|
|
60
|
+
* not nested in our sidebar tree, so it needs no portal fix either.
|
|
61
|
+
*/
|
|
62
|
+
const FILES_SELECTOR = '[data-dsh-better-sidebar] button[class$="_toggleButton"]'
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* dsh-usage-stats' sidebar-footer badge (`sidebar.footer.action`, order 10 —
|
|
66
|
+
* AGENTS.md). Read straight from the installed plugin's own
|
|
67
|
+
* `~/.dsh/profiles/web/node_modules/dsh-usage-stats/lib/client.js`:
|
|
68
|
+
* `data-usage-stats-badge` on the trigger button, `data-usage-stats-panel`
|
|
69
|
+
* on its `position: fixed` result panel (no backdrop/mask, unlike settings).
|
|
70
|
+
* Both live inside the sidebar's `footerActions` row — display:none on the
|
|
71
|
+
* phone breakpoint's sidebar root (home.css.ts) hides them exactly like the
|
|
72
|
+
* settings dialog, so the badge's panel needs the SAME portal fix
|
|
73
|
+
* (styles/chips.css.ts, gated on `:has([data-usage-stats-panel])` alongside
|
|
74
|
+
* the settings gate on `:has([aria-modal="true"])`).
|
|
75
|
+
*/
|
|
76
|
+
const USAGE_SELECTOR = 'button[data-usage-stats-badge]'
|
|
77
|
+
|
|
78
|
+
/** Static chip registry (S5). Order here is the default row order. */
|
|
79
|
+
export const CHIP_DEFS: readonly ChipDef[] = [
|
|
80
|
+
{ id: 'taskboard', label: 'chipTaskboard', Icon: IconChecklistOutline14, selector: TASKBOARD_SELECTOR },
|
|
81
|
+
{ id: 'ssh', label: 'chipSsh', Icon: IconCodeOutline16, selector: SSH_SELECTOR },
|
|
82
|
+
{ id: 'files', label: 'files', Icon: IconPanelLeftOutline16, selector: FILES_SELECTOR },
|
|
83
|
+
{ id: 'usage', label: 'chipUsage', Icon: IconDataOutline16, selector: USAGE_SELECTOR },
|
|
84
|
+
{ id: 'sessionLog', label: 'sessionLog', Icon: IconDownloadOutline16, selector: null },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Live presence of each selector-backed chip's target button, refreshed on
|
|
89
|
+
* every DOM mutation (plugins mount their sidebar-footer entries
|
|
90
|
+
* asynchronously, same as the FAB-visibility / aionui-compat effects
|
|
91
|
+
* elsewhere in this plugin). Not gated by `mobile`/viewport — cheap, and
|
|
92
|
+
* MobileHome itself already returns null above 768px, so this hook's
|
|
93
|
+
* subscription lives only as long as the phone home screen does.
|
|
94
|
+
*/
|
|
95
|
+
function useDetectedIds(): ReadonlySet<string> {
|
|
96
|
+
const [detected, setDetected] = useState<ReadonlySet<string>>(() => new Set())
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const sync = (): void => {
|
|
99
|
+
const next = new Set<string>()
|
|
100
|
+
for (const def of CHIP_DEFS) {
|
|
101
|
+
if (def.selector !== null && document.querySelector(def.selector) !== null) next.add(def.id)
|
|
102
|
+
}
|
|
103
|
+
setDetected(next)
|
|
104
|
+
}
|
|
105
|
+
sync()
|
|
106
|
+
const observer = new MutationObserver(sync)
|
|
107
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
108
|
+
return () => observer.disconnect()
|
|
109
|
+
}, [])
|
|
110
|
+
return detected
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* S5.1: the live DOM anchor every third-party plugin's sidebar-footer entry
|
|
115
|
+
* mounts under (verified 2026-08-17 against the running dev profile —
|
|
116
|
+
* `renderSlot` wraps the list slot's rendered children in a
|
|
117
|
+
* `display: contents` DIV carrying this exact attribute, one plugin's
|
|
118
|
+
* output per DIRECT child). Unlike every other selector in this file this
|
|
119
|
+
* one is not a specific plugin's button — it is the harvest root.
|
|
120
|
+
*/
|
|
121
|
+
const FOOTER_ACTION_SLOT_SELECTOR = '[data-slot="sidebar.footer.action"]'
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One auto-discovered ("harvested") home-screen chip: some OTHER plugin's
|
|
125
|
+
* own sidebar-footer-action button, picked up live from the DOM instead of
|
|
126
|
+
* being hand-registered in {@link CHIP_DEFS}. See AGENTS.md's "Chip
|
|
127
|
+
* harvest" note for the mechanism and its known limits (locale-keyed
|
|
128
|
+
* `id`).
|
|
129
|
+
*/
|
|
130
|
+
interface HarvestedChip {
|
|
131
|
+
/** `harvest:` + the discovered name — the persisted-pref key AND the
|
|
132
|
+
* React list key. Not a slug: the name IS the stable-enough identity:
|
|
133
|
+
* switching the DSH UI language renames the source plugin's label, which
|
|
134
|
+
* mints a new id and orphans the old toggle preference (harmless, same
|
|
135
|
+
* as any other stale `ChipPrefs` key — chips-store.ts's `isChipEnabled`
|
|
136
|
+
* never errors on an unknown key). Accepted limitation, not fixed here. */
|
|
137
|
+
id: string
|
|
138
|
+
name: string
|
|
139
|
+
/** Sanitized cloned `<svg>…</svg>` markup, or `''` when the source
|
|
140
|
+
* button has no icon (falls back to {@link IconCordisPluginOutline14}). */
|
|
141
|
+
iconHtml: string
|
|
142
|
+
/** The plugin's OWN button/link — `.click()`ed directly, same "代点"
|
|
143
|
+
* precedent as every selector-backed {@link ChipDef}. */
|
|
144
|
+
el: HTMLElement
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `name` extraction order (S5.1 plan): visible text first, then the two
|
|
149
|
+
* standard fallbacks for icon-only controls. Returns `''` (caller skips
|
|
150
|
+
* the entry) when none of the three yield anything — an unnamed chip
|
|
151
|
+
* would be unreadable and untoggleable.
|
|
152
|
+
*/
|
|
153
|
+
function harvestName(el: HTMLElement): string {
|
|
154
|
+
const text = (el.textContent ?? '').trim()
|
|
155
|
+
if (text !== '') return text
|
|
156
|
+
const aria = el.getAttribute('aria-label')?.trim()
|
|
157
|
+
if (aria !== undefined && aria !== '') return aria
|
|
158
|
+
const title = el.getAttribute('title')?.trim()
|
|
159
|
+
if (title !== undefined && title !== '') return title
|
|
160
|
+
return ''
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Deep-clones the source icon and strips every `id` (source and
|
|
165
|
+
* descendants) so a colliding `<clipPath id="a">`/`url(#a)` pair between
|
|
166
|
+
* two harvested plugins' icons can never cross-reference. Serialized to a
|
|
167
|
+
* string (not kept as a live node) so React can own it via
|
|
168
|
+
* `dangerouslySetInnerHTML` — the source `<svg>` stays exactly where the
|
|
169
|
+
* other plugin's own React tree put it.
|
|
170
|
+
*/
|
|
171
|
+
function cloneIconHtml(svg: SVGElement): string {
|
|
172
|
+
const clone = svg.cloneNode(true) as SVGElement
|
|
173
|
+
clone.removeAttribute('id')
|
|
174
|
+
clone.querySelectorAll('[id]').forEach((node) => node.removeAttribute('id'))
|
|
175
|
+
const wrap = document.createElement('div')
|
|
176
|
+
wrap.appendChild(clone)
|
|
177
|
+
return wrap.innerHTML
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* True when `el` (or an ancestor up to and including `boundary`) looks like
|
|
182
|
+
* a transient dialog/overlay rather than a stable entry control: `role`
|
|
183
|
+
* `"dialog"`, `aria-modal="true"`, or a computed `position: fixed` (the
|
|
184
|
+
* shape every popup/panel in this codebase and its verified third-party
|
|
185
|
+
* plugins uses — settings, usage-stats' panel, scheduled-tasks' overlay).
|
|
186
|
+
* `getComputedStyle` resolves `position` from the cascade regardless of
|
|
187
|
+
* whether an ancestor is `display: none` (verified live 2026-08-17 against
|
|
188
|
+
* the hidden <768px sidebar), so this is reliable even though the subtree
|
|
189
|
+
* being scanned is not currently painted.
|
|
190
|
+
*/
|
|
191
|
+
function hasDialogAncestor(el: HTMLElement, boundary: HTMLElement): boolean {
|
|
192
|
+
let node: HTMLElement | null = el
|
|
193
|
+
while (node !== null) {
|
|
194
|
+
if (node.getAttribute('role') === 'dialog' || node.getAttribute('aria-modal') === 'true') return true
|
|
195
|
+
if (getComputedStyle(node).position === 'fixed') return true
|
|
196
|
+
if (node === boundary) break
|
|
197
|
+
node = node.parentElement
|
|
198
|
+
}
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The harvest scan (S5.1, stability fix S5.2 2026-08-17). Walks the DIRECT
|
|
204
|
+
* children of the footer-action slot root. The "one child = one plugin's
|
|
205
|
+
* entire output" assumption from S5.1 only holds when the plugin's
|
|
206
|
+
* component returns a single root element — it does NOT hold when a
|
|
207
|
+
* component returns a Fragment with multiple top-level nodes (e.g. a
|
|
208
|
+
* trigger button plus a conditionally-rendered dialog): the slot renderer
|
|
209
|
+
* has no per-entry wrapper, so a Fragment's second root node lands as its
|
|
210
|
+
* OWN direct child of the slot container the moment it mounts. Real-device
|
|
211
|
+
* report (2026-08-17): the "定时任务" (scheduled-tasks) plugin does exactly
|
|
212
|
+
* this — clicking its trigger inserts a NEW direct sibling child
|
|
213
|
+
* (`position: fixed` overlay + dialog) carrying several of its own buttons
|
|
214
|
+
* (close, "+ 新建任务", …), and the old querySelectorAll-everything scan
|
|
215
|
+
* harvested every one of them as a brand new chip on the next
|
|
216
|
+
* MutationObserver tick, while the dialog itself stayed invisible (nested
|
|
217
|
+
* under the <768px sidebar's `display: none` root — same class of bug
|
|
218
|
+
* chips.css.ts already portal-fixes for settings/usage-stats, see below).
|
|
219
|
+
*
|
|
220
|
+
* Three exclusions, in order:
|
|
221
|
+
* 1. **Self**: any child that IS or CONTAINS a `[data-mobile-nav]` node —
|
|
222
|
+
* every element this plugin itself renders carries that attribute
|
|
223
|
+
* (AGENTS.md Conventions), so this one check keeps the harvest from
|
|
224
|
+
* re-discovering our own Files/Session-log buttons as "new" chips.
|
|
225
|
+
* 2. **Dialog/overlay children**: a child that IS, or whose first clickable
|
|
226
|
+
* sits inside, a dialog-like element ({@link hasDialogAncestor}) is
|
|
227
|
+
* skipped ENTIRELY — such children are the transient expanded content of
|
|
228
|
+
* an entry this scan already harvested elsewhere (or will, from its own
|
|
229
|
+
* always-present trigger), not a new plugin's persistent entry point.
|
|
230
|
+
* 3. **Already precisely wired**: a harvested button that is the SAME DOM
|
|
231
|
+
* node a {@link CHIP_DEFS} selector already resolves to (dsh-usage-stats'
|
|
232
|
+
* badge is both — verified live) is dropped, so the precise entry (better
|
|
233
|
+
* icon/label) always wins and the row never shows the plugin twice.
|
|
234
|
+
*
|
|
235
|
+
* Identity is bound to the CHILD, not to individual buttons: at most one
|
|
236
|
+
* candidate — the first clickable in DOM order (`querySelector`, not
|
|
237
|
+
* `querySelectorAll`) — is taken per direct child. A child that later grows
|
|
238
|
+
* MORE buttons in its own subtree (an inline, non-dialog expansion) keeps
|
|
239
|
+
* resolving to the same first button on every rescan, so it never mints
|
|
240
|
+
* additional chips either.
|
|
241
|
+
*/
|
|
242
|
+
function scanHarvest(): HarvestedChip[] {
|
|
243
|
+
const container = document.querySelector(FOOTER_ACTION_SLOT_SELECTOR)
|
|
244
|
+
if (container === null) return []
|
|
245
|
+
const result: HarvestedChip[] = []
|
|
246
|
+
for (const raw of Array.from(container.children)) {
|
|
247
|
+
const child = raw as HTMLElement
|
|
248
|
+
if (child.hasAttribute('data-mobile-nav') || child.querySelector('[data-mobile-nav]') !== null) continue
|
|
249
|
+
const el: HTMLElement | null = child.matches('button, a[href]') ? child : child.querySelector('button, a[href]')
|
|
250
|
+
if (el === null) continue
|
|
251
|
+
if (hasDialogAncestor(el, child)) continue
|
|
252
|
+
if (CHIP_DEFS.some((def) => def.selector !== null && document.querySelector(def.selector) === el)) continue
|
|
253
|
+
const name = harvestName(el)
|
|
254
|
+
if (name === '') continue
|
|
255
|
+
const svg = el.querySelector('svg')
|
|
256
|
+
result.push({ id: `harvest:${name}`, name, iconHtml: svg === null ? '' : cloneIconHtml(svg), el })
|
|
257
|
+
}
|
|
258
|
+
return result
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Live harvested-chip list, refreshed on every DOM mutation — same
|
|
263
|
+
* MutationObserver shape as {@link useDetectedIds} right above (plugins
|
|
264
|
+
* mount their footer entries asynchronously; removing a plugin removes its
|
|
265
|
+
* entry the same way). Kept as a second, independent observer rather than
|
|
266
|
+
* folded into `useDetectedIds`: the two scans answer unrelated questions
|
|
267
|
+
* (is a KNOWN selector present vs. what's UNKNOWN in the slot) and this
|
|
268
|
+
* keeps each hook's diff small and easy to reason about on its own.
|
|
269
|
+
*/
|
|
270
|
+
function useHarvestedChips(): readonly HarvestedChip[] {
|
|
271
|
+
const [harvested, setHarvested] = useState<readonly HarvestedChip[]>(() => [])
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
const sync = (): void => setHarvested(scanHarvest())
|
|
274
|
+
sync()
|
|
275
|
+
const observer = new MutationObserver(sync)
|
|
276
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
277
|
+
return () => observer.disconnect()
|
|
278
|
+
}, [])
|
|
279
|
+
return harvested
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** One harvested chip's icon: the plugin's own (sanitized, cloned) SVG, or the generic plugin glyph when it has none. */
|
|
283
|
+
function HarvestIcon({ html }: { html: string }) {
|
|
284
|
+
if (html === '') return <IconCordisPluginOutline14 size={16} />
|
|
285
|
+
return <span data-mobile-nav="chip-harvest-icon" dangerouslySetInnerHTML={{ __html: html }} />
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Session-log's own availability/action, and everyone else's `.click()` target. */
|
|
289
|
+
function activate(def: ChipDef, sessionId: string | undefined, downloadSessionLog: (id: string) => void): void {
|
|
290
|
+
if (def.id === 'sessionLog') {
|
|
291
|
+
if (sessionId !== undefined) downloadSessionLog(sessionId)
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
if (def.selector !== null) document.querySelector<HTMLButtonElement>(def.selector)?.click()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Full props for the chip row. */
|
|
298
|
+
export interface MobileHomeChipsProps {
|
|
299
|
+
t: Translate<MobileNavKey>
|
|
300
|
+
sessionId: string | undefined
|
|
301
|
+
downloadSessionLog: (id: string) => void
|
|
302
|
+
onCustomize: () => void
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Plugin-entry chips row (S5): a horizontally-scrolling line of 34px pills
|
|
307
|
+
* between the workspace title and the session list, plus a trailing "···"
|
|
308
|
+
* that opens the customize sheet (MobileHomeChipsSheetBody below, rendered
|
|
309
|
+
* by MobileHome inside its existing home-sheet chrome). Every chip renders
|
|
310
|
+
* only when both true: the user has not hidden it (useChipsPrefs) AND its
|
|
311
|
+
* target actually exists right now (useDetectedIds / sessionId) — an
|
|
312
|
+
* uninstalled plugin's chip never appears, matching the plan's "按用户实装
|
|
313
|
+
* 插件逐个接入口". Chips beyond {@link CHIP_DEFS} (S5.1: any OTHER plugin's
|
|
314
|
+
* own sidebar-footer-action entry) are auto-discovered by
|
|
315
|
+
* {@link useHarvestedChips} and appended after the hand-registered ones —
|
|
316
|
+
* "装了新插件、chips 行零代码长出对应入口".
|
|
317
|
+
*/
|
|
318
|
+
export function MobileHomeChips({ t, sessionId, downloadSessionLog, onCustomize }: MobileHomeChipsProps) {
|
|
319
|
+
const detected = useDetectedIds()
|
|
320
|
+
const harvested = useHarvestedChips()
|
|
321
|
+
const prefs = useChipsPrefs()
|
|
322
|
+
const visible = CHIP_DEFS.filter((def) => {
|
|
323
|
+
if (!isChipEnabled(prefs, def.id)) return false
|
|
324
|
+
return def.id === 'sessionLog' ? sessionId !== undefined : detected.has(def.id)
|
|
325
|
+
})
|
|
326
|
+
const visibleHarvested = harvested.filter((h) => isChipEnabled(prefs, h.id))
|
|
327
|
+
return (
|
|
328
|
+
<div data-mobile-nav="chip-row">
|
|
329
|
+
{visible.map((def) => (
|
|
330
|
+
<button
|
|
331
|
+
key={def.id}
|
|
332
|
+
type="button"
|
|
333
|
+
data-mobile-nav="chip"
|
|
334
|
+
onClick={() => activate(def, sessionId, downloadSessionLog)}
|
|
335
|
+
>
|
|
336
|
+
<def.Icon size={16} />
|
|
337
|
+
<span>{t(def.label)}</span>
|
|
338
|
+
</button>
|
|
339
|
+
))}
|
|
340
|
+
{visibleHarvested.map((h) => (
|
|
341
|
+
<button key={h.id} type="button" data-mobile-nav="chip" onClick={() => h.el.click()}>
|
|
342
|
+
<HarvestIcon html={h.iconHtml} />
|
|
343
|
+
<span>{h.name}</span>
|
|
344
|
+
</button>
|
|
345
|
+
))}
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
data-mobile-nav="chip-more"
|
|
349
|
+
aria-label={t('chipCustomize')}
|
|
350
|
+
title={t('chipCustomize')}
|
|
351
|
+
onClick={onCustomize}
|
|
352
|
+
>
|
|
353
|
+
<IconEllipsisOutline16 size={16} />
|
|
354
|
+
</button>
|
|
355
|
+
</div>
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Customize-sheet body (S5, existence filter added 2026-08-17 real-device
|
|
361
|
+
* follow-up): one toggle row per chip whose target actually exists —
|
|
362
|
+
* `useDetectedIds()`, the SAME live probe `MobileHomeChips` uses for the row
|
|
363
|
+
* itself. Originally listed every `CHIP_DEFS` entry unconditionally, on the
|
|
364
|
+
* theory that a not-yet-installed plugin's toggle should still be
|
|
365
|
+
* pre-settable; real-device feedback was that this instead surfaces dead
|
|
366
|
+
* switches for plugins the user never installed (taskboard/ssh). Detection
|
|
367
|
+
* is a live MutationObserver subscription, so installing the plugin later
|
|
368
|
+
* still makes its toggle appear with no reload needed — "将来装了自动出现"
|
|
369
|
+
* survives the change, it just also stops showing before that. `sessionLog`
|
|
370
|
+
* (selector: null) is exempt: its existence is a runtime session-scope
|
|
371
|
+
* question, not a plugin-install question, so it always lists regardless of
|
|
372
|
+
* whether a session happens to be open right now. Stale prefs entries for a
|
|
373
|
+
* chip that no longer renders (e.g. an uninstalled plugin's old toggle
|
|
374
|
+
* value) are simply never read — `isChipEnabled`/`toggleChip`
|
|
375
|
+
* (chips-store.ts) key off `CHIP_DEFS` ids already, so leftover keys in the
|
|
376
|
+
* persisted object are inert, not an error. Rendered by MobileHome inside
|
|
377
|
+
* the SAME home-sheet-layer/mask/sheet chrome the workspace switcher uses
|
|
378
|
+
* (S6's drag-to-close and mask-click-close already bind generically to
|
|
379
|
+
* `[data-mobile-nav="home-sheet"]`, so this second use needs no new gesture
|
|
380
|
+
* wiring — see styles/chips.css.ts).
|
|
381
|
+
*/
|
|
382
|
+
export function MobileHomeChipsSheetBody({ t }: { t: Translate<MobileNavKey> }) {
|
|
383
|
+
const prefs = useChipsPrefs()
|
|
384
|
+
const detected = useDetectedIds()
|
|
385
|
+
const harvested = useHarvestedChips()
|
|
386
|
+
const listed = CHIP_DEFS.filter((def) => def.selector === null || detected.has(def.id))
|
|
387
|
+
return (
|
|
388
|
+
<>
|
|
389
|
+
<div data-mobile-nav="home-sheet-title">{t('chipCustomize')}</div>
|
|
390
|
+
{listed.map((def) => {
|
|
391
|
+
const enabled = isChipEnabled(prefs, def.id)
|
|
392
|
+
return (
|
|
393
|
+
<div key={def.id} data-mobile-nav="chip-toggle-row">
|
|
394
|
+
<def.Icon size={16} />
|
|
395
|
+
<span data-mobile-nav="chip-toggle-label">{t(def.label)}</span>
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
role="switch"
|
|
399
|
+
aria-checked={enabled}
|
|
400
|
+
aria-label={t(def.label)}
|
|
401
|
+
data-mobile-nav="chip-toggle"
|
|
402
|
+
onClick={() => toggleChip(def.id)}
|
|
403
|
+
/>
|
|
404
|
+
</div>
|
|
405
|
+
)
|
|
406
|
+
})}
|
|
407
|
+
{harvested.map((h) => {
|
|
408
|
+
const enabled = isChipEnabled(prefs, h.id)
|
|
409
|
+
return (
|
|
410
|
+
<div key={h.id} data-mobile-nav="chip-toggle-row">
|
|
411
|
+
<HarvestIcon html={h.iconHtml} />
|
|
412
|
+
<span data-mobile-nav="chip-toggle-label">{h.name}</span>
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
role="switch"
|
|
416
|
+
aria-checked={enabled}
|
|
417
|
+
aria-label={h.name}
|
|
418
|
+
data-mobile-nav="chip-toggle"
|
|
419
|
+
onClick={() => toggleChip(h.id)}
|
|
420
|
+
/>
|
|
421
|
+
</div>
|
|
422
|
+
)
|
|
423
|
+
})}
|
|
424
|
+
</>
|
|
425
|
+
)
|
|
426
|
+
}
|