@linxin666/dsh-ssh 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +371 -461
- package/lib/client.js.map +1 -1
- package/lib/index.js +707 -466
- package/lib/types/client/api.d.ts +55 -4
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/native-panel.d.ts +70 -0
- package/lib/types/client/panel/SshPanel.d.ts +1 -3
- package/lib/types/client/panel/TerminalTab.d.ts +6 -1
- package/lib/types/client/panel/controller.d.ts +71 -4
- package/lib/types/engine/terminal-sessions.d.ts +67 -0
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +12 -1
- package/lib/types/routes.d.ts +2 -0
- package/package.json +19 -16
- package/src/client/api.ts +137 -24
- package/src/client/index.ts +31 -9
- package/src/client/locales.ts +2 -0
- package/src/client/native-panel.tsx +141 -0
- package/src/client/panel/SshPanel.tsx +25 -28
- package/src/client/panel/TerminalTab.tsx +54 -14
- package/src/client/panel/controller.ts +126 -7
- package/src/client/panel/panel.module.css +8 -128
- package/src/engine/terminal-sessions.ts +280 -0
- package/src/index.ts +4 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +6 -1
- package/src/routes.ts +26 -107
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/mount.d.ts +0 -27
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -26
- package/src/client/body-mutations.ts +0 -133
- package/src/client/mount.tsx +0 -47
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -51
package/src/mount-once.ts
CHANGED
|
@@ -5,44 +5,138 @@
|
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
-
* and fail the boot. mountOnce makes
|
|
9
|
-
*
|
|
10
|
-
* package name in the client module host).
|
|
8
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
9
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
10
|
+
* deduped by package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
13
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
14
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
15
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
16
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
17
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
18
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
19
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
20
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
21
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
22
|
+
* restart recovered it).
|
|
23
|
+
*
|
|
24
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
25
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
26
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
27
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
28
|
+
* dedupes against it.
|
|
11
29
|
*
|
|
12
30
|
* The registry rides a global symbol so two module instances of the same
|
|
13
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
14
|
-
*
|
|
31
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
32
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
33
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
34
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
35
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
36
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
37
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
38
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
39
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
40
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
41
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
42
|
+
* mounted after it failed with "claims.get is not a function".
|
|
43
|
+
*
|
|
44
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
45
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
46
|
*/
|
|
17
47
|
|
|
48
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
18
49
|
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
19
50
|
|
|
51
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
52
|
+
const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters')
|
|
53
|
+
|
|
54
|
+
/** The slice of a cordis context this guard touches. */
|
|
55
|
+
interface MountContext {
|
|
56
|
+
effect?: (effect: () => unknown) => unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One mount that was refused while another fiber owned the package name. */
|
|
60
|
+
interface PendingMount {
|
|
61
|
+
/** Replay the refused mount; a no-op once its own fiber has been disposed. */
|
|
62
|
+
run(): void
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
interface MountRegistry {
|
|
21
|
-
[MOUNTED]?:
|
|
66
|
+
[MOUNTED]?: unknown
|
|
67
|
+
[WAITERS]?: Map<string, PendingMount[]>
|
|
22
68
|
}
|
|
23
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
72
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
73
|
+
* family plugin down with it.
|
|
74
|
+
* @returns the process-wide set of mounted package names.
|
|
75
|
+
*/
|
|
24
76
|
function mountedSet(): Set<string> {
|
|
25
77
|
const registry = globalThis as MountRegistry
|
|
26
|
-
|
|
78
|
+
const existing = registry[MOUNTED]
|
|
79
|
+
if (existing instanceof Set) return existing as Set<string>
|
|
80
|
+
const created = new Set<string>()
|
|
81
|
+
registry[MOUNTED] = created
|
|
82
|
+
return created
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
86
|
+
function mountWaiters(): Map<string, PendingMount[]> {
|
|
87
|
+
const registry = globalThis as MountRegistry
|
|
88
|
+
return (registry[WAITERS] ??= new Map())
|
|
27
89
|
}
|
|
28
90
|
|
|
29
91
|
/**
|
|
30
92
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
-
* The first mount registers normally and
|
|
32
|
-
*
|
|
93
|
+
* The first mount registers normally and releases the name when its fiber
|
|
94
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
95
|
+
* then runs, unless its own fiber disposes first.
|
|
33
96
|
* @param packageName - npm package identity shared by every install source.
|
|
34
97
|
* @param fn - the original plugin apply.
|
|
35
98
|
* @returns an apply of the same shape.
|
|
36
99
|
*/
|
|
37
100
|
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
-
|
|
101
|
+
const mount = (...args: unknown[]): unknown => {
|
|
39
102
|
const mounted = mountedSet()
|
|
40
|
-
|
|
103
|
+
const ctx = args[0] as MountContext | undefined
|
|
104
|
+
if (mounted.has(packageName)) {
|
|
105
|
+
// Another fiber (the aggregate row, a standalone install, or a copy built
|
|
106
|
+
// from an older revision of this guard) owns the name. Queue instead of
|
|
107
|
+
// dropping: dropping lost the plugin for the rest of the process when the
|
|
108
|
+
// holder was disposed by a reload.
|
|
109
|
+
const waiters = mountWaiters()
|
|
110
|
+
const queue = waiters.get(packageName) ?? []
|
|
111
|
+
let alive = true
|
|
112
|
+
const pending: PendingMount = {
|
|
113
|
+
run: () => {
|
|
114
|
+
if (alive) mount(...args)
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
// A fiber that dies while it waits must not be replayed afterwards: its
|
|
118
|
+
// context is disposed and registering effects on it would throw.
|
|
119
|
+
ctx?.effect?.(() => () => {
|
|
120
|
+
alive = false
|
|
121
|
+
const index = queue.indexOf(pending)
|
|
122
|
+
if (index >= 0) queue.splice(index, 1)
|
|
123
|
+
})
|
|
124
|
+
queue.push(pending)
|
|
125
|
+
waiters.set(packageName, queue)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
41
128
|
mounted.add(packageName)
|
|
42
|
-
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
129
|
ctx?.effect?.(() => () => {
|
|
44
130
|
mounted.delete(packageName)
|
|
131
|
+
const waiters = mountWaiters()
|
|
132
|
+
const queue = waiters.get(packageName)
|
|
133
|
+
if (queue === undefined) return
|
|
134
|
+
waiters.delete(packageName)
|
|
135
|
+
// Defer the handover one microtask so the disposing mount's own effects
|
|
136
|
+
// (routes, ledger, timers) are torn down before the next one registers.
|
|
137
|
+
for (const waiter of queue.splice(0)) queueMicrotask(() => { waiter.run() })
|
|
45
138
|
})
|
|
46
139
|
return fn(...args)
|
|
47
|
-
}
|
|
140
|
+
}
|
|
141
|
+
return mount as T
|
|
48
142
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -188,7 +188,8 @@ export interface ApiErrorBody {
|
|
|
188
188
|
|
|
189
189
|
/** WebSocket terminal protocol frames (host -> client and client -> host). */
|
|
190
190
|
export type TerminalServerFrame =
|
|
191
|
-
|
|
191
|
+
/** The shell is up; `sessionId` reattaches to it after a view detach. */
|
|
192
|
+
| { type: 'ready'; alias: string; sessionId: string }
|
|
192
193
|
| { type: 'output'; data: string }
|
|
193
194
|
| { type: 'exit'; code: number | null; error?: string }
|
|
194
195
|
| {
|
|
@@ -202,6 +203,10 @@ export type TerminalClientFrame =
|
|
|
202
203
|
| { type: 'input'; data: string }
|
|
203
204
|
| { type: 'resize'; cols: number; rows: number }
|
|
204
205
|
| { type: 'auth_response'; responses: string[] }
|
|
206
|
+
/** Leave the socket while the host keeps the shell alive for a reattach. */
|
|
207
|
+
| { type: 'detach' }
|
|
208
|
+
/** End the session outright (the view's disconnect control). */
|
|
209
|
+
| { type: 'close' }
|
|
205
210
|
|
|
206
211
|
/** Route paths the client calls (shared literals). */
|
|
207
212
|
export const SSH_API_BASE = '/api/dsh-ssh' as const
|
package/src/routes.ts
CHANGED
|
@@ -15,10 +15,11 @@ import { tmpdir } from 'node:os'
|
|
|
15
15
|
import { randomBytes } from 'node:crypto'
|
|
16
16
|
import { WebSocket, WebSocketServer } from 'ws'
|
|
17
17
|
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
18
|
-
import type { SshEngine
|
|
18
|
+
import type { SshEngine } from './engine.ts'
|
|
19
|
+
import { TerminalSessionRegistry } from './engine/terminal-sessions.ts'
|
|
19
20
|
import { readJsonBody, writeJson } from './http.ts'
|
|
20
21
|
import { isLoopbackRequest } from './loopback.ts'
|
|
21
|
-
import { SSH_API, type HostPayload, type
|
|
22
|
+
import { SSH_API, type HostPayload, type TerminalServerFrame } from './protocol.ts'
|
|
22
23
|
import type { HostStore } from './store.ts'
|
|
23
24
|
|
|
24
25
|
/** Cap on declared upload bodies (staged to disk before SFTP). */
|
|
@@ -31,12 +32,6 @@ const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
|
|
|
31
32
|
*/
|
|
32
33
|
const terminalWss = new WebSocketServer({ noServer: true })
|
|
33
34
|
|
|
34
|
-
/** Pause the shell when the socket's send buffer exceeds this… */
|
|
35
|
-
const BACKPRESSURE_HIGH_WATER = 1024 * 1024
|
|
36
|
-
|
|
37
|
-
/** …and resume once it drains below this. */
|
|
38
|
-
const BACKPRESSURE_LOW_WATER = 512 * 1024
|
|
39
|
-
|
|
40
35
|
/** URL query helper (first value, decoded). */
|
|
41
36
|
function queryParam(url: URL, name: string): string | undefined {
|
|
42
37
|
const value = url.searchParams.get(name)
|
|
@@ -60,7 +55,7 @@ maxUploadBytes?: number
|
|
|
60
55
|
* @param deps - store, engine, staging dir.
|
|
61
56
|
* @returns routes and the upgrade route.
|
|
62
57
|
*/
|
|
63
|
-
export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
|
|
58
|
+
export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute; terminalSessions: TerminalSessionRegistry } {
|
|
64
59
|
const { store, engine } = deps
|
|
65
60
|
const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
|
|
66
61
|
const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
@@ -452,6 +447,12 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
452
447
|
]
|
|
453
448
|
|
|
454
449
|
// ---------------------------------------------- terminal (upgrade)
|
|
450
|
+
// The shell outlives the view that opened it: a session is created once and
|
|
451
|
+
// any later socket attaches by id, so a panel switch or a dropped socket
|
|
452
|
+
// does not tear the remote shell down (engine/terminal-sessions.ts).
|
|
453
|
+
const terminalSessions = new TerminalSessionRegistry({
|
|
454
|
+
openShell: (alias, size, onKeyboardInteractive) => engine.openShell(alias, size, onKeyboardInteractive),
|
|
455
|
+
})
|
|
455
456
|
const upgrade: WebUpgradeRoute = {
|
|
456
457
|
path: SSH_API.terminal,
|
|
457
458
|
handler: (req, socket, head) => {
|
|
@@ -462,114 +463,32 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
462
463
|
}
|
|
463
464
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
464
465
|
const alias = queryParam(url, 'alias')
|
|
465
|
-
|
|
466
|
+
const sessionId = queryParam(url, 'session')
|
|
467
|
+
if (alias === undefined && sessionId === undefined) {
|
|
466
468
|
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
|
|
467
469
|
socket.destroy()
|
|
468
470
|
return
|
|
469
471
|
}
|
|
470
472
|
const cols = Number.parseInt(queryParam(url, 'cols') ?? '80', 10)
|
|
471
473
|
const rows = Number.parseInt(queryParam(url, 'rows') ?? '24', 10)
|
|
474
|
+
const size = {
|
|
475
|
+
cols: Number.isFinite(cols) ? cols : 80,
|
|
476
|
+
rows: Number.isFinite(rows) ? rows : 24,
|
|
477
|
+
}
|
|
472
478
|
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const resume = (): void => {
|
|
480
|
-
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
|
|
481
|
-
paused = false
|
|
482
|
-
session?.resume()
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
const sendFrame = (frame: TerminalServerFrame): void => {
|
|
486
|
-
if (closed || ws.readyState !== WebSocket.OPEN) return
|
|
487
|
-
ws.send(JSON.stringify(frame), resume)
|
|
488
|
-
if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
|
|
489
|
-
paused = true
|
|
490
|
-
session?.pause()
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
const closeSession = (): void => {
|
|
494
|
-
if (pendingAuthFinish !== undefined) {
|
|
495
|
-
const fn = pendingAuthFinish
|
|
496
|
-
pendingAuthFinish = undefined
|
|
497
|
-
try { fn([]) } catch { /* ignore */ }
|
|
498
|
-
}
|
|
499
|
-
const opened = session
|
|
500
|
-
session = undefined
|
|
501
|
-
if (opened !== undefined) opened.close()
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const onKeyboardInteractive: KeyboardInteractiveHandler = (name, instructions, _lang, prompts, finish) => {
|
|
505
|
-
const entry = store.find(alias)
|
|
506
|
-
// Auto-answer password if configured and prompt is a password request
|
|
507
|
-
if (entry?.auth.kind === 'password' && entry.auth.password !== undefined && prompts.length > 0 && prompts.every(p => /password/i.test(p.prompt))) {
|
|
508
|
-
const password = entry.auth.password
|
|
509
|
-
finish(prompts.map(() => password))
|
|
510
|
-
return
|
|
511
|
-
}
|
|
512
|
-
// Interactive 2FA prompt sent to the client terminal
|
|
513
|
-
pendingAuthFinish = finish
|
|
514
|
-
sendFrame({
|
|
515
|
-
type: 'auth_prompt',
|
|
516
|
-
name,
|
|
517
|
-
instructions,
|
|
518
|
-
prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
|
519
|
-
})
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
engine.openShell(alias, {
|
|
523
|
-
cols: Number.isFinite(cols) ? cols : 80,
|
|
524
|
-
rows: Number.isFinite(rows) ? rows : 24,
|
|
525
|
-
}, onKeyboardInteractive).then((opened) => {
|
|
526
|
-
if (ws.readyState !== WebSocket.OPEN) {
|
|
527
|
-
opened.close()
|
|
528
|
-
return
|
|
529
|
-
}
|
|
530
|
-
session = opened
|
|
531
|
-
sendFrame({ type: 'ready', alias })
|
|
532
|
-
opened.onData = (data) => sendFrame({ type: 'output', data: data.toString('utf8') })
|
|
533
|
-
opened.onExit = (code, error) => {
|
|
534
|
-
sendFrame({ type: 'exit', code, error })
|
|
535
|
-
closed = true
|
|
536
|
-
try { ws.close(1000) } catch { /* already closed */ }
|
|
537
|
-
}
|
|
538
|
-
}).catch((error) => {
|
|
539
|
-
sendFrame({ type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
|
|
540
|
-
closed = true
|
|
479
|
+
if (sessionId !== undefined) {
|
|
480
|
+
if (terminalSessions.attach(ws, sessionId, size)) return
|
|
481
|
+
// The session was reaped or the host restarted: tell the view so it
|
|
482
|
+
// can fall back to a fresh connect instead of sitting blank.
|
|
483
|
+
const expired: TerminalServerFrame = { type: 'exit', code: null, error: 'terminal session expired' }
|
|
484
|
+
try { ws.send(JSON.stringify(expired)) } catch { /* socket raced away */ }
|
|
541
485
|
try { ws.close(1000) } catch { /* already closed */ }
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
try {
|
|
546
|
-
frame = JSON.parse(String(data)) as TerminalClientFrame
|
|
547
|
-
} catch {
|
|
548
|
-
return
|
|
549
|
-
}
|
|
550
|
-
if (frame.type === 'input') {
|
|
551
|
-
session?.send(frame.data)
|
|
552
|
-
} else if (frame.type === 'resize') {
|
|
553
|
-
session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows))
|
|
554
|
-
} else if (frame.type === 'auth_response') {
|
|
555
|
-
if (pendingAuthFinish !== undefined) {
|
|
556
|
-
const fn = pendingAuthFinish
|
|
557
|
-
pendingAuthFinish = undefined
|
|
558
|
-
fn(frame.responses)
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
})
|
|
562
|
-
ws.on('close', () => {
|
|
563
|
-
closed = true
|
|
564
|
-
closeSession()
|
|
565
|
-
})
|
|
566
|
-
ws.on('error', () => {
|
|
567
|
-
closed = true
|
|
568
|
-
closeSession()
|
|
569
|
-
})
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
void terminalSessions.open(ws, alias as string, size)
|
|
570
489
|
})
|
|
571
490
|
},
|
|
572
491
|
}
|
|
573
492
|
|
|
574
|
-
return { routes, upgrade }
|
|
493
|
+
return { routes, upgrade, terminalSessions }
|
|
575
494
|
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One `document.body` MutationObserver per page, shared by every family
|
|
3
|
-
* plugin bundle.
|
|
4
|
-
*
|
|
5
|
-
* Family plugins that inject at the DOM level must notice when the shell
|
|
6
|
-
* re-renders around their injected node: the sidebar entry rows
|
|
7
|
-
* (sidebar-entry-core), the center-column panel containers
|
|
8
|
-
* (panel-mount-core), and the aggregate shell's column shims. Each consumer
|
|
9
|
-
* used to install its OWN `MutationObserver` on `document.body` with
|
|
10
|
-
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
11
|
-
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
12
|
-
* the app produced — chat token streaming alone produces many per second, and
|
|
13
|
-
* the count grows with the number of installed plugins.
|
|
14
|
-
*
|
|
15
|
-
* This module keeps exactly ONE body observer per page. The registry lives on
|
|
16
|
-
* `globalThis` under a `Symbol.for` key, so the per-package generated copies
|
|
17
|
-
* of this file (scripts/sync-shared.mjs) and separately bundled plugins all
|
|
18
|
-
* reach the same hub at runtime instead of one hub per module instance.
|
|
19
|
-
* Record subscribers receive the mutations accumulated since the last flush.
|
|
20
|
-
* Consumers that only re-check their DOM use subscribeBodyInvalidations:
|
|
21
|
-
* when all subscribers use that path, the hub retains no mutation records
|
|
22
|
-
* (or detached subtrees) while a background page's animation frames pause.
|
|
23
|
-
* Both paths run at most once per frame. The last subscriber disconnects the
|
|
24
|
-
* observer, cancels the pending frame and releases its records.
|
|
25
|
-
*
|
|
26
|
-
* Failure policy: without a DOM or `MutationObserver` the subscription is a
|
|
27
|
-
* no-op disposer (the same silence the per-consumer observers had), and when
|
|
28
|
-
* `requestAnimationFrame` is unavailable the flush runs synchronously so a
|
|
29
|
-
* subscriber is never silently dropped. A throwing subscriber cannot stop the
|
|
30
|
-
* others.
|
|
31
|
-
*/
|
|
32
|
-
/**
|
|
33
|
-
* Subscribe to a coalesced DOM re-check without retaining mutation records.
|
|
34
|
-
* The marked wrapper also works with an older hub, which delivers records
|
|
35
|
-
* that it simply ignores until a page reload picks up the updated hub.
|
|
36
|
-
*/
|
|
37
|
-
export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
|
|
38
|
-
/**
|
|
39
|
-
* Subscribe to body-level childList mutations.
|
|
40
|
-
* @param subscriber - called at most once per animation frame with the records
|
|
41
|
-
* collected since the previous flush; must be safe to run repeatedly.
|
|
42
|
-
* @returns the disposer removing this subscriber (and the observer when it was
|
|
43
|
-
* the last one).
|
|
44
|
-
*/
|
|
45
|
-
export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Panel view mounting (dsh-ssh wrapper).
|
|
3
|
-
*
|
|
4
|
-
* The single-occupant takeover lifecycle — container injection into the
|
|
5
|
-
* center column, sibling eviction, remount resilience, sidebar click-out —
|
|
6
|
-
* lives exactly once in shared/client/panel-mount-core.ts (synced copy); this
|
|
7
|
-
* wrapper supplies the SSH panel tree, view dataset key, CSS module, and the
|
|
8
|
-
* html attribute names. Those names are pinned by panel.module.css, skins,
|
|
9
|
-
* and the semantic attributes contract.
|
|
10
|
-
*/
|
|
11
|
-
import type { SshApi } from './api.ts';
|
|
12
|
-
import type { PanelController } from './panel/controller.ts';
|
|
13
|
-
import type { TerminalFontSource } from './panel/helpers.ts';
|
|
14
|
-
import type { LocaleRefreshSource } from './sidebar-entry.ts';
|
|
15
|
-
/** The injected panel container (kept in the DOM, hidden when inactive). */
|
|
16
|
-
export declare const PANEL_VIEW_SELECTOR = "[data-dsh-ssh-view]";
|
|
17
|
-
/**
|
|
18
|
-
* Mount the panel React tree into the center column and bind its visibility
|
|
19
|
-
* to the controller's panelOpen state.
|
|
20
|
-
* @param controller - the panel controller driving the view.
|
|
21
|
-
* @param api - the SSH API client the tabs operate through.
|
|
22
|
-
* @param terminalFont - live terminal-font setting source (issue #577).
|
|
23
|
-
* @param locale - locale-change source; when given, re-renders an open panel
|
|
24
|
-
* on a Language switch.
|
|
25
|
-
* @returns disposer unmounting the tree and restoring the column.
|
|
26
|
-
*/
|
|
27
|
-
export declare function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource, locale?: LocaleRefreshSource): () => void;
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Center-column panel takeover lifecycle.
|
|
3
|
-
*
|
|
4
|
-
* The `conversation` slot is single-occupant (ui-conversation) and external
|
|
5
|
-
* plugins cannot declare slots, so a family panel takes over the center
|
|
6
|
-
* column at the DOM level: a container is appended inside the center column
|
|
7
|
-
* (`[class*="centerCol"]`, the 0.1.0-rc.6+ AppFrame layout; previously
|
|
8
|
-
* `[data-pane="conversation"]` on older shells — the mount selector keeps
|
|
9
|
-
* both, ssh #243 / task-board #107) as an extra trailing child React never
|
|
10
|
-
* manages, and a stylesheet rule hides the conversation content while the
|
|
11
|
-
* panel is active. Toggling is a data attribute on <html> — no React
|
|
12
|
-
* involvement, so the conversation subtree underneath stays mounted and
|
|
13
|
-
* stateful.
|
|
14
|
-
*
|
|
15
|
-
* Consuming plugins keep a thin wrapper that supplies the panel tree,
|
|
16
|
-
* container attribute names, and stylesheet class; those names are pinned by
|
|
17
|
-
* each package's CSS, skins, and the semantic-attributes contract. The
|
|
18
|
-
* sidebar row toggling the panel shares its core the same way
|
|
19
|
-
* (shared/client/sidebar-entry-core.ts, synced copy).
|
|
20
|
-
*/
|
|
21
|
-
import { type Root } from 'react-dom/client';
|
|
22
|
-
/** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
|
|
23
|
-
export interface CenterPanelMountOptions {
|
|
24
|
-
/** Render the panel tree (first open, remount while open, locale refresh). */
|
|
25
|
-
render: (root: Root) => void;
|
|
26
|
-
/** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
|
|
27
|
-
viewDatasetKey: string;
|
|
28
|
-
/** value of the container's L2 `data-dsh-plugin` semantic attribute. */
|
|
29
|
-
pluginName: string;
|
|
30
|
-
/** stylesheet class applied to the injected container. */
|
|
31
|
-
viewClassName: string;
|
|
32
|
-
/** <html> attribute set while this panel is active. */
|
|
33
|
-
activeAttribute: string;
|
|
34
|
-
/** the sibling panel's active attribute, removed from <html> when this panel opens. */
|
|
35
|
-
siblingActiveAttribute: string;
|
|
36
|
-
/** detail value this panel broadcasts on the cross-plugin activation event. */
|
|
37
|
-
panelName: string;
|
|
38
|
-
/** sibling detail value whose activation closes this panel. */
|
|
39
|
-
siblingPanelName: string;
|
|
40
|
-
/** open flag of the owning controller. */
|
|
41
|
-
isOpen: () => boolean;
|
|
42
|
-
/** close the panel, handing the center column back to the conversation. */
|
|
43
|
-
close: () => void;
|
|
44
|
-
/** subscribe to the owning controller's open-state changes; returns an unsubscriber. */
|
|
45
|
-
subscribe: (listener: () => void) => () => void;
|
|
46
|
-
/** locale-change source; when given, re-renders an open panel on a Language switch. */
|
|
47
|
-
locale?: {
|
|
48
|
-
subscribe(listener: () => void): () => void;
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Mount a family panel into the center column and bind its visibility to the
|
|
53
|
-
* owning controller's open state.
|
|
54
|
-
* @returns disposer unmounting the tree and restoring the column.
|
|
55
|
-
*/
|
|
56
|
-
export declare function mountCenterPanel(options: CenterPanelMountOptions): () => void;
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/** Per-package configuration for one sidebar entry row. */
|
|
2
|
-
export interface SidebarEntryOptions {
|
|
3
|
-
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
4
|
-
rowAttribute: string;
|
|
5
|
-
/** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
|
|
6
|
-
rowSelector: string;
|
|
7
|
-
/**
|
|
8
|
-
* L2 semantic-attribute plugin id (issue #506, enum table:
|
|
9
|
-
* skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
|
|
10
|
-
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
|
11
|
-
* leaves the row without semantic attributes.
|
|
12
|
-
*/
|
|
13
|
-
plugin?: string;
|
|
14
|
-
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
|
15
|
-
icon: string;
|
|
16
|
-
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
|
17
|
-
css: Record<string, string>;
|
|
18
|
-
/** Localized row label (aria-label + visible text). */
|
|
19
|
-
label(): string;
|
|
20
|
-
/** Optional localized tooltip (title attribute). */
|
|
21
|
-
tooltip?(): string;
|
|
22
|
-
/**
|
|
23
|
-
* Optional locale-change subscription: re-applies label / aria-label /
|
|
24
|
-
* tooltip whenever the active locale changes. Plain-DOM rows would
|
|
25
|
-
* otherwise keep the label captured at mount; pass the SDK locale runtime
|
|
26
|
-
* subscription (ctx.locale.subscribe) so the row follows the language
|
|
27
|
-
* switch without a reload.
|
|
28
|
-
*/
|
|
29
|
-
refresh?: {
|
|
30
|
-
subscribe(listener: () => void): () => void;
|
|
31
|
-
};
|
|
32
|
-
/** Click action (open/toggle the owning panel). */
|
|
33
|
-
onToggle(): void;
|
|
34
|
-
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
35
|
-
position: 'before' | 'after';
|
|
36
|
-
/**
|
|
37
|
-
* Selectors of the sibling plugin entry rows this package orders against
|
|
38
|
-
* (its own row included — the placement guard excludes a row that is
|
|
39
|
-
* already inside the root). Each package passes the same list it used
|
|
40
|
-
* before the consolidation so the rendered order stays stable.
|
|
41
|
-
*/
|
|
42
|
-
familySelectors: readonly string[];
|
|
43
|
-
/** Optional active-state bridge; highlights the row while the panel is open. */
|
|
44
|
-
active?: {
|
|
45
|
-
subscribe(listener: () => void): () => void;
|
|
46
|
-
isOpen(): boolean;
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
51
|
-
* on later React re-renders.
|
|
52
|
-
* @param options - the row's attribute/icon/copy/action/ordering configuration.
|
|
53
|
-
* @returns disposer removing the entry and its observers.
|
|
54
|
-
*/
|
|
55
|
-
export declare function mountSidebarEntry(options: SidebarEntryOptions): () => void;
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sidebar entry injection — package-specific wiring over the shared core.
|
|
3
|
-
*
|
|
4
|
-
* The DOM injection / self-healing / idempotency logic lives exactly once in
|
|
5
|
-
* shared/client/sidebar-entry-core.ts (synced copy); this wrapper supplies the
|
|
6
|
-
* ssh icon, copy, CSS module, and the panel toggle. The row is plain DOM (no
|
|
7
|
-
* React tree) so it can never disturb the shell's reconciliation; the panel
|
|
8
|
-
* view it toggles is a separate React root mounted in the center column
|
|
9
|
-
* (see mount.tsx).
|
|
10
|
-
*/
|
|
11
|
-
import type { PanelController } from './panel/controller.ts';
|
|
12
|
-
/** Stable data attribute identifying the injected entry row. */
|
|
13
|
-
export declare const ENTRY_SELECTOR = "[data-dsh-ssh-entry]";
|
|
14
|
-
/** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
|
|
15
|
-
export interface LocaleRefreshSource {
|
|
16
|
-
subscribe(listener: () => void): () => void;
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
20
|
-
* on later React re-renders.
|
|
21
|
-
* @param controller - the panel controller the entry toggles.
|
|
22
|
-
* @param locale - locale-change source; when given, re-applies the label on
|
|
23
|
-
* a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
|
|
24
|
-
* @returns disposer removing the entry and its observers.
|
|
25
|
-
*/
|
|
26
|
-
export declare function mountSidebarEntry(controller: PanelController, locale?: LocaleRefreshSource): () => void;
|