@heycys/gallery 0.2.0 → 0.2.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.
Files changed (130) hide show
  1. package/THIRD-PARTY-NOTICES.md +1 -1
  2. package/browser/editor/components/icon/page.mjs +6 -4
  3. package/browser/editor/components/icons.mjs +2 -2
  4. package/browser/editor/page/clipboard/import/read.mjs +20 -2
  5. package/browser/shared/document-copy.mjs +43 -0
  6. package/browser/shared/drafts.mjs +10 -3
  7. package/browser/shared/emoji/icon-value.mjs +2 -2
  8. package/browser/shared/extensions.mjs +83 -7
  9. package/browser/shared/i18n/en.mjs +10 -0
  10. package/browser/shared/outline-shape.mjs +6 -0
  11. package/browser/shared/outline.mjs +135 -10
  12. package/browser/shared/workspace-columns.mjs +11 -7
  13. package/dist/{chunk-QE5RG76S.mjs → chunk-2GKGEVMR.mjs} +11 -7
  14. package/dist/{chunk-HYMEEXKN.mjs → chunk-2RKIHJI2.mjs} +1 -1
  15. package/dist/{chunk-742D2WWJ.mjs → chunk-36FIRJIF.mjs} +2 -2
  16. package/dist/{chunk-EAIFITAV.mjs → chunk-3A5I2GJK.mjs} +1 -1
  17. package/dist/{chunk-7JDX4E65.mjs → chunk-3OFXMN6N.mjs} +1 -1
  18. package/dist/chunk-46ACXOU5.mjs +2 -0
  19. package/dist/{chunk-PUOQ65NC.mjs → chunk-4AMTKWDO.mjs} +1 -1
  20. package/dist/{chunk-HPOI4ZX7.mjs → chunk-4DNHHJSL.mjs} +1 -1
  21. package/dist/chunk-5DPABPBI.mjs +2 -0
  22. package/dist/{chunk-ALZ6ZENZ.mjs → chunk-5IIH335D.mjs} +1 -1
  23. package/dist/{chunk-EP25HDAJ.mjs → chunk-5L26JUYA.mjs} +1 -1
  24. package/dist/{chunk-FDMAXMQA.mjs → chunk-5MEXZ2UF.mjs} +1 -1
  25. package/dist/{chunk-ZHEHPMBK.mjs → chunk-6MPKCH3P.mjs} +1 -1
  26. package/dist/{chunk-C564NU2H.mjs → chunk-74RPP2M2.mjs} +1 -1
  27. package/dist/{chunk-YNAWBAYN.mjs → chunk-7M7UPWU6.mjs} +1 -1
  28. package/dist/{chunk-YROX2O22.mjs → chunk-7N2LPG33.mjs} +1 -1
  29. package/dist/{chunk-VS7JIGJW.mjs → chunk-7XDYK3UC.mjs} +1 -1
  30. package/dist/chunk-AEMVZYZQ.mjs +166 -0
  31. package/dist/{chunk-TR7TWGHB.mjs → chunk-AFGUR7SY.mjs} +1 -1
  32. package/dist/{chunk-K5XATEOO.mjs → chunk-AMDGVDNV.mjs} +1 -1
  33. package/dist/{chunk-JA7G53PC.mjs → chunk-ASAXY2DJ.mjs} +1 -1
  34. package/dist/{chunk-W7665ZEC.mjs → chunk-BYSF5UMM.mjs} +2 -2
  35. package/dist/{chunk-U3SSPL4K.mjs → chunk-CCSGFCLZ.mjs} +1 -1
  36. package/dist/{chunk-JLTZF4LA.mjs → chunk-CG3UHDKH.mjs} +1 -1
  37. package/dist/{chunk-YLPSMDIS.mjs → chunk-CPS4SRPF.mjs} +2 -2
  38. package/dist/{chunk-AHN3NFKJ.mjs → chunk-CZ5KJSGK.mjs} +1 -1
  39. package/dist/chunk-DAMVDXR3.mjs +2 -0
  40. package/dist/{chunk-WQDQWCX5.mjs → chunk-DH6GMDNN.mjs} +1 -1
  41. package/dist/{chunk-U5XL7D6A.mjs → chunk-DHB7UZ7F.mjs} +1 -1
  42. package/dist/{chunk-OEXWRJME.mjs → chunk-DKR7LFIY.mjs} +1 -1
  43. package/dist/chunk-DLFZJJEE.mjs +2 -0
  44. package/dist/{chunk-DJISHPUL.mjs → chunk-FCK6NXON.mjs} +1 -1
  45. package/dist/{chunk-IV64FFI4.mjs → chunk-FX42XU6J.mjs} +1 -1
  46. package/dist/{chunk-FETSREUN.mjs → chunk-FXQBADMN.mjs} +1 -1
  47. package/dist/{chunk-BFJGHSQN.mjs → chunk-G4YP7GVC.mjs} +1 -1
  48. package/dist/{chunk-V6XZ4VIR.mjs → chunk-GWOYMENX.mjs} +1 -1
  49. package/dist/chunk-HATA2W7X.mjs +2 -0
  50. package/dist/{chunk-YI43RG4P.mjs → chunk-HBAUKKQH.mjs} +1 -1
  51. package/dist/{chunk-MAFIGVXY.mjs → chunk-HM4E43VM.mjs} +1 -1
  52. package/dist/{chunk-OFHXGWOX.mjs → chunk-JI6T3A4Y.mjs} +1 -1
  53. package/dist/{chunk-5XA2ZBAE.mjs → chunk-JJNRIFND.mjs} +2 -2
  54. package/dist/chunk-JL6R4FG4.mjs +4 -0
  55. package/dist/{chunk-UMHUSOPJ.mjs → chunk-JXLSHVJ2.mjs} +1 -1
  56. package/dist/{chunk-VFX2KNHP.mjs → chunk-KFZBYDIS.mjs} +1 -1
  57. package/dist/{chunk-ZTHICSSF.mjs → chunk-KH24LWAT.mjs} +1 -1
  58. package/dist/{chunk-GHOYKMQ3.mjs → chunk-KUILYI7H.mjs} +1 -1
  59. package/dist/{chunk-5K44HLTZ.mjs → chunk-L7W624FD.mjs} +1 -1
  60. package/dist/{chunk-J5PUIV3Y.mjs → chunk-LE44MD3O.mjs} +1 -1
  61. package/dist/{chunk-4K5KC6V2.mjs → chunk-LV24RY2E.mjs} +1 -1
  62. package/dist/{chunk-43B75J5T.mjs → chunk-MKTX5PGV.mjs} +1 -1
  63. package/dist/{chunk-LKX55H76.mjs → chunk-NAGMRVW4.mjs} +1 -1
  64. package/dist/{chunk-DAZL73VK.mjs → chunk-NFABH3QW.mjs} +2 -2
  65. package/dist/{chunk-U7NZI6N4.mjs → chunk-NIW7F7LU.mjs} +2 -2
  66. package/dist/{chunk-PAREWQGB.mjs → chunk-NWRZXELO.mjs} +1 -1
  67. package/dist/{chunk-ZWWC4KLF.mjs → chunk-OU3S466R.mjs} +1 -1
  68. package/dist/{chunk-MN7BZL4R.mjs → chunk-PAFSFVYB.mjs} +1 -1
  69. package/dist/chunk-QCEATQC4.mjs +2 -0
  70. package/dist/{chunk-Y34NR5JL.mjs → chunk-QJ3TFJLQ.mjs} +1 -1
  71. package/dist/{chunk-IA4PSIMD.mjs → chunk-QK3VISCA.mjs} +1 -1
  72. package/dist/{chunk-7OG26PGF.mjs → chunk-QXVNUASA.mjs} +1 -1
  73. package/dist/{chunk-GRAXKKG4.mjs → chunk-QZ35UAB4.mjs} +1 -1
  74. package/dist/{chunk-AR5D57ZD.mjs → chunk-S2Y7VI4H.mjs} +2 -2
  75. package/dist/{chunk-HXG7EOPJ.mjs → chunk-SAHS56QM.mjs} +1 -1
  76. package/dist/{chunk-3MBRL2PM.mjs → chunk-SOD23RGL.mjs} +1 -1
  77. package/dist/chunk-TDWJKOLN.mjs +2 -0
  78. package/dist/{chunk-ITOH5EGO.mjs → chunk-TOE6DPLH.mjs} +1 -1
  79. package/dist/chunk-UGO5N3EI.mjs +15 -0
  80. package/dist/{chunk-4OKWZY5E.mjs → chunk-V5JRSGZF.mjs} +1 -1
  81. package/dist/{chunk-BZ5RAF7L.mjs → chunk-VA3KAQZD.mjs} +1 -1
  82. package/dist/{chunk-HDWTZ763.mjs → chunk-VG5JEIUQ.mjs} +1 -1
  83. package/dist/{chunk-POTA2FV4.mjs → chunk-VGTMLQWZ.mjs} +1 -1
  84. package/dist/{chunk-UYVCQK3H.mjs → chunk-VKSRM7EY.mjs} +1 -1
  85. package/dist/{chunk-ZDLPUOAG.mjs → chunk-VNUWREXU.mjs} +1 -1
  86. package/dist/{chunk-AGDILNWB.mjs → chunk-VPVOAJ7X.mjs} +1 -1
  87. package/dist/{chunk-CMA72MAG.mjs → chunk-WCWYNFDF.mjs} +1 -1
  88. package/dist/{chunk-ZQS6H473.mjs → chunk-WTEGUPFG.mjs} +1 -1
  89. package/dist/{chunk-R2ZDLJYT.mjs → chunk-XKOHV4H6.mjs} +1 -1
  90. package/dist/{chunk-MXUBKDRO.mjs → chunk-XMKHCUI6.mjs} +1 -1
  91. package/dist/{chunk-67N2QJYT.mjs → chunk-XQRUG4G3.mjs} +1 -1
  92. package/dist/{chunk-PGDE24N5.mjs → chunk-YBB5LWZK.mjs} +1 -1
  93. package/dist/{chunk-6N3PX4XX.mjs → chunk-YFJU3O7G.mjs} +1 -1
  94. package/dist/chunk-YPPJVDQI.mjs +2 -0
  95. package/dist/chunk-ZANMKAWS.mjs +2 -0
  96. package/dist/{chunk-6QBHU4DF.mjs → chunk-ZCP7BTOZ.mjs} +1 -1
  97. package/dist/{chunk-ERLAIBOA.mjs → chunk-ZEY2RLBT.mjs} +2 -2
  98. package/dist/{chunk-SQ6Y2ZMA.mjs → chunk-ZO7SFB2E.mjs} +2 -2
  99. package/dist/cli.mjs +1 -1
  100. package/dist/server.mjs +1 -1
  101. package/dist/worker.mjs +1 -1
  102. package/kit/math.js +1 -1
  103. package/kit/report.css +3 -3
  104. package/package.json +1 -1
  105. package/release.json +5 -5
  106. package/skill/SKILL.md +8 -7
  107. package/skill/references/extensions.md +11 -7
  108. package/web/assets/index-BHC5ZOMh.js +15 -0
  109. package/web/assets/index-xP0uw3a0.css +1 -0
  110. package/web/index.html +2 -2
  111. package/dist/chunk-6KBYRA2C.mjs +0 -2
  112. package/dist/chunk-F6HVDZR4.mjs +0 -2
  113. package/dist/chunk-J43Z6LSS.mjs +0 -2
  114. package/dist/chunk-JZT7I2OJ.mjs +0 -2
  115. package/dist/chunk-L2SUIS5S.mjs +0 -2
  116. package/dist/chunk-L7UP52JO.mjs +0 -2
  117. package/dist/chunk-LM4XFQHZ.mjs +0 -2
  118. package/dist/chunk-MTYYHM2G.mjs +0 -15
  119. package/dist/chunk-NAMN3RAF.mjs +0 -2
  120. package/dist/chunk-ORID3AE7.mjs +0 -166
  121. package/dist/chunk-SWUTTZCN.mjs +0 -2
  122. package/dist/chunk-UJQJKNGK.mjs +0 -2
  123. package/web/assets/index-BM_K-Auu.css +0 -1
  124. package/web/assets/index-DRslOBiv.js +0 -15
  125. /package/dist/{chunk-YEY3RSFQ.mjs → chunk-5MMN3N5J.mjs} +0 -0
  126. /package/dist/{chunk-UH4SIXTR.mjs → chunk-74UW7QSL.mjs} +0 -0
  127. /package/dist/{chunk-W7XHE4CT.mjs → chunk-EKGYYOSP.mjs} +0 -0
  128. /package/dist/{chunk-QAIHRH3I.mjs → chunk-KNZJT4ET.mjs} +0 -0
  129. /package/dist/{chunk-A55HN5OK.mjs → chunk-N3DSBH5S.mjs} +0 -0
  130. /package/dist/{chunk-QR6A54KJ.mjs → chunk-ZW2A6GBW.mjs} +0 -0
@@ -1,6 +1,6 @@
1
1
  # Third-party notices
2
2
 
3
- @heycys/gallery 0.2.0 carries the following code and data of others, bundled into dist/ and web/, built into kit/, or as files in vendor/ and browser/.
3
+ @heycys/gallery 0.2.1 carries the following code and data of others, bundled into dist/ and web/, built into kit/, or as files in vendor/ and browser/.
4
4
 
5
5
  ## @asamuzakjp/css-color 5.1.11
6
6
 
@@ -43,9 +43,9 @@ const STYLE = `
43
43
  [data-ge-editing] main ${STEP}[data-ge-number-hover]:not(:has(> .${ICON}))::before { box-shadow: inset 0 0 0 20px light-dark(rgb(55 53 47 / 8%), rgb(255 255 255 / 8%)); }
44
44
  .ge-add-icon { position: fixed; left: 0; top: 0; z-index: ${Z.affordance}; display: flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px; border-radius: 6px;
45
45
  color: var(--ge-tx3); font: 14px/1 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
46
- white-space: nowrap; cursor: pointer; user-select: none; opacity: 0; transition: opacity .1s ease-in, background .1s ease-in; }
46
+ white-space: nowrap; cursor: pointer; user-select: none; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .1s ease-in, background .1s ease-in; }
47
47
  .ge-add-icon svg { display: block; flex: none; width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
48
- .ge-add-icon[data-shown] { opacity: 1; }
48
+ .ge-add-icon[data-shown] { opacity: 1; visibility: visible; pointer-events: auto; }
49
49
  .ge-add-icon:hover { background: light-dark(rgb(55 53 47 / 6%), rgb(255 255 255 / 6%)); }
50
50
  `;
51
51
 
@@ -187,21 +187,23 @@ export function create({ struct, say, isEditing, openMediaPicker, assetUrl, impo
187
187
  const add = element("div", { class: "ge-add-icon", role: "button", tabindex: "0", html: t("{0}添加图标", FACE_ICON) });
188
188
  add.addEventListener("mousedown", (event) => event.preventDefault());
189
189
  add.addEventListener("click", async () => {
190
+ if (!shown || !isEditing() || iconOf()) return;
190
191
  await loadEmojiData();
192
+ if (!shown || !isEditing() || iconOf()) return;
191
193
  setIcon(randomEmoji(), t("已添加页面图标"));
192
194
  });
193
195
  let shown = false;
194
196
  function showAdd() {
195
197
  const title = titleOf();
196
198
  const header = title?.parentElement;
197
- if (!title || !header) return hideAdd();
199
+ if (!isEditing() || !title || !header || iconOf()) return hideAdd();
198
200
  if (!add.isConnected) mount(add);
199
201
  const first = header.firstElementChild.getBoundingClientRect();
200
202
  const range = document.createRange();
201
203
  range.selectNodeContents(title);
202
204
  const text = range.getClientRects()[0] ?? title.getBoundingClientRect();
203
205
  add.style.transform = `translate(${Math.round(text.left - 8)}px, ${Math.round(first.top - 28 - 6)}px)`;
204
- if (!shown) requestAnimationFrame(() => add.setAttribute("data-shown", ""));
206
+ if (!shown) requestAnimationFrame(() => { if (shown) add.setAttribute("data-shown", ""); });
205
207
  shown = true;
206
208
  }
207
209
  function hideAdd() {
@@ -18,8 +18,8 @@ const LEVELS = {
18
18
  export const FORMULA_ICON = svg('<path d="M1.6 11.6 5.5 16.25 9.5 3.75h8.75"/><path d="m10.6 7.9 6.6 8.35M17.2 7.9l-6.6 8.35"/>');
19
19
 
20
20
  /** A link (two rings of a chain): the "/" menu's 链接, 链接视图, an empty link's chip, one drawing. */
21
- /** A page (as the workbench's tree draws one with no icon: Lucide's file-text, FinHarness's 文档, here in 20 units): the "/" menu's 页面, a page block left empty, a page with no icon in a search's rows. */
22
- export const PAGE_ICON = svg('<path d="M5 18.33a1.67 1.67 0 0 1-1.67-1.66V3.33a1.67 1.67 0 0 1 1.67-1.66h6.67a2 2 0 0 1 1.42.58l2.99 2.99a2 2 0 0 1 .59 1.43v10a1.67 1.67 0 0 1-1.67 1.66z"/><path d="M11.67 1.67v4.16c0 .46.37.84.83.84h4.17"/><path d="M8.33 7.5H6.67M13.33 10.83H6.67M13.33 14.17H6.67"/>');
21
+ /** A page (the workbench's plain Lucide file outline, here in 20 units): the "/" menu's 页面, a page block left empty, a page with no icon in a search's rows. */
22
+ export const PAGE_ICON = svg('<path d="M5 18.33a1.67 1.67 0 0 1-1.67-1.66V3.33a1.67 1.67 0 0 1 1.67-1.66h6.67a2 2 0 0 1 1.42.58l2.99 2.99a2 2 0 0 1 .59 1.43v10a1.67 1.67 0 0 1-1.67 1.66z"/><path d="M11.67 1.67v4.16c0 .46.37.84.83.84h4.17"/>');
23
23
 
24
24
  /** A section (分区): blocks gathered between brackets, the block menu's 组成新分区 and the section's line in 设置 › 组件列表. */
25
25
  export const SECTION_ICON = svg('<path d="M6 3.75h-.75a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2H6M14 3.75h.75a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2H14"/><path d="M7.25 7.75h5.5M7.25 12.25h5.5"/>');
@@ -14,7 +14,8 @@ import { addressOf } from "../../links/address.mjs";
14
14
  // formatting" (html.mjs containsRichFormatting; WordPress's always): spans, divs and paragraphs
15
15
  // alone paste as their text.
16
16
  // - Office's HTML (its namespaces) that looks like Word's goes through the Word filter (office.mjs).
17
- // - A whole $$ formula $$ → a formula block; several lines from VS Code → code in its language.
17
+ // - A whole $$ formula $$ → a formula block; IDE Markdown → document blocks, other IDE code →
18
+ // code in its language. An unspecified IDE language can be Markdown by its parsed structure.
18
19
  // - HTML: several lines of text, or a table → blocks (html.mjs htmlToBlocks); else one rich text.
19
20
  // - No HTML: several lines → Markdown (markdown.mjs); one line → inline Markdown (bold, links,
20
21
  // addresses, formulas) when it has any, else plain text. A whole line that is a display formula
@@ -89,6 +90,20 @@ function sameAddress(one, other) {
89
90
  }
90
91
  }
91
92
 
93
+ // VS Code and Cursor copy syntax-colored HTML beside the source text, even for Markdown. Use
94
+ // the same Markdown reader as an ordinary text paste, not the colored HTML. A Markdown language
95
+ // hint is enough; for an unspecified language, require some structure or inline formatting the
96
+ // reader actually recognizes. Unrecognized lines remain text; the whole selection need not be
97
+ // valid Markdown. A declared programming language wins over lookalike comments and operators.
98
+ function editorMarkdown({ text, language }) {
99
+ if (language !== "Markdown" && language !== "Plain Text") return null;
100
+ const specs = fromMarkdown(text);
101
+ if (language === "Markdown") return specs;
102
+ const marked = (blocks) => blocks.some((block) => block.type !== "text"
103
+ || block.title.some((piece) => piece[1]?.length) || marked(block.children));
104
+ return !addressOf(text) && marked(specs) ? specs : null;
105
+ }
106
+
92
107
  /**
93
108
  * Another program's content as { kind: "blocks", specs } | { kind: "rich", title } | { kind: "plain",
94
109
  * text } (notion-lite readClipboard, after the editor's own formats).
@@ -98,11 +113,14 @@ export function readExternal(data, options = {}) {
98
113
  const yuque = readYuque(data, options);
99
114
  if (yuque) return yuque;
100
115
  const external = externalContent(data, options);
101
- const { text, html } = external;
116
+ const { text } = external;
102
117
  const convert = { colors: external.colors };
103
118
  const equation = EQUATION.exec(text)?.[1];
104
119
  if (equation?.trim()) return { kind: "blocks", specs: [{ type: "equation", title: [[equation]], props: {}, children: [] }] };
120
+ const markdown = editorMarkdown(external);
121
+ if (markdown?.length && (multiline(text) || markdown.some((block) => block.type !== "text"))) return { kind: "blocks", specs: markdown };
105
122
  if (multiline(text) && external.language) return { kind: "blocks", specs: [{ type: "code", title: [[text]], props: { language: external.language }, children: [] }] };
123
+ const html = markdown ? "" : external.html;
106
124
  if (html) {
107
125
  if (multiline(text) || external.isTable) {
108
126
  const specs = mathifySpecs(htmlToBlocks(parseClipboardHTML(html, options), convert));
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parse } from "parse5";
4
+ import { attr, walk } from "./html-tree.mjs";
5
+ import { escapeAttribute } from "./escape.mjs";
6
+ import { newNames } from "./new-names.mjs";
7
+ import { COMPONENTS_FOLDER, DOCUMENT_FOLDERS, SHARED_FOLDERS, documentFiles, packageFolders, readLink } from "./workspace.mjs";
8
+
9
+ /** Files belonging to a document, excluding packages/pages sharing a legacy reserved folder. */
10
+ export async function documentNames(root, slug) {
11
+ const entries = await fs.readdir(path.join(root, slug), { withFileTypes: true });
12
+ if (!SHARED_FOLDERS.includes(slug)) return entries.map(({ name }) => name);
13
+ const scopes = slug === COMPONENTS_FOLDER ? new Set(packageFolders(root).map(({ id }) => id.split("/")[0])) : new Set();
14
+ return entries.filter(entry => entry.isFile() || (DOCUMENT_FOLDERS.includes(entry.name) && !scopes.has(entry.name))).map(({ name }) => name);
15
+ }
16
+
17
+ /** Same copy primitive for duplicate and cross-workspace copy: local assets and metadata stay intact. */
18
+ export async function copyEntryFiles(root, slug, destination, copy, { names = new Map() } = {}) {
19
+ const source = path.join(root, slug);
20
+ const document = await fs.access(documentFiles(source).html).then(() => true, () => false);
21
+ const link = document ? null : await readLink(source);
22
+ const own = new Set(["", ...(await documentNames(root, slug))]);
23
+ await fs.cp(source, destination, { recursive: true, errorOnExist: true, force: false, filter: from => own.has(path.relative(source, from).split(path.sep)[0]) });
24
+ if (link) return [];
25
+ const file = documentFiles(destination).html;
26
+ const html = await fs.readFile(file, "utf8");
27
+ const text = newNames(html);
28
+ const edits = new Map(), packages = new Set();
29
+ walk(parse(text.replace(/^\uFEFF/, " "), { sourceCodeLocationInfo: true }), node => {
30
+ const component = attr(node, "data-doc-component");
31
+ if (component) packages.add(component);
32
+ for (const { name, value } of node.attrs ?? []) {
33
+ const at = node.sourceCodeLocation?.attrs?.[name];
34
+ if (!at) continue;
35
+ const relative = /^(\.\.\/)([a-z0-9]+(?:-[a-z0-9]+)*)(\/.*)$/.exec(value);
36
+ const next = name === "data-doc-id" && value === slug ? copy : ["href", "src", "poster"].includes(name) && relative && names.has(relative[2]) ? `../${names.get(relative[2])}${relative[3]}` : value;
37
+ if (next !== value) edits.set(at.startOffset, { to: at.endOffset, text: `${name}="${escapeAttribute(next)}"` });
38
+ }
39
+ });
40
+ const next = [...edits].sort(([a], [b]) => b - a).reduce((text, [from, edit]) => text.slice(0, from) + edit.text + text.slice(edit.to), text);
41
+ await fs.writeFile(file, next);
42
+ return [...packages];
43
+ }
@@ -2,13 +2,13 @@ import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { descendantsIn, outlineAndFile, parentIn, placeIn, readOutline, withOutlineLock, writeOutline, writtenAs } from "./outline.mjs";
5
+ import { descendantsIn, outlineAndFile, parentIn, placeIn, readOutline, sharedOutline, withOutlineLock, writeOutline, writtenAs } from "./outline.mjs";
6
6
  import { DOCS, GROUP_LIMITS, isForeign, isTop, newGroupId, topIn, topOf } from "./outline-shape.mjs";
7
7
  import { findPreviewServer } from "./preview-server.mjs";
8
8
  import { idsOf, shownIcon } from "./workspace-columns.mjs";
9
9
  import { nameStops, workspaceLabels, workspaceName } from "./workspace-name.mjs";
10
10
  import { isWorkspace, readRegistry, workspaceId, workspaceKey } from "./workspace-registry.mjs";
11
- import { defaultWorkspace, documentFiles, isSlugName, listDocumentSlugs } from "./workspace.mjs";
11
+ import { defaultWorkspace, documentFiles, isSlugName, listDocumentSlugs, readLink } from "./workspace.mjs";
12
12
 
13
13
  // The drafts of other workspaces in a workspace's sidebar (notes/plans/workspaces-links-plan-2026-11-08.md
14
14
  // §2.2, §2.5, §7.2; docs/workbench.md "Drafts of other workspaces"): an entry of the outline written
@@ -76,6 +76,8 @@ export async function draftFacts(entry, ids = registeredIds()) {
76
76
  const none = { entry, ws, slug, root: null, folder: null, path: null, title: slug, description: "", icon: null };
77
77
  if (!found.root) return { ...none, missing: found.refused === "ambiguous" ? "ambiguous" : "workspace" };
78
78
  const folder = path.join(found.root, slug);
79
+ const link = existsSync(documentFiles(folder).html) ? null : await readLink(folder);
80
+ if (link) return { entry, ws, slug, root: found.root, folder, path: path.join(folder, 'link.json'), title: link.title, description: '', kind: 'link', url: link.url, icon: link.icon, siteIcon: link.siteIcon, missing: null };
79
81
  const { files, html, metadata } = await metadataAt(folder);
80
82
  const missing = !isWorkspace(found.root) ? "workspace" : !html || metadata?.instructions === true ? "draft" : null;
81
83
  // An untitled document says so (its title empty: the workbench names it 未命名文档); one with no doc.json to read, its slug.
@@ -226,6 +228,11 @@ export async function addDraft(treeRoot, draftRoot, slug, { changed = () => {},
226
228
  return { parent, column: top === null || top === "" ? DOCS : top.slice(1), under: parent !== null && !isTop(parent) ? parent : null };
227
229
  };
228
230
  const columnOf = (key) => outline.groups.find((each) => topOf(each.id) === topIn(outline.children, key)) ?? null;
231
+ if (sharedOutline(treeRoot) && holds(entry)) {
232
+ if (!writtenAs(outline, stored)) { await writeOutline(treeRoot, outline); changed(); }
233
+ const existing = pick || storedHolds;
234
+ return { entry, existing, added: existing ? [] : [entry], shown: false, ...where() };
235
+ }
229
236
  let shown = false;
230
237
  let column = outline.groups.find((each) => each.workspace === id) ?? null;
231
238
  const put = () => {
@@ -425,7 +432,7 @@ export function joinedLine(joined) {
425
432
  if (joined.failed) return `${where}:没有加进去(${joined.failed})`;
426
433
  if (joined.existing) return `${where}里已经有它(${joined.names.under ? `在「${joined.names.under}」下面` : `「${joined.names.column}」栏`})`;
427
434
  const more = joined.added.length > 1 ? `,连同它下面的 ${joined.added.length - 1} 篇` : "";
428
- return `${where}:已加进${joined.names.under ? `「${joined.names.under}」下面` : `「${joined.names.column}」栏`}${more}(拖动只改那里的显示位置,文件不动)`;
435
+ return `${where}:已加进${joined.names.under ? `「${joined.names.under}」下面` : `「${joined.names.column}」栏`}${more}(工作区内排序全局同步,文件位置不变)`;
429
436
  }
430
437
 
431
438
  /** Whether a gallery.json at `folder` would be another program's or not readable (what draft-add refuses to make a workspace of). */
@@ -73,12 +73,12 @@ export const iconUrl = (value, slug = null, { tab = false } = {}) => `/__gallery
73
73
  const [TAB_LIGHT, TAB_DARK] = ICON_COLOR_VALUES.gray;
74
74
  /**
75
75
  * The browser tab's image of a page with no icon (the workbench's and the preview page's): the page
76
- * the workbench's tree shows for one (its "page" icon, components/icons.ts: Lucide's file-text,
76
+ * the workbench's tree shows for one (its "page" icon, components/icons.ts: Lucide's plain file outline,
77
77
  * FinHarness's 文档), its box cropped to `1 1 22 22` so it fills the tab as an emoji does, in the tab
78
78
  * glyphs' gray, light or dark as the system is. An image, never none: a tab given no image, or one that does not
79
79
  * load, keeps the one it showed (Chrome), so a page with no icon opened after one with one showed that one's.
80
80
  */
81
- export const DEFAULT_TAB_ICON = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><style>svg{color:${TAB_LIGHT}}@media (prefers-color-scheme:dark){svg{color:${TAB_DARK}}}</style><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>`)}`;
81
+ export const DEFAULT_TAB_ICON = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><style>svg{color:${TAB_LIGHT}}@media (prefers-color-scheme:dark){svg{color:${TAB_DARK}}}</style><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/></svg>`)}`;
82
82
 
83
83
  /** The value of an icon's element as the file (or a page) has it, or null. */
84
84
  export function iconValueOf(html) {
@@ -3,9 +3,12 @@ import path from "node:path";
3
3
  import { SIDEBAR_FILE, SIDEBAR_LIMITS, readSidebarText } from "./extension-sidebar.mjs";
4
4
  import { said } from "./i18n/index.mjs";
5
5
  import { isExtensionId } from "./outline-shape.mjs";
6
- import { DOCUMENT_FOLDERS, layoutOf } from "./workspace.mjs";
6
+ import { defaultWorkspace, DOCUMENT_FOLDERS, layoutOf } from "./workspace.mjs";
7
+ import { sameDirectory } from "./preview-server.mjs";
8
+ import { writeAtomic } from "./files.mjs";
7
9
 
8
- // The extensions of a workspace (docs/workbench.md "Extension columns"): each a folder
10
+ // The extensions visible in a workspace (docs/workbench.md "Extension columns"): public columns from
11
+ // the default workspace plus this project's own, a project sidebar.json taking precedence by id. Each a folder
9
12
  // <workspace>/extensions/<id>/ whose sidebar.json (./extension-sidebar.mjs) gives a column of the sidebar
10
13
  // its entries; what else the folder holds (the extension's own server, its code) is none of Gallery's.
11
14
  // Read from disk each time it is asked (the workbench's list, the outline's columns, gallery extension):
@@ -26,10 +29,32 @@ export async function extensionFolders(root) {
26
29
  .sort((a, b) => (a.id < b.id ? -1 : 1));
27
30
  }
28
31
 
32
+ /** Public columns live in the default workspace; a project's own sidebar.json overrides the same id.
33
+ * Empty local folders do not hide a public column. Keep one source for listing, outline and writes.
34
+ */
35
+ export async function visibleExtensionFolders(root) {
36
+ const home = defaultWorkspace();
37
+ const roots = sameDirectory(home, root) ? [root] : [home, root];
38
+ const found = new Map();
39
+ for (const owner of roots) {
40
+ for (const entry of await extensionFolders(owner)) {
41
+ const file = path.join(entry.folder, SIDEBAR_FILE);
42
+ const present = await fs.lstat(file).then(() => true, () => false);
43
+ if (present || !found.has(entry.id)) found.set(entry.id, { ...entry, root: owner, file, present });
44
+ }
45
+ }
46
+ return [...found.values()].sort((a, b) => a.id < b.id ? -1 : 1);
47
+ }
48
+
49
+ /** Resolve an id, never a client-supplied directory, to the file this workspace actually shows. */
50
+ export async function extensionSource(root, id) {
51
+ if (!isExtensionId(id)) return null;
52
+ return (await visibleExtensionFolders(root)).find(entry => entry.id === id) ?? null;
53
+ }
54
+
29
55
  /** The ids of the extensions that have a sidebar.json (the columns the sidebar has), sorted. */
30
56
  export async function extensionColumnIds(root) {
31
- const found = await Promise.all((await extensionFolders(root)).map(async ({ id, folder }) => ((await fs.stat(path.join(folder, SIDEBAR_FILE)).then(() => true, () => false)) ? id : null)));
32
- return found.filter(Boolean);
57
+ return (await visibleExtensionFolders(root)).filter(entry => entry.present).map(entry => entry.id);
33
58
  }
34
59
 
35
60
  /**
@@ -69,11 +94,62 @@ export async function readExtension(root, id) {
69
94
  }
70
95
 
71
96
  /**
72
- * The extensions of workspace `root`, by id: those with a sidebar.json (the sidebar's columns), or with `all`
97
+ * The extensions visible in workspace `root`, by id: those with a sidebar.json (the sidebar's columns), or with `all`
73
98
  * every folder that may be one (gallery extension list). Each as readExtension says it.
74
99
  */
75
100
  export async function listExtensions(root, { all = false } = {}) {
76
- const folders = await extensionFolders(root);
77
- const read = await Promise.all(folders.map(({ id }) => readExtension(root, id)));
101
+ const folders = await visibleExtensionFolders(root);
102
+ const read = await Promise.all(folders.map(({ id, root: owner }) => readExtension(owner, id)));
78
103
  return all ? read : read.filter((extension) => !extension.missing);
79
104
  }
105
+
106
+ /** Edit only a column's title or the order of its root entries. Called under the workspace lock.
107
+ * Read the latest source, keep unknown fields and whole subtrees, and refuse stale UI changes.
108
+ * Atomic replacement checks the original text again so an external writer is not silently overwritten.
109
+ */
110
+ export async function editExtension(root, id, change) {
111
+ const refused = (status, reason, error) => ({ ok: false, status, reason, error });
112
+ const bad = () => refused(400, "body", said("请求格式不对"));
113
+ const stale = () => refused(409, "stale", said("扩展目录刚在别处改过,请重试"));
114
+ if (!isExtensionId(id) || !change || typeof change !== "object") return bad();
115
+ const rename = Object.hasOwn(change, "title");
116
+ if (rename ? (typeof change.title !== "string" || typeof change.baseTitle !== "string" || change.move !== undefined)
117
+ : (!change.move || !["before", "after"].includes(change.move.side) || typeof change.move.entry !== "string" || typeof change.move.ref !== "string" || !Array.isArray(change.baseOrder))) return bad();
118
+ const name = rename ? change.title.replace(/\s+/g, " ").trim() : null;
119
+ if (rename && (!name || name.length > SIDEBAR_LIMITS.column)) return bad();
120
+ const folder = path.join(layoutOf(root).extensions, id);
121
+ const file = path.join(folder, SIDEBAR_FILE);
122
+ // Never turn an extension path supplied by the client or a symlink into an arbitrary-file write.
123
+ for (const [target, directory] of [[layoutOf(root).extensions, true], [folder, true], [file, false]]) {
124
+ const stat = await fs.lstat(target).catch(() => null);
125
+ if (!stat) return refused(404, "missing", said("找不到这个扩展"));
126
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) return refused(409, "path", said("扩展路径不是普通文件或文件夹"));
127
+ if (!directory && stat.size > SIDEBAR_LIMITS.bytes) return refused(409, "extension", said("文件超过 1MB"));
128
+ }
129
+ const source = await fs.readFile(file, "utf8");
130
+ const read = readSidebarText(source);
131
+ if (read.error) return refused(409, "extension", read.error);
132
+ const value = JSON.parse(source.replace(/^\uFEFF/, ""));
133
+ if (rename) {
134
+ if (read.sidebar.title !== change.baseTitle) return stale();
135
+ if (read.sidebar.title === name) return { ok: true, written: false };
136
+ value.title = name;
137
+ } else {
138
+ const order = value.entries.map(entry => entry.id);
139
+ if (JSON.stringify(order) !== JSON.stringify(change.baseOrder)) return stale();
140
+ const from = order.indexOf(change.move.entry), to = order.indexOf(change.move.ref);
141
+ if (from < 0 || to < 0) return bad(); // Root entries only, in this file; no nesting or cross-column move.
142
+ if (from === to) return { ok: true, written: false };
143
+ const [entry] = value.entries.splice(from, 1);
144
+ const at = value.entries.findIndex(entry => entry.id === change.move.ref);
145
+ value.entries.splice(at + (change.move.side === "after" ? 1 : 0), 0, entry);
146
+ if (JSON.stringify(order) === JSON.stringify(value.entries.map(entry => entry.id))) return { ok: true, written: false };
147
+ }
148
+ const indent = source.match(/\n([ \t]+)"/)?.[1] ?? 2;
149
+ let next = JSON.stringify(value, null, indent) + "\n";
150
+ if (source.includes("\r\n")) next = next.replaceAll("\n", "\r\n");
151
+ if (source.startsWith("\uFEFF")) next = "\uFEFF" + next;
152
+ if (Buffer.byteLength(next) > SIDEBAR_LIMITS.bytes) return refused(409, "extension", said("文件超过 1MB"));
153
+ if (!(await writeAtomic(file, next, { expect: source }))) return stale();
154
+ return { ok: true, written: true };
155
+ }
@@ -1,6 +1,9 @@
1
1
  // English for Gallery's UI, by the Chinese text the source writes (index.mjs t()), in the order the
2
2
  // sources use them. tests/unit/i18n.mjs holds every string to an entry here and every entry to a string.
3
3
  export default {
4
+ "扩展目录刚在别处改过,请重试": "The extension directory changed elsewhere. Please try again.",
5
+ "找不到这个扩展": "This extension could not be found",
6
+ "扩展路径不是普通文件或文件夹": "The extension path is not a regular file or directory",
4
7
  "正在读取文档…": "Loading documents…",
5
8
  "没有连上本地服务": "Not connected to the local service",
6
9
  "重试": "Retry",
@@ -153,6 +156,8 @@ export default {
153
156
  "撤销": "Undo",
154
157
  "已恢复「{0}」": "Restored “{0}”",
155
158
  "永久删除「{0}」?": "Delete “{0}” forever?",
159
+ "永久删除回收区中的全部 {0} 项?": "Permanently delete all {0} items in the trash?",
160
+ "删除全部": "Delete all",
156
161
  "这份文档": "this document",
157
162
  "删除后无法恢复。": "This can't be undone.",
158
163
  "正在导出…": "Exporting…",
@@ -1523,4 +1528,9 @@ export default {
1523
1528
  "选的文件不是 zip": "The file chosen is not a zip",
1524
1529
  "没有这个块包": "No such block pack",
1525
1530
  "粘贴块包的文件夹或 zip 的路径": "Paste the path of the pack's folder or zip",
1531
+ "复制到另一个工作区?": "Copy to another workspace?",
1532
+ "将「{0}」及其子页面复制到「{1}」,原项保留。": "Copy “{0}” and its child pages to “{1}”, keeping the originals.",
1533
+ "已复制 {0} 项,原项保留": "Copied {0} items; originals kept",
1534
+ "跨工作区请复制,不能只移动目录位置": "Copy items to another workspace instead of moving their tree positions",
1535
+ "自定义块 {0} 缺失或版本冲突,未复制": "Custom block {0} is missing or has a version conflict; nothing was copied",
1526
1536
  };
@@ -55,6 +55,10 @@ export const groupOfTop = (key) => (key === "" ? DOCS : key.slice(1));
55
55
  /** Whether `entry` names a draft of another workspace. */
56
56
  export const isForeign = (entry) => entry.startsWith("~");
57
57
 
58
+ /** File ownership is independent of which workspace's window is open. */
59
+ export const entryWorkspace = (entry, self) => /^~(ws-[0-9a-f]{6})\//.exec(entry)?.[1] ?? self;
60
+ export const columnWorkspaceId = (column, self) => column?.extension ? null : column?.workspace ?? column?.owner ?? self;
61
+
58
62
  const tidy = (value) => (typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "");
59
63
 
60
64
  /** A whole column's `left` as it is kept: the slugs once each, 1000 at most; none when there are none. */
@@ -80,6 +84,7 @@ export function groupOf(value) {
80
84
  ...(title ? { title } : {}),
81
85
  ...(GROUP_ROLES.includes(role) && id !== DOCS ? { role } : {}),
82
86
  ...(isWorkspaceColumn ? { workspace } : {}),
87
+ ...(typeof value.owner === "string" && /^ws-[0-9a-f]{6}$/.test(value.owner) && !isWorkspaceColumn && id !== DOCS ? { owner: value.owner } : {}),
83
88
  // A workspace's column alone is picked or hidden (the user's columns and 链接 are removed, never hidden).
84
89
  ...(isWorkspaceColumn && value.picked === true ? { picked: true } : {}),
85
90
  ...(isWorkspaceColumn && value.hidden === true ? { hidden: true } : {}),
@@ -98,6 +103,7 @@ export function validGroups(groups) {
98
103
  if (value.title !== undefined && (typeof value.title !== "string" || tidy(value.title).length > GROUP_LIMITS.title)) return false;
99
104
  if (value.role !== undefined && !GROUP_ROLES.includes(value.role)) return false;
100
105
  if (value.extension !== undefined && group.extension !== value.extension) return false;
106
+ if (value.owner !== undefined && group.owner !== value.owner) return false;
101
107
  if (value.left !== undefined && (!Array.isArray(value.left) || value.left.length > GROUP_LIMITS.left || !value.left.every((slug) => typeof slug === "string" && slugPattern.test(slug)))) return false;
102
108
  ids.add(group.id);
103
109
  }
@@ -3,11 +3,53 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { writeAtomic } from "./files.mjs";
5
5
  import { extensionColumnIds } from "./extensions.mjs";
6
- import { DOCS, entryPattern, extensionGroupId, GROUP_LIMITS, groupOf, isForeign, isTop, newGroupId, topIn, topOf, validGroups } from "./outline-shape.mjs";
6
+ import { DOCS, columnWorkspaceId, entryWorkspace, entryPattern, extensionGroupId, GROUP_LIMITS, groupOf, groupOfTop, isForeign, isTop, newGroupId, topIn, topOf, validGroups } from "./outline-shape.mjs";
7
7
  import { storedHead } from "./sidebar-head.mjs";
8
- import { shownWorkspaces, snapshotOf } from "./workspace-columns.mjs";
9
- import { layoutOf, listDocumentSlugs, listLinkSlugs, pageSlug } from "./workspace.mjs";
8
+ import { forgetSnapshot, shownWorkspaces, snapshotOf } from "./workspace-columns.mjs";
9
+ import { defaultWorkspace, layoutOf, listDocumentSlugs, listLinkSlugs, pageSlug } from "./workspace.mjs";
10
+ import { readRegistry, workspaceId, workspaceKey } from "./workspace-registry.mjs";
10
11
 
12
+ /** Registered workspaces share the default workspace's existing tree. Unregistered/temporary
13
+ * workspaces remain standalone; no registry discovery or copying of document files is needed. */
14
+ export function outlineRoot(root) {
15
+ const home = defaultWorkspace();
16
+ return workspaceKey(root) === workspaceKey(home) || readRegistry().workspaces.some(entry => workspaceKey(entry.root) === workspaceKey(root)) ? home : root;
17
+ }
18
+
19
+ export const sharedOutline = root => workspaceKey(outlineRoot(root)) === workspaceKey(defaultWorkspace());
20
+
21
+ /** Moving a reference across owners must use the confirmed copy command. */
22
+ export function ownsOutline(root, value) {
23
+ const self = workspaceId(root);
24
+ const available = new Set([self, ...shownWorkspaces(root).map(workspace => workspace.id)]);
25
+ return Object.entries(value.children).every(([parent, list]) => {
26
+ const owner = isTop(parent) ? columnWorkspaceId(value.groups?.find(group => topOf(group.id) === parent), self) : entryWorkspace(parent, self);
27
+ // Missing legacy references remain removable and must not block edits to healthy documents.
28
+ return list.every(entry => !available.has(entryWorkspace(entry, self)) || entryWorkspace(entry, self) === owner);
29
+ });
30
+ }
31
+
32
+ // Earlier versions allowed mixed-workspace nesting. Return misplaced roots to their owner,
33
+ // retaining same-owner order and descendants. This changes directory positions, never files.
34
+ function ownedTree(tree, self) {
35
+ const children = Object.fromEntries(tree.groups.map(group => [topOf(group.id), []]));
36
+ const ownTop = owner => { const group = tree.groups.find(group => owner === self ? group.id === DOCS : group.workspace === owner); return group ? topOf(group.id) : null; };
37
+ const walk = (parent, owner) => {
38
+ for (const entry of tree.children[parent] ?? []) {
39
+ const belongs = entryWorkspace(entry, self);
40
+ const at = belongs === owner ? parent : ownTop(belongs) ?? parent;
41
+ (children[at] ??= []).push(entry);
42
+ walk(entry, belongs);
43
+ }
44
+ };
45
+ for (const group of tree.groups) if (!group.extension) walk(topOf(group.id), columnWorkspaceId(group, self));
46
+ return { ...tree, children };
47
+ }
48
+
49
+ // Registered workspaces project one shared tree, partitioned by file owner. Their former local files
50
+ // only seed new entries; the default workspace stores subsequent order/hierarchy edits. The format
51
+ // below is also the standalone/legacy reader. Its picked/hidden/left rules are not applied to shared
52
+ // trees, which list every registered document and link. Project-only extensions keep local positions.
11
53
  // The workspace's outline: the sidebar's columns and which entry sits under which, in what order
12
54
  // (<workspace>/.gallery/outline.json, docs/workbench.md "Workspace commands"; its shape: ./outline-shape.mjs).
13
55
  // The file, version 2:
@@ -56,10 +98,10 @@ const LOCK_WAIT = 15_000;
56
98
  /**
57
99
  * Run `task` holding the outline's lock of workspace `root`: a file made with "wx", which one
58
100
  * process alone can make, removed after; one older than 10 s is a crashed writer's and is taken over.
59
- * Waits for it 15 s at most, then takes it all the same (a writer that holds it that long holds
60
- * nothing any more).
101
+ * A live writer refreshes its lock. Waits 15 s at most and then refuses rather than stealing it.
61
102
  */
62
- export async function withOutlineLock(root, task) {
103
+ export async function withOutlineLock(root, task, { local = false } = {}) {
104
+ if (!local) root = outlineRoot(root);
63
105
  const { gallery } = layoutOf(root);
64
106
  const lock = path.join(gallery, "outline.lock");
65
107
  const mine = `${process.pid} ${randomBytes(4).toString("hex")}`;
@@ -71,13 +113,17 @@ export async function withOutlineLock(root, task) {
71
113
  } catch (error) {
72
114
  if (error.code !== "EEXIST") throw error;
73
115
  const age = await fs.stat(lock).then(({ mtimeMs }) => Date.now() - mtimeMs, () => 0);
74
- if (age > LOCK_STALE || Date.now() > until) await fs.rm(lock, { force: true });
116
+ if (age > LOCK_STALE) await fs.rm(lock, { force: true });
117
+ else if (Date.now() > until) throw Object.assign(new Error("文档目录刚在别处改过"), { code: "stale" });
75
118
  else await new Promise((resolve) => setTimeout(resolve, 20));
76
119
  }
77
120
  }
121
+ const heartbeat = setInterval(() => fs.utimes(lock, new Date(), new Date()).catch(() => {}), LOCK_STALE / 3);
122
+ heartbeat.unref();
78
123
  try {
79
124
  return await task();
80
125
  } finally {
126
+ clearInterval(heartbeat);
81
127
  // Removed only while it is still this writer's (one taken over meanwhile is the next writer's).
82
128
  if ((await fs.readFile(lock, "utf8").catch(() => null)) === mine) await fs.rm(lock, { force: true });
83
129
  }
@@ -98,7 +144,8 @@ export async function readOutline(root, { others = true } = {}) {
98
144
  */
99
145
  export async function otherWorkspaces(root, { env = process.env } = {}) {
100
146
  const shown = shownWorkspaces(root, { env });
101
- const readOwn = (other) => readOutline(other, { others: false });
147
+ // Project-local files seed entries not yet in the shared tree; never recurse into its projection.
148
+ const readOwn = async (other) => (await localOutlineAndFile(other, { others: false })).outline;
102
149
  const unread = () => ({ documents: new Set(), order: [], parentOf: new Map(), facts: new Map(), failed: true });
103
150
  return Promise.all(shown.map(async (each) => ({ ...each, failed: false, ...(await snapshotOf(each.root, readOwn).catch(unread)) })));
104
151
  }
@@ -113,14 +160,16 @@ export async function otherWorkspaces(root, { env = process.env } = {}) {
113
160
  * place a command or the workbench gave. `others: false`: the workspace's own tree alone (no column of
114
161
  * another workspace made, none filled).
115
162
  */
116
- export async function outlineAndFile(root, { others: withOthers = true } = {}) {
163
+ async function localOutlineAndFile(root, { others: withOthers = true, shared = false } = {}) {
117
164
  const instructions = await pageSlug(root);
118
165
  const documents = (await listDocumentSlugs(root)).filter((slug) => slug !== instructions);
119
166
  const links = await listLinkSlugs(root);
120
167
  const extensions = await extensionColumnIds(root);
121
168
  const stored = storedOf(await readJson(layoutOf(root).outline));
122
169
  const others = withOthers ? await otherWorkspaces(root) : [];
123
- const shown = ordered(stored, documents, links, extensions, others, { drawn: withOthers });
170
+ const base = shared && stored ? { ...stored, groups: stored.groups.map(({ picked, hidden, left, ...group }) => group.workspace || group.extension || group.id === DOCS ? group : { ...group, owner: group.owner ?? workspaceId(root) }) } : stored;
171
+ let shown = ordered(base, documents, links, extensions, others, { drawn: withOthers });
172
+ if (shared) shown = ownedTree(shown, workspaceId(root));
124
173
  const outline = {
125
174
  version: 2, ...shown, links, drafts: Object.values(shown.children).flat().filter(isForeign),
126
175
  shown: others.map(({ id, root: other, isDefault }) => ({ id, root: other, isDefault })),
@@ -128,6 +177,54 @@ export async function outlineAndFile(root, { others: withOthers = true } = {}) {
128
177
  return { outline, stored, others };
129
178
  }
130
179
 
180
+ /** Translate the same tree between the default workspace's ids and a project's local ids.
181
+ * No second ordering is kept: only the current workspace's column is displayed first. */
182
+ function projectTree(value, from, to, { first = false } = {}) {
183
+ if (!value || workspaceKey(from) === workspaceKey(to)) return value;
184
+ const fromId = workspaceId(from), toId = workspaceId(to);
185
+ const own = value.groups.find(group => group.workspace === toId);
186
+ const ownId = own?.id ?? toId;
187
+ const groupId = id => id === DOCS ? fromId : id === ownId ? DOCS : id;
188
+ const entry = key => key.startsWith(`~${toId}/`) ? key.slice(toId.length + 2) : isForeign(key) ? key : `~${fromId}/${key}`;
189
+ const key = value => isTop(value) ? topOf(groupId(groupOfTop(value))) : entry(value);
190
+ let groups = value.groups.map(group => {
191
+ if (group.id === DOCS) return { ...group, id: fromId, workspace: fromId };
192
+ if (group.id === ownId) {
193
+ const { workspace, picked, hidden, left, ...rest } = group;
194
+ return { ...rest, id: DOCS };
195
+ }
196
+ return group;
197
+ });
198
+ if (first) groups = [...groups.filter(group => group.id === DOCS), ...groups.filter(group => group.id !== DOCS)];
199
+ return { ...value, groups, children: Object.fromEntries(Object.entries(value.children).map(([parent, list]) => [key(parent), list.map(entry)])) };
200
+ }
201
+
202
+ export async function outlineAndFile(root, { others: withOthers = true } = {}) {
203
+ const home = outlineRoot(root);
204
+ if (workspaceKey(home) === workspaceKey(root)) return localOutlineAndFile(root, { others: withOthers, shared: sharedOutline(root) });
205
+ // A command may just have created/trashed a local item before placing it in the shared tree.
206
+ forgetSnapshot(root);
207
+ const shared = await localOutlineAndFile(home, { shared: true });
208
+ let outline = projectTree(shared.outline, home, root, { first: true });
209
+ // A project's extensions remain private; public ones still come from the default workspace.
210
+ outline.groups = withExtensions(outline.groups, await extensionColumnIds(root));
211
+ const publicIds = new Set(await extensionColumnIds(home));
212
+ const positions = (await readJson(layoutOf(root).outline))?.extensionPositions;
213
+ for (const position of Array.isArray(positions) ? [...positions].reverse() : []) {
214
+ const group = outline.groups.find(group => group.id === position.id && group.extension && !publicIds.has(group.extension));
215
+ if (!group) continue;
216
+ outline.groups = outline.groups.filter(item => item !== group);
217
+ const at = outline.groups.findIndex(item => item.id === position.before);
218
+ outline.groups.splice(at < 0 ? outline.groups.length : at, 0, group);
219
+ }
220
+ outline.children = Object.fromEntries(Object.entries(outline.children).filter(([key]) => !isTop(key) || outline.groups.some(group => topOf(group.id) === key)));
221
+ for (const group of outline.groups) outline.children[topOf(group.id)] ??= [];
222
+ const ownLinks = await listLinkSlugs(root);
223
+ const others = await otherWorkspaces(root);
224
+ outline = { ...outline, links: ownLinks, drafts: Object.values(outline.children).flat().filter(isForeign), shown: others.map(({ id, root: other, isDefault }) => ({ id, root: other, isDefault })) };
225
+ return { outline, stored: projectTree(shared.stored, home, root, { first: true }), others };
226
+ }
227
+
131
228
  // The own column's name before it was 默认文档 or the workspace's (文档, Documents in English), which a
132
229
  // rename of it confirmed unchanged wrote as its title (the field opens with the name shown): read as no
133
230
  // title, so that the name it goes by now shows. A title of another column, or any other, is the user's.
@@ -378,6 +475,33 @@ export function fileOf(value) {
378
475
  * the head's order the file holds kept as it is (writeHead's).
379
476
  */
380
477
  export async function writeOutline(root, value) {
478
+ const home = outlineRoot(root);
479
+ if (sharedOutline(root) && !ownsOutline(root, value)) throw Object.assign(new Error("跨工作区请复制,不能只移动目录位置"), { code: "CROSS_WORKSPACE" });
480
+ if (sharedOutline(root)) value = { ...value, groups: (value.groups ?? [{ id: DOCS }]).map(group => group.id === DOCS || group.workspace || group.extension ? group : { ...group, owner: group.owner ?? workspaceId(root) }) };
481
+ if (workspaceKey(home) !== workspaceKey(root)) {
482
+ const current = (await localOutlineAndFile(home)).outline;
483
+ const translated = projectTree(value, root, home);
484
+ // The current-column-first projection is presentation only. Keep the shared column order;
485
+ // deliberate moves of other columns use the relative order submitted by the client.
486
+ const ownId = workspaceId(root);
487
+ const at = current.groups.findIndex(group => group.workspace === ownId);
488
+ const own = translated.groups.find(group => group.workspace === ownId);
489
+ translated.groups = translated.groups.filter(group => group !== own);
490
+ if (own) translated.groups.splice(Math.max(0, Math.min(at, translated.groups.length)), 0, own);
491
+ const publicIds = new Set(await extensionColumnIds(home));
492
+ const privateGroup = group => group.extension && !publicIds.has(group.extension);
493
+ const positions = value.groups.filter(privateGroup).map(group => ({ id: group.id, before: value.groups.slice(value.groups.indexOf(group) + 1).find(group => !privateGroup(group))?.id ?? null }));
494
+ const localFile = layoutOf(root).outline;
495
+ const local = await readJson(localFile) ?? {};
496
+ if ((positions.length || local.extensionPositions?.length) && JSON.stringify(positions) !== JSON.stringify(local.extensionPositions)) {
497
+ await fs.mkdir(path.dirname(localFile), { recursive: true });
498
+ await writeAtomic(localFile, `${JSON.stringify({ ...local, extensionPositions: positions }, null, 2)}\n`);
499
+ }
500
+ translated.groups = translated.groups.filter(group => !group.extension || publicIds.has(group.extension));
501
+ for (const group of current.groups) if (group.extension && !translated.groups.some(item => item.id === group.id)) translated.groups.push(group);
502
+ value = translated;
503
+ root = home;
504
+ }
381
505
  const file = layoutOf(root).outline;
382
506
  await fs.mkdir(path.dirname(file), { recursive: true });
383
507
  const head = storedHead((await readJson(file))?.head);
@@ -465,6 +589,7 @@ export function moveDocument(root, slug, where) {
465
589
  return withOutlineLock(root, async () => {
466
590
  const { outline, stored } = await outlineAndFile(root);
467
591
  const result = moveIn(outline.children, slug, where);
592
+ if (result.ok && sharedOutline(root) && !ownsOutline(root, outline)) return { ok: false, reason: "cross-workspace" };
468
593
  const written = result.ok && !writtenAs(outline, stored);
469
594
  if (written) await writeOutline(root, outline);
470
595
  return { ...result, ...(result.ok ? { written } : {}) };