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)); }, }; }, });