@chatcode/cco-better-sidebar 0.18.0-rc.5 → 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/lib/index.js CHANGED
@@ -6005,20 +6005,50 @@ function buildPluginMarketApi(ctx, cwdOf) {
6005
6005
  if (scope !== "project" && scope !== "user") throw new SidebarError("bad-request", "scope must be project or user");
6006
6006
  return scope;
6007
6007
  };
6008
+ const selectedProject = async (payload) => {
6009
+ if (typeof payload?.projectPath !== "string") throw new SidebarError("bad-request", "请先选择目标项目目录。");
6010
+ const path = requireString(payload, "projectPath");
6011
+ const qualified = process.platform !== "win32" || /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path);
6012
+ if (!isAbsolute(path) || !qualified || path.includes("\0")) throw new SidebarError("bad-request", "请选择有效的项目目录。");
6013
+ const canonical = await realpath(path);
6014
+ if (!(await stat(canonical)).isDirectory()) throw new SidebarError("bad-request", "请选择项目文件夹。");
6015
+ return canonical;
6016
+ };
6017
+ const picker = () => {
6018
+ const value = ctx.get("directoryPicker", false);
6019
+ if (!value) throw new SidebarError("method-error", "目录选择器未挂载,请启用宿主目录选择插件。", 503);
6020
+ return value.capability();
6021
+ };
6008
6022
  const contextOf = async (payload) => {
6009
6023
  const sessionId = payload?.sessionId;
6010
- if (sessionId === void 0 || sessionId === "") return {
6011
- sessionId: "",
6012
- cwd: process.cwd()
6013
- };
6024
+ if (sessionId === void 0 || sessionId === "") return { sessionId: "" };
6014
6025
  requireString(payload, "sessionId");
6015
6026
  return {
6016
6027
  sessionId,
6017
6028
  cwd: (await cwdOf(payload)).cwd
6018
6029
  };
6019
6030
  };
6031
+ const projectOf = async (payload) => {
6032
+ if (payload?.projectPath !== void 0) return selectedProject(payload);
6033
+ return (await contextOf(payload)).cwd;
6034
+ };
6020
6035
  return {
6021
6036
  "plugins.context": contextOf,
6037
+ "plugins.project.pick": async () => {
6038
+ const capability = picker();
6039
+ if (capability.kind === "browse") return { kind: "browse" };
6040
+ const path = await capability.pick(AbortSignal.timeout(12e4));
6041
+ return {
6042
+ kind: "native",
6043
+ path: path === null ? null : await selectedProject({ projectPath: path })
6044
+ };
6045
+ },
6046
+ "plugins.project.directories": async (payload) => {
6047
+ const capability = picker();
6048
+ if (capability.kind !== "browse") throw new SidebarError("method-error", "当前宿主使用系统文件夹选择窗口。");
6049
+ const path = payload?.path;
6050
+ return capability.list(path === void 0 ? void 0 : requireString(payload, "path"), AbortSignal.timeout(15e3));
6051
+ },
6022
6052
  "plugins.marketplaces": () => service().marketplaces(),
6023
6053
  "plugins.catalog": async (payload) => {
6024
6054
  const market = service();
@@ -6029,10 +6059,20 @@ function buildPluginMarketApi(ctx, cwdOf) {
6029
6059
  warning: ""
6030
6060
  };
6031
6061
  },
6032
- "plugins.installed": async (payload) => service().installed((await contextOf(payload)).cwd),
6033
- "plugins.install": async (payload) => service().install((await contextOf(payload)).cwd, requireString(payload, "pluginId"), scopeOf(payload)),
6062
+ "plugins.installed": async (payload) => {
6063
+ const project = await projectOf(payload);
6064
+ const records = await service().installed(project ?? process.cwd());
6065
+ return project ? records : records.filter((record) => record.scope === "user");
6066
+ },
6067
+ "plugins.install": async (payload) => {
6068
+ const scope = scopeOf(payload);
6069
+ const project = scope === "project" ? await selectedProject(payload) : await projectOf(payload);
6070
+ return service().install(project ?? process.cwd(), requireString(payload, "pluginId"), scope);
6071
+ },
6034
6072
  "plugins.uninstall": async (payload) => {
6035
- await service().uninstall((await contextOf(payload)).cwd, requireString(payload, "pluginId"), scopeOf(payload));
6073
+ const scope = scopeOf(payload);
6074
+ const project = scope === "project" ? await selectedProject(payload) : await projectOf(payload);
6075
+ await service().uninstall(project ?? process.cwd(), requireString(payload, "pluginId"), scope);
6036
6076
  return { ok: true };
6037
6077
  }
6038
6078
  };
@@ -0,0 +1,13 @@
1
+ import type { PluginProjectPick } from '../plugin-market-shared.ts';
2
+ export interface PluginProjectRequest {
3
+ result: Promise<PluginProjectPick>;
4
+ controller: AbortController;
5
+ finish: (path: string | null) => void;
6
+ }
7
+ export declare function PluginProjectPicker({ request, initialPath, onPicked, onCancel, onError }: {
8
+ request: PluginProjectRequest;
9
+ initialPath?: string;
10
+ onPicked: (path: string) => void;
11
+ onCancel: () => void;
12
+ onError: (message: string) => void;
13
+ }): JSX.Element | null;
@@ -1,5 +1,5 @@
1
1
  import type { BrowserProbeResult } from './browser.ts';
2
- import type { MarketplaceCatalog, MarketplacePlugin, InstalledMarketplacePlugin, PluginMarketScope } from '../plugin-market-shared.ts';
2
+ import type { MarketplaceCatalog, MarketplacePlugin, InstalledMarketplacePlugin, PluginMarketScope, PluginProjectPick, PluginProjectDirectories } from '../plugin-market-shared.ts';
3
3
  /** One wire failure. */
4
4
  export declare class SidebarApiError extends Error {
5
5
  readonly code: string;
@@ -187,16 +187,18 @@ export interface SessionScope {
187
187
  export declare const api: {
188
188
  pluginMarketContext: (scope: SessionScope, signal?: AbortSignal) => Promise<{
189
189
  sessionId: string;
190
- cwd: string;
190
+ cwd?: string;
191
191
  }>;
192
+ pluginProjectPick: (signal?: AbortSignal) => Promise<PluginProjectPick>;
193
+ pluginProjectDirectories: (path?: string, signal?: AbortSignal) => Promise<PluginProjectDirectories>;
192
194
  pluginMarketplaces: (signal?: AbortSignal) => Promise<MarketplaceCatalog>;
193
195
  pluginCatalog: (marketplaceId: string, signal?: AbortSignal) => Promise<MarketplacePlugin[] | {
194
196
  plugins: MarketplacePlugin[];
195
197
  warning: string;
196
198
  }>;
197
- pluginsInstalled: (scope: SessionScope, signal?: AbortSignal) => Promise<InstalledMarketplacePlugin[]>;
198
- pluginInstall: (scope: SessionScope, pluginId: string, installScope: PluginMarketScope) => Promise<InstalledMarketplacePlugin>;
199
- pluginUninstall: (scope: SessionScope, pluginId: string, installScope: PluginMarketScope) => Promise<{
199
+ pluginsInstalled: (scope: SessionScope, signal?: AbortSignal, projectPath?: string) => Promise<InstalledMarketplacePlugin[]>;
200
+ pluginInstall: (scope: SessionScope, pluginId: string, installScope: PluginMarketScope, projectPath?: string) => Promise<InstalledMarketplacePlugin>;
201
+ pluginUninstall: (scope: SessionScope, pluginId: string, installScope: PluginMarketScope, projectPath?: string) => Promise<{
200
202
  ok: true;
201
203
  }>;
202
204
  chatcodeStatus: (signal?: AbortSignal) => Promise<ChatCodeAuthState>;
@@ -37,12 +37,24 @@ export declare const zh: {
37
37
  pluginMarketInstalled: string;
38
38
  pluginMarketUninstalled: string;
39
39
  pluginMarketSubtitle: string;
40
+ pluginMarketChooseProject: string;
41
+ pluginMarketProjectHint: string;
42
+ pluginMarketChoosingDirectory: string;
43
+ pluginMarketInstalledAt: string;
44
+ pluginMarketInstallPath: string;
45
+ pluginMarketDirectoryPath: string;
46
+ pluginMarketOpenDirectory: string;
47
+ pluginMarketSelectDirectory: string;
48
+ pluginMarketHomeDirectory: string;
49
+ pluginMarketDirectoryTruncated: string;
40
50
  pluginMarketSearch: string;
41
51
  pluginMarketDefault: string;
42
52
  pluginMarketBrowse: string;
43
53
  pluginMarketNoDescription: string;
44
54
  pluginMarketInstalling: string;
45
55
  pluginMarketUninstalling: string;
56
+ pluginMarketInstallProgress: string;
57
+ pluginMarketUninstallProgress: string;
46
58
  accountMenuSettings: string;
47
59
  accountMenuLogout: string;
48
60
  accountMenuLogoutConfirmTitle: string;
@@ -27,6 +27,28 @@ export interface PluginCatalog {
27
27
  plugins: MarketplacePlugin[];
28
28
  warning: string;
29
29
  }
30
+ /** Host-owned paths for the remote Web directory browser. */
31
+ export interface PluginProjectDirectories {
32
+ path: string;
33
+ home: string;
34
+ crumbs: Array<{
35
+ name: string;
36
+ path: string;
37
+ hidden: boolean;
38
+ }>;
39
+ entries: Array<{
40
+ name: string;
41
+ path: string;
42
+ hidden: boolean;
43
+ }>;
44
+ truncated: boolean;
45
+ }
46
+ export type PluginProjectPick = {
47
+ kind: 'native';
48
+ path: string | null;
49
+ } | {
50
+ kind: 'browse';
51
+ };
30
52
  export interface PluginMarketService {
31
53
  marketplaces(): Promise<MarketplaceCatalog>;
32
54
  plugins(marketplaceId: string): Promise<MarketplacePlugin[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatcode/cco-better-sidebar",
3
- "version": "0.18.0-rc.5",
3
+ "version": "0.18.0-rc.6",
4
4
  "description": "ChatCode CLI Web plugin: a VS Code-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -4,6 +4,17 @@
4
4
  .title { font-size: 16px; line-height: 24px; font-weight: 600; margin: 0; }
5
5
  .subtitle, .project, .meta, .state { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); margin: 2px 0; }
6
6
  .project { overflow-wrap: anywhere; }
7
+ .projectRow { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
8
+ .projectRow > p { flex: 1; min-width: min(100%, 200px); }
9
+ .projectRow > button { flex-shrink: 0; }
10
+ .installPath { margin: 4px 0; overflow-wrap: anywhere; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
11
+ .projectPicker { width: min(620px, calc(100vw - 32px)); }
12
+ .directoryPath { display: flex; align-items: center; gap: 8px; }
13
+ .directoryPath > span { flex: 1; min-width: 0; }
14
+ .directoryCrumbs { display: flex; flex-wrap: wrap; gap: 4px; margin: 12px 0; }
15
+ .directoryList { max-height: 320px; overflow: auto; min-height: 100px; }
16
+ .directoryList > button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
17
+ .directoryList > button:hover, .directoryList > button:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
7
18
  .toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin: 16px 0 12px; }
8
19
  .tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
9
20
  .tabs button { white-space: nowrap; }
@@ -11,6 +22,9 @@
11
22
  .breadcrumb { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
12
23
  .breadcrumb button { flex-shrink: 0; white-space: nowrap; }
13
24
  .breadcrumb > span:last-child { min-width: 0; }
25
+ .operation { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 16px; margin: 12px 0; border: 1px solid var(--dsw-alias-state-business-primary); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-state-business-primary); font-weight: 500; }
26
+ .operation > span:first-child { flex-shrink: 0; }
27
+ .operation > span:last-child { min-width: 0; overflow-wrap: anywhere; }
14
28
  .warning { padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; margin: 10px 0; font-size: 12px; color: var(--dsw-alias-label-secondary); }
15
29
  .error, .notice { font-size: 12px; padding: 8px 10px; border-radius: 8px; margin: 10px 0; overflow-wrap: anywhere; }
16
30
  .error { color: var(--dsw-alias-state-danger-primary); background: var(--dsw-alias-state-danger-bg); }
@@ -7,30 +7,38 @@ import { IconChevronLeftOutline14, IconChevronRightOutline14, IconRefreshOutline
7
7
  import type { PluginMarketCatalog } from './plugin-market-catalog.ts'
8
8
  import type { InstalledMarketplacePlugin, MarketplacePlugin, PluginMarketScope } from '../plugin-market-shared.ts'
9
9
  import css from './PluginMarketView.module.css'
10
+ import { PluginProjectPicker, type PluginProjectRequest } from './PluginProjectPicker.tsx'
10
11
 
11
12
  export function PluginMarketView({ scope, visible, catalog }: { scope: SessionScope; visible: boolean; catalog: PluginMarketCatalog }): JSX.Element {
12
13
  const snapshot = useSyncExternalStore(catalog.subscribe, catalog.getSnapshot)
13
14
  const [marketId, setMarketId] = useState<string | null>(null)
14
15
  const [tab, setTab] = useState<'markets' | 'installed'>('markets')
15
16
  const [query, setQuery] = useState('')
16
- const [installed, setInstalled] = useState<InstalledMarketplacePlugin[]>([])
17
- const [recordsReady, setRecordsReady] = useState(false)
18
- const [recordsError, setRecordsError] = useState('')
17
+ const [recordState, setRecordState] = useState<{ key: string; records: InstalledMarketplacePlugin[]; ready: boolean; error: string }>()
19
18
  const [message, setMessage] = useState<{ text: string; error: boolean }>()
20
- const [working, setWorking] = useState<{ id: string; scope: PluginMarketScope }>()
19
+ const [working, setWorking] = useState<{ id: string; name: string; scope: PluginMarketScope; uninstall: boolean; choosing?: boolean }>()
20
+ const [selection, setSelection] = useState<{ key: string; path: string }>()
21
+ const [picker, setPicker] = useState<PluginProjectRequest>()
22
+ const pickerRef = useRef<PluginProjectRequest>()
21
23
  const [revision, setRevision] = useState(0)
22
24
  const pending = useRef(false)
23
25
  const breadcrumbNode = useRef<HTMLDivElement>(null)
24
26
  const mutationVersion = useRef(0)
25
27
  const focusBack = useRef(false)
26
28
  const scopeKey = `${scope.sessionId}\0${scope.cwd ?? ''}`
29
+ const projectPath = selection?.key === scopeKey ? selection.path : scope.cwd
30
+ const recordsKey = `${scopeKey}\0${projectPath ?? ''}`
31
+ const installed = recordState?.key === recordsKey ? recordState.records : []
32
+ const recordsReady = recordState?.key === recordsKey && recordState.ready
33
+ const recordsError = recordState?.key === recordsKey ? recordState.error : ''
27
34
  const latestScope = useRef(scopeKey)
28
35
  latestScope.current = scopeKey
29
36
  const markets = snapshot.markets?.data?.marketplaces ?? []
30
37
  const market = markets.find(item => item.id === marketId)
31
38
  const catalogEntry = tab === 'markets' && marketId ? snapshot.plugins[marketId] : snapshot.markets
32
39
  const loading = snapshot.refresh?.status === 'loading' || (tab === 'markets' && (!catalogEntry || catalogEntry.status === 'loading'))
33
- const blocked = loading || snapshot.refresh?.status === 'error'
40
+ const operating = !!working && !working.choosing
41
+ const blocked = loading || snapshot.refresh?.status === 'error' || !!working
34
42
 
35
43
  useEffect(() => {
36
44
  if (!visible) return
@@ -40,9 +48,11 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
40
48
  useEffect(() => { setQuery('') }, [tab, marketId])
41
49
  useEffect(() => { if (focusBack.current) { breadcrumbNode.current?.querySelector('button')?.focus(); focusBack.current = false } }, [marketId])
42
50
 
43
- useEffect(() => {
44
- setInstalled([]); setRecordsReady(false); setRecordsError(''); setMessage(undefined)
45
- }, [scopeKey])
51
+ useEffect(() => { setMessage(undefined) }, [scopeKey])
52
+ useEffect(() => () => {
53
+ pickerRef.current?.controller.abort()
54
+ pickerRef.current?.finish(null)
55
+ }, [])
46
56
  useEffect(() => {
47
57
  if (!visible) return
48
58
  const controller = new AbortController()
@@ -53,10 +63,10 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
53
63
  reading = true
54
64
  const version = mutationVersion.current
55
65
  try {
56
- const records = await api.pluginsInstalled(scope, controller.signal)
57
- if (active && !pending.current && version === mutationVersion.current) { setInstalled(records); setRecordsReady(true); setRecordsError('') }
66
+ const records = await api.pluginsInstalled(scope, controller.signal, projectPath)
67
+ if (active && !pending.current && version === mutationVersion.current) setRecordState({ key: recordsKey, records, ready: true, error: '' })
58
68
  } catch (cause) {
59
- if (active && version === mutationVersion.current) setRecordsError(cause instanceof Error ? cause.message : String(cause))
69
+ if (active && version === mutationVersion.current) setRecordState(previous => ({ key: recordsKey, records: previous?.key === recordsKey ? previous.records : [], ready: previous?.key === recordsKey && previous.ready, error: cause instanceof Error ? cause.message : String(cause) }))
60
70
  } finally { reading = false }
61
71
  }
62
72
  void read()
@@ -64,28 +74,70 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
64
74
  // Remote catalogs are never polled or refreshed by navigation.
65
75
  const timer = setInterval(() => { void read() }, 2000)
66
76
  return () => { active = false; clearInterval(timer); controller.abort() }
67
- }, [scopeKey, visible, revision])
77
+ }, [recordsKey, visible, revision])
78
+
79
+ const chooseDirectory = (): Promise<string | null> => new Promise(finish => {
80
+ const controller = new AbortController()
81
+ const request = { result: api.pluginProjectPick(controller.signal), controller, finish }
82
+ pickerRef.current = request
83
+ setPicker(request)
84
+ })
85
+ const finishPicker = (path: string | null): void => {
86
+ pickerRef.current?.finish(path)
87
+ pickerRef.current?.controller.abort()
88
+ pickerRef.current = undefined
89
+ setPicker(undefined)
90
+ }
91
+ const chooseProject = async (): Promise<void> => {
92
+ if (pending.current || blocked) return
93
+ pending.current = true
94
+ mutationVersion.current++
95
+ setMessage(undefined)
96
+ const key = scopeKey
97
+ try {
98
+ const path = await chooseDirectory()
99
+ if (path && latestScope.current === key) setSelection({ key, path })
100
+ } finally { pending.current = false; setRevision(value => value + 1) }
101
+ }
68
102
 
69
103
  const perform = async (plugin: MarketplacePlugin, installScope: PluginMarketScope): Promise<void> => {
70
104
  const refreshStatus = catalog.getSnapshot().refresh?.status
71
105
  if (pending.current || !recordsReady || blocked || refreshStatus === 'loading' || refreshStatus === 'error') return
72
106
  const key = scopeKey
73
- const present = installed.some(item => item.id === plugin.id && item.scope === installScope)
107
+ const record = installed.find(item => item.id === plugin.id && item.scope === installScope)
108
+ const present = !!record
74
109
  pending.current = true
75
110
  mutationVersion.current++
76
- setWorking({ id: plugin.id, scope: installScope })
111
+ const operation = { id: plugin.id, name: plugin.name, scope: installScope, uninstall: present }
112
+ setWorking({ ...operation, choosing: installScope === 'project' && !present })
77
113
  setMessage(undefined)
78
114
  try {
79
- if (present) await api.pluginUninstall(scope, plugin.id, installScope)
80
- else await api.pluginInstall(scope, plugin.id, installScope)
81
- const records = await api.pluginsInstalled(scope)
115
+ let target = record?.scope === 'project' ? record.projectPath : projectPath
116
+ if (installScope === 'project' && !present) {
117
+ const chosen = await chooseDirectory()
118
+ if (!chosen || latestScope.current !== key) return
119
+ target = chosen
120
+ setSelection({ key, path: target })
121
+ setWorking(operation)
122
+ }
123
+ let installedPath = ''
124
+ if (present) await api.pluginUninstall(scope, plugin.id, installScope, target)
125
+ else {
126
+ const result = await api.pluginInstall(scope, plugin.id, installScope, target)
127
+ installedPath = result.installPath
128
+ if (installScope === 'project') {
129
+ target = result.projectPath
130
+ if (latestScope.current === key) setSelection({ key, path: target })
131
+ }
132
+ }
133
+ const records = await api.pluginsInstalled(scope, undefined, target)
82
134
  if (latestScope.current === key) {
83
- setInstalled(records); setRecordsReady(true); setRecordsError('')
84
- setMessage({ text: t(present ? 'pluginMarketUninstalled' : 'pluginMarketInstalled'), error: false })
135
+ setRecordState({ key: `${key}\0${target ?? ''}`, records, ready: true, error: '' })
136
+ setMessage({ text: present ? t('pluginMarketUninstalled') : t('pluginMarketInstalledAt', { path: installedPath }), error: false })
85
137
  }
86
138
  } catch (cause) {
87
139
  if (latestScope.current === key) setMessage({ text: cause instanceof Error ? cause.message : String(cause), error: true })
88
- } finally { pending.current = false; setWorking(undefined) }
140
+ } finally { pending.current = false; setWorking(undefined); setRevision(value => value + 1) }
89
141
  }
90
142
  const refresh = (): void => {
91
143
  if (loading || pending.current) return
@@ -104,12 +156,15 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
104
156
  const warning = [snapshot.markets?.data?.warning, marketId ? snapshot.plugins[marketId]?.warning : ''].filter(Boolean).join('\n')
105
157
  const hasData = tab === 'installed' ? recordsReady : !!catalogEntry?.data
106
158
 
107
- return <section className={css.root} aria-label={t('pluginMarketTitle')} aria-busy={loading} data-chatcode-plugin-market>
159
+ return <section className={css.root} aria-label={t('pluginMarketTitle')} aria-busy={loading || operating} data-chatcode-plugin-market>
108
160
  <header className={css.head}>
109
161
  <div><h2 className={css.title}>{t('pluginMarketTitle')}</h2><p className={css.subtitle}>{t('pluginMarketSubtitle')}</p></div>
110
162
  <Input disabled={blocked} value={query} onChange={event => setQuery(event.target.value)} placeholder={t('pluginMarketSearch')} aria-label={t('pluginMarketSearch')} />
111
163
  </header>
112
- {scope.cwd ? <p className={css.project}>{t('pluginMarketProjectDirectory', { path: scope.cwd })}</p> : null}
164
+ <div className={css.projectRow}>
165
+ <p className={css.project}>{projectPath ? t('pluginMarketProjectDirectory', { path: projectPath }) : t('pluginMarketProjectHint')}</p>
166
+ <Button size="sm" variant="outline" disabled={blocked || !!working || !!picker} onClick={() => { void chooseProject() }}>{t('pluginMarketChooseProject')}</Button>
167
+ </div>
113
168
  <div className={css.toolbar}>
114
169
  <div className={css.tabs}>
115
170
  <Button size="sm" variant="ghost" disabled={blocked} aria-pressed={tab === 'markets'} onClick={() => setTab('markets')}>{t('pluginMarketTitle')}</Button>
@@ -117,6 +172,10 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
117
172
  </div>
118
173
  <Button size="sm" variant="outline" aria-label={t('pluginMarketRefresh')} disabled={snapshot.markets?.status === 'loading' || loading || !!working} onClick={refresh}><span aria-hidden="true" className={loading ? css.spinning : undefined}><IconRefreshOutline14 /></span>{t(loading ? 'pluginMarketRefreshing' : 'pluginMarketRefresh')}</Button>
119
174
  </div>
175
+ {operating ? <div className={css.operation} role="status">
176
+ <span className={css.spinning} aria-hidden="true"><IconRefreshOutline14 /></span>
177
+ <span>{t(working.uninstall ? 'pluginMarketUninstallProgress' : 'pluginMarketInstallProgress', { name: working.name })}</span>
178
+ </div> : null}
120
179
  {warning && tab === 'markets' ? <p className={css.warning} role="status">{warning}</p> : null}
121
180
  {error ? <p className={css.error} role="status">{error}</p> : null}
122
181
  {message ? <p className={message.error ? css.error : css.notice} role="status">{message.text}</p> : null}
@@ -125,7 +184,7 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
125
184
  <span aria-hidden="true">/</span><span>{market?.name ?? marketId}</span>
126
185
  </div> : null}
127
186
  {loading ? <p className={css.state} role="status">{t(hasData ? 'pluginMarketRefreshing' : 'pluginMarketLoading')}</p> : null}
128
- <div className={css.grid} aria-busy={loading}>
187
+ <div className={css.grid} aria-busy={loading || operating}>
129
188
  {showMarkets ? filteredMarkets.map(item => <button key={item.id} type="button" disabled={blocked} className={`${css.card} ${css.marketCard}`} onClick={() => { focusBack.current = true; setMarketId(item.id) }}>
130
189
  <div className={css.cardHead}><h3>{item.name}</h3>{item.official ? <Pill>{t('pluginMarketDefault')}</Pill> : null}</div>
131
190
  <p className={css.description}>{item.description || t('pluginMarketNoDescription')}</p>
@@ -137,12 +196,14 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
137
196
  <div className={css.cardHead}><h3>{item.name}</h3>{records.length ? <Pill>{t('pluginMarketInstalledTab')}</Pill> : null}</div>
138
197
  <p className={css.description}>{item.description || t('pluginMarketNoDescription')}</p>
139
198
  <p className={css.meta}>{marketName}{tab === 'installed' && records[0]?.version ? ` · ${records[0].version}` : ''}</p>
199
+ {records.map(record => <p key={record.scope} className={css.installPath}>{t('pluginMarketInstallPath', { scope: t(record.scope === 'project' ? 'pluginMarketProject' : 'pluginMarketUser'), path: record.installPath })}</p>)}
140
200
  <div className={css.actions}>
141
201
  {(['project', 'user'] as const).map(installScope => {
142
202
  const present = records.some(record => record.scope === installScope)
143
203
  const busy = working?.id === item.id && working.scope === installScope
144
204
  return <Button key={installScope} size="sm" variant={installScope === 'project' && !present ? 'primary' : 'outline'} className={installScope === 'project' && !present ? css.projectInstall : undefined} disabled={blocked || !!working || !recordsReady} onClick={() => { void perform(item, installScope) }}>
145
- {t(busy ? present ? 'pluginMarketUninstalling' : 'pluginMarketInstalling' : present ? 'pluginMarketUninstall' : 'pluginMarketInstall')} · {t(installScope === 'project' ? 'pluginMarketProject' : 'pluginMarketUser')}
205
+ {busy && operating ? <span className={css.spinning} aria-hidden="true"><IconRefreshOutline14 /></span> : null}
206
+ {t(busy ? working?.choosing ? 'pluginMarketChoosingDirectory' : working?.uninstall ? 'pluginMarketUninstalling' : 'pluginMarketInstalling' : present ? 'pluginMarketUninstall' : 'pluginMarketInstall')} · {t(installScope === 'project' ? 'pluginMarketProject' : 'pluginMarketUser')}
146
207
  </Button>
147
208
  })}
148
209
  </div>
@@ -150,5 +211,6 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
150
211
  })}
151
212
  </div>
152
213
  {!loading && hasData && (showMarkets ? filteredMarkets : filteredPlugins).length === 0 ? <p className={css.state}>{t('pluginMarketEmpty')}</p> : null}
214
+ {picker ? <PluginProjectPicker request={picker} initialPath={projectPath} onPicked={finishPicker} onCancel={() => finishPicker(null)} onError={text => { setMessage({ text, error: true }); finishPicker(null) }} /> : null}
153
215
  </section>
154
216
  }
@@ -0,0 +1,73 @@
1
+ /** Native chooser first; the host browse backend supplies the remote Web dialog. */
2
+ import { useEffect, useRef, useState } from 'react'
3
+ import { Button, Input, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
4
+ import { api } from './api.ts'
5
+ import { t } from './locales.ts'
6
+ import { IconFolderOpen16 } from './icons.tsx'
7
+ import type { PluginProjectDirectories, PluginProjectPick } from '../plugin-market-shared.ts'
8
+ import css from './PluginMarketView.module.css'
9
+
10
+ export interface PluginProjectRequest {
11
+ result: Promise<PluginProjectPick>
12
+ controller: AbortController
13
+ finish: (path: string | null) => void
14
+ }
15
+
16
+ export function PluginProjectPicker({ request, initialPath, onPicked, onCancel, onError }: {
17
+ request: PluginProjectRequest
18
+ initialPath?: string
19
+ onPicked: (path: string) => void
20
+ onCancel: () => void
21
+ onError: (message: string) => void
22
+ }): JSX.Element | null {
23
+ const [browse, setBrowse] = useState(false)
24
+ const [path, setPath] = useState(initialPath ?? '')
25
+ const [draft, setDraft] = useState(initialPath ?? '')
26
+ const [listing, setListing] = useState<PluginProjectDirectories>()
27
+ const [loading, setLoading] = useState(true)
28
+ const [error, setError] = useState('')
29
+ const [revision, setRevision] = useState(0)
30
+ const callbacks = useRef({ onPicked, onCancel, onError })
31
+ callbacks.current = { onPicked, onCancel, onError }
32
+ useEffect(() => {
33
+ let active = true
34
+ void request.result.then(result => {
35
+ if (!active) return
36
+ if (result.kind === 'browse') setBrowse(true)
37
+ else if (result.path === null) callbacks.current.onCancel()
38
+ else callbacks.current.onPicked(result.path)
39
+ }).catch(cause => { if (active) callbacks.current.onError(cause instanceof Error ? cause.message : String(cause)) })
40
+ return () => { active = false }
41
+ }, [request])
42
+ useEffect(() => {
43
+ if (!browse) return
44
+ const controller = new AbortController()
45
+ let active = true
46
+ setLoading(true); setListing(undefined); setError('')
47
+ void api.pluginProjectDirectories(path || undefined, controller.signal).then(value => {
48
+ if (active) { setListing(value); setDraft(value.path); setLoading(false) }
49
+ }).catch(cause => {
50
+ if (active) { setError(cause instanceof Error ? cause.message : String(cause)); setLoading(false) }
51
+ })
52
+ return () => { active = false; controller.abort() }
53
+ }, [browse, path, revision])
54
+ const navigate = (next: string): void => { setLoading(true); setListing(undefined); setPath(next); setRevision(value => value + 1) }
55
+ if (!browse) return null
56
+ return <Modal open title={t('pluginMarketChooseProject')} description={t('pluginMarketProjectHint')} closeLabel={t('cancel')} onClose={onCancel} className={css.projectPicker}
57
+ footer={<><Button variant="outline" onClick={onCancel}>{t('cancel')}</Button><Button variant="primary" disabled={loading || !listing} onClick={() => { if (listing) onPicked(listing.path) }}>{t('pluginMarketSelectDirectory')}</Button></>}>
58
+ <form className={css.directoryPath} onSubmit={event => { event.preventDefault(); navigate(draft) }}>
59
+ <Input data-modal-autofocus disabled={loading} aria-label={t('pluginMarketDirectoryPath')} value={draft} onChange={event => setDraft(event.target.value)} />
60
+ <Button type="submit" variant="outline" disabled={loading}>{t('pluginMarketOpenDirectory')}</Button>
61
+ </form>
62
+ <nav className={css.directoryCrumbs} aria-label={t('pluginMarketDirectoryPath')}>
63
+ <Button size="sm" variant="ghost" disabled={loading} onClick={() => navigate('')}>{t('pluginMarketHomeDirectory')}</Button>
64
+ {listing?.crumbs.map(crumb => <Button key={crumb.path} size="sm" variant="ghost" disabled={loading} onClick={() => navigate(crumb.path)}>{crumb.name}</Button>)}
65
+ </nav>
66
+ {error ? <p className={css.error} role="alert">{error}</p> : null}
67
+ {loading ? <p className={css.state} role="status">{t('pluginMarketLoading')}</p> : null}
68
+ <div className={css.directoryList} aria-busy={loading}>
69
+ {listing?.entries.map(entry => <button key={entry.path} type="button" onClick={() => navigate(entry.path)}><IconFolderOpen16 />{entry.name}</button>)}
70
+ </div>
71
+ {listing?.truncated ? <p className={css.state}>{t('pluginMarketDirectoryTruncated')}</p> : null}
72
+ </Modal>
73
+ }
package/src/client/api.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { encodeHtmlUrl } from '../html-route.ts'
10
10
  import type { BrowserProbeResult } from './browser.ts'
11
- import type { MarketplaceCatalog, MarketplacePlugin, InstalledMarketplacePlugin, PluginMarketScope } from '../plugin-market-shared.ts'
11
+ import type { MarketplaceCatalog, MarketplacePlugin, InstalledMarketplacePlugin, PluginMarketScope, PluginProjectPick, PluginProjectDirectories } from '../plugin-market-shared.ts'
12
12
 
13
13
  /** One wire failure. */
14
14
  export class SidebarApiError extends Error {
@@ -288,12 +288,14 @@ function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Re
288
288
 
289
289
  /** The sidebar API surface (session scope threaded through every call). */
290
290
  export const api = {
291
- pluginMarketContext: (scope: SessionScope, signal?: AbortSignal) => call<{ sessionId: string; cwd: string }>('plugins.context', scopePayload(scope, {}), signal),
291
+ pluginMarketContext: (scope: SessionScope, signal?: AbortSignal) => call<{ sessionId: string; cwd?: string }>('plugins.context', scopePayload(scope, {}), signal),
292
+ pluginProjectPick: (signal?: AbortSignal) => call<PluginProjectPick>('plugins.project.pick', {}, signal),
293
+ pluginProjectDirectories: (path?: string, signal?: AbortSignal) => call<PluginProjectDirectories>('plugins.project.directories', { path }, signal),
292
294
  pluginMarketplaces: (signal?: AbortSignal) => call<MarketplaceCatalog>('plugins.marketplaces', {}, signal),
293
295
  pluginCatalog: (marketplaceId: string, signal?: AbortSignal) => call<MarketplacePlugin[] | { plugins: MarketplacePlugin[]; warning: string }>('plugins.catalog', { marketplaceId, includeMetadata: true }, signal),
294
- pluginsInstalled: (scope: SessionScope, signal?: AbortSignal) => call<InstalledMarketplacePlugin[]>('plugins.installed', scopePayload(scope, {}), signal),
295
- pluginInstall: (scope: SessionScope, pluginId: string, installScope: PluginMarketScope) => call<InstalledMarketplacePlugin>('plugins.install', scopePayload(scope, { pluginId, scope: installScope })),
296
- pluginUninstall: (scope: SessionScope, pluginId: string, installScope: PluginMarketScope) => call<{ ok: true }>('plugins.uninstall', scopePayload(scope, { pluginId, scope: installScope })),
296
+ pluginsInstalled: (scope: SessionScope, signal?: AbortSignal, projectPath?: string) => call<InstalledMarketplacePlugin[]>('plugins.installed', scopePayload(scope, { projectPath }), signal),
297
+ pluginInstall: (scope: SessionScope, pluginId: string, installScope: PluginMarketScope, projectPath?: string) => call<InstalledMarketplacePlugin>('plugins.install', scopePayload(scope, { pluginId, scope: installScope, projectPath })),
298
+ pluginUninstall: (scope: SessionScope, pluginId: string, installScope: PluginMarketScope, projectPath?: string) => call<{ ok: true }>('plugins.uninstall', scopePayload(scope, { pluginId, scope: installScope, projectPath })),
297
299
  chatcodeStatus: (signal?: AbortSignal) => call<ChatCodeAuthState>('chatcode.status', {}, signal),
298
300
  chatcodeStart: () => call<{ sessionId: string; url: string }>('chatcode.start', {}),
299
301
  chatcodeCancel: (sessionId: string) => call<{ ok: true }>('chatcode.cancel', { sessionId }),
@@ -30,13 +30,25 @@ export const ja: Record<string, string> = {
30
30
  pluginMarketUser: 'ユーザー',
31
31
  pluginMarketInstalled: 'プラグインをインストールしました。',
32
32
  pluginMarketUninstalled: 'プラグインをアンインストールしました。',
33
- pluginMarketSubtitle: 'マーケットを参照し、プロジェクトまたはユーザー環境にインストールします。',
33
+ pluginMarketSubtitle: 'マーケットを参照し、プロジェクトディレクトリを選ぶかユーザー環境にインストールします。',
34
+ pluginMarketChooseProject: 'プロジェクトディレクトリを選択',
35
+ pluginMarketProjectHint: 'プロジェクトにインストールする場合は対象フォルダーを選択してください。',
36
+ pluginMarketChoosingDirectory: 'ディレクトリを選択中…',
37
+ pluginMarketInstalledAt: 'プラグインのインストール先:{path}',
38
+ pluginMarketInstallPath: '{scope}のインストール先:{path}',
39
+ pluginMarketDirectoryPath: 'ディレクトリパス',
40
+ pluginMarketOpenDirectory: 'ディレクトリを開く',
41
+ pluginMarketSelectDirectory: 'このディレクトリを選択',
42
+ pluginMarketHomeDirectory: 'ホーム',
43
+ pluginMarketDirectoryTruncated: '一部のフォルダーのみ表示しています。完全なパスを入力してプロジェクトを開けます。',
34
44
  pluginMarketSearch: 'マーケットやプラグインを検索',
35
45
  pluginMarketDefault: 'デフォルト',
36
46
  pluginMarketBrowse: 'プラグインを見る',
37
47
  pluginMarketNoDescription: '説明なし',
38
48
  pluginMarketInstalling: 'インストール中…',
39
49
  pluginMarketUninstalling: 'アンインストール中…',
50
+ pluginMarketInstallProgress: '{name} をインストールしています。しばらくお待ちください…',
51
+ pluginMarketUninstallProgress: '{name} をアンインストールしています。しばらくお待ちください…',
40
52
  accountMenuSettings: '設定',
41
53
  accountMenuLogout: 'ログアウト',
42
54
  accountMenuLogoutConfirmTitle: 'ログアウトしますか?',