dsh-zen-remote 2.2.4 → 2.2.6
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 +15 -3
- package/README.md +12 -4
- package/README.zh-CN.md +12 -4
- package/docs/interface.md +18 -1
- package/docs/plugin-bridge.md +1 -1
- package/docs/remote-access.en.md +10 -3
- package/docs/remote-access.md +10 -3
- package/lib/adapters/dsh-better-sidebar/index.js +28 -7
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/methods.js +2 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
- package/lib/client-routes.js +3 -0
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1164 -193
- package/lib/fetch-route-intercept.js +1 -1
- package/lib/index.js +73 -16
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +13 -0
- package/lib/intercept.js.map +1 -1
- package/lib/invoke-binary.js +8 -2
- package/lib/invoke-binary.js.map +1 -1
- package/lib/lan-gate-server.cjs +109 -2
- package/lib/owner-peer.js +23 -0
- package/lib/owner-peer.js.map +1 -1
- package/lib/plugin-adapter-core.js +2 -2
- package/lib/relay-access.js +24 -2
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +2 -2
- package/lib/relay-server.js +103 -100
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
- package/lib/types/client/MobileAttachButton.d.ts +5 -2
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/RemoteFileDownload.d.ts +57 -0
- package/lib/types/client/RemoteFileDownload.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +32 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +27 -0
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +11 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-file-save.d.ts +112 -0
- package/lib/types/client/remote-file-save.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/compat.css.d.ts +1 -1
- package/lib/types/client/styles/compat.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/home.css.d.ts +1 -1
- package/lib/types/client/styles/home.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +99 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +7 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +6 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/invoke-binary.d.ts +4 -1
- package/lib/types/invoke-binary.d.ts.map +1 -1
- package/lib/types/owner-peer.d.ts +9 -0
- package/lib/types/owner-peer.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +1 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +10 -7
- package/lib/types/relay-server.d.ts.map +1 -1
- package/package.json +37 -37
- package/pwa/inject.js +20 -9
- package/pwa/sw.js +97 -21
- package/scripts/check-attach-upload.mjs +37 -0
- package/scripts/check-remote-drop.mjs +114 -0
- package/scripts/check-settings-form.mjs +3 -2
- package/scripts/check-upload-endpoint.mjs +14 -3
- package/src/adapters/dsh-better-sidebar/index.ts +27 -7
- package/src/adapters/dsh-better-sidebar/methods.ts +2 -0
- package/src/client/MobileAttachButton.tsx +14 -9
- package/src/client/MobileAttachChips.tsx +4 -0
- package/src/client/MobileHome.tsx +79 -12
- package/src/client/RemoteFileDownload.tsx +173 -0
- package/src/client/attach-upload.ts +47 -0
- package/src/client/effects/remote-drop.ts +269 -0
- package/src/client/index.tsx +47 -0
- package/src/client/locales.ts +32 -0
- package/src/client/remote-file-save.ts +232 -0
- package/src/client/settings/SettingsSection.tsx +61 -1
- package/src/client/styles/compat.css.ts +43 -12
- package/src/client/styles/header.css.ts +63 -15
- package/src/client/styles/home.css.ts +39 -1
- package/src/client/styles/layout.css.ts +24 -7
- package/src/client/styles/remote-session.css.ts +38 -0
- package/src/client-data/remote-drop.ts +127 -0
- package/src/client-data/settings-form.ts +10 -0
- package/src/client-routes.ts +8 -0
- package/src/fetch-route-intercept.ts +1 -1
- package/src/index.ts +71 -18
- package/src/intercept.ts +13 -0
- package/src/invoke-binary.ts +8 -2
- package/src/owner-peer.ts +32 -0
- package/src/plugin-adapter-core.ts +2 -2
- package/src/relay-access.ts +24 -2
- package/src/relay-client.ts +2 -2
- package/src/relay-server.ts +114 -90
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Behaviour check for the remote-drop decision core
|
|
2
|
+
// (src/client-data/remote-drop.ts): the three-gate truth table, the
|
|
3
|
+
// directory / no-directory / no-entry-API three-state rule,
|
|
4
|
+
// block-directories precedence over redispatch, and the File re-wrap
|
|
5
|
+
// keeping name/type/lastModified.
|
|
6
|
+
//
|
|
7
|
+
// Run: node scripts/check-remote-drop.mjs (needs Node >= 23.6 type stripping)
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import {
|
|
11
|
+
decideRemoteDrop,
|
|
12
|
+
isRemoteSessionId,
|
|
13
|
+
rewrapFiles,
|
|
14
|
+
} from '../src/client-data/remote-drop.ts'
|
|
15
|
+
|
|
16
|
+
const REMOTE_ID = 'zr~0123abcd~session-1'
|
|
17
|
+
const LOCAL_ID = 'session-6f2e9a10-1111-2222-3333-444455556666'
|
|
18
|
+
const fileItem = { kind: 'file', isDirectory: false }
|
|
19
|
+
|
|
20
|
+
// ---- 1. isRemoteSessionId ---------------------------------------------------
|
|
21
|
+
assert.equal(isRemoteSessionId(REMOTE_ID), true, 'a virtual id is remote')
|
|
22
|
+
assert.equal(isRemoteSessionId(LOCAL_ID), false, 'a plain session id is not remote')
|
|
23
|
+
assert.equal(isRemoteSessionId(undefined), false, 'no signal is not remote')
|
|
24
|
+
assert.equal(isRemoteSessionId(''), false, 'an empty id is not remote')
|
|
25
|
+
assert.equal(isRemoteSessionId('zr~short'), false, 'the full zr~ skeleton is required, not the bare prefix')
|
|
26
|
+
|
|
27
|
+
// ---- 2. the three-gate truth table -------------------------------------------
|
|
28
|
+
// trusted x hasHostPaths x remote over one plain file item: exactly the
|
|
29
|
+
// all-true row intervenes, every failing gate lands on passthrough with its
|
|
30
|
+
// own reason (untrusted first, then no bridge, then local session).
|
|
31
|
+
const truthTable = [
|
|
32
|
+
[{ trusted: true, hasHostPaths: true, remote: true }, 'redispatch', 'remote-files'],
|
|
33
|
+
[{ trusted: true, hasHostPaths: true, remote: false }, 'passthrough', 'not-remote-session'],
|
|
34
|
+
[{ trusted: true, hasHostPaths: false, remote: true }, 'passthrough', 'no-host-paths'],
|
|
35
|
+
[{ trusted: true, hasHostPaths: false, remote: false }, 'passthrough', 'no-host-paths'],
|
|
36
|
+
[{ trusted: false, hasHostPaths: true, remote: true }, 'passthrough', 'untrusted-event'],
|
|
37
|
+
[{ trusted: false, hasHostPaths: true, remote: false }, 'passthrough', 'untrusted-event'],
|
|
38
|
+
[{ trusted: false, hasHostPaths: false, remote: true }, 'passthrough', 'untrusted-event'],
|
|
39
|
+
[{ trusted: false, hasHostPaths: false, remote: false }, 'passthrough', 'untrusted-event'],
|
|
40
|
+
]
|
|
41
|
+
for (const [gates, action, reason] of truthTable) {
|
|
42
|
+
const decision = decideRemoteDrop({
|
|
43
|
+
trusted: gates.trusted,
|
|
44
|
+
hasHostPaths: gates.hasHostPaths,
|
|
45
|
+
currentSessionId: gates.remote ? REMOTE_ID : LOCAL_ID,
|
|
46
|
+
items: [fileItem],
|
|
47
|
+
})
|
|
48
|
+
assert.deepEqual(decision, { action, reason }, `gates ${JSON.stringify(gates)}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- 3. directory / no-directory / no-entry-API -------------------------------
|
|
52
|
+
// No entry API: redispatch with the host's own "no directories known" default.
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: undefined }),
|
|
55
|
+
{ action: 'redispatch', reason: 'no-entry-api' },
|
|
56
|
+
)
|
|
57
|
+
// Files only: redispatch.
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [fileItem, fileItem] }),
|
|
60
|
+
{ action: 'redispatch', reason: 'remote-files' },
|
|
61
|
+
)
|
|
62
|
+
// Any directory anywhere in the list: block, even mixed with plain files.
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [fileItem, { kind: 'file', isDirectory: true }] }),
|
|
65
|
+
{ action: 'block-directories', reason: 'contains-directory' },
|
|
66
|
+
)
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [{ kind: 'file', isDirectory: true }] }),
|
|
69
|
+
{ action: 'block-directories', reason: 'contains-directory' },
|
|
70
|
+
)
|
|
71
|
+
// A file item whose entry cannot be read counts as NOT a directory.
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [{ kind: 'file', isDirectory: false }] }),
|
|
74
|
+
{ action: 'redispatch', reason: 'remote-files' },
|
|
75
|
+
)
|
|
76
|
+
// String-only drags (no file items): the host's Files-in-types gate ignores
|
|
77
|
+
// those anyway — passthrough, zero intervention.
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [{ kind: 'other', isDirectory: false }] }),
|
|
80
|
+
{ action: 'passthrough', reason: 'no-files' },
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// ---- 4. block-directories wins over redispatch ---------------------------------
|
|
84
|
+
// Already covered mixed above; pin it once more against the empty-items edge:
|
|
85
|
+
// a directory in an otherwise undecidable list still blocks.
|
|
86
|
+
assert.equal(
|
|
87
|
+
decideRemoteDrop({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: [{ kind: 'other', isDirectory: false }, { kind: 'file', isDirectory: true }] }).action,
|
|
88
|
+
'block-directories',
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// ---- 5. rewrapFiles keeps name / type / lastModified ----------------------------
|
|
92
|
+
const original = new File(['hello'], 'report.pdf', { type: 'application/pdf', lastModified: 1700000000000 })
|
|
93
|
+
const [rewrapped] = rewrapFiles([original])
|
|
94
|
+
assert.notEqual(rewrapped, original, 'the rewrap must produce a distinct File')
|
|
95
|
+
assert.equal(rewrapped.name, 'report.pdf')
|
|
96
|
+
assert.equal(rewrapped.type, 'application/pdf')
|
|
97
|
+
assert.equal(rewrapped.lastModified, 1700000000000)
|
|
98
|
+
assert.equal(rewrapped.size, original.size, 'the content must ride along')
|
|
99
|
+
// Multiple files keep their order, and any iterable source works.
|
|
100
|
+
const pair = rewrapFiles([original, new File([new Uint8Array([1, 2])], 'a.txt', { type: 'text/plain', lastModified: 1 })])
|
|
101
|
+
assert.deepEqual(pair.map((f) => f.name), ['report.pdf', 'a.txt'])
|
|
102
|
+
|
|
103
|
+
// ---- 6. the folder-block branch dismisses the host drop overlay ---------------
|
|
104
|
+
// When a directory drop is stopped outright (no synthetic drop is redispatched),
|
|
105
|
+
// the host's bubble drop handler never runs reset(); the guard must fire a
|
|
106
|
+
// synthetic window `dragend` via dismissHostDropOverlay() so the overlay closes.
|
|
107
|
+
{
|
|
108
|
+
const effects = readFileSync(new URL('../src/client/effects/remote-drop.ts', import.meta.url), 'utf8')
|
|
109
|
+
assert.ok(effects.includes('function dismissHostDropOverlay'), 'the guard defines dismissHostDropOverlay')
|
|
110
|
+
const calls = effects.match(/^\s*dismissHostDropOverlay\(\)\s*$/gm)?.length ?? 0
|
|
111
|
+
assert.equal(calls, 1, `the folder-block branch calls dismissHostDropOverlay() as a standalone statement (saw ${calls})`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log('check-remote-drop: ok')
|
|
@@ -457,8 +457,9 @@ test('the latest-wins gate drops tickets that are no longer newest (T15-fix 4)',
|
|
|
457
457
|
test('deriveClientStatusView maps every probe state and tolerates garbage', () => {
|
|
458
458
|
// T59: the view carries the device's own name ('' when the body had none).
|
|
459
459
|
// PB-5: the bridge rows ([] — the body carried none) and the rerouting
|
|
460
|
-
// note (undefined) ride the same tolerant derivation.
|
|
461
|
-
|
|
460
|
+
// note (undefined) ride the same tolerant derivation. RD-6: the
|
|
461
|
+
// upload-redirect probe (undefined — the body carried no serviceProbe).
|
|
462
|
+
const bare = { state: 'unpaired', serverUrl: '', serverName: '', deviceName: '', nextRetryAt: undefined, lastError: '', intercept: undefined, compat: undefined, pluginBridge: [], pluginRouteIntercept: undefined, uploadProbe: undefined }
|
|
462
463
|
assert.deepEqual(deriveClientStatusView({ state: 'unpaired' }), bare)
|
|
463
464
|
assert.deepEqual(deriveClientStatusView({ state: 'connected', serverUrl: 'http://192.168.1.10:3088' }), {
|
|
464
465
|
...bare,
|
|
@@ -89,6 +89,17 @@ try {
|
|
|
89
89
|
assert.equal(ok.body.bytes, Buffer.byteLength(payload))
|
|
90
90
|
assert.equal(await readFile(join(workspace, UPLOAD_DIR, 'notes.txt'), 'utf8'), payload)
|
|
91
91
|
|
|
92
|
+
// ---- the upload dir self-ignores in git ---------------------------------
|
|
93
|
+
// Files dropped into a session workspace would otherwise surface as
|
|
94
|
+
// untracked entries — and an agent's `git add -A` would commit the user's
|
|
95
|
+
// uploads. The directory ships a self-ignoring .gitignore created with the
|
|
96
|
+
// directory itself; a pre-existing (user-customized) one is never touched.
|
|
97
|
+
assert.equal(
|
|
98
|
+
await readFile(join(workspace, UPLOAD_DIR, '.gitignore'), 'utf8'),
|
|
99
|
+
'*\n!.gitignore\n',
|
|
100
|
+
'upload dir gets a self-ignoring .gitignore',
|
|
101
|
+
)
|
|
102
|
+
|
|
92
103
|
// ---- same name twice does not clobber -----------------------------------
|
|
93
104
|
const again = await post('?session=session-live&name=notes.txt', 'second')
|
|
94
105
|
assert.equal(again.status, 201)
|
|
@@ -165,7 +176,7 @@ try {
|
|
|
165
176
|
|
|
166
177
|
// ---- the workspace holds exactly what was uploaded, nothing partial ------
|
|
167
178
|
assert.deepEqual((await readdir(join(workspace, UPLOAD_DIR))).sort(), [
|
|
168
|
-
'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
|
|
179
|
+
'.gitignore', 'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
|
|
169
180
|
])
|
|
170
181
|
|
|
171
182
|
// ---- T17: the admission wall the apply() wiring passes in ----------------
|
|
@@ -205,8 +216,8 @@ try {
|
|
|
205
216
|
assert.equal(body.error.code, status === 401 ? 'unauthorized' : 'forbidden')
|
|
206
217
|
}
|
|
207
218
|
assert.deepEqual(await readdir(join(workspace, UPLOAD_DIR)), [
|
|
208
|
-
'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
|
|
209
|
-
], 'a refused admit writes nothing')
|
|
219
|
+
'.gitignore', 'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
|
|
220
|
+
], 'a refused admit writes nothing (the .gitignore predates it, created with the dir)')
|
|
210
221
|
assert.equal(logged, 7, 'the admission wall short-circuits before the logger, like the 405/403')
|
|
211
222
|
|
|
212
223
|
const allowed = await fetch(`${admitBases[2]}/_dsh/mobile-nav/upload?session=session-live&name=admitted.txt`, {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The dsh-better-sidebar adapter (PB-12), written against EXACTLY
|
|
3
|
-
* versions 0.25.0, 0.26.0
|
|
3
|
+
* versions 0.25.0, 0.26.0, 0.26.1, 0.28.0-alpha.0 and 0.28.0 (PB-15 widened the list; 0.26.1 is a
|
|
4
4
|
* perf-only patch with the same wire surface as 0.26.0; 0.26.0 is a JSON-api
|
|
5
5
|
* superset of 0.25.0 — it grows fs.readPage and the sidechat dequeue/model/
|
|
6
6
|
* models methods, and moves fs.write's content onto the new raw-text
|
|
@@ -38,7 +38,7 @@ import { SIDEBAR_METHODS, sidebarMethodNameOf } from './methods.js'
|
|
|
38
38
|
* modules — and pinned to the original by a test in
|
|
39
39
|
* test/adapter-better-sidebar.test.cjs, which fails the pair apart when
|
|
40
40
|
* either side moves. */
|
|
41
|
-
const RELAY_UPLOAD_CAP_BYTES =
|
|
41
|
+
const RELAY_UPLOAD_CAP_BYTES = 500 * 1024 * 1024
|
|
42
42
|
|
|
43
43
|
/** better-sidebar's own JSON body bound (lib/index.js:863
|
|
44
44
|
* `MAX_BODY_BYTES = 1 << 20`) — the adapter forwards at most what the
|
|
@@ -53,7 +53,16 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
|
|
|
53
53
|
// 0.26.1 (perf patch): JSON-api method set, routes and WebSocket paths
|
|
54
54
|
// identical to 0.26.0's wire surface (verified against the npm build;
|
|
55
55
|
// lib/index.js itself differs — a perf refactor, not byte-identical).
|
|
56
|
-
|
|
56
|
+
// 0.28.0-alpha.0 (DSH 0.2.1-alpha.5+): same routes and WebSockets; the
|
|
57
|
+
// JSON api grows `git.graph` (session-scoped read, no ids in the answer);
|
|
58
|
+
// `changes.ops` gains a `wait: true` long-poll (parks up to 25 s), which
|
|
59
|
+
// the server hook below strips — see there. Routes now admit through the
|
|
60
|
+
// host's `ctx.connection.requestRejection` (Host fence + signed cookie),
|
|
61
|
+
// which the loopback courier already satisfies (127.0.0.1 + cookie).
|
|
62
|
+
// 0.28.0 (stable, DSH 0.2.1-rc.1+): lib/index.js byte-identical to
|
|
63
|
+
// 0.28.0-alpha.0; the client bundles differ only in CSS-module map order
|
|
64
|
+
// and the service version string. alpha.0 stays for DSH 0.2.1-alpha.5.
|
|
65
|
+
versions: ['0.25.0', '0.26.0', '0.26.1', '0.28.0-alpha.0', '0.28.0'],
|
|
57
66
|
routes: [
|
|
58
67
|
{
|
|
59
68
|
// The JSON API: the method name rides the path tail, the session
|
|
@@ -149,10 +158,21 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
|
|
|
149
158
|
}
|
|
150
159
|
return { allow: true }
|
|
151
160
|
}
|
|
152
|
-
if (ctx.route.path === '/sidebar/api' && ctx.body !== null && typeof ctx.body === 'object' && !Array.isArray(ctx.body)
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
if (ctx.route.path === '/sidebar/api' && ctx.body !== null && typeof ctx.body === 'object' && !Array.isArray(ctx.body)) {
|
|
162
|
+
const record = ctx.body as Record<string, unknown>
|
|
163
|
+
// 0.28.0-alpha.0: a primed `changes.ops` asks with `wait: true` and the
|
|
164
|
+
// backend parks it up to 25 s — longer than the relay's 15 s header
|
|
165
|
+
// wait, so every bridged long-poll would die as request-timeout. The
|
|
166
|
+
// client polls on a fixed 2.5 s interval with an in-flight guard
|
|
167
|
+
// either way, so dropping `wait` degrades a remote session to the
|
|
168
|
+
// 0.26 cadence (same precedent as the unbridged ws/sidechat push).
|
|
169
|
+
const stripWait = apiMethodOf(ctx.pathname) === 'changes.ops' && 'wait' in record
|
|
170
|
+
if ('cwd' in record || stripWait) {
|
|
171
|
+
const stripped = { ...record }
|
|
172
|
+
delete stripped.cwd
|
|
173
|
+
if (stripWait) delete stripped.wait
|
|
174
|
+
return { allow: true, body: stripped }
|
|
175
|
+
}
|
|
156
176
|
}
|
|
157
177
|
return { allow: true }
|
|
158
178
|
},
|
|
@@ -113,6 +113,8 @@ export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
|
|
|
113
113
|
'git.diff-head': forward('read'),
|
|
114
114
|
'git.branch': forward('read'),
|
|
115
115
|
'git.log': forward('read'),
|
|
116
|
+
// 0.28.0-alpha.0:只读拓扑提交图(lib/index.js:8626),sessionId 定位,响应无会话 id。
|
|
117
|
+
'git.graph': forward('read'),
|
|
116
118
|
'git.commit-diff': forward('read'),
|
|
117
119
|
'git.show': forward('read'),
|
|
118
120
|
'git.blame': forward('read'),
|
|
@@ -4,7 +4,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
|
|
|
4
4
|
import { IconPaperclipOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
5
5
|
import type { SessionId } from './compat/types.ts'
|
|
6
6
|
import { NS } from './locales.ts'
|
|
7
|
-
import {
|
|
7
|
+
import { appendDraftOf, rememberThumbnail, uploadUrl, writeMention } from './attach-upload.ts'
|
|
8
8
|
import { useOwnAttachUi } from './host-attach.ts'
|
|
9
9
|
|
|
10
10
|
/** Full props for the composer attachment seat. */
|
|
@@ -49,8 +49,11 @@ async function uploadFile(sessionId: SessionId, file: File): Promise<string> {
|
|
|
49
49
|
* host without one (DSH ≤ 0.1.2) everything below loads exactly as before.
|
|
50
50
|
*
|
|
51
51
|
* Every picked file takes the SAME path: upload to the node half, then append
|
|
52
|
-
* `@.dsh-uploads/name` to the draft through
|
|
53
|
-
*
|
|
52
|
+
* `@.dsh-uploads/name` to the draft through the official write path — no DOM
|
|
53
|
+
* value poking: `inputActions.appendDraft` where the running DSH exposes it
|
|
54
|
+
* (0.2.1-rc.3+ editor runtime; keeps the user's text and inline references),
|
|
55
|
+
* else `inputActions.setDraft` with the whole next draft (attach-upload.ts
|
|
56
|
+
* `writeMention`). S7.1 removed the split that used
|
|
54
57
|
* to send inlineable images straight into the session with `session.prompt`:
|
|
55
58
|
* one behaviour for every attachment, and the user always presses send.
|
|
56
59
|
* MobileAttachChips renders the preview row off those same draft tokens.
|
|
@@ -84,19 +87,21 @@ export function MobileAttachButton({ t, sessionId, useInput, inputActions }: Mob
|
|
|
84
87
|
|
|
85
88
|
setBusy(true)
|
|
86
89
|
setError(null)
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// ref
|
|
90
|
+
// appendDraft (DSH 0.2.1-rc.3+) appends after whatever the composer holds
|
|
91
|
+
// right now, so no rebasing is needed. The setDraft fallback takes the
|
|
92
|
+
// WHOLE next draft, so each mention is appended to the value we last
|
|
93
|
+
// wrote — unless the ref shows the user has typed since, in which case
|
|
94
|
+
// theirs wins and we append to that instead. (Rebasing on the ref
|
|
95
|
+
// unconditionally would lose a mention whenever React had not yet
|
|
91
96
|
// re-rendered us between two files of the same pick.)
|
|
97
|
+
const appends = appendDraftOf(inputActions) !== undefined
|
|
92
98
|
let written = liveDraft.current
|
|
93
99
|
try {
|
|
94
100
|
for (const file of files) {
|
|
95
101
|
const relPath = await uploadFile(sessionId, file)
|
|
96
102
|
// Local preview for the chip row; a no-op for non-images.
|
|
97
103
|
rememberThumbnail(relPath, file)
|
|
98
|
-
written =
|
|
99
|
-
inputActions.setDraft(written)
|
|
104
|
+
written = writeMention(inputActions, (appends || liveDraft.current === written) ? written : liveDraft.current, relPath)
|
|
100
105
|
}
|
|
101
106
|
} catch (cause) {
|
|
102
107
|
setError(cause instanceof Error ? cause.message : String(cause))
|
|
@@ -80,6 +80,10 @@ export function MobileAttachChips({ t, useInput, inputActions }: MobileAttachChi
|
|
|
80
80
|
type="button"
|
|
81
81
|
data-mobile-nav="attach-chip-remove"
|
|
82
82
|
aria-label={t('attachRemove', { name })}
|
|
83
|
+
// Removal stays on setDraft even where the host has appendDraft
|
|
84
|
+
// (DSH 0.2.1-rc.3+): append can only add text, and the public
|
|
85
|
+
// inputActions face has no span-replace verb, so lifting one
|
|
86
|
+
// token out still means writing the whole next draft.
|
|
83
87
|
onClick={() => inputActions.setDraft(draftWithoutMention(draft, relPath))}
|
|
84
88
|
>
|
|
85
89
|
<IconCloseFillRegular size={14} />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import type { ReactNode, TouchEvent as ReactTouchEvent } from 'react'
|
|
3
3
|
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
|
4
4
|
import {
|
|
@@ -368,6 +368,23 @@ export function MobileHome({
|
|
|
368
368
|
return () => window.removeEventListener(GO_HOME_EVENT, onGoHome)
|
|
369
369
|
}, [actions])
|
|
370
370
|
|
|
371
|
+
// Mirror the current phone view onto the AppFrame element itself so CSS inside
|
|
372
|
+
// the main column (session header hide + fade strip gate) can key off a direct
|
|
373
|
+
// ancestor attribute instead of relying solely on cross-subtree body:has(...)
|
|
374
|
+
// invalidation on iOS WebKit (especially across installViewportHeal's display
|
|
375
|
+
// toggle on standalone PWA launch).
|
|
376
|
+
useLayoutEffect(() => {
|
|
377
|
+
const frame = document.querySelector<HTMLElement>('[data-mobile-nav="frame"]')
|
|
378
|
+
?? document.querySelector('[data-shell-overlay]')?.parentElement
|
|
379
|
+
?? null
|
|
380
|
+
if (!phone || frame === null) {
|
|
381
|
+
frame?.removeAttribute('data-mobile-view')
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
frame.setAttribute('data-mobile-view', view)
|
|
385
|
+
return () => frame.removeAttribute('data-mobile-view')
|
|
386
|
+
}, [phone, view])
|
|
387
|
+
|
|
371
388
|
const enter = (start: () => void): void => {
|
|
372
389
|
start()
|
|
373
390
|
setSheet(null)
|
|
@@ -376,19 +393,69 @@ export function MobileHome({
|
|
|
376
393
|
}
|
|
377
394
|
|
|
378
395
|
// Opens the OFFICIAL settings modal (dsh-client-ui-settings-general's
|
|
379
|
-
// SettingsRoot) by clicking its real trigger
|
|
396
|
+
// SettingsRoot) by clicking its real trigger — its "open" state is
|
|
380
397
|
// component-local React state with no public setter, same reasoning as
|
|
381
|
-
// MobileSessionHeader's Chat/Trajectory tab click. The trigger
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
398
|
+
// MobileSessionHeader's Chat/Trajectory tab click. The trigger lives in the
|
|
399
|
+
// sidebar's `sidebar.settings` footer slot (`[class$="_settingsArea"]`, the
|
|
400
|
+
// sidebar shell's stable class suffix — see dsh-client-ui-sidebar's
|
|
401
|
+
// SidebarRoot), which is display:none on the phone breakpoint
|
|
402
|
+
// (home.css.ts): `.click()` still dispatches (bypasses hit-testing, S2.1
|
|
403
|
+
// precedent) and flips `open`.
|
|
404
|
+
//
|
|
405
|
+
// DSH 0.2.1-rc.2 moved the entry: that slot now holds only the ACCOUNT
|
|
406
|
+
// launcher (aria-haspopup="menu"), and Settings is an item inside its menu
|
|
407
|
+
// — the `button[aria-haspopup="dialog"]` this used to forward to is gone
|
|
408
|
+
// (and the resulting dialog is a body portal now, so the chips.css.ts
|
|
409
|
+
// ancestor un-hide below no longer has anything to reveal). Hence the
|
|
410
|
+
// menu fallback; both paths keep the label-free, structure-free
|
|
411
|
+
// "dispatch a click on the official control" approach.
|
|
390
412
|
const openSettings = (): void => {
|
|
391
|
-
document.querySelector<
|
|
413
|
+
const area = document.querySelector<HTMLElement>('[class$="_settingsArea"]')
|
|
414
|
+
// Older DSH: the settings modal's own trigger sits in the settings area.
|
|
415
|
+
const direct = area?.querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]')
|
|
416
|
+
if (direct) {
|
|
417
|
+
direct.click()
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
// DSH 0.2.1-rc.2: the sidebar footer's settings area now holds only the
|
|
421
|
+
// ACCOUNT launcher (aria-haspopup="menu", data-signed-out), and 设置 /
|
|
422
|
+
// Settings is an item inside its menu — the dialog trigger this used to
|
|
423
|
+
// forward to no longer exists there, so the bare
|
|
424
|
+
// querySelector()?.click() silently no-oped (real-phone report
|
|
425
|
+
// 2026-10-21: "手机端右上角设置点了没反应"). Open the menu, then click the
|
|
426
|
+
// settings item.
|
|
427
|
+
//
|
|
428
|
+
// Two constraints from review: (1) menus are body portals and a sibling
|
|
429
|
+
// menu (session actions, model picker) may already be mounted, so the
|
|
430
|
+
// item must come from the menu THIS click opened — snapshot the existing
|
|
431
|
+
// ones and pick the newly mounted menu; (2) the item is located without
|
|
432
|
+
// depending on its label, because the account menu's copy is localized:
|
|
433
|
+
// 设置 / Settings is the FIRST menuitem in both the signed-in and the
|
|
434
|
+
// signed-out variant (AccountMenu items: settings, contact, signin/out),
|
|
435
|
+
// so the first item is the locale-independent anchor, with the label as
|
|
436
|
+
// a refinement if that order ever changes.
|
|
437
|
+
const launcher = area?.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"], button')
|
|
438
|
+
if (!launcher) return
|
|
439
|
+
const before = new Set<Element>(document.querySelectorAll('[role="menu"]'))
|
|
440
|
+
launcher.click()
|
|
441
|
+
const pick = (): boolean => {
|
|
442
|
+
const menu = [...document.querySelectorAll<HTMLElement>('[role="menu"]')].find((el) => !before.has(el))
|
|
443
|
+
if (!menu) return false
|
|
444
|
+
const items = [...menu.querySelectorAll<HTMLButtonElement>('button[role="menuitem"]')]
|
|
445
|
+
const item = items.find((el) => /设置|Settings/i.test(el.textContent || '')) ?? items[0]
|
|
446
|
+
if (!item) return false
|
|
447
|
+
item.click()
|
|
448
|
+
return true
|
|
449
|
+
}
|
|
450
|
+
// React commits the menu in the click's own task; observe for the mount
|
|
451
|
+
// (a deferred sibling launcher cannot leave a stale poll behind) and give
|
|
452
|
+
// up instead of leaking the observer if it never appears.
|
|
453
|
+
if (pick()) return
|
|
454
|
+
const observer = new MutationObserver(() => {
|
|
455
|
+
if (pick()) observer.disconnect()
|
|
456
|
+
})
|
|
457
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
458
|
+
setTimeout(() => observer.disconnect(), 1000)
|
|
392
459
|
}
|
|
393
460
|
|
|
394
461
|
if (!phone) return null
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "save this file to THIS machine" action for a REMOTE session (2.2.6).
|
|
3
|
+
*
|
|
4
|
+
* DSH's produced-file surfaces only ever OPEN a file: the deliverable card's
|
|
5
|
+
* overlay previews it in the document panel, and open-in-app's split button
|
|
6
|
+
* hands the path to the machine the DSH process runs on. On a paired desktop
|
|
7
|
+
* sub-client both of those are the HOST's machine, which is why the
|
|
8
|
+
* remote-session stylesheet hides the open controls — leaving a produced
|
|
9
|
+
* Word/PDF/zip with no way to reach the client's disk at all. This action
|
|
10
|
+
* fills exactly that gap: it reads the file through the relayed
|
|
11
|
+
* `workspaceFiles/readBytes` channel (src/client/remote-file-save.ts) and
|
|
12
|
+
* hands the bytes to the browser's download machinery, so the file lands on
|
|
13
|
+
* the machine whose screen the user is looking at.
|
|
14
|
+
*
|
|
15
|
+
* Two surfaces, one button:
|
|
16
|
+
*
|
|
17
|
+
* - `deliverables.file.actions` — the file card of a closing turn. The slot
|
|
18
|
+
* deliberately hands its entries only the open-route coordinates
|
|
19
|
+
* (`actionUrl`), NOT the path; the card's own preview button carries the
|
|
20
|
+
* resolved absolute path in its `title` attribute (RT
|
|
21
|
+
* dsh-client-ui-deliverables PresentedFileCard: `title:
|
|
22
|
+
* resolveWorkspacePath(cwd, file.path)`), and the entry mounts INSIDE that
|
|
23
|
+
* card (`[data-presented-file]`), so the button reads the same host-rendered
|
|
24
|
+
* contract the plugin's stylesheets already anchor on. A card without the
|
|
25
|
+
* attribute (a future host) simply renders no path and the button reports
|
|
26
|
+
* the failure instead of guessing.
|
|
27
|
+
* - `sidebar.right.tab.document.actions` — the document preview header, where
|
|
28
|
+
* the owner hands `absolutePath` directly (RT
|
|
29
|
+
* dsh-client-ui-sidebar-documentpreview TextPreview `fileOwner`), so the
|
|
30
|
+
* same file can be saved without hunting for the card again.
|
|
31
|
+
*
|
|
32
|
+
* Both registrations are unconditional; both COMPONENTS return null for a
|
|
33
|
+
* local session (`isVirtual`), so a host-role deployment — and every local
|
|
34
|
+
* session on a client deployment — renders exactly nothing.
|
|
35
|
+
*/
|
|
36
|
+
import { useRef, useState } from 'react'
|
|
37
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
38
|
+
import type { SessionId } from './compat/types.ts'
|
|
39
|
+
import { isVirtual } from '../virtual-id.js'
|
|
40
|
+
import { NS } from './locales.ts'
|
|
41
|
+
import { downloadPathFromCard } from './remote-file-save.ts'
|
|
42
|
+
import type { SaveRemoteFile } from './remote-file-save.ts'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Structural mirrors of the two host slot contracts. This package does not
|
|
46
|
+
* depend on dsh-client-ui-deliverables / dsh-client-ui-sidebar-documentpreview
|
|
47
|
+
* (the owners' type-only merges would normally provide these), so the mirror
|
|
48
|
+
* records ONLY what this action reads: both slots are session-scoped lists;
|
|
49
|
+
* the card carries the open-route coordinates, the document header carries
|
|
50
|
+
* the resolved absolute path.
|
|
51
|
+
*/
|
|
52
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
53
|
+
interface SlotMap {
|
|
54
|
+
'deliverables.file.actions': {
|
|
55
|
+
kind: 'list'
|
|
56
|
+
scope: 'session'
|
|
57
|
+
owner: {
|
|
58
|
+
actionUrl: string
|
|
59
|
+
available: boolean
|
|
60
|
+
pending: boolean
|
|
61
|
+
onAction: (action: string, application?: string) => unknown
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
'sidebar.right.tab.document.actions': {
|
|
65
|
+
kind: 'list'
|
|
66
|
+
scope: 'session'
|
|
67
|
+
owner: { absolutePath: string }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type DownloadPhase = 'idle' | 'busy' | 'error'
|
|
73
|
+
|
|
74
|
+
/** Translate seat of this plugin's namespace. */
|
|
75
|
+
type Translate = PropsLocale<typeof NS>['t']
|
|
76
|
+
|
|
77
|
+
/** The button's own inputs: the framework session id, the bound save
|
|
78
|
+
* capability, the copy, and — only on the document header — the absolute
|
|
79
|
+
* path; the card surface resolves its path from the card DOM. */
|
|
80
|
+
interface DownloadButtonProps {
|
|
81
|
+
sessionId: SessionId
|
|
82
|
+
t: Translate
|
|
83
|
+
saveFile: SaveRemoteFile
|
|
84
|
+
absolutePath?: string | undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The save control. One download at a time; the label carries the three
|
|
89
|
+
* states (idle / saving / failed) so a failure is visible on the card instead
|
|
90
|
+
* of a silent no-op.
|
|
91
|
+
* @param props - session, copy, save capability and (header only) the path.
|
|
92
|
+
* @returns the button.
|
|
93
|
+
*/
|
|
94
|
+
function DownloadButton({ sessionId, t, saveFile, absolutePath }: DownloadButtonProps) {
|
|
95
|
+
const [phase, setPhase] = useState<DownloadPhase>('idle')
|
|
96
|
+
const [failure, setFailure] = useState<string | undefined>(undefined)
|
|
97
|
+
const buttonRef = useRef<HTMLButtonElement | null>(null)
|
|
98
|
+
const onClick = async (): Promise<void> => {
|
|
99
|
+
if (phase === 'busy') return
|
|
100
|
+
const path = absolutePath ?? downloadPathFromCard(buttonRef.current)
|
|
101
|
+
setFailure(undefined)
|
|
102
|
+
if (path === undefined) {
|
|
103
|
+
setFailure('no-path')
|
|
104
|
+
setPhase('error')
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
setPhase('busy')
|
|
108
|
+
try {
|
|
109
|
+
await saveFile(sessionId, path)
|
|
110
|
+
setPhase('idle')
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// The reason is rendered INSIDE the button (short) and logged for the
|
|
113
|
+
// console: without it a failed download on a client we cannot debug is
|
|
114
|
+
// a dead end.
|
|
115
|
+
const reason = (error instanceof Error ? error.message : String(error)).trim()
|
|
116
|
+
console.error('[dsh-zen-remote] remote download failed:', path, error)
|
|
117
|
+
setFailure(reason === '' ? 'unknown' : reason.slice(0, 22))
|
|
118
|
+
setPhase('error')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const label = phase === 'busy' ? t('downloadBusy') : phase === 'error' ? (failure === undefined ? t('downloadFailed') : `${t('downloadFailed')} (${failure})`) : t('downloadToLocal')
|
|
122
|
+
return (
|
|
123
|
+
<button
|
|
124
|
+
ref={buttonRef}
|
|
125
|
+
type="button"
|
|
126
|
+
className="zr-download-action"
|
|
127
|
+
data-phase={phase}
|
|
128
|
+
aria-label={t('downloadToLocal')}
|
|
129
|
+
title={failure === undefined ? t('downloadToLocal') : `${t('downloadToLocal')} — ${failure}`}
|
|
130
|
+
onClick={() => {
|
|
131
|
+
void onClick()
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{label}
|
|
135
|
+
</button>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Props the delivered-file card entry receives. */
|
|
140
|
+
export interface DeliverableDownloadProps
|
|
141
|
+
extends PropsRuntime<'deliverables.file.actions'>, PropsLocale<typeof NS> {
|
|
142
|
+
/** Bound by this plugin's registration: the relayed read+save capability. */
|
|
143
|
+
saveFile: SaveRemoteFile
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The deliverable card entry. Renders nothing on a local session.
|
|
148
|
+
* @param props - the card slot's owner props plus the injected save
|
|
149
|
+
* capability and copy.
|
|
150
|
+
* @returns the save button, or null.
|
|
151
|
+
*/
|
|
152
|
+
export function DeliverableDownload({ sessionId, t, saveFile }: DeliverableDownloadProps) {
|
|
153
|
+
if (!isVirtual(sessionId)) return null
|
|
154
|
+
return <DownloadButton sessionId={sessionId} t={t} saveFile={saveFile} />
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Props the document-preview header entry receives. */
|
|
158
|
+
export interface DocumentDownloadProps
|
|
159
|
+
extends PropsRuntime<'sidebar.right.tab.document.actions'>, PropsLocale<typeof NS> {
|
|
160
|
+
/** Bound by this plugin's registration: the relayed read+save capability. */
|
|
161
|
+
saveFile: SaveRemoteFile
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The document preview header entry. Renders nothing on a local session.
|
|
166
|
+
* @param props - the document slot's owner props plus the injected save
|
|
167
|
+
* capability and copy.
|
|
168
|
+
* @returns the save button, or null.
|
|
169
|
+
*/
|
|
170
|
+
export function DocumentDownload({ sessionId, t, saveFile, absolutePath }: DocumentDownloadProps) {
|
|
171
|
+
if (!isVirtual(sessionId)) return null
|
|
172
|
+
return <DownloadButton sessionId={sessionId} t={t} saveFile={saveFile} absolutePath={absolutePath} />
|
|
173
|
+
}
|
|
@@ -63,6 +63,53 @@ export function draftWithMention(draft: string, relPath: string): string {
|
|
|
63
63
|
return `${draft} @${relPath} `
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The two draft writers the attachment flow can use. `setDraft` replaces the
|
|
68
|
+
* WHOLE draft with plain text (and, on DSH 0.2.1+, drops any inline reference
|
|
69
|
+
* chips the user had in it); `appendDraft` adds text after the current draft
|
|
70
|
+
* as new paragraphs and keeps its text and references. DSH 0.2.1-rc.3 grew
|
|
71
|
+
* `appendDraft` on the composer editor runtime; it is optional here because
|
|
72
|
+
* older hosts — and, as of rc.3, the public slot `inputActions` face itself —
|
|
73
|
+
* do not carry it, so it is feature-detected at runtime, never assumed.
|
|
74
|
+
*/
|
|
75
|
+
export interface DraftWriter {
|
|
76
|
+
setDraft(text: string): void
|
|
77
|
+
appendDraft?: (text: string) => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The host's non-destructive append, when the running DSH exposes one.
|
|
82
|
+
* @param actions - the slot's `inputActions` (or any object carrying them).
|
|
83
|
+
* @returns a bound `appendDraft`, or undefined when the host has none.
|
|
84
|
+
*/
|
|
85
|
+
export function appendDraftOf(actions: object): ((text: string) => void) | undefined {
|
|
86
|
+
const append = (actions as { appendDraft?: unknown }).appendDraft
|
|
87
|
+
return typeof append === 'function' ? (append as (text: string) => void).bind(actions) : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Write one `@path` mention into the composer.
|
|
92
|
+
*
|
|
93
|
+
* Prefers `appendDraft` (no whole-draft rewrite, so nothing the user typed or
|
|
94
|
+
* referenced meanwhile can be lost); falls back to `setDraft` with
|
|
95
|
+
* {@link draftWithMention} over `base` on a host without it.
|
|
96
|
+
* @param actions - the draft writers.
|
|
97
|
+
* @param base - the draft to rebase onto for the `setDraft` fallback.
|
|
98
|
+
* @param relPath - workspace-relative path returned by the upload route.
|
|
99
|
+
* @returns the draft text the fallback wrote, or `base` unchanged when the
|
|
100
|
+
* host appended for us (the next render's `useInput` carries the result).
|
|
101
|
+
*/
|
|
102
|
+
export function writeMention(actions: DraftWriter, base: string, relPath: string): string {
|
|
103
|
+
const append = appendDraftOf(actions)
|
|
104
|
+
if (append !== undefined) {
|
|
105
|
+
append(`@${relPath} `)
|
|
106
|
+
return base
|
|
107
|
+
}
|
|
108
|
+
const next = draftWithMention(base, relPath)
|
|
109
|
+
actions.setDraft(next)
|
|
110
|
+
return next
|
|
111
|
+
}
|
|
112
|
+
|
|
66
113
|
/**
|
|
67
114
|
* Remove one `@path` mention from the draft — what a chip's × does.
|
|
68
115
|
*
|