@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.
@@ -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:44px !important;height:44px !important}
93
- .dut-brand-title{font-size:18px !important;font-weight:600 !important}
94
- .dut-sidebar-logo{width:44px !important;height:44px !important}
95
- /* The alpha sidebar keeps its slotted identity at 24px and clips the brand
96
- button. Give only our expanded identity the full artwork height so the
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
- return /(?:^|\s)(new session|send|install)(?:\s|$)|新会话|发送|(?:^|\s)安装(?:\s|$)/u.test(label)
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
+ `
@@ -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
- function page(logo: string): string {
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
- </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">请登录后继续使用。</p><button id="login" class="action" type="button">去登录</button></section></main><script>
12
- const message=document.getElementById('message'),button=document.getElementById('login');const current=new URL(location.href);const returnTo=(()=>{const v=current.searchParams.get('return');return v&&v.startsWith('/')&&!v.startsWith('//')?v:'/'} )();const call=async(name,body={})=>{const r=await fetch('/sidebar/api/'+name,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error?.message||'操作失败');return d.value};let waiting=false;async function status(){try{const s=await call('chatcode.status');if(s.available&&s.validation==='valid'&&!s.expired){location.replace(returnTo);return}if(waiting)message.textContent='正在等待浏览器完成登录…'}catch{message.textContent='无法读取登录状态,请稍后重试。';message.classList.add('error')}}async function login(){const tab=window.open('','_blank');button.disabled=true;button.textContent='正在打开登录页…';message.classList.remove('error');try{const s=await call('chatcode.start');waiting=true;if(tab&&!tab.closed){tab.opener=null;tab.location.href=s.url;message.textContent='请在新打开的浏览器窗口完成登录…'}else{message.innerHTML='浏览器拦截了登录窗口,请 <a href="'+s.url+'" target="_blank" rel="noopener noreferrer">打开登录页</a> 后完成登录。'}}catch{try{tab&&tab.close()}catch{}button.disabled=false;button.textContent='去登录';message.textContent='登录页打开失败,请重试。';message.classList.add('error')}}button.addEventListener('click',login);status();setInterval(status,2000);
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 : page(logo))
26
+ res.end(req.method === 'HEAD' ? undefined : renderChatcodeLoginPage(logo))
24
27
  } catch { res.writeHead(500); res.end('login page unavailable') }
25
28
  } })
26
29
  }
@@ -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 type { PluginMarketService, PluginMarketScope } from './plugin-market-shared.ts'
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
- // Settings can be opened before a conversation exists. In that case the
18
- // host launch directory is the project; never take a sessionless cwd from
19
- // the browser. Conversations still use the existing authoritative resolver.
20
- const contextOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
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: '', cwd: process.cwd() }
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 => service().installed((await contextOf(payload)).cwd),
36
- 'plugins.install': async payload => service().install((await contextOf(payload)).cwd, requireString(payload, 'pluginId'), scopeOf(payload)),
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
- await service().uninstall((await contextOf(payload)).cwd, requireString(payload, 'pluginId'), scopeOf(payload))
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[]>