dsh-mcp-connector 0.2.71 → 0.2.73

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,21 @@
2
2
 
3
3
  本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/);首个公开版本从 `0.1.0` 开始。
4
4
 
5
+ ## [0.2.73] - 2026-10-15
6
+
7
+ ### Fixed
8
+
9
+ - 兼容 DSH 0.2.1-alpha.1 起的 webStartup 服务,同时保留旧宿主 webRuntime 支持,修复面板正文及 API 路由未挂载导致的空白(#151)。两种服务并存时优先使用 webStartup 的可信主机配置,服务卸载时清理路由。
10
+ - 将插件 API 请求失败与 npm 版本查询失败区分提示,不再把路由不可用统一报告为 npm 网络错误。
11
+
12
+ ## [0.2.72] - 2026-10-14
13
+
14
+ ### Fixed
15
+
16
+ - 宿主切换面板销毁 iframe 后,按工作区恢复页签、搜索、市场分类、展开章节、工具筛选/分页及列表滚动。状态仅保留在同源宿主当前页面内存,最多 32 个工作区,不保存凭据表单、API 返回或授权弹框;刷新宿主页面即清除。
17
+ - 市场与已安装列表加入请求序列和工作区检查,防止较早响应覆盖新页签/工作区;加载中的滚动归零不覆盖恢复位置。
18
+ - 本次为浏览状态恢复而非 iframe 保活:仍重新拉取数据、重建 SSE,详情和未完成表单不恢复。
19
+
5
20
  ## [0.2.71] - 2026-10-14
6
21
 
7
22
  ### Added
package/README.en.md CHANGED
@@ -215,7 +215,7 @@ npm run dev:ui
215
215
 
216
216
  Every Registry merge regenerates `catalog-stats.json`; an hourly workflow in this repository synchronizes the Chinese and English product copy plus a local stats snapshot. The static npm README updates with package releases, while the live badges above read the Registry directly and therefore stay current without another npm release.
217
217
 
218
- The current public version is [`dsh-mcp-connector@0.2.71`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.71](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.71).
218
+ The current public version is [`dsh-mcp-connector@0.2.73`](https://www.npmjs.com/package/dsh-mcp-connector), with [GitHub Release v0.2.73](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.73).
219
219
 
220
220
  See [CHANGELOG.md](CHANGELOG.md) for version history and [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md) for the Desktop release checklist.
221
221
 
package/README.md CHANGED
@@ -210,7 +210,7 @@ npm run dev:ui
210
210
 
211
211
  公共 Registry 每次合并后会生成 `catalog-stats.json`;本仓库的定时工作流每小时同步中英文介绍和统计快照。npm 页面中的静态正文随版本发布更新,上方动态统计徽标则直接读取 Registry,可在不发布新 npm 版本时保持实时数量一致。
212
212
 
213
- 当前公开版本为 [`dsh-mcp-connector@0.2.71`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.71](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.71)。
213
+ 当前公开版本为 [`dsh-mcp-connector@0.2.73`](https://www.npmjs.com/package/dsh-mcp-connector),对应 [GitHub Release v0.2.73](https://github.com/duhu2000/dsh-mcp-connector/releases/tag/v0.2.73)。
214
214
 
215
215
  版本能力与变更记录见 [CHANGELOG.md](CHANGELOG.md)。
216
216
  Desktop 发版回归见 [docs/DESKTOP-E2E.md](docs/DESKTOP-E2E.md)。
@@ -23,6 +23,13 @@ npm run dev:ui
23
23
 
24
24
  ## 不自动执行的项目
25
25
 
26
+ ### 面板浏览状态恢复(#135)
27
+
28
+ - 在工作区 A 设置市场分类、搜索、展开章节并滚动;切换其他宿主面板再回来,检查恢复。已安装和工具页签(含筛选、分页)分别重复。
29
+ - 切换工作区 B 不应带入 A 的筛选;回到 A 应恢复 A 的状态。慢网络下快速切页签/工作区,旧响应不得覆盖新页面。
30
+ - 返回仍重新读取数据、重建 SSE;这是状态恢复,不是 iframe 常驻。详情、授权弹框、凭据及 JSON 编辑表单不恢复。
31
+ - 浏览状态只在同源宿主当前页面内存保留,最多 32 个工作区;刷新宿主后清空。跨源/受限父页面不可访问时降级为不跨 iframe 生命周期保存。
32
+
26
33
  - 不在 CI 中完成真实 OAuth 登录。
27
34
  - 不自动删除旧插件、旧 storage domain 或凭据。
28
35
  - 不因探针一次失败直接删除社区条目;定时报告供维护者复核。
package/lib/client.js CHANGED
@@ -1193,7 +1193,7 @@ window.__ModuleLoader__.load({
1193
1193
  if (disposed) return;
1194
1194
  setVersionStatus(status);
1195
1195
  setVersionCheckBusy(status.checking === true);
1196
- setVersionCheckFailed(!status.checking && status.sources?.npm?.ok !== true);
1196
+ setVersionCheckFailed(!status.checking && status.sources?.npm?.ok !== true ? "npm" : false);
1197
1197
  if (status.checking) {
1198
1198
  schedule(1e3);
1199
1199
  return;
@@ -1207,7 +1207,7 @@ window.__ModuleLoader__.load({
1207
1207
  console.warn("[mcp-connector] plugin update check failed:", error);
1208
1208
  if (!disposed) {
1209
1209
  setVersionCheckBusy(false);
1210
- setVersionCheckFailed(true);
1210
+ setVersionCheckFailed("api");
1211
1211
  schedule(VERSION_CHECK_RETRY_MS);
1212
1212
  }
1213
1213
  });
@@ -1668,7 +1668,7 @@ window.__ModuleLoader__.load({
1668
1668
  versionCheckFailed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1669
1669
  className: "mcpConnectorUpdateStatus",
1670
1670
  "data-tone": "error",
1671
- children: "暂时无法确认 npm 最新版本,请重试"
1671
+ children: versionCheckFailed === "api" ? "插件 API 不可用,请检查 DSH 宿主兼容性或服务日志" : "暂时无法确认 npm 最新版本,请重试"
1672
1672
  }),
1673
1673
  versionStatus?.releasePending && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1674
1674
  className: "mcpConnectorVersion",
package/lib/index.js CHANGED
@@ -40,7 +40,7 @@ import { provision, disable, remove, entryIdFor } from './mcp-provision.js';
40
40
  import { classifyConnectionError, validateConnectionRecords } from './mcp-validation.js';
41
41
  import { listMcpTools, McpHttpError } from './mcp-http.js';
42
42
  import { registerTools } from './tools.js';
43
- import { mountWebRoutes } from './web.js';
43
+ import { injectWebRoutes } from './web.js';
44
44
  import { createVersionStatusService } from './version-status.js';
45
45
  import { assertSafeUrl, normalizeConnectionDisplayName } from './util.js';
46
46
  import { readLegacyGrantCandidates, planLegacyMigration, toConnectorGrant, toConnectionRecords } from './migration.js';
@@ -3445,17 +3445,13 @@ export async function apply(ctx, config) {
3445
3445
  const disposeTools = registerTools(ctx, observableApi);
3446
3446
 
3447
3447
  /* ───────────────────────── web 半区(图形化市场)──────────────────────── */
3448
- // webServer / webRuntime 仅存在于 web 部署;无此服务时静默跳过(纯对话工具场景仍可用)。
3449
- let disposeWeb = null;
3448
+ // webServer + 新版 webStartup 或旧版 webRuntime;纯对话部署不挂载 HTTP 路由。
3450
3449
  if (typeof ctx.inject === 'function') {
3451
3450
  try {
3452
- ctx.inject(['webServer', 'webRuntime'], (wctx) => {
3453
- disposeWeb = mountWebRoutes(wctx, observableApi, {
3454
- logger,
3455
- eventHub: statusEvents,
3456
- frameAncestors: config.frameAncestors,
3457
- });
3458
- logger.info('mcp-connector web UI mounted at /mcp-connector/ui/');
3451
+ injectWebRoutes(ctx, observableApi, {
3452
+ logger,
3453
+ eventHub: statusEvents,
3454
+ frameAncestors: config.frameAncestors,
3459
3455
  });
3460
3456
  } catch (error) {
3461
3457
  logger.warn(`mcp-connector web UI not mounted (webServer unavailable): ${error.message}`);
@@ -3467,7 +3463,6 @@ export async function apply(ctx, config) {
3467
3463
  clearTimeout(discoveryTimer);
3468
3464
  clearInterval(discoveryInterval);
3469
3465
  disposeTools();
3470
- disposeWeb?.();
3471
3466
  hostGovernance.dispose();
3472
3467
  hostScopes.dispose();
3473
3468
  hostSessionInjection.dispose();
package/lib/web.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * - GET /mcp-connector/events 无敏感数据的实时状态 SSE
8
8
  *
9
9
  * 所有路由都过浏览器信任 fence(loopback + 同源),与 better-sidebar 同一策略。
10
- * 本模块只依赖 ctx.webServer / ctx.webRuntime;在非 web 部署(无这两个服务)时
10
+ * 本模块依赖 ctx.webServer 及 webStartup(新版)或 webRuntime(旧版);非 web 部署时
11
11
  * 调用方应跳过挂载,保证插件仍可用于纯对话工具场景。
12
12
  *
13
13
  * 静态资源响应的 `Content-Security-Policy` 由 `buildCsp()` 生成;其中
@@ -264,13 +264,47 @@ export function buildCsp(frameAncestors) {
264
264
 
265
265
  /* ───────────────────────── 路由挂载 ───────────────────────── */
266
266
 
267
+ /** Alternative dependencies, not an AND: alpha.1 removed webRuntime.
268
+ * Keep each mount owned by its injection context; prefer webStartup when both exist.
269
+ */
270
+ export function injectWebRoutes(ctx, api, options = {}) {
271
+ const candidates = new Map();
272
+ let active;
273
+ let dispose;
274
+ const reconcile = () => {
275
+ const next = candidates.get('webStartup') ?? candidates.get('webRuntime');
276
+ if (next === active) return;
277
+ dispose?.();
278
+ dispose = undefined;
279
+ active = next;
280
+ if (next) {
281
+ dispose = mountWebRoutes(next.ctx, api, {
282
+ ...options, trustedHosts: next.trustedHosts,
283
+ });
284
+ options.logger?.info?.('mcp-connector web UI mounted at /mcp-connector/ui/');
285
+ }
286
+ };
287
+ for (const name of ['webStartup', 'webRuntime']) {
288
+ ctx.inject(['webServer', name], (wctx) => {
289
+ wctx.effect(() => {
290
+ const candidate = { ctx: wctx, trustedHosts: wctx[name].trustedHosts ?? [] };
291
+ candidates.set(name, candidate);
292
+ reconcile();
293
+ return () => {
294
+ if (candidates.get(name) === candidate) candidates.delete(name);
295
+ reconcile();
296
+ };
297
+ });
298
+ });
299
+ }
300
+ }
301
+
267
302
  /**
268
- * 挂载 web 路由。`wctx` 为已注入 webServer/webRuntime 的子上下文。
303
+ * 挂载 web 路由。`wctx` 为已注入 webServer 和对应信任服务的子上下文。
269
304
  * `frameAncestors` 为面板可嵌入白名单(缺省 = `['self']`,只有同源页面能内嵌;
270
305
  * 桌面壳场景由调用方显式追加外壳源)。返回 disposer(卸载三条 prefix 路由)。
271
306
  */
272
- export function mountWebRoutes(wctx, api, { logger, eventHub, frameAncestors } = {}) {
273
- const trustedHosts = wctx.webRuntime?.trustedHosts ?? [];
307
+ export function mountWebRoutes(wctx, api, { logger, eventHub, frameAncestors, trustedHosts = wctx.webStartup?.trustedHosts ?? wctx.webRuntime?.trustedHosts ?? [] } = {}) {
274
308
  const fence = (req) => isTrustedWebRequest(req, trustedHosts);
275
309
  const csp = buildCsp(frameAncestors);
276
310
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp-connector",
3
- "version": "0.2.71",
3
+ "version": "0.2.73",
4
4
  "description": "DeepSeek Harness MCP Connector: connect servers, search tools across active connections, and troubleshoot discovery. Includes a continuously updated catalog; supports OAuth 2.0 PKCE, API keys, Streamable HTTP/stdio, and mcpServers JSON import.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/ui/index.html CHANGED
@@ -628,6 +628,74 @@
628
628
  let pendingScopeKey = null;
629
629
  let installedItems = [];
630
630
 
631
+ // Same-tab memory only: no credentials, modal contents or API responses are retained.
632
+ // The Host owns this memory across iframe unmounts; reloading the Host clears it.
633
+ const viewStateMemory = (() => {
634
+ try {
635
+ const host = window.parent;
636
+ if (host.location.origin !== window.location.origin) return new Map();
637
+ const key = '__mcpConnectorViewStateV1';
638
+ if (!(host[key] instanceof host.Map)) host[key] = new host.Map();
639
+ return host[key];
640
+ } catch { return new Map(); }
641
+ })();
642
+ let viewStateReady = window.parent === window;
643
+ let viewRenderSequence = 0;
644
+ let viewStateLoading = false;
645
+ let pendingViewScroll = null;
646
+
647
+ function normalizeViewState(value) {
648
+ const text = (s) => typeof s === 'string' ? s.slice(0, 500) : '';
649
+ const category = (s) => CATEGORIES.some((item) => item.value === s) ? s : '';
650
+ return {
651
+ view: ['market', 'installed', 'tools'].includes(value?.view) ? value.view : 'market',
652
+ search: text(value?.search),
653
+ category: category(value?.category),
654
+ expanded: Array.isArray(value?.expanded) ? [...new Set(value.expanded.map(category).filter(Boolean))] : [],
655
+ filter: { connectionKey: text(value?.filter?.connectionKey), serverName: text(value?.filter?.serverName),
656
+ status: ['', 'pending', 'unknown', 'success', 'failed'].includes(value?.filter?.status) ? value.filter.status : '' },
657
+ offset: Number.isSafeInteger(value?.offset) && value.offset >= 0 ? Math.min(value.offset, 100000) : 0,
658
+ scroll: Number.isFinite(value?.scroll) ? Math.max(0, Math.min(value.scroll, 10000000)) : 0,
659
+ };
660
+ }
661
+
662
+ function viewStateKey() { return JSON.stringify([currentWorkspace?.workspaceId ?? null]); }
663
+
664
+ function saveViewState() {
665
+ if (!viewStateReady || viewStateLoading) return;
666
+ const key = viewStateKey();
667
+ viewStateMemory.delete(key);
668
+ viewStateMemory.set(key, normalizeViewState({ view, search: searchQuery, category: marketCategory,
669
+ expanded: [...expandedMarketSections], filter: explorerFilter, offset: explorerOffset, scroll: main.scrollTop }));
670
+ while (viewStateMemory.size > 32) viewStateMemory.delete(viewStateMemory.keys().next().value);
671
+ }
672
+
673
+ function restoreViewState() {
674
+ const state = normalizeViewState(viewStateMemory.get(viewStateKey()));
675
+ view = state.view; searchQuery = state.search; searchInput.value = searchQuery;
676
+ marketCategory = state.category;
677
+ expandedMarketSections.clear(); state.expanded.forEach((value) => expandedMarketSections.add(value));
678
+ explorerFilter = state.filter; explorerOffset = state.offset;
679
+ pendingViewScroll = state.scroll;
680
+ syncTabs();
681
+ }
682
+
683
+ function beginViewRender() {
684
+ if (pendingViewScroll === null) saveViewState();
685
+ viewStateLoading = true;
686
+ return { sequence: ++viewRenderSequence, workspace: viewStateKey(), view };
687
+ }
688
+
689
+ function isViewRenderCurrent(render) {
690
+ return render.sequence === viewRenderSequence && render.workspace === viewStateKey() && render.view === view;
691
+ }
692
+
693
+ function finishViewRender(render) {
694
+ if (!isViewRenderCurrent(render)) return;
695
+ if (pendingViewScroll !== null) { main.scrollTop = pendingViewScroll; pendingViewScroll = null; }
696
+ viewStateLoading = false;
697
+ }
698
+
631
699
  async function call(method, params, { timeoutMs: timeoutOverride } = {}) {
632
700
  const controller = new AbortController();
633
701
  const timeoutMs = timeoutOverride || API_TIMEOUTS[method] || DEFAULT_API_TIMEOUT_MS;
@@ -716,16 +784,20 @@
716
784
  const workspace = event.data.workspace;
717
785
  const previousWorkspaceId = currentWorkspace?.workspaceId ?? null;
718
786
  const previousWorkspaceOptions = JSON.stringify(availableWorkspaces);
787
+ const firstContext = !viewStateReady;
788
+ saveViewState();
719
789
  currentWorkspace = workspace && typeof workspace.workspaceId === 'string'
720
790
  ? { workspaceId: workspace.workspaceId, title: String(workspace.title || workspace.workspaceId) }
721
791
  : null;
722
792
  updateAvailableWorkspaces(event.data.workspaces);
723
- if ((currentWorkspace?.workspaceId ?? null) !== previousWorkspaceId || JSON.stringify(availableWorkspaces) !== previousWorkspaceOptions) {
793
+ if (firstContext || (currentWorkspace?.workspaceId ?? null) !== previousWorkspaceId || JSON.stringify(availableWorkspaces) !== previousWorkspaceOptions) {
724
794
  explorerSequence++; explorerDetailSequence++;
725
795
  explorerData = null; explorerOffset = 0;
726
796
  explorerFilter = { connectionKey: '', serverName: '', status: '' };
727
797
  closeModal(); closeDetail();
728
- if (view === 'tools') main.innerHTML = '';
798
+ viewStateReady = true;
799
+ if (firstContext || (currentWorkspace?.workspaceId ?? null) !== previousWorkspaceId) restoreViewState();
800
+ main.innerHTML = '';
729
801
  refresh();
730
802
  }
731
803
  return;
@@ -1275,10 +1347,13 @@
1275
1347
  }
1276
1348
 
1277
1349
  async function loadMarket({ checkHealth = true, preserveScroll = false } = {}) {
1350
+ const render = beginViewRender();
1351
+ try {
1278
1352
  const previousScrollTop = preserveScroll ? main.scrollTop : 0;
1279
1353
  syncMarketNav();
1280
1354
  main.innerHTML = '<div class="empty">加载中…</div>';
1281
1355
  const [r, status] = await Promise.all([call('catalog', workspaceParams()), call('status', workspaceParams())]);
1356
+ if (!isViewRenderCurrent(render)) return;
1282
1357
  if (status.ok) {
1283
1358
  updateAvailableWorkspaces(status.detail?.scope?.workspaces);
1284
1359
  setTabCount('installed', (status.detail?.items ?? []).length);
@@ -1300,9 +1375,10 @@
1300
1375
  if (checkHealth && !initialHealthCheckStarted && items.some((item) => item.connected?.length > 0)) {
1301
1376
  initialHealthCheckStarted = true;
1302
1377
  call('healthCheck', {}).then(() => {
1303
- if (view === 'market') loadMarket({ checkHealth: false });
1378
+ if (view === 'market') loadMarket({ checkHealth: false, preserveScroll: true });
1304
1379
  });
1305
1380
  }
1381
+ } finally { finishViewRender(render); }
1306
1382
  }
1307
1383
 
1308
1384
  function connectionLabels(d) {
@@ -1370,6 +1446,8 @@
1370
1446
  const formatObserved = (value) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚无记录';
1371
1447
 
1372
1448
  async function loadToolExplorer() {
1449
+ const render = beginViewRender();
1450
+ try {
1373
1451
  const sequence = ++explorerSequence;
1374
1452
  const workspaceId = currentWorkspace?.workspaceId;
1375
1453
  syncMarketNav();
@@ -1377,7 +1455,7 @@
1377
1455
  main.innerHTML = `<section id="tool-explorer"><p class="hint">搜索当前范围内已启用连接的工具缓存;不会执行工具。最近发现成功不代表每次调用均可成功。</p><div class="explorer-filters"><label>连接<select id="explorer-connection" aria-label="按连接筛选"></select></label><label>服务<select id="explorer-server" aria-label="按服务筛选"></select></label><label>发现状态<select id="explorer-status" aria-label="按发现状态筛选"><option value="">全部状态</option>${Object.entries(EXPLORER_STATUS).map(([key, label]) => `<option value="${key}">${label}</option>`).join('')}</select></label><button class="btn ghost" data-explorer-clear>清除筛选</button></div><p id="explorer-summary" role="status">正在读取缓存…</p><div id="explorer-connections"></div><div id="explorer-results"></div><div id="explorer-pages" class="m-actions"></div></section>`;
1378
1456
  }
1379
1457
  const response = await call('toolExplorer', { ...workspaceParams(), ...explorerFilter, query: searchQuery, offset: explorerOffset, limit: 20 });
1380
- if (sequence !== explorerSequence || view !== 'tools' || workspaceId !== currentWorkspace?.workspaceId) return;
1458
+ if (!isViewRenderCurrent(render) || sequence !== explorerSequence || view !== 'tools' || workspaceId !== currentWorkspace?.workspaceId) return;
1381
1459
  if (!response.ok) { $('#explorer-summary').textContent = response.message; return; }
1382
1460
  explorerData = response.detail;
1383
1461
  const { connections = [], selectedConnections = [], items = [], total = 0 } = explorerData;
@@ -1392,8 +1470,10 @@
1392
1470
  $('#explorer-connections').innerHTML = `<details ${explorerFilter.connectionKey || !total ? 'open' : ''}><summary>连接状态与故障处理(${selectedConnections.length})</summary>${selectedConnections.map((c) => `<div class="explorer-card"><strong>${esc(c.connectionName)}</strong><div>${esc(c.serverName)} · ${esc(EXPLORER_STATUS[c.status] || c.status)} · 缓存 ${c.toolCount} 个工具</div><div class="hint">最近发现:${esc(formatObserved(c.checkedAt))};最后成功:${esc(formatObserved(c.observedAt))}${c.stale ? ' · 缓存超过 24 小时' : ''}${c.cacheTruncated ? ' · 缓存清单已截断' : ''}</div>${c.diagnostic ? `<div>${esc(c.diagnostic.stageLabel)}:${esc(c.diagnostic.message)}<br>建议:${esc(c.diagnostic.action)}</div>` : ''}${c.retryReason === 'auth' ? '<p>鉴权失败已暂停后台重试,请到已安装页更新凭据或重新授权。</p>' : c.nextDiscoveryAt ? `<p class="hint">下次后台发现不早于:${esc(formatObserved(c.nextDiscoveryAt))}(页面保持连接时)</p>` : ''}<div class="m-actions"><button class="btn ghost" data-explorer-action="discover" data-key="${esc(c.connectionKey)}" ${explorerBusy.has(c.connectionKey) ? 'disabled' : ''}>重新发现工具</button><button class="btn ghost" data-explorer-action="check" data-key="${esc(c.connectionKey)}" ${explorerBusy.has(c.connectionKey) ? 'disabled' : ''}>检查连接</button></div></div>`).join('') || '<p>当前筛选下没有连接。</p>'}</details>`;
1393
1471
  $('#explorer-results').innerHTML = items.map((item, index) => `<article class="explorer-card"><strong>${esc(item.name)}</strong>${item.title !== item.name ? `<div>${esc(item.title)}</div>` : ''}<p>${esc(item.description || '暂无描述')}</p><div class="hint">来源:${esc(item.connection.connectorName)} / ${esc(item.connection.connectionName)} / ${esc(item.serverName)}<br>${esc(EXPLORER_STATUS[item.connection.status])} · 最后成功缓存 ${esc(formatObserved(item.observedAt))}${item.stale ? ' · 已超过 24 小时' : ''}</div><button class="btn ghost" data-explorer-detail="${index}">参数详情</button></article>`).join('') || `<p class="empty">${connections.length ? '没有匹配工具。请调整关键词或筛选;也可展开连接状态检查是否尚未发现或读取失败。' : '当前范围没有已启用连接。请先添加连接,或选择对应工作区。'}</p>`;
1394
1472
  $('#explorer-pages').innerHTML = `<button class="btn ghost" data-explorer-page="prev" ${explorerOffset === 0 ? 'disabled' : ''}>上一页</button><span>${Math.floor(explorerOffset / 20) + 1} / ${Math.max(1, Math.ceil(total / 20))}</span><button class="btn ghost" data-explorer-page="next" ${explorerOffset + 20 >= total ? 'disabled' : ''}>下一页</button>`;
1473
+ } finally { finishViewRender(render); }
1395
1474
  }
1396
1475
 
1476
+ main.addEventListener('scroll', () => saveViewState(), { passive: true });
1397
1477
  main.addEventListener('change', (event) => {
1398
1478
  const fields = { 'explorer-connection': 'connectionKey', 'explorer-server': 'serverName', 'explorer-status': 'status' };
1399
1479
  const field = fields[event.target.id];
@@ -1429,10 +1509,13 @@
1429
1509
  });
1430
1510
 
1431
1511
  async function loadInstalled({ preserveScroll = false } = {}) {
1512
+ const render = beginViewRender();
1513
+ try {
1432
1514
  const previousScrollTop = preserveScroll ? main.scrollTop : 0;
1433
1515
  syncMarketNav();
1434
1516
  main.innerHTML = '<div class="empty">加载中…</div>';
1435
1517
  const [r, migration] = await Promise.all([call('status', workspaceParams()), call('migrationPreview', { scanStored: false })]);
1518
+ if (!isViewRenderCurrent(render)) return;
1436
1519
  if (!r.ok) {
1437
1520
  main.innerHTML = `<div class="empty">${esc(r.message)}</div>`;
1438
1521
  if (preserveScroll) main.scrollTop = previousScrollTop;
@@ -1456,6 +1539,7 @@
1456
1539
  const rollbackDisabled = scopeRollbackRevision === null ? 'disabled' : '';
1457
1540
  main.innerHTML = `${migrationHtml}<div class="stats"><span class="count">共 <strong>${filtered.length}</strong> 条连接${searchQuery ? ` · 搜索「${esc(searchQuery)}」` : ''} · 作用域 revision=${scopeRevision}</span><button class="btn ghost" id="scope-rollback" type="button" ${rollbackDisabled} onclick="window.__mcp.rollbackScope()">回滚上次范围变更</button><button class="btn ghost" id="config-backup" type="button" onclick="window.__mcp.openConfigBackup()">配置备份</button><button class="btn ghost" type="button" title="扫描企查查旧 OAuth 插件;不会删除旧插件或原凭据" onclick="window.__mcp.scanLegacy()">检测企查查旧授权</button></div><div class="rows">${filtered.map(rowHtml).join('')}</div>${communityCtaHtml()}`;
1458
1541
  if (preserveScroll) main.scrollTop = previousScrollTop;
1542
+ } finally { finishViewRender(render); }
1459
1543
  }
1460
1544
 
1461
1545
  function rowHtml(r) {
@@ -2425,6 +2509,7 @@
2425
2509
  }
2426
2510
  });
2427
2511
  window.addEventListener('pagehide', () => {
2512
+ saveViewState();
2428
2513
  notifyDetailState(false);
2429
2514
  clearTimeout(statusRefreshTimer);
2430
2515
  statusEventSource?.close();
@@ -2445,6 +2530,7 @@
2445
2530
  const categoryFilter = event.target.closest('[data-category-filter]');
2446
2531
  if (categoryFilter) {
2447
2532
  marketCategory = categoryFilter.dataset.categoryFilter || '';
2533
+ pendingViewScroll = null; main.scrollTop = 0;
2448
2534
  loadMarket({ checkHealth: false });
2449
2535
  }
2450
2536
  });
@@ -2472,6 +2558,7 @@
2472
2558
  const btn = e.target.closest('button[data-view]');
2473
2559
  if (!btn) return;
2474
2560
  view = btn.dataset.view;
2561
+ pendingViewScroll = null; main.scrollTop = 0;
2475
2562
  explorerSequence++; explorerDetailSequence++;
2476
2563
  syncTabs();
2477
2564
  refresh();
@@ -2479,6 +2566,7 @@
2479
2566
 
2480
2567
  searchInput.addEventListener('input', (e) => {
2481
2568
  searchQuery = e.target.value.trim();
2569
+ pendingViewScroll = null; main.scrollTop = 0;
2482
2570
  explorerSequence++; explorerOffset = 0;
2483
2571
  clearTimeout(searchTimer);
2484
2572
  if (view === 'tools') { searchTimer = setTimeout(() => { if (view === 'tools') void loadToolExplorer(); }, 200); return; }
@@ -2486,6 +2574,7 @@
2486
2574
  refresh();
2487
2575
  });
2488
2576
 
2577
+ if (viewStateReady) restoreViewState();
2489
2578
  requestWorkspaceContext();
2490
2579
  connectStatusEvents();
2491
2580
  refresh();