1053 lines
50 KiB
JavaScript
1053 lines
50 KiB
JavaScript
window.__ModuleLoader__.load({
|
||
id: '@local/git-forge',
|
||
factory(require) {
|
||
const React = require('react');
|
||
const h = React.createElement;
|
||
const { useState, useEffect, useMemo, useRef, useCallback } = React;
|
||
const P = require('@deepseek-ai/dsh-client-ui-primitives');
|
||
|
||
const NS = 'git-forge';
|
||
const SLOT_HERO = 'conversation.hero.workspace.directoryFlow';
|
||
const SLOT_SIDEBAR = 'sidebar.workspaces.directoryFlow';
|
||
const LS_MODE = 'git-forge.mode';
|
||
const LS_PARENT = 'git-forge.parent';
|
||
|
||
// ---------------------------------------------------------------- locale
|
||
|
||
const en = {
|
||
'title': 'Add workspace',
|
||
'close': 'Close',
|
||
'tabs.label': 'Workspace source',
|
||
'tab.clone': 'Clone repository',
|
||
'tab.local': 'Open local folder',
|
||
'menu.local': 'Open local folder…',
|
||
'local.title': 'Open local folder',
|
||
'local.up': 'Up',
|
||
'local.home': 'Home',
|
||
'local.hidden': 'Show hidden',
|
||
'local.open': 'Open',
|
||
'local.empty': 'No subfolders',
|
||
'local.path': 'Folder path',
|
||
'settings.title': 'Accounts',
|
||
'settings.hint': 'Accounts authorized through gh (GitHub) and tea-cli (Gitea/Forgejo). Tokens stay with the CLIs on the host.',
|
||
'settings.refresh': 'Refresh',
|
||
'settings.verify': 'Check tokens',
|
||
'settings.none': 'No accounts authorized yet.',
|
||
'settings.logout': 'Log out',
|
||
'settings.logoutConfirm': 'Log out {id}? This removes the credentials from {cli}.',
|
||
'settings.ok': 'Token valid',
|
||
'settings.bad': 'Token error',
|
||
'settings.cli': 'CLI tools',
|
||
'settings.missing': 'not installed',
|
||
'settings.add': 'Add account',
|
||
'settings.github': 'GitHub (gh)',
|
||
'settings.gitea': 'Gitea / Forgejo (tea-cli)',
|
||
'settings.host': 'Host',
|
||
'settings.method': 'Method',
|
||
'settings.web': 'Browser (device code)',
|
||
'settings.token': 'Personal access token',
|
||
'settings.password': 'User + password',
|
||
'settings.tokenHint': 'Classic token with at least repo, read:org and gist scopes.',
|
||
'settings.start': 'Start login',
|
||
'settings.code': 'Open {url} and enter this code:',
|
||
'settings.copy': 'Copy',
|
||
'settings.waiting': 'Waiting for authorization…',
|
||
'settings.cancel': 'Cancel',
|
||
'settings.login': 'Log in',
|
||
'settings.name': 'Login name',
|
||
'settings.url': 'Server URL',
|
||
'settings.user': 'User',
|
||
'settings.pwd': 'Password',
|
||
'settings.insecure': 'Skip TLS verification',
|
||
'settings.giteaHint': 'Create a token under Settings → Applications on the server (scopes: repository, issue, user, organization).',
|
||
'settings.added': 'Account added.',
|
||
'settings.removed': 'Account removed.',
|
||
'menu.clone': 'Clone repository…',
|
||
'menu.loading': 'Loading workspaces…',
|
||
'error.title': 'Could not add workspace',
|
||
'url.label': 'Repository URL',
|
||
'url.placeholder': 'https://github.com/owner/repo.git or git@host:owner/repo.git',
|
||
'accounts.heading': 'Or choose from your accounts',
|
||
'accounts.all': 'All',
|
||
'accounts.none': 'No authorized accounts found. Run `gh auth login` (GitHub) or `tea-cli login add` (Gitea/Forgejo) on the host to list your repositories here.',
|
||
'accounts.error': '{account}: {message}',
|
||
'search.placeholder': 'Search repositories',
|
||
'refresh': 'Refresh',
|
||
'loading': 'Loading repositories…',
|
||
'repos.empty': 'No repositories match.',
|
||
'repos.count': '{shown} of {total} repositories',
|
||
'parent.label': 'Clone into',
|
||
'parent.placeholder': '/path/to/parent/directory',
|
||
'name.label': 'Folder name',
|
||
'branch.label': 'Branch',
|
||
'branch.placeholder': 'default branch',
|
||
'cancel': 'Cancel',
|
||
'clone': 'Clone',
|
||
'cloning': 'Cloning…',
|
||
'private': 'private',
|
||
'fork': 'fork',
|
||
'archived': 'archived',
|
||
'kind.github': 'GitHub',
|
||
'kind.gitea': 'Gitea',
|
||
'kind.forgejo': 'Forgejo',
|
||
'local.missing': 'The local folder browser is not available. Clone a repository instead.',
|
||
'target': 'Will clone to {path}',
|
||
};
|
||
|
||
const zh = {
|
||
'title': '添加工作区',
|
||
'close': '关闭',
|
||
'tabs.label': '工作区来源',
|
||
'tab.clone': '克隆仓库',
|
||
'tab.local': '打开本地文件夹',
|
||
'menu.local': '打开本地文件夹…',
|
||
'local.title': '打开本地文件夹',
|
||
'local.up': '上一级',
|
||
'local.home': '主目录',
|
||
'local.hidden': '显示隐藏',
|
||
'local.open': '打开',
|
||
'local.empty': '没有子文件夹',
|
||
'local.path': '文件夹路径',
|
||
'settings.title': '账号',
|
||
'settings.hint': '通过 gh(GitHub)和 tea-cli(Gitea/Forgejo)授权的账号。令牌由主机上的 CLI 保存。',
|
||
'settings.refresh': '刷新',
|
||
'settings.verify': '检查令牌',
|
||
'settings.none': '尚未授权任何账号。',
|
||
'settings.logout': '退出登录',
|
||
'settings.logoutConfirm': '退出 {id}?这会从 {cli} 中删除凭据。',
|
||
'settings.ok': '令牌有效',
|
||
'settings.bad': '令牌错误',
|
||
'settings.cli': '命令行工具',
|
||
'settings.missing': '未安装',
|
||
'settings.add': '添加账号',
|
||
'settings.github': 'GitHub(gh)',
|
||
'settings.gitea': 'Gitea / Forgejo(tea-cli)',
|
||
'settings.host': '主机',
|
||
'settings.method': '方式',
|
||
'settings.web': '浏览器(设备码)',
|
||
'settings.token': '个人访问令牌',
|
||
'settings.password': '用户名 + 密码',
|
||
'settings.tokenHint': '经典令牌,至少需要 repo、read:org 和 gist 权限。',
|
||
'settings.start': '开始登录',
|
||
'settings.code': '打开 {url} 并输入此代码:',
|
||
'settings.copy': '复制',
|
||
'settings.waiting': '正在等待授权…',
|
||
'settings.cancel': '取消',
|
||
'settings.login': '登录',
|
||
'settings.name': '登录名',
|
||
'settings.url': '服务器地址',
|
||
'settings.user': '用户',
|
||
'settings.pwd': '密码',
|
||
'settings.insecure': '跳过 TLS 验证',
|
||
'settings.giteaHint': '在服务器的 设置 → 应用 中创建令牌(权限:repository、issue、user、organization)。',
|
||
'settings.added': '已添加账号。',
|
||
'settings.removed': '已移除账号。',
|
||
'menu.clone': '克隆仓库…',
|
||
'menu.loading': '正在加载工作区…',
|
||
'error.title': '无法添加工作区',
|
||
'url.label': '仓库地址',
|
||
'url.placeholder': 'https://github.com/owner/repo.git 或 git@host:owner/repo.git',
|
||
'accounts.heading': '或从你的账号中选择',
|
||
'accounts.all': '全部',
|
||
'accounts.none': '未找到已授权的账号。在主机上运行 `gh auth login`(GitHub)或 `tea-cli login add`(Gitea/Forgejo)后即可在此列出你的仓库。',
|
||
'accounts.error': '{account}:{message}',
|
||
'search.placeholder': '搜索仓库',
|
||
'refresh': '刷新',
|
||
'loading': '正在加载仓库…',
|
||
'repos.empty': '没有匹配的仓库。',
|
||
'repos.count': '{shown} / {total} 个仓库',
|
||
'parent.label': '克隆到',
|
||
'parent.placeholder': '/父目录/路径',
|
||
'name.label': '文件夹名称',
|
||
'branch.label': '分支',
|
||
'branch.placeholder': '默认分支',
|
||
'cancel': '取消',
|
||
'clone': '克隆',
|
||
'cloning': '正在克隆…',
|
||
'private': '私有',
|
||
'fork': '派生',
|
||
'archived': '已归档',
|
||
'kind.github': 'GitHub',
|
||
'kind.gitea': 'Gitea',
|
||
'kind.forgejo': 'Forgejo',
|
||
'local.missing': '本地文件夹浏览器不可用,请改为克隆仓库。',
|
||
'target': '将克隆到 {path}',
|
||
};
|
||
|
||
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars && vars[k] !== undefined ? vars[k] : ''));
|
||
|
||
// ---------------------------------------------------------------- helpers
|
||
|
||
async function call(path, init) {
|
||
const res = await fetch('git-forge/' + path, {
|
||
...init,
|
||
headers: { accept: 'application/json', ...(init && init.body ? { 'content-type': 'application/json' } : {}) },
|
||
});
|
||
let json;
|
||
try { json = await res.json(); } catch { json = {}; }
|
||
if (!res.ok) throw new Error(json.message || res.statusText || String(res.status));
|
||
return json;
|
||
}
|
||
const post = (path, body) => call(path, { method: 'POST', body: JSON.stringify(body || {}) });
|
||
|
||
function lsGet(key) {
|
||
try { return window.localStorage.getItem(key); } catch { return null; }
|
||
}
|
||
function lsSet(key, value) {
|
||
try { window.localStorage.setItem(key, value); } catch { /* ignore */ }
|
||
}
|
||
const rememberedMode = () => (lsGet(LS_MODE) === 'local' ? 'local' : 'clone');
|
||
|
||
/** Folder name derived from a git URL: last path segment without `.git`. */
|
||
function nameFromUrl(url) {
|
||
const s = String(url || '').trim().replace(/[?#].*$/, '').replace(/\/+$/, '');
|
||
if (!s) return '';
|
||
const seg = s.split(/[/:]/).pop() || '';
|
||
return seg.replace(/\.git$/i, '');
|
||
}
|
||
|
||
function joinPath(parent, name) {
|
||
if (!parent || !name) return '';
|
||
return parent.replace(/\/+$/, '') + '/' + name;
|
||
}
|
||
|
||
const icon = (name, size) => (P[name] ? h(P[name], size ? { size } : null) : null);
|
||
|
||
// ---------------------------------------------------------------- styles
|
||
|
||
const color = {
|
||
primary: 'var(--dsw-alias-label-primary, inherit)',
|
||
secondary: 'var(--dsw-alias-label-secondary, inherit)',
|
||
tertiary: 'var(--dsw-alias-label-tertiary, inherit)',
|
||
border1: 'var(--dsw-alias-border-l1, rgba(128,128,128,.25))',
|
||
border2: 'var(--dsw-alias-border-l2, rgba(128,128,128,.4))',
|
||
hover: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12))',
|
||
error: 'var(--dsw-alias-state-error-primary, #d33)',
|
||
platform: 'var(--dsw-alias-bg-module-platform, transparent)',
|
||
};
|
||
const S = {
|
||
body: { display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 },
|
||
field: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 },
|
||
label: { fontSize: 13, fontWeight: 600, color: color.primary },
|
||
hint: { fontSize: 12, color: color.secondary, lineHeight: 1.45 },
|
||
caption: { fontSize: 11, color: color.tertiary, whiteSpace: 'nowrap' },
|
||
error: { fontSize: 12, color: color.error, lineHeight: 1.45, whiteSpace: 'pre-wrap', wordBreak: 'break-word' },
|
||
errorBox: {
|
||
fontSize: 12, color: color.error, border: '1px solid ' + color.error, borderRadius: 8,
|
||
padding: '8px 10px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 160, overflow: 'auto',
|
||
},
|
||
section: {
|
||
display: 'flex', flexDirection: 'column', gap: 8, padding: 10, borderRadius: 10,
|
||
border: '1px solid ' + color.border1, background: color.platform, minWidth: 0,
|
||
},
|
||
chips: { display: 'flex', flexWrap: 'wrap', gap: 6 },
|
||
chip: (active) => ({
|
||
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, padding: '3px 10px', borderRadius: 999,
|
||
cursor: 'pointer', color: active ? color.primary : color.secondary, fontWeight: active ? 600 : 400,
|
||
border: '1px solid ' + (active ? color.border2 : color.border1),
|
||
background: active ? color.hover : 'transparent',
|
||
}),
|
||
badge: { fontSize: 10, color: color.tertiary, textTransform: 'uppercase', letterSpacing: 0.3 },
|
||
toolbar: { display: 'flex', alignItems: 'center', gap: 6 },
|
||
list: {
|
||
maxHeight: 360, overflowY: 'auto', display: 'flex', flexDirection: 'column',
|
||
border: '1px solid ' + color.border1, borderRadius: 8,
|
||
},
|
||
row: (selected, hovered) => ({
|
||
display: 'flex', alignItems: 'center', gap: 10, padding: '7px 10px', cursor: 'pointer', textAlign: 'left',
|
||
border: 'none', borderBottom: '1px solid ' + color.border1, width: '100%', font: 'inherit',
|
||
color: color.primary, background: selected || hovered ? color.hover : 'transparent',
|
||
boxShadow: selected ? 'inset 3px 0 0 ' + color.border2 : 'none',
|
||
}),
|
||
rowMain: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, flex: 1 },
|
||
rowTitle: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 },
|
||
fullName: { fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||
desc: { fontSize: 12, color: color.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||
rowSide: { display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, flexShrink: 0 },
|
||
empty: { fontSize: 12, color: color.secondary, padding: '14px 10px', textAlign: 'center' },
|
||
split: { display: 'flex', gap: 10, minWidth: 0 },
|
||
footer: { display: 'flex', justifyContent: 'flex-end', gap: 8 },
|
||
code: { fontFamily: 'var(--dsw-font-mono, monospace)' },
|
||
};
|
||
|
||
// ---------------------------------------------------------------- repo picker
|
||
|
||
function RepoRow({ repo, selected, onPick, t }) {
|
||
const [hovered, setHovered] = useState(false);
|
||
return h('button', {
|
||
type: 'button',
|
||
style: S.row(selected, hovered),
|
||
'aria-pressed': selected,
|
||
title: repo.htmlUrl || repo.cloneUrl,
|
||
onMouseEnter: () => setHovered(true),
|
||
onMouseLeave: () => setHovered(false),
|
||
onClick: () => onPick(repo),
|
||
},
|
||
h('div', { style: S.rowMain },
|
||
h('div', { style: S.rowTitle },
|
||
h('span', { style: S.fullName }, repo.fullName),
|
||
repo.private ? h(P.Tag, { tone: 'quiet' }, t('private')) : null,
|
||
repo.fork ? h(P.Tag, { tone: 'quiet' }, t('fork')) : null,
|
||
repo.archived ? h(P.Tag, { tone: 'warning' }, t('archived')) : null),
|
||
repo.description ? h('div', { style: S.desc }, repo.description) : null),
|
||
h('div', { style: S.rowSide },
|
||
h('span', { style: S.caption }, repo.host),
|
||
h('span', { style: S.badge }, t('kind.' + (repo.flavor || repo.kind)) || repo.flavor || repo.kind)));
|
||
}
|
||
|
||
function AccountChip({ active, onClick, children }) {
|
||
return h('button', { type: 'button', style: S.chip(active), 'aria-pressed': active, onClick }, children);
|
||
}
|
||
|
||
function RepoPicker({ t, open, selectedUrl, onPick }) {
|
||
const [accounts, setAccounts] = useState(null); // null = not loaded yet
|
||
const [repos, setRepos] = useState([]);
|
||
const [errors, setErrors] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [loadError, setLoadError] = useState('');
|
||
const [account, setAccount] = useState('all');
|
||
const [query, setQuery] = useState('');
|
||
const seq = useRef(0);
|
||
|
||
const load = useCallback(async (refresh) => {
|
||
const my = ++seq.current;
|
||
setLoading(true);
|
||
setLoadError('');
|
||
try {
|
||
const [acc, rep] = await Promise.all([
|
||
call('accounts'),
|
||
call('repos' + (refresh ? '?refresh=1' : '')),
|
||
]);
|
||
if (my !== seq.current) return;
|
||
setAccounts(acc.accounts || []);
|
||
setRepos(rep.repos || []);
|
||
setErrors(rep.errors || []);
|
||
} catch (e) {
|
||
if (my !== seq.current) return;
|
||
setAccounts((prev) => prev || []);
|
||
setLoadError(e && e.message ? e.message : String(e));
|
||
} finally {
|
||
if (my === seq.current) setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// Lazy load once per open.
|
||
useEffect(() => {
|
||
if (!open) return undefined;
|
||
load(false);
|
||
return () => { seq.current++; };
|
||
}, [open, load]);
|
||
|
||
const visible = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
return repos.filter((r) => {
|
||
if (account !== 'all' && r.account !== account) return false;
|
||
if (!q) return true;
|
||
return (String(r.fullName || '') + ' ' + String(r.description || '')).toLowerCase().includes(q);
|
||
});
|
||
}, [repos, account, query]);
|
||
|
||
const accountLabel = (id) => {
|
||
const a = (accounts || []).find((x) => x.id === id);
|
||
return a ? a.user + '@' + a.host : id;
|
||
};
|
||
|
||
const noAccounts = accounts !== null && accounts.length === 0 && !loading && !loadError;
|
||
|
||
let listBody;
|
||
if (loading && repos.length === 0) listBody = h('div', { style: S.empty }, t('loading'));
|
||
else if (visible.length === 0) listBody = h('div', { style: S.empty }, t('repos.empty'));
|
||
else {
|
||
listBody = visible.map((r) => h(RepoRow, {
|
||
key: r.account + ':' + r.fullName,
|
||
repo: r,
|
||
t,
|
||
selected: !!selectedUrl && (selectedUrl === r.cloneUrl || selectedUrl === r.sshUrl),
|
||
onPick,
|
||
}));
|
||
}
|
||
|
||
return h('div', { style: S.section },
|
||
h('div', { style: S.label }, t('accounts.heading')),
|
||
noAccounts
|
||
? h('div', { style: S.hint }, t('accounts.none'))
|
||
: h(React.Fragment, null,
|
||
accounts && accounts.length > 0 ? h('div', { style: S.chips },
|
||
h(AccountChip, { active: account === 'all', onClick: () => setAccount('all') }, t('accounts.all')),
|
||
accounts.map((a) => h(AccountChip, { key: a.id, active: account === a.id, onClick: () => setAccount(a.id) },
|
||
h('span', null, a.user + '@' + a.host),
|
||
h('span', { style: S.badge }, t('kind.' + (a.flavor || a.kind)) || a.flavor || a.kind)))) : null,
|
||
h('div', { style: S.toolbar },
|
||
h('div', { style: { flex: 1, minWidth: 0 } },
|
||
h(P.Input, {
|
||
icon: icon('IconSearchOutlineRegular'),
|
||
value: query,
|
||
placeholder: t('search.placeholder'),
|
||
onChange: (e) => setQuery(e.target.value),
|
||
})),
|
||
h(P.Button, {
|
||
variant: 'toolbar',
|
||
size: 'sm',
|
||
icon: icon('IconRefreshOutlineRegular'),
|
||
disabled: loading,
|
||
title: t('refresh'),
|
||
'aria-label': t('refresh'),
|
||
onClick: () => load(true),
|
||
})),
|
||
loading && repos.length > 0 ? h('div', { style: S.hint }, t('loading')) : null,
|
||
h('div', { style: S.list, role: 'listbox' }, listBody),
|
||
repos.length > 0 ? h('div', { style: S.caption }, fill(t('repos.count'), { shown: visible.length, total: repos.length })) : null),
|
||
loadError ? h('div', { style: S.error }, loadError) : null,
|
||
errors.map((e, i) => h('div', { key: i, style: S.error },
|
||
fill(t('accounts.error'), { account: accountLabel(e.account), message: e.message }))));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- clone form
|
||
|
||
function CloneDialog({ t, open, busy, onCancel, onPicked, onTab, localMissing }) {
|
||
const [url, setUrl] = useState('');
|
||
const [parent, setParent] = useState(() => lsGet(LS_PARENT) || '');
|
||
const [name, setName] = useState('');
|
||
const [nameEdited, setNameEdited] = useState(false);
|
||
const [branch, setBranch] = useState('');
|
||
const [cloning, setCloning] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const mounted = useRef(true);
|
||
useEffect(() => () => { mounted.current = false; }, []);
|
||
|
||
// Reset the form each time the dialog opens; fill the default parent dir.
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setUrl('');
|
||
setName('');
|
||
setNameEdited(false);
|
||
setBranch('');
|
||
setError('');
|
||
const saved = lsGet(LS_PARENT);
|
||
if (saved) { setParent(saved); return; }
|
||
let alive = true;
|
||
call('defaults').then((d) => {
|
||
if (alive && d && d.cloneRoot) setParent((p) => p || d.cloneRoot);
|
||
}).catch(() => { /* leave empty; user can type it */ });
|
||
return () => { alive = false; };
|
||
}, [open]);
|
||
|
||
const changeUrl = (value) => {
|
||
setUrl(value);
|
||
if (!nameEdited) setName(nameFromUrl(value));
|
||
};
|
||
|
||
const pick = (repo) => {
|
||
changeUrl(repo.cloneUrl);
|
||
setError('');
|
||
};
|
||
|
||
const disabled = !url.trim() || !parent.trim() || !(name.trim() || nameFromUrl(url)) || cloning || busy;
|
||
|
||
const submit = async () => {
|
||
if (disabled) return;
|
||
setCloning(true);
|
||
setError('');
|
||
try {
|
||
const body = { url: url.trim(), parent: parent.trim(), name: name.trim() || nameFromUrl(url) };
|
||
if (branch.trim()) body.branch = branch.trim();
|
||
const result = await post('clone', body);
|
||
lsSet(LS_PARENT, body.parent);
|
||
if (mounted.current) setCloning(false);
|
||
onPicked(result.path);
|
||
} catch (e) {
|
||
if (!mounted.current) return;
|
||
setCloning(false);
|
||
setError(e && e.message ? e.message : String(e));
|
||
}
|
||
};
|
||
|
||
const target = joinPath(parent.trim(), name.trim() || nameFromUrl(url));
|
||
|
||
const footer = h('div', { style: S.footer },
|
||
h(P.Button, { variant: 'ghost', disabled: cloning, onClick: onCancel }, t('cancel')),
|
||
h(P.Button, { variant: 'primary', disabled, onClick: submit }, cloning ? t('cloning') : t('clone')));
|
||
|
||
return h(P.Modal, {
|
||
open,
|
||
onClose: () => { if (!cloning) onCancel(); },
|
||
title: onTab ? t('title') : t('tab.clone'),
|
||
closeLabel: t('close'),
|
||
footer,
|
||
className: 'git-forge-dialog',
|
||
},
|
||
h('style', null, '.git-forge-dialog{width:min(720px,100%)!important;max-height:100%;}'),
|
||
h('div', { style: S.body },
|
||
onTab ? h(P.SegmentedTabs, {
|
||
label: t('tabs.label'),
|
||
value: 'clone',
|
||
onChange: onTab,
|
||
items: [
|
||
{ value: 'clone', label: t('tab.clone'), id: 'git-forge-tab-clone', panelId: 'git-forge-panel' },
|
||
{ value: 'local', label: t('tab.local'), id: 'git-forge-tab-local', panelId: 'git-forge-panel' },
|
||
],
|
||
}) : null,
|
||
h('div', { id: 'git-forge-panel', role: 'tabpanel', 'aria-labelledby': 'git-forge-tab-clone', style: S.body },
|
||
localMissing ? h('div', { style: S.errorBox }, t('local.missing')) : null,
|
||
h('label', { style: S.field },
|
||
h('span', { style: S.label }, t('url.label')),
|
||
h(P.Input, {
|
||
value: url,
|
||
autoFocus: true,
|
||
spellCheck: false,
|
||
placeholder: t('url.placeholder'),
|
||
onChange: (e) => changeUrl(e.target.value),
|
||
onKeyDown: (e) => { if (e.key === 'Enter') submit(); },
|
||
})),
|
||
h(RepoPicker, { t, open, selectedUrl: url.trim(), onPick: pick }),
|
||
h('div', { style: S.split },
|
||
h('label', { style: { ...S.field, flex: 2 } },
|
||
h('span', { style: S.label }, t('parent.label')),
|
||
h(P.Input, {
|
||
icon: icon('IconFolderCloseRegular'),
|
||
value: parent,
|
||
spellCheck: false,
|
||
placeholder: t('parent.placeholder'),
|
||
onChange: (e) => setParent(e.target.value),
|
||
})),
|
||
h('label', { style: { ...S.field, flex: 1 } },
|
||
h('span', { style: S.label }, t('name.label')),
|
||
h(P.Input, {
|
||
value: name,
|
||
spellCheck: false,
|
||
onChange: (e) => { setName(e.target.value); setNameEdited(e.target.value !== ''); },
|
||
}))),
|
||
h('label', { style: S.field },
|
||
h('span', { style: S.label }, t('branch.label')),
|
||
h(P.Input, {
|
||
value: branch,
|
||
spellCheck: false,
|
||
placeholder: t('branch.placeholder'),
|
||
onChange: (e) => setBranch(e.target.value),
|
||
})),
|
||
target ? h('div', { style: { ...S.hint, ...S.code } }, fill(t('target'), { path: target })) : null,
|
||
error ? h('div', { style: S.errorBox, role: 'alert' }, error) : null)));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- local-mode switcher
|
||
|
||
// The shadowed browse picker renders its own modal, so we float the same
|
||
// tab strip just above it (separate portal, stacked over the modal layer)
|
||
// to let the user return to the clone form.
|
||
function ModeSwitcher({ t, onTab }) {
|
||
const [pos, setPos] = useState(null);
|
||
const self = useRef(null);
|
||
useEffect(() => {
|
||
let frame = 0;
|
||
const measure = () => {
|
||
const dialogs = [...document.querySelectorAll('[role="dialog"][aria-modal="true"]')]
|
||
.filter((d) => !(self.current && self.current.contains(d)));
|
||
const dlg = dialogs[dialogs.length - 1];
|
||
if (dlg) {
|
||
const r = dlg.getBoundingClientRect();
|
||
const next = { top: Math.max(8, r.top - 52), left: r.left + r.width / 2 };
|
||
setPos((p) => (p && p.top === next.top && p.left === next.left ? p : next));
|
||
}
|
||
frame = requestAnimationFrame(measure);
|
||
};
|
||
measure();
|
||
return () => cancelAnimationFrame(frame);
|
||
}, []);
|
||
const ReactDOM = require('react-dom');
|
||
const node = h('div', {
|
||
ref: self,
|
||
style: {
|
||
position: 'fixed', zIndex: 1001,
|
||
top: pos ? pos.top : 16, left: pos ? pos.left : '50%', transform: 'translateX(-50%)',
|
||
padding: 4, borderRadius: 12,
|
||
background: 'var(--dsw-alias-bg-layer-2)', boxShadow: 'var(--dsw-elevation-prominent)',
|
||
visibility: pos ? 'visible' : 'hidden',
|
||
},
|
||
},
|
||
h(P.SegmentedTabs, {
|
||
label: t('tabs.label'),
|
||
value: 'local',
|
||
onChange: onTab,
|
||
items: [
|
||
{ value: 'clone', label: t('tab.clone'), id: 'git-forge-switch-clone' },
|
||
{ value: 'local', label: t('tab.local'), id: 'git-forge-switch-local' },
|
||
],
|
||
}));
|
||
return ReactDOM.createPortal(node, document.body);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- slot occupant
|
||
|
||
function AddWorkspaceFlow(props) {
|
||
const { t, open } = props;
|
||
const [mode, setModeState] = useState(rememberedMode);
|
||
const wasOpen = useRef(open);
|
||
|
||
// Reset to the remembered mode whenever the flow opens.
|
||
useEffect(() => {
|
||
if (open && !wasOpen.current) setModeState(rememberedMode());
|
||
wasOpen.current = open;
|
||
}, [open]);
|
||
|
||
const setMode = (next) => {
|
||
lsSet(LS_MODE, next);
|
||
setModeState(next);
|
||
};
|
||
|
||
let sh = null;
|
||
try { sh = typeof props.shadowed === 'function' ? props.shadowed() : null; } catch { sh = null; }
|
||
|
||
if (mode === 'local') {
|
||
const has = !!(sh && sh.Component);
|
||
return h(React.Fragment, null,
|
||
h(has ? sh.Component : LocalFolderDialog, {
|
||
...(has ? sh.props : { t, listDirectory: props.listDirectory }),
|
||
open: props.open,
|
||
busy: props.busy,
|
||
onPicked: props.onPicked,
|
||
onCancel: props.onCancel,
|
||
onError: props.onError,
|
||
}),
|
||
props.open ? h(ModeSwitcher, { t, onTab: setMode }) : null);
|
||
}
|
||
|
||
return h(CloneDialog, {
|
||
t,
|
||
open: !!open,
|
||
busy: !!props.busy,
|
||
onCancel: () => props.onCancel && props.onCancel(),
|
||
onPicked: (path) => props.onPicked && props.onPicked(path),
|
||
onTab: setMode,
|
||
localMissing: mode === 'local' && !(sh && sh.Component),
|
||
});
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------- fallback local folder dialog
|
||
|
||
// Used when no core directory picker occupant can be found to delegate to.
|
||
function LocalFolderDialog({ t, open, busy, onPicked, onCancel, listDirectory }) {
|
||
const [dir, setDir] = useState(null);
|
||
const [draft, setDraft] = useState('');
|
||
const [hidden, setHidden] = useState(false);
|
||
const [err, setErr] = useState('');
|
||
const load = useCallback((path, showHidden) => {
|
||
setErr('');
|
||
// Prefer the core directory-picker service (no Host plugin route needed).
|
||
const viaHost = () => call('ls?' + new URLSearchParams({ ...(path ? { path } : {}), hidden: showHidden ? '1' : '0' }));
|
||
const viaCore = typeof listDirectory === 'function'
|
||
? Promise.resolve().then(() => listDirectory(path || undefined)).then((r) => {
|
||
const crumbs = r.crumbs || [];
|
||
return {
|
||
path: r.path,
|
||
home: r.home,
|
||
parent: crumbs.length > 1 ? crumbs[crumbs.length - 2].path : '',
|
||
dirs: (r.entries || []).filter((e) => showHidden || !e.hidden).map((e) => e.name),
|
||
};
|
||
}, (e) => (String(e && e.message) === 'no-list' ? viaHost() : Promise.reject(e)))
|
||
: viaHost();
|
||
viaCore
|
||
.then((r) => { setDir(r); setDraft(r.path); })
|
||
.catch((e) => setErr(String(e.message || e)));
|
||
}, []);
|
||
useEffect(() => { if (open) load(lsGet(LS_PARENT) || '', hidden); }, [open]);
|
||
if (!open) return null;
|
||
const go = (p) => load(p, hidden);
|
||
return h(P.Modal, {
|
||
open: true,
|
||
onClose: onCancel,
|
||
closeLabel: t('close'),
|
||
title: t('local.title'),
|
||
className: 'git-forge-dialog',
|
||
footer: h(React.Fragment, null,
|
||
h(P.Button, { variant: 'outline', onClick: onCancel }, t('cancel')),
|
||
h(P.Button, { variant: 'primary', disabled: !dir || busy, onClick: () => dir && onPicked(dir.path) }, t('local.open'))),
|
||
},
|
||
h('style', null, '.git-forge-dialog{width:min(720px,100%)!important;max-height:100%;}'),
|
||
h('div', { style: S.body },
|
||
h('form', { style: S.toolbar, onSubmit: (e) => { e.preventDefault(); go(draft); } },
|
||
h('div', { style: { flex: 1, minWidth: 0 } }, h(P.Input, { value: draft, 'aria-label': t('local.path'), spellCheck: false, onChange: (e) => setDraft(e.target.value) })),
|
||
h(P.Button, { variant: 'ghost', size: 'sm', disabled: !dir || !dir.parent, onClick: () => go(dir.parent) }, t('local.up')),
|
||
h(P.Button, { variant: 'ghost', size: 'sm', onClick: () => go(dir ? dir.home : '') }, t('local.home'))),
|
||
h('label', { style: { ...S.hint, display: 'flex', alignItems: 'center', gap: 6 } },
|
||
h('input', { type: 'checkbox', checked: hidden, onChange: (e) => { setHidden(e.target.checked); load(dir && dir.path, e.target.checked); } }), t('local.hidden')),
|
||
err ? h('div', { style: S.errorBox, role: 'alert' }, err) : null,
|
||
h('div', { style: S.list },
|
||
dir && dir.dirs.length === 0 ? h('div', { style: S.empty }, t('local.empty')) : null,
|
||
...(dir ? dir.dirs.map((name) => h('button', {
|
||
key: name, type: 'button', style: S.row(false, false),
|
||
onClick: () => go(dir.path.replace(/\/+$/, '') + '/' + name),
|
||
}, icon('IconFolderCloseRegular', 16), h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, name))) : []))));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- settings: accounts
|
||
|
||
function Field({ label, children }) {
|
||
return h('label', { style: { ...S.field, flex: 1 } }, h('span', { style: S.label }, label), children);
|
||
}
|
||
|
||
function SettingsPanel({ t, view }) {
|
||
const [status, setStatus] = useState(null);
|
||
const [busy, setBusy] = useState('');
|
||
const [msg, setMsg] = useState(null);
|
||
const [ghHost, setGhHost] = useState('github.com');
|
||
const [ghMethod, setGhMethod] = useState('web');
|
||
const [ghToken, setGhToken] = useState('');
|
||
const [flow, setFlow] = useState(null);
|
||
const [gt, setGt] = useState({ url: '', name: '', method: 'token', token: '', user: '', password: '', insecure: false });
|
||
|
||
const refresh = useCallback((verify) => {
|
||
setBusy(verify ? 'verify' : 'refresh');
|
||
return call('auth/status' + (verify ? '?verify=1' : ''))
|
||
.then(setStatus)
|
||
.catch((e) => setMsg({ bad: true, text: String(e.message || e) }))
|
||
.finally(() => setBusy(''));
|
||
}, []);
|
||
useEffect(() => { refresh(true); }, [refresh]);
|
||
|
||
// Poll the device flow until gh finishes.
|
||
useEffect(() => {
|
||
if (!flow || flow.state !== 'pending') return undefined;
|
||
const id = setInterval(() => {
|
||
call('auth/github/device/status?id=' + encodeURIComponent(flow.id)).then((f) => {
|
||
setFlow(f);
|
||
if (f.state === 'success') { setMsg({ text: t('settings.added') }); refresh(true); }
|
||
else if (f.state === 'error') setMsg({ bad: true, text: f.message || 'gh error' });
|
||
}).catch(() => {});
|
||
}, 2000);
|
||
return () => clearInterval(id);
|
||
}, [flow && flow.id, flow && flow.state]);
|
||
|
||
if (view === 'summary') return null;
|
||
|
||
const act = (name, fn) => async () => {
|
||
setBusy(name); setMsg(null);
|
||
try { await fn(); } catch (e) { setMsg({ bad: true, text: String(e.message || e) }); } finally { setBusy(''); }
|
||
};
|
||
const ghStart = act('gh', async () => {
|
||
if (ghMethod === 'token') {
|
||
await post('auth/github/token', { hostname: ghHost, token: ghToken });
|
||
setGhToken(''); setMsg({ text: t('settings.added') }); await refresh(true);
|
||
} else {
|
||
setFlow(await post('auth/github/device', { hostname: ghHost }));
|
||
}
|
||
});
|
||
const ghCancel = () => { if (flow) post('auth/github/device/cancel', { id: flow.id }).catch(() => {}); setFlow(null); };
|
||
const gtLogin = act('gt', async () => {
|
||
await post('auth/gitea', {
|
||
url: gt.url, name: gt.name || undefined, insecure: gt.insecure,
|
||
...(gt.method === 'token' ? { token: gt.token } : { user: gt.user, password: gt.password }),
|
||
});
|
||
setGt((g) => ({ ...g, token: '', password: '' })); setMsg({ text: t('settings.added') }); await refresh(true);
|
||
});
|
||
const doLogout = (a) => act('logout:' + a.id, async () => {
|
||
const cli = a.kind === 'github' ? 'gh' : ((status && status.cli && status.cli.tea.binary) || 'tea-cli');
|
||
if (!window.confirm(fill(t('settings.logoutConfirm'), { id: a.id, cli }))) return;
|
||
await post('auth/logout', { account: a.id }); setMsg({ text: t('settings.removed') }); await refresh(true);
|
||
})();
|
||
|
||
const accounts = (status && status.accounts) || [];
|
||
const cli = status && status.cli;
|
||
const ghOk = !cli || cli.gh.available;
|
||
const teaOk = !cli || cli.tea.available;
|
||
const setG = (k) => (e) => { const v = e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e; setGt((g) => ({ ...g, [k]: v })); };
|
||
const radio = (value, cur, set, label) => h('label', { style: { ...S.hint, display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: 12 } },
|
||
h('input', { type: 'radio', checked: cur === value, onChange: () => set(value) }), label);
|
||
|
||
return h('div', { style: { ...S.body, gap: 16, padding: '4px 0' } },
|
||
// accounts
|
||
h('div', { style: S.section },
|
||
h('div', { style: { ...S.toolbar, justifyContent: 'space-between' } },
|
||
h('span', { style: S.label }, t('settings.title')),
|
||
h('div', { style: S.toolbar },
|
||
h(P.Button, { variant: 'ghost', size: 'sm', disabled: !!busy, onClick: () => refresh(false) }, t('settings.refresh')),
|
||
h(P.Button, { variant: 'ghost', size: 'sm', disabled: !!busy, onClick: () => refresh(true) }, t('settings.verify')))),
|
||
h('div', { style: S.hint }, t('settings.hint')),
|
||
!status ? h('div', { style: S.hint }, '…') : accounts.length === 0 ? h('div', { style: S.empty }, t('settings.none')) :
|
||
h('div', { style: { ...S.list, maxHeight: 'none' } }, ...accounts.map((a) => h('div', { key: a.id, style: { ...S.row(false, false), cursor: 'default' } },
|
||
h('div', { style: S.rowMain },
|
||
h('div', { style: S.rowTitle },
|
||
h('span', { style: { fontSize: 13, fontWeight: 600 } }, a.user || a.name),
|
||
h('span', { style: S.badge }, t('kind.' + (a.flavor || a.kind)) || a.kind),
|
||
a.status === 'ok' ? h('span', { style: { ...S.caption, color: 'var(--dsw-alias-state-business-primary, #2a2)' } }, '● ' + t('settings.ok')) : null,
|
||
a.status === 'error' ? h('span', { style: { ...S.caption, color: color.error }, title: a.error }, '● ' + t('settings.bad')) : null),
|
||
h('div', { style: S.caption }, a.id + ' · ' + a.url)),
|
||
h(P.Button, { variant: 'outline', size: 'sm', disabled: !!busy, onClick: () => doLogout(a) }, t('settings.logout'))))),
|
||
cli ? h('div', { style: S.caption },
|
||
t('settings.cli') + ': gh ' + (cli.gh.available ? cli.gh.version.replace(/^gh version\s*/, '') : t('settings.missing')) +
|
||
' · ' + (cli.tea.binary || 'tea-cli / tea') + ' ' + (cli.tea.available ? cli.tea.version.replace(/^Version:\s*/, '') : t('settings.missing'))) : null),
|
||
msg ? h('div', { style: msg.bad ? S.errorBox : S.hint, role: msg.bad ? 'alert' : 'status' }, msg.text) : null,
|
||
// github
|
||
h('div', { style: S.section },
|
||
h('span', { style: S.label }, t('settings.add') + ' — ' + t('settings.github')),
|
||
!ghOk ? h('div', { style: S.error }, 'gh ' + t('settings.missing')) : h(React.Fragment, null,
|
||
h('div', { style: S.split },
|
||
h(Field, { label: t('settings.host') }, h(P.Input, { value: ghHost, spellCheck: false, onChange: (e) => setGhHost(e.target.value) })),
|
||
h(Field, { label: t('settings.method') }, h('div', { style: { paddingTop: 6 } },
|
||
radio('web', ghMethod, setGhMethod, t('settings.web')), radio('token', ghMethod, setGhMethod, t('settings.token'))))),
|
||
ghMethod === 'token' ? h(Field, { label: t('settings.token') },
|
||
h(P.Input, { type: 'password', value: ghToken, autoComplete: 'off', onChange: (e) => setGhToken(e.target.value) }),
|
||
h('span', { style: S.hint }, t('settings.tokenHint'))) : null,
|
||
flow && flow.state === 'pending' ? h('div', { style: { ...S.section, background: 'transparent' } },
|
||
h('span', { style: S.hint }, fill(t('settings.code'), { url: '' }), h('a', { href: flow.verifyUrl, target: '_blank', rel: 'noreferrer' }, flow.verifyUrl)),
|
||
h('div', { style: S.toolbar },
|
||
h('code', { style: { fontSize: 20, fontWeight: 700, letterSpacing: 2 } }, flow.code),
|
||
h(P.Button, { variant: 'ghost', size: 'sm', onClick: () => { try { navigator.clipboard.writeText(flow.code); } catch { /* ignore */ } } }, t('settings.copy'))),
|
||
h('div', { style: S.toolbar },
|
||
h('span', { style: S.hint }, t('settings.waiting')),
|
||
h(P.Button, { variant: 'outline', size: 'sm', onClick: ghCancel }, t('settings.cancel')))) :
|
||
h('div', null, h(P.Button, {
|
||
variant: 'primary', size: 'sm', disabled: !!busy || (ghMethod === 'token' && !ghToken.trim()), onClick: ghStart,
|
||
}, ghMethod === 'token' ? t('settings.login') : t('settings.start'))))),
|
||
// gitea
|
||
h('div', { style: S.section },
|
||
h('span', { style: S.label }, t('settings.add') + ' — ' + t('settings.gitea')),
|
||
!teaOk ? h('div', { style: S.error }, 'tea-cli / tea ' + t('settings.missing')) : h(React.Fragment, null,
|
||
h('div', { style: S.split },
|
||
h(Field, { label: t('settings.url') }, h(P.Input, { value: gt.url, placeholder: 'https://gitea.example.com', spellCheck: false, onChange: setG('url') })),
|
||
h(Field, { label: t('settings.name') }, h(P.Input, { value: gt.name, placeholder: 'optional', spellCheck: false, onChange: setG('name') }))),
|
||
h(Field, { label: t('settings.method') }, h('div', null,
|
||
radio('token', gt.method, setG('method'), t('settings.token')), radio('password', gt.method, setG('method'), t('settings.password')))),
|
||
gt.method === 'token' ? h(Field, { label: t('settings.token') },
|
||
h(P.Input, { type: 'password', value: gt.token, autoComplete: 'off', onChange: setG('token') }),
|
||
h('span', { style: S.hint }, t('settings.giteaHint'))) :
|
||
h('div', { style: S.split },
|
||
h(Field, { label: t('settings.user') }, h(P.Input, { value: gt.user, autoComplete: 'off', onChange: setG('user') })),
|
||
h(Field, { label: t('settings.pwd') }, h(P.Input, { type: 'password', value: gt.password, autoComplete: 'off', onChange: setG('password') }))),
|
||
h('label', { style: { ...S.hint, display: 'flex', alignItems: 'center', gap: 6 } },
|
||
h('input', { type: 'checkbox', checked: gt.insecure, onChange: setG('insecure') }), t('settings.insecure')),
|
||
h('div', null, h(P.Button, {
|
||
variant: 'primary', size: 'sm',
|
||
disabled: !!busy || !gt.url.trim() || (gt.method === 'token' ? !gt.token.trim() : !(gt.user && gt.password)),
|
||
onClick: gtLogin,
|
||
}, t('settings.login'))))));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- hero workspace menu
|
||
|
||
const SLOT_PICKER = 'conversation.hero.workspace';
|
||
const MENU_LOCAL = '::git-forge-local';
|
||
const MENU_CLONE = '::git-forge-clone';
|
||
|
||
// Replaces the core workspace menu so it offers two separate entries:
|
||
// "Open local folder…" (the core browse picker) and "Clone repository…".
|
||
function HeroWorkspacePicker(props) {
|
||
const { t, wsT, open, anchorRef, selectedId, onPick, onClose, useWorkspaces } = props;
|
||
const coreList = props.listDirectory;
|
||
const snapshot = useWorkspaces ? useWorkspaces((state) => state) : { items: [], phase: 'ready' };
|
||
const workspaces = (snapshot && snapshot.items) || [];
|
||
const [flow, setFlow] = useState(null); // null | 'local' | 'clone'
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const getAnchorRect = useCallback(() => (anchorRef && anchorRef.current ? anchorRef.current.getBoundingClientRect() : null), [anchorRef]);
|
||
|
||
let core = null;
|
||
try { core = props.core ? props.core() : null; } catch { core = null; }
|
||
let browse = null;
|
||
try { browse = props.browse ? props.browse() : null; } catch { browse = null; }
|
||
|
||
const adopt = (path) => {
|
||
const create = core && core.props && core.props.createWorkspace;
|
||
if (typeof create !== 'function') return Promise.reject(new Error('workspace service unavailable'));
|
||
return create({ path }).then((ws) => { setFlow(null); onPick && onPick(ws.workspaceId); });
|
||
};
|
||
const fail = (reason) => { setFlow(null); setError(reason && reason.message ? reason.message : String(reason)); };
|
||
|
||
const startFlow = (mode) => { onClose && onClose(); setError(''); setFlow(mode); };
|
||
|
||
const defaultName = wsT ? wsT('workspace.defaultName') : 'Default workspace';
|
||
const flowBusy = flow !== null || busy;
|
||
const addEntries = [
|
||
{ id: MENU_LOCAL, label: t('menu.local'), icon: icon('IconFolderOpenOutlineRegular', 16) || icon('IconPlusOutlineRegular', 16), disabled: flowBusy },
|
||
{ id: MENU_CLONE, label: t('menu.clone'), icon: icon('IconDownloadOutlineRegular', 16) || icon('IconBranchOutlineRegular', 16), disabled: flowBusy },
|
||
];
|
||
const pinAdd = workspaces.length > 0;
|
||
const items = pinAdd ? workspaces.map((w) => ({
|
||
id: w.workspaceId,
|
||
label: w.title === 'default-workspace' ? defaultName : w.title,
|
||
icon: icon('IconFolderCloseRegular', 16),
|
||
disabled: flowBusy,
|
||
})) : addEntries;
|
||
|
||
const onSelect = (id) => {
|
||
if (id === MENU_LOCAL) return startFlow('local');
|
||
if (id === MENU_CLONE) return startFlow('clone');
|
||
onPick && onPick(id);
|
||
};
|
||
|
||
return h(React.Fragment, null,
|
||
h(P.Menu, {
|
||
open: !!open,
|
||
anchor: null,
|
||
items,
|
||
...(pinAdd ? { footer: addEntries } : {}),
|
||
selectedId,
|
||
onSelect,
|
||
onClose,
|
||
side: 'bottom',
|
||
portal: true,
|
||
getAnchorRect,
|
||
}),
|
||
flow === 'local' ? h(browse && browse.Component ? browse.Component : LocalFolderDialog, {
|
||
...(browse && browse.Component ? browse.props : { t, listDirectory: coreList }),
|
||
open: true,
|
||
busy,
|
||
onPicked: (path) => { setBusy(true); adopt(path).catch(fail).finally(() => setBusy(false)); },
|
||
onCancel: () => setFlow(null),
|
||
onError: fail,
|
||
}) : null,
|
||
flow === 'clone' ? h(CloneDialog, {
|
||
t,
|
||
open: true,
|
||
busy,
|
||
onCancel: () => setFlow(null),
|
||
onPicked: (path) => { setBusy(true); adopt(path).catch(fail).finally(() => setBusy(false)); },
|
||
}) : null,
|
||
h(P.Modal, {
|
||
open: !!error,
|
||
onClose: () => setError(''),
|
||
closeLabel: t('close'),
|
||
title: t('error.title'),
|
||
footer: h(P.Button, { variant: 'primary', onClick: () => setError('') }, t('close')),
|
||
}, h('div', { role: 'alert', style: { color: 'var(--dsw-alias-state-danger)', wordBreak: 'break-word' } }, error)));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- menu relabel
|
||
|
||
const RELABEL = { en: 'Add or clone workspace…', zh: '添加或克隆工作区…' };
|
||
|
||
function relabelAddWorkspace(locale) {
|
||
const restores = [];
|
||
let timer = null;
|
||
let tries = 0;
|
||
let disposed = false;
|
||
|
||
const publish = () => {
|
||
try {
|
||
if (typeof locale.publish === 'function' && locale.snapshot && locale.snapshot.active) {
|
||
locale.publish(locale.snapshot.active, false);
|
||
}
|
||
} catch { /* best effort */ }
|
||
};
|
||
|
||
const attempt = () => {
|
||
timer = null;
|
||
if (disposed) return;
|
||
let applied = false;
|
||
try {
|
||
const dicts = locale.dicts;
|
||
const m = dicts && typeof dicts.get === 'function' ? dicts.get('workspace') : null;
|
||
if (m && typeof m.get === 'function' && typeof m.set === 'function') {
|
||
for (const lang of Object.keys(RELABEL)) {
|
||
const old = m.get(lang);
|
||
if (!old || typeof old !== 'object') continue;
|
||
m.set(lang, { ...old, 'menu.addWorkspace': RELABEL[lang] });
|
||
restores.push(() => { m.set(lang, old); });
|
||
applied = true;
|
||
}
|
||
}
|
||
} catch { /* best effort */ }
|
||
if (applied) publish();
|
||
else if (++tries < 10) timer = setTimeout(attempt, 500);
|
||
};
|
||
|
||
attempt();
|
||
|
||
return () => {
|
||
disposed = true;
|
||
if (timer) clearTimeout(timer);
|
||
if (restores.length) {
|
||
for (const r of restores.splice(0)) { try { r(); } catch { /* ignore */ } }
|
||
publish();
|
||
}
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------- plugin
|
||
|
||
return {
|
||
inject: ['slots', 'locale'],
|
||
apply(ctx) {
|
||
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'git-forge: dictionaries');
|
||
|
||
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
|
||
name: 'plugins.bundle.config',
|
||
key: '@local/git-forge',
|
||
locale: NS,
|
||
inject: () => ({ t: ctx.locale.bind(NS) }),
|
||
}, SettingsPanel));
|
||
|
||
// uiWorkspace is optional: borrow listDirectory from the browse picker's
|
||
// injected props, else the service if present, else the host /ls route.
|
||
const listDirectory = (path, signal) => {
|
||
let svc;
|
||
try { svc = ctx.get('uiWorkspace'); } catch { svc = undefined; }
|
||
if (svc && typeof svc.listDirectory === 'function') return svc.listDirectory(path, signal);
|
||
for (const key of [SLOT_HERO, SLOT_SIDEBAR]) {
|
||
try {
|
||
for (const e of ctx.slots.entries(key) || []) {
|
||
const p = e && e.component !== AddWorkspaceFlow && e.inject ? e.inject() : null;
|
||
if (p && typeof p.listDirectory === 'function') return p.listDirectory(path, signal);
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
return Promise.reject(new Error('no-list'));
|
||
};
|
||
|
||
const findOther = (key, self) => {
|
||
const entries = ctx.slots.entries(key) || [];
|
||
let best = null;
|
||
for (const entry of entries) {
|
||
if (!entry || entry.component === self) continue;
|
||
const pr = entry.options && typeof entry.options.priority === 'number' ? entry.options.priority : Infinity;
|
||
const bp = best && best.options && typeof best.options.priority === 'number' ? best.options.priority : Infinity;
|
||
if (best === null || pr < bp) best = entry;
|
||
}
|
||
return best ? { Component: best.component, props: best.inject ? best.inject() || {} : {} } : null;
|
||
};
|
||
|
||
ctx.slots.inject(SLOT_PICKER, () => ctx.slots.register({
|
||
name: SLOT_PICKER,
|
||
// Shadow the core picker (priority 0); lowest priority renders.
|
||
priority: -10,
|
||
inject: () => ({
|
||
t: ctx.locale.bind(NS),
|
||
wsT: ctx.locale.bind('workspace'),
|
||
listDirectory,
|
||
core: () => findOther(SLOT_PICKER, HeroWorkspacePicker),
|
||
browse: () => findOther(SLOT_HERO, AddWorkspaceFlow) || findOther(SLOT_SIDEBAR, AddWorkspaceFlow),
|
||
}),
|
||
}, HeroWorkspacePicker));
|
||
|
||
const findShadowed = (key) => {
|
||
try {
|
||
const entries = ctx.slots.entries(key) || [];
|
||
let best = null;
|
||
let bestPriority = Infinity;
|
||
for (const entry of entries) {
|
||
if (!entry || entry.component === AddWorkspaceFlow) continue;
|
||
const pr = entry.options && typeof entry.options.priority === 'number' ? entry.options.priority : Infinity;
|
||
if (best === null || pr < bestPriority) { best = entry; bestPriority = pr; }
|
||
}
|
||
if (!best) return null;
|
||
return { Component: best.component, props: best.inject ? best.inject() || {} : {} };
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const injected = () => ({ t: ctx.locale.bind(NS), listDirectory });
|
||
|
||
// The hero menu has its own "Open local folder…" / "Clone repository…"
|
||
// entries, so the hero directory-flow hole stays with the default
|
||
// browse picker. Only the sidebar "+" gets the combined clone/local flow.
|
||
ctx.slots.inject(SLOT_SIDEBAR, () => ctx.slots.register({
|
||
name: SLOT_SIDEBAR,
|
||
priority: -10,
|
||
inject: () => ({ ...injected(), shadowed: () => findShadowed(SLOT_SIDEBAR) }),
|
||
}, AddWorkspaceFlow));
|
||
},
|
||
};
|
||
},
|
||
});
|