dsh-plugin-panel 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process'
3
3
  import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
- import { collectTrend } from './trend.js'
6
+ import { collectHot, collectSearch, collectTrend } from './trend.js'
7
7
 
8
8
  const CHANNEL = '/dsh-plugins-ui-rpc'
9
9
  const MAX_SOURCE_BYTES = 1_200_000
@@ -65,6 +65,8 @@ function registerDetailRpc(ctx, services) {
65
65
  else if (method === 'facts') result = { ok: true, value: readFacts(record.payload) }
66
66
  else if (method === 'reveal') result = { ok: true, value: revealPackage(record.payload) }
67
67
  else if (method === 'trend') result = { ok: true, value: await readTrend() }
68
+ else if (method === 'hot') result = { ok: true, value: await readHot() }
69
+ else if (method === 'search') result = { ok: true, value: await readSearch(record.payload) }
68
70
  else throw new Error('Unknown detail method.')
69
71
  } catch (error) {
70
72
  const message = error instanceof Error ? error.message : String(error)
@@ -154,8 +156,75 @@ function writeTrendCache(value) {
154
156
  }
155
157
  }
156
158
 
159
+ const memo = new Map()
160
+
161
+ /** Keep one in-process result briefly; the on-disk archive is the real cache. */
162
+ async function withMemo(key, ttlMs, run) {
163
+ const hit = memo.get(key)
164
+ if (hit && Date.now() - hit.at < ttlMs) return hit.value
165
+ const value = await run()
166
+ memo.set(key, { at: Date.now(), value })
167
+ return value
168
+ }
169
+
157
170
  function readTrend() {
158
- return collectTrend(readTrendCache, writeTrendCache)
171
+ return withMemo('trend', 5 * 60 * 1000, () => collectTrend(readTrendCache, writeTrendCache))
172
+ }
173
+
174
+ function hotCacheFile() {
175
+ const profile = process.env.DSH_PROFILE_DIR || path.join(process.env.HOME || '', '.dsh/profiles/desktop')
176
+ return path.join(profile, '.plugin-panel', 'hot.json')
177
+ }
178
+
179
+ function readHotCache() {
180
+ try {
181
+ return JSON.parse(readFileSync(hotCacheFile(), 'utf8'))
182
+ } catch {
183
+ return null
184
+ }
185
+ }
186
+
187
+ function writeHotCache(value) {
188
+ try {
189
+ const file = hotCacheFile()
190
+ mkdirSync(path.dirname(file), { recursive: true })
191
+ writeFileSync(file, JSON.stringify(value))
192
+ } catch {
193
+ /* ranking still renders without a cache */
194
+ }
195
+ }
196
+
197
+ function readHot() {
198
+ return withMemo('hot', 5 * 60 * 1000, () => collectHot(readHotCache, writeHotCache, 7))
199
+ }
200
+
201
+ function searchCacheFile() {
202
+ const profile = process.env.DSH_PROFILE_DIR || path.join(process.env.HOME || '', '.dsh/profiles/desktop')
203
+ return path.join(profile, '.plugin-panel', 'search-index.json')
204
+ }
205
+
206
+ function readSearchCache() {
207
+ try {
208
+ return JSON.parse(readFileSync(searchCacheFile(), 'utf8'))
209
+ } catch {
210
+ return null
211
+ }
212
+ }
213
+
214
+ function writeSearchCache(value) {
215
+ try {
216
+ const file = searchCacheFile()
217
+ mkdirSync(path.dirname(file), { recursive: true })
218
+ writeFileSync(file, JSON.stringify(value))
219
+ } catch {
220
+ /* search still works without the cache */
221
+ }
222
+ }
223
+
224
+ function readSearch(payload) {
225
+ const input = payload && typeof payload === 'object' ? payload : {}
226
+ const query = typeof input.query === 'string' ? input.query : ''
227
+ return collectSearch(readSearchCache, writeSearchCache, query, 100)
159
228
  }
160
229
 
161
230
  function readFacts(payload) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-panel",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Plugin manager for the DeepSeek Harness right sidebar: list installed plugins, see where each one lives, pin favourites, and open a plugin folder in Finder.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,9 @@
17
17
  "catalog.test.mjs",
18
18
  "cordis.patch.yml",
19
19
  "locale/*.json",
20
- "docs",
20
+ "docs/DETAIL.md",
21
+ "docs/DETAIL.zh.md",
22
+ "docs/screenshot.png",
21
23
  "icon.svg",
22
24
  "README.md"
23
25
  ],
package/trend.js CHANGED
@@ -23,6 +23,16 @@ async function fetchJson(url, timeoutMs = FETCH_TIMEOUT_MS) {
23
23
  }
24
24
  }
25
25
 
26
+ /** The catalog's current published version, without downloading the manifest. */
27
+ async function latestTag() {
28
+ try {
29
+ const tags = await fetchJson(`${REGISTRY}/-/package/${CATALOG}/dist-tags`, 10000)
30
+ return tags && typeof tags.latest === 'string' ? tags.latest : ''
31
+ } catch {
32
+ return ''
33
+ }
34
+ }
35
+
26
36
  async function fetchIndex(version) {
27
37
  for (const base of CDN_BASES) {
28
38
  try {
@@ -62,10 +72,11 @@ function foldByDay(entries) {
62
72
  }
63
73
 
64
74
  async function collectTrend(readCache, writeCache) {
75
+ const latest = await latestTag()
65
76
  const cached = readCache()
66
77
  if (cached && Array.isArray(cached.entries) && cached.entries.length) {
67
- const age = Date.now() - (cached.fetchedAt || 0)
68
- if (age < 6 * 3600 * 1000) return shape(cached.entries, cached.title, cached.at)
78
+ // One probe per day is enough: the archive only grows when the catalog publishes.
79
+ if (!latest || cached.latest === latest) return shape(cached.entries, cached.title, cached.at)
69
80
  }
70
81
 
71
82
  const meta = await fetchJson(`${REGISTRY}/${CATALOG}`)
@@ -88,7 +99,7 @@ async function collectTrend(readCache, writeCache) {
88
99
 
89
100
  const entries = [...known.values()].sort((a, b) => String(a.builtAt).localeCompare(String(b.builtAt)))
90
101
  const at = new Date().toISOString()
91
- writeCache({ entries, fetchedAt: Date.now(), at, title: CATALOG })
102
+ writeCache({ entries, fetchedAt: Date.now(), at, title: CATALOG, latest })
92
103
  return shape(entries, CATALOG, at)
93
104
  }
94
105
 
@@ -104,4 +115,180 @@ function shape(entries, title, at) {
104
115
  }
105
116
  }
106
117
 
107
- export { collectTrend }
118
+ /** Repository icons from the catalog, keyed by the `github:owner/repo` prefix. */
119
+ async function readIcons(version) {
120
+ const file = await readCatalogFile(version, 'icons')
121
+ const body = file && file.body
122
+ if (!body || !body.icons || !body.sources) return new Map()
123
+ const index = new Map()
124
+ for (const [source, hash] of Object.entries(body.sources)) {
125
+ const data = body.icons[hash]
126
+ if (!data) continue
127
+ if (source.startsWith('github:')) {
128
+ const repo = source.slice(7).split('@')[0].toLowerCase()
129
+ if (!index.has(`repo:${repo}`)) index.set(`repo:${repo}`, data)
130
+ } else if (source.startsWith('npm:')) {
131
+ const name = source.slice(4).split('@')[0].toLowerCase()
132
+ if (name && !index.has(`npm:${name}`)) index.set(`npm:${name}`, data)
133
+ }
134
+ }
135
+ return index
136
+ }
137
+
138
+ /** `2026.1113.0` -> `2026-11-13`; the middle segment packs month and day. */
139
+ function versionDate(version) {
140
+ const [year, md] = String(version).split('.')
141
+ if (!year || !md) return ''
142
+ const month = md.length === 3 ? md.slice(0, 1) : md.slice(0, 2)
143
+ const day = md.length === 3 ? md.slice(1) : md.slice(2)
144
+ return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
145
+ }
146
+
147
+ function shiftDays(day, offset) {
148
+ const date = new Date(`${day}T00:00:00Z`)
149
+ date.setUTCDate(date.getUTCDate() + offset)
150
+ return date.toISOString().slice(0, 10)
151
+ }
152
+
153
+ /** Read one file out of a catalog version. */
154
+ async function readCatalogFile(version, name) {
155
+ const index = await fetchIndex(version)
156
+ if (!index || !index[name] || !index[name].url) return null
157
+ const url = `${CDN_BASES[0]}/${CATALOG}@${version}/v1/${index[name].url}`
158
+ try {
159
+ return { index, body: await fetchJson(url, 60000) }
160
+ } catch {
161
+ return { index, body: null }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Plugins whose repository gained the most GitHub stars over a window.
167
+ * Star counts are repository-level, so entries sharing a repository collapse
168
+ * into one row; the caller should label them as repository stars.
169
+ */
170
+ async function collectHot(readCache, writeCache, days = 7) {
171
+ const tag = await latestTag()
172
+ const cached = readCache()
173
+ if (cached && cached.value && Array.isArray(cached.value.top) && cached.value.top.length) {
174
+ if (!tag || cached.latest === tag) return cached.value
175
+ }
176
+
177
+ const meta = await fetchJson(`${REGISTRY}/${CATALOG}`)
178
+ const versions = Object.keys(meta.versions || {})
179
+ if (!versions.length) throw new Error('catalog has no versions')
180
+ const latest = versions[versions.length - 1]
181
+ const latestDay = versionDate(latest)
182
+ const cutoff = shiftDays(latestDay, -days)
183
+ const older = versions.filter((v) => versionDate(v) <= cutoff).pop()
184
+ if (!older) throw new Error('catalog has no snapshot old enough')
185
+
186
+ const [current, previous] = await Promise.all([
187
+ readCatalogFile(latest, 'stars'),
188
+ readCatalogFile(older, 'stars'),
189
+ ])
190
+ if (!current || !current.body || !previous || !previous.body) throw new Error('star snapshots unavailable')
191
+ const pluginsFile = await readCatalogFile(latest, 'plugins')
192
+
193
+ const byName = new Map()
194
+ for (const entry of (pluginsFile && pluginsFile.body && pluginsFile.body.plugins) || []) {
195
+ if (entry && entry.name) byName.set(entry.name, entry)
196
+ }
197
+
198
+ const repoOf = (key) => {
199
+ const entry = byName.get(key)
200
+ const repo = entry && entry.repository
201
+ return repo ? repo.replace(/^https?:\/\/github\.com\//i, '').replace(/\/$/, '').toLowerCase() : String(key).toLowerCase()
202
+ }
203
+
204
+ const newStars = current.body.stars || {}
205
+ const oldStars = previous.body.stars || {}
206
+ const rows = new Map()
207
+ for (const [key, value] of Object.entries(newStars)) {
208
+ const before = oldStars[key]
209
+ if (typeof before !== 'number') continue
210
+ const delta = value - before
211
+ if (delta <= 0) continue
212
+ const repo = repoOf(key)
213
+ const seen = rows.get(repo)
214
+ if (seen && seen.delta >= delta) continue
215
+ const entry = byName.get(key)
216
+ const catalog = (entry && entry.catalog) || {}
217
+ const summary = catalog.summary || {}
218
+ rows.set(repo, {
219
+ name: (entry && entry.name) || key,
220
+ repo,
221
+ delta,
222
+ stars: value,
223
+ category: catalog.category || '',
224
+ summary: summary.zh || summary.en || '',
225
+ })
226
+ }
227
+
228
+ const ranked = [...rows.values()].sort((a, b) => b.delta - a.delta).slice(0, 50)
229
+ const icons = await readIcons(latest).catch(() => new Map())
230
+ const top = ranked.map((row) => ({
231
+ ...row,
232
+ icon: icons.get(`repo:${row.repo}`) || icons.get(`npm:${String(row.name).toLowerCase()}`) || '',
233
+ }))
234
+ const value = { days, from: older, to: latest, fromDay: versionDate(older), toDay: latestDay, total: rows.size, top }
235
+ writeCache({ fetchedAt: Date.now(), value, latest })
236
+ return value
237
+ }
238
+
239
+ /**
240
+ * Compact plugin index for search: one row per package with only the fields
241
+ * the UI shows. Built once from the catalog and cached on disk.
242
+ */
243
+ async function buildCatalogIndex() {
244
+ const tag = await latestTag()
245
+ const versions = await fetchJson(`${REGISTRY}/${CATALOG}`).then((m) => Object.keys(m.versions || {}))
246
+ const latest = versions[versions.length - 1]
247
+ if (!latest) throw new Error('catalog has no versions')
248
+ const file = await readCatalogFile(latest, 'plugins')
249
+ const entries = (file && file.body && file.body.plugins) || []
250
+ const rows = []
251
+ for (const entry of entries) {
252
+ if (!entry || !entry.name) continue
253
+ const catalog = entry.catalog || {}
254
+ const summary = catalog.summary || {}
255
+ rows.push({
256
+ name: entry.name,
257
+ version: entry.version || '',
258
+ category: catalog.category || '',
259
+ summary: summary.zh || summary.en || '',
260
+ repo: String(entry.repository || '').replace(/^https?:\/\/github\.com\//i, '').replace(/\/$/, ''),
261
+ })
262
+ }
263
+ return { latest: tag || latest, rows }
264
+ }
265
+
266
+ async function collectSearch(readCache, writeCache, query, limit = 100) {
267
+ const needle = String(query || '').trim().toLowerCase()
268
+ if (!needle) return { query: '', items: [], total: 0 }
269
+
270
+ let index = readCache()
271
+ if (!index || !Array.isArray(index.rows) || !index.rows.length) {
272
+ index = await buildCatalogIndex()
273
+ writeCache(index)
274
+ } else {
275
+ const tag = await latestTag()
276
+ if (tag && index.latest !== tag) {
277
+ index = await buildCatalogIndex()
278
+ writeCache(index)
279
+ }
280
+ }
281
+
282
+ const items = []
283
+ for (const row of index.rows) {
284
+ if (row.name.toLowerCase().includes(needle)
285
+ || row.summary.toLowerCase().includes(needle)
286
+ || row.category.toLowerCase().includes(needle)) {
287
+ items.push(row)
288
+ if (items.length >= limit) break
289
+ }
290
+ }
291
+ return { query, items, total: index.rows.length }
292
+ }
293
+
294
+ export { collectTrend, collectHot, collectSearch }
Binary file
Binary file