@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.
Files changed (47) hide show
  1. package/README.md +19 -1
  2. package/README_EN.md +19 -1
  3. package/lib/client-editor.js +326 -258
  4. package/lib/client-mermaid.js +312 -259
  5. package/lib/client-registry.js +1392 -605
  6. package/lib/client-terminal.js +328 -260
  7. package/lib/client.js +1352 -565
  8. package/lib/index.js +162 -5
  9. package/lib/types/client/LogoutConfirmDialog.d.ts +5 -0
  10. package/lib/types/client/PluginMarketSettingsSection.d.ts +7 -0
  11. package/lib/types/client/PluginMarketView.d.ts +7 -0
  12. package/lib/types/client/api.d.ts +15 -0
  13. package/lib/types/client/builtins/tabs.d.ts +6 -3
  14. package/lib/types/client/locales.d.ts +23 -0
  15. package/lib/types/client/plugin-market-catalog.d.ts +25 -0
  16. package/lib/types/client/ui-tweaks/branding.d.ts +1 -1
  17. package/lib/types/client/ui-tweaks/index.d.ts +1 -1
  18. package/lib/types/client/ui-tweaks/sidebar-header.d.ts +6 -0
  19. package/lib/types/client/ui-tweaks/typography.d.ts +9 -0
  20. package/lib/types/login-page-script.d.ts +2 -0
  21. package/lib/types/login-route.d.ts +2 -0
  22. package/lib/types/plugin-market-api.d.ts +5 -0
  23. package/lib/types/plugin-market-shared.d.ts +37 -0
  24. package/lib/types/ui-tweaks/config.d.ts +5 -6
  25. package/package.json +1 -1
  26. package/src/client/AccountMenu.tsx +12 -2
  27. package/src/client/LogoutConfirmDialog.tsx +39 -0
  28. package/src/client/PluginMarketSettingsSection.tsx +55 -0
  29. package/src/client/PluginMarketView.module.css +35 -0
  30. package/src/client/PluginMarketView.tsx +154 -0
  31. package/src/client/api.ts +7 -0
  32. package/src/client/builtins/tabs.tsx +16 -3
  33. package/src/client/index.tsx +10 -1
  34. package/src/client/locales-ja.ts +23 -0
  35. package/src/client/locales.ts +48 -0
  36. package/src/client/plugin-market-catalog.ts +90 -0
  37. package/src/client/sidebar.module.css +81 -19
  38. package/src/client/ui-tweaks/branding.tsx +25 -16
  39. package/src/client/ui-tweaks/index.tsx +36 -99
  40. package/src/client/ui-tweaks/sidebar-header.ts +73 -0
  41. package/src/client/ui-tweaks/typography.ts +38 -0
  42. package/src/index.ts +2 -0
  43. package/src/login-page-script.ts +107 -0
  44. package/src/login-route.ts +7 -4
  45. package/src/plugin-market-api.ts +42 -0
  46. package/src/plugin-market-shared.ts +21 -0
  47. 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 7 built-in tab descriptors: the plugin registers its own pages
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 5 built-in tab descriptors. */
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'),
@@ -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
@@ -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: '更新',
@@ -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
+ }