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:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
+10
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"insert": [
|
||||
{
|
||||
"id": "dsh-docker.platform-management.plugin",
|
||||
"name": "@dsh-docker/platform-management"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
+2818
File diff suppressed because it is too large.
Load diff
+2804
File diff suppressed because it is too large.
Load diff
+128
@@ -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,
|
||||
})
|
||||
+248
@@ -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,
|
||||
})
|
||||
+1122
File diff suppressed because it is too large.
Load diff
Generated
+32
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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"
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"insert": [
|
||||
{
|
||||
"id": "dsh-docker.settings-document-editor.plugin",
|
||||
"name": "@dsh-docker/settings-document-editor"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
+270
@@ -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; } });
|
||||
+256
@@ -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 })))
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const name = '@dsh-docker/settings-document-editor'
|
||||
|
||||
export function apply() {}
|
||||
+215
@@ -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; }
|
||||
}
|
||||
+37
@@ -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"
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"insert": [
|
||||
{
|
||||
"id": "dsh-docker.settings-navigation.plugin",
|
||||
"name": "@dsh-docker/settings-navigation"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
+267
@@ -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; } });
|
||||
+253
@@ -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))
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const name = '@dsh-docker/settings-navigation'
|
||||
|
||||
export function apply() {}
|
||||
+108
@@ -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;
|
||||
}
|
||||
+32
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user