@dsh-sparrow/dsh-file-manage 0.1.0 → 0.1.2-rc.1.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  English | [简体中文](CHANGELOG.zh-CN.md)
4
4
 
5
+ ## 0.1.2-rc.1.1 (2026-09-09)
6
+
7
+ - If dsh is upgraded to a version this plugin does not support yet, the plugin now disables itself instead of running against an unknown contract (dsh and other plugins are unaffected).
8
+
9
+ ## 0.1.2-rc.1 (2026-09-05)
10
+
11
+ - Version-line alignment with official dsh 0.1.2-rc.1 (stability line).
12
+ - Pagination and delete race fixes: after quickly reopening the panel or hitting Retry, stale pages no longer merge into the fresh list, "Load more" no longer sticks disabled, and a late delete callback no longer dismisses a newly opened confirmation dialog.
13
+ - Requests that outlive the panel now stop by themselves instead of hanging (15s cap for list/delete, 60s for the count pass), and the count pass stops paging as soon as the client connection drops, so an abandoned count no longer keeps burning API quota.
14
+ - The sidebar "Cloud Files" button now aligns its width with Settings (same as the Archive entry).
15
+ - "Load more" now lives at the end of the file list and the list auto-scrolls to the bottom after each page loads (the button no longer sits in a fixed panel footer).
16
+ - A subtle 🐦 dsh-sparrow brand line closes the panel.
17
+
5
18
  ## 0.1.0 (2026-09-02)
6
19
 
7
20
  - Promoted 0.1.0 (identical to 0.1.0-alpha.3).
@@ -2,6 +2,19 @@
2
2
 
3
3
  简体中文 | [English](CHANGELOG.md)
4
4
 
5
+ ## 0.1.2-rc.1.1(2026-09-09)
6
+
7
+ - dsh 升级到本插件尚不支持的版本时,插件会自动停用(dsh 与其它插件不受影响),不再带病运行。
8
+
9
+ ## 0.1.2-rc.1(2026-09-05)
10
+
11
+ - 版本对齐官方 dsh 0.1.2-rc.1(稳定线)。
12
+ - 翻页与删除的并发修复:快速关开面板或点击重试后,陈旧页不再混入新列表、「加载更多」不再卡死,迟到的删除回调不再误关新弹出的确认框。
13
+ - 请求超时后自行停止不再无限挂起(列表/删除 15s、计数 60s 上限),计数翻页在客户端连接断开(超时/导航)时立即停下,被放弃的计数不再持续消耗 API 配额。
14
+ - 侧边栏「云端文件」按钮与 Settings 同宽对齐(与归档入口一致)。
15
+ - 「加载更多」移到文件列表末尾,每页加载后自动滚到列表底部(按钮不再固定在面板底部)。
16
+ - 面板底部新增 🐦 dsh-sparrow 品牌行。
17
+
5
18
  ## 0.1.0 (2026-09-02)
6
19
 
7
20
  - 转正 0.1.0(与 0.1.0-alpha.3 一致)。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 peiyucn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -12,14 +12,14 @@ Large images pasted into DSH are auto-uploaded to the DeepSeek Files API, but th
12
12
  dsh plugin --profile web add @dsh-sparrow/dsh-file-manage
13
13
  ```
14
14
 
15
- Requires dsh ≥ 0.1.2-alpha.4 and a working `pnpm` (`dsh plugin` forwards installation to pnpm).
15
+ Requires dsh 0.1.2-rc.1 — the exact official version line this release is built and verified against (newer lines, pre-releases in particular, are not covered) — and a working `pnpm` (`dsh plugin` forwards installation to pnpm).
16
16
 
17
17
  > Do **not** run `npm install @dsh-sparrow/dsh-file-manage` directly: that only downloads the package into some `node_modules` and does not register it with the DSH web profile. Use the `dsh plugin` command above, then restart DSH.
18
18
 
19
19
  ## Usage
20
20
 
21
21
  * **Entry**: the "Cloud Files" button at the sidebar footer
22
- * **List**: 20 files per page with "Load more" pagination (official after cursor, newest first); each row shows filename / size / upload time / expiry time (when present)
22
+ * **List**: newest first with "Load more" at the bottom (100 more files per click, official after cursor); each row shows filename / size / upload time / expiry time (when present)
23
23
  * **Quota**: the panel header shows the total count and a netdisk-style quota bar (used storage / the official 25 GiB limit)
24
24
  * **Delete**: per-row delete with confirmation; "DSH auto-uploaded" files (`dsh-` prefix) get an extra note — sessions referencing them will transparently re-upload on next use (may be slower)
25
25
  * **Copy file_id**: one-click copy per row
@@ -40,3 +40,5 @@ Requires dsh ≥ 0.1.2-alpha.4 and a working `pnpm` (`dsh plugin` forwards insta
40
40
  ## Uninstall & leftovers
41
41
 
42
42
  * The plugin keeps no local state; uninstalling leaves the cloud files untouched and DSH behavior unchanged
43
+
44
+ **Changelog**: [CHANGELOG.md](https://github.com/peiyucn/dsh-sparrow/blob/main/plugins/dsh-file-manage/CHANGELOG.md)
package/README.zh-CN.md CHANGED
@@ -12,14 +12,14 @@ DSH 贴图的大图会自动上传到 DeepSeek Files API(云端文件库),
12
12
  dsh plugin --profile web add @dsh-sparrow/dsh-file-manage
13
13
  ```
14
14
 
15
- 适配 dsh ≥ 0.1.2-alpha.4,并需要可用的 `pnpm`(`dsh plugin` 会把安装操作转发给 pnpm)。
15
+ 适配 dsh 0.1.2-rc.1(本版构建与验证所对齐的确切官方版本线;更新的版本线、尤其是预发布版本不在承诺范围内),并需要可用的 `pnpm`(`dsh plugin` 会把安装操作转发给 pnpm)。
16
16
 
17
17
  > **不要**直接执行 `npm install @dsh-sparrow/dsh-file-manage`:那只会把包下载到某个 `node_modules`,不会注册进 DSH 的 web profile。请使用上面的 `dsh plugin` 命令安装,并在安装后重启 DSH。
18
18
 
19
19
  ## 使用
20
20
 
21
21
  * **入口**:侧边栏底部「云端文件」按钮
22
- * **列表**:每页 20 条,底部「加载更多」翻页(官方 after 游标,最新在前);行内展示文件名 / 大小 / 上传时间 / 到期时间(有则显)
22
+ * **列表**:最新在前,底部「加载更多」每次多显示 100 条(官方 after 游标翻页);行内展示文件名 / 大小 / 上传时间 / 到期时间(有则显)
23
23
  * **配额**:列表顶部显示总数与网盘式配额进度条(已用存储 / 25 GiB 官方上限)
24
24
  * **删除**:行内删除按钮二次确认;「DSH 自动上传」角标(`dsh-` 前缀)的文件有额外提示——删除后旧会话再次引用时官方会自动重新上传(可能稍慢)
25
25
  * **复制 file_id**:行内按钮一键复制
@@ -40,3 +40,5 @@ dsh plugin --profile web add @dsh-sparrow/dsh-file-manage
40
40
  ## 卸载与残留
41
41
 
42
42
  * 插件无任何本地持久化状态;卸载后云端文件原样保留,DSH 行为不受影响
43
+
44
+ **更新日志**:[CHANGELOG.zh-CN.md](https://github.com/peiyucn/dsh-sparrow/blob/main/plugins/dsh-file-manage/CHANGELOG.zh-CN.md)
package/lib/client.js CHANGED
@@ -32,19 +32,56 @@ __export(index_exports, {
32
32
  });
33
33
  module.exports = __toCommonJS(index_exports);
34
34
 
35
+ // src/client/paging.ts
36
+ var RENDER_PAGE_SIZE = 100;
37
+ var FILE_PAGE_SIZE = 200;
38
+ function renderedRowCount(totalRows, renderLimit) {
39
+ const total = Math.max(0, Math.trunc(totalRows));
40
+ const limit = Math.max(0, Math.trunc(renderLimit));
41
+ return Math.min(total, limit);
42
+ }
43
+ function hasLoadMore(totalRows, renderLimit, dataHasMore) {
44
+ return renderedRowCount(totalRows, renderLimit) < totalRows || dataHasMore;
45
+ }
46
+
35
47
  // src/client/api.ts
48
+ var COUNT_CACHE_TTL_MS = 3e4;
49
+ var countCache = null;
50
+ function readCountCache(entry, nowMs) {
51
+ return entry !== null && nowMs < entry.expiresAt ? entry.value : null;
52
+ }
53
+ function invalidateCountCache() {
54
+ countCache = null;
55
+ }
36
56
  var REQUEST_TIMEOUT_MS = 15e3;
37
57
  var COUNT_TIMEOUT_MS = 6e4;
58
+ async function fetchWithTimeout(path, init, timeoutMs) {
59
+ try {
60
+ return await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
61
+ } catch (error) {
62
+ if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
63
+ throw new Error(`\u8BF7\u6C42\u8D85\u65F6\uFF08${Math.round(timeoutMs / 1e3)}s\uFF09\uFF0C\u8BF7\u91CD\u8BD5`);
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ async function readJson(response) {
69
+ try {
70
+ return await response.json();
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+ async function readErrorMessage(response) {
76
+ const payload = await readJson(response);
77
+ return payload.error?.message ?? `\u8BF7\u6C42\u5931\u8D25\uFF08HTTP ${response.status}\uFF09`;
78
+ }
38
79
  async function listApi(after) {
39
- const params = new URLSearchParams();
80
+ const params = new URLSearchParams({ limit: String(FILE_PAGE_SIZE) });
40
81
  if (after !== void 0) params.set("after", after);
41
- const response = await fetch(`/api/file-manage/list?${params.toString()}`, {
42
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
43
- });
44
- const payload = await response.json();
45
- if (!response.ok) {
46
- throw new Error(payload.error?.message ?? `\u8BF7\u6C42\u5931\u8D25\uFF08HTTP ${response.status}\uFF09`);
47
- }
82
+ const response = await fetchWithTimeout(`/api/file-manage/list?${params.toString()}`, {}, REQUEST_TIMEOUT_MS);
83
+ if (!response.ok) throw new Error(await readErrorMessage(response));
84
+ const payload = await readJson(response);
48
85
  return {
49
86
  rows: payload.items ?? [],
50
87
  hasMore: payload.hasMore ?? false,
@@ -52,12 +89,12 @@ async function listApi(after) {
52
89
  };
53
90
  }
54
91
  async function countApi() {
55
- const response = await fetch("/api/file-manage/count", { signal: AbortSignal.timeout(COUNT_TIMEOUT_MS) });
56
- const payload = await response.json();
57
- if (!response.ok) {
58
- throw new Error(payload.error?.message ?? `\u8BF7\u6C42\u5931\u8D25\uFF08HTTP ${response.status}\uFF09`);
59
- }
60
- return {
92
+ const cached = readCountCache(countCache, Date.now());
93
+ if (cached !== null) return cached;
94
+ const response = await fetchWithTimeout("/api/file-manage/count", {}, COUNT_TIMEOUT_MS);
95
+ if (!response.ok) throw new Error(await readErrorMessage(response));
96
+ const payload = await readJson(response);
97
+ const summary = {
61
98
  count: payload.count ?? 0,
62
99
  totalBytes: payload.totalBytes ?? 0,
63
100
  totalBytesLabel: payload.totalBytesLabel ?? "0 B",
@@ -65,17 +102,14 @@ async function countApi() {
65
102
  quotaBytesLabel: payload.quotaBytesLabel ?? "0 B",
66
103
  quotaCount: payload.quotaCount ?? 0
67
104
  };
105
+ countCache = { value: summary, expiresAt: Date.now() + COUNT_CACHE_TTL_MS };
106
+ return summary;
68
107
  }
69
108
  async function deleteApi(id) {
70
109
  const params = new URLSearchParams({ id });
71
- const response = await fetch(`/api/file-manage/files?${params.toString()}`, {
72
- method: "DELETE",
73
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
74
- });
75
- const payload = await response.json();
76
- if (!response.ok) {
77
- throw new Error(payload.error?.message ?? `\u8BF7\u6C42\u5931\u8D25\uFF08HTTP ${response.status}\uFF09`);
78
- }
110
+ const response = await fetchWithTimeout(`/api/file-manage/files?${params.toString()}`, { method: "DELETE" }, REQUEST_TIMEOUT_MS);
111
+ if (!response.ok) throw new Error(await readErrorMessage(response));
112
+ invalidateCountCache();
79
113
  }
80
114
 
81
115
  // src/client/FileManageDock.tsx
@@ -411,17 +445,22 @@ var styles = {
411
445
  fontSize: 12,
412
446
  lineHeight: "18px"
413
447
  },
414
- footerBar: {
448
+ /** 合集品牌 footer:固定面板底部(滚动区之外),顶部分割线与内容区隔开;紧凑规格。 */
449
+ footer: {
415
450
  flex: "none",
416
- display: "flex",
417
- justifyContent: "center",
418
- // 列表已包区块卡,不再加顶部分隔线(避免与卡片叠加显乱)。
419
- padding: "10px 24px 14px"
451
+ boxSizing: "border-box",
452
+ padding: "6px 24px 8px",
453
+ borderTop: "1px solid var(--dsw-alias-border-l1, #e2e5ea)",
454
+ textAlign: "center",
455
+ fontSize: 11,
456
+ lineHeight: "16px",
457
+ color: "var(--dsw-alias-label-tertiary, #8a919f)"
420
458
  }
421
459
  };
422
460
 
423
461
  // src/client/FileManageDock.tsx
424
462
  var import_jsx_runtime = require("react/jsx-runtime");
463
+ var COPIED_RESET_MS = 2e3;
425
464
  function FileManageDock({ wide, listFiles, deleteFile, countFiles, t }) {
426
465
  const [open, setOpen] = (0, import_react.useState)(false);
427
466
  const [rows, setRows] = (0, import_react.useState)([]);
@@ -437,13 +476,20 @@ function FileManageDock({ wide, listFiles, deleteFile, countFiles, t }) {
437
476
  const [summaryPending, setSummaryPending] = (0, import_react.useState)(false);
438
477
  const closeButtonRef = (0, import_react.useRef)(null);
439
478
  const refreshSeqRef = (0, import_react.useRef)(0);
479
+ const bodyRef = (0, import_react.useRef)(null);
480
+ const scrollPendingRef = (0, import_react.useRef)(false);
440
481
  const copiedTimerRef = (0, import_react.useRef)(null);
482
+ const [renderLimit, setRenderLimit] = (0, import_react.useState)(RENDER_PAGE_SIZE);
441
483
  const reload = (0, import_react.useCallback)(() => {
442
484
  const seq = ++refreshSeqRef.current;
443
485
  setLoading(true);
444
486
  setSummaryPending(true);
445
487
  setSummary(null);
446
488
  setError(null);
489
+ setLoadingMore(false);
490
+ setBusyDelete(false);
491
+ scrollPendingRef.current = false;
492
+ setRenderLimit(RENDER_PAGE_SIZE);
447
493
  listFiles().then((page) => {
448
494
  if (seq !== refreshSeqRef.current) return;
449
495
  setRows(page.rows);
@@ -486,18 +532,29 @@ function FileManageDock({ wide, listFiles, deleteFile, countFiles, t }) {
486
532
  if (copiedTimerRef.current !== null) window.clearTimeout(copiedTimerRef.current);
487
533
  }, []);
488
534
  const loadMore = (0, import_react.useCallback)(() => {
489
- if (lastId === void 0 || loadingMore) return;
535
+ if (loadingMore) return;
536
+ if (renderLimit < rows.length) {
537
+ setRenderLimit((limit) => limit + RENDER_PAGE_SIZE);
538
+ scrollPendingRef.current = true;
539
+ return;
540
+ }
541
+ if (lastId === void 0) return;
542
+ const seq = refreshSeqRef.current;
490
543
  setLoadingMore(true);
544
+ scrollPendingRef.current = true;
491
545
  listFiles(lastId).then((page) => {
546
+ if (seq !== refreshSeqRef.current) return;
492
547
  setRows((current) => [...current, ...page.rows]);
493
548
  setHasMore(page.hasMore);
494
549
  setLastId(page.lastId);
550
+ setRenderLimit((limit) => limit + RENDER_PAGE_SIZE);
495
551
  }).catch((reason) => {
552
+ if (seq !== refreshSeqRef.current) return;
496
553
  setError(reason instanceof Error ? reason.message : String(reason));
497
554
  }).finally(() => {
498
- setLoadingMore(false);
555
+ if (seq === refreshSeqRef.current) setLoadingMore(false);
499
556
  });
500
- }, [lastId, loadingMore, listFiles]);
557
+ }, [renderLimit, rows.length, lastId, loadingMore, listFiles]);
501
558
  const copyId = (0, import_react.useCallback)(async (row) => {
502
559
  try {
503
560
  await navigator.clipboard.writeText(row.id);
@@ -505,27 +562,39 @@ function FileManageDock({ wide, listFiles, deleteFile, countFiles, t }) {
505
562
  if (copiedTimerRef.current !== null) window.clearTimeout(copiedTimerRef.current);
506
563
  copiedTimerRef.current = window.setTimeout(() => {
507
564
  setCopied(null);
508
- }, 2e3);
565
+ }, COPIED_RESET_MS);
509
566
  } catch {
510
567
  setCopied(null);
511
568
  setError(t("copyFailed"));
512
569
  }
513
570
  }, [t]);
571
+ (0, import_react.useEffect)(() => {
572
+ if (!scrollPendingRef.current) return;
573
+ scrollPendingRef.current = false;
574
+ const el = bodyRef.current;
575
+ if (el !== null) el.scrollTop = el.scrollHeight;
576
+ }, [rows, renderLimit]);
514
577
  const initialLoading = loading || summaryPending;
515
578
  const quotaRatio = summary === null ? 0 : storageUsageRatio(summary.totalBytes, summary.quotaBytes);
579
+ const visibleCount = renderedRowCount(rows.length, renderLimit);
516
580
  const submitDelete = (0, import_react.useCallback)(() => {
517
581
  if (confirming === null || busyDelete) return;
582
+ const seq = refreshSeqRef.current;
583
+ const deleteId = confirming.id;
518
584
  setBusyDelete(true);
519
- deleteFile(confirming.id).then(() => {
520
- setRows((current) => current.filter((row) => row.id !== confirming.id));
521
- setConfirming(null);
522
- void countFiles().then(setSummary).catch(() => {
585
+ deleteFile(deleteId).then(() => {
586
+ setRows((current) => current.filter((row) => row.id !== deleteId));
587
+ setConfirming((current) => current !== null && current.id === deleteId ? null : current);
588
+ void countFiles().then((next) => {
589
+ if (seq === refreshSeqRef.current) setSummary(next);
590
+ }).catch(() => {
523
591
  });
524
592
  }).catch((reason) => {
593
+ if (seq !== refreshSeqRef.current) return;
525
594
  setError(reason instanceof Error ? reason.message : String(reason));
526
- setConfirming(null);
595
+ setConfirming((current) => current !== null && current.id === deleteId ? null : current);
527
596
  }).finally(() => {
528
- setBusyDelete(false);
597
+ if (seq === refreshSeqRef.current) setBusyDelete(false);
529
598
  });
530
599
  }, [busyDelete, confirming, deleteFile, countFiles]);
531
600
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
@@ -560,9 +629,9 @@ function FileManageDock({ wide, listFiles, deleteFile, countFiles, t }) {
560
629
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-file-manage-quota-text", children: `${summary.totalBytesLabel} / ${summary.quotaBytesLabel} \xB7 ${t("quota.used", { percent: formatUsagePercent(quotaRatio) })}` }),
561
630
  quotaRatio > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-file-manage-quota-fill", style: { width: `${Math.round(quotaRatio * 100)}%` } }) : null
562
631
  ] }),
563
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-file-manage-count", children: rows.length < summary.count ? t("summary.loaded", { loaded: rows.length, count: summary.count }) : t("summary.count", { count: summary.count }) })
632
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-file-manage-count", children: visibleCount < summary.count ? t("summary.loaded", { loaded: visibleCount, count: summary.count }) : t("summary.count", { count: summary.count }) })
564
633
  ] }) : null,
565
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...styles.body, padding: summary !== null ? "0 24px 24px" : "12px 24px 24px" }, className: "dsh-file-manage-body", children: initialLoading ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-file-manage-loading", role: "status", children: [
634
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { ref: bodyRef, style: { ...styles.body, padding: summary !== null ? "0 24px 24px" : "12px 24px 24px" }, className: "dsh-file-manage-body", children: initialLoading ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-file-manage-loading", role: "status", children: [
566
635
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-file-manage-spinner", "aria-hidden": true }),
567
636
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("loading") })
568
637
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
@@ -574,7 +643,7 @@ function FileManageDock({ wide, listFiles, deleteFile, countFiles, t }) {
574
643
  }, children: t("retry") })
575
644
  ] }) : null,
576
645
  error === null && rows.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.secondarySmall, children: t("empty") }) : null,
577
- rows.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-file-manage-card", children: rows.map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.row, children: [
646
+ rows.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-file-manage-card", children: rows.slice(0, renderLimit).map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.row, children: [
578
647
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { minWidth: 0 }, children: [
579
648
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 6, minWidth: 0 }, children: [
580
649
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.title, title: row.filename, children: row.filename }),
@@ -611,20 +680,21 @@ function FileManageDock({ wide, listFiles, deleteFile, countFiles, t }) {
611
680
  }
612
681
  )
613
682
  ] })
614
- ] }, row.id)) }) : null
683
+ ] }, row.id)) }) : null,
684
+ hasLoadMore(rows.length, renderLimit, hasMore) && !initialLoading ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { display: "flex", justifyContent: "center", paddingTop: 12 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
685
+ "button",
686
+ {
687
+ type: "button",
688
+ className: "dsh-file-manage-btn",
689
+ disabled: loadingMore,
690
+ onClick: () => {
691
+ void loadMore();
692
+ },
693
+ children: loadingMore ? t("loading") : t("loadMore")
694
+ }
695
+ ) }) : null
615
696
  ] }) }),
616
- hasMore && !initialLoading ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.footerBar, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
617
- "button",
618
- {
619
- type: "button",
620
- className: "dsh-file-manage-btn",
621
- disabled: loadingMore,
622
- onClick: () => {
623
- void loadMore();
624
- },
625
- children: loadingMore ? t("loading") : t("loadMore")
626
- }
627
- ) }) : null
697
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.footer, children: "\u{1F426} dsh-sparrow" })
628
698
  ] }) }) : null,
629
699
  confirming !== null ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-file-manage-confirm-overlay", role: "presentation", onMouseDown: (event) => {
630
700
  if (event.target === event.currentTarget && !busyDelete) setConfirming(null);
package/lib/compat.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 宿主兼容自检(根 AGENTS《插件与宿主兼容》)——纯能力版。
3
+ *
4
+ * 本插件不读 dsh 会话数据,因此没有会话格式门;守卫点是**宿主能力面**
5
+ * (依赖的服务/方法/导出是否存在,以及运行环境特性)。不满足即抛错自停用,
6
+ * 绝不带病运行。判定与文案是纯逻辑,供单测。
7
+ */
8
+ /**
9
+ * 缺失的能力名(纯逻辑,供单测)。
10
+ * @param capabilities - 本插件声明的能力。
11
+ * @returns 未满足的能力名,按声明顺序。
12
+ */
13
+ export function missingCapabilities(capabilities) {
14
+ return capabilities.filter(capability => !capability.ok).map(capability => capability.name);
15
+ }
16
+ /**
17
+ * 能力门:任一必需能力缺失即抛错(自停用)。
18
+ *
19
+ * cordis 逐插件捕获 `apply` 异常并把该插件标为 inactive,dsh 与其余插件不受影响
20
+ * (已查证 cordis `lib/index.js:1350-1362`)。抛错前先记一条面向用户的告警,
21
+ * 说明「为什么停用、怎么恢复」。
22
+ * @param ctx - 插件上下文(只用到 logger)。
23
+ * @param pluginName - 插件名(日志与错误前缀)。
24
+ * @param capabilities - 本插件声明的能力。
25
+ */
26
+ export function assertCapabilities(ctx, pluginName, capabilities) {
27
+ const missing = missingCapabilities(capabilities);
28
+ if (missing.length === 0)
29
+ return;
30
+ const reason = `缺少本插件依赖的 ${missing.join('、')}`;
31
+ ctx.logger.warn(`${pluginName}: ${reason};已停用插件以免影响 dsh(升级本插件或运行环境后自动恢复)`);
32
+ throw new Error(`${pluginName}: ${reason}`);
33
+ }
34
+ //# sourceMappingURL=compat.js.map
package/lib/host.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** dsh-file-manage host half:DeepSeek Files API 云端文件清单 REST 路由(复用官方 DeepSeekFilesClient)。 */
2
2
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
3
- import { DeepSeekFileId, DeepSeekFilesClient, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT } from '@deepseek-ai/dsh-llm-deepseek';
3
+ import { assertCapabilities } from './compat.js';
4
4
  import { classifyUpstreamError, COUNT_PAGE_LIMIT, COUNT_PAGE_TIMEOUT_MS, decodeFileIdParam, formatBytes, MAX_COUNT_PAGES, normalizePageQuery, toFileRow } from './files.js';
5
5
  export const name = 'dsh-file-manage';
6
6
  export const inject = ['webServer', 'credentials', 'settings'];
@@ -9,6 +9,11 @@ const PREFIX = '/api/file-manage';
9
9
  const DEFAULT_BASE_URL = 'https://api.deepseek.com';
10
10
  const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
11
11
  const LLM_DEEPSEEK_NS = 'llm-deepseek';
12
+ /**
13
+ * 单页 list / 单条 delete 的上游超时:官方 client 的 fetch 本身无默认超时,客户端断开
14
+ * (关面板 / 15s 超时)后残留的上游请求必须自限,否则会无限挂起并占用官方 API 配额。
15
+ */
16
+ const UPSTREAM_TIMEOUT_MS = 15_000;
12
17
  /** 插件自身错误(面板可读的 code / message + HTTP 状态)。 */
13
18
  class FileManageError extends Error {
14
19
  code;
@@ -68,11 +73,31 @@ function sendUpstreamError(res, error) {
68
73
  const info = classifyUpstreamError(error);
69
74
  sendJson(res, info.status, { error: { code: info.code, message: info.message } });
70
75
  }
76
+ let officialSurface;
77
+ /** 读取(并缓存)官方导出面;缺任何一项即抛可读错误(启动期抛错 = 自停用)。 */
78
+ async function deepSeekSurface() {
79
+ if (officialSurface !== undefined)
80
+ return officialSurface;
81
+ const module = await import('@deepseek-ai/dsh-llm-deepseek');
82
+ const missing = ['DeepSeekFileId', 'DeepSeekFilesClient', 'MAX_STORED_FILE_BYTES', 'MAX_STORED_FILE_COUNT']
83
+ .filter(key => module[key] === undefined);
84
+ if (missing.length > 0) {
85
+ throw new FileManageError('UNSUPPORTED_HOST', `当前 dsh 的 dsh-llm-deepseek 不再导出 ${missing.join('、')};本插件已停用(升级本插件后自动恢复)`, 501);
86
+ }
87
+ officialSurface = module;
88
+ return officialSurface;
89
+ }
71
90
  /**
72
91
  * host half 入口:注册 prefix 路由(list / 单条删除),全部副作用挂在 apply 的 effect 上。
73
92
  * @param ctx - DSH 插件上下文。
74
93
  */
75
- export function apply(ctx) {
94
+ export async function apply(ctx) {
95
+ // 宿主兼容自检(根 AGENTS《插件与宿主兼容》,先于一切注册):能力面或官方面缺失即自停用。
96
+ assertCapabilities(ctx, name, [
97
+ { name: 'webServer.register', ok: typeof ctx.webServer?.register === 'function' },
98
+ { name: 'credentials.resolve', ok: typeof ctx.credentials?.resolve === 'function' },
99
+ ]);
100
+ await deepSeekSurface();
76
101
  ctx.effect(() => ctx.webServer.register({
77
102
  kind: 'prefix',
78
103
  path: PREFIX,
@@ -80,6 +105,7 @@ export function apply(ctx) {
80
105
  const url = new URL(req.url ?? '/', 'http://localhost');
81
106
  const pathname = url.pathname;
82
107
  try {
108
+ const surface = await deepSeekSurface();
83
109
  if (req.method === 'GET' && pathname === `${PREFIX}/list`) {
84
110
  const query = normalizePageQuery({
85
111
  after: url.searchParams.get('after') ?? undefined,
@@ -87,11 +113,12 @@ export function apply(ctx) {
87
113
  order: url.searchParams.get('order') ?? undefined,
88
114
  });
89
115
  const connection = await resolveConnection(ctx);
90
- const client = new DeepSeekFilesClient({ baseURL: connection.baseURL, apiKey: connection.apiKey });
116
+ const client = new surface.DeepSeekFilesClient({ baseURL: connection.baseURL, apiKey: connection.apiKey });
91
117
  const page = await client.list({
92
- ...query.after === undefined ? {} : { after: DeepSeekFileId(query.after) },
118
+ ...query.after === undefined ? {} : { after: surface.DeepSeekFileId(query.after) },
93
119
  limit: query.limit,
94
120
  order: query.order,
121
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
95
122
  });
96
123
  sendJson(res, 200, {
97
124
  items: page.data.map(toFileRow),
@@ -103,11 +130,14 @@ export function apply(ctx) {
103
130
  if (req.method === 'GET' && pathname === `${PREFIX}/count`) {
104
131
  // 官方 list 无总数字段:翻到底累计(每页 1000,配额内最多 10 页,MAX_COUNT_PAGES 兜底)。
105
132
  const connection = await resolveConnection(ctx);
106
- const client = new DeepSeekFilesClient({ baseURL: connection.baseURL, apiKey: connection.apiKey });
133
+ const client = new surface.DeepSeekFilesClient({ baseURL: connection.baseURL, apiKey: connection.apiKey });
107
134
  let count = 0;
108
135
  let totalBytes = 0;
109
136
  let after;
110
137
  for (let page = 0; page < MAX_COUNT_PAGES; page++) {
138
+ // 客户端已断开(超时 / 关面板)时停止翻页:剩余页数没有接收方,白烧官方 API 配额。
139
+ if (res.destroyed)
140
+ break;
111
141
  const result = await client.list({
112
142
  ...after === undefined ? {} : { after },
113
143
  limit: COUNT_PAGE_LIMIT,
@@ -125,9 +155,9 @@ export function apply(ctx) {
125
155
  count,
126
156
  totalBytes,
127
157
  totalBytesLabel: formatBytes(totalBytes),
128
- quotaBytes: MAX_STORED_FILE_BYTES,
129
- quotaBytesLabel: formatBytes(MAX_STORED_FILE_BYTES),
130
- quotaCount: MAX_STORED_FILE_COUNT,
158
+ quotaBytes: surface.MAX_STORED_FILE_BYTES,
159
+ quotaBytesLabel: formatBytes(surface.MAX_STORED_FILE_BYTES),
160
+ quotaCount: surface.MAX_STORED_FILE_COUNT,
131
161
  });
132
162
  return;
133
163
  }
@@ -138,8 +168,8 @@ export function apply(ctx) {
138
168
  return;
139
169
  }
140
170
  const connection = await resolveConnection(ctx);
141
- const client = new DeepSeekFilesClient({ baseURL: connection.baseURL, apiKey: connection.apiKey });
142
- await client.delete(DeepSeekFileId(id));
171
+ const client = new surface.DeepSeekFilesClient({ baseURL: connection.baseURL, apiKey: connection.apiKey });
172
+ await client.delete(surface.DeepSeekFileId(id), AbortSignal.timeout(UPSTREAM_TIMEOUT_MS));
143
173
  sendJson(res, 200, { deleted: true, id });
144
174
  return;
145
175
  }
@@ -9,6 +9,17 @@ export interface FileCountSummary {
9
9
  quotaBytesLabel: string;
10
10
  quotaCount: number;
11
11
  }
12
+ /** 总数统计 TTL 缓存时长:面板快速重开/重试不重复 count 全量翻页(count 要翻到底,配额内最多 10 页)。 */
13
+ export declare const COUNT_CACHE_TTL_MS = 30000;
14
+ /** TTL 缓存条目(api.ts 模块级单例:面板单实例,随 client bundle 生命周期,有界一条)。 */
15
+ export interface CountCacheEntry {
16
+ readonly value: FileCountSummary;
17
+ readonly expiresAt: number;
18
+ }
19
+ /** TTL 读缓存(时钟注入的纯逻辑,供单测):未过期返回快照,过期或缺条返回 null。 */
20
+ export declare function readCountCache(entry: CountCacheEntry | null, nowMs: number): FileCountSummary | null;
21
+ /** 删除成功后失效计数缓存:下一次 count 拉取重新翻页,包含删除结果。 */
22
+ export declare function invalidateCountCache(): void;
12
23
  export declare function listApi(after?: string): Promise<{
13
24
  rows: FileRow[];
14
25
  hasMore: boolean;
@@ -0,0 +1,13 @@
1
+ /** 文件面板渲染窗口纯逻辑(对齐 archive-manage spec 09):数据分页与 DOM 渲染窗口分离。 @module dsh-file-manage/client/paging */
2
+ /** 渲染窗口大小:每次渲染的行数上限,「加载更多」按此递增(archive 同款 100)。 */
3
+ export declare const RENDER_PAGE_SIZE = 100;
4
+ /**
5
+ * 数据分页大小:每次 list 拉取的行数。必须大于渲染窗口(200 > 100),
6
+ * 窗口才会真正裁剪 DOM——否则窗口永远跑在数据前面,退化回全量渲染。
7
+ */
8
+ export declare const FILE_PAGE_SIZE = 200;
9
+ /** 窗口内可见行数:min(已加载行数, 窗口上限);负数/小数钳到安全值(不抛)。 */
10
+ export declare function renderedRowCount(totalRows: number, renderLimit: number): number;
11
+ /** 「加载更多」是否可见:窗口未盖满已加载行(纯延伸窗口),或服务端还有更多数据(延伸窗口 + 拉页)。 */
12
+ export declare function hasLoadMore(totalRows: number, renderLimit: number, dataHasMore: boolean): boolean;
13
+ //# sourceMappingURL=paging.d.ts.map
@@ -56,11 +56,16 @@ export declare const styles: {
56
56
  fontSize: number;
57
57
  lineHeight: string;
58
58
  };
59
- readonly footerBar: {
59
+ /** 合集品牌 footer:固定面板底部(滚动区之外),顶部分割线与内容区隔开;紧凑规格。 */
60
+ readonly footer: {
60
61
  flex: string;
61
- display: "flex";
62
- justifyContent: "center";
62
+ boxSizing: "border-box";
63
63
  padding: string;
64
+ borderTop: string;
65
+ textAlign: "center";
66
+ fontSize: number;
67
+ lineHeight: string;
68
+ color: "var(--dsw-alias-label-tertiary, #8a919f)";
64
69
  };
65
70
  };
66
71
  //# sourceMappingURL=styles.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 宿主兼容自检(根 AGENTS《插件与宿主兼容》)——纯能力版。
3
+ *
4
+ * 本插件不读 dsh 会话数据,因此没有会话格式门;守卫点是**宿主能力面**
5
+ * (依赖的服务/方法/导出是否存在,以及运行环境特性)。不满足即抛错自停用,
6
+ * 绝不带病运行。判定与文案是纯逻辑,供单测。
7
+ */
8
+ /** 一项能力:`name` 进日志,`ok` 由调用方探测(服务/方法/导出/浏览器特性是否存在)。 */
9
+ export interface HostCapability {
10
+ readonly name: string;
11
+ readonly ok: boolean;
12
+ }
13
+ /**
14
+ * 缺失的能力名(纯逻辑,供单测)。
15
+ * @param capabilities - 本插件声明的能力。
16
+ * @returns 未满足的能力名,按声明顺序。
17
+ */
18
+ export declare function missingCapabilities(capabilities: readonly HostCapability[]): string[];
19
+ /**
20
+ * 能力门:任一必需能力缺失即抛错(自停用)。
21
+ *
22
+ * cordis 逐插件捕获 `apply` 异常并把该插件标为 inactive,dsh 与其余插件不受影响
23
+ * (已查证 cordis `lib/index.js:1350-1362`)。抛错前先记一条面向用户的告警,
24
+ * 说明「为什么停用、怎么恢复」。
25
+ * @param ctx - 插件上下文(只用到 logger)。
26
+ * @param pluginName - 插件名(日志与错误前缀)。
27
+ * @param capabilities - 本插件声明的能力。
28
+ */
29
+ export declare function assertCapabilities(ctx: {
30
+ logger: {
31
+ warn(message: string): void;
32
+ };
33
+ }, pluginName: string, capabilities: readonly HostCapability[]): void;
34
+ //# sourceMappingURL=compat.d.ts.map
@@ -6,5 +6,5 @@ export declare const inject: string[];
6
6
  * host half 入口:注册 prefix 路由(list / 单条删除),全部副作用挂在 apply 的 effect 上。
7
7
  * @param ctx - DSH 插件上下文。
8
8
  */
9
- export declare function apply(ctx: Context): void;
9
+ export declare function apply(ctx: Context): Promise<void>;
10
10
  //# sourceMappingURL=host.d.ts.map