dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x

This commit is contained in:
hermes committed 2026-10-01 19:21:19 +00:00
commit e6dcef9bf5
302 files changed
+75178

No files matched your search

@@ -0,0 +1,10 @@
[
{
"insert": [
{
"id": "dsh-docker.platform-management.plugin",
"name": "@dsh-docker/platform-management"
}
]
}
]
@@ -0,0 +1,128 @@
import { readFileSync, watch } from 'node:fs'
import { basename, dirname } from 'node:path'
import { installProviderRouting } from './provider-routing.js'
export const name = '@dsh-docker/platform-management'
export const inject = ['subprocess']
const PROXY_KEYS = Object.freeze([
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy',
'NO_PROXY', 'no_proxy', 'ALL_PROXY', 'all_proxy',
])
const CONNECTION_PROXY_KEYS = Object.freeze([
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy',
])
const SHARED_DSH_PROXY_URL = 'http://127.0.0.1:17898'
const ROUTING_STATE_PATH = `${process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform'}/outbound-proxy-routing.json`
function routingState(path = ROUTING_STATE_PATH) {
try {
const value = JSON.parse(readFileSync(path, 'utf8'))
if (value?.schema !== 1 || typeof value.enabled !== 'boolean' || typeof value.scopes?.agentNetwork !== 'boolean') return null
return value
} catch {
return null
}
}
function agentNetworkEnvironment(environment = process.env) {
const proxy = environment.DSH_PLATFORM_AGENT_PROXY_URL
if (typeof proxy !== 'string' || proxy === '') return null
const noProxy = environment.NO_PROXY ?? environment.no_proxy ?? ''
const allProxy = environment.ALL_PROXY !== undefined || environment.all_proxy !== undefined
return {
HTTP_PROXY: proxy,
HTTPS_PROXY: proxy,
http_proxy: proxy,
https_proxy: proxy,
NO_PROXY: noProxy,
no_proxy: noProxy,
...(allProxy ? { ALL_PROXY: proxy, all_proxy: proxy } : {}),
}
}
function sharedDshProxyEnabled(state) {
return state !== null && state.enabled
&& (state.scopes?.dshCore === true || state.scopes?.dshPlugins === true)
}
function synchronizeSharedDshEnvironment(environment, state) {
if (state === null) return
for (const key of CONNECTION_PROXY_KEYS) delete environment[key]
const noProxy = [...new Set([
...(state.noProxy?.system ?? []),
...(state.noProxy?.user ?? []),
])].join(',')
environment.NO_PROXY = noProxy
environment.no_proxy = noProxy
if (!sharedDshProxyEnabled(state)) return
for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) environment[key] = SHARED_DSH_PROXY_URL
if (state.environment?.allProxy === 'scope-proxy') {
environment.ALL_PROXY = SHARED_DSH_PROXY_URL
environment.all_proxy = SHARED_DSH_PROXY_URL
}
}
function watchSharedDshEnvironment(path = ROUTING_STATE_PATH, environment = process.env) {
const original = Object.fromEntries(PROXY_KEYS.map(key => [key, environment[key]]))
const synchronize = () => synchronizeSharedDshEnvironment(environment, routingState(path))
synchronize()
let watcher
try {
watcher = watch(dirname(path), (_event, filename) => {
if (filename === null || filename.toString() === basename(path)) synchronize()
})
watcher.on('error', () => {})
} catch {}
return () => {
watcher?.close()
for (const key of PROXY_KEYS) {
if (original[key] === undefined) delete environment[key]
else environment[key] = original[key]
}
}
}
function managedSubprocessSpec(spec, environment = process.env, routingPath = ROUTING_STATE_PATH) {
if (spec?.env?.DSH_SHELL !== '1') return spec
const env = { ...spec.env }
for (const key of PROXY_KEYS) delete env[key]
const state = routingState(routingPath)
if (state !== null && (!state.enabled || !state.scopes.agentNetwork)) {
for (const key of PROXY_KEYS) env[key] = undefined
return { ...spec, env }
}
const proxy = agentNetworkEnvironment(environment)
if (proxy === null) return spec
return { ...spec, env: { ...env, ...proxy } }
}
export function apply(ctx) {
const removeSharedDshEnvironment = watchSharedDshEnvironment()
const removeProviderRouting = installProviderRouting(ctx)
const service = ctx.subprocess
const spawn = service.spawn
const spawnTerminal = service.spawnTerminal
const managedSpawn = function (spec) {
return spawn.call(this, managedSubprocessSpec(spec))
}
const managedSpawnTerminal = function (spec) {
return spawnTerminal.call(this, managedSubprocessSpec(spec))
}
service.spawn = managedSpawn
service.spawnTerminal = managedSpawnTerminal
ctx.effect(() => () => {
removeSharedDshEnvironment()
removeProviderRouting()
if (service.spawn === managedSpawn) service.spawn = spawn
if (service.spawnTerminal === managedSpawnTerminal) service.spawnTerminal = spawnTerminal
}, 'dsh-docker managed network scopes')
}
export const managedNetworkInternals = Object.freeze({
agentNetworkEnvironment,
managedSubprocessSpec,
routingState,
sharedDshProxyEnabled,
synchronizeSharedDshEnvironment,
})
@@ -0,0 +1,248 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { readFileSync } from 'node:fs'
import { request } from 'node:http'
import { Agent, fetch as proxyFetch, ProxyAgent } from 'undici'
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/
const FETCH_ROUTER = Symbol.for('dsh-docker.platform-management.provider-fetch-router')
const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
const DEFAULT_SOCKET = '/run/dsh-platform/outbound-proxy.sock'
const DEFAULT_PROXY_URL = 'http://127.0.0.1:17897'
const DEFAULT_SHARED_PROXY_URL = 'http://127.0.0.1:17898'
const DEFAULT_AGENT_PROXY_URL = 'http://127.0.0.1:17895'
const DEFAULT_ROUTING_STATE = '/run/dsh-platform/outbound-proxy-routing.json'
function routingState(path) {
try {
const value = JSON.parse(readFileSync(path, 'utf8'))
if (value?.schema !== 1 || typeof value.enabled !== 'boolean' || value.modelApi === null || typeof value.modelApi !== 'object') return null
return value
} catch {
return null
}
}
function providerProxyEnabled(state, providerId) {
if (state === null || !state.enabled) return false
const policy = state.modelApi.providers?.[providerId] ?? state.modelApi.default
return policy?.proxyEnabled === true
}
function providerRoutingCapability(options) {
return options?.routingCapability === 'shared-dsh' || options?.supportsIndependentRouting === false
? 'shared-dsh'
: 'provider'
}
function sharedDshProxyEnabled(state) {
return state !== null && state.enabled
&& (state.scopes?.dshCore === true || state.scopes?.dshPlugins === true)
}
function agentNetworkProxyEnabled(state) {
return state !== null && state.enabled && state.scopes?.agentNetwork === true
}
function controlRequest(socketPath, method, path, body) {
return new Promise((resolve, reject) => {
const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
const outgoing = request({
socketPath,
method,
path,
headers: bytes === undefined ? {} : {
'content-type': 'application/json',
'content-length': bytes.byteLength,
},
signal: AbortSignal.timeout(5_000),
}, response => {
const chunks = []
let size = 0
response.on('data', chunk => {
size += chunk.byteLength
if (size > MAX_CONTROL_RESPONSE_BYTES) outgoing.destroy(new Error('Outbound Proxy response is too large'))
else chunks.push(chunk)
})
response.on('end', () => {
let value
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {
reject(new Error('Outbound Proxy response is invalid'))
return
}
if ((response.statusCode ?? 500) >= 400) {
const error = new Error(value?.error?.message ?? `Outbound Proxy returned HTTP ${String(response.statusCode)}`)
error.statusCode = response.statusCode
error.code = value?.error?.code
reject(error)
} else resolve(value)
})
})
outgoing.once('error', reject)
outgoing.end(bytes)
})
}
async function issueProviderHandle(providerId, socketPath) {
for (let attempt = 0; attempt < 2; attempt += 1) {
const configuration = await controlRequest(socketPath, 'GET', '/v1/configuration')
try {
return await controlRequest(socketPath, 'POST', '/v1/provider-handles', {
providerId,
policyRevision: configuration.revision,
})
} catch (error) {
if (error?.code !== 'REVISION_CONFLICT' || attempt > 0) throw error
}
}
throw new Error('Provider policy changed repeatedly')
}
function installFetchRouter(store, routedFetch = proxyFetch, resolveDefaultRoute = () => undefined) {
const owner = Object.freeze({})
let registry = globalThis[FETCH_ROUTER]
if (registry === undefined) {
const original = globalThis.fetch
registry = { original, owner, store, routedFetch, resolveDefaultRoute }
const wrapper = function (input, init) {
const route = registry.store.getStore() ?? registry.resolveDefaultRoute()
if (route === undefined || (init !== undefined && Object.hasOwn(init, 'dispatcher'))) {
return registry.original.call(this, input, init)
}
return registry.routedFetch(input, { ...init, dispatcher: route.dispatcher })
}
registry.wrapper = wrapper
globalThis[FETCH_ROUTER] = registry
globalThis.fetch = wrapper
} else {
registry.owner = owner
registry.store = store
registry.routedFetch = routedFetch
registry.resolveDefaultRoute = resolveDefaultRoute
}
return () => {
if (registry.owner !== owner) return
if (globalThis.fetch === registry.wrapper) globalThis.fetch = registry.original
delete globalThis[FETCH_ROUTER]
}
}
function routedIterator(next, providerId, store, { resolveRoute }) {
let iterator
let routePromise
let closed = false
async function route() {
if (routePromise === undefined) {
routePromise = resolveRoute(providerId)
}
return routePromise
}
async function invoke(method, value) {
const context = await route()
if (iterator === undefined) iterator = store.run(context, () => next()[Symbol.asyncIterator]())
if (typeof iterator[method] !== 'function') {
await close(context)
if (method === 'throw') throw value
return { value, done: true }
}
try {
const result = await store.run(context, () => iterator[method](value))
if (result.done) await close(context)
return result
} catch (error) {
await close(context)
throw error
}
}
async function close(context) {
if (closed) return
closed = true
if (context.owned) await context.dispatcher.close()
}
return {
next: value => invoke('next', value),
return: value => invoke('return', value),
throw: value => invoke('throw', value),
[Symbol.asyncIterator]() { return this },
}
}
export function installProviderRouting(ctx, {
socketPath = process.env.DSH_PLATFORM_RUN === undefined
? DEFAULT_SOCKET
: `${process.env.DSH_PLATFORM_RUN}/outbound-proxy.sock`,
proxyUrl = DEFAULT_PROXY_URL,
routingStatePath = process.env.DSH_PLATFORM_RUN === undefined
? DEFAULT_ROUTING_STATE
: `${process.env.DSH_PLATFORM_RUN}/outbound-proxy-routing.json`,
createDispatcher = options => new ProxyAgent(options),
createSharedDispatcher = options => new ProxyAgent(options),
createAgentDispatcher = options => new ProxyAgent(options),
createDirectDispatcher = () => new Agent(),
routedFetch = proxyFetch,
} = {}) {
const store = new AsyncLocalStorage()
const directDispatcher = createDirectDispatcher()
const sharedDispatcher = createSharedDispatcher({ uri: DEFAULT_SHARED_PROXY_URL })
const agentDispatcher = createAgentDispatcher({ uri: DEFAULT_AGENT_PROXY_URL })
const resolveDefaultRoute = () => {
const state = routingState(routingStatePath)
if (state === null) return undefined
return { dispatcher: sharedDshProxyEnabled(state) ? sharedDispatcher : directDispatcher }
}
const removeFetchRouter = installFetchRouter(store, routedFetch, resolveDefaultRoute)
const disposeTool = ctx.on('tools/execute', (_execution, next) => {
const state = routingState(routingStatePath)
return store.run({
dispatcher: agentNetworkProxyEnabled(state) ? agentDispatcher : directDispatcher,
owned: false,
}, next)
}, { global: true, prepend: true })
const disposeStream = ctx.on('llm/stream', (options, next) => {
if (!PROVIDER_ID_PATTERN.test(options.provider)) return next()
const capability = providerRoutingCapability(options)
return routedIterator(next, options.provider, store, {
resolveRoute: async providerId => {
const state = routingState(routingStatePath)
if (state !== null && !providerProxyEnabled(state, providerId)) {
return { dispatcher: directDispatcher, owned: false }
}
if (capability === 'shared-dsh') {
return {
dispatcher: sharedDshProxyEnabled(state) ? sharedDispatcher : directDispatcher,
owned: false,
}
}
const { handle } = await issueProviderHandle(providerId, socketPath)
return {
dispatcher: createDispatcher({ uri: proxyUrl, token: `DSH-Provider ${handle}` }),
owned: true,
}
},
})
}, { global: true, prepend: true })
return () => {
disposeTool?.()
disposeStream?.()
removeFetchRouter()
void directDispatcher.close()
void sharedDispatcher.close()
void agentDispatcher.close()
}
}
export const providerRoutingInternals = Object.freeze({
FETCH_ROUTER,
controlRequest,
installFetchRouter,
issueProviderHandle,
agentNetworkProxyEnabled,
providerProxyEnabled,
providerRoutingCapability,
sharedDshProxyEnabled,
routingState,
routedIterator,
})
@@ -0,0 +1,32 @@
{
"name": "@dsh-docker/platform-management",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@dsh-docker/platform-management",
"version": "1.0.0",
"dependencies": {
"undici": "8.10.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-client-connection": "*",
"@deepseek-ai/dsh-client-locale": "*",
"@deepseek-ai/dsh-client-ui-primitives": "*",
"@deepseek-ai/dsh-client-ui-settings": "*",
"react": "^18.2.0"
}
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
}
}
}
@@ -0,0 +1,39 @@
{
"name": "@dsh-docker/platform-management",
"version": "1.0.0",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.bundle.js",
"./package.json": "./package.json"
},
"dependencies": {
"undici": "8.10.0"
},
"dshDocker": {
"description": {
"zh": "管理 DSH Docker 更新、运行维护、系统资源与登录会话。",
"en": "Manage DSH Docker updates, runtime maintenance, system resources, and login sessions."
}
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web"
}
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-client-connection": "*",
"@deepseek-ai/dsh-client-locale": "*",
"@deepseek-ai/dsh-client-ui-primitives": "*",
"@deepseek-ai/dsh-client-ui-settings": "*",
"react": "^18.2.0"
}
}
@@ -0,0 +1,10 @@
[
{
"insert": [
{
"id": "dsh-docker.settings-document-editor.plugin",
"name": "@dsh-docker/settings-document-editor"
}
]
}
]
@@ -0,0 +1,270 @@
window.__ModuleLoader__.load({ id: "@dsh-docker/settings-document-editor", factory: (require) => {
var module = { exports: {} }; var exports = module.exports;
const css = Object.freeze({"actions":"dshSettingsDocumentEditor_actions","body":"dshSettingsDocumentEditor_body","close":"dshSettingsDocumentEditor_close","confirmActions":"dshSettingsDocumentEditor_confirmActions","confirmation":"dshSettingsDocumentEditor_confirmation","danger":"dshSettingsDocumentEditor_danger","dialog":"dshSettingsDocumentEditor_dialog","dirty":"dshSettingsDocumentEditor_dirty","editor":"dshSettingsDocumentEditor_editor","editorFrame":"dshSettingsDocumentEditor_editorFrame","error":"dshSettingsDocumentEditor_error","footer":"dshSettingsDocumentEditor_footer","header":"dshSettingsDocumentEditor_header","lineNumbers":"dshSettingsDocumentEditor_lineNumbers","loading":"dshSettingsDocumentEditor_loading","mask":"dshSettingsDocumentEditor_mask","overlay":"dshSettingsDocumentEditor_overlay","primary":"dshSettingsDocumentEditor_primary","saved":"dshSettingsDocumentEditor_saved"});
const styleId = "@dsh-docker/settings-document-editor/style.module.css";
if (typeof document !== 'undefined' && ![...document.querySelectorAll('style[data-plugin-css]')].some(tag => tag.dataset.pluginCss === styleId)) {
const tag = document.createElement('style');
tag.dataset.plugin = "@dsh-docker/settings-document-editor";
tag.dataset.pluginCss = styleId;
tag.textContent = ".dshSettingsDocumentEditor_overlay {\n position: fixed;\n inset: 0;\n z-index: 1200;\n display: grid;\n place-items: center;\n padding: 24px;\n color: var(--dsw-alias-label-primary);\n}\n\n.dshSettingsDocumentEditor_overlay,\n.dshSettingsDocumentEditor_overlay *,\n.dshSettingsDocumentEditor_overlay *::before,\n.dshSettingsDocumentEditor_overlay *::after {\n corner-shape: round;\n}\n\n.dshSettingsDocumentEditor_mask {\n position: absolute;\n inset: 0;\n background: rgb(0 0 0 / 48%);\n}\n\n.dshSettingsDocumentEditor_dialog {\n position: relative;\n display: grid;\n grid-template-rows: auto minmax(0, 1fr) auto;\n box-sizing: border-box;\n width: min(820px, 100%);\n height: min(680px, calc(100dvh - 48px));\n min-height: 420px;\n overflow: hidden;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n background: var(--dsw-alias-bg-module-platform);\n box-shadow: 0 18px 50px rgb(0 0 0 / 32%);\n}\n\n.dshSettingsDocumentEditor_header,\n.dshSettingsDocumentEditor_footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 18px;\n}\n\n.dshSettingsDocumentEditor_header { border-bottom: 1px solid var(--dsw-alias-border-l2); }\n.dshSettingsDocumentEditor_footer { border-top: 1px solid var(--dsw-alias-border-l2); }\n.dshSettingsDocumentEditor_header h2 { margin: 0; font-size: 17px; line-height: 24px; font-weight: 600; }\n.dshSettingsDocumentEditor_header p { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }\n\n.dshSettingsDocumentEditor_close {\n flex: none;\n width: 32px;\n height: 32px;\n padding: 0;\n border: 0;\n border-radius: 6px;\n color: var(--dsw-alias-label-secondary);\n background: transparent;\n font: 18px/32px ui-sans-serif, system-ui, sans-serif;\n cursor: pointer;\n}\n.dshSettingsDocumentEditor_close:hover { background: var(--dsw-alias-interactive-bg-hover); }\n\n.dshSettingsDocumentEditor_body { position: relative; min-height: 0; padding: 14px 18px; }\n.dshSettingsDocumentEditor_editorFrame {\n display: grid;\n grid-template-columns: auto minmax(0, 1fr);\n box-sizing: border-box;\n width: 100%;\n height: 100%;\n min-height: 300px;\n overflow: hidden;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n background: var(--dsw-alias-bg-base);\n}\n.dshSettingsDocumentEditor_editorFrame:focus-within { border-color: var(--dsw-alias-border-l2); }\n\n.dshSettingsDocumentEditor_lineNumbers {\n box-sizing: border-box;\n min-width: 44px;\n height: 100%;\n margin: 0;\n overflow: hidden;\n padding: 12px 10px;\n border-right: 1px solid var(--dsw-alias-border-l2);\n color: var(--dsw-alias-label-tertiary);\n background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, var(--dsw-alias-bg-module-platform));\n font: 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right;\n user-select: none;\n}\n\n.dshSettingsDocumentEditor_editor {\n box-sizing: border-box;\n width: 100%;\n height: 100%;\n min-width: 0;\n min-height: 0;\n resize: none;\n padding: 12px 14px;\n border: 0;\n outline: none;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n font: 13px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n letter-spacing: 0;\n tab-size: 2;\n}\n\n.dshSettingsDocumentEditor_loading {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 9px;\n height: 100%;\n color: var(--dsw-alias-label-secondary);\n font-size: 13px;\n}\n.dshSettingsDocumentEditor_loading span {\n width: 14px;\n height: 14px;\n border: 2px solid var(--dsw-alias-border-l2);\n border-top-color: var(--dsw-alias-label-primary);\n border-radius: 50%;\n animation: document-editor-spin .8s linear infinite;\n}\n@keyframes document-editor-spin { to { transform: rotate(360deg); } }\n\n.dshSettingsDocumentEditor_error {\n position: absolute;\n right: 28px;\n bottom: 24px;\n left: 28px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 9px 11px;\n border-left: 3px solid var(--dsw-alias-state-error-primary);\n color: var(--dsw-alias-state-error-primary);\n background: var(--dsw-alias-bg-module-platform);\n box-shadow: 0 4px 16px rgb(0 0 0 / 18%);\n font-size: 12px;\n line-height: 18px;\n}\n\n.dshSettingsDocumentEditor_actions,\n.dshSettingsDocumentEditor_confirmActions { display: flex; justify-content: flex-end; gap: 8px; }\n.dshSettingsDocumentEditor_footer button,\n.dshSettingsDocumentEditor_error button,\n.dshSettingsDocumentEditor_confirmation button {\n min-height: 32px;\n padding: 4px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 16px;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n font: inherit;\n font-size: 12px;\n cursor: pointer;\n}\n.dshSettingsDocumentEditor_footer button:hover:not(:disabled),\n.dshSettingsDocumentEditor_errorLine truncated
document.head.appendChild(tag);
}
const React = require('react')
const { useCallback, useEffect, useRef, useState } = React
const { createPortal } = require('react-dom')
const API = '/_dsh_platform/plugin-api/v1/settings-document'
const h = React.createElement
const inject = ['slots', 'locale', 'connection', 'remote']
function browserCookie(name) {
for (const part of document.cookie.split(';')) {
const separator = part.indexOf('=')
if (separator >= 0 && part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim()
}
return ''
}
async function request(method, body) {
const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method)
const response = await fetch(API, {
method,
headers: {
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const value = await response.json()
if (!response.ok) {
const error = new Error(value.error ?? `HTTP ${String(response.status)}`)
error.status = response.status
throw error
}
return value
}
class DocumentController {
constructor() {
this.listeners = new Set()
this.state = {
open: false,
loading: false,
saving: false,
content: '',
savedContent: '',
revision: null,
error: null,
conflict: false,
confirmClose: false,
}
}
subscribe(listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publish(patch) {
this.state = { ...this.state, ...patch }
for (const listener of this.listeners) listener(this.state)
}
async open() {
if (this.state.open) return
this.publish({
open: true,
loading: true,
content: '',
savedContent: '',
revision: null,
error: null,
conflict: false,
confirmClose: false,
})
await this.reload()
}
async reload() {
this.publish({ loading: true, error: null, conflict: false, confirmClose: false })
try {
const value = await request('GET')
this.publish({
loading: false,
content: value.content,
savedContent: value.content,
revision: value.revision,
})
} catch (error) {
this.publish({ loading: false, error: error.message })
}
}
edit(content) {
this.publish({ content, error: null, conflict: false })
}
requestClose() {
if (this.state.saving) return
if (this.state.content !== this.state.savedContent) this.publish({ confirmClose: true })
else this.close()
}
close() {
this.publish({ open: false, confirmClose: false, error: null, conflict: false })
}
async save() {
if (this.state.saving || this.state.loading || this.state.revision === null
|| this.state.content === this.state.savedContent) return
this.publish({ saving: true, error: null, conflict: false })
try {
const value = await request('PUT', { content: this.state.content, revision: this.state.revision })
this.publish({
saving: false,
content: value.content,
savedContent: value.content,
revision: value.revision,
})
} catch (error) {
this.publish({ saving: false, error: error.message, conflict: error.status === 409 })
}
}
}
function Editor({ controller, t }) {
const [state, setState] = useState(controller.state)
const textarea = useRef(null)
const lineNumbers = useRef(null)
useEffect(() => controller.subscribe(setState), [controller])
useEffect(() => {
if (state.open && !state.loading && !state.confirmClose) textarea.current?.focus()
}, [state.open, state.loading, state.confirmClose])
const keyDown = useCallback(event => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
void controller.save()
} else if (event.key === 'Escape') {
event.preventDefault()
controller.requestClose()
}
}, [controller])
useEffect(() => {
if (!state.open) return undefined
document.addEventListener('keydown', keyDown)
return () => document.removeEventListener('keydown', keyDown)
}, [keyDown, state.open])
if (!state.open) return null
const dirty = state.content !== state.savedContent
return createPortal(h('div', { className: css.overlay, role: 'presentation' },
h('div', { className: css.mask, 'aria-hidden': 'true', onClick: () => controller.requestClose() }),
h('section', { className: css.dialog, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-title' },
h('header', { className: css.header },
h('div', null,
h('h2', { id: 'dsh-settings-document-title' }, t('title')),
h('p', null, t('filename'))),
h('button', { className: css.close, type: 'button', 'aria-label': t('close'), onClick: () => controller.requestClose() }, '×')),
h('div', { className: css.body },
state.loading
? h('div', { className: css.loading, role: 'status' }, h('span', null), t('loading'))
: h('div', { className: css.editorFrame },
h('pre', { ref: lineNumbers, className: css.lineNumbers, 'aria-hidden': 'true' },
Array.from({ length: state.content.split('\n').length }, (_, index) => String(index + 1)).join('\n')),
h('textarea', {
ref: textarea,
className: css.editor,
value: state.content,
wrap: 'off',
spellCheck: false,
'aria-label': t('editorLabel'),
onChange: event => controller.edit(event.target.value),
onScroll: event => { lineNumbers.current.scrollTop = event.currentTarget.scrollTop },
})),
state.error === null ? null : h('div', { className: css.error, role: 'alert' },
h('span', null, state.conflict ? t('conflict') : t('loadError')),
state.conflict ? h('button', { type: 'button', onClick: () => { void controller.reload() } }, t('reload')) : null)),
h('footer', { className: css.footer },
h('span', { className: dirty ? css.dirty : css.saved, role: 'status' }, dirty ? t('unsaved') : t('saved')),
h('div', { className: css.actions },
h('button', { type: 'button', disabled: dirty || state.loading || state.saving, onClick: () => { void controller.reload() } }, t('reload')),
h('button', { type: 'button', onClick: () => controller.requestClose() }, t('cancel')),
h('button', { className: css.primary, type: 'button', disabled: !dirty || state.loading || state.saving, onClick: () => { void controller.save() } }, state.saving ? t('saving') : t('save')))),
state.confirmClose ? h('div', { className: css.confirmation, role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-discard-title' },
h('div', null,
h('strong', { id: 'dsh-settings-document-discard-title' }, t('discardTitle')),
h('p', null, t('discardBody')),
h('div', { className: css.confirmActions },
h('button', { type: 'button', onClick: () => controller.publish({ confirmClose: false }) }, t('continueEditing')),
h('button', { className: css.danger, type: 'button', onClick: () => controller.close() }, t('discard'))))) : null)), document.body)
}
function interceptDocumentOpen(target, method, controller, result) {
const descriptor = Object.getOwnPropertyDescriptor(target, method)
const intercepted = async () => {
void controller.open()
return result
}
Object.defineProperty(target, method, {
configurable: true,
enumerable: descriptor?.enumerable ?? true,
value: intercepted,
writable: true,
})
return () => {
if (target[method] !== intercepted) return
if (descriptor === undefined) Reflect.deleteProperty(target, method)
else Object.defineProperty(target, method, descriptor)
}
}
function apply(ctx) {
const controller = new DocumentController()
ctx.effect(() => ctx.locale.register('settings.dshDocumentEditor', {
zh: {
title: '编辑配置文件', filename: 'settings.yaml', close: '关闭', loading: '正在读取配置文件', editorLabel: '配置文件内容',
loadError: '无法读取或保存配置文件。', conflict: '配置文件已在其他位置发生变化,请重新加载后再编辑。', reload: '重新加载',
unsaved: '有未保存的修改', saved: '所有修改均已保存', cancel: '关闭', save: '保存', saving: '正在保存',
discardTitle: '放弃未保存的修改?', discardBody: '关闭后,本次未保存的修改将会丢失。', continueEditing: '继续编辑', discard: '放弃修改',
},
en: {
title: 'Edit configuration file', filename: 'settings.yaml', close: 'Close', loading: 'Loading configuration file', editorLabel: 'Configuration file content',
loadError: 'The configuration file could not be loaded or saved.', conflict: 'The configuration file changed elsewhere. Reload it before editing again.', reload: 'Reload',
unsaved: 'Unsaved changes', saved: 'All changes saved', cancel: 'Close', save: 'Save', saving: 'Saving',
discardTitle: 'Discard unsaved changes?', discardBody: 'Your unsaved changes will be lost when this editor closes.', continueEditing: 'Continue editing', discard: 'Discard changes',
},
}), 'dsh-settings-document-editor: locale')
const connection = ctx.get('connection')
if (typeof connection.api?.settings?.openDocument === 'function') {
ctx.effect(() => interceptDocumentOpen(
connection.api.settings,
'openDocument',
controller,
{ result: { ok: true, value: { opened: true } } },
), 'dsh-settings-document-editor: legacy open action')
} else {
ctx.inject(['remote.settings'], child => {
child.effect(() => interceptDocumentOpen(
child.remote.settings,
'openSettingsDocument',
controller,
{ ok: true, value: { opened: true } },
), 'dsh-settings-document-editor: open action')
})
}
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: 'dsh-settings-document-editor',
order: 80,
locale: 'settings.dshDocumentEditor',
}, props => h(Editor, { ...props, controller })))
}
exports.inject = inject;
exports.apply = apply;
return module.exports; } });
@@ -0,0 +1,256 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import css from './style.module.css'
const { createPortal } = require('react-dom')
const API = '/_dsh_platform/plugin-api/v1/settings-document'
const h = React.createElement
export const inject = ['slots', 'locale', 'connection', 'remote']
function browserCookie(name) {
for (const part of document.cookie.split(';')) {
const separator = part.indexOf('=')
if (separator >= 0 && part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim()
}
return ''
}
async function request(method, body) {
const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method)
const response = await fetch(API, {
method,
headers: {
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_gateway_csrf') } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const value = await response.json()
if (!response.ok) {
const error = new Error(value.error ?? `HTTP ${String(response.status)}`)
error.status = response.status
throw error
}
return value
}
class DocumentController {
constructor() {
this.listeners = new Set()
this.state = {
open: false,
loading: false,
saving: false,
content: '',
savedContent: '',
revision: null,
error: null,
conflict: false,
confirmClose: false,
}
}
subscribe(listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publish(patch) {
this.state = { ...this.state, ...patch }
for (const listener of this.listeners) listener(this.state)
}
async open() {
if (this.state.open) return
this.publish({
open: true,
loading: true,
content: '',
savedContent: '',
revision: null,
error: null,
conflict: false,
confirmClose: false,
})
await this.reload()
}
async reload() {
this.publish({ loading: true, error: null, conflict: false, confirmClose: false })
try {
const value = await request('GET')
this.publish({
loading: false,
content: value.content,
savedContent: value.content,
revision: value.revision,
})
} catch (error) {
this.publish({ loading: false, error: error.message })
}
}
edit(content) {
this.publish({ content, error: null, conflict: false })
}
requestClose() {
if (this.state.saving) return
if (this.state.content !== this.state.savedContent) this.publish({ confirmClose: true })
else this.close()
}
close() {
this.publish({ open: false, confirmClose: false, error: null, conflict: false })
}
async save() {
if (this.state.saving || this.state.loading || this.state.revision === null
|| this.state.content === this.state.savedContent) return
this.publish({ saving: true, error: null, conflict: false })
try {
const value = await request('PUT', { content: this.state.content, revision: this.state.revision })
this.publish({
saving: false,
content: value.content,
savedContent: value.content,
revision: value.revision,
})
} catch (error) {
this.publish({ saving: false, error: error.message, conflict: error.status === 409 })
}
}
}
function Editor({ controller, t }) {
const [state, setState] = useState(controller.state)
const textarea = useRef(null)
const lineNumbers = useRef(null)
useEffect(() => controller.subscribe(setState), [controller])
useEffect(() => {
if (state.open && !state.loading && !state.confirmClose) textarea.current?.focus()
}, [state.open, state.loading, state.confirmClose])
const keyDown = useCallback(event => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
void controller.save()
} else if (event.key === 'Escape') {
event.preventDefault()
controller.requestClose()
}
}, [controller])
useEffect(() => {
if (!state.open) return undefined
document.addEventListener('keydown', keyDown)
return () => document.removeEventListener('keydown', keyDown)
}, [keyDown, state.open])
if (!state.open) return null
const dirty = state.content !== state.savedContent
return createPortal(h('div', { className: css.overlay, role: 'presentation' },
h('div', { className: css.mask, 'aria-hidden': 'true', onClick: () => controller.requestClose() }),
h('section', { className: css.dialog, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-title' },
h('header', { className: css.header },
h('div', null,
h('h2', { id: 'dsh-settings-document-title' }, t('title')),
h('p', null, t('filename'))),
h('button', { className: css.close, type: 'button', 'aria-label': t('close'), onClick: () => controller.requestClose() }, '×')),
h('div', { className: css.body },
state.loading
? h('div', { className: css.loading, role: 'status' }, h('span', null), t('loading'))
: h('div', { className: css.editorFrame },
h('pre', { ref: lineNumbers, className: css.lineNumbers, 'aria-hidden': 'true' },
Array.from({ length: state.content.split('\n').length }, (_, index) => String(index + 1)).join('\n')),
h('textarea', {
ref: textarea,
className: css.editor,
value: state.content,
wrap: 'off',
spellCheck: false,
'aria-label': t('editorLabel'),
onChange: event => controller.edit(event.target.value),
onScroll: event => { lineNumbers.current.scrollTop = event.currentTarget.scrollTop },
})),
state.error === null ? null : h('div', { className: css.error, role: 'alert' },
h('span', null, state.conflict ? t('conflict') : t('loadError')),
state.conflict ? h('button', { type: 'button', onClick: () => { void controller.reload() } }, t('reload')) : null)),
h('footer', { className: css.footer },
h('span', { className: dirty ? css.dirty : css.saved, role: 'status' }, dirty ? t('unsaved') : t('saved')),
h('div', { className: css.actions },
h('button', { type: 'button', disabled: dirty || state.loading || state.saving, onClick: () => { void controller.reload() } }, t('reload')),
h('button', { type: 'button', onClick: () => controller.requestClose() }, t('cancel')),
h('button', { className: css.primary, type: 'button', disabled: !dirty || state.loading || state.saving, onClick: () => { void controller.save() } }, state.saving ? t('saving') : t('save')))),
state.confirmClose ? h('div', { className: css.confirmation, role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': 'dsh-settings-document-discard-title' },
h('div', null,
h('strong', { id: 'dsh-settings-document-discard-title' }, t('discardTitle')),
h('p', null, t('discardBody')),
h('div', { className: css.confirmActions },
h('button', { type: 'button', onClick: () => controller.publish({ confirmClose: false }) }, t('continueEditing')),
h('button', { className: css.danger, type: 'button', onClick: () => controller.close() }, t('discard'))))) : null)), document.body)
}
function interceptDocumentOpen(target, method, controller, result) {
const descriptor = Object.getOwnPropertyDescriptor(target, method)
const intercepted = async () => {
void controller.open()
return result
}
Object.defineProperty(target, method, {
configurable: true,
enumerable: descriptor?.enumerable ?? true,
value: intercepted,
writable: true,
})
return () => {
if (target[method] !== intercepted) return
if (descriptor === undefined) Reflect.deleteProperty(target, method)
else Object.defineProperty(target, method, descriptor)
}
}
export function apply(ctx) {
const controller = new DocumentController()
ctx.effect(() => ctx.locale.register('settings.dshDocumentEditor', {
zh: {
title: '编辑配置文件', filename: 'settings.yaml', close: '关闭', loading: '正在读取配置文件', editorLabel: '配置文件内容',
loadError: '无法读取或保存配置文件。', conflict: '配置文件已在其他位置发生变化,请重新加载后再编辑。', reload: '重新加载',
unsaved: '有未保存的修改', saved: '所有修改均已保存', cancel: '关闭', save: '保存', saving: '正在保存',
discardTitle: '放弃未保存的修改?', discardBody: '关闭后,本次未保存的修改将会丢失。', continueEditing: '继续编辑', discard: '放弃修改',
},
en: {
title: 'Edit configuration file', filename: 'settings.yaml', close: 'Close', loading: 'Loading configuration file', editorLabel: 'Configuration file content',
loadError: 'The configuration file could not be loaded or saved.', conflict: 'The configuration file changed elsewhere. Reload it before editing again.', reload: 'Reload',
unsaved: 'Unsaved changes', saved: 'All changes saved', cancel: 'Close', save: 'Save', saving: 'Saving',
discardTitle: 'Discard unsaved changes?', discardBody: 'Your unsaved changes will be lost when this editor closes.', continueEditing: 'Continue editing', discard: 'Discard changes',
},
}), 'dsh-settings-document-editor: locale')
const connection = ctx.get('connection')
if (typeof connection.api?.settings?.openDocument === 'function') {
ctx.effect(() => interceptDocumentOpen(
connection.api.settings,
'openDocument',
controller,
{ result: { ok: true, value: { opened: true } } },
), 'dsh-settings-document-editor: legacy open action')
} else {
ctx.inject(['remote.settings'], child => {
child.effect(() => interceptDocumentOpen(
child.remote.settings,
'openSettingsDocument',
controller,
{ ok: true, value: { opened: true } },
), 'dsh-settings-document-editor: open action')
})
}
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: 'dsh-settings-document-editor',
order: 80,
locale: 'settings.dshDocumentEditor',
}, props => h(Editor, { ...props, controller })))
}
@@ -0,0 +1,3 @@
export const name = '@dsh-docker/settings-document-editor'
export function apply() {}
@@ -0,0 +1,215 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 24px;
color: var(--dsw-alias-label-primary);
}
.overlay,
.overlay *,
.overlay *::before,
.overlay *::after {
corner-shape: round;
}
.mask {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 48%);
}
.dialog {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
box-sizing: border-box;
width: min(820px, 100%);
height: min(680px, calc(100dvh - 48px));
min-height: 420px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-module-platform);
box-shadow: 0 18px 50px rgb(0 0 0 / 32%);
}
.header,
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
}
.header { border-bottom: 1px solid var(--dsw-alias-border-l2); }
.footer { border-top: 1px solid var(--dsw-alias-border-l2); }
.header h2 { margin: 0; font-size: 17px; line-height: 24px; font-weight: 600; }
.header p { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
.close {
flex: none;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 6px;
color: var(--dsw-alias-label-secondary);
background: transparent;
font: 18px/32px ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
.close:hover { background: var(--dsw-alias-interactive-bg-hover); }
.body { position: relative; min-height: 0; padding: 14px 18px; }
.editorFrame {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 300px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-base);
}
.editorFrame:focus-within { border-color: var(--dsw-alias-border-l2); }
.lineNumbers {
box-sizing: border-box;
min-width: 44px;
height: 100%;
margin: 0;
overflow: hidden;
padding: 12px 10px;
border-right: 1px solid var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-tertiary);
background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, var(--dsw-alias-bg-module-platform));
font: 12px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-align: right;
user-select: none;
}
.editor {
box-sizing: border-box;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
resize: none;
padding: 12px 14px;
border: 0;
outline: none;
color: var(--dsw-alias-label-primary);
background: transparent;
font: 13px/20px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0;
tab-size: 2;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
height: 100%;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
}
.loading span {
width: 14px;
height: 14px;
border: 2px solid var(--dsw-alias-border-l2);
border-top-color: var(--dsw-alias-label-primary);
border-radius: 50%;
animation: document-editor-spin .8s linear infinite;
}
@keyframes document-editor-spin { to { transform: rotate(360deg); } }
.error {
position: absolute;
right: 28px;
bottom: 24px;
left: 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 11px;
border-left: 3px solid var(--dsw-alias-state-error-primary);
color: var(--dsw-alias-state-error-primary);
background: var(--dsw-alias-bg-module-platform);
box-shadow: 0 4px 16px rgb(0 0 0 / 18%);
font-size: 12px;
line-height: 18px;
}
.actions,
.confirmActions { display: flex; justify-content: flex-end; gap: 8px; }
.footer button,
.error button,
.confirmation button {
min-height: 32px;
padding: 4px 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
color: var(--dsw-alias-label-primary);
background: transparent;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.footer button:hover:not(:disabled),
.error button:hover,
.confirmation button:hover { background: var(--dsw-alias-interactive-bg-hover); }
.footer button:disabled { cursor: not-allowed; opacity: .45; }
.footer .primary {
border-color: transparent;
color: var(--dsw-alias-label-primary-foreground);
background: var(--dsw-alias-button-primary-fill);
}
.footer .primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
.saved, .dirty { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
.dirty { color: var(--dsw-alias-state-warn-label); }
.confirmation {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
padding: 24px;
background: rgb(0 0 0 / 48%);
}
.confirmation > div {
box-sizing: border-box;
width: min(420px, 100%);
padding: 18px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-module-platform);
box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
}
.confirmation strong { font-size: 15px; line-height: 22px; }
.confirmation p { margin: 6px 0 18px; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; }
.confirmation .danger { color: var(--dsw-alias-state-error-primary); }
@media (max-width: 640px) {
.overlay { padding: 0; }
.dialog { width: 100%; height: 100dvh; min-height: 0; border: 0; border-radius: 0; }
.header, .footer { padding: 12px 14px; }
.body { padding: 12px 14px; }
.editorFrame { min-height: 0; }
.footer { align-items: flex-start; flex-direction: column; }
.actions { width: 100%; }
.actions button { flex: 1; }
.error { right: 20px; bottom: 20px; left: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.loading span { animation: none; }
}
@@ -0,0 +1,37 @@
{
"name": "@dsh-docker/settings-document-editor",
"version": "1.0.0",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.bundle.js",
"./package.json": "./package.json"
},
"dshDocker": {
"description": {
"zh": "在浏览器中查看和编辑 DSH 配置文件。",
"en": "View and edit the DSH settings document in the browser."
}
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web"
}
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-client-connection": "*",
"@deepseek-ai/dsh-client-locale": "*",
"@deepseek-ai/dsh-api-remotes": "*",
"@deepseek-ai/dsh-client-ui-settings": "*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
@@ -0,0 +1,10 @@
[
{
"insert": [
{
"id": "dsh-docker.settings-navigation.plugin",
"name": "@dsh-docker/settings-navigation"
}
]
}
]
@@ -0,0 +1,267 @@
window.__ModuleLoader__.load({ id: "@dsh-docker/settings-navigation", factory: (require) => {
var module = { exports: {} }; var exports = module.exports;
const css = Object.freeze({"backButton":"dshSettingsNavigation_backButton","backMount":"dshSettingsNavigation_backMount","content":"dshSettingsNavigation_content","header":"dshSettingsNavigation_header","nav":"dshSettingsNavigation_nav","navList":"dshSettingsNavigation_navList","navTitle":"dshSettingsNavigation_navTitle","options":"dshSettingsNavigation_options","panel":"dshSettingsNavigation_panel"});
const styleId = "@dsh-docker/settings-navigation/style.module.css";
if (typeof document !== 'undefined' && ![...document.querySelectorAll('style[data-plugin-css]')].some(tag => tag.dataset.pluginCss === styleId)) {
const tag = document.createElement('style');
tag.dataset.plugin = "@dsh-docker/settings-navigation";
tag.dataset.pluginCss = styleId;
tag.textContent = ".dshSettingsNavigation_panel {\n --dsh-settings-navigation-header-height: 54px;\n}\n\n.dshSettingsNavigation_nav,\n.dshSettingsNavigation_navList,\n.dshSettingsNavigation_content,\n.dshSettingsNavigation_options {\n min-height: 0;\n}\n\n.dshSettingsNavigation_navList {\n flex: 1;\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n scrollbar-gutter: stable;\n padding-bottom: 18px;\n}\n\n.dshSettingsNavigation_backMount {\n display: none;\n flex: none;\n}\n\n.dshSettingsNavigation_backButton {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 0;\n border: 0;\n border-radius: 50%;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n cursor: pointer;\n}\n\n.dshSettingsNavigation_backButton:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dshSettingsNavigation_backButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 2px;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] {\n display: grid;\n grid-template: var(--dsh-settings-navigation-header-height) minmax(0, 1fr) / minmax(0, 1fr);\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_nav,\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_content {\n grid-area: 1 / 1 / 3 / 2;\n width: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_nav {\n padding-right: 16px;\n padding-left: 16px;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_navTitle {\n padding-left: 0;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_navList {\n padding-right: 0;\n padding-bottom: 22px;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_content {\n pointer-events: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_header {\n position: relative;\n z-index: 1;\n pointer-events: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'] .dshSettingsNavigation_header > * {\n pointer-events: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='directory'] .dshSettingsNavigation_options {\n display: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_nav {\n display: none;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_content {\n pointer-events: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_backMount {\n display: inline-flex;\n align-items: center;\n margin-right: auto;\n}\n\n.dshSettingsNavigation_panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .dshSettingsNavigation_options {\n display: block;\n}\n";
document.head.appendChild(tag);
}
const React = require('react')
const { useCallback, useEffect, useRef, useState } = React
const { createPortal } = require('react-dom')
const { IconChevronLeftOutline14 } = require('@deepseek-ai/dsh-client-ui-primitives')
const h = React.createElement
const COMPACT_WIDTH = 640
const NS = 'settings.dshSettingsNavigation'
const inject = ['slots', 'locale']
function directChildren(element) {
return Array.from(element?.children ?? [])
}
function matchSettingsDialog(dialog) {
if (dialog?.getAttribute?.('role') !== 'dialog'
|| dialog.getAttribute('aria-modal') !== 'true') return null
const labelledBy = dialog.getAttribute('aria-labelledby')
const title = labelledBy ? dialog.ownerDocument?.getElementById(labelledBy) : null
if (!title || !dialog.contains(title)) return null
const [nav, content, ...extra] = directChildren(dialog)
if (extra.length !== 0 || nav?.tagName !== 'NAV' || content === undefined
|| !nav.contains(title)) return null
const [navTitle, navList, ...navExtra] = directChildren(nav)
const [header, options, ...contentExtra] = directChildren(content)
if (navExtra.length !== 0 || contentExtra.length !== 0 || navTitle !== title
|| navList === undefined || header === undefined || options === undefined) return null
const buttons = Array.from(navList.querySelectorAll?.('button') ?? [])
const current = buttons.find(button => button.getAttribute('aria-current') === 'true')
if (buttons.length === 0 || current === undefined || header.querySelector?.('button') === null) return null
return { dialog, nav, navTitle, navList, content, header, options, buttons, current }
}
function findSettingsDialog(root) {
for (const dialog of root.querySelectorAll?.('[role="dialog"][aria-modal="true"][aria-labelledby]') ?? []) {
const match = matchSettingsDialog(dialog)
if (match !== null) return match
}
return null
}
class SettingsNavigationController {
constructor({ root, ResizeObserverImpl, MutationObserverImpl, requestFrame, classes, onChange }) {
this.root = root
this.ResizeObserverImpl = ResizeObserverImpl
this.MutationObserverImpl = MutationObserverImpl
this.requestFrame = requestFrame
this.classes = classes
this.onChange = onChange
this.match = null
this.compact = false
this.modeInitialized = false
this.view = 'directory'
this.directoryScrollTop = 0
this.backMount = null
this.resizeObserver = null
this.mutationObserver = null
this.scanQueued = false
}
start() {
this.scan()
this.mutationObserver = new this.MutationObserverImpl(() => this.queueScan())
this.mutationObserver.observe(this.root.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['aria-current', 'aria-labelledby'],
})
}
stop() {
this.mutationObserver?.disconnect()
this.mutationObserver = null
this.detach()
}
queueScan() {
if (this.scanQueued) return
this.scanQueued = true
queueMicrotask(() => {
this.scanQueued = false
this.scan()
})
}
scan() {
const next = findSettingsDialog(this.root)
const sameStructure = next !== null && this.match !== null
&& ['dialog', 'nav', 'navTitle', 'navList', 'content', 'header', 'options']
.every(key => next[key] === this.match[key])
if (sameStructure) {
this.match.current = next.current
this.match.buttons = next.buttons
return
}
this.detach()
if (next !== null) this.attach(next)
}
attach(match) {
this.match = match
const roles = [
['dialog', match.dialog, this.classes.panel],
['nav', match.nav, this.classes.nav],
['nav-title', match.navTitle, this.classes.navTitle],
['nav-list', match.navList, this.classes.navList],
['content', match.content, this.classes.content],
['header', match.header, this.classes.header],
['options', match.options, this.classes.options],
]
for (const [role, node, className] of roles) {
node.dataset.dshSettingsNavigationRole = role
node.classList.add(className)
}
this.backMount = this.root.createElement('span')
this.backMount.className = this.classes.backMount
this.backMount.dataset.dshSettingsNavigationRole = 'back'
match.header.prepend(this.backMount)
match.navList.addEventListener('click', this.onNavClick)
this.view = match.buttons.indexOf(match.current) > 0 ? 'detail' : 'directory'
this.resizeObserver = new this.ResizeObserverImpl(entries => {
const width = entries.at(-1)?.contentRect?.width ?? match.dialog.getBoundingClientRect().width
this.setCompact(width < COMPACT_WIDTH)
})
this.resizeObserver.observe(match.dialog)
this.setCompact(match.dialog.getBoundingClientRect().width < COMPACT_WIDTH)
}
detach() {
if (this.match === null) return
this.resizeObserver?.disconnect()
this.resizeObserver = null
this.match.navList.removeEventListener('click', this.onNavClick)
const roles = [
[this.match.dialog, this.classes.panel],
[this.match.nav, this.classes.nav],
[this.match.navTitle, this.classes.navTitle],
[this.match.navList, this.classes.navList],
[this.match.content, this.classes.content],
[this.match.header, this.classes.header],
[this.match.options, this.classes.options],
]
for (const [node, className] of roles) {
delete node.dataset.dshSettingsNavigationRole
node.classList.remove(className)
}
delete this.match.dialog.dataset.dshSettingsNavigationMode
delete this.match.dialog.dataset.dshSettingsNavigationView
this.backMount?.remove()
this.backMount = null
this.match = null
this.compact = false
this.modeInitialized = false
this.view = 'directory'
this.directoryScrollTop = 0
this.onChange()
}
setCompact(compact) {
if (this.match === null) return
if (this.modeInitialized && compact && !this.compact) this.view = 'detail'
this.compact = compact
this.modeInitialized = true
this.match.dialog.dataset.dshSettingsNavigationMode = compact ? 'compact' : 'wide'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
}
onNavClick = event => {
if (!this.compact || this.match === null) return
const button = event.target.closest?.('button')
if (!button || !this.match.navList.contains(button)) return
this.match.current = button
this.directoryScrollTop = this.match.navList.scrollTop
this.view = 'detail'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
this.requestFrame(() => this.backMount?.querySelector('button')?.focus())
}
back() {
if (!this.compact || this.match === null) return
this.view = 'directory'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
const current = this.match.current
const navList = this.match.navList
const scrollTop = this.directoryScrollTop
this.requestFrame(() => {
current?.focus({ preventScroll: true })
this.requestFrame(() => { navList.scrollTop = scrollTop })
})
}
snapshot() {
return {
active: this.match !== null,
compact: this.compact,
view: this.view,
backMount: this.backMount,
}
}
}
function SettingsNavigationOverlay({ t }) {
const [, render] = useState(0)
const forceRender = useCallback(() => render(value => value + 1), [])
const controllerRef = useRef(null)
useEffect(() => {
const controller = new SettingsNavigationController({
root: document,
ResizeObserverImpl: ResizeObserver,
MutationObserverImpl: MutationObserver,
requestFrame: callback => requestAnimationFrame(callback),
classes: css,
onChange: forceRender,
})
controllerRef.current = controller
controller.start()
return () => {
controller.stop()
controllerRef.current = null
}
}, [forceRender])
const state = controllerRef.current?.snapshot()
if (!state?.active || !state.compact || state.view !== 'detail' || state.backMount === null) return null
return createPortal(h('button', {
type: 'button',
className: css.backButton,
'aria-label': t('back'),
onClick: () => controllerRef.current?.back(),
}, h(IconChevronLeftOutline14, { size: 18 })), state.backMount)
}
function apply(ctx) {
ctx.effect(() => ctx.locale.register(NS, {
zh: { back: '返回设置目录' },
en: { back: 'Back to Settings directory' },
}), 'dsh-settings-navigation: locale')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: 'dsh-settings-navigation',
order: 70,
locale: NS,
}, SettingsNavigationOverlay))
}
exports.inject = inject;
exports.apply = apply;
return module.exports; } });
@@ -0,0 +1,253 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import css from './style.module.css'
const { createPortal } = require('react-dom')
const { IconChevronLeftOutline14 } = require('@deepseek-ai/dsh-client-ui-primitives')
const h = React.createElement
const COMPACT_WIDTH = 640
const NS = 'settings.dshSettingsNavigation'
export const inject = ['slots', 'locale']
function directChildren(element) {
return Array.from(element?.children ?? [])
}
function matchSettingsDialog(dialog) {
if (dialog?.getAttribute?.('role') !== 'dialog'
|| dialog.getAttribute('aria-modal') !== 'true') return null
const labelledBy = dialog.getAttribute('aria-labelledby')
const title = labelledBy ? dialog.ownerDocument?.getElementById(labelledBy) : null
if (!title || !dialog.contains(title)) return null
const [nav, content, ...extra] = directChildren(dialog)
if (extra.length !== 0 || nav?.tagName !== 'NAV' || content === undefined
|| !nav.contains(title)) return null
const [navTitle, navList, ...navExtra] = directChildren(nav)
const [header, options, ...contentExtra] = directChildren(content)
if (navExtra.length !== 0 || contentExtra.length !== 0 || navTitle !== title
|| navList === undefined || header === undefined || options === undefined) return null
const buttons = Array.from(navList.querySelectorAll?.('button') ?? [])
const current = buttons.find(button => button.getAttribute('aria-current') === 'true')
if (buttons.length === 0 || current === undefined || header.querySelector?.('button') === null) return null
return { dialog, nav, navTitle, navList, content, header, options, buttons, current }
}
function findSettingsDialog(root) {
for (const dialog of root.querySelectorAll?.('[role="dialog"][aria-modal="true"][aria-labelledby]') ?? []) {
const match = matchSettingsDialog(dialog)
if (match !== null) return match
}
return null
}
class SettingsNavigationController {
constructor({ root, ResizeObserverImpl, MutationObserverImpl, requestFrame, classes, onChange }) {
this.root = root
this.ResizeObserverImpl = ResizeObserverImpl
this.MutationObserverImpl = MutationObserverImpl
this.requestFrame = requestFrame
this.classes = classes
this.onChange = onChange
this.match = null
this.compact = false
this.modeInitialized = false
this.view = 'directory'
this.directoryScrollTop = 0
this.backMount = null
this.resizeObserver = null
this.mutationObserver = null
this.scanQueued = false
}
start() {
this.scan()
this.mutationObserver = new this.MutationObserverImpl(() => this.queueScan())
this.mutationObserver.observe(this.root.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['aria-current', 'aria-labelledby'],
})
}
stop() {
this.mutationObserver?.disconnect()
this.mutationObserver = null
this.detach()
}
queueScan() {
if (this.scanQueued) return
this.scanQueued = true
queueMicrotask(() => {
this.scanQueued = false
this.scan()
})
}
scan() {
const next = findSettingsDialog(this.root)
const sameStructure = next !== null && this.match !== null
&& ['dialog', 'nav', 'navTitle', 'navList', 'content', 'header', 'options']
.every(key => next[key] === this.match[key])
if (sameStructure) {
this.match.current = next.current
this.match.buttons = next.buttons
return
}
this.detach()
if (next !== null) this.attach(next)
}
attach(match) {
this.match = match
const roles = [
['dialog', match.dialog, this.classes.panel],
['nav', match.nav, this.classes.nav],
['nav-title', match.navTitle, this.classes.navTitle],
['nav-list', match.navList, this.classes.navList],
['content', match.content, this.classes.content],
['header', match.header, this.classes.header],
['options', match.options, this.classes.options],
]
for (const [role, node, className] of roles) {
node.dataset.dshSettingsNavigationRole = role
node.classList.add(className)
}
this.backMount = this.root.createElement('span')
this.backMount.className = this.classes.backMount
this.backMount.dataset.dshSettingsNavigationRole = 'back'
match.header.prepend(this.backMount)
match.navList.addEventListener('click', this.onNavClick)
this.view = match.buttons.indexOf(match.current) > 0 ? 'detail' : 'directory'
this.resizeObserver = new this.ResizeObserverImpl(entries => {
const width = entries.at(-1)?.contentRect?.width ?? match.dialog.getBoundingClientRect().width
this.setCompact(width < COMPACT_WIDTH)
})
this.resizeObserver.observe(match.dialog)
this.setCompact(match.dialog.getBoundingClientRect().width < COMPACT_WIDTH)
}
detach() {
if (this.match === null) return
this.resizeObserver?.disconnect()
this.resizeObserver = null
this.match.navList.removeEventListener('click', this.onNavClick)
const roles = [
[this.match.dialog, this.classes.panel],
[this.match.nav, this.classes.nav],
[this.match.navTitle, this.classes.navTitle],
[this.match.navList, this.classes.navList],
[this.match.content, this.classes.content],
[this.match.header, this.classes.header],
[this.match.options, this.classes.options],
]
for (const [node, className] of roles) {
delete node.dataset.dshSettingsNavigationRole
node.classList.remove(className)
}
delete this.match.dialog.dataset.dshSettingsNavigationMode
delete this.match.dialog.dataset.dshSettingsNavigationView
this.backMount?.remove()
this.backMount = null
this.match = null
this.compact = false
this.modeInitialized = false
this.view = 'directory'
this.directoryScrollTop = 0
this.onChange()
}
setCompact(compact) {
if (this.match === null) return
if (this.modeInitialized && compact && !this.compact) this.view = 'detail'
this.compact = compact
this.modeInitialized = true
this.match.dialog.dataset.dshSettingsNavigationMode = compact ? 'compact' : 'wide'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
}
onNavClick = event => {
if (!this.compact || this.match === null) return
const button = event.target.closest?.('button')
if (!button || !this.match.navList.contains(button)) return
this.match.current = button
this.directoryScrollTop = this.match.navList.scrollTop
this.view = 'detail'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
this.requestFrame(() => this.backMount?.querySelector('button')?.focus())
}
back() {
if (!this.compact || this.match === null) return
this.view = 'directory'
this.match.dialog.dataset.dshSettingsNavigationView = this.view
this.onChange()
const current = this.match.current
const navList = this.match.navList
const scrollTop = this.directoryScrollTop
this.requestFrame(() => {
current?.focus({ preventScroll: true })
this.requestFrame(() => { navList.scrollTop = scrollTop })
})
}
snapshot() {
return {
active: this.match !== null,
compact: this.compact,
view: this.view,
backMount: this.backMount,
}
}
}
function SettingsNavigationOverlay({ t }) {
const [, render] = useState(0)
const forceRender = useCallback(() => render(value => value + 1), [])
const controllerRef = useRef(null)
useEffect(() => {
const controller = new SettingsNavigationController({
root: document,
ResizeObserverImpl: ResizeObserver,
MutationObserverImpl: MutationObserver,
requestFrame: callback => requestAnimationFrame(callback),
classes: css,
onChange: forceRender,
})
controllerRef.current = controller
controller.start()
return () => {
controller.stop()
controllerRef.current = null
}
}, [forceRender])
const state = controllerRef.current?.snapshot()
if (!state?.active || !state.compact || state.view !== 'detail' || state.backMount === null) return null
return createPortal(h('button', {
type: 'button',
className: css.backButton,
'aria-label': t('back'),
onClick: () => controllerRef.current?.back(),
}, h(IconChevronLeftOutline14, { size: 18 })), state.backMount)
}
export function apply(ctx) {
ctx.effect(() => ctx.locale.register(NS, {
zh: { back: '返回设置目录' },
en: { back: 'Back to Settings directory' },
}), 'dsh-settings-navigation: locale')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: 'dsh-settings-navigation',
order: 70,
locale: NS,
}, SettingsNavigationOverlay))
}
@@ -0,0 +1,3 @@
export const name = '@dsh-docker/settings-navigation'
export function apply() {}
@@ -0,0 +1,108 @@
.panel {
--dsh-settings-navigation-header-height: 54px;
}
.nav,
.navList,
.content,
.options {
min-height: 0;
}
.navList {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
padding-bottom: 18px;
}
.backMount {
display: none;
flex: none;
}
.backButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--dsw-alias-label-primary);
background: transparent;
cursor: pointer;
}
.backButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.backButton:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 2px;
}
.panel[data-dsh-settings-navigation-mode='compact'] {
display: grid;
grid-template: var(--dsh-settings-navigation-header-height) minmax(0, 1fr) / minmax(0, 1fr);
}
.panel[data-dsh-settings-navigation-mode='compact'] .nav,
.panel[data-dsh-settings-navigation-mode='compact'] .content {
grid-area: 1 / 1 / 3 / 2;
width: auto;
}
.panel[data-dsh-settings-navigation-mode='compact'] .nav {
padding-right: 16px;
padding-left: 16px;
}
.panel[data-dsh-settings-navigation-mode='compact'] .navTitle {
padding-left: 0;
}
.panel[data-dsh-settings-navigation-mode='compact'] .navList {
padding-right: 0;
padding-bottom: 22px;
}
.panel[data-dsh-settings-navigation-mode='compact'] .content {
pointer-events: none;
}
.panel[data-dsh-settings-navigation-mode='compact'] .header {
position: relative;
z-index: 1;
pointer-events: none;
}
.panel[data-dsh-settings-navigation-mode='compact'] .header > * {
pointer-events: auto;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='directory'] .options {
display: none;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .nav {
display: none;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .content {
pointer-events: auto;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .backMount {
display: inline-flex;
align-items: center;
margin-right: auto;
}
.panel[data-dsh-settings-navigation-mode='compact'][data-dsh-settings-navigation-view='detail'] .options {
display: block;
}
@@ -0,0 +1,32 @@
{
"name": "@dsh-docker/settings-navigation",
"version": "1.0.0",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.bundle.js",
"./package.json": "./package.json"
},
"dshDocker": {
"description": {
"zh": "优化 DSH 设置目录滚动和窄屏分级导航。",
"en": "Improve DSH Settings directory scrolling and compact navigation."
}
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-client-locale": "*",
"@deepseek-ai/dsh-client-ui-primitives": "*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}