@dev.fast/whiteboard 0.2.4 → 0.2.5-preview.20261013.109

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 (77) hide show
  1. package/dist/{account-alias-_1_7vROx.js → account-alias-D0hF0sKk.js} +2 -3
  2. package/dist/{agent-cli-BrFqhnzM.js → agent-cli-BBPxwUOd.js} +6 -6
  3. package/dist/agent-cli-YSuhE5X8.js +2 -0
  4. package/dist/{agent-client-B_b1GtUc.js → agent-client-P3J4y8Zn.js} +3 -3
  5. package/dist/{authoring-tools-DvIO906l.js → authoring-tools-D8WXsm_g.js} +11 -10
  6. package/dist/build-info.json +1 -1
  7. package/dist/{cli-gRGxkwc0.js → cli-C0ziP6hg.js} +1 -1
  8. package/dist/{cli-runner-Cjl8h376.js → cli-runner-C1gY-HAI.js} +16 -17
  9. package/dist/cli.js +1 -1
  10. package/dist/{client-BFClfQLO.js → client-DzcSt6pV.js} +63 -13
  11. package/dist/{desktop-discovery-C-Qz54wO.js → desktop-discovery-Ba3dD5Cn.js} +1 -1
  12. package/dist/{headless-host-DcWaxx9P.js → headless-host-BHD_Vg5F.js} +4 -4
  13. package/dist/{local-data-Bm84Xdck.js → local-data-BlERtVm2.js} +281 -75
  14. package/dist/{mcp-BDy9Aybd.js → mcp-t7VN9R3k.js} +4 -4
  15. package/dist/{package-paths-B6-zxvIO.js → package-paths-B9PDoAl6.js} +1 -1
  16. package/dist/{process-error-telemetry-Dzh5a3s_.js → process-error-telemetry-C0swByfp.js} +158 -57
  17. package/dist/{profile-RDEioF8b.js → profile-BsYDBr7t.js} +2 -2
  18. package/dist/profile-j97aRTS7.js +2 -0
  19. package/dist/{review-agent-traces-DNJFYzc8.js → review-home-paths-Y9mYWkCI.js} +2600 -62
  20. package/dist/{review-telemetry-DBy3eVCo.js → review-telemetry-CmM6tCbj.js} +2 -2
  21. package/dist/server/desktop-host.js +35 -46
  22. package/dist/{server-discovery-Dbzn3w6Z.js → server-discovery-ZVi8Ay8J.js} +1 -1
  23. package/dist/sharing/index.d.ts +37 -49
  24. package/dist/sharing/index.js +1 -1
  25. package/dist/{src-CmdiBL20.js → src-DiHP11l3.js} +11 -1
  26. package/dist/{stored-document-migration-BxZWQQjv.js → stored-document-migration-u4L_1VZa.js} +15 -36
  27. package/dist/{tool-failure-DJmTieh5.js → tool-failure-CJHBCHMh.js} +2 -2
  28. package/instructions/scratchpad.md +1 -1
  29. package/package.json +5 -4
  30. package/src/ask/agents.ts +6 -0
  31. package/src/ask/thread-state.ts +17 -1
  32. package/src/ask/thread.ts +116 -22
  33. package/src/ask/threads.ts +8 -2
  34. package/src/error-telemetry.ts +13 -9
  35. package/src/fixtures/blocks/tutorial.json +3 -40
  36. package/src/review-api/README.md +10 -7
  37. package/src/review-api/ask-history.ts +37 -0
  38. package/src/review-api/authoring-tools.ts +8 -5
  39. package/src/review-api/blocks/database_lens.ts +5 -0
  40. package/src/review-api/blocks/tutorial.ts +2 -18
  41. package/src/review-api/comparison-coverage.ts +16 -9
  42. package/src/review-api/component-reference.ts +1 -1
  43. package/src/review-api/document.ts +24 -10
  44. package/src/review-api/http.ts +267 -189
  45. package/src/review-api/local-data.ts +126 -37
  46. package/src/review-api/store-schema.ts +25 -1
  47. package/src/review-api/store.ts +165 -33
  48. package/src/review-api/workspaces.ts +14 -14
  49. package/src/review-api/worktree-events.ts +46 -0
  50. package/src/review-app.ts +1 -1
  51. package/src/review-info.ts +1 -1
  52. package/src/review-stack.ts +2 -2
  53. package/src/server/review-server-core.ts +25 -1
  54. package/src/server/tutorial-service.ts +22 -46
  55. package/src/sharing/export.ts +1 -0
  56. package/src/sharing/import.ts +111 -26
  57. package/tutorial/document.json +27 -86
  58. package/tutorial/lenses.json +10 -0
  59. package/tutorial/runtime-manifest.json +2 -3
  60. package/tutorial/sample-service/src/database/drizzle-orm.d.ts +16 -0
  61. package/tutorial/sample-service/src/database/schema.ts +9 -10
  62. package/tutorial/sample-service/src/inventory/forklift-mood.ts +4 -0
  63. package/tutorial/sample-service/src/inventory/inventory-service.ts +18 -1
  64. package/tutorial/sample-service/src/inventory/warehouse-lore.ts +11 -0
  65. package/tutorial/sample-service/src/orders/order-service.ts +3 -2
  66. package/tutorial/sample-service/src/orders/order.ts +27 -5
  67. package/tutorial/sample-service/src/payments/fraud-vibes.ts +4 -0
  68. package/tutorial/sample-service/src/payments/payment-gateway.ts +19 -2
  69. package/tutorial/sample-service/src/payments/raccoon-ledger.ts +9 -0
  70. package/dist/agent-cli-BR2j-2n5.js +0 -2
  71. package/dist/input-error-OLgB_h31.js +0 -11
  72. package/dist/profile-BQGaX8aX.js +0 -2
  73. package/dist/review-home-paths-6zZH-1c9.js +0 -2560
  74. package/dist/tutorial-trace-DrBvkaeL.js +0 -43
  75. package/src/tutorial-conversation.ts +0 -18
  76. package/tutorial/software-map.json +0 -139
  77. package/tutorial/trace.json +0 -20
@@ -1,16 +1,15 @@
1
- import { _ as readFileAtCommit, b as resolveRevision, c as diffTrees, f as gitCommonDir, g as parseGitRemote, h as listTrackedFilesAtCommit, i as diff, l as diffWorkingTree, m as listCommitRange, n as createBlobBatchReader, o as diffFileSummariesTrees, p as jjRevisionIsConflicted, r as detectLocalVcs, s as diffFileSummariesWorkingTree, t as LocalVcsToolsMissingError, u as git, v as resolveRepoContext, y as resolveRepoContextSync } from "./src-CmdiBL20.js";
1
+ import { C as ReviewInputError, _ as readFileAtCommit, b as resolveRevision, c as diffTrees, f as gitCommonDir, g as parseGitRemote, h as listTrackedFilesAtCommit, i as diff, l as diffWorkingTree, m as listCommitRange, n as createBlobBatchReader, o as diffFileSummariesTrees, p as jjRevisionIsConflicted, r as detectLocalVcs, s as diffFileSummariesWorkingTree, t as LocalVcsToolsMissingError, u as git, v as resolveRepoContext, y as resolveRepoContextSync } from "./src-DiHP11l3.js";
2
2
  import { c as jsonString, o as jsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
3
3
  import { _ as SCRATCHPAD_REVIEW_ID, b as structuralChangeCounts, g as ReviewSelectedDiffSchema, m as ReviewApiSelectionSourceSchema, y as decodeStructuralDiffEvent } from "./src-WCbabvdS.js";
4
- import { $ as unionIntervals, A as applyLensEdit, C as resourceReferences, D as summarizeEdit, E as sourceReferences, F as checkSourcePath, I as fileLineRangeSchema, K as sourceAnchors, L as requireVisibleSource, N as lensSelections, O as checkReferences, P as SourceRangeError, Q as subtractIntervals, R as sliceSourceRange, S as resourceReference, X as intersectIntervals, Y as emptyCoverage, a as defineSoftwareMap, b as pinsSchema, c as traceSchema, d as applyEdit, et as updateCoverage, f as assignFreshIds, g as explicitPins, h as elements, i as SoftwareModelValidationError, j as lensEditSchema, l as textIncludesQuote, m as editSchema, n as migrateStoredDocument, p as documentSchema, q as coverageSchema, r as migrateStoredLenses, t as liftFileLenses, u as anchorPins, v as isUnit, w as reviewTargetSchema } from "./stored-document-migration-BxZWQQjv.js";
5
- import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
4
+ import { s as safeStorageSegment } from "./review-home-paths-Y9mYWkCI.js";
5
+ import { $ as unionIntervals, A as applyLensEdit, C as resourceReferences, D as summarizeEdit, E as sourceReferences, F as checkSourcePath, I as fileLineRangeSchema, K as sourceAnchors, L as requireVisibleSource, N as lensSelections, O as checkReferences, P as SourceRangeError, Q as subtractIntervals, R as sliceSourceRange, S as resourceReference, X as intersectIntervals, Y as emptyCoverage, a as defineSoftwareMap, b as pinsSchema, c as traceSchema, d as applyEdit, et as updateCoverage, f as assignFreshIds, g as explicitPins, h as elements, i as SoftwareModelValidationError, j as lensEditSchema, l as textIncludesQuote, m as editSchema, n as migrateStoredDocument, p as documentSchema, q as coverageSchema, r as migrateStoredLenses, t as liftFileLenses, u as anchorPins, v as isUnit, w as reviewTargetSchema } from "./stored-document-migration-u4L_1VZa.js";
6
6
  import { D as processIsAlive, O as withFileLock, P as writePrivateJsonAtomic } from "./s3-config-CAxhO9u_.js";
7
7
  import { t as errorMessage } from "./error-message-OtiDonty.js";
8
- import { s as safeStorageSegment } from "./review-home-paths-6zZH-1c9.js";
9
8
  import { r as isMissingFileError } from "./fs-utils-BMPLt0cr.js";
10
9
  import { createRequire } from "node:module";
11
10
  import crypto, { createHash, randomUUID } from "node:crypto";
12
11
  import { z } from "zod";
13
- import { existsSync, realpathSync, rmSync, watch } from "node:fs";
12
+ import { existsSync, realpathSync, rmSync, statSync } from "node:fs";
14
13
  import * as fs$1 from "node:fs/promises";
15
14
  import { lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
16
15
  import path, { dirname, isAbsolute, relative, resolve, sep } from "node:path";
@@ -1015,11 +1014,12 @@ async function comparisonCoverage(data, reviewId, pins, mode, signal, publish) {
1015
1014
  counted: true
1016
1015
  };
1017
1016
  if (mode === "textual") return coverage;
1018
- const publishCurrent = () => publish?.({
1017
+ const unsupported = /* @__PURE__ */ new Set();
1018
+ const current = () => ({
1019
1019
  ...coverage,
1020
- files: [...coverage.files]
1020
+ files: coverage.files.filter((file) => !unsupported.has(file.path))
1021
1021
  });
1022
- publishCurrent();
1022
+ publish?.(current());
1023
1023
  const baseFiles = new Map(coverage.files.map((file, index) => [file.previousPath ?? file.path, index]));
1024
1024
  const headFiles = new Map(coverage.files.map((file, index) => [file.path, index]));
1025
1025
  const remaining = /* @__PURE__ */ new Set();
@@ -1037,8 +1037,11 @@ async function comparisonCoverage(data, reviewId, pins, mode, signal, publish) {
1037
1037
  if (event.type !== "file") continue;
1038
1038
  const path = (event.file.rhs ?? event.file.lhs).path;
1039
1039
  if (!remaining.delete(path)) throw new Error(`Unexpected structural result: ${path}`);
1040
+ const baseIndex = event.file.lhs && baseFiles.get(event.file.lhs.path);
1041
+ const headIndex = event.file.rhs && headFiles.get(event.file.rhs.path);
1040
1042
  if (event.error) {
1041
1043
  if (event.error.code !== "unsupported_file_type" && event.error.code !== "not_utf8") throw new Error(`Cannot count ${path}: ${event.error.message}`);
1044
+ for (const index of [baseIndex, headIndex]) if (index !== void 0) unsupported.add(coverage.files[index].path);
1042
1045
  } else if (event.diff.type === "text") {
1043
1046
  const diff = event.diff;
1044
1047
  const folded = foldedChanges(diff);
@@ -1056,17 +1059,16 @@ async function comparisonCoverage(data, reviewId, pins, mode, signal, publish) {
1056
1059
  }
1057
1060
  };
1058
1061
  }
1059
- const baseIndex = event.file.lhs && baseFiles.get(event.file.lhs.path);
1060
- const index = (event.file.rhs && headFiles.get(event.file.rhs.path)) ?? baseIndex;
1062
+ const index = headIndex ?? baseIndex;
1061
1063
  if (index !== void 0) {
1062
1064
  if ((coverage.fileSources.get(coverage.files[index].path) ?? []).every((source) => source.file === (source.side === "base" ? event.file.lhs : event.file.rhs)?.path)) coverage.alignments = new Map(coverage.alignments).set(coverage.files[index].path, structuralRows(diff));
1063
1065
  }
1064
- publishCurrent();
1065
1066
  }
1067
+ publish?.(current());
1066
1068
  if (!remaining.size) break;
1067
1069
  }
1068
1070
  if (remaining.size) throw new Error("Structural coverage is incomplete.");
1069
- return coverage;
1071
+ return current();
1070
1072
  }
1071
1073
  /**
1072
1074
  * The changed lines diffr folds by default. A file it hides (lockfiles,
@@ -1831,6 +1833,11 @@ const askUsageSchema = z.object({
1831
1833
  currency: z.string()
1832
1834
  }).optional()
1833
1835
  });
1836
+ /** A follow-up waiting for the turn under way to end. */
1837
+ const askQueuedSchema = z.object({
1838
+ id: z.string(),
1839
+ text: z.string()
1840
+ });
1834
1841
  const statusSchema = z.enum([
1835
1842
  "starting",
1836
1843
  "running",
@@ -1867,7 +1874,10 @@ const askThreadStateSchema = z.object({
1867
1874
  title: z.string(),
1868
1875
  quote: z.string().optional()
1869
1876
  }),
1870
- entries: z.array(askEntrySchema)
1877
+ entries: z.array(askEntrySchema),
1878
+ /** Follow-ups asked while the agent answered, which it could not take
1879
+ * then: each goes as its own question once the turn ends. */
1880
+ queued: z.array(askQueuedSchema).optional()
1871
1881
  });
1872
1882
  /** One change to a thread. Answer text arrives as `append`, so a streamed
1873
1883
  * token costs its own length, not the thread's. */
@@ -1882,6 +1892,8 @@ const askChangeSchema = z.discriminatedUnion("type", [
1882
1892
  accepts: askAcceptsSchema.optional(),
1883
1893
  usage: askUsageSchema.optional(),
1884
1894
  title: z.string().optional(),
1895
+ /** Replaces the queue; empty clears it. */
1896
+ queued: z.array(askQueuedSchema).optional(),
1885
1897
  /** `null` clears the error; absent leaves it. */
1886
1898
  error: z.string().nullable().optional(),
1887
1899
  /** `null` clears it; absent leaves it. */
@@ -1925,7 +1937,7 @@ z.union([z.object({
1925
1937
  function applyAskChange(state, change) {
1926
1938
  switch (change.type) {
1927
1939
  case "set": {
1928
- const { error: _error, signIn: _signIn, ...rest } = state;
1940
+ const { error: _error, signIn: _signIn, queued: _queued, ...rest } = state;
1929
1941
  const next = {
1930
1942
  ...rest,
1931
1943
  status: change.status ?? state.status,
@@ -1942,6 +1954,8 @@ function applyAskChange(state, change) {
1942
1954
  if (usage) next.usage = usage;
1943
1955
  const title = change.title ?? state.title;
1944
1956
  if (title) next.title = title;
1957
+ const queued = change.queued ?? state.queued;
1958
+ if (queued?.length) next.queued = queued;
1945
1959
  const error = change.error === void 0 ? state.error : change.error;
1946
1960
  if (error) next.error = error;
1947
1961
  const signIn = change.signIn === void 0 ? state.signIn : change.signIn;
@@ -2032,6 +2046,10 @@ const rowSchema = z.object({
2032
2046
  * `reviews`, so there is no foreign key; deleting a review deletes its rows. */
2033
2047
  var AskHistory = class {
2034
2048
  db;
2049
+ externalWriteGuard;
2050
+ setExternalWriteGuard(guard) {
2051
+ this.externalWriteGuard = guard;
2052
+ }
2035
2053
  constructor(db) {
2036
2054
  this.db = db;
2037
2055
  db.exec(`CREATE TABLE IF NOT EXISTS ask_conversations(
@@ -2054,25 +2072,42 @@ var AskHistory = class {
2054
2072
  db.exec("CREATE TABLE IF NOT EXISTS ask_agent_offers(agent TEXT PRIMARY KEY, offer TEXT NOT NULL)");
2055
2073
  db.exec("CREATE TABLE IF NOT EXISTS ask_agent_model_offers(agent TEXT NOT NULL, model TEXT NOT NULL, offer TEXT NOT NULL, PRIMARY KEY(agent, model))");
2056
2074
  }
2075
+ assertWritable(reviewId) {
2076
+ if (reviewId.startsWith("shared-")) this.externalWriteGuard?.(reviewId);
2077
+ const row = this.db.prepare("SELECT deleted_at FROM review_attention WHERE review_id=?").get(reviewId);
2078
+ if (row?.deleted_at) throw new ReviewInputError(`This whiteboard was moved to Trash on ${new Date(String(row.deleted_at)).toLocaleDateString("en-US", {
2079
+ month: "short",
2080
+ day: "numeric"
2081
+ })}. Restore it with session_lifecycle({"sessionId":"${reviewId}","state":"active"}) to continue.`, 409);
2082
+ }
2083
+ assertThreadWritable(id) {
2084
+ const record = this.get(id);
2085
+ if (record) this.assertWritable(record.reviewId);
2086
+ }
2057
2087
  save(record) {
2088
+ this.assertWritable(record.reviewId);
2058
2089
  this.db.prepare(`INSERT OR REPLACE INTO ask_conversations(id,review_id,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass)
2059
2090
  VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(record.id, record.reviewId, record.agent, record.sessionId, record.version, record.head, record.cwd, JSON.stringify(record.selection), record.title, record.createdAt, record.updatedAt, record.bypass ? 1 : 0);
2060
2091
  }
2061
2092
  /** Whether reopening the conversation bypasses permissions. */
2062
2093
  setBypass(id, bypass) {
2094
+ this.assertThreadWritable(id);
2063
2095
  this.db.prepare("UPDATE ask_conversations SET bypass=? WHERE id=?").run(bypass ? 1 : 0, id);
2064
2096
  }
2065
2097
  /** Points a conversation at a new session, when its agent could not
2066
2098
  * reopen the one it had. */
2067
2099
  updateSession(id, sessionId) {
2100
+ this.assertThreadWritable(id);
2068
2101
  this.db.prepare("UPDATE ask_conversations SET session_id=? WHERE id=?").run(sessionId, id);
2069
2102
  }
2070
2103
  /** Keeps what the panel shows, so a reopen need not wait on the agent. */
2071
2104
  saveEntries(id, entries) {
2105
+ this.assertThreadWritable(id);
2072
2106
  this.db.prepare("UPDATE ask_conversations SET entries=? WHERE id=?").run(JSON.stringify(entries), id);
2073
2107
  }
2074
2108
  /** The agent's name for the conversation, which the list shows. */
2075
2109
  rename(id, title) {
2110
+ this.assertThreadWritable(id);
2076
2111
  this.db.prepare("UPDATE ask_conversations SET title=? WHERE id=?").run(title.slice(0, 200), id);
2077
2112
  }
2078
2113
  saveOffer(agent, offer) {
@@ -2092,6 +2127,7 @@ var AskHistory = class {
2092
2127
  return row && askOfferSchema.safeParse(JSON.parse(row.offer)).data;
2093
2128
  }
2094
2129
  touch(id, at = (/* @__PURE__ */ new Date()).toISOString()) {
2130
+ this.assertThreadWritable(id);
2095
2131
  this.db.prepare("UPDATE ask_conversations SET updated_at=? WHERE id=?").run(at, id);
2096
2132
  }
2097
2133
  /** Newest first. */
@@ -2107,7 +2143,11 @@ var AskHistory = class {
2107
2143
  const row = this.db.prepare("SELECT * FROM ask_conversations WHERE id=?").get(id);
2108
2144
  return row ? rowSchema.parse(row) : void 0;
2109
2145
  }
2146
+ removeReview(reviewId) {
2147
+ this.db.prepare("DELETE FROM ask_conversations WHERE review_id=?").run(reviewId);
2148
+ }
2110
2149
  delete(id) {
2150
+ this.assertThreadWritable(id);
2111
2151
  this.db.prepare("DELETE FROM ask_conversations WHERE id=?").run(id);
2112
2152
  }
2113
2153
  };
@@ -2145,7 +2185,7 @@ function initializeReviewStoreSchema(db) {
2145
2185
  CREATE TABLE IF NOT EXISTS versions(review_id TEXT REFERENCES reviews(id), version INTEGER, snapshot TEXT NOT NULL,
2146
2186
  PRIMARY KEY(review_id,version));
2147
2187
  DROP TABLE IF EXISTS receipts;
2148
- CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT, dismissed_at TEXT);
2188
+ CREATE TABLE IF NOT EXISTS review_attention(review_id TEXT PRIMARY KEY REFERENCES reviews(id), viewed_at TEXT, deleted_at TEXT);
2149
2189
  CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL);
2150
2190
  CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY, repository_id TEXT NOT NULL REFERENCES repositories(id),
2151
2191
  kind TEXT NOT NULL, mime_type TEXT NOT NULL, data BLOB NOT NULL);
@@ -2156,6 +2196,17 @@ function initializeReviewStoreSchema(db) {
2156
2196
  CREATE TABLE IF NOT EXISTS server_identity(one INTEGER PRIMARY KEY CHECK(one=1), id TEXT NOT NULL);
2157
2197
  DROP TABLE IF EXISTS authoring_drafts;
2158
2198
  `);
2199
+ db.exec("BEGIN IMMEDIATE");
2200
+ try {
2201
+ if (db.prepare("PRAGMA table_info(review_attention)").all().some((column) => column.name === "dismissed_at")) {
2202
+ db.exec("ALTER TABLE review_attention RENAME COLUMN dismissed_at TO deleted_at");
2203
+ db.prepare("UPDATE review_attention SET deleted_at=? WHERE deleted_at IS NOT NULL").run((/* @__PURE__ */ new Date()).toISOString());
2204
+ }
2205
+ db.exec("COMMIT");
2206
+ } catch (error) {
2207
+ db.exec("ROLLBACK");
2208
+ throw error;
2209
+ }
2159
2210
  }
2160
2211
  //#endregion
2161
2212
  //#region src/review-api/store.ts
@@ -2176,14 +2227,15 @@ const commandSchema = z.strictObject({ operation: z.discriminatedUnion("type", [
2176
2227
  type: z.literal("delete"),
2177
2228
  reviewId
2178
2229
  }),
2230
+ z.strictObject({
2231
+ type: z.literal("lifecycle"),
2232
+ reviewId,
2233
+ state: z.enum(["active", "trash"])
2234
+ }),
2179
2235
  z.strictObject({
2180
2236
  type: z.literal("attention"),
2181
2237
  reviewId,
2182
- action: z.enum([
2183
- "view",
2184
- "dismiss",
2185
- "restore"
2186
- ])
2238
+ action: z.literal("view")
2187
2239
  }),
2188
2240
  z.strictObject({
2189
2241
  type: z.literal("create"),
@@ -2241,6 +2293,7 @@ var ReviewStore = class {
2241
2293
  liveSources = /* @__PURE__ */ new Map();
2242
2294
  refreshTimer;
2243
2295
  refreshSubscribers = 0;
2296
+ purgeTimer;
2244
2297
  watchWorktrees() {
2245
2298
  this.refreshSubscribers++;
2246
2299
  this.refreshTimer ??= setInterval(() => {
@@ -2284,6 +2337,7 @@ var ReviewStore = class {
2284
2337
  if (this.refreshPending) return this.refreshPending;
2285
2338
  const run = this.pending.then(async () => {
2286
2339
  for (const summary of this.list()) {
2340
+ if (summary.trashedAt) continue;
2287
2341
  const snapshot = this.read(summary.reviewId, summary.version);
2288
2342
  try {
2289
2343
  const live = this.liveSources.get(summary.reviewId);
@@ -2324,6 +2378,7 @@ var ReviewStore = class {
2324
2378
  externalChanges;
2325
2379
  observedDataVersion;
2326
2380
  observedVersions = /* @__PURE__ */ new Map();
2381
+ observedDeletions = /* @__PURE__ */ new Map();
2327
2382
  subscribeCatalog(listener) {
2328
2383
  this.catalogListeners.add(listener);
2329
2384
  return () => {
@@ -2346,10 +2401,14 @@ var ReviewStore = class {
2346
2401
  this.providers = providers;
2347
2402
  this.db = new DatabaseSync(databasePath, { timeout: 5e3 });
2348
2403
  initializeReviewStoreSchema(this.db);
2349
- this.activity = new ReviewActivity(this.db, (id) => this.assertExists(id));
2404
+ this.activity = new ReviewActivity(this.db, (id) => this.assertWritable(id));
2350
2405
  this.askHistory = new AskHistory(this.db);
2406
+ this.purgeExpired();
2407
+ this.purgeTimer = setInterval(() => this.purgeExpired(), 6e4);
2408
+ this.purgeTimer.unref();
2351
2409
  this.observedDataVersion = this.dataVersion();
2352
2410
  this.observedVersions = this.currentVersions();
2411
+ this.observedDeletions = this.currentDeletions();
2353
2412
  this.externalChanges = setInterval(() => this.refreshExternalChanges(), 250);
2354
2413
  this.externalChanges.unref();
2355
2414
  }
@@ -2359,17 +2418,29 @@ var ReviewStore = class {
2359
2418
  currentVersions() {
2360
2419
  return new Map(this.db.prepare("SELECT id,version FROM reviews").all().map((row) => [String(row.id), Number(row.version)]));
2361
2420
  }
2421
+ currentDeletions() {
2422
+ return new Map(this.db.prepare("SELECT review_id,deleted_at FROM review_attention WHERE deleted_at IS NOT NULL").all().map((row) => [String(row.review_id), String(row.deleted_at)]));
2423
+ }
2362
2424
  refreshExternalChanges() {
2363
2425
  const version = this.dataVersion();
2364
2426
  if (version === this.observedDataVersion) return;
2365
2427
  this.observedDataVersion = version;
2366
2428
  const current = this.currentVersions();
2367
2429
  const previous = this.observedVersions;
2430
+ const deletions = this.currentDeletions();
2431
+ const previousDeletions = this.observedDeletions;
2432
+ this.observedDeletions = deletions;
2368
2433
  this.observedVersions = current;
2369
2434
  for (const [reviewId, savedVersion] of current) if (previous.get(reviewId) !== savedVersion) this.notify({
2370
2435
  reviewId,
2371
2436
  version: savedVersion
2372
2437
  });
2438
+ else if (previousDeletions.get(reviewId) !== deletions.get(reviewId)) this.notify({
2439
+ reviewId,
2440
+ version: savedVersion,
2441
+ lifecycle: true,
2442
+ attention: true
2443
+ });
2373
2444
  for (const [reviewId, savedVersion] of previous) if (!current.has(reviewId)) {
2374
2445
  this.activity.deleted(reviewId);
2375
2446
  this.notify({
@@ -2392,9 +2463,10 @@ var ReviewStore = class {
2392
2463
  }]));
2393
2464
  }
2394
2465
  updateViewedCoverage(reviewId, files, viewed) {
2395
- this.assertExists(reviewId);
2466
+ this.assertWritable(reviewId);
2396
2467
  this.db.exec("BEGIN IMMEDIATE");
2397
2468
  try {
2469
+ this.assertWritable(reviewId);
2398
2470
  const current = this.viewedCoverage(reviewId);
2399
2471
  for (const file of files) {
2400
2472
  const previous = current.get(file.path);
@@ -2473,6 +2545,7 @@ var ReviewStore = class {
2473
2545
  this.closing = true;
2474
2546
  clearInterval(this.externalChanges);
2475
2547
  clearInterval(this.refreshTimer);
2548
+ clearInterval(this.purgeTimer);
2476
2549
  await this.pending;
2477
2550
  this.listeners.clear();
2478
2551
  this.catalogListeners.clear();
@@ -2498,6 +2571,53 @@ var ReviewStore = class {
2498
2571
  assertExists(id) {
2499
2572
  if (!this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(id)) throw new ReviewInputError("Review not found. If this is an old Whiteboard review, ask your agent to migrate your old Whiteboard reviews.", 404);
2500
2573
  }
2574
+ assertWritable(id) {
2575
+ this.assertExists(id);
2576
+ const trashedAt = this.trashTime(id);
2577
+ if (trashedAt) throw new ReviewInputError(`This whiteboard was moved to Trash on ${new Date(trashedAt).toLocaleDateString("en-US", {
2578
+ month: "short",
2579
+ day: "numeric"
2580
+ })}. Restore it with session_lifecycle({"sessionId":"${id}","state":"active"}) to continue.`, 409);
2581
+ }
2582
+ trashTime(id) {
2583
+ const row = this.db.prepare("SELECT deleted_at FROM review_attention WHERE review_id=?").get(id);
2584
+ return row?.deleted_at ? String(row.deleted_at) : null;
2585
+ }
2586
+ removePermanently(id) {
2587
+ for (const table of [
2588
+ "ask_conversations",
2589
+ "authoring_presences",
2590
+ "review_coverage",
2591
+ "review_attention",
2592
+ "versions"
2593
+ ]) this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(id);
2594
+ this.db.prepare("DELETE FROM reviews WHERE id=?").run(id);
2595
+ this.liveSources.delete(id);
2596
+ }
2597
+ purgeExpired() {
2598
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - 2592e6)).toISOString();
2599
+ const expired = [];
2600
+ this.db.exec("BEGIN IMMEDIATE");
2601
+ try {
2602
+ for (const row of this.db.prepare("SELECT review_id,version FROM review_attention JOIN reviews ON reviews.id=review_id WHERE deleted_at <= ?").all(cutoff)) {
2603
+ const reviewId = String(row.review_id);
2604
+ expired.push({
2605
+ reviewId,
2606
+ version: Number(row.version),
2607
+ deleted: true
2608
+ });
2609
+ this.removePermanently(reviewId);
2610
+ }
2611
+ this.db.exec("COMMIT");
2612
+ } catch (error) {
2613
+ this.db.exec("ROLLBACK");
2614
+ throw error;
2615
+ }
2616
+ for (const result of expired) {
2617
+ this.activity.deleted(result.reviewId);
2618
+ this.notify(result);
2619
+ }
2620
+ }
2501
2621
  read(id, version) {
2502
2622
  const row = version === void 0 ? this.db.prepare("SELECT snapshot FROM versions JOIN reviews ON reviews.id=review_id AND reviews.version=versions.version WHERE reviews.id=?").get(id) : this.db.prepare("SELECT snapshot FROM versions WHERE review_id=? AND version=?").get(id, version);
2503
2623
  if (!row) {
@@ -2516,7 +2636,11 @@ var ReviewStore = class {
2516
2636
  head: snapshot.pins.head
2517
2637
  };
2518
2638
  const live = version === void 0 ? this.liveSources.get(id) : void 0;
2519
- if (live?.version === snapshot.version) return structuredClone(live);
2639
+ if (live?.version === snapshot.version) return {
2640
+ ...structuredClone(live),
2641
+ trashedAt: this.trashTime(id)
2642
+ };
2643
+ snapshot.trashedAt = this.trashTime(id);
2520
2644
  return snapshot;
2521
2645
  }
2522
2646
  setDiffStats(pins, stats, mode = "structural") {
@@ -2540,7 +2664,7 @@ var ReviewStore = class {
2540
2664
  summaries(mode, id) {
2541
2665
  const reviews = this.db.prepare(`SELECT json_remove(versions.snapshot,'$.document') AS summary,
2542
2666
  (SELECT json_extract(first.snapshot,'$.createdAt') FROM versions AS first WHERE first.review_id=reviews.id ORDER BY first.version LIMIT 1) AS first_created_at,
2543
- review_attention.viewed_at, review_attention.dismissed_at, repositories.name AS repository_name, repositories.path AS repository_path
2667
+ review_attention.viewed_at, review_attention.deleted_at, repositories.name AS repository_name, repositories.path AS repository_path
2544
2668
  FROM reviews
2545
2669
  JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
2546
2670
  LEFT JOIN review_attention ON review_attention.review_id=reviews.id
@@ -2564,7 +2688,7 @@ var ReviewStore = class {
2564
2688
  repositoryGroup: row.repository_path ? this.repositoryGroup(String(row.repository_path)) : void 0,
2565
2689
  repositoryName: row.repository_name ? String(row.repository_name) : summary.pins?.repositoryId ?? "",
2566
2690
  viewedAt: row.viewed_at ? String(row.viewed_at) : null,
2567
- dismissedAt: row.dismissed_at ? String(row.dismissed_at) : null,
2691
+ trashedAt: row.deleted_at ? String(row.deleted_at) : null,
2568
2692
  working: this.activity.isWorking(summary.reviewId)
2569
2693
  };
2570
2694
  if (summary.kind === "scratchpad") listed.contents = this.scratchpadContents();
@@ -2621,6 +2745,8 @@ var ReviewStore = class {
2621
2745
  pullRequest?.catch(() => {});
2622
2746
  const run = this.pending.then(async () => {
2623
2747
  const op = command.operation;
2748
+ this.purgeExpired();
2749
+ if (op.type !== "create" && op.type !== "delete" && op.type !== "lifecycle") this.assertWritable(op.reviewId);
2624
2750
  if (op.type !== "create" && op.type !== "edit" && op.type !== "lens_edit" && op.type !== "restore" && this.read(op.reviewId).kind === "scratchpad") throw new ReviewInputError("The scratchpad has no lifecycle, title or pins of its own.", 409);
2625
2751
  if (op.type === "create" && op.kind === "scratchpad") {
2626
2752
  if (op.target) throw new ReviewInputError("A scratchpad has no target of its own.");
@@ -2639,39 +2765,37 @@ var ReviewStore = class {
2639
2765
  if (found) return this.existingReview(found, others, resolvedTarget.pins);
2640
2766
  }
2641
2767
  if (op.type === "delete") {
2768
+ this.assertExists(op.reviewId);
2769
+ if (!this.trashTime(op.reviewId)) throw new ReviewInputError("Move this whiteboard to Trash before deleting it.", 409);
2642
2770
  const result = {
2643
2771
  reviewId: op.reviewId,
2644
2772
  version: this.read(op.reviewId).version,
2645
2773
  deleted: true
2646
2774
  };
2647
- this.commitCommand(result, () => {
2648
- for (const table of [
2649
- "ask_conversations",
2650
- "authoring_presences",
2651
- "review_coverage",
2652
- "review_attention",
2653
- "versions"
2654
- ]) this.db.prepare(`DELETE FROM ${table} WHERE review_id=?`).run(op.reviewId);
2655
- this.db.prepare("DELETE FROM reviews WHERE id=?").run(op.reviewId);
2656
- }, () => this.assertMutation(op.reviewId, result.version));
2775
+ this.commitCommand(result, () => this.removePermanently(op.reviewId), () => this.assertMutation(op.reviewId, result.version));
2657
2776
  return result;
2658
2777
  }
2659
- if (op.type === "attention") {
2778
+ if (op.type === "attention" || op.type === "lifecycle") {
2660
2779
  const result = {
2661
2780
  reviewId: op.reviewId,
2662
2781
  version: this.read(op.reviewId).version,
2663
- attention: true
2782
+ attention: true,
2783
+ ...op.type === "lifecycle" && { lifecycle: true }
2664
2784
  };
2665
2785
  this.commitCommand(result, () => {
2666
2786
  this.db.prepare("INSERT OR IGNORE INTO review_attention(review_id) VALUES(?)").run(op.reviewId);
2667
- if (op.action === "view") this.db.prepare("UPDATE review_attention SET viewed_at=? WHERE review_id=?").run((/* @__PURE__ */ new Date()).toISOString(), op.reviewId);
2668
- else this.db.prepare("UPDATE review_attention SET dismissed_at=? WHERE review_id=?").run(op.action === "dismiss" ? (/* @__PURE__ */ new Date()).toISOString() : null, op.reviewId);
2787
+ if (op.type === "attention") this.db.prepare("UPDATE review_attention SET viewed_at=? WHERE review_id=?").run((/* @__PURE__ */ new Date()).toISOString(), op.reviewId);
2788
+ else {
2789
+ const timestamp = op.state === "trash" ? (/* @__PURE__ */ new Date()).toISOString() : null;
2790
+ this.db.prepare("UPDATE review_attention SET deleted_at=CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(deleted_at, ?) END WHERE review_id=?").run(timestamp, timestamp, op.reviewId);
2791
+ }
2669
2792
  });
2670
2793
  return result;
2671
2794
  }
2672
2795
  const id = op.type !== "create" ? op.reviewId : op.kind === "scratchpad" ? SCRATCHPAD_ID : randomUUID();
2673
2796
  const previous = op.type === "create" ? void 0 : this.read(id);
2674
2797
  let snapshot = op.type === "create" ? createdSnapshot(id, op, resolvedTarget, fromPullRequest?.title) : structuredClone(previous);
2798
+ delete snapshot.trashedAt;
2675
2799
  delete snapshot.sourceUnavailable;
2676
2800
  delete snapshot.lastEdit;
2677
2801
  let nextId = previous ? Number(this.db.prepare("SELECT next_id FROM reviews WHERE id=?").get(id).next_id) : 0;
@@ -2760,7 +2884,10 @@ var ReviewStore = class {
2760
2884
  this.commitCommand(result, () => {
2761
2885
  this.db.prepare("INSERT INTO reviews(id,version,next_id) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET version=excluded.version,next_id=excluded.next_id").run(id, snapshot.version, nextId);
2762
2886
  this.db.prepare("INSERT INTO versions(review_id,version,snapshot) VALUES(?,?,?)").run(id, snapshot.version, JSON.stringify(snapshot));
2763
- }, previous ? () => this.assertMutation(id, previous.version) : void 0, op.type === "edit" || op.type === "lens_edit" ? {
2887
+ }, previous ? () => {
2888
+ this.assertWritable(id);
2889
+ this.assertMutation(id, previous.version);
2890
+ } : void 0, op.type === "edit" || op.type === "lens_edit" ? {
2764
2891
  surface: op.type === "edit" ? "document" : "lenses",
2765
2892
  activityId: op.activityId,
2766
2893
  owned: (owner) => {
@@ -2778,6 +2905,11 @@ var ReviewStore = class {
2778
2905
  if (op.type !== "create" || op.kind || op.target || !op.pullRequestUrl) return void 0;
2779
2906
  if (!this.providers.resolvePullRequest) return Promise.reject(new ReviewInputError("Pull request targets are unavailable."));
2780
2907
  const [existing] = op.reuseExisting === false ? [] : this.reviewsForPullRequest(op.pullRequestUrl);
2908
+ if (existing) try {
2909
+ this.assertReusableReview(existing);
2910
+ } catch (error) {
2911
+ return Promise.reject(error);
2912
+ }
2781
2913
  return this.providers.resolvePullRequest(op.pullRequestUrl, {
2782
2914
  id: op.repositoryId,
2783
2915
  preferred: existing && this.read(existing).pins?.repositoryId
@@ -2791,10 +2923,14 @@ var ReviewStore = class {
2791
2923
  AND COALESCE(json_extract(versions.snapshot,'$.origin.tutorial'), 0) = 0
2792
2924
  ORDER BY json_extract(versions.snapshot,'$.createdAt') DESC, reviews.rowid DESC`).all(pullRequestKey(url)).map((row) => String(row.id));
2793
2925
  }
2926
+ assertReusableReview(reviewId) {
2927
+ if (this.trashTime(reviewId)) throw new ReviewInputError(`This PR's whiteboard is in Trash. Restore it to continue:\n\nsession_lifecycle({"sessionId":"${reviewId}","state":"active"})`, 409);
2928
+ }
2794
2929
  /** The answer to a create that found its PR's review. Its target stays:
2795
2930
  * moving it would silently point existing links at different code. */
2796
2931
  existingReview(reviewId, others, requested) {
2797
2932
  const snapshot = this.read(reviewId);
2933
+ this.assertReusableReview(reviewId);
2798
2934
  const headMoved = snapshot.pins?.repositoryId !== requested.repositoryId || snapshot.pins?.head !== requested.head;
2799
2935
  const working = this.activity.read(reviewId).activities ?? [];
2800
2936
  return {
@@ -2840,16 +2976,16 @@ var ReviewStore = class {
2840
2976
  notify(result) {
2841
2977
  if (result.deleted) this.observedVersions.delete(result.reviewId);
2842
2978
  else if (!result.attention) this.observedVersions.set(result.reviewId, result.version);
2843
- if (!result.attention) for (const listener of this.listeners) try {
2979
+ if (!result.attention || result.lifecycle) for (const listener of this.listeners) try {
2844
2980
  listener(result);
2845
2981
  } catch {}
2846
2982
  for (const listener of this.catalogListeners) try {
2847
2983
  listener();
2848
2984
  } catch {}
2849
2985
  }
2850
- /** Dismissed reviews, as Home lists them; only a restore clears it, not a view. */
2851
- dismissedIds() {
2852
- return this.db.prepare("SELECT review_id FROM review_attention WHERE dismissed_at IS NOT NULL").all().map((row) => String(row.review_id));
2986
+ /** Viewing a review in Trash does not restore it. */
2987
+ trashedIds() {
2988
+ return this.db.prepare("SELECT review_id FROM review_attention WHERE deleted_at IS NOT NULL").all().map((row) => String(row.review_id));
2853
2989
  }
2854
2990
  has(reviewId) {
2855
2991
  return this.db.prepare("SELECT 1 FROM reviews WHERE id=?").get(reviewId) !== void 0;
@@ -2986,23 +3122,23 @@ var ReviewWorkspaces = class {
2986
3122
  }
2987
3123
  this.stop = store.subscribeCatalog(() => {
2988
3124
  this.collect();
2989
- this.releaseDismissed();
3125
+ this.releaseTrashed();
2990
3126
  });
2991
3127
  this.collect();
2992
- this.releaseDismissed();
3128
+ this.releaseTrashed();
2993
3129
  }
2994
- dismissed;
3130
+ trashed;
2995
3131
  releasing = /* @__PURE__ */ new Map();
2996
3132
  /**
2997
- * Frees a newly dismissed review's checkouts to save disk. The review itself
3133
+ * Frees a newly trashed review's checkouts to save disk. The review itself
2998
3134
  * stays; reopening it rebuilds them. Checkouts rebuilt by peeking at a
2999
- * dismissed review are kept until the next startup or dismissal, since
3135
+ * trashed review are kept until the next startup or move to Trash, since
3000
3136
  * the Desktop's canvas tabs and source windows may still show them.
3001
3137
  */
3002
- releaseDismissed() {
3003
- const dismissed = new Set(this.store.dismissedIds());
3004
- const released = [...dismissed].filter((id) => !this.dismissed?.has(id));
3005
- this.dismissed = dismissed;
3138
+ releaseTrashed() {
3139
+ const trashed = new Set(this.store.trashedIds());
3140
+ const released = [...trashed].filter((id) => !this.trashed?.has(id));
3141
+ this.trashed = trashed;
3006
3142
  if (!released.length) return;
3007
3143
  const repositories = this.repositoryDirs();
3008
3144
  repositories.catch(() => void 0);
@@ -3026,7 +3162,7 @@ var ReviewWorkspaces = class {
3026
3162
  await Promise.allSettled(requests);
3027
3163
  await this.releaseCheckouts(reviewId, await repositories);
3028
3164
  }).catch((error) => {
3029
- console.error(`Could not free the pinned checkouts of dismissed review ${reviewId}:`, error);
3165
+ console.error(`Could not free the pinned checkouts of trashed review ${reviewId}:`, error);
3030
3166
  }).finally(() => {
3031
3167
  if (this.releasing.get(reviewId) === done) this.releasing.delete(reviewId);
3032
3168
  });
@@ -3034,7 +3170,7 @@ var ReviewWorkspaces = class {
3034
3170
  this.cleanup = done;
3035
3171
  }
3036
3172
  async releaseCheckouts(reviewId, repositories) {
3037
- if (!this.store.dismissedIds().includes(reviewId)) return;
3173
+ if (!this.store.trashedIds().includes(reviewId)) return;
3038
3174
  const environments = this.all().filter((item) => item.reviewId === reviewId);
3039
3175
  const checkouts = repositories.filter((repository) => existsSync(reviewManagedCheckoutRoot(repository, reviewId)));
3040
3176
  if (!environments.length && !checkouts.length || !this.claim(reviewId)) return;
@@ -3350,7 +3486,43 @@ function rootEventMatters(name, tracked) {
3350
3486
  if (file === ".git" || file.startsWith(".git/")) return false;
3351
3487
  return !tracked || tracked.has(parent(file));
3352
3488
  }
3489
+ /** A directory appearing at the edge of the tracked tree may be newly ignored. */
3490
+ function atTrackedEdge(name, tracked) {
3491
+ const file = normalize(name);
3492
+ return !!tracked && tracked.has(parent(file)) && !tracked.has(file);
3493
+ }
3494
+ /** Wholly ignored directories, so watchers can skip them. */
3495
+ async function ignoredDirectories(rootPath, gitDirectory) {
3496
+ try {
3497
+ const { stdout } = await exec$1("git", [
3498
+ "-C",
3499
+ rootPath,
3500
+ ...gitDirectory ? [
3501
+ "--git-dir",
3502
+ gitDirectory,
3503
+ "--work-tree",
3504
+ rootPath
3505
+ ] : [],
3506
+ "ls-files",
3507
+ "-z",
3508
+ "--others",
3509
+ "--ignored",
3510
+ "--exclude-standard",
3511
+ "--directory"
3512
+ ], { maxBuffer: 33554432 });
3513
+ return stdout.split("\0").filter((entry) => entry.endsWith("/") && entry !== ".jj/").map((entry) => entry.slice(0, -1));
3514
+ } catch {
3515
+ return [];
3516
+ }
3517
+ }
3353
3518
  const CHECKOUT_STATE = /^(?:index|[A-Z_]+|(?:rebase-merge|rebase-apply|sequencer|refs\/(?:bisect|worktree|rewritten))(?:\/.*)?)$/;
3519
+ /** Objects, reflogs, LFS objects and Review's own checkouts; never watched. */
3520
+ const INERT_GIT_PATHS = [
3521
+ "objects",
3522
+ "logs",
3523
+ "lfs",
3524
+ "dev-fast"
3525
+ ];
3354
3526
  /** Objects, reflogs, locks and other checkouts' state cannot change this one. */
3355
3527
  function gitEventMatters(name, prefix) {
3356
3528
  const file = normalize(name);
@@ -3762,7 +3934,7 @@ var LocalReviewData = class {
3762
3934
  worktrees = /* @__PURE__ */ new Map();
3763
3935
  forgetWorktree(repositoryId) {
3764
3936
  const entry = this.worktrees.get(repositoryId);
3765
- if (entry) for (const watcher of entry.watchers) watcher.close();
3937
+ if (entry) unwatch(entry);
3766
3938
  this.worktrees.delete(repositoryId);
3767
3939
  }
3768
3940
  async worktreeState(repositoryId, vcs) {
@@ -3771,33 +3943,59 @@ var LocalReviewData = class {
3771
3943
  entry = {
3772
3944
  epoch: 0,
3773
3945
  inspectedEpoch: -1,
3774
- watchers: [],
3946
+ subscriptions: /* @__PURE__ */ new Map(),
3775
3947
  healthy: true,
3776
3948
  forks: /* @__PURE__ */ new Map()
3777
3949
  };
3778
3950
  this.worktrees.set(repositoryId, entry);
3779
3951
  const state = entry;
3952
+ const current = () => this.worktrees.get(repositoryId) === state;
3780
3953
  const common = await gitCommonDir(vcs.rootPath);
3781
3954
  const prefix = vcs.kind === "git" && common ? await gitDirectoryPrefix(vcs.rootPath, common) : null;
3782
- const roots = /* @__PURE__ */ new Map([[vcs.rootPath, (name) => prefix === null || rootEventMatters(name, state.tracked)]]);
3783
- if (common) roots.set(common, (name) => prefix === null || gitEventMatters(name, prefix));
3784
- for (const [root, matters] of roots) try {
3785
- const watcher = (this.options.watch ?? watch)(root, { recursive: true }, (_event, name) => {
3786
- if (name === null || matters(String(name))) state.epoch++;
3787
- });
3788
- watcher.on("error", () => {
3789
- state.epoch++;
3790
- state.inspectedEpoch = -1;
3955
+ const watch = async (root, ignore, matters) => {
3956
+ try {
3957
+ const subscription = await (this.options.subscribe ?? (await import("@parcel/watcher")).subscribe)(root, (error, events) => {
3958
+ if (error) {
3959
+ state.healthy = false;
3960
+ const failed = state.subscriptions.get(root);
3961
+ if (failed) release(failed);
3962
+ state.subscriptions.delete(root);
3963
+ } else if (events.map((event) => matters(path.relative(root, event.path), event)).includes(true)) state.epoch++;
3964
+ }, { ignore });
3965
+ if (!current()) return void release(subscription);
3966
+ const previous = state.subscriptions.get(root);
3967
+ state.subscriptions.set(root, subscription);
3968
+ if (previous) release(previous);
3969
+ } catch {
3791
3970
  state.healthy = false;
3792
- watcher.close();
3793
- });
3794
- watcher.unref();
3795
- state.watchers.push(watcher);
3796
- } catch {
3797
- state.healthy = false;
3798
- }
3971
+ }
3972
+ };
3973
+ const rootIgnore = async () => [...common ? [".git"] : [], ...await ignoredDirectories(vcs.rootPath, vcs.kind === "jj" ? common ?? void 0 : void 0)].map((directory) => path.join(vcs.rootPath, directory));
3974
+ let ignored = await rootIgnore();
3975
+ const refreshIgnored = () => {
3976
+ clearTimeout(state.ignoring);
3977
+ state.ignoring = setTimeout(async () => {
3978
+ const next = await rootIgnore();
3979
+ if (!current() || next.join("\0") === ignored.join("\0")) return;
3980
+ ignored = next;
3981
+ const previous = state.subscriptions.get(vcs.rootPath);
3982
+ state.subscriptions.delete(vcs.rootPath);
3983
+ if (previous) await release(previous);
3984
+ await watch(vcs.rootPath, ignored, rootMatters);
3985
+ state.epoch++;
3986
+ }, 1e3);
3987
+ state.ignoring.unref();
3988
+ };
3989
+ const rootMatters = (name, event) => {
3990
+ if (path.basename(name) === ".gitignore" || event.type === "create" && atTrackedEdge(name, state.tracked) && statSync(event.path, { throwIfNoEntry: false })?.isDirectory()) refreshIgnored();
3991
+ return prefix === null || rootEventMatters(name, state.tracked);
3992
+ };
3993
+ await Promise.all([watch(vcs.rootPath, ignored, rootMatters), common && watch(common, INERT_GIT_PATHS.map((directory) => path.join(common, directory)), (name) => {
3994
+ if (name === path.join("info", "exclude")) refreshIgnored();
3995
+ return prefix === null || gitEventMatters(name, prefix);
3996
+ })]);
3799
3997
  }
3800
- if (entry.inspection && entry.inspectedEpoch === entry.epoch && entry.healthy && entry.watchers.length) return {
3998
+ if (entry.inspection && entry.inspectedEpoch === entry.epoch && entry.healthy && entry.subscriptions.size) return {
3801
3999
  ...entry.inspection,
3802
4000
  forks: entry.forks
3803
4001
  };
@@ -3820,7 +4018,7 @@ var LocalReviewData = class {
3820
4018
  this.structuralComparisons.close();
3821
4019
  this.closed = true;
3822
4020
  await this.workspaceManager?.close();
3823
- for (const entry of this.worktrees.values()) for (const watcher of entry.watchers) watcher.close();
4021
+ await Promise.all([...this.worktrees.values()].map(unwatch));
3824
4022
  this.worktrees.clear();
3825
4023
  const readers = [...this.readers.values()];
3826
4024
  this.readers.clear();
@@ -4445,6 +4643,14 @@ function inputError(run) {
4445
4643
  throw error;
4446
4644
  }
4447
4645
  }
4646
+ async function unwatch(entry) {
4647
+ clearTimeout(entry.ignoring);
4648
+ const subscriptions = [...entry.subscriptions.values()];
4649
+ entry.subscriptions.clear();
4650
+ await Promise.all(subscriptions.map(release));
4651
+ }
4652
+ /** Linux fails to unwatch a directory that is already gone. */
4653
+ const release = (subscription) => subscription.unsubscribe().catch(() => {});
4448
4654
  function openLocalReviewStore(databasePath, options = {}) {
4449
4655
  const store = new ReviewStore(databasePath, {
4450
4656
  projectSource: (snapshot, pins) => data.projectSource(snapshot, pins),