@chatcode/cco-better-sidebar 0.18.0-rc.4 → 0.18.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/README_EN.md +15 -1
- package/lib/client-editor.js +338 -266
- package/lib/client-mermaid.js +328 -260
- package/lib/client-registry.js +1049 -542
- package/lib/client-terminal.js +337 -265
- package/lib/client.js +1048 -541
- package/lib/index.js +162 -11
- package/lib/types/client/LogoutConfirmDialog.d.ts +5 -0
- package/lib/types/client/PluginProjectPicker.d.ts +13 -0
- package/lib/types/client/api.d.ts +7 -5
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/ui-tweaks/branding.d.ts +1 -1
- package/lib/types/client/ui-tweaks/sidebar-header.d.ts +6 -0
- package/lib/types/login-page-script.d.ts +2 -0
- package/lib/types/login-route.d.ts +2 -0
- package/lib/types/plugin-market-shared.d.ts +22 -0
- package/package.json +1 -1
- package/src/client/AccountMenu.tsx +12 -2
- package/src/client/LogoutConfirmDialog.tsx +39 -0
- package/src/client/PluginMarketView.module.css +14 -0
- package/src/client/PluginMarketView.tsx +86 -24
- package/src/client/PluginProjectPicker.tsx +73 -0
- package/src/client/api.ts +7 -5
- package/src/client/locales-ja.ts +15 -1
- package/src/client/locales.ts +66 -3
- package/src/client/sidebar.module.css +81 -19
- package/src/client/ui-tweaks/branding.tsx +10 -9
- package/src/client/ui-tweaks/sidebar-header.ts +73 -0
- package/src/login-page-script.ts +107 -0
- package/src/login-route.ts +7 -4
- package/src/plugin-market-api.ts +55 -9
- package/src/plugin-market-shared.ts +9 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ReactNode } from 'react'
|
|
11
|
+
import { clearSidebarHeader, SIDEBAR_HEADER_CSS, syncSidebarHeader } from './sidebar-header.ts'
|
|
11
12
|
|
|
12
13
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
13
14
|
interface SlotMap {
|
|
@@ -89,14 +90,11 @@ body:not([data-ds-dark-theme]) .dut-unicom-primary-action:active{transform:trans
|
|
|
89
90
|
hovered, the host owns the complete interaction treatment again. */
|
|
90
91
|
body[data-ds-dark-theme] .dut-unicom-primary-action:not(:hover){background:#9B0B09 !important}
|
|
91
92
|
.dut-project-logo{display:block;object-fit:contain}
|
|
92
|
-
.dut-left-logo{width:
|
|
93
|
-
.dut-brand-title{font-
|
|
94
|
-
.dut-sidebar-logo{width:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
44px knot is not cropped by that host-owned overflow boundary. */
|
|
98
|
-
[class*="brandIdentity"]:has(.dut-sidebar-logo){height:44px !important}
|
|
99
|
-
.dut-sidebar-brand-name{font-size:18px !important;line-height:22px !important;font-weight:600 !important;letter-spacing:0 !important;white-space:nowrap}
|
|
93
|
+
.dut-left-logo{width:24px !important;height:24px !important}
|
|
94
|
+
.dut-brand-title{font:var(--dsw-font-s-strong-14) !important}
|
|
95
|
+
.dut-sidebar-logo{width:24px !important;height:24px !important}
|
|
96
|
+
.dut-sidebar-brand-name{font:var(--dsw-font-s-strong-14) !important;letter-spacing:-.01em !important;white-space:nowrap}
|
|
97
|
+
${SIDEBAR_HEADER_CSS}
|
|
100
98
|
.dut-brand-version{display:none !important}
|
|
101
99
|
.dut-hero-preview{display:none !important}
|
|
102
100
|
.dut-hero-logo{width:60px !important;height:60px !important}
|
|
@@ -110,7 +108,8 @@ function hasPrimaryLabel(button: HTMLButtonElement): boolean {
|
|
|
110
108
|
// descriptions, where words like "install" do not identify a primary action.
|
|
111
109
|
if (button.closest('[data-chatcode-plugin-market], [data-plugin-module]')) return false
|
|
112
110
|
const label = `${button.textContent ?? ''} ${button.getAttribute('aria-label') ?? ''} ${button.title}`.trim().toLowerCase()
|
|
113
|
-
|
|
111
|
+
if (button.classList.contains('dut-sidebar-brand') || button.classList.contains('dut-sidebar-new-session')) return false
|
|
112
|
+
return /(?:^|\s)(send|install)(?:\s|$)|发送|(?:^|\s)安装(?:\s|$)/u.test(label)
|
|
114
113
|
}
|
|
115
114
|
|
|
116
115
|
/** Replace one host SVG while retaining its generated layout class names. */
|
|
@@ -241,6 +240,7 @@ function syncBranding(): void {
|
|
|
241
240
|
if (svg instanceof SVGElement) replaceHostLogo(svg)
|
|
242
241
|
})
|
|
243
242
|
|
|
243
|
+
syncSidebarHeader()
|
|
244
244
|
document.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
|
|
245
245
|
button.classList.toggle('dut-unicom-primary-action', hasPrimaryLabel(button))
|
|
246
246
|
})
|
|
@@ -285,6 +285,7 @@ export function installBranding(): () => void {
|
|
|
285
285
|
return () => {
|
|
286
286
|
observer.disconnect()
|
|
287
287
|
if (frame !== undefined) window.cancelAnimationFrame(frame)
|
|
288
|
+
clearSidebarHeader()
|
|
288
289
|
favicon.remove()
|
|
289
290
|
style.remove()
|
|
290
291
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Compact sidebar chrome; the host retains every button, shortcut and drag surface. */
|
|
2
|
+
export const SIDEBAR_HEADER_CSS = `
|
|
3
|
+
.dut-sidebar-header{height:44px !important;padding:0 4px !important;margin-bottom:8px !important;gap:8px !important}
|
|
4
|
+
.dut-sidebar-brand{min-width:0 !important;background:transparent !important;box-shadow:none !important}
|
|
5
|
+
.dut-sidebar-identity{height:24px !important;gap:8px !important}
|
|
6
|
+
.dut-sidebar-wordmark{font:var(--dsw-font-s-strong-14) !important;letter-spacing:-.01em !important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
7
|
+
.dut-sidebar-toggle{width:28px !important;height:28px !important;border-radius:8px !important;color:var(--dsw-alias-label-secondary) !important}
|
|
8
|
+
.dut-sidebar-toggle:hover{background:var(--dsw-alias-interactive-bg-hover) !important}
|
|
9
|
+
/* Reuse the original ChatCode primary-action red, scoped to this marked CTA. */
|
|
10
|
+
.dut-sidebar-new-session{height:40px !important;justify-content:flex-start !important;gap:8px !important;margin:0 0 10px !important;padding:8px 10px !important;border:1px solid #9B0B09 !important;border-radius:10px !important;background:linear-gradient(135deg,#9B0B09,#b71916) !important;color:#fff !important;font:var(--dsw-font-s-strong-14) !important;font-weight:600 !important;box-shadow:none !important;transform:none !important}
|
|
11
|
+
.dut-sidebar-new-session:hover{background:linear-gradient(135deg,#b71916,#cf302c) !important}
|
|
12
|
+
.dut-sidebar-new-session:active{background:#9B0B09 !important}
|
|
13
|
+
.dut-sidebar-new-session:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
|
|
14
|
+
.dut-sidebar-new-session > span:first-child{flex:1;min-width:0;overflow:hidden;mask-image:none !important}
|
|
15
|
+
.dut-sidebar-new-session > span:first-child > span{width:auto !important;justify-content:flex-start !important;gap:8px !important;min-width:0}
|
|
16
|
+
.dut-sidebar-new-session > span:first-child > span > svg{width:16px;height:16px;flex:none;color:inherit}
|
|
17
|
+
.dut-sidebar-new-session > span:first-child > span > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
18
|
+
.dut-sidebar-new-session > span + span{opacity:1 !important;flex:none;color:color-mix(in srgb,currentColor 75%,transparent);font:var(--dsw-font-xxs-12)}
|
|
19
|
+
/* Temporary navigation-only hide. Remove this block to restore Plugins;
|
|
20
|
+
registrations, settings, installed plugins and other panel entries stay intact. */
|
|
21
|
+
nav:is([aria-label="全局面板"],[aria-label="Global panels"]) > button:is([aria-label="插件"],[aria-label="Plugins"]),
|
|
22
|
+
nav:is([aria-label="全局面板"],[aria-label="Global panels"]):has(> button:is([aria-label="插件"],[aria-label="Plugins"]):only-child){display:none !important}
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const HEADER_CLASSES = [
|
|
26
|
+
'dut-sidebar-header', 'dut-sidebar-brand', 'dut-sidebar-identity',
|
|
27
|
+
'dut-sidebar-wordmark', 'dut-sidebar-toggle', 'dut-sidebar-new-session',
|
|
28
|
+
] as const
|
|
29
|
+
type HeaderClass = typeof HEADER_CLASSES[number]
|
|
30
|
+
|
|
31
|
+
/** Mark only the expanded brand row and its adjacent native New Session button. */
|
|
32
|
+
export function syncSidebarHeader(): void {
|
|
33
|
+
const desired = new Map<HTMLElement, Set<HeaderClass>>()
|
|
34
|
+
const mark = (element: Element | null | undefined, className: HeaderClass): void => {
|
|
35
|
+
if (!(element instanceof HTMLElement)) return
|
|
36
|
+
const classes = desired.get(element) ?? new Set<HeaderClass>()
|
|
37
|
+
classes.add(className)
|
|
38
|
+
desired.set(element, classes)
|
|
39
|
+
}
|
|
40
|
+
for (const name of document.querySelectorAll<HTMLElement>('.dut-sidebar-brand-name,.dut-brand-title')) {
|
|
41
|
+
const row = name.closest<HTMLElement>('[data-window-drag]')
|
|
42
|
+
const logo = row?.querySelector('.dut-sidebar-logo,.dut-left-logo')
|
|
43
|
+
if (!row || !logo) continue
|
|
44
|
+
const brand = [...row.children].find(child => child.contains(name) && child.contains(logo))
|
|
45
|
+
if (!brand) continue
|
|
46
|
+
mark(row, 'dut-sidebar-header')
|
|
47
|
+
mark(brand, 'dut-sidebar-brand')
|
|
48
|
+
mark(logo.closest('[class*="brandIdentity"]'), 'dut-sidebar-identity')
|
|
49
|
+
mark(name, 'dut-sidebar-wordmark')
|
|
50
|
+
for (const button of row.querySelectorAll('button')) {
|
|
51
|
+
if (!brand.contains(button) && button !== brand) mark(button, 'dut-sidebar-toggle')
|
|
52
|
+
}
|
|
53
|
+
// The host renders this native action directly after the drag row. Do
|
|
54
|
+
// not infer a new action from arbitrary text in another panel or dialog.
|
|
55
|
+
const next = row.nextElementSibling
|
|
56
|
+
if (next instanceof HTMLButtonElement) mark(next, 'dut-sidebar-new-session')
|
|
57
|
+
}
|
|
58
|
+
// A collapsed rail no longer has a wordmark. Remove expanded-only styling
|
|
59
|
+
// without changing the host's rail geometry or its logo/expand interaction.
|
|
60
|
+
for (const className of HEADER_CLASSES) {
|
|
61
|
+
for (const element of document.querySelectorAll<HTMLElement>(`.${className}`)) {
|
|
62
|
+
if (!desired.get(element)?.has(className)) element.classList.remove(className)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const [element, classes] of desired) element.classList.add(...classes)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Restore native header styling when the branding effect is disposed. */
|
|
69
|
+
export function clearSidebarHeader(): void {
|
|
70
|
+
for (const className of HEADER_CLASSES) {
|
|
71
|
+
for (const element of document.querySelectorAll(`.${className}`)) element.classList.remove(className)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Standalone sign-in recovery; each document starts once and always keeps a usable reopen action. */
|
|
2
|
+
export const CHATCODE_LOGIN_PAGE_SCRIPT = String.raw`
|
|
3
|
+
(() => {
|
|
4
|
+
const message = document.getElementById('message');
|
|
5
|
+
const button = document.getElementById('login');
|
|
6
|
+
const fallback = document.getElementById('login-fallback');
|
|
7
|
+
const link = document.getElementById('login-link');
|
|
8
|
+
const current = new URL(location.href);
|
|
9
|
+
const returnTo = (() => {
|
|
10
|
+
const value = current.searchParams.get('return');
|
|
11
|
+
if (!value || !value.startsWith('/') || value.startsWith('//')) return '/';
|
|
12
|
+
const target = new URL(value, location.origin);
|
|
13
|
+
return target.origin === location.origin ? target.pathname + target.search + target.hash : '/';
|
|
14
|
+
})();
|
|
15
|
+
let busy = false, checking = false, attempted = false, recheck = false, attempt, popup;
|
|
16
|
+
const call = async name => {
|
|
17
|
+
const response = await fetch('/sidebar/api/' + name, {
|
|
18
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
|
|
19
|
+
});
|
|
20
|
+
const data = await response.json();
|
|
21
|
+
if (!response.ok || !data.ok) throw new Error('操作失败');
|
|
22
|
+
return data.value;
|
|
23
|
+
};
|
|
24
|
+
const showMessage = (text, error = false) => {
|
|
25
|
+
message.textContent = text;
|
|
26
|
+
message.classList.toggle('error', error);
|
|
27
|
+
};
|
|
28
|
+
async function login() {
|
|
29
|
+
if (busy) return;
|
|
30
|
+
attempted = true;
|
|
31
|
+
busy = true;
|
|
32
|
+
button.disabled = true;
|
|
33
|
+
button.textContent = '正在打开登录页…';
|
|
34
|
+
let tab = null;
|
|
35
|
+
try { tab = window.open('', '_blank'); if (tab) tab.opener = null; } catch {}
|
|
36
|
+
try {
|
|
37
|
+
if (!attempt) {
|
|
38
|
+
const started = await call('chatcode.start');
|
|
39
|
+
const url = new URL(started.url);
|
|
40
|
+
if (!['http:', 'https:'].includes(url.protocol) || typeof started.sessionId !== 'string') throw new Error('登录地址无效');
|
|
41
|
+
attempt = { sessionId: started.sessionId, url: url.href };
|
|
42
|
+
}
|
|
43
|
+
link.href = attempt.url;
|
|
44
|
+
link.textContent = attempt.url;
|
|
45
|
+
fallback.hidden = false;
|
|
46
|
+
if (tab && !tab.closed) {
|
|
47
|
+
try { tab.location.href = attempt.url; popup = tab; } catch { try { tab.close(); } catch {} }
|
|
48
|
+
}
|
|
49
|
+
showMessage('请在登录页完成登录;若网页已关闭,可随时重新打开。');
|
|
50
|
+
} catch {
|
|
51
|
+
try { if (tab) tab.close(); } catch {}
|
|
52
|
+
showMessage('登录页打开失败,请重试。', true);
|
|
53
|
+
} finally {
|
|
54
|
+
busy = false;
|
|
55
|
+
button.disabled = false;
|
|
56
|
+
button.textContent = attempt ? '重新打开登录页' : '去登录';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function status() {
|
|
60
|
+
if (checking || busy) return;
|
|
61
|
+
checking = true;
|
|
62
|
+
const observedAttempt = attempt, observedStarted = attempted;
|
|
63
|
+
try {
|
|
64
|
+
const state = await call('chatcode.status');
|
|
65
|
+
if (busy || attempt !== observedAttempt || attempted !== observedStarted) return;
|
|
66
|
+
if (state.available && state.validation === 'valid' && !state.expired) {
|
|
67
|
+
try { if (popup) popup.close(); window.focus(); } catch {}
|
|
68
|
+
location.replace(returnTo);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!state.available) {
|
|
72
|
+
recheck = true;
|
|
73
|
+
button.textContent = '重新检测';
|
|
74
|
+
showMessage('登录服务暂不可用,请稍后重试。', true);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (state.configured && state.validation === 'unavailable') {
|
|
78
|
+
recheck = true;
|
|
79
|
+
button.textContent = '重新检测';
|
|
80
|
+
showMessage('暂时无法验证已有账号,请检查网络后重新检测。', true);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
recheck = false;
|
|
84
|
+
button.textContent = attempt ? '重新打开登录页' : '去登录';
|
|
85
|
+
if (attempt && (state.login?.sessionId !== attempt.sessionId || state.login.state !== 'pending')) {
|
|
86
|
+
attempt = undefined;
|
|
87
|
+
fallback.hidden = true;
|
|
88
|
+
link.removeAttribute('href');
|
|
89
|
+
link.textContent = '';
|
|
90
|
+
button.textContent = '去登录';
|
|
91
|
+
showMessage('登录请求已失效,请重新登录。');
|
|
92
|
+
}
|
|
93
|
+
if (!attempted) await login();
|
|
94
|
+
} catch {
|
|
95
|
+
recheck = true;
|
|
96
|
+
button.textContent = '重新检测';
|
|
97
|
+
showMessage('无法读取登录状态,请稍后重试。', true);
|
|
98
|
+
} finally {
|
|
99
|
+
checking = false;
|
|
100
|
+
if (!busy) button.disabled = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
button.addEventListener('click', () => { void (recheck ? status() : login()); });
|
|
104
|
+
void status();
|
|
105
|
+
setInterval(() => { void status(); }, 2000);
|
|
106
|
+
})();
|
|
107
|
+
`
|
package/src/login-route.ts
CHANGED
|
@@ -2,14 +2,17 @@ import { readFile } from 'node:fs/promises'
|
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
|
|
5
|
+
import { CHATCODE_LOGIN_PAGE_SCRIPT } from './login-page-script.ts'
|
|
5
6
|
|
|
6
7
|
const assetPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'logo.svg')
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
/** Render the standalone login gate with one automatic attempt and persistent manual recovery. */
|
|
10
|
+
export function renderChatcodeLoginPage(logo: string): string {
|
|
9
11
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>请登录 ChatCode</title><style>
|
|
10
12
|
*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;font-family:Inter,"Microsoft YaHei",system-ui,sans-serif;color:#172b4d;background:radial-gradient(circle at 18% 18%,#e5edff 0,transparent 30rem),linear-gradient(135deg,#edf2fb,#dfe8f7)}.card{width:min(490px,calc(100vw - 40px));padding:34px 44px 36px;border:1px solid #dce4ef;border-radius:18px;background:#fff;box-shadow:0 20px 48px rgba(36,62,103,.18),0 2px 8px rgba(36,62,103,.06)}.brand{display:flex;align-items:center;justify-content:center;gap:12px;padding-bottom:27px;border-bottom:1px solid #edf0f5}.brand img{width:34px;height:34px}.brand strong{font-size:22px;letter-spacing:-.02em}.content{padding-top:40px;text-align:center}.content h1{margin:0;font-size:28px;line-height:1.3;font-weight:650}.content p{min-height:24px;margin:12px 0 30px;color:#71809a;font-size:14px;line-height:1.7}.action{width:100%;min-height:46px;border:0;border-radius:8px;background:#9B0B09;color:#fff;font:600 15px/1 Inter,"Microsoft YaHei",sans-serif;cursor:pointer;box-shadow:0 8px 18px rgba(155,11,9,.25);transition:transform .18s ease,box-shadow .18s ease,background .18s ease}.action:hover:not(:disabled){transform:translateY(-2px);background:#b20d0a;box-shadow:0 12px 24px rgba(155,11,9,.38)}.action:focus-visible{outline:3px solid #f0b7b5;outline-offset:3px}.action:disabled{cursor:default;opacity:.7}.error{color:#b42318}@media(max-width:480px){.card{padding:30px 26px}.content{padding-top:32px}}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
.login-fallback{margin:18px 0 0;color:#71809a;font-size:12px;line-height:1.7;overflow-wrap:anywhere}.login-fallback a{display:block;margin-top:4px;color:#315da8;text-underline-offset:3px}
|
|
14
|
+
</style></head><body><main class="card"><div class="brand"><img src="data:image/svg+xml;base64,${logo}" alt=""><strong>ChatCode</strong></div><section class="content"><h1>请登录 ChatCode</h1><p id="message" aria-live="polite">正在检查登录状态…</p><button id="login" class="action" type="button" disabled>正在检查登录状态…</button><div id="login-fallback" class="login-fallback" hidden>若登录网页已关闭或未弹出,可再次访问:<a id="login-link" target="_blank" rel="noopener noreferrer"></a></div></section></main><script>
|
|
15
|
+
${CHATCODE_LOGIN_PAGE_SCRIPT}
|
|
13
16
|
</script></body></html>`
|
|
14
17
|
}
|
|
15
18
|
|
|
@@ -20,7 +23,7 @@ export function registerLoginRoute(ctx: Context, fence: (req: SidebarHttpRequest
|
|
|
20
23
|
try {
|
|
21
24
|
const logo = (await readFile(assetPath)).toString('base64')
|
|
22
25
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'self'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'" })
|
|
23
|
-
res.end(req.method === 'HEAD' ? undefined :
|
|
26
|
+
res.end(req.method === 'HEAD' ? undefined : renderChatcodeLoginPage(logo))
|
|
24
27
|
} catch { res.writeHead(500); res.end('login page unavailable') }
|
|
25
28
|
} })
|
|
26
29
|
}
|
package/src/plugin-market-api.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/** Shared config plugin routes inside the sidebar's existing trust fence. */
|
|
2
2
|
import type { Context } from './context-types.ts'
|
|
3
|
-
import
|
|
3
|
+
import { realpath, stat } from 'node:fs/promises'
|
|
4
|
+
import { isAbsolute } from 'node:path'
|
|
5
|
+
import type { PluginMarketService, PluginMarketScope, PluginProjectDirectories } from './plugin-market-shared.ts'
|
|
4
6
|
import { requireString, SidebarError } from './wire.ts'
|
|
5
7
|
|
|
8
|
+
interface DirectoryPicker {
|
|
9
|
+
capability(): { kind: 'native'; pick(signal: AbortSignal): Promise<string | null> }
|
|
10
|
+
| { kind: 'browse'; list(path?: string, signal?: AbortSignal): Promise<PluginProjectDirectories> }
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export function buildPluginMarketApi(ctx: Pick<Context, 'get'>, cwdOf: (payload: unknown) => Promise<{ cwd: string }>): Record<string, (payload: unknown) => Promise<unknown>> {
|
|
7
14
|
const service = (): PluginMarketService => {
|
|
8
15
|
const value = ctx.get('chatcodePluginMarket', false) as PluginMarketService | undefined
|
|
@@ -14,17 +21,46 @@ export function buildPluginMarketApi(ctx: Pick<Context, 'get'>, cwdOf: (payload:
|
|
|
14
21
|
if (scope !== 'project' && scope !== 'user') throw new SidebarError('bad-request', 'scope must be project or user')
|
|
15
22
|
return scope
|
|
16
23
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
const selectedProject = async (payload: unknown): Promise<string> => {
|
|
25
|
+
if (typeof (payload as { projectPath?: unknown } | null)?.projectPath !== 'string') throw new SidebarError('bad-request', '请先选择目标项目目录。')
|
|
26
|
+
const path = requireString(payload, 'projectPath')
|
|
27
|
+
const qualified = process.platform !== 'win32' || /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path)
|
|
28
|
+
if (!isAbsolute(path) || !qualified || path.includes('\0')) throw new SidebarError('bad-request', '请选择有效的项目目录。')
|
|
29
|
+
const canonical = await realpath(path)
|
|
30
|
+
if (!(await stat(canonical)).isDirectory()) throw new SidebarError('bad-request', '请选择项目文件夹。')
|
|
31
|
+
return canonical
|
|
32
|
+
}
|
|
33
|
+
const picker = () => {
|
|
34
|
+
const value = ctx.get('directoryPicker', false) as DirectoryPicker | undefined
|
|
35
|
+
if (!value) throw new SidebarError('method-error', '目录选择器未挂载,请启用宿主目录选择插件。', 503)
|
|
36
|
+
return value.capability()
|
|
37
|
+
}
|
|
38
|
+
// A sessionless settings page has no implicit project. Only user-scoped
|
|
39
|
+
// operations may use the launch cwd internally, which never becomes a target.
|
|
40
|
+
const contextOf = async (payload: unknown): Promise<{ sessionId: string; cwd?: string }> => {
|
|
21
41
|
const sessionId = (payload as { sessionId?: unknown } | null)?.sessionId
|
|
22
|
-
if (sessionId === undefined || sessionId === '') return { sessionId: ''
|
|
42
|
+
if (sessionId === undefined || sessionId === '') return { sessionId: '' }
|
|
23
43
|
requireString(payload, 'sessionId')
|
|
24
44
|
return { sessionId: sessionId as string, cwd: (await cwdOf(payload)).cwd }
|
|
25
45
|
}
|
|
46
|
+
const projectOf = async (payload: unknown): Promise<string | undefined> => {
|
|
47
|
+
if ((payload as { projectPath?: unknown } | null)?.projectPath !== undefined) return selectedProject(payload)
|
|
48
|
+
return (await contextOf(payload)).cwd
|
|
49
|
+
}
|
|
26
50
|
return {
|
|
27
51
|
'plugins.context': contextOf,
|
|
52
|
+
'plugins.project.pick': async () => {
|
|
53
|
+
const capability = picker()
|
|
54
|
+
if (capability.kind === 'browse') return { kind: 'browse' }
|
|
55
|
+
const path = await capability.pick(AbortSignal.timeout(120_000))
|
|
56
|
+
return { kind: 'native', path: path === null ? null : await selectedProject({ projectPath: path }) }
|
|
57
|
+
},
|
|
58
|
+
'plugins.project.directories': async payload => {
|
|
59
|
+
const capability = picker()
|
|
60
|
+
if (capability.kind !== 'browse') throw new SidebarError('method-error', '当前宿主使用系统文件夹选择窗口。')
|
|
61
|
+
const path = (payload as { path?: unknown } | null)?.path
|
|
62
|
+
return capability.list(path === undefined ? undefined : requireString(payload, 'path'), AbortSignal.timeout(15_000))
|
|
63
|
+
},
|
|
28
64
|
'plugins.marketplaces': () => service().marketplaces(),
|
|
29
65
|
'plugins.catalog': async payload => {
|
|
30
66
|
const market = service()
|
|
@@ -32,10 +68,20 @@ export function buildPluginMarketApi(ctx: Pick<Context, 'get'>, cwdOf: (payload:
|
|
|
32
68
|
if ((payload as { includeMetadata?: unknown }).includeMetadata !== true) return market.plugins(id)
|
|
33
69
|
return market.catalog ? market.catalog(id) : { plugins: await market.plugins(id), warning: '' }
|
|
34
70
|
},
|
|
35
|
-
'plugins.installed': async payload =>
|
|
36
|
-
|
|
71
|
+
'plugins.installed': async payload => {
|
|
72
|
+
const project = await projectOf(payload)
|
|
73
|
+
const records = await service().installed(project ?? process.cwd())
|
|
74
|
+
return project ? records : records.filter(record => record.scope === 'user')
|
|
75
|
+
},
|
|
76
|
+
'plugins.install': async payload => {
|
|
77
|
+
const scope = scopeOf(payload)
|
|
78
|
+
const project = scope === 'project' ? await selectedProject(payload) : await projectOf(payload)
|
|
79
|
+
return service().install(project ?? process.cwd(), requireString(payload, 'pluginId'), scope)
|
|
80
|
+
},
|
|
37
81
|
'plugins.uninstall': async payload => {
|
|
38
|
-
|
|
82
|
+
const scope = scopeOf(payload)
|
|
83
|
+
const project = scope === 'project' ? await selectedProject(payload) : await projectOf(payload)
|
|
84
|
+
await service().uninstall(project ?? process.cwd(), requireString(payload, 'pluginId'), scope)
|
|
39
85
|
return { ok: true }
|
|
40
86
|
},
|
|
41
87
|
}
|
|
@@ -11,6 +11,15 @@ export interface InstalledMarketplacePlugin extends MarketplacePlugin {
|
|
|
11
11
|
}
|
|
12
12
|
export interface MarketplaceCatalog { marketplaces: PluginMarketplace[]; warning: string }
|
|
13
13
|
export interface PluginCatalog { plugins: MarketplacePlugin[]; warning: string }
|
|
14
|
+
/** Host-owned paths for the remote Web directory browser. */
|
|
15
|
+
export interface PluginProjectDirectories {
|
|
16
|
+
path: string
|
|
17
|
+
home: string
|
|
18
|
+
crumbs: Array<{ name: string; path: string; hidden: boolean }>
|
|
19
|
+
entries: Array<{ name: string; path: string; hidden: boolean }>
|
|
20
|
+
truncated: boolean
|
|
21
|
+
}
|
|
22
|
+
export type PluginProjectPick = { kind: 'native'; path: string | null } | { kind: 'browse' }
|
|
14
23
|
export interface PluginMarketService {
|
|
15
24
|
marketplaces(): Promise<MarketplaceCatalog>
|
|
16
25
|
plugins(marketplaceId: string): Promise<MarketplacePlugin[]>
|