Files

1053 lines
50 KiB
JavaScript
Raw Permalink 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.
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));
},
};
},
});