798 lines
33 KiB
JavaScript
798 lines
33 KiB
JavaScript
|
|
window.__ModuleLoader__.load({
|
|||
|
|
id: 'dsh-abap-workbench',
|
|||
|
|
factory(require) {
|
|||
|
|
const React = require('react')
|
|||
|
|
const h = React.createElement
|
|||
|
|
const { useCallback, useEffect, useMemo, useState } = React
|
|||
|
|
|
|||
|
|
const NS = 'abap-adt-ui'
|
|||
|
|
const API = '/abap-adt'
|
|||
|
|
const DICTS = {
|
|||
|
|
zh: {
|
|||
|
|
sectionLabel: 'ABAP ADT',
|
|||
|
|
title: 'ABAP ADT 配置',
|
|||
|
|
subtitle: '管理当前工作区的 SAP ADT 目的地(写入 .dsh-abap-adt/destinations.yaml,立即生效)',
|
|||
|
|
readOnlyBadge: '只读预设',
|
|||
|
|
workspace: '工作区',
|
|||
|
|
workspaceHint: '目的地文件写到该工作区的 .dsh-abap-adt/ 目录',
|
|||
|
|
refresh: '刷新',
|
|||
|
|
destinations: '目的地',
|
|||
|
|
empty: '还没有配置目的地。内置 demo 模拟系统始终可用。',
|
|||
|
|
add: '新增目的地',
|
|||
|
|
name: '名称',
|
|||
|
|
url: 'URL',
|
|||
|
|
client: '客户端',
|
|||
|
|
language: '语言',
|
|||
|
|
username: '用户名',
|
|||
|
|
passwordEnv: '密码引用',
|
|||
|
|
passwordHint: '只填引用名(如 ADT_DEV_PASSWORD),真值存 DSH 凭据库或环境变量;不要写明文',
|
|||
|
|
strictSSL: '严格 TLS',
|
|||
|
|
profile: '环境',
|
|||
|
|
description: '说明',
|
|||
|
|
defaultDestination: '默认目的地',
|
|||
|
|
save: '保存',
|
|||
|
|
saving: '保存中…',
|
|||
|
|
saved: '已保存',
|
|||
|
|
modeTitle: '运行模式(本工作区)',
|
|||
|
|
modes: { 'read-only': '只读', debug: '调试', change: '更改' },
|
|||
|
|
modeHints: {
|
|||
|
|
'read-only': '只读:写/激活/删除/传输/执行/调试全部关闭,只做调查与补丁建议。',
|
|||
|
|
debug: '调试:允许执行以触发断点,可用 adt_debug 设断点/单步/看变量;写与传输仍关闭。',
|
|||
|
|
change: '更改:按白名单可写(含传输,每次写需给请求号),允许执行与调试器;写前先给用户看 diff。',
|
|||
|
|
},
|
|||
|
|
modeSwitched: '运行模式已切换',
|
|||
|
|
whitelistTitle: '可写白名单(更改模式)',
|
|||
|
|
allowedPackages: '允许编辑的包',
|
|||
|
|
allowedPackagesHint: '逗号分隔的 glob,例如 Z*,$TMP,Y*;$TMP 为本地对象包',
|
|||
|
|
allowedTransports: '允许的传输请求号',
|
|||
|
|
allowedTransportsHint: '逗号分隔的 glob,例如 D01K96*;* 表示不限制(仍要求用户提供请求号)',
|
|||
|
|
whitelistDisabled: '白名单只在「更改」模式生效;先切到更改模式再编辑',
|
|||
|
|
applyWhitelist: '应用白名单',
|
|||
|
|
whitelistSaved: '白名单已保存并写入策略',
|
|||
|
|
policyTitle: '当前生效策略(来自 destinations.yaml 的 policy 段)',
|
|||
|
|
policyNote: '运行模式由本工作区决定(上方切换),以 policy 段写入 destinations.yaml;预设本身只带安全基线(只读)。',
|
|||
|
|
test: '测试连接',
|
|||
|
|
testing: '测试中…',
|
|||
|
|
remove: '删除',
|
|||
|
|
loadError: '读取配置失败',
|
|||
|
|
saveError: '保存失败',
|
|||
|
|
chipPrefix: 'SAP',
|
|||
|
|
chipDemo: 'demo',
|
|||
|
|
chipDest: '个目的地',
|
|||
|
|
chipReadOnly: '只读',
|
|||
|
|
chipError: 'ADT 状态未知',
|
|||
|
|
chipTitle: '切换该工作区的默认 ADT 目的地(作用于本工作区所有会话)',
|
|||
|
|
chipSwitched: '已切换,下次 adt_* 调用生效',
|
|||
|
|
chipSwitchFailed: '切换失败',
|
|||
|
|
chipGateNote: '状态条只在对该工作区配置了 ABAP(存在 .dsh-abap-adt/destinations.yaml)时显示;可在插件配置 workspaces 白名单里进一步限定。',
|
|||
|
|
fileLabel: '配置文件',
|
|||
|
|
notExists: '(尚未创建,保存后生成)',
|
|||
|
|
never: '—',
|
|||
|
|
},
|
|||
|
|
en: {
|
|||
|
|
sectionLabel: 'ABAP ADT',
|
|||
|
|
title: 'ABAP ADT Settings',
|
|||
|
|
subtitle: 'Manage SAP ADT destinations for this workspace (written to .dsh-abap-adt/destinations.yaml, applied immediately)',
|
|||
|
|
readOnlyBadge: 'Read-only preset',
|
|||
|
|
workspace: 'Workspace',
|
|||
|
|
workspaceHint: 'The destinations file is written under .dsh-abap-adt/ in this workspace',
|
|||
|
|
refresh: 'Refresh',
|
|||
|
|
destinations: 'Destinations',
|
|||
|
|
empty: 'No destinations configured yet. The built-in demo mock system is always available.',
|
|||
|
|
add: 'Add destination',
|
|||
|
|
name: 'Name',
|
|||
|
|
url: 'URL',
|
|||
|
|
client: 'Client',
|
|||
|
|
language: 'Language',
|
|||
|
|
username: 'User',
|
|||
|
|
passwordEnv: 'Password ref',
|
|||
|
|
passwordHint: 'Use a reference name (e.g. ADT_DEV_PASSWORD); the value lives in the DSH credential store or environment. Never paste a password here.',
|
|||
|
|
strictSSL: 'Strict TLS',
|
|||
|
|
profile: 'Profile',
|
|||
|
|
description: 'Description',
|
|||
|
|
defaultDestination: 'Default destination',
|
|||
|
|
save: 'Save',
|
|||
|
|
saving: 'Saving…',
|
|||
|
|
saved: 'Saved',
|
|||
|
|
modeTitle: 'Run mode (this workspace)',
|
|||
|
|
modes: { 'read-only': 'Read-only', debug: 'Debug', change: 'Change' },
|
|||
|
|
modeHints: {
|
|||
|
|
'read-only': 'Read-only: writes, activation, deletion, transports, execution and the debugger are off; investigate and propose patches only.',
|
|||
|
|
debug: 'Debug: execution is allowed to trigger breakpoints; adt_debug is available; writes and transports stay off.',
|
|||
|
|
change: 'Change: whitelisted packages are writable (transports need the request number on every write), execution and the debugger are allowed; show a diff before writing.',
|
|||
|
|
},
|
|||
|
|
modeSwitched: 'Run mode switched',
|
|||
|
|
whitelistTitle: 'Write whitelists (change mode)',
|
|||
|
|
allowedPackages: 'Editable packages',
|
|||
|
|
allowedPackagesHint: 'Comma-separated globs, e.g. Z*,$TMP,Y*; $TMP is the local-objects package',
|
|||
|
|
allowedTransports: 'Allowed transport numbers',
|
|||
|
|
allowedTransportsHint: 'Comma-separated globs, e.g. D01K96*; * means unrestricted (the request number is still required from the user)',
|
|||
|
|
whitelistDisabled: 'Whitelists only apply in Change mode; switch to Change first',
|
|||
|
|
applyWhitelist: 'Apply whitelists',
|
|||
|
|
whitelistSaved: 'Whitelists saved and written into the policy',
|
|||
|
|
policyTitle: 'Effective policy (from the destinations.yaml policy block)',
|
|||
|
|
policyNote: 'The run mode belongs to this workspace (switch above) and is written as a policy block into destinations.yaml; the preset itself only carries the read-only baseline.',
|
|||
|
|
test: 'Test connection',
|
|||
|
|
testing: 'Testing…',
|
|||
|
|
remove: 'Remove',
|
|||
|
|
policyTitle: 'Permission policy (locked by the preset)',
|
|||
|
|
policyNote: 'The current session preset locks SAP writes (sentinel allowedPackages + transports/execution/debugger off). This page manages destinations only; switch to a writable preset to edit code.',
|
|||
|
|
loadError: 'Failed to load configuration',
|
|||
|
|
saveError: 'Save failed',
|
|||
|
|
chipPrefix: 'SAP',
|
|||
|
|
chipDemo: 'demo',
|
|||
|
|
chipDest: 'dest(s)',
|
|||
|
|
chipReadOnly: 'read-only',
|
|||
|
|
chipError: 'ADT status unknown',
|
|||
|
|
chipTitle: 'Switch this workspace default ADT destination (applies to every session in the workspace)',
|
|||
|
|
chipSwitched: 'Switched — applies to the next adt_* call',
|
|||
|
|
chipSwitchFailed: 'Switch failed',
|
|||
|
|
chipGateNote: 'The status chip only appears where ABAP is configured for the workspace (.dsh-abap-adt/destinations.yaml exists); narrow it further with the plugin config workspaces allowlist.',
|
|||
|
|
fileLabel: 'Config file',
|
|||
|
|
notExists: ' (not created yet — save to create)',
|
|||
|
|
never: '—',
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const C = {
|
|||
|
|
text: 'var(--dsw-alias-label-primary, #1f2328)',
|
|||
|
|
muted: 'var(--dsw-alias-label-secondary, #6b7280)',
|
|||
|
|
border: 'var(--dsw-alias-border-l1, rgba(128,128,128,.28))',
|
|||
|
|
borderStrong: 'var(--dsw-alias-border-l2, rgba(128,128,128,.45))',
|
|||
|
|
surface: 'var(--dsw-alias-bg-layer-1, #ffffff)',
|
|||
|
|
surface2: 'var(--dsw-alias-bg-layer-2, #f5f6f7)',
|
|||
|
|
brand: 'var(--dsw-alias-brand-primary, #0a6ed1)',
|
|||
|
|
ok: 'var(--dsw-alias-state-success-primary, #1a7f37)',
|
|||
|
|
err: 'var(--dsw-alias-state-error-primary, #cf222e)',
|
|||
|
|
warn: 'var(--dsw-alias-state-warn-primary, #9a6700)',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const inputStyle = {
|
|||
|
|
boxSizing: 'border-box',
|
|||
|
|
width: '100%',
|
|||
|
|
padding: '6px 8px',
|
|||
|
|
fontSize: 13,
|
|||
|
|
color: C.text,
|
|||
|
|
background: C.surface,
|
|||
|
|
border: `1px solid ${C.border}`,
|
|||
|
|
borderRadius: 6,
|
|||
|
|
outline: 'none',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const buttonStyle = (primary) => ({
|
|||
|
|
padding: '6px 12px',
|
|||
|
|
fontSize: 13,
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
borderRadius: 6,
|
|||
|
|
border: `1px solid ${primary ? C.brand : C.borderStrong}`,
|
|||
|
|
background: primary ? C.brand : 'transparent',
|
|||
|
|
color: primary ? '#ffffff' : C.text,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const smallButtonStyle = {
|
|||
|
|
padding: '4px 8px',
|
|||
|
|
fontSize: 12,
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
borderRadius: 5,
|
|||
|
|
border: `1px solid ${C.border}`,
|
|||
|
|
background: 'transparent',
|
|||
|
|
color: C.text,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function api(path, init) {
|
|||
|
|
const response = await fetch(`${API}${path}`, {
|
|||
|
|
credentials: 'same-origin',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
...init,
|
|||
|
|
})
|
|||
|
|
const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` }))
|
|||
|
|
if (!response.ok || payload.ok === false) throw new Error(payload.error || `HTTP ${response.status}`)
|
|||
|
|
return payload
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function blankDestination() {
|
|||
|
|
return { name: '', url: '', client: '100', language: 'EN', username: '', passwordEnv: '', strictSSL: false, profile: 'dev', description: '' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function Field({ label, hint, children }) {
|
|||
|
|
return h(
|
|||
|
|
'label',
|
|||
|
|
{ style: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 } },
|
|||
|
|
h('span', { style: { fontSize: 12, color: C.muted } }, label),
|
|||
|
|
children,
|
|||
|
|
hint ? h('span', { style: { fontSize: 11, color: C.muted, lineHeight: 1.4 } }, hint) : null,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function Badge({ children, tone }) {
|
|||
|
|
const toneColor =
|
|||
|
|
tone === 'ok' ? C.ok : tone === 'warn' ? C.warn : tone === 'err' ? C.err : tone === 'brand' ? C.brand : C.muted
|
|||
|
|
return h(
|
|||
|
|
'span',
|
|||
|
|
{
|
|||
|
|
style: {
|
|||
|
|
display: 'inline-flex',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
gap: 4,
|
|||
|
|
padding: '2px 8px',
|
|||
|
|
fontSize: 11,
|
|||
|
|
fontWeight: 600,
|
|||
|
|
borderRadius: 999,
|
|||
|
|
border: `1px solid ${toneColor}`,
|
|||
|
|
color: toneColor,
|
|||
|
|
background: 'transparent',
|
|||
|
|
whiteSpace: 'nowrap',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
children,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function SettingsPage() {
|
|||
|
|
const [loading, setLoading] = useState(true)
|
|||
|
|
const [error, setError] = useState('')
|
|||
|
|
const [workspace, setWorkspace] = useState('')
|
|||
|
|
const [workspaces, setWorkspaces] = useState([])
|
|||
|
|
const [file, setFile] = useState(null)
|
|||
|
|
const [defaultDestination, setDefaultDestination] = useState('')
|
|||
|
|
const [destinations, setDestinations] = useState([])
|
|||
|
|
const [policy, setPolicy] = useState({})
|
|||
|
|
const [mode, setMode] = useState('read-only')
|
|||
|
|
const [whitelist, setWhitelist] = useState(null)
|
|||
|
|
const [saving, setSaving] = useState(false)
|
|||
|
|
const [message, setMessage] = useState('')
|
|||
|
|
const [messageTone, setMessageTone] = useState('ok')
|
|||
|
|
const [tests, setTests] = useState({})
|
|||
|
|
const [testing, setTesting] = useState({})
|
|||
|
|
|
|||
|
|
const load = useCallback(async (target) => {
|
|||
|
|
setLoading(true)
|
|||
|
|
setError('')
|
|||
|
|
setMessage('')
|
|||
|
|
try {
|
|||
|
|
const query = new URLSearchParams()
|
|||
|
|
if (target) query.set('workspace', target)
|
|||
|
|
const payload = await api(`/state?${query.toString()}`)
|
|||
|
|
setWorkspace(payload.workspace || '')
|
|||
|
|
setWorkspaces(payload.workspaces || [])
|
|||
|
|
setFile(payload.file || null)
|
|||
|
|
setDefaultDestination(payload.defaultDestination || '')
|
|||
|
|
setDestinations(payload.destinations || [])
|
|||
|
|
setPolicy(payload.policy || {})
|
|||
|
|
setMode(payload.mode || 'read-only')
|
|||
|
|
setWhitelist(payload.whitelist || null)
|
|||
|
|
setTests({})
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(err.message)
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false)
|
|||
|
|
}
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
load()
|
|||
|
|
}, [load])
|
|||
|
|
|
|||
|
|
const destinationNames = useMemo(
|
|||
|
|
() => destinations.map((dest) => dest.name).filter((value) => value && value.trim()),
|
|||
|
|
[destinations],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const updateDestination = (index, key, value) => {
|
|||
|
|
setDestinations((rows) => rows.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const addDestination = () => {
|
|||
|
|
setDestinations((rows) => [...rows, blankDestination()])
|
|||
|
|
setMessage('')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const removeDestination = (index) => {
|
|||
|
|
setDestinations((rows) => rows.filter((_, rowIndex) => rowIndex !== index))
|
|||
|
|
setMessage('')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const save = async () => {
|
|||
|
|
setSaving(true)
|
|||
|
|
setMessage('')
|
|||
|
|
try {
|
|||
|
|
// Round-trip only the fields this page edits: schema defaults such as
|
|||
|
|
// timeoutMs must not be re-serialized from a display value.
|
|||
|
|
const payloadDestinations = destinations.map((dest) => ({
|
|||
|
|
name: dest.name,
|
|||
|
|
url: dest.url,
|
|||
|
|
description: dest.description,
|
|||
|
|
client: dest.client,
|
|||
|
|
language: dest.language,
|
|||
|
|
username: dest.username,
|
|||
|
|
passwordEnv: dest.passwordEnv,
|
|||
|
|
strictSSL: dest.strictSSL,
|
|||
|
|
profile: dest.profile,
|
|||
|
|
}))
|
|||
|
|
const payload = await api('/save', {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: JSON.stringify({ workspace, defaultDestination, destinations: payloadDestinations }),
|
|||
|
|
})
|
|||
|
|
setMessage(`${DICTS.zh.saved}: ${payload.path}`)
|
|||
|
|
setMessageTone('ok')
|
|||
|
|
await load(workspace)
|
|||
|
|
} catch (err) {
|
|||
|
|
setMessage(`${DICTS.zh.saveError}: ${err.message}`)
|
|||
|
|
setMessageTone('err')
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const switchMode = async (next) => {
|
|||
|
|
if (next === mode || !workspace) return
|
|||
|
|
setSaving(true)
|
|||
|
|
setMessage('')
|
|||
|
|
try {
|
|||
|
|
await api('/mode', { method: 'POST', body: JSON.stringify({ workspace, mode: next }) })
|
|||
|
|
await load(workspace)
|
|||
|
|
setMessage(`${DICTS.zh.modeSwitched}: ${DICTS.zh.modes[next]}`)
|
|||
|
|
setMessageTone('ok')
|
|||
|
|
} catch (err) {
|
|||
|
|
setMessage(`${DICTS.zh.saveError}: ${err.message}`)
|
|||
|
|
setMessageTone('err')
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const applyWhitelist = async () => {
|
|||
|
|
if (!workspace || !whitelist) return
|
|||
|
|
setSaving(true)
|
|||
|
|
setMessage('')
|
|||
|
|
try {
|
|||
|
|
await api('/mode', {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
workspace,
|
|||
|
|
mode,
|
|||
|
|
allowedPackages: whitelist.allowedPackages,
|
|||
|
|
allowedTransports: whitelist.allowedTransports,
|
|||
|
|
}),
|
|||
|
|
})
|
|||
|
|
await load(workspace)
|
|||
|
|
setMessage(DICTS.zh.whitelistSaved)
|
|||
|
|
setMessageTone('ok')
|
|||
|
|
} catch (err) {
|
|||
|
|
setMessage(`${DICTS.zh.saveError}: ${err.message}`)
|
|||
|
|
setMessageTone('err')
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const test = async (dest) => {
|
|||
|
|
const key = dest.name || `#${destinations.indexOf(dest) + 1}`
|
|||
|
|
setTesting((map) => ({ ...map, [key]: true }))
|
|||
|
|
try {
|
|||
|
|
const payload = await api('/ping', {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
url: dest.url,
|
|||
|
|
client: dest.client,
|
|||
|
|
language: dest.language,
|
|||
|
|
username: dest.username,
|
|||
|
|
passwordEnv: dest.passwordEnv,
|
|||
|
|
strictSSL: dest.strictSSL,
|
|||
|
|
}),
|
|||
|
|
})
|
|||
|
|
setTests((map) => ({ ...map, [key]: payload.result }))
|
|||
|
|
} catch (err) {
|
|||
|
|
setTests((map) => ({ ...map, [key]: { ok: false, error: err.message, status: 0, ms: 0 } }))
|
|||
|
|
} finally {
|
|||
|
|
setTesting((map) => ({ ...map, [key]: false }))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return h('div', { style: { padding: 24, color: C.muted, fontSize: 13 } }, '…')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const header = h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
|
|||
|
|
h('div', { style: { fontSize: 18, fontWeight: 700, color: C.text } }, DICTS.zh.title),
|
|||
|
|
h(Badge, { tone: mode === 'change' ? 'err' : mode === 'debug' ? 'brand' : 'warn' }, DICTS.zh.modes[mode] || mode),
|
|||
|
|
h('div', { style: { flex: 1 } }),
|
|||
|
|
h('button', { style: smallButtonStyle, onClick: () => load(workspace), type: 'button' }, DICTS.zh.refresh),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const errorBox = error
|
|||
|
|
? h(
|
|||
|
|
'div',
|
|||
|
|
{
|
|||
|
|
style: {
|
|||
|
|
marginTop: 12,
|
|||
|
|
padding: '10px 12px',
|
|||
|
|
borderRadius: 8,
|
|||
|
|
border: `1px solid ${C.err}`,
|
|||
|
|
color: C.err,
|
|||
|
|
fontSize: 13,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
`${DICTS.zh.loadError}: ${error}`,
|
|||
|
|
)
|
|||
|
|
: null
|
|||
|
|
|
|||
|
|
const workspaceRow = h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12, marginTop: 16 } },
|
|||
|
|
h(
|
|||
|
|
Field,
|
|||
|
|
{ label: DICTS.zh.workspace, hint: DICTS.zh.workspaceHint },
|
|||
|
|
h('input', {
|
|||
|
|
style: inputStyle,
|
|||
|
|
value: workspace,
|
|||
|
|
list: 'abap-adt-workspaces',
|
|||
|
|
onChange: (event) => setWorkspace(event.target.value),
|
|||
|
|
onBlur: () => {
|
|||
|
|
if (workspace && workspace !== file?.path?.replace(/[\\/]\.dsh-abap-adt[\\/].*$/, '')) load(workspace)
|
|||
|
|
},
|
|||
|
|
placeholder: 'D:\\AIagent\\SAP',
|
|||
|
|
}),
|
|||
|
|
),
|
|||
|
|
h(
|
|||
|
|
Field,
|
|||
|
|
{ label: DICTS.zh.fileLabel },
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { fontSize: 12, color: C.muted, wordBreak: 'break-all', paddingTop: 6 } },
|
|||
|
|
file?.path ? `${file.path}${file.exists ? '' : DICTS.zh.notExists}` : DICTS.zh.never,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
h(
|
|||
|
|
'datalist',
|
|||
|
|
{ id: 'abap-adt-workspaces' },
|
|||
|
|
workspaces.map((item) => h('option', { key: item.id || item.path, value: item.path }, item.title)),
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const destinationCards = destinations.length
|
|||
|
|
? destinations.map((dest, index) => {
|
|||
|
|
const key = dest.name || `#${index + 1}`
|
|||
|
|
const result = tests[key]
|
|||
|
|
return h(
|
|||
|
|
'div',
|
|||
|
|
{
|
|||
|
|
key: index,
|
|||
|
|
style: {
|
|||
|
|
marginTop: 12,
|
|||
|
|
padding: 12,
|
|||
|
|
borderRadius: 10,
|
|||
|
|
border: `1px solid ${C.border}`,
|
|||
|
|
background: C.surface2,
|
|||
|
|
display: 'flex',
|
|||
|
|
flexDirection: 'column',
|
|||
|
|
gap: 10,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 10 } },
|
|||
|
|
h(Field, { label: DICTS.zh.name }, h('input', { style: inputStyle, value: dest.name || '', onChange: (e) => updateDestination(index, 'name', e.target.value), placeholder: 'dev' })),
|
|||
|
|
h(Field, { label: DICTS.zh.url }, h('input', { style: inputStyle, value: dest.url || '', onChange: (e) => updateDestination(index, 'url', e.target.value), placeholder: 'https://sap.example.com:44300' })),
|
|||
|
|
h(Field, { label: DICTS.zh.client }, h('input', { style: inputStyle, value: dest.client || '', onChange: (e) => updateDestination(index, 'client', e.target.value), placeholder: '100' })),
|
|||
|
|
h(Field, { label: DICTS.zh.language }, h('input', { style: inputStyle, value: dest.language || '', onChange: (e) => updateDestination(index, 'language', e.target.value), placeholder: 'ZH' })),
|
|||
|
|
),
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 10 } },
|
|||
|
|
h(Field, { label: DICTS.zh.username }, h('input', { style: inputStyle, value: dest.username || '', onChange: (e) => updateDestination(index, 'username', e.target.value) })),
|
|||
|
|
h(Field, { label: DICTS.zh.passwordEnv, hint: DICTS.zh.passwordHint }, h('input', { style: inputStyle, value: dest.passwordEnv || '', onChange: (e) => updateDestination(index, 'passwordEnv', e.target.value), placeholder: 'ADT_DEV_PASSWORD' })),
|
|||
|
|
h(Field, { label: DICTS.zh.profile }, h('select', { style: inputStyle, value: dest.profile || 'dev', onChange: (e) => updateDestination(index, 'profile', e.target.value) }, ['dev', 'qa', 'prd'].map((value) => h('option', { key: value, value }, value)))),
|
|||
|
|
h(Field, { label: DICTS.zh.description }, h('input', { style: inputStyle, value: dest.description || '', onChange: (e) => updateDestination(index, 'description', e.target.value) })),
|
|||
|
|
),
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } },
|
|||
|
|
h(
|
|||
|
|
'label',
|
|||
|
|
{ style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: C.muted } },
|
|||
|
|
h('input', { type: 'checkbox', checked: dest.strictSSL !== false, onChange: (e) => updateDestination(index, 'strictSSL', e.target.checked) }),
|
|||
|
|
DICTS.zh.strictSSL,
|
|||
|
|
),
|
|||
|
|
h('div', { style: { flex: 1 } }),
|
|||
|
|
result
|
|||
|
|
? h(
|
|||
|
|
'span',
|
|||
|
|
{ style: { fontSize: 12, color: result.ok ? C.ok : C.err } },
|
|||
|
|
`${result.status || 'ERR'} · ${result.ms}ms${result.error ? ` · ${result.error}` : ''}`,
|
|||
|
|
)
|
|||
|
|
: null,
|
|||
|
|
h('button', { type: 'button', style: smallButtonStyle, onClick: () => test(dest), disabled: testing[key] }, testing[key] ? DICTS.zh.testing : DICTS.zh.test),
|
|||
|
|
h('button', { type: 'button', style: smallButtonStyle, onClick: () => removeDestination(index) }, DICTS.zh.remove),
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
: h('div', { style: { marginTop: 12, fontSize: 13, color: C.muted } }, DICTS.zh.empty)
|
|||
|
|
|
|||
|
|
const policyRows = Object.entries(policy).length
|
|||
|
|
? Object.entries(policy).map(([key, value]) =>
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ key, style: { display: 'flex', gap: 10, fontSize: 12, padding: '3px 0' } },
|
|||
|
|
h('span', { style: { minWidth: 210, color: C.muted, fontFamily: 'ui-monospace, monospace' } }, key),
|
|||
|
|
h('span', { style: { color: C.text, wordBreak: 'break-all' } }, Array.isArray(value) ? value.join(', ') || '(empty)' : String(value)),
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
: h('div', { style: { fontSize: 12, color: C.muted } }, '—')
|
|||
|
|
|
|||
|
|
return h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { padding: '20px 24px 40px', color: C.text, maxWidth: 1080 } },
|
|||
|
|
header,
|
|||
|
|
h('div', { style: { marginTop: 6, fontSize: 12.5, color: C.muted } }, DICTS.zh.subtitle),
|
|||
|
|
errorBox,
|
|||
|
|
workspaceRow,
|
|||
|
|
h('div', { style: { marginTop: 6, fontSize: 11.5, color: C.muted, lineHeight: 1.5 } }, DICTS.zh.chipGateNote),
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{
|
|||
|
|
style: {
|
|||
|
|
marginTop: 16,
|
|||
|
|
padding: 12,
|
|||
|
|
borderRadius: 10,
|
|||
|
|
border: `1px solid ${C.border}`,
|
|||
|
|
background: C.surface2,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|||
|
|
h('span', { style: { fontSize: 13, fontWeight: 650 } }, DICTS.zh.modeTitle),
|
|||
|
|
['read-only', 'debug', 'change'].map((value) =>
|
|||
|
|
h(
|
|||
|
|
'button',
|
|||
|
|
{
|
|||
|
|
key: value,
|
|||
|
|
type: 'button',
|
|||
|
|
disabled: saving || !workspace,
|
|||
|
|
onClick: () => switchMode(value),
|
|||
|
|
style: { ...buttonStyle(value === mode), padding: '5px 14px' },
|
|||
|
|
},
|
|||
|
|
DICTS.zh.modes[value],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
h('div', { style: { flex: 1 } }),
|
|||
|
|
h(Badge, { tone: mode === 'change' ? 'err' : mode === 'debug' ? 'brand' : 'warn' }, DICTS.zh.modes[mode] || mode),
|
|||
|
|
),
|
|||
|
|
h('div', { style: { marginTop: 8, fontSize: 12, color: C.muted, lineHeight: 1.5 } }, DICTS.zh.modeHints[mode] || ''),
|
|||
|
|
whitelist
|
|||
|
|
? h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { marginTop: 12, paddingTop: 12, borderTop: `1px solid ${C.border}` } },
|
|||
|
|
h('div', { style: { fontSize: 12.5, fontWeight: 600, marginBottom: 8 } }, DICTS.zh.whitelistTitle),
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 12, alignItems: 'end' } },
|
|||
|
|
h(
|
|||
|
|
Field,
|
|||
|
|
{
|
|||
|
|
label: DICTS.zh.allowedPackages,
|
|||
|
|
hint: whitelist.editable ? DICTS.zh.allowedPackagesHint : DICTS.zh.whitelistDisabled,
|
|||
|
|
},
|
|||
|
|
h('input', {
|
|||
|
|
style: inputStyle,
|
|||
|
|
value: whitelist.allowedPackages || '',
|
|||
|
|
disabled: !whitelist.editable || saving,
|
|||
|
|
onChange: (event) => setWhitelist((value) => ({ ...value, allowedPackages: event.target.value })),
|
|||
|
|
}),
|
|||
|
|
),
|
|||
|
|
h(
|
|||
|
|
Field,
|
|||
|
|
{
|
|||
|
|
label: DICTS.zh.allowedTransports,
|
|||
|
|
hint: whitelist.editable ? DICTS.zh.allowedTransportsHint : DICTS.zh.whitelistDisabled,
|
|||
|
|
},
|
|||
|
|
h('input', {
|
|||
|
|
style: inputStyle,
|
|||
|
|
value: whitelist.allowedTransports || '',
|
|||
|
|
disabled: !whitelist.editable || saving,
|
|||
|
|
onChange: (event) => setWhitelist((value) => ({ ...value, allowedTransports: event.target.value })),
|
|||
|
|
}),
|
|||
|
|
),
|
|||
|
|
h(
|
|||
|
|
'button',
|
|||
|
|
{
|
|||
|
|
type: 'button',
|
|||
|
|
style: buttonStyle(true),
|
|||
|
|
disabled: !whitelist.editable || saving,
|
|||
|
|
onClick: applyWhitelist,
|
|||
|
|
},
|
|||
|
|
DICTS.zh.applyWhitelist,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
: null,
|
|||
|
|
),
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { marginTop: 24, display: 'flex', alignItems: 'center', gap: 10 } },
|
|||
|
|
h('div', { style: { fontSize: 15, fontWeight: 650 } }, DICTS.zh.destinations),
|
|||
|
|
h('div', { style: { flex: 1 } }),
|
|||
|
|
h('button', { type: 'button', style: smallButtonStyle, onClick: addDestination }, `+ ${DICTS.zh.add}`),
|
|||
|
|
),
|
|||
|
|
destinationCards,
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { marginTop: 16, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 } },
|
|||
|
|
h(
|
|||
|
|
Field,
|
|||
|
|
{ label: DICTS.zh.defaultDestination },
|
|||
|
|
h(
|
|||
|
|
'select',
|
|||
|
|
{ style: inputStyle, value: defaultDestination, onChange: (e) => setDefaultDestination(e.target.value) },
|
|||
|
|
h('option', { value: '' }, 'demo'),
|
|||
|
|
destinationNames.map((value) => h('option', { key: value, value }, value)),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { marginTop: 16, display: 'flex', alignItems: 'center', gap: 12 } },
|
|||
|
|
h('button', { type: 'button', style: buttonStyle(true), onClick: save, disabled: saving || !workspace }, saving ? DICTS.zh.saving : DICTS.zh.save),
|
|||
|
|
message ? h('span', { style: { fontSize: 12, color: messageTone === 'err' ? C.err : C.ok, wordBreak: 'break-all' } }, message) : null,
|
|||
|
|
),
|
|||
|
|
h(
|
|||
|
|
'div',
|
|||
|
|
{ style: { marginTop: 28, padding: 14, borderRadius: 10, border: `1px solid ${C.border}`, background: C.surface2 } },
|
|||
|
|
h('div', { style: { fontSize: 14, fontWeight: 650 } }, DICTS.zh.policyTitle),
|
|||
|
|
h('div', { style: { marginTop: 6, fontSize: 12.5, color: C.muted, lineHeight: 1.5 } }, DICTS.zh.policyNote),
|
|||
|
|
h('div', { style: { marginTop: 10 } }, policyRows),
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function StatusChip(props) {
|
|||
|
|
const sessionId = props?.sessionId
|
|||
|
|
const [state, setState] = useState(null)
|
|||
|
|
const [failed, setFailed] = useState(false)
|
|||
|
|
const [busy, setBusy] = useState(false)
|
|||
|
|
const [message, setMessage] = useState('')
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
let alive = true
|
|||
|
|
const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ''
|
|||
|
|
api(`/state${query}`)
|
|||
|
|
.then((payload) => {
|
|||
|
|
if (alive) setState(payload)
|
|||
|
|
})
|
|||
|
|
.catch(() => {
|
|||
|
|
if (alive) setFailed(true)
|
|||
|
|
})
|
|||
|
|
return () => {
|
|||
|
|
alive = false
|
|||
|
|
}
|
|||
|
|
}, [sessionId])
|
|||
|
|
|
|||
|
|
if (failed) return null
|
|||
|
|
if (!state) return null
|
|||
|
|
|
|||
|
|
// Workspace gate: only workspaces that actually have an ABAP
|
|||
|
|
// destinations file (or are explicitly allowlisted on the Host) get the
|
|||
|
|
// chip — unrelated workspaces and modes stay clean.
|
|||
|
|
const enabled = state.chip ? state.chip.enabled : Boolean(state.file?.exists)
|
|||
|
|
if (!enabled) return null
|
|||
|
|
|
|||
|
|
const destinations = state.destinations || []
|
|||
|
|
const names = destinations.map((dest) => dest.name).filter(Boolean)
|
|||
|
|
const selected = state.defaultDestination || 'demo'
|
|||
|
|
const options = ['demo', ...names.filter((name) => name !== 'demo')]
|
|||
|
|
const modeText = DICTS.zh.modes[state.mode]
|
|||
|
|
const modeBadge = modeText
|
|||
|
|
? { text: modeText, tone: state.mode === 'change' ? 'err' : state.mode === 'debug' ? 'brand' : 'warn' }
|
|||
|
|
: null
|
|||
|
|
|
|||
|
|
const switchDefault = async (value) => {
|
|||
|
|
if (value === selected) return
|
|||
|
|
setBusy(true)
|
|||
|
|
setMessage('')
|
|||
|
|
try {
|
|||
|
|
await api('/default', {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: JSON.stringify({ workspace: state.workspace, defaultDestination: value }),
|
|||
|
|
})
|
|||
|
|
setState((previous) => (previous ? { ...previous, defaultDestination: value } : previous))
|
|||
|
|
setMessage(DICTS.zh.chipSwitched)
|
|||
|
|
} catch (err) {
|
|||
|
|
setMessage(`${DICTS.zh.chipSwitchFailed}: ${err.message}`)
|
|||
|
|
} finally {
|
|||
|
|
setBusy(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return h(
|
|||
|
|
'span',
|
|||
|
|
{
|
|||
|
|
title: `${DICTS.zh.chipTitle}\n${state.file?.path || ''}`,
|
|||
|
|
style: {
|
|||
|
|
display: 'inline-flex',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
gap: 6,
|
|||
|
|
padding: '3px 8px',
|
|||
|
|
borderRadius: 999,
|
|||
|
|
border: `1px solid ${C.border}`,
|
|||
|
|
background: 'transparent',
|
|||
|
|
fontSize: 11.5,
|
|||
|
|
color: C.muted,
|
|||
|
|
whiteSpace: 'nowrap',
|
|||
|
|
userSelect: 'none',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
h('span', { style: { width: 6, height: 6, borderRadius: '50%', background: names.length ? C.ok : C.warn } }),
|
|||
|
|
h('span', { style: { fontSize: 11.5, color: C.muted } }, DICTS.zh.chipPrefix),
|
|||
|
|
h(
|
|||
|
|
'select',
|
|||
|
|
{
|
|||
|
|
value: selected,
|
|||
|
|
disabled: busy,
|
|||
|
|
onChange: (event) => switchDefault(event.target.value),
|
|||
|
|
style: {
|
|||
|
|
appearance: 'none',
|
|||
|
|
border: 'none',
|
|||
|
|
background: 'transparent',
|
|||
|
|
color: C.text,
|
|||
|
|
fontSize: 11.5,
|
|||
|
|
fontWeight: 600,
|
|||
|
|
padding: '0 2px',
|
|||
|
|
cursor: busy ? 'progress' : 'pointer',
|
|||
|
|
maxWidth: 170,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
options.map((name) => h('option', { key: name, value: name }, name)),
|
|||
|
|
),
|
|||
|
|
names.length ? h('span', { style: { fontSize: 11, color: C.muted } }, `${names.length} ${DICTS.zh.chipDest}`) : null,
|
|||
|
|
modeBadge ? h(Badge, { tone: modeBadge.tone }, modeBadge.text) : null,
|
|||
|
|
message ? h('span', { style: { fontSize: 10.5, color: C.muted, maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis' } }, message) : null,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
inject: ['slots'],
|
|||
|
|
apply(ctx) {
|
|||
|
|
let t = (key) => DICTS.zh[key] ?? key
|
|||
|
|
try {
|
|||
|
|
if (ctx.locale) {
|
|||
|
|
const disposeLocale = ctx.locale.register(NS, { en: DICTS.en, zh: DICTS.zh })
|
|||
|
|
ctx.effect?.(() => disposeLocale)
|
|||
|
|
const bound = ctx.locale.bind(NS)
|
|||
|
|
t = (key, params) => bound(key, params)
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
// Locale is optional: fall back to the shipped Chinese strings.
|
|||
|
|
console.warn('abap-adt-ui: locale registration failed', err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx.slots.inject('settings.section', () =>
|
|||
|
|
ctx.slots.register(
|
|||
|
|
{
|
|||
|
|
name: 'settings.section',
|
|||
|
|
id: 'abap-adt',
|
|||
|
|
order: 30,
|
|||
|
|
label: () => t('sectionLabel'),
|
|||
|
|
},
|
|||
|
|
SettingsPage,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
ctx.slots.inject('conversation.input.left', () =>
|
|||
|
|
ctx.slots.register(
|
|||
|
|
{
|
|||
|
|
name: 'conversation.input.left',
|
|||
|
|
id: 'abap-adt-status',
|
|||
|
|
order: 40,
|
|||
|
|
label: () => t('chipPrefix'),
|
|||
|
|
},
|
|||
|
|
StatusChip,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
})
|