Files
gitea/gitea-publisher/client.js
T

361 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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;
},
});