canopycms 0.1.0 → 0.2.0-int.119

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 (113) hide show
  1. package/CHANGELOG.md +144 -41
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +98 -37
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/finalize.js +14 -1
  10. package/dist/assets/keys.d.ts +4 -1
  11. package/dist/assets/keys.js +4 -1
  12. package/dist/assets/store-local.d.ts +18 -9
  13. package/dist/assets/store-local.js +58 -26
  14. package/dist/assets/store-s3.d.ts +19 -4
  15. package/dist/assets/store-s3.js +56 -13
  16. package/dist/assets/types.d.ts +26 -2
  17. package/dist/cli/cli.js +112 -34
  18. package/dist/editor/BranchManager.js +2 -1
  19. package/dist/editor/CommentsPanel.js +67 -29
  20. package/dist/editor/Editor.d.ts +3 -0
  21. package/dist/editor/Editor.js +58 -36
  22. package/dist/editor/EntryNavigator.d.ts +8 -1
  23. package/dist/editor/EntryNavigator.js +31 -16
  24. package/dist/editor/FormHeader.d.ts +2 -1
  25. package/dist/editor/FormHeader.js +9 -5
  26. package/dist/editor/FormRenderer.d.ts +7 -2
  27. package/dist/editor/FormRenderer.js +51 -16
  28. package/dist/editor/PreviewFrame.d.ts +11 -2
  29. package/dist/editor/PreviewFrame.js +20 -4
  30. package/dist/editor/PreviewPane.d.ts +15 -1
  31. package/dist/editor/PreviewPane.js +55 -8
  32. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  33. package/dist/editor/PreviewToolbarTools.js +9 -1
  34. package/dist/editor/branch-status.d.ts +6 -0
  35. package/dist/editor/branch-status.js +10 -0
  36. package/dist/editor/comments/BranchComments.d.ts +2 -4
  37. package/dist/editor/comments/BranchComments.js +7 -11
  38. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  39. package/dist/editor/comments/ThreadCarousel.js +4 -49
  40. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  41. package/dist/editor/components/BranchLockBanner.d.ts +2 -0
  42. package/dist/editor/components/BranchLockBanner.js +6 -2
  43. package/dist/editor/components/BranchPill.d.ts +5 -4
  44. package/dist/editor/components/BranchPill.js +6 -8
  45. package/dist/editor/components/BranchesDrawer.js +3 -3
  46. package/dist/editor/components/EditorHeader.d.ts +2 -6
  47. package/dist/editor/components/EditorHeader.js +29 -28
  48. package/dist/editor/components/EditorRail.d.ts +3 -4
  49. package/dist/editor/components/EditorRail.js +3 -4
  50. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  51. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  52. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  53. package/dist/editor/components/QuickOpenPalette.js +6 -1
  54. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  55. package/dist/editor/components/RenameEntryModal.js +20 -5
  56. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  57. package/dist/editor/components/SaveSplitButton.js +25 -12
  58. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  59. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  60. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  61. package/dist/editor/components/UndoToastMessage.js +6 -4
  62. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  63. package/dist/editor/components/UnsavedMark.js +20 -0
  64. package/dist/editor/components/UserBadge.js +13 -11
  65. package/dist/editor/copy.d.ts +4 -0
  66. package/dist/editor/copy.js +4 -0
  67. package/dist/editor/editor-utils.d.ts +49 -9
  68. package/dist/editor/editor-utils.js +117 -16
  69. package/dist/editor/entry-search.d.ts +4 -2
  70. package/dist/editor/entry-search.js +2 -3
  71. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  72. package/dist/editor/fields/ObjectField.d.ts +13 -2
  73. package/dist/editor/fields/ObjectField.js +38 -9
  74. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  75. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  76. package/dist/editor/group-manager/index.js +1 -1
  77. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  78. package/dist/editor/hooks/useEditorLayout.js +5 -0
  79. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  80. package/dist/editor/hooks/useEntryManager.js +10 -8
  81. package/dist/editor/media/AssetCard.d.ts +6 -3
  82. package/dist/editor/media/AssetCard.js +20 -7
  83. package/dist/editor/media/AssetListRow.d.ts +9 -4
  84. package/dist/editor/media/AssetListRow.js +14 -7
  85. package/dist/editor/media/MediaLibrary.js +5 -4
  86. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  87. package/dist/editor/media/MediaLibraryBody.js +65 -37
  88. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  89. package/dist/editor/media/useAssetLibrary.js +17 -10
  90. package/dist/editor/pane-header.d.ts +6 -0
  91. package/dist/editor/pane-header.js +6 -0
  92. package/dist/editor/permission-manager/index.js +1 -1
  93. package/dist/editor/preview-bridge.d.ts +16 -0
  94. package/dist/editor/preview-bridge.js +12 -3
  95. package/dist/editor/preview-path.d.ts +11 -4
  96. package/dist/editor/preview-path.js +62 -19
  97. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  98. package/dist/editor/shell/ActivityRail.js +3 -3
  99. package/dist/editor/shell/EditorShell.d.ts +15 -0
  100. package/dist/editor/shell/EditorShell.js +17 -1
  101. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  102. package/dist/editor/shell/OverlayHeader.js +15 -0
  103. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  104. package/dist/editor/shell/PaneLayout.js +68 -13
  105. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  106. package/dist/editor/shell/SidePanelHost.js +70 -33
  107. package/dist/editor/shell/TopBar.js +2 -1
  108. package/dist/editor/theme.js +2 -0
  109. package/dist/editor/undo-removal.d.ts +19 -10
  110. package/dist/editor/undo-removal.js +44 -16
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  * S3-backed AssetStore. Same bucket-prefix layout as LocalAssetStore (see
3
3
  * keys.ts). Assumes an EXISTING content bucket (versioning/SSE/replication
4
4
  * already configured by the site's CDK stack). It reads and writes under the
5
- * five asset prefixes, plus a caller's output prefix for `copyPublicObject`.
5
+ * six asset prefixes, plus a caller's output prefix for `copyPublicObject`.
6
6
  */
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, paginateListObjectsV2, } from '@aws-sdk/client-s3';
@@ -11,6 +11,8 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
11
11
  import { isHttpUrlOrSameOriginPath } from '../utils/sanitize-href.js';
12
12
  import { ASSET_PREFIXES, createKeyBuilders } from './keys.js';
13
13
  const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
14
+ /** What `metaKey` and `hiddenKey` put after the hash32. */
15
+ const JSON_SUFFIX = '.json';
14
16
  const PRESIGN_EXPIRY_SECONDS = 15 * 60;
15
17
  /** A presigned read is followed by the browser at once; it is never stored or cached. */
16
18
  const PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
@@ -234,7 +236,10 @@ export class S3AssetStore {
234
236
  throw err;
235
237
  }
236
238
  }
237
- async *listPublicObjectKeys(prefix) {
239
+ listPublicObjectKeys(prefix) {
240
+ return this.listKeys(prefix);
241
+ }
242
+ async *listKeys(prefix) {
238
243
  const pages = paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix });
239
244
  for await (const page of pages) {
240
245
  for (const object of page.Contents ?? []) {
@@ -264,31 +269,69 @@ export class S3AssetStore {
264
269
  return null;
265
270
  return JSON.parse(Buffer.from(bytes).toString('utf-8'));
266
271
  }
272
+ /**
273
+ * One page of meta keys, filtered by a full listing of the hidden markers: few files are hidden,
274
+ * and a listing needs only `s3:ListBucket`. A hidden file's meta is fetched only for
275
+ * `includeHidden`.
276
+ */
267
277
  async listMeta(input) {
268
- const listed = await this.client.send(new ListObjectsV2Command({
269
- Bucket: this.bucket,
270
- Prefix: this.keys.metaPrefix(),
271
- MaxKeys: input?.limit,
272
- ContinuationToken: input?.cursor,
273
- }));
278
+ const metaPrefix = this.keys.metaPrefix();
279
+ const [listed, hidden] = await Promise.all([
280
+ this.client.send(new ListObjectsV2Command({
281
+ Bucket: this.bucket,
282
+ Prefix: metaPrefix,
283
+ MaxKeys: input?.limit,
284
+ ContinuationToken: input?.cursor,
285
+ })),
286
+ this.listHiddenHashes(),
287
+ ]);
274
288
  const objectKeys = (listed.Contents ?? [])
275
289
  .map((obj) => obj.Key)
276
290
  .filter((key) => Boolean(key));
277
291
  const fetched = await Promise.all(objectKeys.map(async (key) => {
292
+ const isHidden = hidden.has(key.slice(metaPrefix.length, -JSON_SUFFIX.length));
293
+ if (isHidden && !input?.includeHidden)
294
+ return null;
278
295
  const bytes = await this.getObjectBytes(key);
279
- // A meta object deleted between LIST and GET (deleteMeta race) reads
280
- // as null; skip it rather than failing the whole page.
281
296
  if (!bytes)
282
297
  return null;
283
- return JSON.parse(Buffer.from(bytes).toString('utf-8'));
298
+ const meta = JSON.parse(Buffer.from(bytes).toString('utf-8'));
299
+ return isHidden ? { ...meta, hidden: true } : meta;
284
300
  }));
285
301
  return {
286
302
  items: fetched.filter((meta) => meta !== null),
287
303
  nextCursor: listed.IsTruncated ? listed.NextContinuationToken : undefined,
288
304
  };
289
305
  }
290
- async deleteMeta(hash32) {
291
- await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: this.keys.metaKey(hash32) }));
306
+ async listHiddenHashes() {
307
+ const prefix = this.keys.hiddenPrefix();
308
+ const hashes = new Set();
309
+ for await (const key of this.listKeys(prefix)) {
310
+ if (key.endsWith(JSON_SUFFIX))
311
+ hashes.add(key.slice(prefix.length, -JSON_SUFFIX.length));
312
+ }
313
+ return hashes;
314
+ }
315
+ async hideAsset(hash32, marker) {
316
+ return this.putIfAbsent({
317
+ Key: this.keys.hiddenKey(hash32),
318
+ Body: JSON.stringify(marker),
319
+ ContentType: 'application/json',
320
+ });
321
+ }
322
+ /**
323
+ * Lists before deleting: a DELETE of an absent key writes a delete marker on a versioned bucket.
324
+ * The listing needs only `s3:ListBucket` covering `asset-hidden/` (a bucket-wide grant does; a
325
+ * policy scoping it by prefix must name this one), no object permission, so finalize can call
326
+ * this on every re-upload under a role that has never hidden a file.
327
+ */
328
+ async unhideAsset(hash32) {
329
+ const key = this.keys.hiddenKey(hash32);
330
+ const listed = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: key, MaxKeys: 1 }));
331
+ if (listed.Contents?.[0]?.Key !== key)
332
+ return false;
333
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
334
+ return true;
292
335
  }
293
336
  async getObject(key) {
294
337
  try {
@@ -19,6 +19,15 @@ export interface AssetMeta {
19
19
  uploadedBy?: string;
20
20
  uploadedAt: string;
21
21
  }
22
+ /** A listed asset; `hidden` is set only on a listing that includes hidden files. */
23
+ export type ListedAssetMeta = AssetMeta & {
24
+ hidden?: true;
25
+ };
26
+ /** The body of a hidden marker. Only the marker's presence hides a file; this records who and when. */
27
+ export interface HiddenAssetMarker {
28
+ hiddenAt: string;
29
+ hiddenBy?: string;
30
+ }
22
31
  export type StagedUploadTarget = {
23
32
  mode: 'direct';
24
33
  url: string;
@@ -95,14 +104,29 @@ export interface AssetStore {
95
104
  listPublicObjectKeys?(prefix: string): AsyncIterable<string>;
96
105
  /** A short-lived URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
97
106
  presignPublicObjectRead?(key: string): Promise<string | null>;
107
+ /**
108
+ * Meta is create-only and never deleted: every transform not yet stored, at upload, release or
109
+ * after a lazy output expires, is computed from it, and content anywhere may still name the file.
110
+ */
98
111
  putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
99
112
  getMeta(hash32: string): Promise<AssetMeta | null>;
113
+ /**
114
+ * A page of meta, without hidden files unless `includeHidden`, which marks each hidden one
115
+ * `hidden: true`. A page may hold fewer than `limit` items while `nextCursor` is still set.
116
+ */
100
117
  listMeta(input?: {
101
118
  cursor?: string;
102
119
  limit?: number;
120
+ includeHidden?: boolean;
103
121
  }): Promise<{
104
- items: AssetMeta[];
122
+ items: ListedAssetMeta[];
105
123
  nextCursor?: string;
106
124
  }>;
107
- deleteMeta(hash32: string): Promise<void>;
125
+ /**
126
+ * Hide a file from `listMeta`: a create-only marker beside its meta, which stays, so every URL
127
+ * and transform keeps working. `already-exists` when it was hidden already.
128
+ */
129
+ hideAsset(hash32: string, marker: HiddenAssetMarker): Promise<CreateOnlyResult>;
130
+ /** Remove the hidden marker; `false` when there was none. Needs no permission on a store that has never hidden a file. */
131
+ unhideAsset(hash32: string): Promise<boolean>;
108
132
  }
package/dist/cli/cli.js CHANGED
@@ -13330,6 +13330,8 @@ var init_asset_prefixes = __esm({
13330
13330
  originals: "asset-originals",
13331
13331
  staging: "asset-staging",
13332
13332
  meta: "asset-meta",
13333
+ /** One marker per file removed from the media library; its meta stays (see `hideAsset`). */
13334
+ hidden: "asset-hidden",
13333
13335
  public: "assets",
13334
13336
  /** Transform-output prefix; directive-aware key building lives in transform-directives.ts. */
13335
13337
  transform: "assets/t"
@@ -14115,16 +14117,18 @@ function createKeyBuilders(prefixes = ASSET_PREFIXES) {
14115
14117
  stagingKey: (uuid) => `${prefixes.staging}/${uuid}`,
14116
14118
  metaKey: (hash32) => `${prefixes.meta}/${hash32}.json`,
14117
14119
  metaPrefix: () => `${prefixes.meta}/`,
14120
+ hiddenKey: (hash32) => `${prefixes.hidden}/${hash32}.json`,
14121
+ hiddenPrefix: () => `${prefixes.hidden}/`,
14118
14122
  publicKey: (hash32, slug, ext) => `${prefixes.public}/${hash32}/${slug}.${ext}`
14119
14123
  };
14120
14124
  }
14121
- var keyBuilders, originalKey, stagingKey, metaKey, publicKey, originalPrefix, metaPrefix;
14125
+ var keyBuilders, originalKey, stagingKey, metaKey, hiddenKey, publicKey, originalPrefix, metaPrefix;
14122
14126
  var init_keys = __esm({
14123
14127
  "dist/assets/keys.js"() {
14124
14128
  "use strict";
14125
14129
  init_asset_prefixes();
14126
14130
  keyBuilders = createKeyBuilders();
14127
- ({ originalKey, stagingKey, metaKey, publicKey } = keyBuilders);
14131
+ ({ originalKey, stagingKey, metaKey, hiddenKey, publicKey } = keyBuilders);
14128
14132
  ({ originalPrefix, metaPrefix } = keyBuilders);
14129
14133
  }
14130
14134
  });
@@ -14140,13 +14144,14 @@ function matchesAwsError(err2, name, httpStatusCode) {
14140
14144
  const shaped = err2;
14141
14145
  return shaped.name === name || shaped.$metadata?.httpStatusCode === httpStatusCode;
14142
14146
  }
14143
- var DEFAULT_MAX_UPLOAD_BYTES, PRESIGN_EXPIRY_SECONDS, PRESIGNED_READ_EXPIRY_SECONDS, isPreconditionFailed, isConditionalRequestConflict, CONFLICT_ATTEMPTS, CONFLICT_BASE_DELAY_MS, isNoSuchKey, S3AssetStore;
14147
+ var DEFAULT_MAX_UPLOAD_BYTES, JSON_SUFFIX, PRESIGN_EXPIRY_SECONDS, PRESIGNED_READ_EXPIRY_SECONDS, isPreconditionFailed, isConditionalRequestConflict, CONFLICT_ATTEMPTS, CONFLICT_BASE_DELAY_MS, isNoSuchKey, S3AssetStore;
14144
14148
  var init_store_s3 = __esm({
14145
14149
  "dist/assets/store-s3.js"() {
14146
14150
  "use strict";
14147
14151
  init_sanitize_href3();
14148
14152
  init_keys();
14149
14153
  DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
14154
+ JSON_SUFFIX = ".json";
14150
14155
  PRESIGN_EXPIRY_SECONDS = 15 * 60;
14151
14156
  PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
14152
14157
  isPreconditionFailed = (err2) => matchesAwsError(err2, "PreconditionFailed", 412);
@@ -14341,7 +14346,10 @@ var init_store_s3 = __esm({
14341
14346
  throw err2;
14342
14347
  }
14343
14348
  }
14344
- async *listPublicObjectKeys(prefix) {
14349
+ listPublicObjectKeys(prefix) {
14350
+ return this.listKeys(prefix);
14351
+ }
14352
+ async *listKeys(prefix) {
14345
14353
  const pages = paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix });
14346
14354
  for await (const page of pages) {
14347
14355
  for (const object of page.Contents ?? []) {
@@ -14371,27 +14379,67 @@ var init_store_s3 = __esm({
14371
14379
  return null;
14372
14380
  return JSON.parse(Buffer.from(bytes).toString("utf-8"));
14373
14381
  }
14382
+ /**
14383
+ * One page of meta keys, filtered by a full listing of the hidden markers: few files are hidden,
14384
+ * and a listing needs only `s3:ListBucket`. A hidden file's meta is fetched only for
14385
+ * `includeHidden`.
14386
+ */
14374
14387
  async listMeta(input) {
14375
- const listed = await this.client.send(new ListObjectsV2Command({
14376
- Bucket: this.bucket,
14377
- Prefix: this.keys.metaPrefix(),
14378
- MaxKeys: input?.limit,
14379
- ContinuationToken: input?.cursor
14380
- }));
14388
+ const metaPrefix2 = this.keys.metaPrefix();
14389
+ const [listed, hidden] = await Promise.all([
14390
+ this.client.send(new ListObjectsV2Command({
14391
+ Bucket: this.bucket,
14392
+ Prefix: metaPrefix2,
14393
+ MaxKeys: input?.limit,
14394
+ ContinuationToken: input?.cursor
14395
+ })),
14396
+ this.listHiddenHashes()
14397
+ ]);
14381
14398
  const objectKeys = (listed.Contents ?? []).map((obj) => obj.Key).filter((key) => Boolean(key));
14382
14399
  const fetched = await Promise.all(objectKeys.map(async (key) => {
14400
+ const isHidden = hidden.has(key.slice(metaPrefix2.length, -JSON_SUFFIX.length));
14401
+ if (isHidden && !input?.includeHidden)
14402
+ return null;
14383
14403
  const bytes = await this.getObjectBytes(key);
14384
14404
  if (!bytes)
14385
14405
  return null;
14386
- return JSON.parse(Buffer.from(bytes).toString("utf-8"));
14406
+ const meta = JSON.parse(Buffer.from(bytes).toString("utf-8"));
14407
+ return isHidden ? { ...meta, hidden: true } : meta;
14387
14408
  }));
14388
14409
  return {
14389
14410
  items: fetched.filter((meta) => meta !== null),
14390
14411
  nextCursor: listed.IsTruncated ? listed.NextContinuationToken : void 0
14391
14412
  };
14392
14413
  }
14393
- async deleteMeta(hash32) {
14394
- await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: this.keys.metaKey(hash32) }));
14414
+ async listHiddenHashes() {
14415
+ const prefix = this.keys.hiddenPrefix();
14416
+ const hashes = /* @__PURE__ */ new Set();
14417
+ for await (const key of this.listKeys(prefix)) {
14418
+ if (key.endsWith(JSON_SUFFIX))
14419
+ hashes.add(key.slice(prefix.length, -JSON_SUFFIX.length));
14420
+ }
14421
+ return hashes;
14422
+ }
14423
+ async hideAsset(hash32, marker) {
14424
+ return this.putIfAbsent({
14425
+ Key: this.keys.hiddenKey(hash32),
14426
+ Body: JSON.stringify(marker),
14427
+ ContentType: "application/json"
14428
+ });
14429
+ }
14430
+ /**
14431
+ * Lists before deleting: a DELETE of an absent key writes a delete marker on a versioned bucket.
14432
+ * The listing needs only `s3:ListBucket` covering `asset-hidden/` (a bucket-wide grant does; a
14433
+ * policy scoping it by prefix must name this one), no object permission, so finalize can call
14434
+ * this on every re-upload under a role that has never hidden a file.
14435
+ */
14436
+ async unhideAsset(hash32) {
14437
+ const key = this.keys.hiddenKey(hash32);
14438
+ const listed = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: key, MaxKeys: 1 }));
14439
+ if (listed.Contents?.[0]?.Key !== key)
14440
+ return false;
14441
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
14442
+ return true;
14395
14443
  }
14396
14444
  async getObject(key) {
14397
14445
  try {
@@ -14766,7 +14814,14 @@ var init_asset_refs = __esm({
14766
14814
  import fs26 from "node:fs/promises";
14767
14815
  import path29 from "node:path";
14768
14816
  import { randomUUID as randomUUID4 } from "node:crypto";
14769
- var DEFAULT_MAX_UPLOAD_BYTES2, DEFAULT_LIST_LIMIT, HEADERS_SIDECAR_SUFFIX, TEMP_DIR, ORIGINAL_EXT_RE, LocalAssetStore;
14817
+ function compareNewestFirst(a, b) {
14818
+ if (a.uploadedAt !== b.uploadedAt)
14819
+ return a.uploadedAt < b.uploadedAt ? 1 : -1;
14820
+ if (a.hash32 === b.hash32)
14821
+ return 0;
14822
+ return a.hash32 < b.hash32 ? 1 : -1;
14823
+ }
14824
+ var DEFAULT_MAX_UPLOAD_BYTES2, DEFAULT_LIST_LIMIT, HEADERS_SIDECAR_SUFFIX, TEMP_DIR, ORIGINAL_EXT_RE, HIDDEN_MARKER_RE, LocalAssetStore;
14770
14825
  var init_store_local = __esm({
14771
14826
  "dist/assets/store-local.js"() {
14772
14827
  "use strict";
@@ -14778,6 +14833,7 @@ var init_store_local = __esm({
14778
14833
  HEADERS_SIDECAR_SUFFIX = ".headers.json";
14779
14834
  TEMP_DIR = ".asset-tmp";
14780
14835
  ORIGINAL_EXT_RE = /^[a-z0-9]+$/;
14836
+ HIDDEN_MARKER_RE = /^([a-f0-9]{32})\.json$/;
14781
14837
  LocalAssetStore = class {
14782
14838
  constructor(options) {
14783
14839
  this.capabilities = { directUpload: false };
@@ -14849,11 +14905,12 @@ var init_store_local = __esm({
14849
14905
  }
14850
14906
  }
14851
14907
  /**
14852
- * Create-only write of a blob and its headers sidecar. Each is written whole to a temp file, then
14853
- * `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
14854
- * The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
14855
- * already there was linked by an earlier writer of this key and is kept, as the blob is: the
14856
- * first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
14908
+ * Create-only write of a blob and, when `headers` is given, its headers sidecar. Each is written
14909
+ * whole to a temp file, then `link()`ed into place, which fails with EEXIST rather than replacing
14910
+ * (see docs/concurrency.md). The sidecar is linked first, so a reader never finds a blob without
14911
+ * its headers. A sidecar already there was linked by an earlier writer of this key and is kept,
14912
+ * as the blob is: the first writer's headers win, as on S3 (a static's Content-Disposition names
14913
+ * its uploaded file).
14857
14914
  */
14858
14915
  async createExclusive(key, data, headers) {
14859
14916
  const filePath = this.resolveKey(key);
@@ -14866,8 +14923,10 @@ var init_store_local = __esm({
14866
14923
  const tempHeaders = path29.join(tempDir, randomUUID4());
14867
14924
  try {
14868
14925
  await fs26.writeFile(tempBlob, data);
14869
- await fs26.writeFile(tempHeaders, JSON.stringify(headers), "utf-8");
14870
- await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
14926
+ if (headers) {
14927
+ await fs26.writeFile(tempHeaders, JSON.stringify(headers), "utf-8");
14928
+ await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
14929
+ }
14871
14930
  return await this.linkIfAbsent(tempBlob, filePath) ? "created" : "already-exists";
14872
14931
  } finally {
14873
14932
  await Promise.all([tempBlob, tempHeaders].map((temp) => fs26.unlink(temp).catch(() => {
@@ -14999,6 +15058,11 @@ var init_store_local = __esm({
14999
15058
  throw err2;
15000
15059
  }
15001
15060
  }
15061
+ /**
15062
+ * The cursor names the last item returned, and the next page starts after it in sort order, so a
15063
+ * page boundary holds when that item is hidden in between: its meta is never deleted, and the
15064
+ * comparison needs only its place, not its presence in the filtered list.
15065
+ */
15002
15066
  async listMeta(input) {
15003
15067
  const limit = input?.limit ?? DEFAULT_LIST_LIMIT;
15004
15068
  const dir = this.resolveKey(ASSET_PREFIXES.meta);
@@ -15022,29 +15086,43 @@ var init_store_local = __esm({
15022
15086
  }
15023
15087
  }
15024
15088
  }
15025
- all.sort((a, b) => {
15026
- if (a.uploadedAt !== b.uploadedAt)
15027
- return a.uploadedAt < b.uploadedAt ? 1 : -1;
15028
- return a.hash32 < b.hash32 ? 1 : -1;
15029
- });
15089
+ all.sort(compareNewestFirst);
15090
+ const hidden = await this.hiddenHashes();
15091
+ const listed = input?.includeHidden ? all.map((meta) => hidden.has(meta.hash32) ? { ...meta, hidden: true } : meta) : all.filter((meta) => !hidden.has(meta.hash32));
15030
15092
  let startIndex = 0;
15031
15093
  if (input?.cursor) {
15032
15094
  const afterHash32 = Buffer.from(input.cursor, "base64").toString("utf-8");
15033
- const idx = all.findIndex((m) => m.hash32 === afterHash32);
15034
- startIndex = idx === -1 ? 0 : idx + 1;
15095
+ const anchor = all.find((m) => m.hash32 === afterHash32);
15096
+ if (anchor) {
15097
+ const after = listed.findIndex((m) => compareNewestFirst(m, anchor) > 0);
15098
+ startIndex = after === -1 ? listed.length : after;
15099
+ }
15035
15100
  }
15036
- const items = all.slice(startIndex, startIndex + limit);
15037
- const hasMore = startIndex + limit < all.length;
15101
+ const items = listed.slice(startIndex, startIndex + limit);
15102
+ const hasMore = startIndex + limit < listed.length;
15038
15103
  const nextCursor = hasMore ? Buffer.from(items[items.length - 1].hash32, "utf-8").toString("base64") : void 0;
15039
15104
  return { items, nextCursor };
15040
15105
  }
15041
- async deleteMeta(hash32) {
15042
- const filePath = this.resolveKey(metaKey(hash32));
15106
+ async hiddenHashes() {
15043
15107
  try {
15044
- await fs26.unlink(filePath);
15108
+ const names = await fs26.readdir(this.resolveKey(ASSET_PREFIXES.hidden));
15109
+ return new Set(names.flatMap((name) => HIDDEN_MARKER_RE.exec(name)?.[1] ?? []));
15045
15110
  } catch (err2) {
15046
15111
  if (isNotFoundError(err2))
15047
- return;
15112
+ return /* @__PURE__ */ new Set();
15113
+ throw err2;
15114
+ }
15115
+ }
15116
+ async hideAsset(hash32, marker) {
15117
+ return this.createExclusive(hiddenKey(hash32), new TextEncoder().encode(JSON.stringify(marker)));
15118
+ }
15119
+ async unhideAsset(hash32) {
15120
+ try {
15121
+ await fs26.unlink(this.resolveKey(hiddenKey(hash32)));
15122
+ return true;
15123
+ } catch (err2) {
15124
+ if (isNotFoundError(err2))
15125
+ return false;
15048
15126
  throw err2;
15049
15127
  }
15050
15128
  }
@@ -208,5 +208,6 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
208
208
  ...branches.filter((b) => b.name !== currentBranchName),
209
209
  ]
210
210
  : branches;
211
- return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
211
+ const hasBranchThreads = comments.some((t) => t.type === 'branch');
212
+ return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
212
213
  };
@@ -1,23 +1,17 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useId, useRef, useState } from 'react';
4
- import { Badge, Button, Drawer, Group, Paper, ScrollArea, Stack, Text, Textarea, Title, Switch, Tooltip, VisuallyHidden, } from '@mantine/core';
4
+ import { Badge, Button, Group, Paper, ScrollArea, Stack, Text, Textarea, Switch, Tooltip, VisuallyHidden, } from '@mantine/core';
5
+ import { IconChevronRight } from '@tabler/icons-react';
5
6
  import { UserBadge } from './components/UserBadge.js';
6
7
  import { userIdLabel } from './user-display.js';
8
+ import { OverlayDrawer } from './shell/OverlayHeader.js';
7
9
  import { RelativeTime } from './components/RelativeTime.js';
8
10
  import { ThreadReplyControls } from './comments/ThreadReplyControls.js';
9
11
  import { useTrackedWrites } from './comments/use-tracked-writes.js';
10
12
  import { CloseRefusedNotice } from './comments/CloseRefusedNotice.js';
11
13
  import { ON_REMOVED_ITEM } from './comments/item-anchor-copy.js';
12
14
  import { CommentWriteAlert, toCommentWriteFailure, } from './comments/CommentWriteAlert.js';
13
- /**
14
- * A jump button that stays focusable while a write is in flight (`aria-disabled`, not `disabled`),
15
- * so its reason opens on keyboard focus and touch as well as hover.
16
- */
17
- const JumpButton = ({ blocked, onClick, children }) => (_jsx(Tooltip, { label: "Your change is still saving. Try again once it finishes.", disabled: !blocked, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", onClick: () => {
18
- if (!blocked)
19
- onClick();
20
- }, "data-disabled": blocked ? true : undefined, "aria-disabled": blocked ? true : undefined, children: children }) }));
21
15
  const THREAD_KIND_LABELS = { field: 'Field', entry: 'Entry', branch: 'Branch' };
22
16
  export const CommentsPanel = ({ opened, branchName, comments, canResolve, currentUserId, onAddComment, onResolveThread, onClose, onJumpToField, onJumpToEntry, onJumpToBranch, onGetUserMetadata, entryLabel, fieldLabel, }) => {
23
17
  const [newCommentText, setNewCommentText] = useState('');
@@ -65,27 +59,71 @@ export const CommentsPanel = ({ opened, branchName, comments, canResolve, curren
65
59
  setIsSubmitting(false);
66
60
  }
67
61
  };
68
- const threadHeading = (thread) => {
69
- const kind = THREAD_KIND_LABELS[thread.type];
62
+ // "Entry › Field", or undefined for a thread on the branch itself.
63
+ const threadPath = (thread) => {
70
64
  if (!thread.entryPath)
71
- return kind;
65
+ return undefined;
72
66
  const entry = entryLabel?.(thread.entryPath) ?? thread.entryPath;
73
67
  const field = thread.canopyPath ? (fieldLabel?.(thread) ?? thread.canopyPath) : undefined;
74
- return `${kind} · ${field ? `${entry} › ${field}` : entry}`;
68
+ return field ? `${entry} › ${field}` : entry;
69
+ };
70
+ const threadHeading = (thread) => {
71
+ const kind = THREAD_KIND_LABELS[thread.type];
72
+ const path = threadPath(thread);
73
+ return path ? `${kind} · ${path}` : kind;
74
+ };
75
+ const jumpTarget = (thread) => {
76
+ const { entryPath, canopyPath } = thread;
77
+ if (thread.type === 'field' &&
78
+ entryPath &&
79
+ canopyPath &&
80
+ !thread.onRemovedItem &&
81
+ onJumpToField) {
82
+ return { label: 'Jump to field', go: () => onJumpToField(entryPath, canopyPath, thread.id) };
83
+ }
84
+ // A removed item's thread lives on its entry.
85
+ if ((thread.type === 'entry' || thread.onRemovedItem) && entryPath && onJumpToEntry) {
86
+ return { label: 'Jump to entry', go: () => onJumpToEntry(entryPath, thread.id) };
87
+ }
88
+ if (thread.type === 'branch' && onJumpToBranch) {
89
+ return { label: 'Open branch discussion', go: () => onJumpToBranch(thread.id) };
90
+ }
91
+ return null;
92
+ };
93
+ const setHeadingRef = (threadId, node) => {
94
+ if (node)
95
+ headingRefs.current.set(threadId, node);
96
+ else
97
+ headingRefs.current.delete(threadId);
75
98
  };
76
- return (_jsx(Drawer, { opened: opened, onClose: close, returnFocus: !jumping, position: "right", size: 420, title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Comments" }), _jsx(Text, { size: "xs", c: "dimmed", children: branchName })] }), children: _jsxs(Stack, { gap: "md", h: "100%", style: { display: 'flex', flexDirection: 'column' }, children: [_jsx(CloseRefusedNotice, { refused: closeRefused }), _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Posts a branch-level comment on \"", branchName, "\". To comment on a field, use its comment icon."] }), _jsx(Textarea, { placeholder: "Add a comment\u2026", "aria-label": "New branch comment", value: newCommentText, onChange: (e) => setNewCommentText(e.target.value), minRows: 3, disabled: isSubmitting, "data-testid": "comment-textarea" }), error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), _jsx(Button, { onClick: handleAddComment, size: "sm", loading: isSubmitting, disabled: !newCommentText.trim(), "data-testid": "comment-submit", children: "Comment" })] }) }), _jsxs(Group, { justify: "space-between", children: [_jsxs(Text, { ref: threadCountRef, size: "sm", fw: 500, tabIndex: -1, children: [filteredThreads.length, " ", filteredThreads.length === 1 ? 'thread' : 'threads'] }), _jsx(Switch, { label: "Show resolved", checked: showResolved, onChange: (e) => setShowResolved(e.currentTarget.checked), size: "sm" })] }), _jsx(ScrollArea, { style: { flex: 1 }, children: filteredThreads.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", ta: "center", py: "xl", children: showResolved ? 'No comments yet' : 'No unresolved comments' })) : (_jsx(Stack, { gap: "md", children: filteredThreads.map((thread) => (_jsx(Paper, { withBorder: true, p: "md", component: "article", "aria-labelledby": headingId(thread.id), bg: thread.resolved ? 'var(--mantine-color-default-hover)' : undefined, "data-testid": "comment-thread", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", children: [_jsxs(Text, { id: headingId(thread.id), ref: (node) => {
77
- if (node)
78
- headingRefs.current.set(thread.id, node);
79
- else
80
- headingRefs.current.delete(thread.id);
81
- }, size: "xs", fw: 600, tabIndex: -1, "data-testid": "comment-thread-heading", children: [threadHeading(thread), thread.onRemovedItem && (_jsxs(VisuallyHidden, { children: [", ", ON_REMOVED_ITEM.toLowerCase()] }))] }), thread.resolved && (_jsx(Badge, { size: "xs", color: "green", variant: "light", children: "Resolved" })), thread.onRemovedItem && (_jsx(Badge, { size: "xs", color: "gray", variant: "light", c: "var(--mantine-color-text)", children: ON_REMOVED_ITEM }))] }), _jsxs(Group, { gap: "xs", children: [thread.type === 'field' &&
82
- thread.entryPath &&
83
- thread.canopyPath &&
84
- !thread.onRemovedItem &&
85
- onJumpToField && (_jsx(JumpButton, { blocked: inFlight > 0, onClick: () => jump(() => onJumpToField(thread.entryPath, thread.canopyPath, thread.id)), children: "Jump to field" })), (thread.type === 'entry' || thread.onRemovedItem) &&
86
- thread.entryPath &&
87
- onJumpToEntry && (_jsx(JumpButton, { blocked: inFlight > 0, onClick: () => jump(() => onJumpToEntry(thread.entryPath, thread.id)), children: "Jump to entry" })), thread.type === 'branch' && onJumpToBranch && (_jsx(JumpButton, { blocked: inFlight > 0, onClick: () => jump(() => onJumpToBranch(thread.id)), children: "Open branch discussion" }))] }), thread.comments.map((comment, idx) => (_jsxs("div", { children: [idx > 0 && (_jsx("div", { style: {
88
- borderTop: '1px solid var(--mantine-color-default-border)',
89
- margin: '8px 0',
90
- } })), _jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [onGetUserMetadata ? (_jsx(UserBadge, { userId: comment.userId, getUserMetadata: onGetUserMetadata, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })) : (_jsx(Text, { size: "xs", fw: 500, children: userIdLabel(comment.userId) })), _jsx(RelativeTime, { iso: comment.timestamp, size: "xs", c: "dimmed" })] }), _jsx(Text, { size: "sm", style: { whiteSpace: 'pre-wrap' }, children: comment.text })] })] }, comment.id))), !thread.resolved && (_jsx(ThreadReplyControls, { thread: thread, onAddReply: (text) => addComment(text, thread.type, thread.entryPath, thread.canopyPath, thread.id), onResolve: () => resolveThread(thread.id), focusAfterResolve: () => showResolved ? headingRefs.current.get(thread.id) : threadCountRef.current, currentUserId: currentUserId, canResolve: canResolve, replyText: replyDrafts[thread.id] ?? '', onReplyTextChange: (text) => setReplyDrafts((prev) => ({ ...prev, [thread.id]: text })) }))] }) }, thread.id))) })) })] }) }));
99
+ // The heading's text names the article; the removed-item badge beside it is outside it.
100
+ const headingText = (thread) => (_jsxs("span", { id: headingId(thread.id), "data-testid": "comment-thread-heading", children: [threadHeading(thread), thread.onRemovedItem && _jsxs(VisuallyHidden, { children: [", ", ON_REMOVED_ITEM.toLowerCase()] })] }));
101
+ return (_jsx(OverlayDrawer, { opened: opened, onClose: close, returnFocus: !jumping, position: "right", size: 420, title: "Comments", children: _jsxs(Stack, { gap: "md", h: "100%", style: { display: 'flex', flexDirection: 'column' }, children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Select a comment's heading to see it where it was left." }), _jsx(CloseRefusedNotice, { refused: closeRefused }), _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Posts a branch-level comment on \"", branchName, "\". To comment on a field, use its comment icon."] }), _jsx(Textarea, { placeholder: "Add a comment\u2026", "aria-label": "New branch comment", value: newCommentText, onChange: (e) => setNewCommentText(e.target.value), minRows: 3, disabled: isSubmitting, "data-testid": "comment-textarea" }), error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), _jsx(Button, { onClick: handleAddComment, size: "sm", loading: isSubmitting, disabled: !newCommentText.trim(), "data-testid": "comment-submit", children: "Comment" })] }) }), _jsxs(Group, { justify: "space-between", children: [_jsxs(Text, { ref: threadCountRef, size: "sm", fw: 500, tabIndex: -1, children: [filteredThreads.length, " ", filteredThreads.length === 1 ? 'thread' : 'threads'] }), _jsx(Switch, { label: "Show resolved", checked: showResolved, onChange: (e) => setShowResolved(e.currentTarget.checked), size: "sm" })] }), _jsx(ScrollArea, { style: { flex: 1 }, children: filteredThreads.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", ta: "center", py: "xl", children: showResolved ? 'No comments yet' : 'No unresolved comments' })) : (_jsx(Stack, { gap: "md", children: filteredThreads.map((thread) => {
102
+ const target = jumpTarget(thread);
103
+ return (_jsx(Paper, { withBorder: true, p: "md", component: "article", "aria-labelledby": headingId(thread.id), bg: thread.resolved ? 'var(--mantine-color-default-hover)' : undefined, "data-testid": "comment-thread", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", children: [target ? (
104
+ // The heading is the jump control, the one tab stop on the card, in an
105
+ // `h3` under the drawer's `h2` so it is reachable by heading. It stays
106
+ // focusable while a write is in flight (`aria-disabled`, not `disabled`),
107
+ // so its reason opens on keyboard focus and touch as well as hover, and
108
+ // keeps its text colour: only the cursor says it is waiting. The visible
109
+ // text is inside the button's name (WCAG 2.5.3), after a hidden prefix
110
+ // that says what it does.
111
+ _jsx(Text, { component: "h3", size: "xs", fw: 600, m: 0, children: _jsx(Tooltip, { label: "Your change is still saving. Try again once it finishes.", disabled: inFlight === 0, multiline: true, maw: 280, children: _jsxs(Button, { ref: (node) => setHeadingRef(thread.id, node), variant: "subtle", color: "neutral", h: "auto", px: 6, py: 2, mx: -6, rightSection: _jsx(IconChevronRight, { size: 14, "aria-hidden": true }), "aria-disabled": inFlight > 0 ? true : undefined, styles: {
112
+ root: { cursor: inFlight > 0 ? 'default' : 'pointer' },
113
+ label: {
114
+ whiteSpace: 'normal',
115
+ textAlign: 'start',
116
+ lineHeight: 1.35,
117
+ overflowWrap: 'anywhere',
118
+ },
119
+ }, onClick: () => {
120
+ if (inFlight === 0)
121
+ jump(target.go);
122
+ }, children: [_jsx(VisuallyHidden, { component: "span", children: `${target.label}: ` }), headingText(thread)] }) }) })) : (_jsx(Text, { component: "h3", ref: (node) => setHeadingRef(thread.id, node), size: "xs", fw: 600, m: 0, tabIndex: -1, children: headingText(thread) })), thread.resolved && (_jsx(Badge, { size: "xs", color: "green", variant: "light", children: "Resolved" })), thread.onRemovedItem && (_jsx(Badge, { size: "xs", color: "gray", variant: "light", c: "var(--mantine-color-text)", children: ON_REMOVED_ITEM }))] }), thread.comments.map((comment, idx) => (_jsxs("div", { children: [idx > 0 && (_jsx("div", { style: {
123
+ borderTop: '1px solid var(--mantine-color-default-border)',
124
+ margin: '8px 0',
125
+ } })), _jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [onGetUserMetadata ? (_jsx(UserBadge, { userId: comment.userId, getUserMetadata: onGetUserMetadata, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })) : (_jsx(Text, { size: "xs", fw: 500, children: userIdLabel(comment.userId) })), _jsx(RelativeTime, { iso: comment.timestamp, size: "xs", c: "dimmed" })] }), _jsx(Text, { size: "sm", style: { whiteSpace: 'pre-wrap' }, children: comment.text })] })] }, comment.id))), !thread.resolved && (_jsx(ThreadReplyControls, { thread: thread, onAddReply: (text) => addComment(text, thread.type, thread.entryPath, thread.canopyPath, thread.id), onResolve: () => resolveThread(thread.id), focusAfterResolve: () => showResolved
126
+ ? headingRefs.current.get(thread.id)
127
+ : threadCountRef.current, currentUserId: currentUserId, canResolve: canResolve, replyText: replyDrafts[thread.id] ?? '', onReplyTextChange: (text) => setReplyDrafts((prev) => ({ ...prev, [thread.id]: text })) }))] }) }, thread.id));
128
+ }) })) })] }) }));
91
129
  };
@@ -7,7 +7,10 @@ import type { LogicalPath, ContentId } from '../paths/types.js';
7
7
  export interface EditorEntry {
8
8
  path: LogicalPath;
9
9
  contentId: ContentId;
10
+ /** What the editor calls the entry: its title, or a labelled singleton's type label. */
10
11
  label: string;
12
+ /** The entry's title, kept where `label` is a singleton's type label: link text and search use it. */
13
+ title?: string;
11
14
  status?: string;
12
15
  schema: EntrySchema;
13
16
  previewSrc?: string;