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
@@ -9,12 +9,13 @@
9
9
  * not mistaken for a no-op update.
10
10
  */
11
11
  import { createElement as h, useEffect, useRef } from 'react'
12
- import styles from '../styles/List.module.css'
13
- import type { HubPlugin, LocaleId, Translate } from '../types.ts'
14
- import { SITE_URL } from '../lib/catalog.ts'
12
+ import styles from '../../styles/List.module.css'
13
+ import type { HubPlugin, LocaleId, Translate } from '../../types.ts'
14
+ import { SITE_URL } from '../../logic/constants.ts'
15
+ import { useIncrementalList } from '../../hooks/useIncrementalList.ts'
15
16
  import { PluginCard } from './PluginCard.tsx'
16
17
 
17
- export function CatalogList({ plugins, failed, visible, total, t, langPath, reload, category, installedFilter, copied, installedName, installedVersion, hasUpdate, langKey, onInstall, onUninstall }: {
18
+ export function CatalogList({ plugins, failed, visible, total, t, langPath, reload, category, copied, installedName, installedVersion, hasUpdate, langKey, onInstall, onUninstall }: {
18
19
  plugins: HubPlugin[] | null
19
20
  failed: boolean
20
21
  visible: HubPlugin[]
@@ -23,7 +24,6 @@ export function CatalogList({ plugins, failed, visible, total, t, langPath, relo
23
24
  langPath: string
24
25
  reload: () => void
25
26
  category: string
26
- installedFilter: 'all' | 'installed' | 'notInstalled'
27
27
  copied: string | null
28
28
  installedName: (p: HubPlugin) => string | null
29
29
  installedVersion: (p: HubPlugin) => string | null
@@ -34,10 +34,12 @@ export function CatalogList({ plugins, failed, visible, total, t, langPath, relo
34
34
  }) {
35
35
  /** 列表滚动容器:分类/搜索切换后列表内容替换但 scrollTop 保留,会让用户误以为列表没更新,需重置回顶部 */
36
36
  const listRef = useRef<HTMLDivElement | null>(null)
37
+ /** 增量渲染:全量目录太大(4400+),只渲染首批,滚动接近底部时自动追加下一批 */
38
+ const { shown, hasMore, sentinelRef } = useIncrementalList(visible)
37
39
 
38
40
  useEffect(() => {
39
41
  listRef.current?.scrollTo({ top: 0 })
40
- }, [category, installedFilter])
42
+ }, [category])
41
43
 
42
44
  return h('div', { className: styles.body },
43
45
  h('div', { ref: listRef, className: styles.list },
@@ -51,7 +53,7 @@ export function CatalogList({ plugins, failed, visible, total, t, langPath, relo
51
53
  h('div', { className: styles.stateTitle }, t('noResult')),
52
54
  h('div', { className: styles.stateDesc }, t('noResultDesc')),
53
55
  ),
54
- plugins !== null && !failed && visible.map((p) => h(PluginCard, {
56
+ plugins !== null && !failed && shown.map((p) => h(PluginCard, {
55
57
  // 唯一 key:数据重构后同一 slug 可能对应多个作者仓库,仅用 slug 会撞 key,
56
58
  // 导致切换分类时 React 复用旧 DOM、列表不刷新。
57
59
  key: p.ownerSlug ? `${p.ownerSlug}/${p.slug}` : p.slug,
@@ -66,6 +68,8 @@ export function CatalogList({ plugins, failed, visible, total, t, langPath, relo
66
68
  onInstall,
67
69
  onUninstall,
68
70
  })),
71
+ // 滚动加载哨兵:透明 1px 元素,进入视口即触发下一批渲染(加载是纯追加,无需按钮)
72
+ hasMore && h('div', { ref: sentinelRef, className: styles.moreSentinel, 'aria-hidden': 'true' }),
69
73
  ),
70
74
  plugins !== null && !failed && h('div', { className: styles.footer },
71
75
  h('a', {
@@ -8,10 +8,11 @@
8
8
  * uninstall actions.
9
9
  */
10
10
  import { createElement as h } from 'react'
11
- import styles from '../styles/List.module.css'
12
- import type { HubPlugin, LocaleId, Translate } from '../types.ts'
13
- import { CATEGORY_LABELS, categoryLabel, pluginDetailUrl } from '../lib/catalog.ts'
14
- import { fmtStars, relTime } from '../lib/format.ts'
11
+ import styles from '../../styles/List.module.css'
12
+ import type { HubPlugin, LocaleId, Translate } from '../../types.ts'
13
+ import { CATEGORY_LABELS, categoryLabel } from '../../logic/constants.ts'
14
+ import { pluginDetailUrl } from '../../logic/urls.ts'
15
+ import { fmtStars, relTime } from '../../logic/format.ts'
15
16
 
16
17
  export function PluginCard({ plugin: p, copied, installedName, installedVersion, hasUpdate, t, langKey, langPath, onInstall, onUninstall }: {
17
18
  plugin: HubPlugin
@@ -0,0 +1,80 @@
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
+ * Toolbar above the market list:
7
+ * row 1 — full-width search input;
8
+ * row 2 — result count on the left, sort segmented control on the right
9
+ * (mirrors the Installed view: clicking the same sort toggles
10
+ * ascending/descending, a new sort uses its default order; the active
11
+ * button shows an up/down arrow for the current direction).
12
+ */
13
+ import { createElement as h, Fragment } from 'react'
14
+ import type { FormEvent, ReactNode } from 'react'
15
+ import styles from '../../styles/Header.module.css'
16
+ import type { Translate } from '../../types.ts'
17
+ import { SORTS } from '../../logic/constants.ts'
18
+ import type { SortKey } from '../../logic/constants.ts'
19
+ import { SortArrowIcon } from '../ui/icons.tsx'
20
+
21
+ /** 单选按钮组中的一个按钮(segmented control,与已安装视图排序按钮同款) */
22
+ function SegBtn({ active, onClick, label, icon }: {
23
+ active: boolean
24
+ onClick: () => void
25
+ label: string
26
+ icon?: ReactNode
27
+ }) {
28
+ return h('button', {
29
+ type: 'button',
30
+ className: active ? `${styles.segBtn} ${styles.segBtnActive}` : styles.segBtn,
31
+ 'aria-pressed': active,
32
+ onClick,
33
+ }, label, icon)
34
+ }
35
+
36
+ export function CatalogControls({ query, setQuery, sort, sortDir, toggleSort, t, resultText }: {
37
+ query: string
38
+ setQuery: (value: string) => void
39
+ sort: SortKey
40
+ /** 当前排序方向:决定激活排序按钮上的箭头朝上(正序)/朝下(倒序) */
41
+ sortDir: 'asc' | 'desc'
42
+ toggleSort: (value: SortKey) => void
43
+ t: Translate
44
+ resultText: string | null
45
+ }) {
46
+ return h(Fragment, null,
47
+ // 第一行:搜索框占满整行(搜插件名称、描述、标签)
48
+ h('div', { className: styles.searchRow },
49
+ h('input', {
50
+ className: styles.search,
51
+ type: 'search',
52
+ placeholder: t('search'),
53
+ value: query,
54
+ spellCheck: false,
55
+ onInput: (e: FormEvent<HTMLInputElement>) =>
56
+ setQuery((e.target as HTMLInputElement).value),
57
+ }),
58
+ ),
59
+ // 第二行:左侧筛选结果数「结果 N 条插件」(数字等宽预留 5 位不跳动),
60
+ // 右侧排序按钮组(与已安装视图同款 segmented control,激活按钮带正/倒序箭头)
61
+ h('div', { className: styles.controls },
62
+ resultText ? h('span', { className: styles.resultSeg },
63
+ ...resultText.split(/(\d+)/).map((part, i) =>
64
+ /^\d+$/.test(part)
65
+ ? h('span', { key: i, className: styles.resultCount }, part)
66
+ : part)) : null,
67
+ h('div', { className: styles.sortGroup },
68
+ h('span', { className: styles.segLabel }, t('sortByLabel')),
69
+ h('div', { className: styles.segGroup, role: 'radiogroup', 'aria-label': t('sortAria') },
70
+ SORTS.map((key) => SegBtn({
71
+ active: sort === key,
72
+ onClick: () => toggleSort(key),
73
+ label: t(key),
74
+ icon: sort === key ? h(SortArrowIcon, { up: sortDir === 'asc' }) : null,
75
+ })),
76
+ ),
77
+ ),
78
+ ),
79
+ )
80
+ }
@@ -7,10 +7,10 @@
7
7
  * by the purple ad banner that promotes the catalog stats.
8
8
  */
9
9
  import { createElement as h, Fragment } from 'react'
10
- import styles from '../styles/Header.module.css'
11
- import type { Translate } from '../types.ts'
12
- import { GITHUB_URL, PLUGIN_VERSION, SITE_URL } from '../lib/catalog.ts'
13
- import { GitHubIcon, LogoIcon } from './icons.tsx'
10
+ import styles from '../../styles/Header.module.css'
11
+ import type { Translate } from '../../types.ts'
12
+ import { GITHUB_URL, PLUGIN_VERSION, SITE_URL } from '../../logic/constants.ts'
13
+ import { GitHubIcon, LogoIcon } from '../ui/icons.tsx'
14
14
 
15
15
  export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggleLang, hubUpdate, onVersionClick, onAboutClick }: {
16
16
  t: Translate
@@ -8,9 +8,9 @@
8
8
  * carry no counts so their widths stay uniform.
9
9
  */
10
10
  import { createElement as h } from 'react'
11
- import styles from '../styles/Header.module.css'
12
- import type { LocaleId } from '../types.ts'
13
- import { CATEGORY_ORDER, CATEGORY_SHORT_LABELS, categoryLabel } from '../lib/catalog.ts'
11
+ import styles from '../../styles/Header.module.css'
12
+ import type { LocaleId } from '../../types.ts'
13
+ import { CATEGORY_ORDER, CATEGORY_SHORT_LABELS, categoryLabel } from '../../logic/constants.ts'
14
14
 
15
15
  export function CategoryTabs({ category, setCategory, allLabel, totalCount, langKey }: {
16
16
  category: string
@@ -0,0 +1,60 @@
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
+ * Primary navigation: a compact segmented control switching between the
7
+ * four top-level sections — Market (browse), Installed (manage), Custom
8
+ * (manual command installs) and Settings (network sources & preferences).
9
+ * Kept visually distinct from the category chips so the hierarchy reads:
10
+ * section → category → plugin.
11
+ */
12
+ import { createElement as h } from 'react'
13
+ import styles from '../../styles/SectionTabs.module.css'
14
+ import { BellIcon, InstalledIcon, MarketIcon, SettingsIcon, TerminalIcon } from '../ui/icons.tsx'
15
+
16
+ export type SectionView = 'market' | 'installed' | 'custom' | 'settings'
17
+
18
+ const ORDER: Array<{ id: SectionView; labelKey: string; hintKey: string; Icon: () => ReturnType<typeof h> }> = [
19
+ { id: 'market', labelKey: 'viewMarket', hintKey: 'viewMarketHint', Icon: MarketIcon },
20
+ { id: 'installed', labelKey: 'viewInstalled', hintKey: 'viewInstalledHint', Icon: InstalledIcon },
21
+ { id: 'custom', labelKey: 'viewCustom', hintKey: 'viewCustomHint', Icon: TerminalIcon },
22
+ { id: 'settings', labelKey: 'viewSettings', hintKey: 'viewSettingsHint', Icon: SettingsIcon },
23
+ ]
24
+
25
+ export function SectionTabs({ view, setView, installedCount, t, noticeCount, onOpenNotifications }: {
26
+ view: SectionView
27
+ setView: (value: SectionView) => void
28
+ installedCount: number
29
+ t: (key: string) => string
30
+ /** 通知中心红圈计数(记录 + 进行中任务 + 待重启);0 时不显示徽标 */
31
+ noticeCount: number
32
+ /** 点击打开通知中心 */
33
+ onOpenNotifications: () => void
34
+ }) {
35
+ return h('div', { className: styles.root, role: 'tablist' },
36
+ ORDER.map(({ id, labelKey, hintKey, Icon }) => h('button', {
37
+ key: id,
38
+ role: 'tab',
39
+ 'aria-selected': view === id,
40
+ title: t(hintKey),
41
+ className: view === id ? styles.tabActive : styles.tab,
42
+ onClick: () => setView(id),
43
+ },
44
+ h('span', { className: styles.tabIcon }, h(Icon)),
45
+ t(labelKey),
46
+ id === 'installed' && installedCount > 0
47
+ ? h('span', { className: view === id ? styles.tabCountActive : styles.tabCount }, installedCount)
48
+ : null)),
49
+ // 通知入口:设置在最后一个 tab 后边,靠右对齐 —— 铃铛 + 红底白字计数(内联跟在铃铛后,不悬浮)
50
+ h('button', {
51
+ type: 'button',
52
+ className: styles.noticeBtn,
53
+ onClick: onOpenNotifications,
54
+ title: t('notificationsHint'),
55
+ 'aria-label': t('notificationsHint'),
56
+ },
57
+ h('span', { className: styles.noticeIcon }, h(BellIcon)),
58
+ noticeCount > 0 ? h('span', { className: styles.noticeCount }, noticeCount > 99 ? '99+' : String(noticeCount)) : null),
59
+ )
60
+ }
@@ -3,18 +3,18 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * 头部「关注我们」弹窗:介绍 DSH-Plugin Hub 是干什么的 + 用户反馈群二维码。
6
+ * 头部「关注我们」弹窗:介绍 DSH-Plugin Hub 的功能定位 + 用户反馈群二维码。
7
7
  * 内容以 Markdown 形式由 dsh-update Worker 的 /about 接口下发(key = hub:about),
8
- * 作者想怎么写就怎么写、随写随推,客户端只负责渲染 —— 非写死。
8
+ * 客户端按接口返回渲染,不内置固定文案。
9
9
  * 图片(反馈群二维码)经 renderMarkdown 的 ![](url) 语法嵌入,居中、最大高度受控。
10
10
  */
11
11
  import { createElement as h } from 'react'
12
12
  import type { MouseEvent } from 'react'
13
- import styles from '../styles/Modal.module.css'
14
- import type { HubAboutInfo } from '../lib/catalog.ts'
15
- import type { LocaleId, Translate } from '../types.ts'
16
- import { renderMarkdown } from '../lib/renderMarkdown.ts'
17
- import { CloseIcon } from './icons.tsx'
13
+ import styles from '../../styles/Modal.module.css'
14
+ import type { HubAboutInfo } from '../../types.ts'
15
+ import type { LocaleId, Translate } from '../../types.ts'
16
+ import { renderMarkdown } from '../../logic/renderMarkdown.ts'
17
+ import { CloseIcon } from '../ui/icons.tsx'
18
18
 
19
19
  export function AboutModal({ info, lang, t, onClose }: {
20
20
  info: HubAboutInfo | null
@@ -12,11 +12,11 @@
12
12
  */
13
13
  import { createElement as h } from 'react'
14
14
  import type { MouseEvent } from 'react'
15
- import styles from '../styles/Modal.module.css'
16
- import type { HubUpdateInfo } from '../lib/catalog.ts'
17
- import type { LocaleId, Translate } from '../types.ts'
18
- import { renderMarkdown } from '../lib/renderMarkdown.ts'
19
- import { CloseIcon } from './icons.tsx'
15
+ import styles from '../../styles/Modal.module.css'
16
+ import type { HubUpdateInfo } from '../../types.ts'
17
+ import type { LocaleId, Translate } from '../../types.ts'
18
+ import { renderMarkdown } from '../../logic/renderMarkdown.ts'
19
+ import { CloseIcon } from '../ui/icons.tsx'
20
20
 
21
21
  export function HubUpdateModal({ info, lang, t, hasUpdate, onProceed, onClose }: {
22
22
  info: HubUpdateInfo
@@ -0,0 +1,193 @@
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-plugin detail dialog: opens from the Installed view (row click
7
+ * or the detail button) and shows everything we know about an installed
8
+ * entry — the catalog metadata (category, catalog entry, stars/forks,
9
+ * latest version) merged with the host runtime facts (install path, installed
10
+ * version, install time, last update). Update / uninstall actions are
11
+ * offered from here too, mirroring the row actions.
12
+ */
13
+ import { createElement as h } from 'react'
14
+ import type { MouseEvent } from 'react'
15
+ import styles from '../../styles/Modal.module.css'
16
+ import type { LocaleId, Translate } from '../../types.ts'
17
+ import type { InstalledItem } from '../../logic/installed.ts'
18
+ import { CATEGORY_LABELS, categoryLabel } from '../../logic/constants.ts'
19
+ import { pluginDetailUrl } from '../../logic/urls.ts'
20
+ import { fmtStars } from '../../logic/format.ts'
21
+ import { CloseIcon, CopyIcon, FolderIcon, LinkIcon } from '../ui/icons.tsx'
22
+
23
+ /** ISO 安装时间 → 本地可读;非法值返回 null(行内不显示)。 */
24
+ function fmtDate(iso: string | null, lang: LocaleId): string | null {
25
+ if (!iso) return null
26
+ const d = new Date(iso)
27
+ return Number.isNaN(d.getTime()) ? null : d.toLocaleString(lang === 'en' ? 'en-US' : 'zh-CN')
28
+ }
29
+
30
+ export function InstalledDetailModal({ item, t, lang, langPath, onClose, onUpdate, onUninstall, onCopyPath, onReveal }: {
31
+ item: InstalledItem
32
+ t: Translate
33
+ lang: LocaleId
34
+ langPath: string
35
+ onClose: () => void
36
+ /** 更新:仅目录插件且有更新时展示(点击后关闭详情、走安装确认弹窗的更新语义) */
37
+ onUpdate: (item: InstalledItem) => void
38
+ onUninstall: (item: InstalledItem) => void
39
+ /** 复制安装路径:成功提示由调用方 toast 反馈 */
40
+ onCopyPath: (path: string) => void
41
+ /** 在系统文件管理器里定位安装目录(服务端 spawn open):失败提示由调用方 toast 反馈 */
42
+ onReveal: (item: InstalledItem) => void
43
+ }) {
44
+ const p = item.plugin
45
+ const name = p?.displayName ?? item.name
46
+ const installedAt = fmtDate(item.installedAt, lang)
47
+ // 最后更新:目录插件取仓库最近提交时间(live 数据);自定义安装无目录信号
48
+ const lastUpdated = fmtDate(p?.dates?.repoUpdatedAt ?? null, lang)
49
+ const hasUpdate = item.hasUpdate && item.plugin !== null
50
+
51
+ return h('div', {
52
+ className: styles.overlay,
53
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
54
+ if (e.target === e.currentTarget) onClose()
55
+ },
56
+ },
57
+ h('div', { className: `${styles.modal} ${styles.detailModal}`, role: 'dialog', 'aria-modal': 'true' },
58
+ h('div', { className: styles.modalHead },
59
+ h('div', { className: styles.modalTitle }, name),
60
+ h('button', {
61
+ className: styles.modalClose,
62
+ 'aria-label': t('confirmCancel'),
63
+ onClick: onClose,
64
+ }, h(CloseIcon)),
65
+ ),
66
+ h('div', { className: styles.modalBody },
67
+ // 描述:目录收录的插件简介
68
+ p?.description ? h('div', { className: styles.modalDesc }, p.description) : null,
69
+ // 有可用更新:醒目提示条(版本号存在时给出升级路径)
70
+ hasUpdate
71
+ ? h('div', { className: styles.detailUpdateHint },
72
+ item.installedVersion && item.catalogVersion
73
+ ? t('versionUpHint', { from: item.installedVersion, to: item.catalogVersion })
74
+ : t('updateAvailableHint'))
75
+ : null,
76
+ // 详情信息网格:分类 / 状态 / 版本 / 收录入口 / 包名 / 统计 / 安装路径 / 安装时间 / 最后更新
77
+ h('div', { className: styles.detailGrid },
78
+ p?.category
79
+ ? h('div', { className: styles.detailRow },
80
+ h('span', { className: styles.detailLabel }, t('detailCategory')),
81
+ h('span', { className: styles.detailValue },
82
+ categoryLabel(CATEGORY_LABELS, p.category, lang)))
83
+ : null,
84
+ h('div', { className: styles.detailRow },
85
+ h('span', { className: styles.detailLabel }, t('statusLabel')),
86
+ h('span', { className: styles.detailValue },
87
+ h('span', {
88
+ className: item.loaded ? styles.detailStatusRunning : styles.detailStatusPending,
89
+ 'aria-hidden': 'true',
90
+ }),
91
+ h('span', { className: styles.detailStatusText },
92
+ item.loaded ? t('statusRunning') : t('statusPending')))),
93
+ item.installedVersion
94
+ ? h('div', { className: styles.detailRow },
95
+ h('span', { className: styles.detailLabel }, t('installedVersionLabel')),
96
+ h('span', { className: styles.detailValue },
97
+ h('span', { className: styles.detailMono }, item.installedVersion),
98
+ // 目录有更新版本且与已装版本不同:给出「→ 目录最新」
99
+ item.catalogVersion && item.catalogVersion !== item.installedVersion
100
+ ? h('span', { className: styles.detailArrow },
101
+ '\u2192', h('span', { className: styles.detailMono }, item.catalogVersion),
102
+ h('span', { className: styles.detailDim }, t('catalogLatest')))
103
+ : null))
104
+ : null,
105
+ // 收录入口:仓库名 → 官网详情页(目录插件跳收录页;自定义安装回退到 GitHub 仓库)
106
+ item.repo
107
+ ? h('div', { className: styles.detailRow },
108
+ h('span', { className: styles.detailLabel }, t('detailCatalog')),
109
+ h('a', {
110
+ className: styles.detailLink,
111
+ href: p ? pluginDetailUrl(p, langPath) : `https://github.com/${item.repo}`,
112
+ target: '_blank',
113
+ rel: 'noopener noreferrer',
114
+ title: item.repo,
115
+ }, h(LinkIcon), item.repo))
116
+ : null,
117
+ item.plugin?.source?.npmPackage
118
+ ? h('div', { className: styles.detailRow },
119
+ h('span', { className: styles.detailLabel }, t('packageName')),
120
+ h('span', { className: styles.detailValue },
121
+ h('span', { className: styles.detailMono }, item.plugin.source.npmPackage)))
122
+ : null,
123
+ p?.stats && (p.stats.stargazers_count ?? 0) > 0
124
+ ? h('div', { className: styles.detailRow },
125
+ h('span', { className: styles.detailLabel }, t('detailStats')),
126
+ h('span', { className: styles.detailValue },
127
+ h('span', { className: styles.detailStars }, '\u2605 ', fmtStars(p.stats.stargazers_count)),
128
+ h('span', { className: styles.detailDim }, t('fork'), ' ', fmtStars(p.stats.forks_count))))
129
+ : null,
130
+ item.installPath
131
+ ? h('div', { className: styles.detailRow },
132
+ h('span', { className: styles.detailLabel }, t('installPath')),
133
+ h('div', { className: styles.detailPath },
134
+ // 整条点击复制路径(等宽文本省略号)
135
+ h('span', {
136
+ className: styles.detailPathText,
137
+ title: t('copyPath'),
138
+ onClick: () => onCopyPath(item.installPath!),
139
+ }, item.installPath),
140
+ h('span', { className: styles.detailPathActions },
141
+ h('button', {
142
+ className: styles.detailPathBtn,
143
+ type: 'button',
144
+ title: t('copyPath'),
145
+ onClick: (e: MouseEvent<HTMLButtonElement>) => {
146
+ e.stopPropagation()
147
+ onCopyPath(item.installPath!)
148
+ },
149
+ }, h(CopyIcon)),
150
+ h('button', {
151
+ className: styles.detailPathBtn,
152
+ type: 'button',
153
+ title: t('openFolder'),
154
+ onClick: (e: MouseEvent<HTMLButtonElement>) => {
155
+ e.stopPropagation()
156
+ onReveal(item)
157
+ },
158
+ }, h(FolderIcon)),
159
+ )))
160
+ : null,
161
+ installedAt
162
+ ? h('div', { className: styles.detailRow },
163
+ h('span', { className: styles.detailLabel }, t('installedAtLabel')),
164
+ h('span', { className: styles.detailValue }, installedAt))
165
+ : null,
166
+ // 最后更新时间:与安装时间成对展示(目录插件才有)
167
+ lastUpdated
168
+ ? h('div', { className: styles.detailRow },
169
+ h('span', { className: styles.detailLabel }, t('lastUpdatedLabel')),
170
+ h('span', { className: styles.detailValue }, lastUpdated))
171
+ : null,
172
+ ),
173
+ ),
174
+ // 底部操作:完成 / 更新 / 卸载(复制路径与打开文件夹已在路径行内)
175
+ h('div', { className: styles.modalActions },
176
+ h('button', {
177
+ className: styles.restartLater,
178
+ onClick: onClose,
179
+ }, t('done')),
180
+ hasUpdate
181
+ ? h('button', {
182
+ className: styles.modalInstall,
183
+ onClick: () => onUpdate(item),
184
+ }, t('update'))
185
+ : null,
186
+ h('button', {
187
+ className: styles.uninstallConfirm,
188
+ onClick: () => onUninstall(item),
189
+ }, t('uninstall')),
190
+ ),
191
+ ),
192
+ )
193
+ }