@chatcode/cco-better-sidebar 0.18.0-rc.3 → 0.18.0-rc.5
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 +19 -1
- package/README_EN.md +19 -1
- package/lib/client-editor.js +326 -258
- package/lib/client-mermaid.js +312 -259
- package/lib/client-registry.js +1392 -605
- package/lib/client-terminal.js +328 -260
- package/lib/client.js +1352 -565
- package/lib/index.js +162 -5
- package/lib/types/client/LogoutConfirmDialog.d.ts +5 -0
- package/lib/types/client/PluginMarketSettingsSection.d.ts +7 -0
- package/lib/types/client/PluginMarketView.d.ts +7 -0
- package/lib/types/client/api.d.ts +15 -0
- package/lib/types/client/builtins/tabs.d.ts +6 -3
- package/lib/types/client/locales.d.ts +23 -0
- package/lib/types/client/plugin-market-catalog.d.ts +25 -0
- package/lib/types/client/ui-tweaks/branding.d.ts +1 -1
- package/lib/types/client/ui-tweaks/index.d.ts +1 -1
- package/lib/types/client/ui-tweaks/sidebar-header.d.ts +6 -0
- package/lib/types/client/ui-tweaks/typography.d.ts +9 -0
- package/lib/types/login-page-script.d.ts +2 -0
- package/lib/types/login-route.d.ts +2 -0
- package/lib/types/plugin-market-api.d.ts +5 -0
- package/lib/types/plugin-market-shared.d.ts +37 -0
- package/lib/types/ui-tweaks/config.d.ts +5 -6
- package/package.json +1 -1
- package/src/client/AccountMenu.tsx +12 -2
- package/src/client/LogoutConfirmDialog.tsx +39 -0
- package/src/client/PluginMarketSettingsSection.tsx +55 -0
- package/src/client/PluginMarketView.module.css +35 -0
- package/src/client/PluginMarketView.tsx +154 -0
- package/src/client/api.ts +7 -0
- package/src/client/builtins/tabs.tsx +16 -3
- package/src/client/index.tsx +10 -1
- package/src/client/locales-ja.ts +23 -0
- package/src/client/locales.ts +48 -0
- package/src/client/plugin-market-catalog.ts +90 -0
- package/src/client/sidebar.module.css +81 -19
- package/src/client/ui-tweaks/branding.tsx +25 -16
- package/src/client/ui-tweaks/index.tsx +36 -99
- package/src/client/ui-tweaks/sidebar-header.ts +73 -0
- package/src/client/ui-tweaks/typography.ts +38 -0
- package/src/index.ts +2 -0
- package/src/login-page-script.ts +107 -0
- package/src/login-route.ts +7 -4
- package/src/plugin-market-api.ts +42 -0
- package/src/plugin-market-shared.ts +21 -0
- package/src/ui-tweaks/config.ts +10 -10
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Replace the product's market settings seat through the public slot API. */
|
|
2
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
|
3
|
+
import type { Context } from '../context-types.ts'
|
|
4
|
+
import { api, type SessionScope } from './api.ts'
|
|
5
|
+
import { useCurrentSessionId } from './current-session.ts'
|
|
6
|
+
import { PluginMarketView } from './PluginMarketView.tsx'
|
|
7
|
+
import { RenderBoundary } from './RenderBoundary.tsx'
|
|
8
|
+
import { t } from './locales.ts'
|
|
9
|
+
import type { PluginMarketCatalog } from './plugin-market-catalog.ts'
|
|
10
|
+
|
|
11
|
+
export function PluginMarketSettingsSection({ ctx, catalog }: { ctx: Context; catalog: PluginMarketCatalog }): JSX.Element {
|
|
12
|
+
const sessionId = useCurrentSessionId(ctx) ?? ''
|
|
13
|
+
const sessions = useSyncExternalStore(
|
|
14
|
+
useCallback(listener => ctx.sessions.list.subscribe(listener), [ctx]),
|
|
15
|
+
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
|
|
16
|
+
)
|
|
17
|
+
useSyncExternalStore(
|
|
18
|
+
useCallback(listener => ctx.locale.subscribe(listener), [ctx]),
|
|
19
|
+
useCallback(() => ctx.locale.getSnapshot(), [ctx]),
|
|
20
|
+
)
|
|
21
|
+
const cwd = sessions.byId[sessionId]?.cwd
|
|
22
|
+
const key = `${sessionId}\0${cwd ?? ''}`
|
|
23
|
+
const [context, setContext] = useState<{ key: string; scope: SessionScope }>()
|
|
24
|
+
const [error, setError] = useState('')
|
|
25
|
+
const [revision, setRevision] = useState(0)
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const controller = new AbortController()
|
|
28
|
+
let active = true
|
|
29
|
+
setError('')
|
|
30
|
+
void api.pluginMarketContext({ sessionId, cwd }, controller.signal)
|
|
31
|
+
.then(scope => { if (active) setContext({ key, scope }) })
|
|
32
|
+
.catch(cause => { if (active) setError(cause instanceof Error ? cause.message : String(cause)) })
|
|
33
|
+
return () => { active = false; controller.abort() }
|
|
34
|
+
}, [sessionId, cwd, revision])
|
|
35
|
+
|
|
36
|
+
return <div data-chatcode-plugin-market-settings>
|
|
37
|
+
{context?.key === key ? <PluginMarketView key={key} scope={context.scope} catalog={catalog} visible />
|
|
38
|
+
: error ? <><p role="status">{error}</p><button type="button" onClick={() => setRevision(value => value + 1)}>{t('pluginMarketRefresh')}</button></>
|
|
39
|
+
: <p role="status">{t('pluginMarketLoading')}</p>}
|
|
40
|
+
</div>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerPluginMarketSettings(ctx: Context, catalog: PluginMarketCatalog): void {
|
|
44
|
+
// Same seat as dsh-market, lower priority wins independent of activation
|
|
45
|
+
// order. The settings shell projects elected entries, giving one nav row.
|
|
46
|
+
// The old market component never mounts; the skill-market seat is untouched.
|
|
47
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
48
|
+
name: 'settings.section',
|
|
49
|
+
id: 'market',
|
|
50
|
+
order: 40,
|
|
51
|
+
priority: -10,
|
|
52
|
+
label: () => t('pluginMarketTitle'),
|
|
53
|
+
inject: () => ({ ctx, catalog }),
|
|
54
|
+
}, ({ ctx, catalog }: { ctx: Context; catalog: PluginMarketCatalog }) => <RenderBoundary><PluginMarketSettingsSection ctx={ctx} catalog={catalog} /></RenderBoundary>))
|
|
55
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.root { box-sizing: border-box; height: 100%; overflow: auto; padding: 4px; color: var(--dsw-alias-label-primary); font: var(--dsw-font-s-14); }
|
|
2
|
+
.head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 8px; }
|
|
3
|
+
.head > span { box-sizing: border-box; min-width: 220px; max-width: 380px; flex: 1; }
|
|
4
|
+
.title { font-size: 16px; line-height: 24px; font-weight: 600; margin: 0; }
|
|
5
|
+
.subtitle, .project, .meta, .state { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); margin: 2px 0; }
|
|
6
|
+
.project { overflow-wrap: anywhere; }
|
|
7
|
+
.toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin: 16px 0 12px; }
|
|
8
|
+
.tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
|
|
9
|
+
.tabs button { white-space: nowrap; }
|
|
10
|
+
.tabs button[aria-pressed='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); font-weight: 600; }
|
|
11
|
+
.breadcrumb { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
|
|
12
|
+
.breadcrumb button { flex-shrink: 0; white-space: nowrap; }
|
|
13
|
+
.breadcrumb > span:last-child { min-width: 0; }
|
|
14
|
+
.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
|
+
.error, .notice { font-size: 12px; padding: 8px 10px; border-radius: 8px; margin: 10px 0; overflow-wrap: anywhere; }
|
|
16
|
+
.error { color: var(--dsw-alias-state-danger-primary); background: var(--dsw-alias-state-danger-bg); }
|
|
17
|
+
.notice { color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-bg); }
|
|
18
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 10px; margin-top: 12px; }
|
|
19
|
+
.card { display: flex; flex-direction: column; min-width: 0; min-height: 150px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); }
|
|
20
|
+
.cardHead { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
21
|
+
.card h3 { font-size: 14px; line-height: 20px; font-weight: 600; margin: 0; overflow-wrap: anywhere; }
|
|
22
|
+
.description { font-size: 12px; line-height: 18px; margin: 8px 0; flex: 1; color: var(--dsw-alias-label-secondary); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
|
|
23
|
+
.meta { min-height: 18px; overflow-wrap: anywhere; }
|
|
24
|
+
.actions { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
|
25
|
+
.projectInstall.projectInstall { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }
|
|
26
|
+
.projectInstall.projectInstall:hover:not(:disabled) { background-color: var(--dsw-alias-button-primary-fill) !important; color: var(--dsw-alias-label-primary-foreground) !important; }
|
|
27
|
+
.marketCard { text-align: left; color: inherit; font: inherit; cursor: pointer; width: 100%; }
|
|
28
|
+
.marketCard:hover:not(:disabled) { border-color: var(--dsw-alias-border-l1); background: var(--dsw-alias-interactive-bg-hover); }
|
|
29
|
+
.marketCard:disabled { cursor: not-allowed; opacity: 0.5; }
|
|
30
|
+
.spinning { display: inline-flex; animation: marketRefresh 1s linear infinite; }
|
|
31
|
+
@keyframes marketRefresh { to { transform: rotate(360deg); } }
|
|
32
|
+
@media (prefers-reduced-motion: reduce) { .spinning { animation: none; } }
|
|
33
|
+
.marketCard:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }
|
|
34
|
+
.enter { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
|
|
35
|
+
@media (max-width: 640px) { .head { flex-direction: column; } .head > span { width: 100%; max-width: none; min-width: 0; } }
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Two catalog levels with project/user actions directly on plugin cards. */
|
|
2
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
3
|
+
import { Button, Input, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
4
|
+
import { api, type SessionScope } from './api.ts'
|
|
5
|
+
import { t } from './locales.ts'
|
|
6
|
+
import { IconChevronLeftOutline14, IconChevronRightOutline14, IconRefreshOutline14 } from './icons.tsx'
|
|
7
|
+
import type { PluginMarketCatalog } from './plugin-market-catalog.ts'
|
|
8
|
+
import type { InstalledMarketplacePlugin, MarketplacePlugin, PluginMarketScope } from '../plugin-market-shared.ts'
|
|
9
|
+
import css from './PluginMarketView.module.css'
|
|
10
|
+
|
|
11
|
+
export function PluginMarketView({ scope, visible, catalog }: { scope: SessionScope; visible: boolean; catalog: PluginMarketCatalog }): JSX.Element {
|
|
12
|
+
const snapshot = useSyncExternalStore(catalog.subscribe, catalog.getSnapshot)
|
|
13
|
+
const [marketId, setMarketId] = useState<string | null>(null)
|
|
14
|
+
const [tab, setTab] = useState<'markets' | 'installed'>('markets')
|
|
15
|
+
const [query, setQuery] = useState('')
|
|
16
|
+
const [installed, setInstalled] = useState<InstalledMarketplacePlugin[]>([])
|
|
17
|
+
const [recordsReady, setRecordsReady] = useState(false)
|
|
18
|
+
const [recordsError, setRecordsError] = useState('')
|
|
19
|
+
const [message, setMessage] = useState<{ text: string; error: boolean }>()
|
|
20
|
+
const [working, setWorking] = useState<{ id: string; scope: PluginMarketScope }>()
|
|
21
|
+
const [revision, setRevision] = useState(0)
|
|
22
|
+
const pending = useRef(false)
|
|
23
|
+
const breadcrumbNode = useRef<HTMLDivElement>(null)
|
|
24
|
+
const mutationVersion = useRef(0)
|
|
25
|
+
const focusBack = useRef(false)
|
|
26
|
+
const scopeKey = `${scope.sessionId}\0${scope.cwd ?? ''}`
|
|
27
|
+
const latestScope = useRef(scopeKey)
|
|
28
|
+
latestScope.current = scopeKey
|
|
29
|
+
const markets = snapshot.markets?.data?.marketplaces ?? []
|
|
30
|
+
const market = markets.find(item => item.id === marketId)
|
|
31
|
+
const catalogEntry = tab === 'markets' && marketId ? snapshot.plugins[marketId] : snapshot.markets
|
|
32
|
+
const loading = snapshot.refresh?.status === 'loading' || (tab === 'markets' && (!catalogEntry || catalogEntry.status === 'loading'))
|
|
33
|
+
const blocked = loading || snapshot.refresh?.status === 'error'
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!visible) return
|
|
37
|
+
catalog.start()
|
|
38
|
+
if (tab === 'markets' && marketId) catalog.ensure(marketId)
|
|
39
|
+
}, [catalog, visible, tab, marketId])
|
|
40
|
+
useEffect(() => { setQuery('') }, [tab, marketId])
|
|
41
|
+
useEffect(() => { if (focusBack.current) { breadcrumbNode.current?.querySelector('button')?.focus(); focusBack.current = false } }, [marketId])
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setInstalled([]); setRecordsReady(false); setRecordsError(''); setMessage(undefined)
|
|
45
|
+
}, [scopeKey])
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!visible) return
|
|
48
|
+
const controller = new AbortController()
|
|
49
|
+
let active = true
|
|
50
|
+
let reading = false
|
|
51
|
+
const read = async (): Promise<void> => {
|
|
52
|
+
if (pending.current || reading) return
|
|
53
|
+
reading = true
|
|
54
|
+
const version = mutationVersion.current
|
|
55
|
+
try {
|
|
56
|
+
const records = await api.pluginsInstalled(scope, controller.signal)
|
|
57
|
+
if (active && !pending.current && version === mutationVersion.current) { setInstalled(records); setRecordsReady(true); setRecordsError('') }
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
if (active && version === mutationVersion.current) setRecordsError(cause instanceof Error ? cause.message : String(cause))
|
|
60
|
+
} finally { reading = false }
|
|
61
|
+
}
|
|
62
|
+
void read()
|
|
63
|
+
// Only local installation records are polled for CLI synchronization.
|
|
64
|
+
// Remote catalogs are never polled or refreshed by navigation.
|
|
65
|
+
const timer = setInterval(() => { void read() }, 2000)
|
|
66
|
+
return () => { active = false; clearInterval(timer); controller.abort() }
|
|
67
|
+
}, [scopeKey, visible, revision])
|
|
68
|
+
|
|
69
|
+
const perform = async (plugin: MarketplacePlugin, installScope: PluginMarketScope): Promise<void> => {
|
|
70
|
+
const refreshStatus = catalog.getSnapshot().refresh?.status
|
|
71
|
+
if (pending.current || !recordsReady || blocked || refreshStatus === 'loading' || refreshStatus === 'error') return
|
|
72
|
+
const key = scopeKey
|
|
73
|
+
const present = installed.some(item => item.id === plugin.id && item.scope === installScope)
|
|
74
|
+
pending.current = true
|
|
75
|
+
mutationVersion.current++
|
|
76
|
+
setWorking({ id: plugin.id, scope: installScope })
|
|
77
|
+
setMessage(undefined)
|
|
78
|
+
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)
|
|
82
|
+
if (latestScope.current === key) {
|
|
83
|
+
setInstalled(records); setRecordsReady(true); setRecordsError('')
|
|
84
|
+
setMessage({ text: t(present ? 'pluginMarketUninstalled' : 'pluginMarketInstalled'), error: false })
|
|
85
|
+
}
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
if (latestScope.current === key) setMessage({ text: cause instanceof Error ? cause.message : String(cause), error: true })
|
|
88
|
+
} finally { pending.current = false; setWorking(undefined) }
|
|
89
|
+
}
|
|
90
|
+
const refresh = (): void => {
|
|
91
|
+
if (loading || pending.current) return
|
|
92
|
+
catalog.refresh(tab === 'markets' && marketId ? marketId : undefined)
|
|
93
|
+
setRevision(value => value + 1)
|
|
94
|
+
setMessage(undefined)
|
|
95
|
+
}
|
|
96
|
+
const term = query.trim().toLocaleLowerCase()
|
|
97
|
+
const matches = (item: { name: string; description: string }): boolean => `${item.name} ${item.description}`.toLocaleLowerCase().includes(term)
|
|
98
|
+
// One installed card per plugin, with independent controls for both scopes.
|
|
99
|
+
const entries = tab === 'installed' ? [...new Map(installed.map(item => [item.id, item])).values()] : marketId ? snapshot.plugins[marketId]?.data ?? [] : []
|
|
100
|
+
const filteredPlugins = entries.filter(matches)
|
|
101
|
+
const filteredMarkets = markets.filter(matches)
|
|
102
|
+
const showMarkets = tab === 'markets' && !marketId
|
|
103
|
+
const error = snapshot.refresh?.error || (tab === 'markets' ? catalogEntry?.error : '') || recordsError
|
|
104
|
+
const warning = [snapshot.markets?.data?.warning, marketId ? snapshot.plugins[marketId]?.warning : ''].filter(Boolean).join('\n')
|
|
105
|
+
const hasData = tab === 'installed' ? recordsReady : !!catalogEntry?.data
|
|
106
|
+
|
|
107
|
+
return <section className={css.root} aria-label={t('pluginMarketTitle')} aria-busy={loading} data-chatcode-plugin-market>
|
|
108
|
+
<header className={css.head}>
|
|
109
|
+
<div><h2 className={css.title}>{t('pluginMarketTitle')}</h2><p className={css.subtitle}>{t('pluginMarketSubtitle')}</p></div>
|
|
110
|
+
<Input disabled={blocked} value={query} onChange={event => setQuery(event.target.value)} placeholder={t('pluginMarketSearch')} aria-label={t('pluginMarketSearch')} />
|
|
111
|
+
</header>
|
|
112
|
+
{scope.cwd ? <p className={css.project}>{t('pluginMarketProjectDirectory', { path: scope.cwd })}</p> : null}
|
|
113
|
+
<div className={css.toolbar}>
|
|
114
|
+
<div className={css.tabs}>
|
|
115
|
+
<Button size="sm" variant="ghost" disabled={blocked} aria-pressed={tab === 'markets'} onClick={() => setTab('markets')}>{t('pluginMarketTitle')}</Button>
|
|
116
|
+
<Button size="sm" variant="ghost" disabled={blocked} aria-pressed={tab === 'installed'} onClick={() => setTab('installed')}>{t('pluginMarketInstalledTab')}</Button>
|
|
117
|
+
</div>
|
|
118
|
+
<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
|
+
</div>
|
|
120
|
+
{warning && tab === 'markets' ? <p className={css.warning} role="status">{warning}</p> : null}
|
|
121
|
+
{error ? <p className={css.error} role="status">{error}</p> : null}
|
|
122
|
+
{message ? <p className={message.error ? css.error : css.notice} role="status">{message.text}</p> : null}
|
|
123
|
+
{tab === 'markets' && marketId ? <div ref={breadcrumbNode} className={css.breadcrumb}>
|
|
124
|
+
<Button size="sm" variant="ghost" disabled={blocked} onClick={() => setMarketId(null)}><IconChevronLeftOutline14 />{t('pluginMarketBack')}</Button>
|
|
125
|
+
<span aria-hidden="true">/</span><span>{market?.name ?? marketId}</span>
|
|
126
|
+
</div> : null}
|
|
127
|
+
{loading ? <p className={css.state} role="status">{t(hasData ? 'pluginMarketRefreshing' : 'pluginMarketLoading')}</p> : null}
|
|
128
|
+
<div className={css.grid} aria-busy={loading}>
|
|
129
|
+
{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
|
+
<div className={css.cardHead}><h3>{item.name}</h3>{item.official ? <Pill>{t('pluginMarketDefault')}</Pill> : null}</div>
|
|
131
|
+
<p className={css.description}>{item.description || t('pluginMarketNoDescription')}</p>
|
|
132
|
+
<span className={css.enter}>{t('pluginMarketBrowse')}<IconChevronRightOutline14 /></span>
|
|
133
|
+
</button>) : filteredPlugins.map(item => {
|
|
134
|
+
const records = installed.filter(record => record.id === item.id)
|
|
135
|
+
const marketName = markets.find(value => value.id === item.marketplaceId)?.name ?? item.marketplaceId
|
|
136
|
+
return <article key={item.id} className={css.card} aria-label={item.name} data-plugin-id={item.id}>
|
|
137
|
+
<div className={css.cardHead}><h3>{item.name}</h3>{records.length ? <Pill>{t('pluginMarketInstalledTab')}</Pill> : null}</div>
|
|
138
|
+
<p className={css.description}>{item.description || t('pluginMarketNoDescription')}</p>
|
|
139
|
+
<p className={css.meta}>{marketName}{tab === 'installed' && records[0]?.version ? ` · ${records[0].version}` : ''}</p>
|
|
140
|
+
<div className={css.actions}>
|
|
141
|
+
{(['project', 'user'] as const).map(installScope => {
|
|
142
|
+
const present = records.some(record => record.scope === installScope)
|
|
143
|
+
const busy = working?.id === item.id && working.scope === installScope
|
|
144
|
+
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')}
|
|
146
|
+
</Button>
|
|
147
|
+
})}
|
|
148
|
+
</div>
|
|
149
|
+
</article>
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
{!loading && hasData && (showMarkets ? filteredMarkets : filteredPlugins).length === 0 ? <p className={css.state}>{t('pluginMarketEmpty')}</p> : null}
|
|
153
|
+
</section>
|
|
154
|
+
}
|
package/src/client/api.ts
CHANGED
|
@@ -8,6 +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
12
|
|
|
12
13
|
/** One wire failure. */
|
|
13
14
|
export class SidebarApiError extends Error {
|
|
@@ -287,6 +288,12 @@ function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Re
|
|
|
287
288
|
|
|
288
289
|
/** The sidebar API surface (session scope threaded through every call). */
|
|
289
290
|
export const api = {
|
|
291
|
+
pluginMarketContext: (scope: SessionScope, signal?: AbortSignal) => call<{ sessionId: string; cwd: string }>('plugins.context', scopePayload(scope, {}), signal),
|
|
292
|
+
pluginMarketplaces: (signal?: AbortSignal) => call<MarketplaceCatalog>('plugins.marketplaces', {}, signal),
|
|
293
|
+
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 })),
|
|
290
297
|
chatcodeStatus: (signal?: AbortSignal) => call<ChatCodeAuthState>('chatcode.status', {}, signal),
|
|
291
298
|
chatcodeStart: () => call<{ sessionId: string; url: string }>('chatcode.start', {}),
|
|
292
299
|
chatcodeCancel: (sessionId: string) => call<{ ok: true }>('chatcode.cancel', { sessionId }),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* (editor / git / terminal / browser / diff) through
|
|
2
|
+
* The 6 built-in tab descriptors: the plugin registers its own pages
|
|
3
|
+
* (editor / git / terminal / browser / diff / plugin-market) through
|
|
4
4
|
* the same {@link BetterSidebarService} external plugins use — eating its
|
|
5
5
|
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
|
|
6
6
|
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
|
|
@@ -17,6 +17,8 @@ import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
|
|
17
17
|
import { GitView } from '../GitView.tsx'
|
|
18
18
|
import { DiffTab } from '../DiffTab.tsx'
|
|
19
19
|
import { BrowserView } from '../BrowserView.tsx'
|
|
20
|
+
import { PluginMarketView } from '../PluginMarketView.tsx'
|
|
21
|
+
import { createPluginMarketCatalog, type PluginMarketCatalog } from '../plugin-market-catalog.ts'
|
|
20
22
|
import {
|
|
21
23
|
IconBranchOutline16,
|
|
22
24
|
IconCodeOutline16,
|
|
@@ -63,6 +65,8 @@ export const TERMINAL_LIMIT = 3
|
|
|
63
65
|
export interface BuiltinTabOptions {
|
|
64
66
|
/** Returns the display title for newly opened terminal tabs. */
|
|
65
67
|
terminalTitle?: () => string
|
|
68
|
+
/** Shared app-lifetime catalog; production supplies the activation's store. */
|
|
69
|
+
pluginMarketCatalog?: PluginMarketCatalog
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
/** A client-side uuid for terminal tab identity (not shown in the UI). */
|
|
@@ -80,8 +84,9 @@ function uiTerminalCount(state: SidebarState): number {
|
|
|
80
84
|
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
/** The
|
|
87
|
+
/** The 6 built-in tab descriptors. */
|
|
84
88
|
export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): readonly TabDescriptor[] {
|
|
89
|
+
const pluginMarketCatalog = options.pluginMarketCatalog ?? createPluginMarketCatalog()
|
|
85
90
|
return [
|
|
86
91
|
{
|
|
87
92
|
id: 'editor',
|
|
@@ -266,6 +271,14 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
|
|
266
271
|
}),
|
|
267
272
|
component: (props) => <BrowserView {...props} />,
|
|
268
273
|
},
|
|
274
|
+
{
|
|
275
|
+
id: 'plugin-market',
|
|
276
|
+
title: () => t('pluginMarketTitle'),
|
|
277
|
+
icon: (size: number) => <IconGlobeOutline16 size={size} />,
|
|
278
|
+
order: 60,
|
|
279
|
+
single: true,
|
|
280
|
+
component: ({ scope, visible }) => <PluginMarketView scope={scope} visible={visible} catalog={pluginMarketCatalog} />,
|
|
281
|
+
},
|
|
269
282
|
{
|
|
270
283
|
id: 'diff',
|
|
271
284
|
title: () => t('git'),
|
package/src/client/index.tsx
CHANGED
|
@@ -39,6 +39,8 @@ import { SideCardSection } from './SideCardSection.tsx'
|
|
|
39
39
|
import { UsageHeatmapSection } from './UsageHeatmapSection.tsx'
|
|
40
40
|
import { AiRatioSection } from './AiRatioSection.tsx'
|
|
41
41
|
import { UpdateSettingsSection } from './UpdateSettingsSection.tsx'
|
|
42
|
+
import { registerPluginMarketSettings } from './PluginMarketSettingsSection.tsx'
|
|
43
|
+
import { createPluginMarketCatalog } from './plugin-market-catalog.ts'
|
|
42
44
|
import { api } from './api.ts'
|
|
43
45
|
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en,
|
|
44
46
|
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
|
|
@@ -142,6 +144,11 @@ export function apply(ctx: Context): void {
|
|
|
142
144
|
// registrations (the official createXXXStore() factory rule — no
|
|
143
145
|
// module-level singleton).
|
|
144
146
|
const sidebarStore = createSidebarStore()
|
|
147
|
+
const pluginMarketCatalog = createPluginMarketCatalog()
|
|
148
|
+
ctx.effect(() => {
|
|
149
|
+
pluginMarketCatalog.start()
|
|
150
|
+
return () => pluginMarketCatalog.dispose()
|
|
151
|
+
}, 'dsh-better-sidebar: plugin catalogs')
|
|
145
152
|
// Extend (rather than replace) DSH's single-owner Workspace browser with a
|
|
146
153
|
// durable Pinned sessions peer group and an extra native row-menu action.
|
|
147
154
|
// The native browser keeps ownership of search, workspace management and
|
|
@@ -180,7 +187,7 @@ export function apply(ctx: Context): void {
|
|
|
180
187
|
// service (eating our own dogfood). The disposer unregisters them on
|
|
181
188
|
// fiber disposal (HMR-safe).
|
|
182
189
|
ctx.effect(
|
|
183
|
-
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
|
|
190
|
+
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle, pluginMarketCatalog }),
|
|
184
191
|
'dsh-better-sidebar: register built-in tabs and viewers',
|
|
185
192
|
)
|
|
186
193
|
// A failure anywhere in the client lifecycle must never take the app down
|
|
@@ -481,6 +488,8 @@ export function apply(ctx: Context): void {
|
|
|
481
488
|
'dsh-better-sidebar: skill market navigation icon fallback',
|
|
482
489
|
)
|
|
483
490
|
|
|
491
|
+
registerPluginMarketSettings(ctx, pluginMarketCatalog)
|
|
492
|
+
|
|
484
493
|
// The "Side card" settings section: appears in the DSH Settings shell
|
|
485
494
|
// once the shell's declaration is on the ledger (slots.inject waits for
|
|
486
495
|
// it); the section reads/writes the prefs through the plugin's own
|
package/src/client/locales-ja.ts
CHANGED
|
@@ -16,8 +16,31 @@
|
|
|
16
16
|
|
|
17
17
|
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
|
|
18
18
|
export const ja: Record<string, string> = {
|
|
19
|
+
pluginMarketTitle: 'プラグインマーケット',
|
|
20
|
+
pluginMarketProjectDirectory: 'プロジェクトディレクトリ:{path}',
|
|
21
|
+
pluginMarketInstalledTab: 'インストール済み',
|
|
22
|
+
pluginMarketRefresh: '更新',
|
|
23
|
+
pluginMarketLoading: 'プラグインを読み込み中…',
|
|
24
|
+
pluginMarketRefreshing: '一覧を更新中…',
|
|
25
|
+
pluginMarketEmpty: 'プラグインはありません。',
|
|
26
|
+
pluginMarketBack: '戻る',
|
|
27
|
+
pluginMarketInstall: 'インストール',
|
|
28
|
+
pluginMarketUninstall: 'アンインストール',
|
|
29
|
+
pluginMarketProject: 'プロジェクト',
|
|
30
|
+
pluginMarketUser: 'ユーザー',
|
|
31
|
+
pluginMarketInstalled: 'プラグインをインストールしました。',
|
|
32
|
+
pluginMarketUninstalled: 'プラグインをアンインストールしました。',
|
|
33
|
+
pluginMarketSubtitle: 'マーケットを参照し、プロジェクトまたはユーザー環境にインストールします。',
|
|
34
|
+
pluginMarketSearch: 'マーケットやプラグインを検索',
|
|
35
|
+
pluginMarketDefault: 'デフォルト',
|
|
36
|
+
pluginMarketBrowse: 'プラグインを見る',
|
|
37
|
+
pluginMarketNoDescription: '説明なし',
|
|
38
|
+
pluginMarketInstalling: 'インストール中…',
|
|
39
|
+
pluginMarketUninstalling: 'アンインストール中…',
|
|
19
40
|
accountMenuSettings: '設定',
|
|
20
41
|
accountMenuLogout: 'ログアウト',
|
|
42
|
+
accountMenuLogoutConfirmTitle: 'ログアウトしますか?',
|
|
43
|
+
accountMenuLogoutConfirmDescription: 'ChatCode CLI からログアウトしますか?続行するには再度ログインする必要があります。',
|
|
21
44
|
accountMenuLabel: 'アカウントメニュー',
|
|
22
45
|
updateMenu: '更新を確認 / 今すぐ更新',
|
|
23
46
|
updateNav: '更新',
|
package/src/client/locales.ts
CHANGED
|
@@ -23,8 +23,31 @@
|
|
|
23
23
|
|
|
24
24
|
/** The zh dictionary (also registered into the ChatCode CLI locale registry under {@link LOCALE_NS}). */
|
|
25
25
|
export const zh = {
|
|
26
|
+
pluginMarketTitle: '插件市场',
|
|
27
|
+
pluginMarketProjectDirectory: '项目目录:{path}',
|
|
28
|
+
pluginMarketInstalledTab: '已安装',
|
|
29
|
+
pluginMarketRefresh: '刷新',
|
|
30
|
+
pluginMarketLoading: '正在加载插件…',
|
|
31
|
+
pluginMarketRefreshing: '正在刷新列表…',
|
|
32
|
+
pluginMarketEmpty: '暂无插件。',
|
|
33
|
+
pluginMarketBack: '返回',
|
|
34
|
+
pluginMarketInstall: '安装',
|
|
35
|
+
pluginMarketUninstall: '卸载',
|
|
36
|
+
pluginMarketProject: '项目级',
|
|
37
|
+
pluginMarketUser: '用户级',
|
|
38
|
+
pluginMarketInstalled: '插件安装成功。',
|
|
39
|
+
pluginMarketUninstalled: '插件卸载成功。',
|
|
40
|
+
pluginMarketSubtitle: '浏览插件市场,选择安装到当前项目或用户环境。',
|
|
41
|
+
pluginMarketSearch: '搜索市场或插件',
|
|
42
|
+
pluginMarketDefault: '默认',
|
|
43
|
+
pluginMarketBrowse: '查看插件',
|
|
44
|
+
pluginMarketNoDescription: '暂无描述',
|
|
45
|
+
pluginMarketInstalling: '安装中…',
|
|
46
|
+
pluginMarketUninstalling: '卸载中…',
|
|
26
47
|
accountMenuSettings: '设置',
|
|
27
48
|
accountMenuLogout: '退出登录',
|
|
49
|
+
accountMenuLogoutConfirmTitle: '退出登录?',
|
|
50
|
+
accountMenuLogoutConfirmDescription: '确定要退出 ChatCode CLI 吗?退出后需要重新登录才能继续使用。',
|
|
28
51
|
accountMenuLabel: '账户菜单',
|
|
29
52
|
updateMenu: '检查更新 / 一键更新',
|
|
30
53
|
updateNav: '更新',
|
|
@@ -494,8 +517,31 @@ export const zh = {
|
|
|
494
517
|
|
|
495
518
|
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
|
496
519
|
export const en: Record<keyof typeof zh, string> = {
|
|
520
|
+
pluginMarketTitle: 'Plugin marketplace',
|
|
521
|
+
pluginMarketProjectDirectory: 'Project directory: {path}',
|
|
522
|
+
pluginMarketInstalledTab: 'Installed',
|
|
523
|
+
pluginMarketRefresh: 'Refresh',
|
|
524
|
+
pluginMarketLoading: 'Loading plugins…',
|
|
525
|
+
pluginMarketRefreshing: 'Refreshing list…',
|
|
526
|
+
pluginMarketEmpty: 'No plugins.',
|
|
527
|
+
pluginMarketBack: 'Back',
|
|
528
|
+
pluginMarketInstall: 'Install',
|
|
529
|
+
pluginMarketUninstall: 'Uninstall',
|
|
530
|
+
pluginMarketProject: 'Project',
|
|
531
|
+
pluginMarketUser: 'User',
|
|
532
|
+
pluginMarketInstalled: 'Plugin installed.',
|
|
533
|
+
pluginMarketUninstalled: 'Plugin uninstalled.',
|
|
534
|
+
pluginMarketSubtitle: 'Browse marketplaces and install plugins for your project or user.',
|
|
535
|
+
pluginMarketSearch: 'Search marketplaces or plugins',
|
|
536
|
+
pluginMarketDefault: 'Default',
|
|
537
|
+
pluginMarketBrowse: 'Browse plugins',
|
|
538
|
+
pluginMarketNoDescription: 'No description',
|
|
539
|
+
pluginMarketInstalling: 'Installing…',
|
|
540
|
+
pluginMarketUninstalling: 'Uninstalling…',
|
|
497
541
|
accountMenuSettings: 'Settings',
|
|
498
542
|
accountMenuLogout: 'Log out',
|
|
543
|
+
accountMenuLogoutConfirmTitle: 'Log out?',
|
|
544
|
+
accountMenuLogoutConfirmDescription: 'Are you sure you want to log out of ChatCode CLI? You will need to sign in again to continue.',
|
|
499
545
|
accountMenuLabel: 'Account menu',
|
|
500
546
|
updateMenu: 'Check for updates / Update now',
|
|
501
547
|
updateNav: 'Update',
|
|
@@ -995,6 +1041,8 @@ import { zhMO as zhMODict } from './locales-zh-MO.ts'
|
|
|
995
1041
|
// English fallback for the new account/model surfaces until third-language translations are supplied.
|
|
996
1042
|
// Keep this list explicit so the dictionary parity check still catches other missing keys.
|
|
997
1043
|
const accountAndModelFallback = {
|
|
1044
|
+
accountMenuLogoutConfirmTitle: en.accountMenuLogoutConfirmTitle,
|
|
1045
|
+
accountMenuLogoutConfirmDescription: en.accountMenuLogoutConfirmDescription,
|
|
998
1046
|
chatcodeGateChecking: en.chatcodeGateChecking,
|
|
999
1047
|
chatcodeGateUnverified: en.chatcodeGateUnverified,
|
|
1000
1048
|
chatcodeGatePending: en.chatcodeGatePending,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** App-lifetime remote catalogs, shared by Settings and the sidebar tab. */
|
|
2
|
+
import { api } from './api.ts'
|
|
3
|
+
import type { MarketplaceCatalog, MarketplacePlugin } from '../plugin-market-shared.ts'
|
|
4
|
+
|
|
5
|
+
interface Entry<T> {
|
|
6
|
+
readonly status: 'loading' | 'ready' | 'error'
|
|
7
|
+
readonly data?: T
|
|
8
|
+
readonly error?: string
|
|
9
|
+
readonly warning?: string
|
|
10
|
+
}
|
|
11
|
+
interface Snapshot {
|
|
12
|
+
readonly markets?: Entry<MarketplaceCatalog>
|
|
13
|
+
readonly plugins: Readonly<Record<string, Entry<MarketplacePlugin[]>>>
|
|
14
|
+
readonly refresh?: { readonly status: 'loading' | 'ready' | 'error'; readonly error?: string }
|
|
15
|
+
}
|
|
16
|
+
export interface PluginMarketCatalog {
|
|
17
|
+
subscribe(listener: () => void): () => void
|
|
18
|
+
getSnapshot(): Snapshot
|
|
19
|
+
start(): void
|
|
20
|
+
ensure(marketplaceId: string): void
|
|
21
|
+
refresh(marketplaceId?: string): void
|
|
22
|
+
dispose(): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createPluginMarketCatalog(): PluginMarketCatalog {
|
|
26
|
+
let snapshot: Snapshot = { plugins: {} }
|
|
27
|
+
let disposed = false
|
|
28
|
+
const listeners = new Set<() => void>()
|
|
29
|
+
const controllers = new Set<AbortController>()
|
|
30
|
+
const requests = new Map<string, Promise<string | undefined>>()
|
|
31
|
+
const publish = (next: Snapshot): void => {
|
|
32
|
+
if (disposed) return
|
|
33
|
+
snapshot = next
|
|
34
|
+
listeners.forEach(listener => listener())
|
|
35
|
+
}
|
|
36
|
+
const load = <T>(
|
|
37
|
+
key: string,
|
|
38
|
+
current: () => Entry<T> | undefined,
|
|
39
|
+
update: (entry: Entry<T>) => void,
|
|
40
|
+
request: (signal: AbortSignal) => Promise<{ data: T; warning?: string }>,
|
|
41
|
+
): Promise<string | undefined> => {
|
|
42
|
+
if (disposed) return Promise.resolve(undefined)
|
|
43
|
+
const pending = requests.get(key)
|
|
44
|
+
if (pending) return pending
|
|
45
|
+
const entry: Entry<T> = { status: 'loading', data: current()?.data }
|
|
46
|
+
const controller = new AbortController()
|
|
47
|
+
controllers.add(controller)
|
|
48
|
+
update(entry)
|
|
49
|
+
const result = request(controller.signal).then(
|
|
50
|
+
({ data, warning }) => { if (current() === entry) update({ status: 'ready', data, ...(warning ? { warning } : {}) }); return undefined },
|
|
51
|
+
cause => {
|
|
52
|
+
const error = cause instanceof Error ? cause.message : String(cause)
|
|
53
|
+
if (current() === entry) update({ status: 'error', data: entry.data, error })
|
|
54
|
+
return error
|
|
55
|
+
},
|
|
56
|
+
).finally(() => { controllers.delete(controller); requests.delete(key) })
|
|
57
|
+
requests.set(key, result)
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
const markets = (force = false): Promise<string | undefined> => {
|
|
61
|
+
if (!force && snapshot.markets) return Promise.resolve(undefined)
|
|
62
|
+
return load('markets', () => snapshot.markets, entry => publish({ ...snapshot, markets: entry }), async signal => ({ data: await api.pluginMarketplaces(signal) }))
|
|
63
|
+
}
|
|
64
|
+
const plugins = (id: string, force = false): Promise<string | undefined> => {
|
|
65
|
+
if (!force && snapshot.plugins[id]) return Promise.resolve(undefined)
|
|
66
|
+
return load(`plugins:${id}`, () => snapshot.plugins[id], entry => publish({ ...snapshot, plugins: { ...snapshot.plugins, [id]: entry } }), async signal => {
|
|
67
|
+
const result = await api.pluginCatalog(id, signal)
|
|
68
|
+
return Array.isArray(result) ? { data: result } : { data: result.plugins, warning: result.warning }
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
|
73
|
+
getSnapshot: () => snapshot,
|
|
74
|
+
start: () => { void markets() },
|
|
75
|
+
ensure: id => { void plugins(id) },
|
|
76
|
+
refresh: id => {
|
|
77
|
+
if (disposed || snapshot.refresh?.status === 'loading') return
|
|
78
|
+
// Refresh every previously visited catalog before unlocking navigation.
|
|
79
|
+
// Keep cached cards for layout continuity; views disable their controls.
|
|
80
|
+
const ids = new Set(Object.keys(snapshot.plugins))
|
|
81
|
+
if (id) ids.add(id)
|
|
82
|
+
publish({ ...snapshot, refresh: { status: 'loading' } })
|
|
83
|
+
void Promise.all([markets(true), ...[...ids].map(key => plugins(key, true))]).then(results => {
|
|
84
|
+
const errors = [...new Set(results.filter((value): value is string => value !== undefined))]
|
|
85
|
+
publish({ ...snapshot, refresh: errors.length ? { status: 'error', error: errors.join('\n') } : { status: 'ready' } })
|
|
86
|
+
})
|
|
87
|
+
},
|
|
88
|
+
dispose: () => { disposed = true; controllers.forEach(controller => controller.abort()); controllers.clear(); requests.clear(); listeners.clear() },
|
|
89
|
+
}
|
|
90
|
+
}
|