@chatcode/cco-better-sidebar 0.18.0-rc.5 → 0.18.0-rc.7

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,28 @@ 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
+ pluginMarketAwaitingConfirmation: string;
44
+ pluginMarketConfirmProjectInstall: string;
45
+ pluginMarketConfirmProjectDescription: string;
46
+ pluginMarketConfirmInstall: string;
47
+ pluginMarketInstalledAt: string;
48
+ pluginMarketInstallPath: string;
49
+ pluginMarketDirectoryPath: string;
50
+ pluginMarketOpenDirectory: string;
51
+ pluginMarketSelectDirectory: string;
52
+ pluginMarketHomeDirectory: string;
53
+ pluginMarketDirectoryTruncated: string;
40
54
  pluginMarketSearch: string;
41
55
  pluginMarketDefault: string;
42
56
  pluginMarketBrowse: string;
43
57
  pluginMarketNoDescription: string;
44
58
  pluginMarketInstalling: string;
45
59
  pluginMarketUninstalling: string;
60
+ pluginMarketInstallProgress: string;
61
+ pluginMarketUninstallProgress: string;
46
62
  accountMenuSettings: string;
47
63
  accountMenuLogout: string;
48
64
  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.7",
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,18 @@
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
+ .confirmPath { margin: 0; padding: 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; color: var(--dsw-alias-label-primary); overflow-wrap: anywhere; white-space: pre-wrap; }
13
+ .directoryPath { display: flex; align-items: center; gap: 8px; }
14
+ .directoryPath > span { flex: 1; min-width: 0; }
15
+ .directoryCrumbs { display: flex; flex-wrap: wrap; gap: 4px; margin: 12px 0; }
16
+ .directoryList { max-height: 320px; overflow: auto; min-height: 100px; }
17
+ .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; }
18
+ .directoryList > button:hover, .directoryList > button:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
7
19
  .toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin: 16px 0 12px; }
8
20
  .tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
9
21
  .tabs button { white-space: nowrap; }
@@ -11,6 +23,9 @@
11
23
  .breadcrumb { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
12
24
  .breadcrumb button { flex-shrink: 0; white-space: nowrap; }
13
25
  .breadcrumb > span:last-child { min-width: 0; }
26
+ .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; }
27
+ .operation > span:first-child { flex-shrink: 0; }
28
+ .operation > span:last-child { min-width: 0; overflow-wrap: anywhere; }
14
29
  .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
30
  .error, .notice { font-size: 12px; padding: 8px 10px; border-radius: 8px; margin: 10px 0; overflow-wrap: anywhere; }
16
31
  .error { color: var(--dsw-alias-state-danger-primary); background: var(--dsw-alias-state-danger-bg); }
@@ -1,36 +1,48 @@
1
1
  /** Two catalog levels with project/user actions directly on plugin cards. */
2
2
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
3
- import { Button, Input, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import { Button, Input, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
4
4
  import { api, type SessionScope } from './api.ts'
5
5
  import { t } from './locales.ts'
6
6
  import { IconChevronLeftOutline14, IconChevronRightOutline14, IconRefreshOutline14 } from './icons.tsx'
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'
11
+
12
+ interface ProjectInstallConfirmation { name: string; path: string; finish: (confirmed: boolean) => void }
10
13
 
11
14
  export function PluginMarketView({ scope, visible, catalog }: { scope: SessionScope; visible: boolean; catalog: PluginMarketCatalog }): JSX.Element {
12
15
  const snapshot = useSyncExternalStore(catalog.subscribe, catalog.getSnapshot)
13
16
  const [marketId, setMarketId] = useState<string | null>(null)
14
17
  const [tab, setTab] = useState<'markets' | 'installed'>('markets')
15
18
  const [query, setQuery] = useState('')
16
- const [installed, setInstalled] = useState<InstalledMarketplacePlugin[]>([])
17
- const [recordsReady, setRecordsReady] = useState(false)
18
- const [recordsError, setRecordsError] = useState('')
19
+ const [recordState, setRecordState] = useState<{ key: string; records: InstalledMarketplacePlugin[]; ready: boolean; error: string }>()
19
20
  const [message, setMessage] = useState<{ text: string; error: boolean }>()
20
- const [working, setWorking] = useState<{ id: string; scope: PluginMarketScope }>()
21
+ const [working, setWorking] = useState<{ id: string; name: string; scope: PluginMarketScope; uninstall: boolean; confirming?: boolean }>()
22
+ const [confirmation, setConfirmation] = useState<ProjectInstallConfirmation>()
23
+ const confirmationRef = useRef<ProjectInstallConfirmation>()
24
+ const [selection, setSelection] = useState<{ key: string; path: string }>()
25
+ const [picker, setPicker] = useState<PluginProjectRequest>()
26
+ const pickerRef = useRef<PluginProjectRequest>()
21
27
  const [revision, setRevision] = useState(0)
22
28
  const pending = useRef(false)
23
29
  const breadcrumbNode = useRef<HTMLDivElement>(null)
24
30
  const mutationVersion = useRef(0)
25
31
  const focusBack = useRef(false)
26
32
  const scopeKey = `${scope.sessionId}\0${scope.cwd ?? ''}`
33
+ const projectPath = selection?.key === scopeKey ? selection.path : scope.cwd
34
+ const recordsKey = `${scopeKey}\0${projectPath ?? ''}`
35
+ const installed = recordState?.key === recordsKey ? recordState.records : []
36
+ const recordsReady = recordState?.key === recordsKey && recordState.ready
37
+ const recordsError = recordState?.key === recordsKey ? recordState.error : ''
27
38
  const latestScope = useRef(scopeKey)
28
39
  latestScope.current = scopeKey
29
40
  const markets = snapshot.markets?.data?.marketplaces ?? []
30
41
  const market = markets.find(item => item.id === marketId)
31
42
  const catalogEntry = tab === 'markets' && marketId ? snapshot.plugins[marketId] : snapshot.markets
32
43
  const loading = snapshot.refresh?.status === 'loading' || (tab === 'markets' && (!catalogEntry || catalogEntry.status === 'loading'))
33
- const blocked = loading || snapshot.refresh?.status === 'error'
44
+ const operating = !!working && !working.confirming
45
+ const blocked = loading || snapshot.refresh?.status === 'error' || !!working
34
46
 
35
47
  useEffect(() => {
36
48
  if (!visible) return
@@ -40,9 +52,17 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
40
52
  useEffect(() => { setQuery('') }, [tab, marketId])
41
53
  useEffect(() => { if (focusBack.current) { breadcrumbNode.current?.querySelector('button')?.focus(); focusBack.current = false } }, [marketId])
42
54
 
55
+ useEffect(() => { setMessage(undefined) }, [scopeKey])
43
56
  useEffect(() => {
44
- setInstalled([]); setRecordsReady(false); setRecordsError(''); setMessage(undefined)
45
- }, [scopeKey])
57
+ confirmationRef.current?.finish(false)
58
+ confirmationRef.current = undefined
59
+ setConfirmation(undefined)
60
+ }, [scopeKey, visible])
61
+ useEffect(() => () => {
62
+ pickerRef.current?.controller.abort()
63
+ pickerRef.current?.finish(null)
64
+ confirmationRef.current?.finish(false)
65
+ }, [])
46
66
  useEffect(() => {
47
67
  if (!visible) return
48
68
  const controller = new AbortController()
@@ -53,10 +73,10 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
53
73
  reading = true
54
74
  const version = mutationVersion.current
55
75
  try {
56
- const records = await api.pluginsInstalled(scope, controller.signal)
57
- if (active && !pending.current && version === mutationVersion.current) { setInstalled(records); setRecordsReady(true); setRecordsError('') }
76
+ const records = await api.pluginsInstalled(scope, controller.signal, projectPath)
77
+ if (active && !pending.current && version === mutationVersion.current) setRecordState({ key: recordsKey, records, ready: true, error: '' })
58
78
  } catch (cause) {
59
- if (active && version === mutationVersion.current) setRecordsError(cause instanceof Error ? cause.message : String(cause))
79
+ 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
80
  } finally { reading = false }
61
81
  }
62
82
  void read()
@@ -64,28 +84,83 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
64
84
  // Remote catalogs are never polled or refreshed by navigation.
65
85
  const timer = setInterval(() => { void read() }, 2000)
66
86
  return () => { active = false; clearInterval(timer); controller.abort() }
67
- }, [scopeKey, visible, revision])
87
+ }, [recordsKey, visible, revision])
88
+
89
+ const chooseDirectory = (): Promise<string | null> => new Promise(finish => {
90
+ const controller = new AbortController()
91
+ const request = { result: api.pluginProjectPick(controller.signal), controller, finish }
92
+ pickerRef.current = request
93
+ setPicker(request)
94
+ })
95
+ const finishPicker = (path: string | null): void => {
96
+ pickerRef.current?.finish(path)
97
+ pickerRef.current?.controller.abort()
98
+ pickerRef.current = undefined
99
+ setPicker(undefined)
100
+ }
101
+ const confirmProjectInstall = (name: string, path: string): Promise<boolean> => new Promise(finish => {
102
+ const request = { name, path, finish }
103
+ confirmationRef.current = request
104
+ setConfirmation(request)
105
+ })
106
+ const finishConfirmation = (confirmed: boolean): void => {
107
+ const request = confirmationRef.current
108
+ confirmationRef.current = undefined
109
+ setConfirmation(undefined)
110
+ request?.finish(confirmed)
111
+ }
112
+ const chooseProject = async (): Promise<void> => {
113
+ if (pending.current || blocked) return
114
+ pending.current = true
115
+ mutationVersion.current++
116
+ setMessage(undefined)
117
+ const key = scopeKey
118
+ try {
119
+ const path = await chooseDirectory()
120
+ if (path && latestScope.current === key) setSelection({ key, path })
121
+ } finally { pending.current = false; setRevision(value => value + 1) }
122
+ }
68
123
 
69
124
  const perform = async (plugin: MarketplacePlugin, installScope: PluginMarketScope): Promise<void> => {
70
125
  const refreshStatus = catalog.getSnapshot().refresh?.status
71
126
  if (pending.current || !recordsReady || blocked || refreshStatus === 'loading' || refreshStatus === 'error') return
72
127
  const key = scopeKey
73
- const present = installed.some(item => item.id === plugin.id && item.scope === installScope)
128
+ const record = installed.find(item => item.id === plugin.id && item.scope === installScope)
129
+ const present = !!record
130
+ if (installScope === 'project' && !present && !projectPath) {
131
+ setMessage({ text: t('pluginMarketProjectHint'), error: true })
132
+ return
133
+ }
74
134
  pending.current = true
75
135
  mutationVersion.current++
76
- setWorking({ id: plugin.id, scope: installScope })
136
+ const operation = { id: plugin.id, name: plugin.name, scope: installScope, uninstall: present }
137
+ setWorking({ ...operation, confirming: installScope === 'project' && !present })
77
138
  setMessage(undefined)
78
139
  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)
140
+ let target = record?.scope === 'project' ? record.projectPath : projectPath
141
+ if (installScope === 'project' && !present) {
142
+ const confirmed = await confirmProjectInstall(plugin.name, target!)
143
+ if (!confirmed || latestScope.current !== key) return
144
+ setWorking(operation)
145
+ }
146
+ let installedPath = ''
147
+ if (present) await api.pluginUninstall(scope, plugin.id, installScope, target)
148
+ else {
149
+ const result = await api.pluginInstall(scope, plugin.id, installScope, target)
150
+ installedPath = result.installPath
151
+ if (installScope === 'project') {
152
+ target = result.projectPath
153
+ if (latestScope.current === key) setSelection({ key, path: target })
154
+ }
155
+ }
156
+ const records = await api.pluginsInstalled(scope, undefined, target)
82
157
  if (latestScope.current === key) {
83
- setInstalled(records); setRecordsReady(true); setRecordsError('')
84
- setMessage({ text: t(present ? 'pluginMarketUninstalled' : 'pluginMarketInstalled'), error: false })
158
+ setRecordState({ key: `${key}\0${target ?? ''}`, records, ready: true, error: '' })
159
+ setMessage({ text: present ? t('pluginMarketUninstalled') : t('pluginMarketInstalledAt', { path: installedPath }), error: false })
85
160
  }
86
161
  } catch (cause) {
87
162
  if (latestScope.current === key) setMessage({ text: cause instanceof Error ? cause.message : String(cause), error: true })
88
- } finally { pending.current = false; setWorking(undefined) }
163
+ } finally { pending.current = false; setWorking(undefined); setRevision(value => value + 1) }
89
164
  }
90
165
  const refresh = (): void => {
91
166
  if (loading || pending.current) return
@@ -104,12 +179,15 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
104
179
  const warning = [snapshot.markets?.data?.warning, marketId ? snapshot.plugins[marketId]?.warning : ''].filter(Boolean).join('\n')
105
180
  const hasData = tab === 'installed' ? recordsReady : !!catalogEntry?.data
106
181
 
107
- return <section className={css.root} aria-label={t('pluginMarketTitle')} aria-busy={loading} data-chatcode-plugin-market>
182
+ return <section className={css.root} aria-label={t('pluginMarketTitle')} aria-busy={loading || operating} data-chatcode-plugin-market>
108
183
  <header className={css.head}>
109
184
  <div><h2 className={css.title}>{t('pluginMarketTitle')}</h2><p className={css.subtitle}>{t('pluginMarketSubtitle')}</p></div>
110
185
  <Input disabled={blocked} value={query} onChange={event => setQuery(event.target.value)} placeholder={t('pluginMarketSearch')} aria-label={t('pluginMarketSearch')} />
111
186
  </header>
112
- {scope.cwd ? <p className={css.project}>{t('pluginMarketProjectDirectory', { path: scope.cwd })}</p> : null}
187
+ <div className={css.projectRow}>
188
+ <p className={css.project}>{projectPath ? t('pluginMarketProjectDirectory', { path: projectPath }) : t('pluginMarketProjectHint')}</p>
189
+ <Button size="sm" variant="outline" disabled={blocked || !!working || !!picker} onClick={() => { void chooseProject() }}>{t('pluginMarketChooseProject')}</Button>
190
+ </div>
113
191
  <div className={css.toolbar}>
114
192
  <div className={css.tabs}>
115
193
  <Button size="sm" variant="ghost" disabled={blocked} aria-pressed={tab === 'markets'} onClick={() => setTab('markets')}>{t('pluginMarketTitle')}</Button>
@@ -117,6 +195,10 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
117
195
  </div>
118
196
  <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
197
  </div>
198
+ {operating ? <div className={css.operation} role="status">
199
+ <span className={css.spinning} aria-hidden="true"><IconRefreshOutline14 /></span>
200
+ <span>{t(working.uninstall ? 'pluginMarketUninstallProgress' : 'pluginMarketInstallProgress', { name: working.name })}</span>
201
+ </div> : null}
120
202
  {warning && tab === 'markets' ? <p className={css.warning} role="status">{warning}</p> : null}
121
203
  {error ? <p className={css.error} role="status">{error}</p> : null}
122
204
  {message ? <p className={message.error ? css.error : css.notice} role="status">{message.text}</p> : null}
@@ -125,7 +207,7 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
125
207
  <span aria-hidden="true">/</span><span>{market?.name ?? marketId}</span>
126
208
  </div> : null}
127
209
  {loading ? <p className={css.state} role="status">{t(hasData ? 'pluginMarketRefreshing' : 'pluginMarketLoading')}</p> : null}
128
- <div className={css.grid} aria-busy={loading}>
210
+ <div className={css.grid} aria-busy={loading || operating}>
129
211
  {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
212
  <div className={css.cardHead}><h3>{item.name}</h3>{item.official ? <Pill>{t('pluginMarketDefault')}</Pill> : null}</div>
131
213
  <p className={css.description}>{item.description || t('pluginMarketNoDescription')}</p>
@@ -137,12 +219,14 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
137
219
  <div className={css.cardHead}><h3>{item.name}</h3>{records.length ? <Pill>{t('pluginMarketInstalledTab')}</Pill> : null}</div>
138
220
  <p className={css.description}>{item.description || t('pluginMarketNoDescription')}</p>
139
221
  <p className={css.meta}>{marketName}{tab === 'installed' && records[0]?.version ? ` · ${records[0].version}` : ''}</p>
222
+ {records.map(record => <p key={record.scope} className={css.installPath}>{t('pluginMarketInstallPath', { scope: t(record.scope === 'project' ? 'pluginMarketProject' : 'pluginMarketUser'), path: record.installPath })}</p>)}
140
223
  <div className={css.actions}>
141
224
  {(['project', 'user'] as const).map(installScope => {
142
225
  const present = records.some(record => record.scope === installScope)
143
226
  const busy = working?.id === item.id && working.scope === installScope
144
227
  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')}
228
+ {busy && operating ? <span className={css.spinning} aria-hidden="true"><IconRefreshOutline14 /></span> : null}
229
+ {t(busy ? working?.confirming ? 'pluginMarketAwaitingConfirmation' : working?.uninstall ? 'pluginMarketUninstalling' : 'pluginMarketInstalling' : present ? 'pluginMarketUninstall' : 'pluginMarketInstall')} · {t(installScope === 'project' ? 'pluginMarketProject' : 'pluginMarketUser')}
146
230
  </Button>
147
231
  })}
148
232
  </div>
@@ -150,5 +234,10 @@ export function PluginMarketView({ scope, visible, catalog }: { scope: SessionSc
150
234
  })}
151
235
  </div>
152
236
  {!loading && hasData && (showMarkets ? filteredMarkets : filteredPlugins).length === 0 ? <p className={css.state}>{t('pluginMarketEmpty')}</p> : null}
237
+ {confirmation ? <Modal open title={t('pluginMarketConfirmProjectInstall')} description={t('pluginMarketConfirmProjectDescription', { name: confirmation.name })} closeLabel={t('cancel')} onClose={() => finishConfirmation(false)} className={css.projectPicker}
238
+ footer={<><Button variant="outline" data-modal-autofocus onClick={() => finishConfirmation(false)}>{t('cancel')}</Button><Button variant="primary" onClick={() => finishConfirmation(true)}>{t('pluginMarketConfirmInstall')}</Button></>}>
239
+ <p className={css.confirmPath}>{confirmation.path}</p>
240
+ </Modal> : null}
241
+ {picker ? <PluginProjectPicker request={picker} initialPath={projectPath} onPicked={finishPicker} onCancel={() => finishPicker(null)} onError={text => { setMessage({ text, error: true }); finishPicker(null) }} /> : null}
153
242
  </section>
154
243
  }
@@ -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,29 @@ 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
+ pluginMarketAwaitingConfirmation: '確認待ち…',
38
+ pluginMarketConfirmProjectInstall: 'プロジェクトへのインストールを確認',
39
+ pluginMarketConfirmProjectDescription: '{name} を以下のプロジェクトディレクトリにインストールします。変更する場合はキャンセルし、ページ上部でディレクトリを選択してください。',
40
+ pluginMarketConfirmInstall: 'インストールを確認',
41
+ pluginMarketInstalledAt: 'プラグインのインストール先:{path}',
42
+ pluginMarketInstallPath: '{scope}のインストール先:{path}',
43
+ pluginMarketDirectoryPath: 'ディレクトリパス',
44
+ pluginMarketOpenDirectory: 'ディレクトリを開く',
45
+ pluginMarketSelectDirectory: 'このディレクトリを選択',
46
+ pluginMarketHomeDirectory: 'ホーム',
47
+ pluginMarketDirectoryTruncated: '一部のフォルダーのみ表示しています。完全なパスを入力してプロジェクトを開けます。',
34
48
  pluginMarketSearch: 'マーケットやプラグインを検索',
35
49
  pluginMarketDefault: 'デフォルト',
36
50
  pluginMarketBrowse: 'プラグインを見る',
37
51
  pluginMarketNoDescription: '説明なし',
38
52
  pluginMarketInstalling: 'インストール中…',
39
53
  pluginMarketUninstalling: 'アンインストール中…',
54
+ pluginMarketInstallProgress: '{name} をインストールしています。しばらくお待ちください…',
55
+ pluginMarketUninstallProgress: '{name} をアンインストールしています。しばらくお待ちください…',
40
56
  accountMenuSettings: '設定',
41
57
  accountMenuLogout: 'ログアウト',
42
58
  accountMenuLogoutConfirmTitle: 'ログアウトしますか?',