dsh-plugin 0.1.1 → 1.0.0

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 (64) hide show
  1. package/README.en.md +49 -26
  2. package/README.md +49 -26
  3. package/client/client.js +2148 -229
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +396 -0
  6. package/lib/index.js +17 -8
  7. package/lib/services/install-types.js +11 -0
  8. package/lib/services/install.js +4 -0
  9. package/lib/services/installed-versions.js +51 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/pending-restart.js +34 -0
  12. package/lib/services/preflight.js +94 -0
  13. package/lib/services/profile.js +88 -0
  14. package/lib/services/progress.js +50 -0
  15. package/lib/services/task-queue.js +417 -0
  16. package/lib/types/http/routes.d.ts +23 -0
  17. package/lib/types/index.d.ts +27 -0
  18. package/lib/types/services/install-types.d.ts +63 -0
  19. package/lib/types/services/install.d.ts +18 -0
  20. package/lib/types/services/installed-versions.d.ts +14 -0
  21. package/lib/types/services/loader.d.ts +43 -0
  22. package/lib/types/services/pending-restart.d.ts +13 -0
  23. package/lib/types/services/preflight.d.ts +6 -0
  24. package/lib/types/services/profile.d.ts +24 -0
  25. package/lib/types/services/progress.d.ts +17 -0
  26. package/lib/types/services/task-queue.d.ts +76 -0
  27. package/package.json +27 -6
  28. package/src/client/components/CatalogControls.tsx +104 -0
  29. package/src/client/components/CatalogHeader.tsx +78 -0
  30. package/src/client/components/CatalogList.tsx +75 -0
  31. package/src/client/components/CategoryTabs.tsx +35 -0
  32. package/src/client/components/Dropdown.tsx +80 -0
  33. package/src/client/components/NotificationsModal.tsx +230 -0
  34. package/src/client/components/PluginCard.tsx +104 -0
  35. package/src/client/components/PluginHubSection.tsx +339 -0
  36. package/src/client/components/ProgressView.tsx +23 -0
  37. package/src/client/components/icons.tsx +125 -0
  38. package/src/client/components/modals.tsx +365 -0
  39. package/src/client/hooks/useCatalog.ts +192 -0
  40. package/src/client/hooks/useTaskQueue.ts +405 -0
  41. package/src/client/index.tsx +8 -422
  42. package/src/client/lib/catalog.ts +204 -0
  43. package/src/client/lib/env.ts +16 -0
  44. package/src/client/lib/failures.ts +153 -0
  45. package/src/client/lib/format.ts +22 -0
  46. package/src/client/locales.ts +186 -18
  47. package/src/client/styles/Dropdown.module.css +123 -0
  48. package/src/client/styles/Header.module.css +432 -0
  49. package/src/client/styles/List.module.css +324 -0
  50. package/src/client/styles/Modal.module.css +965 -0
  51. package/src/client/styles/tokens.module.css +86 -0
  52. package/src/client/types.ts +80 -0
  53. package/src/server/http/routes.ts +397 -0
  54. package/src/server/index.ts +35 -0
  55. package/src/server/services/install-types.ts +63 -0
  56. package/src/server/services/install.ts +18 -0
  57. package/src/server/services/installed-versions.ts +65 -0
  58. package/src/server/services/loader.ts +110 -0
  59. package/src/server/services/pending-restart.ts +36 -0
  60. package/src/server/services/preflight.ts +97 -0
  61. package/src/server/services/profile.ts +89 -0
  62. package/src/server/services/progress.ts +48 -0
  63. package/src/server/services/task-queue.ts +450 -0
  64. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,27 @@
1
+ /**
2
+ * dsh-plugin host entry: mounts the Plugin Hub HTTP routes once the host
3
+ * web server service is available. The browser bundle (src/client) talks to
4
+ * these same-origin routes to perform real installs from inside the app.
5
+ */
6
+ import { type LoaderHandle } from './services/install.ts';
7
+ import { type WebServerService } from './http/routes.ts';
8
+ export declare const name = "dsh-plugin";
9
+ export interface Config {
10
+ /** DSH profile that owns plugin mutations. Defaults to the booted profile. */
11
+ profile?: string;
12
+ }
13
+ /** The subset of the cordis host context this plugin touches. */
14
+ interface HostContext {
15
+ webServer: WebServerService;
16
+ /** 运行中 loader:卸载成功后主动移除条目、立即生效(宿主未提供时卸载仍需重启) */
17
+ loader?: LoaderHandle;
18
+ inject(services: string[], callback: (host: HostContext) => void): void;
19
+ effect(callback: () => void, label?: string): void;
20
+ }
21
+ /**
22
+ * Mount the Plugin Hub routes after the web server service becomes available.
23
+ * @param ctx - cordis host context.
24
+ * @param config - optional profile override.
25
+ */
26
+ export declare function apply(ctx: HostContext, config?: Config): void;
27
+ export {};
@@ -0,0 +1,63 @@
1
+ /** 安装/卸载运行时的公共类型与常量(无依赖,供各服务模块复用)。 */
2
+ /** 一个 pnpm 安装子进程的结果快照。 */
3
+ export interface InstallResult {
4
+ exitCode: number | null;
5
+ timedOut: boolean;
6
+ error: string | null;
7
+ stdout: string;
8
+ stderr: string;
9
+ }
10
+ export interface InstallTask {
11
+ id: number;
12
+ /** 操作目标:`github:<owner>/<repo>`(安装)或 npm 包名(卸载),冲突/恢复时需要展示给用户 */
13
+ target: string;
14
+ action: 'add' | 'remove';
15
+ /** 队列语义:pending 排队中 / running 执行中 / done / failed / cancelled(用户取消) */
16
+ status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
17
+ timedOut: boolean;
18
+ exitCode: number | null;
19
+ /** 0-100 估算进度:解析 pnpm 的 `Progress: resolved…` 输出,阶段行兜底 */
20
+ progress: number;
21
+ /** Newest output lines first (consumer shows the tail). */
22
+ lines: string[];
23
+ }
24
+ /** 子进程启动参数(复用宿主 dsh 入口,或回退到 PATH 上的 `dsh`)。 */
25
+ export interface Invocation {
26
+ file: string;
27
+ prefixArgs: string[];
28
+ cwd: string;
29
+ useShell: boolean;
30
+ }
31
+ /** 排队中的变更任务及其启动参数。 */
32
+ export interface QueueItem {
33
+ task: InstallTask;
34
+ options: {
35
+ action: 'add' | 'remove';
36
+ profile: string;
37
+ target: string;
38
+ timeoutMs?: number;
39
+ env?: NodeJS.ProcessEnv;
40
+ };
41
+ }
42
+ /**
43
+ * 安装成功但宿主尚未重启的插件(待重启后生效)。纯内存态:宿主进程一重启
44
+ * 它自然清空,正好等于「重启后提醒消失」的语义,无需任何重启探测。
45
+ */
46
+ export interface PendingRestart {
47
+ /** `github:<owner>/<repo>` 安装目标(与任务一致;卸载时为展示用的 owner/repo) */
48
+ target: string;
49
+ /** 待重启语义:install 装完等挂载生效 / uninstall 卸完等清理 loader 残留 */
50
+ kind: 'install' | 'uninstall';
51
+ /** 任务完成时刻(epoch ms) */
52
+ at: number;
53
+ }
54
+ /** Grammar of an accepted `github:<owner>/<repo>` install target. */
55
+ export declare const REPO_RE: RegExp;
56
+ /** Grammar of an npm package name (used for uninstall targets). */
57
+ export declare const PACKAGE_RE: RegExp;
58
+ /** 捕获输出上限:stdout/stderr 各保留尾部 N 字节。 */
59
+ export declare const CAPTURE_LIMIT_BYTES: number;
60
+ /** Max output lines kept per task (newest wins). */
61
+ export declare const MAX_TASK_LINES = 200;
62
+ /** Max tracked tasks; oldest finished tasks are dropped first. */
63
+ export declare const MAX_TASKS = 50;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Install runner 的统一出口:执行真实插件变更(spawn 官方 dsh CLI 子进程)。
3
+ * 实现按职责拆分在同目录子模块,本文件只做公共 API 的 re-export,保持外部
4
+ * import 面稳定(routes.ts / index.ts 仍从 `services/install.ts` 引入)。
5
+ *
6
+ * 模块划分:
7
+ * - install-types.ts 公共类型与常量
8
+ * - pending-restart.ts 待重启列表内存态管理
9
+ * - loader.ts 运行中 loader 的读取与卸载即时停用
10
+ * - profile.ts 宿主 profile / 目标校验 / allowBuilds 写入
11
+ * - task-queue.ts 任务注册表 + FIFO 队列 worker + 子进程生命周期
12
+ */
13
+ export type { InstallResult, InstallTask, Invocation, PendingRestart, QueueItem } from './install-types.ts';
14
+ export type { LoaderHandle } from './loader.ts';
15
+ export { addPendingRestart, clearPendingRestart, listPendingRestarts } from './pending-restart.ts';
16
+ export { dumpLoaderEntries, removeLoadedEntry } from './loader.ts';
17
+ export { addAllowBuildsKey, githubTarget, parseAllowBuildsKey, readProfileArg, validPackageName } from './profile.ts';
18
+ export { activeTask, cancelTask, getTask, hasQueuedTarget, hasRunningTask, runPluginMutation, startPluginMutation } from './task-queue.ts';
@@ -0,0 +1,14 @@
1
+ export interface InstalledVersionRecord {
2
+ /** 安装时的目录最新 release 版本(无 release 的仓库为空串) */
3
+ version: string;
4
+ /** 安装时的仓库最近更新时间(ISO,GitHub pushed_at);无 release 仓库的更新依据 */
5
+ updatedAt: string;
6
+ installedAt: string;
7
+ }
8
+ export type InstalledVersionTable = Record<string, InstalledVersionRecord>;
9
+ export declare function versionsFilePath(profile: string): string;
10
+ export declare function readInstalledVersions(profile: string): InstalledVersionTable;
11
+ /** Record the catalog signals (version + repo updated time) present at install time for one repo. */
12
+ export declare function recordInstalledVersion(profile: string, repo: string, version: string, updatedAt: string): void;
13
+ /** Forget the recorded version for one repo (called on uninstall). */
14
+ export declare function removeInstalledVersion(profile: string, repo: string): void;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 运行中 loader 的读取与停用:卸载成功后对匹配条目做 live-disable,
3
+ * 使卸载立即生效(刷新页面不再加载已卸载的 client bundle,也无需重启)。
4
+ */
5
+ /**
6
+ * 运行中 loader 的最小接口:卸载成功后即时移除条目用。
7
+ * 对应官方 `ctx.loader`(cordis-plugin-loader 的 Loader 服务)的读取面与移除面。
8
+ * 移除方式:对匹配条目 live-disable(`entry.update({ disabled: true })`),
9
+ * 而不是 `loader.remove(id)` —— disable 走 Entry.update 的 disabled 分支,不触发
10
+ * tree.write()(不会把运行态条目烘焙回 cordis.yml),也不依赖嵌套子树的 id 解析。
11
+ */
12
+ export interface LoaderHandle {
13
+ /** 遍历当前插件树的全部条目(含嵌套子树)。 */
14
+ entries(): Iterable<{
15
+ id: string;
16
+ options: {
17
+ id?: string;
18
+ name?: string;
19
+ disabled?: boolean | null;
20
+ };
21
+ /** live-disable 一个条目(官方 Entry.update,force=true 覆盖初始化竞态)。 */
22
+ update(options: {
23
+ disabled: boolean | null;
24
+ }, create?: boolean, force?: boolean): Promise<void>;
25
+ }>;
26
+ /** 停止并移除一个条目(官方 Loader API:resolve → EntryGroup.remove → tree.write)。 */
27
+ remove(id: string): Promise<void>;
28
+ }
29
+ /** 运行中 loader 的全部条目(id + options.name),诊断用。 */
30
+ export declare function dumpLoaderEntries(loader: LoaderHandle | undefined): Array<{
31
+ id: string;
32
+ name?: string;
33
+ }>;
34
+ /**
35
+ * 卸载成功后从运行中 loader 停用指定包的条目,使卸载立即生效(刷新页面不再
36
+ * 加载已卸载的 client bundle,也无需重启)。做法:按 name 扫描
37
+ * `loader.entries()`(含嵌套子树),对每个匹配条目
38
+ * `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
39
+ * client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
40
+ * 返回 true = 无需重启(条目已停用,或本就没加载过);false = 未能停用,
41
+ * 需登记「待重启清理」兜底。宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
42
+ */
43
+ export declare function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<boolean>;
@@ -0,0 +1,13 @@
1
+ /** 待重启列表的内存态管理(宿主进程一重启自然清空)。 */
2
+ import type { PendingRestart } from './install-types.ts';
3
+ /**
4
+ * 登记一条待重启记录(同一目标去重)。
5
+ * - 卸载:目标刚装完未重启(已有待重启记录)→ 宿主从未加载过它,卸完磁盘已干净、
6
+ * loader 无残留,无需再提醒重启,直接移除该条;否则(此前已被宿主加载过)登记「卸载后待重启清理」。
7
+ * - 安装:已有记录则刷新为安装语义(如卸载后未重启又重装),否则新登记。
8
+ */
9
+ export declare function addPendingRestart(target: string, kind: 'install' | 'uninstall'): void;
10
+ /** 当前待重启列表快照(副本,消费方不会污染内部数组)。 */
11
+ export declare function listPendingRestarts(): PendingRestart[];
12
+ /** 清除某目标的待重启记录(卸载已即时生效、无需重启时调用)。 */
13
+ export declare function clearPendingRestart(target: string): void;
@@ -0,0 +1,6 @@
1
+ /** 预检结果:ok=false 表示分发改入口文件缺失,missing 为该文件在包内的相对路径。 */
2
+ export interface PreflightResult {
3
+ ok: boolean;
4
+ missing: string | null;
5
+ }
6
+ export declare function preflightTarget(target: string): Promise<PreflightResult>;
@@ -0,0 +1,24 @@
1
+ /** Resolve the active profile from the booted CLI args, falling back to `web`. */
2
+ export declare function readProfileArg(fallback?: string): string;
3
+ /** Resolve a profile directory (`DSH_HOME` or `~/.dsh`). */
4
+ export declare function profileDirectory(profile: string): string;
5
+ /** Build a safe `github:<owner>/<repo>` target, or null when the repo is unsafe. */
6
+ export declare function githubTarget(repo: string): string | null;
7
+ /** Validate an npm package name (uninstall target grammar). */
8
+ export declare function validPackageName(name: string): boolean;
9
+ /**
10
+ * The exact `allowBuilds` key pnpm printed in its
11
+ * `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` hint. For `github:` installs pnpm
12
+ * matches against `name@<resolved fetch URL>` — the URL it actually fetches,
13
+ * e.g. `https://codeload.github.com/<owner>/<repo>/tar.gz/<commit>` — not the
14
+ * `git+ssh://`/branch spec. A bare name or a `git+` spec does not match, so
15
+ * the only reliable key is the one pnpm prints verbatim. Null when absent.
16
+ */
17
+ export declare function parseAllowBuildsKey(output: string): string | null;
18
+ /**
19
+ * Add one `allowBuilds` entry to the profile's pnpm-workspace.yaml, merging
20
+ * with existing entries and leaving the rest of the yaml intact. Broken
21
+ * placeholder entries (pnpm's failed-install bug writes a literal
22
+ * `set this to true or false` value) are dropped on rewrite.
23
+ */
24
+ export declare function addAllowBuildsKey(profile: string, key: string): void;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Pure progress-estimation helpers shared by the install runner.
3
+ *
4
+ * The dsh CLI wraps pnpm, which refreshes its `Progress: …` line in place
5
+ * with carriage returns, so raw output arrives as a stream of partial
6
+ * fragments. These helpers normalize one fragment into a clean terminal
7
+ * line and map it onto a 0-100 estimate; keeping them free of I/O makes the
8
+ * estimation logic directly unit-testable (see tests/progress.test.ts).
9
+ */
10
+ /** Strip the `[out]` / `[err]` source prefix and ANSI escape sequences. */
11
+ export declare function cleanLine(raw: string): string;
12
+ /**
13
+ * Estimate 0-100 progress from one CLI output line. pnpm emits
14
+ * `Progress: resolved N, reused X, downloaded Y, added Z` during the fetch
15
+ * phase; resolution/install phase lines bump the estimate towards done.
16
+ */
17
+ export declare function estimateProgress(line: string): number;
@@ -0,0 +1,76 @@
1
+ import type { InstallResult, InstallTask } from './install-types.ts';
2
+ import type { LoaderHandle } from './loader.ts';
3
+ /** Snapshot of a task (keeps the live object untouched by consumers). */
4
+ export declare function getTask(id: number): InstallTask | undefined;
5
+ export interface ActiveTaskInfo {
6
+ id: number;
7
+ target: string;
8
+ action: 'add' | 'remove';
9
+ status: 'pending' | 'running';
10
+ progress: number;
11
+ lines: string[];
12
+ }
13
+ /** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
14
+ export declare function activeTask(): ActiveTaskInfo[];
15
+ /**
16
+ * True while a mutation child process is still alive. The concurrent slot is
17
+ * keyed off the live child set rather than task.status: cancelling a running
18
+ * task flips its status immediately, but the child may still be shutting down
19
+ * (the close event lands later) — the slot must stay held until then, otherwise
20
+ * a new task would start while the old pnpm still runs in the same profile dir.
21
+ */
22
+ export declare function hasRunningTask(): boolean;
23
+ /** Whether the target already has a non-terminal task in the queue (install/uninstall dedupe). */
24
+ export declare function hasQueuedTarget(target: string): boolean;
25
+ /**
26
+ * Cancel a queued or running task.
27
+ * - pending: removed from the queue immediately (never spawned).
28
+ * - running: kills the child process; the queue worker then picks the next one.
29
+ * Returns false when the task is unknown or already finished.
30
+ */
31
+ export declare function cancelTask(id: number): boolean;
32
+ /**
33
+ * Enqueue a plugin mutation and return its task. Tasks run strictly serially:
34
+ * the queue worker starts the next one only after the previous finishes.
35
+ * Progress is visible through `getTask(id)` / `activeTask()` until done.
36
+ */
37
+ export declare function startPluginMutation(options: {
38
+ action: 'add' | 'remove';
39
+ profile: string;
40
+ target: string;
41
+ timeoutMs?: number;
42
+ env?: NodeJS.ProcessEnv;
43
+ /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
44
+ displayTarget?: string;
45
+ /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
46
+ uninstallLoader?: LoaderHandle;
47
+ }): InstallTask;
48
+ /**
49
+ * 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
50
+ * git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有报错,但宿主重启加载插件树时会
51
+ * ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在登记「待重启」前就把这类残缺包拦下。
52
+ * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失的入口路径。
53
+ */
54
+ export declare function verifyInstalledEntry(profile: string, target: string): {
55
+ name: string | null;
56
+ missing: string | null;
57
+ };
58
+ /**
59
+ * Run a plugin mutation with one recovery path: when `add` fails because a
60
+ * git-hosted package's `prepare` script is blocked by pnpm's allowBuilds
61
+ * gate (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`), read the exact allowlist
62
+ * key pnpm printed, write it into the profile's pnpm-workspace.yaml, and
63
+ * retry once. Other failures fall through to the single-spawn result.
64
+ */
65
+ export declare function runPluginMutation(options: {
66
+ action: 'add' | 'remove';
67
+ profile: string;
68
+ target: string;
69
+ timeoutMs?: number;
70
+ env?: NodeJS.ProcessEnv;
71
+ task?: InstallTask;
72
+ /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
73
+ displayTarget?: string;
74
+ /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
75
+ uninstallLoader?: LoaderHandle;
76
+ }): Promise<InstallResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "0.1.1",
3
+ "version": "1.0.0",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 4000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):4000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,6 +11,17 @@
11
11
  "url": "git+https://github.com/dshplugin/dsh-plugin-hub.git"
12
12
  },
13
13
  "homepage": "https://dsh-plugin.org",
14
+ "bugs": {
15
+ "url": "https://github.com/dshplugin/dsh-plugin-hub/issues"
16
+ },
17
+ "author": "DSH-Plugin Hub contributors",
18
+ "funding": {
19
+ "type": "custom",
20
+ "url": "https://dsh-plugin.org"
21
+ },
22
+ "engines": {
23
+ "node": ">=22.6"
24
+ },
14
25
  "keywords": [
15
26
  "deepseek",
16
27
  "deepseek-harness",
@@ -29,11 +40,21 @@
29
40
  "open-source"
30
41
  ],
31
42
  "scripts": {
32
- "build": "npm run build:client",
33
- "build:client": "tsdown && node scripts/normalize-client-banner.mjs",
34
- "readme:stats": "node scripts/sync-readme-stats.mjs",
35
- "typecheck": "tsc -p tsconfig.client.json --noEmit",
36
- "check": "npm run typecheck && npm run build",
43
+ "build": "npm run build:server && npm run build:client",
44
+ "build:server": "rm -rf lib && tsc -p tsconfig.server.json",
45
+ "build:client": "tsdown && node scripts/tools/normalize-client-banner.mjs",
46
+ "readme:stats": "node scripts/tools/sync-readme-stats.mjs",
47
+ "reload": "bash scripts/run/reload-dsh.sh",
48
+ "dev": "bash scripts/run/dev-dsh.sh",
49
+ "test": "node --test",
50
+ "test:watch": "node --test --watch",
51
+ "typecheck": "npm run typecheck:client && npm run typecheck:server && npm run typecheck:test",
52
+ "typecheck:client": "tsc -p tsconfig.client.json --noEmit",
53
+ "typecheck:server": "tsc -p tsconfig.server.json --noEmit",
54
+ "typecheck:test": "tsc -p tsconfig.test.json --noEmit",
55
+ "check": "npm run typecheck && npm run test && npm run build",
56
+ "verify:release": "node scripts/tools/verify-release.mjs",
57
+ "prepublishOnly": "npm run verify:release",
37
58
  "prepack": "npm run build"
38
59
  },
39
60
  "dependencies": {},
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Toolbar above the list: search input + sort dropdown + installed / not
3
+ * installed filter buttons + the notifications entry. All buttons share the
4
+ * same size (24px high, like the notification entry button).
5
+ */
6
+ import { createElement as h, Fragment } from 'react'
7
+ import type { FormEvent } from 'react'
8
+ import styles from '../styles/Header.module.css'
9
+ import type { Translate } from '../types.ts'
10
+ import { SORTS } from '../lib/catalog.ts'
11
+ import type { SortKey } from '../lib/catalog.ts'
12
+ import { Dropdown } from './Dropdown.tsx'
13
+
14
+ export function CatalogControls({ query, setQuery, sort, setSort, installedFilter, setInstalledFilter, installedCount, notInstalledCount, t, resultText, noticeCount, onOpenNotifications }: {
15
+ query: string
16
+ setQuery: (value: string) => void
17
+ sort: SortKey
18
+ setSort: (value: SortKey) => void
19
+ installedFilter: 'all' | 'installed' | 'notInstalled'
20
+ setInstalledFilter: (value: 'all' | 'installed' | 'notInstalled') => void
21
+ installedCount: number
22
+ notInstalledCount: number
23
+ t: Translate
24
+ resultText: string | null
25
+ noticeCount: number
26
+ onOpenNotifications: () => void
27
+ }) {
28
+ return h(Fragment, null,
29
+ h('div', { className: styles.searchRow },
30
+ h('input', {
31
+ className: styles.search,
32
+ type: 'search',
33
+ placeholder: t('search'),
34
+ value: query,
35
+ spellCheck: false,
36
+ onInput: (e: FormEvent<HTMLInputElement>) =>
37
+ setQuery((e.target as HTMLInputElement).value),
38
+ }),
39
+ ),
40
+ h('div', { className: styles.controls },
41
+ // 筛选结果数:位于排序下拉之前,选中分类后提示「筛选出 N 条插件」;
42
+ // 数字单独等宽显示并预留 5 位宽度,数字变化时整行不左右跳动
43
+ resultText ? h('span', { className: styles.filterResults },
44
+ ...resultText.split(/(\d+)/).map((part, i) =>
45
+ /^\d+$/.test(part)
46
+ ? h('span', { key: i, className: styles.resultCount }, part)
47
+ : part)) : null,
48
+ // 全部 / 已安装 / 未安装:互斥单选组,点击直接切换选中态,「全部」即默认恢复项;
49
+ // 已安装/未安装计数跟随当前分类,为 0 时置灰不可点
50
+ h('button', {
51
+ className: installedFilter === 'all'
52
+ ? styles.installedBtnActive
53
+ : styles.installedBtn,
54
+ onClick: () => setInstalledFilter('all'),
55
+ title: t('filterAllHint'),
56
+ 'aria-pressed': installedFilter === 'all',
57
+ }, t('all')),
58
+ // 排序下拉框:紧跟「全部」之后,按需切换排序方式
59
+ Dropdown<SortKey>({
60
+ value: sort,
61
+ options: SORTS.map((key) => ({ value: key, label: t(key) })),
62
+ onChange: setSort,
63
+ }),
64
+ h('button', {
65
+ className: installedFilter === 'installed'
66
+ ? styles.installedBtnActive
67
+ : installedCount === 0
68
+ ? styles.installedBtnDisabled
69
+ : styles.installedBtn,
70
+ onClick: () => setInstalledFilter('installed'),
71
+ disabled: installedCount === 0,
72
+ title: installedCount === 0 ? t('filterInstalledNone') : t('filterInstalledHint'),
73
+ 'aria-pressed': installedFilter === 'installed',
74
+ },
75
+ t('installed'),
76
+ h('span', {
77
+ className: installedFilter === 'installed' ? styles.segCountActive : styles.segCount,
78
+ }, installedCount)),
79
+ h('button', {
80
+ className: installedFilter === 'notInstalled'
81
+ ? styles.installedBtnActive
82
+ : notInstalledCount === 0
83
+ ? styles.installedBtnDisabled
84
+ : styles.installedBtn,
85
+ onClick: () => setInstalledFilter('notInstalled'),
86
+ disabled: notInstalledCount === 0,
87
+ title: notInstalledCount === 0 ? t('filterNotInstalledNone') : t('filterNotInstalledHint'),
88
+ 'aria-pressed': installedFilter === 'notInstalled',
89
+ },
90
+ t('notInstalled'),
91
+ h('span', {
92
+ className: installedFilter === 'notInstalled' ? styles.segCountActive : styles.segCount,
93
+ }, notInstalledCount)),
94
+ // 通知中心入口:整行控件最右侧、贴右对齐;有任何通知时右上角悬浮红圈白字计数;
95
+ // 按钮文字用短文案(notificationsBtn),避免英文长标题挤占工具栏空间被截断
96
+ h('button', {
97
+ className: styles.failBtn,
98
+ onClick: onOpenNotifications,
99
+ title: t('notificationsHint'),
100
+ 'aria-label': t('notificationsHint'),
101
+ }, t('notificationsBtn'), noticeCount > 0 ? h('span', { className: styles.failBadge }, noticeCount) : null),
102
+ ),
103
+ )
104
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Section header: brand title row (H1 + open-site button) + tagline, followed
3
+ * by the purple ad banner that promotes the catalog stats.
4
+ */
5
+ import { createElement as h, Fragment } from 'react'
6
+ import styles from '../styles/Header.module.css'
7
+ import type { Translate } from '../types.ts'
8
+ import { GITHUB_URL, PLUGIN_VERSION, SITE_URL } from '../lib/catalog.ts'
9
+ import { GitHubIcon, LogoIcon } from './icons.tsx'
10
+
11
+ export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggleLang }: {
12
+ t: Translate
13
+ langPath: string
14
+ statsTotal: number
15
+ statsVerified: number
16
+ onToggleLang: () => void
17
+ }) {
18
+ return h(Fragment, null,
19
+ h('div', { className: styles.header },
20
+ // 第一行:logo + 标题(官网链接)与 GitHub 源码图标同行,GitHub 顶到标题行末尾
21
+ h('div', { className: styles.headerTitleRow },
22
+ h('a', {
23
+ className: styles.brandTitle,
24
+ href: `${SITE_URL}${langPath}`,
25
+ target: '_blank',
26
+ rel: 'noopener noreferrer',
27
+ title: t('openHint'),
28
+ 'aria-label': t('openHint'),
29
+ },
30
+ h(LogoIcon),
31
+ h('h1', { className: styles.title },
32
+ t('title'),
33
+ h('span', { className: styles.version }, ` v${PLUGIN_VERSION}`),
34
+ ),
35
+ ),
36
+ // 右侧控件组:语言切换 + GitHub 源码图标(独立链接,brandTitle 是官网 <a> 不能嵌套),
37
+ // 一起 space-between 顶到标题行最右
38
+ h('div', { className: styles.headerRight },
39
+ // 语言切换:按钮文字始终显示「要切到的语言」,点击即切,无需图标
40
+ h('button', {
41
+ className: styles.langBtn,
42
+ type: 'button',
43
+ onClick: onToggleLang,
44
+ title: t('toggleLangHint'),
45
+ 'aria-label': t('toggleLangHint'),
46
+ }, langPath === 'zh/' ? 'EN' : '中文'),
47
+ h('a', {
48
+ className: styles.githubLink,
49
+ href: GITHUB_URL,
50
+ target: '_blank',
51
+ rel: 'noopener noreferrer',
52
+ title: t('githubHint'),
53
+ 'aria-label': t('githubHint'),
54
+ }, h(GitHubIcon)),
55
+ ),
56
+ ),
57
+ // 第二行:副标题仍是官网链接,点击跳 dsh-plugin.org
58
+ h('a', {
59
+ className: styles.taglineLink,
60
+ href: `${SITE_URL}${langPath}`,
61
+ target: '_blank',
62
+ rel: 'noopener noreferrer',
63
+ title: t('openHint'),
64
+ },
65
+ h('div', { className: styles.tagline }, t('tagline', { total: statsTotal, verified: statsVerified })),
66
+ ),
67
+ ),
68
+ h('a', {
69
+ className: styles.adBanner,
70
+ href: `${SITE_URL}${langPath}`,
71
+ target: '_blank',
72
+ rel: 'noopener noreferrer',
73
+ },
74
+ h('span', { className: styles.adBadge }, t('adBadge')),
75
+ h('span', { className: styles.adText }, t('ad', { total: statsTotal, verified: statsVerified })),
76
+ h('span', { className: styles.adArrow }, '\u2197')),
77
+ )
78
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Catalog list: the scrollable container with the loading / failed / empty
3
+ * states, the plugin cards, and the count footer. Resets scroll position when
4
+ * the category or install-status filter changes so the replaced content is
5
+ * not mistaken for a no-op update.
6
+ */
7
+ import { createElement as h, useEffect, useRef } from 'react'
8
+ import styles from '../styles/List.module.css'
9
+ import type { HubPlugin, LocaleId, Translate } from '../types.ts'
10
+ import { SITE_URL } from '../lib/catalog.ts'
11
+ import { PluginCard } from './PluginCard.tsx'
12
+
13
+ export function CatalogList({ plugins, failed, visible, total, t, langPath, reload, category, installedFilter, copied, installedName, installedVersion, hasUpdate, langKey, onInstall, onUninstall }: {
14
+ plugins: HubPlugin[] | null
15
+ failed: boolean
16
+ visible: HubPlugin[]
17
+ total: number
18
+ t: Translate
19
+ langPath: string
20
+ reload: () => void
21
+ category: string
22
+ installedFilter: 'all' | 'installed' | 'notInstalled'
23
+ copied: string | null
24
+ installedName: (p: HubPlugin) => string | null
25
+ installedVersion: (p: HubPlugin) => string | null
26
+ hasUpdate: (p: HubPlugin) => boolean
27
+ langKey: LocaleId
28
+ onInstall: (p: HubPlugin, opts?: { update?: boolean }) => void
29
+ onUninstall: (p: HubPlugin) => void
30
+ }) {
31
+ /** 列表滚动容器:分类/搜索切换后列表内容替换但 scrollTop 保留,会让用户误以为列表没更新,需重置回顶部 */
32
+ const listRef = useRef<HTMLDivElement | null>(null)
33
+
34
+ useEffect(() => {
35
+ listRef.current?.scrollTo({ top: 0 })
36
+ }, [category, installedFilter])
37
+
38
+ return h('div', { className: styles.body },
39
+ h('div', { ref: listRef, className: styles.list },
40
+ plugins === null && !failed && h('div', { className: styles.state }, t('loading')),
41
+ failed && h('div', { className: styles.state },
42
+ h('div', { className: styles.stateTitle }, t('failed')),
43
+ h('div', { className: styles.stateDesc }, t('failedDesc')),
44
+ h('button', { className: styles.retryBtn, onClick: () => reload() }, t('retry')),
45
+ ),
46
+ plugins !== null && !failed && visible.length === 0 && h('div', { className: styles.state },
47
+ h('div', { className: styles.stateTitle }, t('noResult')),
48
+ h('div', { className: styles.stateDesc }, t('noResultDesc')),
49
+ ),
50
+ plugins !== null && !failed && visible.map((p) => h(PluginCard, {
51
+ // 唯一 key:数据重构后同一 slug 可能对应多个作者仓库,仅用 slug 会撞 key,
52
+ // 导致切换分类时 React 复用旧 DOM、列表不刷新。
53
+ key: p.ownerSlug ? `${p.ownerSlug}/${p.slug}` : p.slug,
54
+ plugin: p,
55
+ copied,
56
+ installedName,
57
+ installedVersion,
58
+ hasUpdate,
59
+ t,
60
+ langKey,
61
+ langPath,
62
+ onInstall,
63
+ onUninstall,
64
+ })),
65
+ ),
66
+ plugins !== null && !failed && h('div', { className: styles.footer },
67
+ h('a', {
68
+ className: styles.footLink,
69
+ href: `${SITE_URL}${langPath}`,
70
+ target: '_blank',
71
+ rel: 'noopener noreferrer',
72
+ }, t('browseAll', { n: total })),
73
+ ),
74
+ )
75
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Category tabs: an "all" chip followed by one chip per catalog category.
3
+ * The "all" chip carries the total plugin count; the per-category chips
4
+ * carry no counts so their widths stay uniform.
5
+ */
6
+ import { createElement as h } from 'react'
7
+ import styles from '../styles/Header.module.css'
8
+ import type { LocaleId } from '../types.ts'
9
+ import { CATEGORY_ORDER, CATEGORY_SHORT_LABELS, categoryLabel } from '../lib/catalog.ts'
10
+
11
+ export function CategoryTabs({ category, setCategory, allLabel, totalCount, langKey }: {
12
+ category: string
13
+ setCategory: (value: string) => void
14
+ allLabel: string
15
+ totalCount: number
16
+ langKey: LocaleId
17
+ }) {
18
+ return h('div', { className: styles.tabs },
19
+ // 第一个按钮固定为「全部」:展示全部分类,直接显示插件总数;
20
+ // 其余分类按钮不带数量,保持排版整齐
21
+ h('button', {
22
+ key: 'all',
23
+ className: category === 'all' ? styles.tabActive : styles.tab,
24
+ onClick: () => setCategory('all'),
25
+ },
26
+ allLabel,
27
+ h('span', { className: styles.tabCount }, totalCount)),
28
+ CATEGORY_ORDER.map((id) => h('button', {
29
+ key: id,
30
+ className: category === id ? styles.tabActive : styles.tab,
31
+ onClick: () => setCategory(id),
32
+ },
33
+ categoryLabel(CATEGORY_SHORT_LABELS, id, langKey))),
34
+ )
35
+ }