@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/README.md +1 -1
- package/README_EN.md +1 -1
- package/lib/client-editor.js +332 -271
- package/lib/client-mermaid.js +325 -268
- package/lib/client-registry.js +796 -439
- package/lib/client-terminal.js +331 -270
- package/lib/client.js +792 -435
- package/lib/index.js +47 -7
- package/lib/types/client/PluginProjectPicker.d.ts +13 -0
- package/lib/types/client/api.d.ts +7 -5
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/plugin-market-shared.d.ts +22 -0
- package/package.json +1 -1
- package/src/client/PluginMarketView.module.css +14 -0
- package/src/client/PluginMarketView.tsx +86 -24
- package/src/client/PluginProjectPicker.tsx +73 -0
- package/src/client/api.ts +7 -5
- package/src/client/locales-ja.ts +13 -1
- package/src/client/locales.ts +60 -3
- package/src/plugin-market-api.ts +55 -9
- package/src/plugin-market-shared.ts +9 -0
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) =>
|
|
6033
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 [
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
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) {
|
|
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)
|
|
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
|
-
}, [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
setMessage({ text:
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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 }),
|
package/src/client/locales-ja.ts
CHANGED
|
@@ -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: 'ログアウトしますか?',
|