361 lines
21 KiB
JavaScript
361 lines
21 KiB
JavaScript
// @local/gitea-publisher — 浏览器半边。
|
||
// 在会话顶栏动作区(conversation.session.header.actions)放一个「发布到 Gitea」按钮:
|
||
// - 挂载时用 ctx.remote.$mount 注册 typert 描述符(参数 codec 只需通过严格性校验,
|
||
// create() 惰性、上线路径永不调用,所以给哑工厂即可;result 用 src-json,宿主返回值原样到达)。
|
||
// - publish 的第一个位置参数是 sessionId —— 它对应 lookup 参数(wire: workspaceFileScopeId),
|
||
// 宿主端由 workspaceFiles 的 lookup 解析成 { sessionId, workspaceRoot }。
|
||
window.__ModuleLoader__.load({
|
||
id: '@local/gitea-publisher',
|
||
factory(require) {
|
||
const React = require('react');
|
||
|
||
const css = [
|
||
'.gpub_root{position:relative;display:inline-flex}',
|
||
'.gpub_trigger{border-radius:var(--dsw-radius-sm);min-height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;align-items:center;gap:5px;padding:3px 6px;font-size:12px;line-height:18px;display:inline-flex}',
|
||
'.gpub_trigger:hover,.gpub_trigger:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-l1)}',
|
||
'.gpub_trigger:disabled{cursor:default;opacity:.7}',
|
||
'.gpub_dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-error-primary)}',
|
||
'.gpub_dotOk{background:var(--dsw-alias-state-success-primary, var(--dsw-alias-label-quaternary))}',
|
||
'.gpub_panel{box-sizing:border-box;position:absolute;top:calc(100% + 6px);right:0;z-index:120;width:340px;max-width:min(340px,calc(100vw - 24px));border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);box-shadow:var(--dsw-elevation-prominent);border:0;padding:10px 12px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
|
||
'.gpub_row{display:flex;flex-direction:column;gap:2px;min-width:0}',
|
||
'.gpub_label{color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
||
'.gpub_value{font-family:var(--dsw-font-mono);word-break:break-all}',
|
||
'.gpub_error{color:var(--dsw-alias-state-error-primary);white-space:pre-wrap;word-break:break-word;max-height:180px;overflow:auto;margin:0}',
|
||
'.gpub_ok{color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:2px;min-width:0}',
|
||
'.gpub_link{color:var(--dsw-alias-label-secondary);text-underline-offset:2px}',
|
||
'.gpub_actions{display:flex;gap:6px;margin-top:2px}',
|
||
'.gpub_btn{border-radius:var(--dsw-radius-sm);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-specific-menu);color:var(--dsw-alias-label-secondary);cursor:pointer;padding:3px 10px;font-size:12px;line-height:18px}',
|
||
'.gpub_btn:hover,.gpub_btn:focus-visible{background:var(--dsw-alias-fill-l1);color:var(--dsw-alias-label-primary)}',
|
||
'.gpub_btnPrimary{border-color:var(--dsw-alias-border-accent, var(--dsw-alias-border-l2));color:var(--dsw-alias-label-primary)}',
|
||
'.gpub_spinner{animation:gpub-spin .9s linear infinite;transform-origin:center}',
|
||
'@keyframes gpub-spin{to{transform:rotate(360deg)}}',
|
||
].join('');
|
||
|
||
const zh = {
|
||
'label': 'Gitea',
|
||
'title': '发布到 Gitea',
|
||
'loading': '正在检查配置…',
|
||
'notConfigured': 'Gitea 尚未配置',
|
||
'missing': '缺少配置项:{keys}',
|
||
'baseUrlInvalid': 'baseUrl 必须以 http:// 或 https:// 开头。',
|
||
'branchInvalid': 'branch 名称不合法。',
|
||
'configHint': '在 profile 的 cordis.patch.yml 中给 gitea-publisher 行的 config.gitea 填入 baseUrl / token / repo。',
|
||
'target': '目标',
|
||
'apiError': 'API 检查失败:{detail}',
|
||
'gitMissing': '宿主上没有 git,可执行文件不可用,无法发布。',
|
||
'publish': '发布',
|
||
'republish': '再推一次',
|
||
'close': '关闭',
|
||
'publishing': '正在快照工作区并推送到 Gitea…',
|
||
'success': '已推送到 {repo}',
|
||
'createdNote': '(仓库为本次自动创建)',
|
||
'publicRepo': '公开',
|
||
'privateRepo': '私有',
|
||
'commit': 'commit {sha} · {files} 个文件 → {branch}',
|
||
'unchangedNote': '(内容未变化,远端已是最新)',
|
||
'repoBound': '(已绑定本工作区)',
|
||
'repoNew': '(未绑定:将按工作区目录名新建)',
|
||
'repoCollision': '(目录名撞车:自动加后缀区分)',
|
||
'repoPending': '(等待会话工作区解析)',
|
||
'error.bad-repo-name': '仓库名不合法。',
|
||
'skipped': '跳过 {count} 个超大文件',
|
||
'errorHead': '发布失败',
|
||
'error.not-configured': 'Gitea 尚未配置。',
|
||
'error.busy': '上一次发布还在进行中。',
|
||
'error.bad-branch': '分支名不合法。',
|
||
'error.workspace-unavailable': '找不到这个会话的工作区目录。',
|
||
'error.git-missing': '宿主机上没有可用的 git。',
|
||
'error.git-init-failed': '临时 git 仓库初始化失败。',
|
||
'error.git-add-failed': 'git add 失败。',
|
||
'error.git-commit-failed': 'git commit 失败。',
|
||
'error.git-reset-failed': '对齐远端分支失败。',
|
||
'error.fetch-failed': '拉取远端分支失败。',
|
||
'error.no-files': '快照里没有可提交的文件(可能都被 .gitignore 排除了)。',
|
||
'error.repo-missing': '目标仓库不存在,且未开启自动创建。',
|
||
'error.repo-create-failed': '自动创建仓库失败。',
|
||
'error.repo-check-failed': '查询目标仓库失败。',
|
||
'error.api-unreachable': '连不上 Gitea API。',
|
||
'error.api-error': 'Gitea API 返回异常。',
|
||
'error.auth-failed': '令牌无效或无权限。',
|
||
'error.push-rejected': '远端分支拒绝了这次推送。',
|
||
'error.push-timeout': '推送超时。',
|
||
'error.push-failed': 'git push 失败。',
|
||
'error.cancelled': '已取消。',
|
||
'error.internal': '插件内部错误。',
|
||
'statusFailed': '连不上宿主插件服务。',
|
||
'statusHint': '请刷新页面(Ctrl+Shift+R);若刚重启过 dsh-web,必须刷新一次。',
|
||
'retry': '重试',
|
||
};
|
||
const en = {
|
||
'label': 'Gitea',
|
||
'title': 'Publish to Gitea',
|
||
'loading': 'Checking configuration…',
|
||
'notConfigured': 'Gitea is not configured',
|
||
'missing': 'Missing settings: {keys}',
|
||
'baseUrlInvalid': 'baseUrl must start with http:// or https://.',
|
||
'branchInvalid': 'Invalid branch name.',
|
||
'configHint': 'Fill config.gitea (baseUrl / token / repo) on the gitea-publisher row in the profile cordis.patch.yml.',
|
||
'target': 'Target',
|
||
'apiError': 'API check failed: {detail}',
|
||
'gitMissing': 'No usable git executable on the host.',
|
||
'publish': 'Publish',
|
||
'republish': 'Push again',
|
||
'close': 'Close',
|
||
'publishing': 'Snapshotting the workspace and pushing to Gitea…',
|
||
'success': 'Pushed to {repo}',
|
||
'createdNote': '(repository was created just now)',
|
||
'publicRepo': 'public',
|
||
'privateRepo': 'private',
|
||
'commit': 'commit {sha} · {files} files → {branch}',
|
||
'unchangedNote': '(no changes — remote is already up to date)',
|
||
'repoBound': '(bound to this workspace)',
|
||
'repoNew': '(unbound: will create a repo named after the workspace directory)',
|
||
'repoCollision': '(directory-name collision: suffixed automatically)',
|
||
'repoPending': '(waiting for workspace resolution)',
|
||
'error.bad-repo-name': 'Invalid repository name.',
|
||
'skipped': '{count} oversized files skipped',
|
||
'errorHead': 'Publish failed',
|
||
'error.not-configured': 'Gitea is not configured.',
|
||
'error.busy': 'A publish is already running.',
|
||
'error.bad-branch': 'Invalid branch name.',
|
||
'error.workspace-unavailable': 'This session’s workspace directory is missing.',
|
||
'error.git-missing': 'No usable git executable on the host.',
|
||
'error.git-init-failed': 'Failed to initialize the temporary git repository.',
|
||
'error.git-add-failed': 'git add failed.',
|
||
'error.git-commit-failed': 'git commit failed.',
|
||
'error.git-reset-failed': 'Failed to align with the remote branch.',
|
||
'error.fetch-failed': 'Failed to fetch the remote branch.',
|
||
'error.no-files': 'Nothing to commit in the snapshot (all files are .gitignore-d).',
|
||
'error.repo-missing': 'The target repository does not exist and auto-create is off.',
|
||
'error.repo-create-failed': 'Could not create the repository.',
|
||
'error.repo-check-failed': 'Could not look up the target repository.',
|
||
'error.api-unreachable': 'The Gitea API is unreachable.',
|
||
'error.api-error': 'The Gitea API returned an error.',
|
||
'error.auth-failed': 'Invalid token or missing permission.',
|
||
'error.push-rejected': 'The remote branch rejected the push.',
|
||
'error.push-timeout': 'The push timed out.',
|
||
'error.push-failed': 'git push failed.',
|
||
'error.cancelled': 'Cancelled.',
|
||
'error.internal': 'Internal plugin error.',
|
||
'statusFailed': 'Cannot reach the host plugin service.',
|
||
'statusHint': 'Reload the page (Ctrl+Shift+R). If dsh-web was just restarted, a reload is required.',
|
||
'retry': 'Retry',
|
||
};
|
||
|
||
const BranchIcon = ({ spin }) => React.createElement('svg', {
|
||
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
|
||
className: spin ? 'gpub_spinner' : undefined, 'aria-hidden': 'true',
|
||
}, spin
|
||
? React.createElement('circle', { cx: 8, cy: 8, r: 6, stroke: 'currentColor', strokeWidth: 1.5, strokeDasharray: '26 12', strokeLinecap: 'round' })
|
||
: React.createElement(React.Fragment, null,
|
||
React.createElement('path', { d: 'M5 3.5v7', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }),
|
||
React.createElement('circle', { cx: 5, cy: 12, r: 1.7, stroke: 'currentColor', strokeWidth: 1.3 }),
|
||
React.createElement('circle', { cx: 5, cy: 3, r: 1.7, stroke: 'currentColor', strokeWidth: 1.3 }),
|
||
React.createElement('circle', { cx: 11.5, cy: 4.5, r: 1.7, stroke: 'currentColor', strokeWidth: 1.3 }),
|
||
React.createElement('path', { d: 'M11.5 6.3c0 2.4-1.8 3.2-3.6 3.6', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' })));
|
||
|
||
/** 顶栏入口:按钮 + 弹出面板(状态、发布结果、错误)。 */
|
||
function PublishAction({ sessionId, t, getStatus, runPublish }) {
|
||
const [status, setStatus] = React.useState(null);
|
||
const [statusErr, setStatusErr] = React.useState(null);
|
||
const [reloadKey, setReloadKey] = React.useState(0);
|
||
const [phase, setPhase] = React.useState('idle');
|
||
const [result, setResult] = React.useState(null);
|
||
const [open, setOpen] = React.useState(false);
|
||
const rootRef = React.useRef(null);
|
||
|
||
React.useEffect(() => {
|
||
let alive = true;
|
||
setStatusErr(null);
|
||
// 超时兜底:连接半死时 RPC 可能永远 pending,宁可报“连不上”也别假加载。
|
||
const deadline = new Promise((_resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new Error('timeout (20s)')), 20000);
|
||
if (timer.unref) timer.unref();
|
||
});
|
||
Promise.race([Promise.resolve().then(() => getStatus(sessionId)), deadline]).then(
|
||
(value) => { if (alive) setStatus(value && typeof value === 'object' ? value : null); },
|
||
(error) => { if (alive) { setStatus(null); setStatusErr(String(error?.message ?? error)); } },
|
||
);
|
||
return () => { alive = false; };
|
||
}, [getStatus, reloadKey, sessionId]);
|
||
|
||
React.useEffect(() => {
|
||
if (!open) return;
|
||
const onPointer = (event) => {
|
||
if (rootRef.current !== null && !rootRef.current.contains(event.target)) setOpen(false);
|
||
};
|
||
document.addEventListener('pointerdown', onPointer);
|
||
return () => document.removeEventListener('pointerdown', onPointer);
|
||
}, [open]);
|
||
|
||
const doPublish = () => {
|
||
setPhase('publishing');
|
||
setResult(null);
|
||
Promise.resolve().then(() => runPublish(sessionId, {})).then(
|
||
(value) => { setPhase('idle'); setResult(value && typeof value === 'object' ? value : { ok: false, error: 'internal' }); },
|
||
(error) => { setPhase('idle'); setResult({ ok: false, error: 'internal', detail: String(error?.message ?? error) }); },
|
||
);
|
||
};
|
||
|
||
const onClick = () => {
|
||
if (phase === 'publishing') return;
|
||
if (open) { setOpen(false); return; }
|
||
setOpen(true);
|
||
if (result === null && status !== null && status.configured === true && phase === 'idle') doPublish();
|
||
};
|
||
|
||
const notConfigured = status !== null && status.configured === false;
|
||
const target = status !== null && status.configured === true
|
||
? `${status.baseUrl}/${status.owner ?? ''}/${status.repo ?? '…'} @ ${status.branch}`
|
||
: null;
|
||
// 每工作区仓库模式下,给目标加一句状态注记(已绑定 / 将按目录名新建 / 撞名加后缀)。
|
||
const repoNote = status !== null && status.configured === true && status.repoPolicy === 'per-workspace'
|
||
? (status.repo === null ? t('repoPending')
|
||
: status.repoSource === 'bound' ? t('repoBound')
|
||
: status.repoSource === 'derived-collision' ? t('repoCollision')
|
||
: t('repoNew'))
|
||
: null;
|
||
|
||
return React.createElement('span', { className: 'gpub_root', ref: rootRef },
|
||
React.createElement('style', null, css),
|
||
React.createElement('button', {
|
||
type: 'button',
|
||
className: 'gpub_trigger',
|
||
title: t('title'),
|
||
onClick,
|
||
},
|
||
React.createElement(BranchIcon, { spin: phase === 'publishing' }),
|
||
React.createElement('span', null, t('label')),
|
||
status !== null && (notConfigured ? React.createElement('span', { className: 'gpub_dot' }) : React.createElement('span', { className: 'gpub_dot gpub_dotOk' }))),
|
||
open ? React.createElement('div', { className: 'gpub_panel', role: 'dialog' },
|
||
status === null && statusErr !== null
|
||
? React.createElement(React.Fragment, null,
|
||
React.createElement('div', { className: 'gpub_row' }, React.createElement('strong', null, t('statusFailed'))),
|
||
React.createElement('div', { className: 'gpub_label' }, String(statusErr)),
|
||
React.createElement('div', { className: 'gpub_label' }, t('statusHint')),
|
||
React.createElement('div', { className: 'gpub_row' },
|
||
React.createElement('button', { type: 'button', className: 'gpub_btn', onClick: () => setReloadKey((k) => k + 1) }, t('retry')),
|
||
React.createElement('button', { type: 'button', className: 'gpub_btn', onClick: () => setOpen(false) }, t('close'))))
|
||
: status === null
|
||
? React.createElement('div', { className: 'gpub_label' }, t('loading'))
|
||
: notConfigured ? React.createElement(React.Fragment, null,
|
||
React.createElement('div', { className: 'gpub_row' },
|
||
React.createElement('strong', null, t('notConfigured')),
|
||
Array.isArray(status.missing) && status.missing.length > 0
|
||
? React.createElement('div', { className: 'gpub_label' }, t('missing', { keys: status.missing.join(', ') }))
|
||
: null,
|
||
status.baseUrlInvalid === true ? React.createElement('div', { className: 'gpub_label' }, t('baseUrlInvalid')) : null,
|
||
status.branchInvalid === true ? React.createElement('div', { className: 'gpub_label' }, t('branchInvalid')) : null,
|
||
React.createElement('div', { className: 'gpub_label' }, t('configHint'))))
|
||
: React.createElement(React.Fragment, null,
|
||
React.createElement('div', { className: 'gpub_row' },
|
||
React.createElement('span', { className: 'gpub_label' }, t('target')),
|
||
React.createElement('span', { className: 'gpub_value' }, target ?? ''),
|
||
repoNote !== null ? React.createElement('span', { className: 'gpub_label' }, repoNote) : null),
|
||
status.apiError ? React.createElement('div', { className: 'gpub_label' }, t('apiError', { detail: status.apiError })) : null,
|
||
status.gitAvailable === false ? React.createElement('div', { className: 'gpub_label' }, t('gitMissing')) : null),
|
||
phase === 'publishing' ? React.createElement('div', { className: 'gpub_row' },
|
||
React.createElement('span', { className: 'gpub_label' }, t('publishing'))) : null,
|
||
phase === 'idle' && result !== null ? (result.ok === true
|
||
? React.createElement('div', { className: 'gpub_ok' },
|
||
React.createElement('div', null, t('success', { repo: '' }), ' ',
|
||
React.createElement('a', { className: 'gpub_link', href: result.repo ?? '#', target: '_blank', rel: 'noreferrer' }, `${result.owner}/${result.repoName}`)),
|
||
result.created === true ? React.createElement('span', { className: 'gpub_label' }, t('createdNote') + ' · ' + (result.isPrivate === true ? t('privateRepo') : t('publicRepo'))) : null,
|
||
result.unchanged === true ? React.createElement('span', { className: 'gpub_label' }, t('unchangedNote')) : null,
|
||
React.createElement('span', { className: 'gpub_label' }, t('commit', { sha: result.commit ?? '?', files: result.files ?? 0, branch: result.branch ?? '' })),
|
||
result.skippedCount > 0 ? React.createElement('span', { className: 'gpub_label' }, t('skipped', { count: result.skippedCount })) : null)
|
||
: React.createElement('div', { className: 'gpub_row' },
|
||
React.createElement('strong', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('errorHead')),
|
||
React.createElement('p', { className: 'gpub_error' }, [t(`error.${result.error ?? 'internal'}`), result.detail].filter(Boolean).join('\n')))) : null,
|
||
React.createElement('div', { className: 'gpub_actions' },
|
||
phase === 'idle' && (result === null || result.ok !== true) && status !== null && status.configured === true && status.gitAvailable !== false
|
||
? React.createElement('button', { type: 'button', className: 'gpub_btn gpub_btnPrimary', onClick: doPublish }, result === null ? t('publish') : t('republish'))
|
||
: phase === 'idle' && result !== null && result.ok === true
|
||
? React.createElement('button', { type: 'button', className: 'gpub_btn', onClick: doPublish }, t('republish'))
|
||
: null,
|
||
React.createElement('button', { type: 'button', className: 'gpub_btn', onClick: () => setOpen(false) }, t('close'))))
|
||
: null);
|
||
}
|
||
|
||
// typert 客户端贡献:参数 codec 必须是 strict 哑工厂(校验存在性,线上传输不经过它);
|
||
// result 用 src-json,宿主返回的 JSON 原样交给组件。
|
||
const strict = (typeSymbol) => ({ mode: 'strict', typeSymbol, create: () => ({}) });
|
||
const contribution = {
|
||
package: '@local/gitea-publisher',
|
||
descriptors: [
|
||
{
|
||
id: '@local/gitea-publisher#giteaPublish/status',
|
||
service: 'giteaPublish',
|
||
namespace: 'giteaPublish',
|
||
method: 'status',
|
||
invocation: { kind: 'direct' },
|
||
parameters: [
|
||
{ name: 'workspaceFileScope', wire: 'workspaceFileScopeId', source: 'lookup', lookup: 'workspaceFileScope', codec: strict('@local/gitea-publisher#SessionId') },
|
||
],
|
||
cancellation: { parameter: 'signal' },
|
||
result: { mode: 'src-json' },
|
||
},
|
||
{
|
||
id: '@local/gitea-publisher#giteaPublish/publish',
|
||
service: 'giteaPublish',
|
||
namespace: 'giteaPublish',
|
||
method: 'publish',
|
||
invocation: { kind: 'direct' },
|
||
parameters: [
|
||
{ name: 'workspaceFileScope', wire: 'workspaceFileScopeId', source: 'lookup', lookup: 'workspaceFileScope', codec: strict('@local/gitea-publisher#SessionId') },
|
||
{ name: 'request', wire: 'request', source: 'json', codec: strict('@local/gitea-publisher#PublishRequest') },
|
||
],
|
||
cancellation: { parameter: 'signal' },
|
||
result: { mode: 'src-json' },
|
||
},
|
||
],
|
||
};
|
||
|
||
const exports = {};
|
||
exports.inject = ['remote', 'slots', 'locale'];
|
||
|
||
function apply(ctx) {
|
||
ctx.effect(() => ctx.locale.register('gitea', { zh, en }), 'gitea-publisher: dictionaries');
|
||
// remote.giteaPublish 在 $mount 完成后才作为服务出现:不能静态 inject(会死锁启动),
|
||
// 也不能在组件里裸访问(渲染可能早于 mount 完成,报 without inject)。用 ready promise 衔接。
|
||
let resolveReady;
|
||
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
||
ctx.effect(async () => {
|
||
const dispose = await ctx.remote.$mount(contribution);
|
||
// 注意:ctx.remote.<ns> 的属性糖受 inject 守卫(同包静态注入才放行,而这里
|
||
// 静态注入会死锁启动),必须用 ctx.get 拿挂载后的命名空间服务。
|
||
const api = ctx.get('remote.giteaPublish');
|
||
if (api === undefined) throw new Error('gitea-publisher: remote namespace did not appear after $mount');
|
||
resolveReady(api);
|
||
return () => dispose();
|
||
}, 'gitea-publisher: remote');
|
||
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
||
name: 'conversation.session.header.actions',
|
||
id: 'gitea-publish',
|
||
order: 35,
|
||
locale: 'gitea',
|
||
inject: () => ({
|
||
// 命名空间方法返回 {ok, value} 的 RPC 信封(src-json 的宿主返回值在 value 里);
|
||
// 信封 ok:false 只在协议层失败(撤回/取消/断连)时出现,宿主业务失败在 value.ok=false。
|
||
getStatus: async (sessionId) => {
|
||
const api = await ready;
|
||
const envelope = await api.status(sessionId);
|
||
if (envelope && envelope.ok === true) return envelope.value;
|
||
throw new Error(envelope?.error?.message ?? 'giteaPublish/status: RPC failed');
|
||
},
|
||
runPublish: async (sessionId, request) => {
|
||
const api = await ready;
|
||
const envelope = await api.publish(sessionId, request ?? {});
|
||
if (envelope && envelope.ok === true) return envelope.value;
|
||
const detail = envelope?.error ? [envelope.error.code, envelope.error.message].filter(Boolean).join(': ') : 'RPC failed';
|
||
return { ok: false, error: 'internal', detail };
|
||
},
|
||
}),
|
||
}, PublishAction));
|
||
}
|
||
|
||
exports.apply = apply;
|
||
return exports;
|
||
},
|
||
});
|