dsh-plugin 1.1.2 → 1.2.1

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 (110) hide show
  1. package/client/client.js +4465 -1442
  2. package/client/client.js.map +1 -1
  3. package/lib/http/routes.js +438 -22
  4. package/lib/index.js +1 -1
  5. package/lib/services/install/install.js +4 -0
  6. package/lib/services/install/npm-check.js +55 -0
  7. package/lib/services/{npm-resolve.js → install/npm-resolve.js} +7 -5
  8. package/lib/services/{preflight.js → install/preflight.js} +15 -4
  9. package/lib/services/{task-queue.js → install/task-queue.js} +155 -66
  10. package/lib/services/loader.js +56 -1
  11. package/lib/services/log.js +138 -0
  12. package/lib/services/probe.js +157 -0
  13. package/lib/services/{profile.js → profile/profile.js} +29 -1
  14. package/lib/services/settings.js +60 -0
  15. package/lib/types/http/routes.d.ts +1 -1
  16. package/lib/types/index.d.ts +1 -1
  17. package/lib/types/services/{install-types.d.ts → install/install-types.d.ts} +3 -0
  18. package/lib/types/services/{install.d.ts → install/install.d.ts} +5 -5
  19. package/lib/types/services/install/npm-check.d.ts +12 -0
  20. package/lib/types/services/{npm-resolve.d.ts → install/npm-resolve.d.ts} +2 -1
  21. package/lib/types/services/{task-queue.d.ts → install/task-queue.d.ts} +5 -1
  22. package/lib/types/services/loader.d.ts +14 -8
  23. package/lib/types/services/log.d.ts +49 -0
  24. package/lib/types/services/probe.d.ts +30 -0
  25. package/lib/types/services/{pending-restart.d.ts → profile/pending-restart.d.ts} +1 -1
  26. package/lib/types/services/{profile.d.ts → profile/profile.d.ts} +2 -0
  27. package/lib/types/services/settings.d.ts +24 -0
  28. package/package.json +1 -1
  29. package/src/client/components/PluginHubSection.tsx +341 -120
  30. package/src/client/components/{CatalogList.tsx → catalog/CatalogList.tsx} +11 -7
  31. package/src/client/components/{PluginCard.tsx → catalog/PluginCard.tsx} +5 -4
  32. package/src/client/components/layout/CatalogControls.tsx +80 -0
  33. package/src/client/components/{CatalogHeader.tsx → layout/CatalogHeader.tsx} +4 -4
  34. package/src/client/components/{CategoryTabs.tsx → layout/CategoryTabs.tsx} +3 -3
  35. package/src/client/components/layout/SectionTabs.tsx +60 -0
  36. package/src/client/components/{AboutModal.tsx → modals/AboutModal.tsx} +7 -7
  37. package/src/client/components/{HubUpdateModal.tsx → modals/HubUpdateModal.tsx} +5 -5
  38. package/src/client/components/modals/InstalledDetailModal.tsx +193 -0
  39. package/src/client/components/modals/LogsModal.tsx +301 -0
  40. package/src/client/components/modals/LogsPathModal.tsx +161 -0
  41. package/src/client/components/{NotificationsModal.tsx → modals/NotificationsModal.tsx} +47 -20
  42. package/src/client/components/{ProgressView.tsx → modals/ProgressView.tsx} +2 -2
  43. package/src/client/components/{modals.tsx → modals/modals.tsx} +117 -40
  44. package/src/client/components/{Dropdown.tsx → ui/Dropdown.tsx} +14 -6
  45. package/src/client/components/ui/Toggle.tsx +28 -0
  46. package/src/client/components/ui/icons.tsx +343 -0
  47. package/src/client/components/views/CustomInstallView.tsx +250 -0
  48. package/src/client/components/views/DiagnosticsView.tsx +172 -0
  49. package/src/client/components/views/InstalledView.tsx +328 -0
  50. package/src/client/components/views/LogsView.tsx +150 -0
  51. package/src/client/components/views/MarketView.tsx +66 -0
  52. package/src/client/components/views/SettingsView.tsx +273 -0
  53. package/src/client/data/catalog.ts +38 -0
  54. package/src/client/data/host.ts +107 -0
  55. package/src/client/data/hub.ts +41 -0
  56. package/src/client/hooks/useCatalog.ts +71 -115
  57. package/src/client/hooks/useIncrementalList.ts +46 -0
  58. package/src/client/hooks/useLanguage.ts +40 -0
  59. package/src/client/hooks/useSettings.ts +90 -0
  60. package/src/client/hooks/useTaskQueue.ts +156 -39
  61. package/src/client/locales.ts +358 -38
  62. package/src/client/logic/constants.ts +86 -0
  63. package/src/client/{lib → logic}/failures.ts +95 -74
  64. package/src/client/logic/install-command.ts +56 -0
  65. package/src/client/logic/installed.ts +207 -0
  66. package/src/client/logic/normalize.ts +52 -0
  67. package/src/client/logic/urls.ts +125 -0
  68. package/src/client/styles/CustomInstallView.module.css +191 -0
  69. package/src/client/styles/DiagnosticsView.module.css +231 -0
  70. package/src/client/styles/Dropdown.module.css +42 -41
  71. package/src/client/styles/Header.module.css +100 -132
  72. package/src/client/styles/InstalledView.module.css +447 -0
  73. package/src/client/styles/List.module.css +6 -0
  74. package/src/client/styles/LogsView.module.css +503 -0
  75. package/src/client/styles/Modal.module.css +352 -70
  76. package/src/client/styles/SectionTabs.module.css +142 -0
  77. package/src/client/styles/SettingsView.module.css +232 -0
  78. package/src/client/styles/Toggle.module.css +60 -0
  79. package/src/client/styles/tokens.module.css +1 -0
  80. package/src/client/types.ts +23 -3
  81. package/src/server/http/routes.ts +414 -22
  82. package/src/server/index.ts +1 -1
  83. package/src/server/services/{install-types.ts → install/install-types.ts} +3 -1
  84. package/src/server/services/{install.ts → install/install.ts} +5 -5
  85. package/src/server/services/install/npm-check.ts +53 -0
  86. package/src/server/services/{npm-resolve.ts → install/npm-resolve.ts} +7 -5
  87. package/src/server/services/{preflight.ts → install/preflight.ts} +16 -4
  88. package/src/server/services/{task-queue.ts → install/task-queue.ts} +157 -63
  89. package/src/server/services/loader.ts +55 -1
  90. package/src/server/services/log.ts +167 -0
  91. package/src/server/services/probe.ts +157 -0
  92. package/src/server/services/{pending-restart.ts → profile/pending-restart.ts} +1 -1
  93. package/src/server/services/{profile.ts → profile/profile.ts} +31 -1
  94. package/src/server/services/settings.ts +79 -0
  95. package/lib/services/install.js +0 -4
  96. package/src/client/components/CatalogControls.tsx +0 -108
  97. package/src/client/components/icons.tsx +0 -129
  98. package/src/client/lib/catalog.ts +0 -293
  99. package/src/client/lib/env.ts +0 -20
  100. /package/lib/services/{install-types.js → install/install-types.js} +0 -0
  101. /package/lib/services/{installed-versions.js → profile/installed-versions.js} +0 -0
  102. /package/lib/services/{pending-restart.js → profile/pending-restart.js} +0 -0
  103. /package/lib/services/{progress.js → profile/progress.js} +0 -0
  104. /package/lib/types/services/{preflight.d.ts → install/preflight.d.ts} +0 -0
  105. /package/lib/types/services/{installed-versions.d.ts → profile/installed-versions.d.ts} +0 -0
  106. /package/lib/types/services/{progress.d.ts → profile/progress.d.ts} +0 -0
  107. /package/src/client/{lib → logic}/format.ts +0 -0
  108. /package/src/client/{lib → logic}/renderMarkdown.ts +0 -0
  109. /package/src/server/services/{installed-versions.ts → profile/installed-versions.ts} +0 -0
  110. /package/src/server/services/{progress.ts → profile/progress.ts} +0 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * System diagnostics list: one row per connectivity channel (npm registry /
7
+ * GitHub API / catalog site), each with a live status badge — 200 OK in
8
+ * green, Unreachable in red, Checking… while probing. The list runs
9
+ * automatically when the page opens; every row is a button that re-probes
10
+ * just that channel, so a click always yields visible feedback
11
+ * (row pulses → badge flips → latency refreshes).
12
+ */
13
+ import { createElement as h, useCallback, useEffect, useRef, useState } from 'react'
14
+ import styles from '../../styles/DiagnosticsView.module.css'
15
+ import type { EnvInfo, Translate } from '../../types.ts'
16
+ import { PLUGIN_VERSION } from '../../logic/constants.ts'
17
+
18
+ type ProbeKey = 'npm' | 'github' | 'catalog'
19
+
20
+ /** 系统版本文本:提交 Issue 时粘贴到正文,方便作者复现。 */
21
+ function formatEnv(env: EnvInfo): string {
22
+ return [
23
+ `DSH-Plugin Hub ${PLUGIN_VERSION}`,
24
+ `DSH ${env.dshVersion ?? 'unknown'}`,
25
+ `Node ${env.nodeVersion}`,
26
+ `${env.platform} ${env.arch} (${env.release})`,
27
+ `Profile ${env.profile}`,
28
+ env.dshHome ? `Home ${env.dshHome}` : '',
29
+ ].filter(Boolean).join('\n')
30
+ }
31
+
32
+ interface RowState {
33
+ key: ProbeKey
34
+ /** 目标地址展示(registry 地址 / github.com / dsh-plugin.org) */
35
+ display: string
36
+ nameKey: string
37
+ status: 'idle' | 'running' | 'ok' | 'fail'
38
+ ms: number | null
39
+ statusCode: number | null
40
+ }
41
+
42
+ const INITIAL_ROWS: RowState[] = [
43
+ { key: 'npm', display: 'registry.npmjs.org', nameKey: 'diagNpm', status: 'idle', ms: null, statusCode: null },
44
+ { key: 'github', display: 'github.com', nameKey: 'diagGithub', status: 'idle', ms: null, statusCode: null },
45
+ { key: 'catalog', display: 'api.dsh-plugin.org', nameKey: 'diagCatalog', status: 'idle', ms: null, statusCode: null },
46
+ ]
47
+
48
+ export function DiagnosticsView({ t, env, onCopy }: {
49
+ t: Translate
50
+ env: EnvInfo | null
51
+ onCopy: (text: string) => void
52
+ }) {
53
+ const [rows, setRows] = useState<RowState[]>(INITIAL_ROWS)
54
+ /** 当前探测请求:重测/卸载时 abort 旧请求,避免乱序结果覆盖 */
55
+ const runningRef = useRef<AbortController | null>(null)
56
+
57
+ const patchRows = (key: ProbeKey | undefined, patch: Partial<RowState>) => {
58
+ setRows((prev) => prev.map((r) => (key === undefined || r.key === key ? { ...r, ...patch } : r)))
59
+ }
60
+
61
+ /** 探测:key 缺省 = 全量串行;传 key = 只重测该通道 */
62
+ const run = useCallback((key?: ProbeKey) => {
63
+ runningRef.current?.abort()
64
+ const controller = new AbortController()
65
+ runningRef.current = controller
66
+ patchRows(key, { status: 'running', ms: null, statusCode: null })
67
+
68
+ void (async () => {
69
+ try {
70
+ const res = await fetch('/dsh-plugin-hub/diagnostics', {
71
+ method: 'POST',
72
+ headers: { 'content-type': 'application/json' },
73
+ body: key === undefined ? '{}' : JSON.stringify({ key }),
74
+ cache: 'no-store',
75
+ signal: controller.signal,
76
+ })
77
+ if (!res.ok || res.body === null) throw new Error(`http ${res.status}`)
78
+ const reader = res.body.getReader()
79
+ const decoder = new TextDecoder()
80
+ let buf = ''
81
+ for (;;) {
82
+ const { done, value } = await reader.read()
83
+ if (done) break
84
+ buf += decoder.decode(value, { stream: true })
85
+ let nl = buf.indexOf('\n')
86
+ while (nl !== -1) {
87
+ const raw = buf.slice(0, nl).trim()
88
+ buf = buf.slice(nl + 1)
89
+ if (raw !== '') {
90
+ const ev = JSON.parse(raw) as { type?: string; key?: string; ms?: number | null; status?: number | null }
91
+ if (ev.type === 'ok' || ev.type === 'fail') {
92
+ patchRows(ev.key as ProbeKey, {
93
+ status: ev.type === 'ok' ? 'ok' : 'fail',
94
+ ms: typeof ev.ms === 'number' ? ev.ms : null,
95
+ statusCode: typeof ev.status === 'number' ? ev.status : null,
96
+ })
97
+ }
98
+ }
99
+ nl = buf.indexOf('\n')
100
+ }
101
+ }
102
+ } catch {
103
+ // 连接本地服务失败:该项标记失败(被新请求 abort 的旧请求直接忽略)
104
+ if (controller.signal.aborted) return
105
+ patchRows(key, { status: 'fail', ms: null, statusCode: null })
106
+ }
107
+ })()
108
+ }, [])
109
+
110
+ // 进入页面自动全量探测一轮;卸载时中断
111
+ useEffect(() => {
112
+ run()
113
+ return () => runningRef.current?.abort()
114
+ }, [run])
115
+
116
+ const anyRunning = rows.some((r) => r.status === 'running')
117
+ const allOk = !anyRunning && rows.every((r) => r.status === 'ok')
118
+ const anyFail = !anyRunning && rows.some((r) => r.status === 'fail')
119
+
120
+ return h('div', { className: styles.panel },
121
+ // 最顶部:系统版本 —— 复制当前宿主版本与系统信息,提 Issue 时粘贴到正文便于复现
122
+ h('div', { className: styles.envRow },
123
+ h('div', { className: styles.envLabel },
124
+ h('div', { className: styles.envTitle }, t('settingsEnvSnapshot')),
125
+ h('div', { className: styles.envDesc }, t('settingsEnvSnapshotDesc')),
126
+ ),
127
+ h('button', {
128
+ type: 'button',
129
+ className: styles.envCopyBtn,
130
+ disabled: env === null,
131
+ onClick: () => { if (env) onCopy(formatEnv(env)) },
132
+ }, t('settingsEnvCopy')),
133
+ ),
134
+ // 头部:右侧「立即诊断」按钮,点击全量重测所有通道
135
+ h('div', { className: styles.head },
136
+ h('span', { className: styles.headHint }, t('diagHeadHint', { n: rows.length })),
137
+ h('button', {
138
+ type: 'button',
139
+ className: styles.runBtn,
140
+ disabled: anyRunning,
141
+ onClick: () => run(),
142
+ }, t('diagRunAll')),
143
+ ),
144
+ rows.map((r) => {
145
+ const badge = r.status === 'running'
146
+ ? h('span', { className: `${styles.badge} ${styles.badgeRunning}` }, t('diagChecking'))
147
+ : r.status === 'ok'
148
+ ? h('span', { className: `${styles.badge} ${styles.badgeOk}` },
149
+ r.statusCode !== null ? `HTTP ${r.statusCode} ${t('diagOk')}` : t('diagOk'))
150
+ : r.status === 'fail'
151
+ ? h('span', { className: `${styles.badge} ${styles.badgeFail}` }, t('settingsDiagFail'))
152
+ : h('span', { className: `${styles.badge} ${styles.badgeIdle}` }, t('diagIdle'))
153
+ return h('button', {
154
+ key: r.key,
155
+ type: 'button',
156
+ className: styles.row,
157
+ disabled: r.status === 'running',
158
+ onClick: () => run(r.key),
159
+ title: t('diagRecheck'),
160
+ },
161
+ h('span', { className: styles.name }, t(r.nameKey)),
162
+ h('span', { className: styles.display }, r.display),
163
+ h('span', { className: styles.meta }, r.status === 'ok' && r.ms !== null ? `${r.ms} ms` : ''),
164
+ badge,
165
+ )
166
+ }),
167
+ // 底部总览:全部通过 / 存在异常 / 检测中,汇总整体状态
168
+ h('div', {
169
+ className: `${styles.summary} ${anyRunning ? styles.summaryRunning : allOk ? styles.summaryOk : styles.summaryFail}`,
170
+ }, anyRunning ? t('diagSummaryRunning') : allOk ? t('diagSummaryOk') : t('diagSummaryFail')),
171
+ )
172
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * Installed view: manages the plugins installed into the current profile.
7
+ * Follows the mainstream extension-manager pattern (VSCode Installed tab) —
8
+ * a searchable, sortable list of installed entries that mixes catalog
9
+ * plugins (full metadata) and custom command installs (outside the catalog,
10
+ * only runtime info, tagged with a custom badge). The toolbar carries a
11
+ * search box plus two segmented radio-button groups: source filter
12
+ * (all / catalog / custom) and sort (name / recently installed / stars / forks).
13
+ * Each row carries restart (when pending), update and uninstall actions
14
+ * and opens a detail modal with the full install metadata.
15
+ *
16
+ * Pure presentational: owns only local search/sort state; the InstalledItem
17
+ * model arrives from the catalog hook and actions bubble up via callbacks.
18
+ */
19
+ import { createElement as h, useMemo, useState } from 'react'
20
+ import type { FormEvent, MouseEvent, ReactNode } from 'react'
21
+ import styles from '../../styles/InstalledView.module.css'
22
+ import type { LocaleId, Translate } from '../../types.ts'
23
+ import type { InstalledItem } from '../../logic/installed.ts'
24
+ import { CATEGORY_LABELS, categoryLabel } from '../../logic/constants.ts'
25
+ import { SortArrowIcon } from '../ui/icons.tsx'
26
+
27
+ const INSTALLED_SORTS = ['sortName', 'sortInstalledAt', 'sortStars', 'sortForks'] as const
28
+ type InstalledSortKey = (typeof INSTALLED_SORTS)[number]
29
+
30
+ /** 各排序的默认方向:名称按字母正序,最近安装 / Star / Fork 按数值倒序 */
31
+ const SORT_DEFAULT_DIR: Record<InstalledSortKey, 'asc' | 'desc'> = {
32
+ sortName: 'asc', sortInstalledAt: 'desc', sortStars: 'desc', sortForks: 'desc',
33
+ }
34
+
35
+ /** 已安装列表的来源筛选:全部 / 目录收录 / 自定义安装 */
36
+ type SourceFilter = 'all' | 'catalog' | 'custom'
37
+
38
+ /** 单选按钮组中的一个按钮(segmented control,与排序/来源筛选共用);
39
+ 可带尾随图标(排序按钮激活时显示当前方向箭头) */
40
+ function SegBtn({ active, onClick, label, icon }: {
41
+ active: boolean
42
+ onClick: () => void
43
+ label: string
44
+ icon?: ReactNode
45
+ }) {
46
+ return h('button', {
47
+ type: 'button',
48
+ className: active ? `${styles.segBtn} ${styles.segBtnActive}` : styles.segBtn,
49
+ 'aria-pressed': active,
50
+ onClick,
51
+ }, label, icon)
52
+ }
53
+
54
+ function InstalledRow({ item, t, langKey, canReveal, revealLabel, onOpenDetail, onReveal, onUpdate, onUninstall, onRestart }: {
55
+ item: InstalledItem
56
+ t: Translate
57
+ langKey: LocaleId
58
+ /** 系统文件管理器可定位安装目录的平台(macOS / Linux):行内显示打开目录按钮 */
59
+ canReveal: boolean
60
+ /** 打开目录按钮文案(macOS = 在 Finder 中显示,Linux = 在文件夹中显示) */
61
+ revealLabel: string
62
+ onOpenDetail: (item: InstalledItem) => void
63
+ /** 在系统文件管理器里定位安装目录(macOS Finder / Linux 文件管理器) */
64
+ onReveal: (item: InstalledItem) => void
65
+ onUpdate: (item: InstalledItem) => void
66
+ onUninstall: (item: InstalledItem) => void
67
+ /** 重启宿主:仅待重启(装完未挂载)条目展示,重启后该插件才生效 */
68
+ onRestart: () => void
69
+ }) {
70
+ const name = item.plugin?.displayName ?? item.name
71
+ const repo = item.repo
72
+ const category = item.plugin?.category
73
+ const desc = item.plugin?.description
74
+ // 命令行安装进来的插件没有目录元数据(plugin === null):列表里打「自定义安装」标签区分
75
+ const isCustom = item.plugin === null
76
+ return h('li', {
77
+ className: styles.row,
78
+ onClick: () => onOpenDetail(item),
79
+ role: 'button',
80
+ tabIndex: 0,
81
+ title: t('detail'),
82
+ onKeyDown: (e: KeyboardEvent) => {
83
+ if (e.key === 'Enter' || e.key === ' ') {
84
+ e.preventDefault()
85
+ onOpenDetail(item)
86
+ }
87
+ },
88
+ },
89
+ // 第一行:状态点 + 名称/版本/更新徽标 + 行尾来源标签列 + 右侧固定操作按钮组
90
+ h('div', { className: styles.rowTitleLine },
91
+ // 运行状态点:绿 = 已加载进 loader;橙 = 待重启(装完未挂载的 dsh 插件);
92
+ // 灰 = 非 dsh 插件(如官方示例项目,宿主不加载,无需重启)
93
+ h('span', {
94
+ className: item.loaded ? styles.statusDot
95
+ : item.dshCapable ? `${styles.statusDot} ${styles.statusPending}`
96
+ : `${styles.statusDot} ${styles.statusInactive}`,
97
+ title: item.loaded ? t('statusRunning')
98
+ : item.dshCapable ? t('statusPending')
99
+ : t('exampleHint'),
100
+ 'aria-label': item.loaded ? t('statusRunning')
101
+ : item.dshCapable ? t('statusPending')
102
+ : t('exampleHint'),
103
+ }),
104
+ // 主信息:名称 + 已装版本 + 有更新徽标(flex 撑满,超长省略)
105
+ h('div', { className: styles.rowMain },
106
+ h('span', { className: styles.rowTitle }, name),
107
+ item.installedVersion
108
+ ? h('span', { className: styles.versionBadge, title: t('installedVersionLabel') }, item.installedVersion)
109
+ : null,
110
+ item.hasUpdate
111
+ ? h('span', { className: styles.updateBadge, title: t('updateAvailableHint') }, t('updateAvailable'))
112
+ : null,
113
+ ),
114
+ // 来源标签列(行尾、操作按钮前,右对齐固定):目录插件 →「插件中心」(品牌色),
115
+ // 命令行安装 →「手动安装」(琥珀色);两词 4 字等宽,所有条目标签同列对齐
116
+ h('span', {
117
+ className: isCustom
118
+ ? `${styles.rowSourceTag} ${styles.rowSourceTagManual}`
119
+ : `${styles.rowSourceTag} ${styles.rowSourceTagHub}`,
120
+ title: isCustom ? t('manualInstallHint') : t('hubInstallHint'),
121
+ }, isCustom ? t('manualInstall') : t('hubInstall')),
122
+ // 行操作:重启(dsh 插件待重启时)/ 详情 / 更新(有更新时)/ 卸载 —— 按钮点击不触发行打开详情
123
+ h('div', { className: styles.rowActions },
124
+ // 装完未挂载的 dsh 插件:给「重启」入口,点了宿主重启后插件即生效;非 dsh 插件不提示(重启无用)
125
+ !item.loaded && item.dshCapable
126
+ ? h('button', {
127
+ className: styles.rowRestart,
128
+ type: 'button',
129
+ title: t('restartPendingHint'),
130
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onRestart() },
131
+ }, t('restart'))
132
+ : null,
133
+ // 支持文件管理器定位的平台(macOS / Linux):行内该位改为打开安装目录按钮;
134
+ // 行点击本身即打开详情弹窗,故不支持的平台(如 Windows)不显示此按钮
135
+ canReveal
136
+ ? h('button', {
137
+ className: styles.rowDetail,
138
+ type: 'button',
139
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onReveal(item) },
140
+ }, revealLabel)
141
+ : null,
142
+ item.hasUpdate && item.plugin
143
+ ? h('button', {
144
+ className: styles.rowUpdate,
145
+ type: 'button',
146
+ title: t('updateAvailableHint'),
147
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onUpdate(item) },
148
+ }, t('update'))
149
+ : null,
150
+ h('button', {
151
+ className: styles.rowUninstall,
152
+ type: 'button',
153
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onUninstall(item) },
154
+ }, t('uninstall')),
155
+ ),
156
+ ),
157
+ // 示例项目徽标单独一行(非 dsh 插件):标签较长也能完整显示,不挤压名称行
158
+ !item.dshCapable
159
+ ? h('div', { className: styles.exampleRow },
160
+ h('span', { className: styles.exampleBadge, title: t('exampleHint') }, t('exampleLabel')),
161
+ )
162
+ : null,
163
+ // 第二行:仓库 · 分类(与名称左对齐缩进,独立成行不再挤压名称)
164
+ repo || category
165
+ ? h('div', { className: styles.rowMeta },
166
+ item.repo ? h('span', { className: styles.rowRepo }, item.repo) : null,
167
+ category ? h('span', { className: styles.rowCategory },
168
+ categoryLabel(CATEGORY_LABELS, category, langKey)) : null,
169
+ )
170
+ : null,
171
+ // 第三行(仅目录插件):简介单独一行展示,超长以省略号收尾
172
+ desc ? h('div', { className: styles.rowDesc, title: desc }, desc) : null,
173
+ )
174
+ }
175
+
176
+ /** 行列表:单个已安装列表(自定义安装与目录插件混排,靠徽标区分)。 */
177
+ function RowList({ items, t, langKey, canReveal, revealLabel, onOpenDetail, onReveal, onUpdate, onUninstall, onRestart }: {
178
+ items: InstalledItem[]
179
+ t: Translate
180
+ langKey: LocaleId
181
+ canReveal: boolean
182
+ revealLabel: string
183
+ onOpenDetail: (item: InstalledItem) => void
184
+ onReveal: (item: InstalledItem) => void
185
+ onUpdate: (item: InstalledItem) => void
186
+ onUninstall: (item: InstalledItem) => void
187
+ onRestart: () => void
188
+ }) {
189
+ return h('ul', { className: styles.list },
190
+ items.map((item) => h(InstalledRow, {
191
+ key: item.name,
192
+ item,
193
+ t,
194
+ langKey,
195
+ canReveal,
196
+ revealLabel,
197
+ onOpenDetail,
198
+ onReveal,
199
+ onUpdate,
200
+ onUninstall,
201
+ onRestart,
202
+ })),
203
+ )
204
+ }
205
+
206
+ export function InstalledView({ items, t, langKey, platform, onOpenDetail, onReveal, onUpdate, onUninstall, onRestart }: {
207
+ items: InstalledItem[]
208
+ t: Translate
209
+ langKey: LocaleId
210
+ /** 宿主系统平台(node process.platform):支持文件管理器定位(macOS / Linux)时行内显示打开目录按钮 */
211
+ platform: string
212
+ onOpenDetail: (item: InstalledItem) => void
213
+ onReveal: (item: InstalledItem) => void
214
+ /** 更新:仅目录插件且有更新时触发(自定义安装无目录信号可比) */
215
+ onUpdate: (item: InstalledItem) => void
216
+ onUninstall: (item: InstalledItem) => void
217
+ /** 重启宿主:待重启条目行内按钮触发(装完未挂载的插件重启后生效) */
218
+ onRestart: () => void
219
+ }) {
220
+ const [query, setQuery] = useState('')
221
+ /** 默认按「最近安装」排序(最新在前);点其他排序按钮用该排序的默认方向(Star/Fork 最多在前、名称正序) */
222
+ const [sort, setSort] = useState<InstalledSortKey>('sortInstalledAt')
223
+ /** 当前排序方向:点同一个排序按钮切换 正/倒序;点新排序用该排序的默认方向 */
224
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
225
+ /** 已安装列表的来源筛选:全部 / 目录收录 / 自定义安装(单选按钮组) */
226
+ const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
227
+
228
+ /** 点击排序按钮:同一按钮切换正/倒序,新按钮用默认方向 */
229
+ function pickSort(key: InstalledSortKey) {
230
+ if (key === sort) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
231
+ else { setSort(key); setSortDir(SORT_DEFAULT_DIR[key]) }
232
+ }
233
+
234
+ // 搜索 + 排序后的展示列表:搜索命中名称/包名/仓库/描述;排序支持名称/最近安装/Star/Fork
235
+ const list = useMemo(() => {
236
+ const q = query.trim().toLowerCase()
237
+ const filtered = items.filter((item) => {
238
+ if (!q) return true
239
+ return (item.plugin?.displayName ?? item.name).toLowerCase().includes(q)
240
+ || item.name.toLowerCase().includes(q)
241
+ || (item.repo ?? '').toLowerCase().includes(q)
242
+ || (item.plugin?.description ?? '').toLowerCase().includes(q)
243
+ })
244
+ const dir = sortDir === 'asc' ? 1 : -1
245
+ return [...filtered].sort((a, b) => {
246
+ if (sort === 'sortName') return (a.plugin?.displayName ?? a.name).localeCompare(b.plugin?.displayName ?? b.name) * dir
247
+ // Star / Fork:目录插件按数量比较(正/倒序随 dir);自定义安装(无目录数据,stats 缺失)永远排最后
248
+ if (sort === 'sortStars' || sort === 'sortForks') {
249
+ const metric = sort === 'sortStars' ? 'stargazers_count' : 'forks_count'
250
+ const sa = a.plugin?.stats?.[metric]
251
+ const sb = b.plugin?.stats?.[metric]
252
+ if (sa == null && sb == null) return 0
253
+ if (sa == null) return 1
254
+ if (sb == null) return -1
255
+ return (sa - sb) * dir
256
+ }
257
+ // 最近安装:按安装时间比较;无安装时间记录的永远排最后
258
+ const av = a.installedAt ?? ''
259
+ const bv = b.installedAt ?? ''
260
+ if (av === bv) return 0
261
+ if (av === '') return 1
262
+ if (bv === '') return -1
263
+ return av.localeCompare(bv) * dir
264
+ })
265
+ }, [items, query, sort, sortDir])
266
+
267
+ const catalogItems = list.filter((item) => item.plugin !== null)
268
+ const customItems = list.filter((item) => item.plugin === null)
269
+ // 已安装列表:搜索 + 排序后,再按来源筛选(全部 / 目录收录 / 自定义安装)
270
+ const filtered = sourceFilter === 'all' ? list
271
+ : sourceFilter === 'catalog' ? catalogItems : customItems
272
+
273
+ // 行列表公共参数:macOS 文案特指 Finder,Windows / Linux 用通用「在文件夹中显示」
274
+ const rowListProps = {
275
+ t, langKey,
276
+ canReveal: platform === 'darwin' || platform === 'win32' || platform === 'linux',
277
+ revealLabel: platform === 'darwin' ? t('revealFolder') : t('openFolder'),
278
+ onOpenDetail, onReveal, onUpdate, onUninstall, onRestart,
279
+ }
280
+
281
+ // 工具栏:第一行搜索框撑满,第二行来源筛选按钮组 + 排序按钮组(单选按钮组形态)
282
+ const listToolbar = h('div', { className: styles.toolbar },
283
+ h('div', { className: styles.searchWrap },
284
+ h('input', {
285
+ className: styles.searchInput,
286
+ type: 'search',
287
+ placeholder: t('installedSearch'),
288
+ value: query,
289
+ spellCheck: false,
290
+ onInput: (e: FormEvent<HTMLInputElement>) =>
291
+ setQuery((e.target as HTMLInputElement).value),
292
+ }),
293
+ ),
294
+ h('div', { className: styles.segRow },
295
+ h('span', { className: styles.segLabel }, t('filterByLabel')),
296
+ h('div', { className: styles.segGroup, role: 'radiogroup', 'aria-label': t('filterByLabel') },
297
+ SegBtn({ active: sourceFilter === 'all', onClick: () => setSourceFilter('all'), label: t('all') }),
298
+ SegBtn({ active: sourceFilter === 'catalog', onClick: () => setSourceFilter('catalog'), label: t('installedFilterCatalog') }),
299
+ SegBtn({ active: sourceFilter === 'custom', onClick: () => setSourceFilter('custom'), label: t('customLabel') }),
300
+ ),
301
+ h('span', { className: styles.segLabel }, t('sortByLabel')),
302
+ h('div', { className: styles.segGroup, role: 'radiogroup', 'aria-label': t('sortAria') },
303
+ INSTALLED_SORTS.map((key) => SegBtn({
304
+ active: sort === key,
305
+ onClick: () => pickSort(key),
306
+ label: t(key),
307
+ icon: sort === key ? h(SortArrowIcon, { up: sortDir === 'asc' }) : null,
308
+ })),
309
+ ),
310
+ ),
311
+ )
312
+
313
+ return h('div', { className: styles.root },
314
+ listToolbar,
315
+ // 筛选后的列表;一个都没装 → 引导空态,搜索/筛选无结果 → 无结果空态
316
+ filtered.length === 0
317
+ ? items.length === 0
318
+ ? h('div', { className: styles.empty },
319
+ h('div', { className: styles.emptyTitle }, t('installedEmpty')),
320
+ h('div', { className: styles.emptyDesc }, t('installedEmptyDesc')),
321
+ )
322
+ : h('div', { className: styles.empty },
323
+ h('div', { className: styles.emptyTitle }, t('noResult')),
324
+ h('div', { className: styles.emptyDesc }, t('noResultDesc')),
325
+ )
326
+ : h(RowList, { items: filtered, ...rowListProps }),
327
+ )
328
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * 系统日志入口视图:不在这里堆全文,而是给出「打开日志查看器」窗口入口 +
7
+ * 日志文件真实路径(可一键在系统文件管理器中定位)+ 最近若干条预览。
8
+ * 完整阅读/筛选/分页在 LogsModal 内置查看器弹窗里完成 —— 无需借助任何
9
+ * 外部软件,点击即弹出自己的窗口加载本地 JSONL 日志。
10
+ */
11
+ import { createElement as h, useCallback, useEffect, useRef, useState } from 'react'
12
+ import styles from '../../styles/LogsView.module.css'
13
+ import type { Translate } from '../../types.ts'
14
+ import { openLogFile } from '../../data/host.ts'
15
+ import { LogsModal, LogEntryRow, fmtLogTime, type LogEntryView } from '../modals/LogsModal.tsx'
16
+ import { LogsPathModal } from '../modals/LogsPathModal.tsx'
17
+
18
+ /** 设置页预览条数:只放最近一小段,给用户快速感观,完整内容进查看器。 */
19
+ const PREVIEW = 12
20
+
21
+ export function LogsView({ t, logPath, onLogPathSaved }: {
22
+ t: Translate
23
+ /** 设置里已保存的日志位置覆盖(空串 = 默认位置) */
24
+ logPath: string
25
+ /** 保存自定义日志位置(写入设置,服务端同时预建目录验证可写) */
26
+ onLogPathSaved: (value: string) => void
27
+ }) {
28
+ const [entries, setEntries] = useState<LogEntryView[]>([])
29
+ const [path, setPath] = useState('')
30
+ // 真正默认位置(~/.dsh/profiles/<profile>/hub.log,跨机器/平台动态):弹窗「恢复默认」用
31
+ const [defaultPath, setDefaultPath] = useState('')
32
+ const [loading, setLoading] = useState(false)
33
+ const [error, setError] = useState(false)
34
+ const [open, setOpen] = useState(false)
35
+ const [opening, setOpening] = useState(false)
36
+ const [openFailed, setOpenFailed] = useState(false)
37
+ // 日志存放位置弹窗:点「修改」打开,手输或选目录
38
+ const [showPathModal, setShowPathModal] = useState(false)
39
+ const mountedRef = useRef(true)
40
+
41
+ const load = useCallback(async () => {
42
+ setLoading(true)
43
+ try {
44
+ const res = await fetch('/dsh-plugin-hub/logs', { cache: 'no-store' })
45
+ if (!res.ok) throw new Error(`http ${res.status}`)
46
+ const data = await res.json() as { entries: LogEntryView[]; path: string; defaultPath?: string }
47
+ if (mountedRef.current) {
48
+ setEntries(data.entries)
49
+ if (data.path !== '') setPath(data.path)
50
+ if (data.defaultPath !== undefined) setDefaultPath(data.defaultPath)
51
+ setError(false)
52
+ }
53
+ } catch {
54
+ if (mountedRef.current) setError(true)
55
+ } finally {
56
+ if (mountedRef.current) setLoading(false)
57
+ }
58
+ }, [])
59
+
60
+ useEffect(() => {
61
+ mountedRef.current = true
62
+ void load()
63
+ return () => { mountedRef.current = false }
64
+ }, [load])
65
+
66
+ /** 导出:拼纯文本 → Blob → 触发浏览器下载 .log 文件 */
67
+ const exportFile = () => {
68
+ const text = entries.map((e) => `[${fmtLogTime(e.at)}] [${e.level}] [${e.category}] [${e.event}] ${e.message}`).join('\n')
69
+ if (text === '') return
70
+ const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
71
+ const url = URL.createObjectURL(blob)
72
+ const a = document.createElement('a')
73
+ a.href = url
74
+ a.download = 'dsh-plugin-hub-log.log'
75
+ document.body.appendChild(a)
76
+ a.click()
77
+ a.remove()
78
+ URL.revokeObjectURL(url)
79
+ }
80
+
81
+ /** 在系统文件管理器中定位日志文件。 */
82
+ const openFile = async () => {
83
+ if (opening) return
84
+ setOpening(true)
85
+ setOpenFailed(false)
86
+ const ok = await openLogFile()
87
+ setOpening(false)
88
+ if (!ok) setOpenFailed(true)
89
+ }
90
+
91
+ const preview = entries.slice(0, PREVIEW)
92
+ return h('div', { className: styles.panel },
93
+ h('div', { className: styles.head },
94
+ h('span', { className: styles.headHint }, t('logsHeadHint')),
95
+ h('div', { className: styles.actions },
96
+ h('button', {
97
+ type: 'button',
98
+ className: `${styles.btn} ${styles.btnPrimary}`,
99
+ onClick: () => setOpen(true),
100
+ }, t('logViewerOpen')),
101
+ h('button', { type: 'button', className: styles.btn, disabled: entries.length === 0, onClick: exportFile }, t('logsExport')),
102
+ h('button', { type: 'button', className: styles.btn, disabled: loading, onClick: () => void load() }, t('logsRefresh')),
103
+ ),
104
+ ),
105
+ // 日志存放位置:一行展示真实路径 + 「修改」弹窗编辑(手输 / 选目录)+ 一键定位文件
106
+ h('div', { className: styles.pathRow },
107
+ h('span', { className: styles.pathLabel }, t('logPathSettingTitle')),
108
+ h('span', { className: styles.pathText, title: path }, path === '' ? '…' : path),
109
+ openFailed && h('span', { className: styles.footFail }, t('logOpenFileFail')),
110
+ h('button', {
111
+ type: 'button',
112
+ className: styles.btn,
113
+ onClick: () => setShowPathModal(true),
114
+ }, t('logPathChange')),
115
+ h('button', {
116
+ type: 'button',
117
+ className: styles.btn,
118
+ disabled: opening || path === '',
119
+ title: t('logOpenFileTip'),
120
+ onClick: () => void openFile(),
121
+ }, t('logOpenFile')),
122
+ ),
123
+ error
124
+ ? h('div', { className: styles.empty }, t('logsLoadFail'))
125
+ : loading && entries.length === 0
126
+ ? h('div', { className: styles.empty }, t('logsLoading'))
127
+ : entries.length === 0
128
+ ? h('div', { className: styles.empty }, t('logsEmpty'))
129
+ : h('div', { className: styles.list },
130
+ preview.map((e, i) => h('button', {
131
+ key: `${e.at}-${i}`,
132
+ type: 'button',
133
+ className: styles.previewRow,
134
+ onClick: () => setOpen(true),
135
+ title: t('logViewerOpen'),
136
+ }, h(LogEntryRow, { e, t }))),
137
+ ),
138
+ open && h(LogsModal, { t, onClose: () => setOpen(false) }),
139
+ showPathModal && h(LogsPathModal, {
140
+ t,
141
+ defaultPath,
142
+ currentPath: path,
143
+ onSaved: (v: string) => {
144
+ onLogPathSaved(v)
145
+ void load() // 刷新生效路径与最近日志(新位置若已写入则立即可见)
146
+ },
147
+ onClose: () => setShowPathModal(false),
148
+ }),
149
+ )
150
+ }