dsh-plugin-deck-studio 0.2.25 → 0.2.27

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
@@ -2458,7 +2458,12 @@ export function createService(config, logger, env = process.env) {
2458
2458
  appendixCount: adapterOf(deck.deckId)?.available() ? adapterOf(deck.deckId).overview().appendixCount ?? 0 : workspace.manifest(deck.deckId)?.appendix?.count ?? 0,
2459
2459
  appendixRows: workspace.manifest(deck.deckId)?.appendix?.rows ?? [],
2460
2460
  }));
2461
- const activeId = (requestedDeckId && decks.some((deck) => deck.deckId === requestedDeckId) ? requestedDeckId : null) ?? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null;
2461
+ // F-205:显式点名要打开某本 deck、但这次快照里还没有它(刚导入 / 刚创建,列表还没跟上)时,
2462
+ // 不要静默退回第一本——前端拿到 requestedMissing 会立刻重试,避免「导入成功却打开别的 deck」。
2463
+ const requestedMissing = Boolean(requestedDeckId) && !decks.some((deck) => deck.deckId === requestedDeckId);
2464
+ const activeId = requestedMissing
2465
+ ? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null
2466
+ : requestedDeckId ?? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null;
2462
2467
  // P0:进 deck 时先体检(规格 §2「进入某本 deck 时,每次,读 manifest 之前」)。
2463
2468
  // mismatch / partial 时不 ensureDeck:那会凭空建出空的 decks/<id>/,旧图也找不回(禁止项)。
2464
2469
  const dataDirInfo = dataDirStateOf({ config, store, workspace, adapters, env }, activeId);
@@ -2575,6 +2580,9 @@ export function createService(config, logger, env = process.env) {
2575
2580
  renderSettings: renderSettings(),
2576
2581
  decks,
2577
2582
  active,
2583
+ // F-205:这次请求点名的 deck 是否存在(true = 列表还没跟上,前端重试即可)。
2584
+ requestedDeckId: requestedDeckId ?? null,
2585
+ requestedMissing,
2578
2586
  designProjects: designProjects(),
2579
2587
  bindings: listBindings(),
2580
2588
  proposals: base.pendingProposals,
@@ -2677,6 +2685,25 @@ function noteDataDirTransitions(serviceLike, report, trigger = "poll") {
2677
2685
  /**
2678
2686
  * 数据目录载荷:黄条 / 设置页 / 自检共用(不重算解析,只合并最新体检)。
2679
2687
  */
2688
+ /**
2689
+ * 「指针覆盖了 profile 配置」的事实(没有冲突返回 null)。
2690
+ * 这是本次事故的根因:web profile 没配 dataRoot、desktop 配了仓库 .deck,而指针全机共享——
2691
+ * 谁写的指针谁说了算,界面上却只显示「当前 = 指针」,看不出 profile 里还写着另一个目录。
2692
+ */
2693
+ function pointerConflictOf(resolved = {}) {
2694
+ const profileDataRoot = String(resolved.profileDataRoot ?? "").trim();
2695
+ const fact = resolved.dataRoot ?? {};
2696
+ if (!profileDataRoot || fact.source !== "pointer") return null;
2697
+ if (String(fact.value ?? "") === profileDataRoot) return null;
2698
+ return {
2699
+ profileDataRoot,
2700
+ pointerDataRoot: String(fact.value ?? ""),
2701
+ pointerFile: String(resolved.pointerFile?.path ?? ""),
2702
+ setBy: String(resolved.pointerFile?.setBy ?? ""),
2703
+ updatedAt: String(resolved.pointerFile?.updatedAt ?? ""),
2704
+ };
2705
+ }
2706
+
2680
2707
  function dataDirStateOf(serviceLike, deckId = null, { refresh = false, countImages = false } = {}) {
2681
2708
  const { config } = serviceLike;
2682
2709
  const resolved = config.dataDir ?? {};
@@ -2693,6 +2720,8 @@ function dataDirStateOf(serviceLike, deckId = null, { refresh = false, countImag
2693
2720
  firstRun: resolved.firstRun === true,
2694
2721
  processCwd: resolved.processCwd ?? process.cwd(),
2695
2722
  sourceLabel: resolved.sourceLabel ?? "",
2723
+ // 指针赢过 profile 时的「为什么」:profile 配的是哪个目录、指针文件在哪。前端只在有冲突时多画一行。
2724
+ pointerConflict: pointerConflictOf(resolved),
2696
2725
  banner: dataDirBannerFacts({ report, deckId, envOverride: resolved.envOverride?.active === true, dataRoot: config.dataRoot }),
2697
2726
  decks: report.decks,
2698
2727
  candidates: report.candidates,
package/lib/data-dir.js CHANGED
@@ -232,6 +232,8 @@ function decksDirEmpty(dataRoot) {
232
232
  /** 冲突 warn 只打一次:5s 轮询与多次重建服务都不重复刷屏。 */
233
233
  let warnedDataRootConflict = false;
234
234
  let warnedProjectsRootConflict = false;
235
+ /** 指针 vs profile 的冲突按「指针→profile」这一对去重:同一处冲突不刷屏,换了目录再提醒一次。 */
236
+ const warnedPointerProfilePairs = new Set();
235
237
 
236
238
  export function resolveDataDir({ env = process.env, profileDataRoot = "", profileProjectsRoot = "", logger = null } = {}) {
237
239
  const envDataRoot = normalizeDirPath(env[DATA_DIR_ENV] ?? "", env);
@@ -253,6 +255,13 @@ export function resolveDataDir({ env = process.env, profileDataRoot = "", profil
253
255
  const pointerDataRoot = pointer.error || !isAbsolute(pointer.dataRoot) ? "" : pointer.dataRoot;
254
256
  const pointerProjectsRoot = pointer.error || !isAbsolute(pointer.projectsRoot) ? "" : pointer.projectsRoot;
255
257
 
258
+ // 指针(用户点「切到这个目录」写下的)优先级高于 profile;两者不一致时以前是**完全静默**的,
259
+ // 表现成「我这本 deck 怎么没了」。留一条 warn,日志里能找到是谁把目录换掉的。
260
+ const pointerProfileKey = pointerDataRoot && profileRoot && pointerDataRoot !== profileRoot ? `${pointerDataRoot}→${profileRoot}` : "";
261
+ if (warn && pointerProfileKey && !warnedPointerProfilePairs.has(pointerProfileKey)) {
262
+ warnedPointerProfilePairs.add(pointerProfileKey);
263
+ warn(`dataRoot: 指针文件(${pointerDataRoot})与 profile 配置(${profileRoot})不一致,已按指针生效;要让 profile 配置生效就删掉指针文件 ${pointer.path}`);
264
+ }
256
265
  const dataRoot = envDataRoot || pointerDataRoot || profileRoot || normalizeDirPath(defaultDataRoot(env), env);
257
266
  const source = envDataRoot ? "env" : pointerDataRoot ? "pointer" : profileRoot ? "profile" : "default";
258
267
  const projectsSource = envProjectsRoot ? "env" : pointerProjectsRoot ? "pointer" : profileProjects ? "profile" : "default";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-deck-studio",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "description": "Deckline:DSH 的 image deck / PPT 工作台插件——原生 Chat + 整页图片 deck 工作区 + 设计风格库;用对话做 PPT、按页出图、版本可追踪、导出 PDF/PPTX。",
5
5
  "icon": "assets/icon.svg",
6
6
  "homepage": "https://github.com/yuna78/dsh-deck-studio#readme",
@@ -188,6 +188,13 @@ Deckline 的数据分两处:**你的 deck 源**(`projectsRoot`,纯 md +
188
188
  `dataRoot` / `DSH_DECK_DATA_ROOT`,两边就各看各的。所以「换到另一个端口,内容变空 / 标题在但页面空白」先别怀疑端口:
189
189
  去那个实例的 ⚙ 看「当前数据目录」,再和这边显示的路径比一比。
190
190
  **换端口不改数据,换 dataRoot 才改数据。**
191
+ - **指针的优先级高于 profile**(「打开一片空白 + 黄条」最常见的真凶):`$DSH_HOME/deckline/location.json`
192
+ 是全机共享的「用户选择」,解析顺序是 `env > 指针 > profile patch > 默认`。在一个实例里点过
193
+ 「切到这个目录」,同一个 `$DSH_HOME` 下的其它 profile 也会跟着换目录——**哪怕它在 patch 里写了 `dataRoot`**。
194
+ 想让各端稳定一致,二选一:① 每个 profile 的 patch 写**同一个** `dataRoot`,并删掉
195
+ `~/.dsh/deckline/location.json`;② 只用默认目录,不要在界面里切。真有冲突时黄条会多写一行
196
+ 「你的 profile 里配的是 A,但指针文件把它改成了 B——指针优先;切换只改指针,不动文件」,
197
+ 日志里也有一条 `dataRoot: 指针文件(B)与 profile 配置(A)不一致` 的 warn(每次冲突只打一次)。
191
198
  - 报问题不用自己拼路径:⚙ →「当前数据目录与自检」底部 **「复制诊断信息」** 一键复制(内容由插件生成、已脱敏,
192
199
  不含 key),贴出来就包含 cwd / dataRoot(含来源)/ projectsRoot / 每本 deck 有没有 `script/current.md`。
193
200
  - 开发环境用独立 `dataRoot`,不要把运行数据写进仓库(见 §7「开发数据目录」)。
package/ui/workbench.html CHANGED
@@ -830,9 +830,15 @@ function mdToHtml(markdown, options = {}) {
830
830
  }
831
831
 
832
832
  /* ---------------- 数据加载 ---------------- */
833
+ /* F-205:并发保护。导入 / 切 deck 会连着打好几次 workbench 接口,先发的旧请求如果后回来,
834
+ * 会把刚切到的新 deck 覆盖成旧数据(表现:导入成功却停在上一个 / 第一个 deck)。
835
+ * 只让**最后发起**的那次结果落地。 */
836
+ let LOAD_SEQ = 0;
833
837
  async function loadWorkbench(deckId = null) {
838
+ const seq = ++LOAD_SEQ;
834
839
  try {
835
840
  const next = await route("workbench", deckId ? { deckId } : undefined);
841
+ if (seq !== LOAD_SEQ) return;
836
842
  const active = next.active || {};
837
843
  const signature = JSON.stringify([
838
844
  active.deckId,
@@ -865,6 +871,7 @@ async function loadWorkbench(deckId = null) {
865
871
  }
866
872
  }
867
873
  } catch (error) {
874
+ if (seq !== LOAD_SEQ) return;
868
875
  STATE.demo = true;
869
876
  STATE.data = demoData();
870
877
  renderChrome();
@@ -1521,11 +1528,16 @@ function renderDataDirBanner(data) {
1521
1528
  : "⚠ 这本 deck 的正文和图片不在当前数据目录";
1522
1529
  const currentRoot = dd?.dataRoot?.value || banner.dataRoot || "—";
1523
1530
  const sourceNote = dd?.sourceLabel ? `(${esc(dd.sourceLabel)})` : "";
1531
+ // 指针赢过 profile 时把「为什么」写出来:否则只看到「当前 = 指针」,不知道 profile 里还写着另一个目录。
1532
+ const conflict = dd?.pointerConflict && dd.pointerConflict.profileDataRoot !== dd?.dataRoot?.value ? dd.pointerConflict : null;
1533
+ const conflictLine = conflict
1534
+ ? `<span class="data-dir-line data-dir-conflict">你的 profile 里配的是 <code>${esc(conflict.profileDataRoot)}</code>,但指针文件把它改成了上面这个目录${conflict.setBy ? `(${esc(conflict.setBy)})` : ""}——<b>指针优先</b>。点「切到这个目录」只改指针,不会删改任何 deck 文件。</span>`
1535
+ : "";
1524
1536
  const candidates = banner.candidates || [];
1525
1537
  const rows = candidates.length
1526
1538
  ? candidates.map((candidate) => `<div class="data-dir-candidate"><span class="data-dir-path">已发现:${esc(candidate.dataRoot)}</span><span class="data-dir-count">(${Number(candidate.deckCount) || 0} 本 deck · ${Number(candidate.imageCount) || 0} 张图)</span><button class="btn ghost" data-data-root="${esc(candidate.dataRoot)}"${envOverride ? " disabled" : ""}>切到这个目录</button></div>`).join("")
1527
1539
  : `<div class="data-dir-candidate data-dir-empty"><span>没找到这本 deck 的历史数据。如果你在另一个 DSH 实例里出过图,请在那个实例的『设置』里看它显示的数据目录,然后把它填到下面。</span><input id="dataDirManualInput" placeholder="/绝对路径"${envOverride ? " disabled" : ""}><button class="btn ghost" id="dataDirManualSwitch"${envOverride ? " disabled" : ""}>用这个目录</button></div>`;
1528
- box.innerHTML = `<div class="data-dir-banner-body"><div class="data-dir-banner-main"><b>${title}</b><span class="data-dir-line" title="换端口不改数据,换 dataRoot 才改数据。">当前:<code>${esc(currentRoot)}</code>${sourceNote}</span>${rows}</div><div class="data-dir-banner-actions"><button class="btn ghost" id="dataDirOpenSettings">打开设置</button><button class="btn ghost" id="dataDirCopyDiag">复制诊断信息</button>${envOverride ? `<span class="data-dir-readonly">由环境变量 ${esc(dd?.envOverride?.name || "DSH_DECK_DATA_ROOT")} 指定(只读)</span>` : ""}</div></div>`;
1540
+ box.innerHTML = `<div class="data-dir-banner-body"><div class="data-dir-banner-main"><b>${title}</b><span class="data-dir-line" title="换端口不改数据,换 dataRoot 才改数据。">当前:<code>${esc(currentRoot)}</code>${sourceNote}</span>${conflictLine}${rows}</div><div class="data-dir-banner-actions"><button class="btn ghost" id="dataDirOpenSettings">打开设置</button><button class="btn ghost" id="dataDirCopyDiag">复制诊断信息</button>${envOverride ? `<span class="data-dir-readonly">由环境变量 ${esc(dd?.envOverride?.name || "DSH_DECK_DATA_ROOT")} 指定(只读)</span>` : ""}</div></div>`;
1529
1541
  box.hidden = false;
1530
1542
  box.querySelectorAll("[data-data-root]").forEach((node) => node.addEventListener("click", () => switchDataDirTo(node.dataset.dataRoot)));
1531
1543
  box.querySelector("#dataDirOpenSettings")?.addEventListener("click", () => openSettingsPanel());
@@ -5119,6 +5131,32 @@ getEl("impList").addEventListener("click", (event) => {
5119
5131
  renderImportPicker();
5120
5132
  }
5121
5133
  });
5134
+ /**
5135
+ * F-205:导入成功后必须停在**刚导入的这本** deck。
5136
+ *
5137
+ * 导入要跑 pdftoppm(几秒到几十秒),期间 5s 轮询、宿主指令、刚创建 deck 的列表延迟都可能
5138
+ * 把视图切到别的 deck(典型:工作台列表还没收录新 deck 时接口退回第一本)。
5139
+ * 这里在成功后的 ~3 秒里以新 deck 为准核对几次;用户自己点了/按了键就立刻停手,不抢手动导航。
5140
+ */
5141
+ async function openImportedDeck(deckId) {
5142
+ let userTookOver = false;
5143
+ const flag = () => { userTookOver = true; };
5144
+ window.addEventListener("pointerdown", flag, true);
5145
+ window.addEventListener("keydown", flag, true);
5146
+ try {
5147
+ await showDeck(deckId);
5148
+ for (const gap of [500, 900, 1500]) {
5149
+ await new Promise((resolve) => setTimeout(resolve, gap));
5150
+ if (userTookOver) return;
5151
+ if (STATE.view === "deck" && STATE.deckId === deckId) continue;
5152
+ await showDeck(deckId);
5153
+ }
5154
+ } finally {
5155
+ window.removeEventListener("pointerdown", flag, true);
5156
+ window.removeEventListener("keydown", flag, true);
5157
+ }
5158
+ }
5159
+
5122
5160
  getEl("impSubmit").addEventListener("click", async () => {
5123
5161
  if (!IMP.selected || IMP.busy) return;
5124
5162
  IMP.busy = true;
@@ -5143,7 +5181,8 @@ getEl("impSubmit").addEventListener("click", async () => {
5143
5181
  getEl("impState").textContent = parts.join(" · ");
5144
5182
  closeImportPdfModal();
5145
5183
  flash(`已导入「${result.deck?.label || ""}」:${result.pages} 页整页图`, "ok");
5146
- if (result.deck?.deckId) await showDeck(result.deck.deckId);
5184
+ // F-205:导入成功后以新 deck 为准(详见 openImportedDeck)。
5185
+ if (result.deck?.deckId) await openImportedDeck(result.deck.deckId);
5147
5186
  } catch (error) {
5148
5187
  getEl("impState").textContent = "导入失败:" + importErrText(error?.code, error?.message);
5149
5188
  } finally {
@@ -6322,7 +6361,11 @@ function backToManual(box) {
6322
6361
  STATE.homeSection = match ? match[1] : "decks";
6323
6362
  showHome();
6324
6363
  }
6325
- setInterval(() => loadWorkbench(STATE.deckId), 5000);
6364
+ // F-205:导入(pdftoppm 可能要几十秒)期间别用旧 deckId 轮询,否则回来晚的那次会把新 deck 顶掉。
6365
+ setInterval(() => {
6366
+ if (IMP.busy) return;
6367
+ loadWorkbench(STATE.deckId);
6368
+ }, 5000);
6326
6369
 
6327
6370
  /**
6328
6371
  * 0.2.21:boot 完成 → 把启动期间暂存的「打开某本 deck」执行掉,再向宿主报 ready。