@dev.fast/whiteboard 0.2.5-preview.20261013.113 → 0.2.5-preview.20261015.115

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 (127) hide show
  1. package/THIRD_PARTY_NOTICES.md +29 -0
  2. package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
  3. package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
  4. package/dist/agent-cli-CRPPmS3l.js +2 -0
  5. package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
  6. package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
  7. package/dist/background-server-Cw7CcfaZ.js +156 -0
  8. package/dist/background-server-D2Rrr_e2.js +2 -0
  9. package/dist/build-info.json +1 -1
  10. package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
  11. package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
  12. package/dist/cli.js +3 -2
  13. package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
  14. package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
  15. package/dist/headless-host-Cp37C-v0.js +2 -0
  16. package/dist/input-error-OLgB_h31.js +11 -0
  17. package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
  18. package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
  19. package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
  20. package/dist/profile-BSOkA6A-.js +2 -0
  21. package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
  22. package/dist/remote-attach-BAt2eZx3.js +137 -0
  23. package/dist/remote-extensions-2Cxf3hMo.js +2 -0
  24. package/dist/remote-extensions-BiNx6NQK.js +323 -0
  25. package/dist/remote-language-server-DQgWUNeV.js +252 -0
  26. package/dist/remote-uninstall-DLtDG0o7.js +153 -0
  27. package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
  28. package/dist/review-app-launcher-D7I4DGoH.js +222 -0
  29. package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
  30. package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
  31. package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
  32. package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
  33. package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
  34. package/dist/server/desktop-host.js +1663 -266
  35. package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
  36. package/dist/sharing/index.d.ts +88 -32
  37. package/dist/sharing/index.js +1 -1
  38. package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
  39. package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
  40. package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
  41. package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
  42. package/instructions/authoring.md +16 -12
  43. package/instructions/file-lenses.md +4 -2
  44. package/package.json +9 -14
  45. package/src/agent-connect.ts +507 -0
  46. package/src/agent-selection.ts +4 -0
  47. package/src/ask/agents.ts +59 -13
  48. package/src/ask/pi-mcp.ts +7 -3
  49. package/src/ask/thread-state.ts +9 -4
  50. package/src/ask/thread.ts +88 -50
  51. package/src/ask/threads.ts +53 -7
  52. package/src/cli-install.ts +3 -1
  53. package/src/cli-runner.ts +472 -81
  54. package/src/cli.ts +1 -1
  55. package/src/connect-agents.ts +285 -0
  56. package/src/connect-prompts.ts +60 -6
  57. package/src/desktop-discovery.ts +24 -0
  58. package/src/pinned-download.ts +118 -0
  59. package/src/remote-attach.ts +130 -0
  60. package/src/remote-extensions.ts +359 -0
  61. package/src/remote-language-server.ts +428 -0
  62. package/src/remote-toolchains.ts +107 -0
  63. package/src/remote-uninstall.ts +280 -0
  64. package/src/review-api/README.md +1 -3
  65. package/src/review-api/agent-cli.ts +4 -4
  66. package/src/review-api/agent-client.ts +23 -0
  67. package/src/review-api/ask-history.ts +31 -16
  68. package/src/review-api/authoring-tools.ts +1 -1
  69. package/src/review-api/blocks/index.ts +2 -4
  70. package/src/review-api/document.ts +1 -4
  71. package/src/review-api/http.ts +265 -237
  72. package/src/review-api/local-data.ts +18 -3
  73. package/src/review-api/onboarding.ts +478 -0
  74. package/src/review-api/origin.ts +7 -0
  75. package/src/review-api/recent-folders.ts +173 -0
  76. package/src/review-api/store.ts +56 -10
  77. package/src/review-api/workspaces.ts +56 -11
  78. package/src/review-app-launcher.ts +61 -3
  79. package/src/review-stack.ts +162 -36
  80. package/src/server/background-server.ts +250 -0
  81. package/src/server/desktop-host-shutdown.ts +24 -0
  82. package/src/server/desktop-host.ts +39 -24
  83. package/src/server/desktop-server.ts +68 -118
  84. package/src/server/global-verb-relay.ts +16 -0
  85. package/src/server/headless-host.ts +128 -3
  86. package/src/server/hono-http.ts +6 -1
  87. package/src/server/legacy-tutorial-cleanup.ts +38 -0
  88. package/src/server/review-gateway-hosts.ts +379 -0
  89. package/src/server/review-gateway-list.ts +109 -0
  90. package/src/server/review-gateway-memory.ts +130 -0
  91. package/src/server/review-gateway-pushes.ts +142 -0
  92. package/src/server/review-gateway-streams.ts +415 -0
  93. package/src/server/review-gateway-transport.ts +204 -0
  94. package/src/server/review-gateway.ts +433 -0
  95. package/src/server/review-server-core.ts +36 -6
  96. package/src/server-discovery.ts +11 -0
  97. package/src/sharing/import.ts +1 -0
  98. package/src/ui-telemetry-events.ts +5 -1
  99. package/src/vsix.ts +122 -0
  100. package/dist/agent-cli-BR_Jw5BL.js +0 -2
  101. package/dist/headless-host-BLLJ7Dyu.js +0 -95
  102. package/dist/profile-aMSw1oCM.js +0 -2
  103. package/dist/server-discovery-ZVi8Ay8J.js +0 -66
  104. package/src/fixtures/blocks/tutorial.json +0 -14
  105. package/src/review-api/blocks/tutorial.ts +0 -23
  106. package/src/server/tutorial-service.ts +0 -217
  107. package/tutorial/document.json +0 -216
  108. package/tutorial/lenses.json +0 -10
  109. package/tutorial/runtime-manifest.json +0 -11
  110. package/tutorial/sample-service/package.json +0 -9
  111. package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
  112. package/tutorial/sample-service/src/app.ts +0 -26
  113. package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
  114. package/tutorial/sample-service/src/database/schema.ts +0 -9
  115. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
  116. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
  117. package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
  118. package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
  119. package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
  120. package/tutorial/sample-service/src/orders/order-service.ts +0 -35
  121. package/tutorial/sample-service/src/orders/order.ts +0 -43
  122. package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
  123. package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
  124. package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
  125. package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
  126. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
  127. package/tutorial/sample-service/tsconfig.json +0 -12
@@ -0,0 +1,173 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { z } from "zod";
7
+
8
+ export interface RecentFoldersEnvironment {
9
+ homeDir?: string;
10
+ env?: NodeJS.ProcessEnv;
11
+ platform?: NodeJS.Platform;
12
+ }
13
+
14
+ /** VS Code forks whose recently opened folders count as projects. */
15
+ const EDITORS = ["Cursor", "Code"];
16
+
17
+ async function readText(file: string): Promise<string | undefined> {
18
+ try {
19
+ return await readFile(file, "utf8");
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+
25
+ /** A file's JSON as `schema` reads it, or undefined when it is missing,
26
+ * malformed, or shaped otherwise: another app's file, read on its terms. */
27
+ async function readJson<Schema extends z.ZodType>(
28
+ file: string,
29
+ schema: Schema,
30
+ ): Promise<z.infer<Schema> | undefined> {
31
+ const text = await readText(file);
32
+
33
+ try {
34
+ return text === undefined
35
+ ? undefined
36
+ : schema.safeParse(JSON.parse(text)).data;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ const claudeConfigSchema = z.object({
43
+ projects: z.record(z.string(), z.object({}).loose()),
44
+ });
45
+
46
+ /** Claude Code keeps per-folder settings for every folder it ran in. */
47
+ async function claudeFolders(homeDir: string, env: NodeJS.ProcessEnv) {
48
+ const config = await readJson(
49
+ path.join(env.CLAUDE_CONFIG_DIR ?? homeDir, ".claude.json"),
50
+ claudeConfigSchema,
51
+ );
52
+
53
+ return Object.keys(config?.projects ?? {});
54
+ }
55
+
56
+ /** Codex records each folder it was trusted in as a `[projects."…"]` table. */
57
+ export function codexConfigFolders(toml: string): string[] {
58
+ const folders: string[] = [];
59
+
60
+ for (const match of toml.matchAll(
61
+ /^\[projects\.(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\]/gmu,
62
+ )) {
63
+ if (match[2] !== undefined) folders.push(match[2]);
64
+ else if (match[1] !== undefined)
65
+ try {
66
+ // A TOML basic string escapes as JSON does for paths.
67
+ folders.push(JSON.parse(`"${match[1]}"`));
68
+ } catch {
69
+ // An escape JSON lacks: not a path worth guessing at.
70
+ }
71
+ }
72
+
73
+ return folders;
74
+ }
75
+
76
+ async function codexFolders(homeDir: string, env: NodeJS.ProcessEnv) {
77
+ const toml = await readText(
78
+ path.join(env.CODEX_HOME ?? path.join(homeDir, ".codex"), "config.toml"),
79
+ );
80
+
81
+ return toml === undefined ? [] : codexConfigFolders(toml);
82
+ }
83
+
84
+ function editorDataDir(
85
+ app: string,
86
+ homeDir: string,
87
+ env: NodeJS.ProcessEnv,
88
+ platform: NodeJS.Platform,
89
+ ) {
90
+ if (platform === "darwin")
91
+ return path.join(homeDir, "Library", "Application Support", app);
92
+
93
+ if (platform === "win32")
94
+ return path.join(
95
+ env.APPDATA ?? path.join(homeDir, "AppData", "Roaming"),
96
+ app,
97
+ );
98
+
99
+ return path.join(env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config"), app);
100
+ }
101
+
102
+ /** The parts of an editor's storage.json that name folders; either can be
103
+ * missing or reshaped by another version without losing the other. */
104
+ const editorStorageSchema = z.object({
105
+ profileAssociations: z
106
+ .object({ workspaces: z.record(z.string(), z.unknown()) })
107
+ .optional()
108
+ .catch(undefined),
109
+ backupWorkspaces: z
110
+ .object({ folders: z.array(z.object({ folderUri: z.string() })) })
111
+ .optional()
112
+ .catch(undefined),
113
+ });
114
+
115
+ /** The folders an editor opened: those with a profile, and those with
116
+ * unsaved work. Both live in its storage.json, unlike its sqlite history. */
117
+ function editorStorageFolders(
118
+ storage: z.infer<typeof editorStorageSchema>,
119
+ ): string[] {
120
+ const uris = [
121
+ ...Object.keys(storage.profileAssociations?.workspaces ?? {}),
122
+ ...(storage.backupWorkspaces?.folders ?? []).map(
123
+ (folder) => folder.folderUri,
124
+ ),
125
+ ];
126
+
127
+ return uris.flatMap((uri) => {
128
+ if (!uri.startsWith("file://")) return [];
129
+
130
+ try {
131
+ return [fileURLToPath(uri)];
132
+ } catch {
133
+ return [];
134
+ }
135
+ });
136
+ }
137
+
138
+ async function editorFolders(
139
+ app: string,
140
+ homeDir: string,
141
+ env: NodeJS.ProcessEnv,
142
+ platform: NodeJS.Platform,
143
+ ) {
144
+ const storage = await readJson(
145
+ path.join(
146
+ editorDataDir(app, homeDir, env, platform),
147
+ "User",
148
+ "globalStorage",
149
+ "storage.json",
150
+ ),
151
+ editorStorageSchema,
152
+ );
153
+
154
+ return storage ? editorStorageFolders(storage) : [];
155
+ }
156
+
157
+ /** Folders the user's agents and editors have worked in, unverified: some
158
+ * are gone, and many are not repositories or are inside one. */
159
+ export async function recentFolders(
160
+ input: RecentFoldersEnvironment = {},
161
+ ): Promise<string[]> {
162
+ const homeDir = input.homeDir ?? os.homedir();
163
+ const env = input.env ?? process.env;
164
+ const platform = input.platform ?? process.platform;
165
+
166
+ const lists = await Promise.all([
167
+ claudeFolders(homeDir, env),
168
+ codexFolders(homeDir, env),
169
+ ...EDITORS.map((app) => editorFolders(app, homeDir, env, platform)),
170
+ ]);
171
+
172
+ return [...new Set(lists.flat().filter((folder) => path.isAbsolute(folder)))];
173
+ }
@@ -150,12 +150,14 @@ export const commandSchema = z.strictObject({
150
150
 
151
151
  /** Source identity displayed in the review header and Home, alongside immutable pins. */
152
152
  export interface SnapshotOrigin {
153
- /** Managed tutorial; readable by ID but excluded from the user catalog. */
154
- tutorial?: boolean;
155
153
  branch?: string;
156
154
  baseRef?: string;
155
+ /** The base the review's target names, such as the branch below it. */
156
+ baseBranch?: string;
157
157
  pullRequestNumber?: number;
158
158
  pullRequestUrl?: string;
159
+ /** As the forge reported it when the PR was attached. */
160
+ pullRequestTitle?: string;
159
161
  /** The legacy review revision this version was imported from. */
160
162
  revision?: string;
161
163
  }
@@ -216,6 +218,7 @@ export interface Result {
216
218
  export interface ResolvedPullRequest {
217
219
  target: ReviewTarget;
218
220
  pins: Pins;
221
+ /** Empty when the PR has none. */
219
222
  title: string;
220
223
  }
221
224
 
@@ -228,6 +231,8 @@ export interface ReviewProviders {
228
231
  url: string,
229
232
  repository: { id?: string; preferred?: string },
230
233
  ): Promise<ResolvedPullRequest>;
234
+ /** Undefined when the forge can't be read. */
235
+ pullRequestTitle?(url: string): Promise<string | undefined>;
231
236
  resolveTarget?(
232
237
  target: ReviewTarget,
233
238
  pinned?: Pins,
@@ -844,8 +849,8 @@ export class ReviewStore {
844
849
  }
845
850
  }
846
851
 
847
- /** Managed records are discoverable even if their preparation stamp was lost. */
848
- tutorialIds(): string[] {
852
+ /** Reviews of the tutorial earlier versions shipped, for their removal. */
853
+ legacyTutorialIds(): string[] {
849
854
  return this.db
850
855
  .prepare(
851
856
  `SELECT reviews.id FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version WHERE json_extract(versions.snapshot,'$.origin.tutorial') = 1`,
@@ -877,8 +882,7 @@ export class ReviewStore {
877
882
  JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
878
883
  LEFT JOIN review_attention ON review_attention.review_id=reviews.id
879
884
  LEFT JOIN repositories ON repositories.id=json_extract(versions.snapshot,'$.pins.repositoryId')
880
- WHERE COALESCE(json_extract(versions.snapshot,'$.origin.tutorial'), 0) = 0
881
- ${id === undefined ? "" : "AND reviews.id=?"}
885
+ ${id === undefined ? "" : "WHERE reviews.id=?"}
882
886
  ORDER BY reviews.rowid`,
883
887
  )
884
888
  .all(...(id === undefined ? [] : [id]))
@@ -1004,7 +1008,7 @@ export class ReviewStore {
1004
1008
 
1005
1009
  return inspectSnapshot(snapshot, targetId);
1006
1010
  }
1007
- /** The host can seed a managed document; transport callers only supply a command. */
1011
+ /** The host can seed a document; transport callers only supply a command. */
1008
1012
  execute(
1009
1013
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Command boundary: commandSchema.parse below rejects malformed input before mutation.
1010
1014
  input: unknown,
@@ -1016,11 +1020,19 @@ export class ReviewStore {
1016
1020
 
1017
1021
  if (initial && command.operation.type !== "create")
1018
1022
  throw new ReviewInputError("Initial content requires a create command.");
1023
+
1019
1024
  // Network and fetch time stay out of the write queue.
1020
1025
  const pullRequest = this.startPullRequest(command);
1021
1026
 
1022
1027
  pullRequest?.catch(() => {});
1023
1028
 
1029
+ const pullRequestTitle = pullRequest
1030
+ ? pullRequest.then(
1031
+ ({ title }) => title || undefined,
1032
+ () => undefined,
1033
+ )
1034
+ : this.startPullRequestTitle(command);
1035
+
1024
1036
  const run = this.pending.then(async () => {
1025
1037
  const op = command.operation;
1026
1038
  this.purgeExpired();
@@ -1172,7 +1184,14 @@ export class ReviewStore {
1172
1184
 
1173
1185
  let snapshot: Snapshot =
1174
1186
  op.type === "create"
1175
- ? createdSnapshot(id, op, resolvedTarget, fromPullRequest?.title)
1187
+ ? createdSnapshot(
1188
+ id,
1189
+ op,
1190
+ resolvedTarget,
1191
+ fromPullRequest &&
1192
+ (fromPullRequest.title ||
1193
+ `PR #${op.pullRequestUrl!.split("/").at(-1)}`),
1194
+ )
1176
1195
  : structuredClone(previous!);
1177
1196
 
1178
1197
  delete snapshot.trashedAt;
@@ -1210,6 +1229,17 @@ export class ReviewStore {
1210
1229
  }
1211
1230
  }
1212
1231
 
1232
+ // A base named rather than given as a commit; a stacked review names
1233
+ // the branch below it.
1234
+ if (requestedTarget)
1235
+ snapshot.origin = {
1236
+ ...snapshot.origin,
1237
+ baseBranch:
1238
+ requestedTarget.base === resolvedTarget?.pins.base
1239
+ ? undefined
1240
+ : requestedTarget.base,
1241
+ };
1242
+
1213
1243
  switch (op.type) {
1214
1244
  case "create":
1215
1245
  if (initial) {
@@ -1223,7 +1253,7 @@ export class ReviewStore {
1223
1253
  assignFreshIds(block, (prefix) => `${prefix}-${++nextId}`);
1224
1254
  }
1225
1255
 
1226
- setPullRequest(snapshot, op.pullRequestUrl);
1256
+ setPullRequest(snapshot, op.pullRequestUrl, await pullRequestTitle);
1227
1257
  break;
1228
1258
  case "rename":
1229
1259
  snapshot.title = op.title;
@@ -1236,6 +1266,7 @@ export class ReviewStore {
1236
1266
  snapshot.pins?.repositoryId !== resolvedTarget!.pins.repositoryId
1237
1267
  ? null
1238
1268
  : undefined),
1269
+ await pullRequestTitle,
1239
1270
  );
1240
1271
  snapshot.staleSources = [];
1241
1272
  snapshot.target = resolvedTarget!.target;
@@ -1425,6 +1456,22 @@ export class ReviewStore {
1425
1456
  preferred: existing && this.read(existing).pins?.repositoryId,
1426
1457
  });
1427
1458
  }
1459
+ private startPullRequestTitle(
1460
+ command: z.infer<typeof commandSchema>,
1461
+ ): Promise<string | undefined> {
1462
+ const op = command.operation;
1463
+
1464
+ if (
1465
+ (op.type !== "create" && op.type !== "set_target") ||
1466
+ !op.pullRequestUrl
1467
+ )
1468
+ return Promise.resolve(undefined);
1469
+
1470
+ return (
1471
+ this.providers.pullRequestTitle?.(op.pullRequestUrl) ??
1472
+ Promise.resolve(undefined)
1473
+ );
1474
+ }
1428
1475
  /** Reviews whose PR is this one, newest version first. Summaries only. */
1429
1476
  private reviewsForPullRequest(url: string): string[] {
1430
1477
  return this.db
@@ -1432,7 +1479,6 @@ export class ReviewStore {
1432
1479
  `SELECT reviews.id FROM reviews
1433
1480
  JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
1434
1481
  WHERE lower(json_extract(versions.snapshot,'$.origin.pullRequestUrl'))=?
1435
- AND COALESCE(json_extract(versions.snapshot,'$.origin.tutorial'), 0) = 0
1436
1482
  ORDER BY json_extract(versions.snapshot,'$.createdAt') DESC, reviews.rowid DESC`,
1437
1483
  )
1438
1484
  .all(pullRequestKey(url))
@@ -48,9 +48,8 @@ interface Environment extends WorkspaceStatus {
48
48
  role: "base" | "head";
49
49
  }
50
50
 
51
- // A lease lasts until the owning Desktop exits; closing the review does not release it.
52
51
  const OWNED_ELSEWHERE =
53
- "Another Desktop owns this review's language workspaces. Quit that Desktop, then retry.";
52
+ "Another Whiteboard server is managing this review's language workspaces. Retry once it stops.";
54
53
 
55
54
  /** Local lifecycle only: source and authored history never depend on preparation.
56
55
  * Status/queue/process lifecycle follows #334, retaining the legacy prepare config
@@ -101,10 +100,23 @@ export class ReviewWorkspaces {
101
100
  throw error;
102
101
  }
103
102
 
104
- this.stop = store.subscribeCatalog(() => {
103
+ const stopCatalog = store.subscribeCatalog(() => {
105
104
  this.collect();
106
105
  this.releaseTrashed();
107
106
  });
107
+
108
+ const stopDeleted = store.subscribe(({ reviewId, deleted }) => {
109
+ if (!deleted) return;
110
+ const repositories = this.repositoryDirs();
111
+ repositories.catch(() => undefined);
112
+ this.release(reviewId, repositories, () => !this.hasReview(reviewId));
113
+ });
114
+
115
+ this.stop = () => {
116
+ stopCatalog();
117
+ stopDeleted();
118
+ };
119
+
108
120
  this.collect();
109
121
  this.releaseTrashed();
110
122
  }
@@ -131,7 +143,13 @@ export class ReviewWorkspaces {
131
143
  // Each release awaits this only after earlier cleanup and logs its failure.
132
144
  repositories.catch(() => undefined);
133
145
 
134
- for (const reviewId of released) this.release(reviewId, repositories);
146
+ for (const reviewId of released)
147
+ this.release(reviewId, repositories, () => this.stillTrashed(reviewId));
148
+ }
149
+
150
+ /** Restored since the move to Trash: its checkouts may be in use. */
151
+ private stillTrashed(reviewId: string) {
152
+ return this.store.trashedIds().includes(reviewId);
135
153
  }
136
154
 
137
155
  private async repositoryDirs(): Promise<string[]> {
@@ -151,18 +169,26 @@ export class ReviewWorkspaces {
151
169
  return this.releasing.get(reviewId) ?? Promise.resolve();
152
170
  }
153
171
 
154
- private release(reviewId: string, repositories = this.repositoryDirs()) {
172
+ private release(
173
+ reviewId: string,
174
+ repositories: Promise<string[]>,
175
+ stillReleased: () => boolean,
176
+ ) {
155
177
  // Requests started later wait for this release; earlier ones finish first.
156
178
  const requests = [...this.requests.values()];
157
179
 
158
180
  const done = this.cleanup
159
181
  .then(async () => {
160
182
  await Promise.allSettled(requests);
161
- await this.releaseCheckouts(reviewId, await repositories);
183
+ await this.releaseCheckouts(
184
+ reviewId,
185
+ await repositories,
186
+ stillReleased,
187
+ );
162
188
  })
163
189
  .catch((error) => {
164
190
  console.error(
165
- `Could not free the pinned checkouts of trashed review ${reviewId}:`,
191
+ `Could not free the pinned checkouts of review ${reviewId}:`,
166
192
  error,
167
193
  );
168
194
  })
@@ -175,9 +201,12 @@ export class ReviewWorkspaces {
175
201
  this.cleanup = done;
176
202
  }
177
203
 
178
- private async releaseCheckouts(reviewId: string, repositories: string[]) {
179
- // Restored since the move to Trash: its checkouts may be in use.
180
- if (!this.store.trashedIds().includes(reviewId)) return;
204
+ private async releaseCheckouts(
205
+ reviewId: string,
206
+ repositories: string[],
207
+ stillReleased: () => boolean,
208
+ ) {
209
+ if (!stillReleased()) return;
181
210
 
182
211
  const environments = this.all().filter(
183
212
  (item) => item.reviewId === reviewId,
@@ -365,7 +394,9 @@ export class ReviewWorkspaces {
365
394
 
366
395
  // A trashed review keeps its record, so collection would skip it.
367
396
  if (this.store.has(environment.reviewId))
368
- this.release(environment.reviewId);
397
+ this.release(environment.reviewId, this.repositoryDirs(), () =>
398
+ this.stillTrashed(environment.reviewId),
399
+ );
369
400
  else this.collect(id);
370
401
  await this.cleanup;
371
402
  }
@@ -647,6 +678,20 @@ export class ReviewWorkspaces {
647
678
  if (environment.rootPath)
648
679
  await removeReviewPrepareArtifacts(environment.rootPath);
649
680
 
681
+ if (
682
+ environment.repository &&
683
+ !this.all().some(
684
+ (other) =>
685
+ other.id !== environment.id &&
686
+ other.reviewId === environment.reviewId &&
687
+ other.repository === environment.repository,
688
+ )
689
+ )
690
+ await removeReviewManagedCheckouts(
691
+ environment.repository,
692
+ environment.reviewId,
693
+ );
694
+
650
695
  this.db
651
696
  .prepare("DELETE FROM pinned_environments WHERE id=?")
652
697
  .run(environment.id);
@@ -1,6 +1,13 @@
1
- import { type SpawnOptions, spawn } from "node:child_process";
2
- import { closeSync, fstatSync, mkdtempSync, openSync, readSync } from "node:fs";
3
- import { tmpdir } from "node:os";
1
+ import { type SpawnOptions, execFileSync, spawn } from "node:child_process";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fstatSync,
6
+ mkdtempSync,
7
+ openSync,
8
+ readSync,
9
+ } from "node:fs";
10
+ import { homedir, tmpdir } from "node:os";
4
11
  import path from "node:path";
5
12
 
6
13
  import {
@@ -19,10 +26,12 @@ import {
19
26
  const RELEASE_APPS = {
20
27
  stable: {
21
28
  bundleId: "dev.fast.review",
29
+ macApp: "Whiteboard.app",
22
30
  linuxLauncher: "/usr/bin/review-desktop",
23
31
  },
24
32
  preview: {
25
33
  bundleId: "dev.fast.review.preview",
34
+ macApp: "Whiteboard Preview.app",
26
35
  linuxLauncher: "/usr/bin/review-preview-desktop",
27
36
  },
28
37
  };
@@ -227,6 +236,55 @@ export async function focusReviewDesktop(
227
236
  }
228
237
  }
229
238
 
239
+ export function desktopApplicationInstalled(
240
+ input: Pick<
241
+ LaunchDesktopApplicationInput,
242
+ "platform" | "electron" | "env"
243
+ > & {
244
+ exists?: (path: string) => boolean;
245
+ spotlight?: (bundleId: string) => boolean;
246
+ } = {},
247
+ ): boolean {
248
+ const env = input.env ?? process.env;
249
+ const platform = input.platform ?? process.platform;
250
+ const exists = input.exists ?? existsSync;
251
+ const apps = Object.values(RELEASE_APPS);
252
+
253
+ if (
254
+ (platform !== "linux" && platform !== "darwin") ||
255
+ (input.electron ?? Boolean(process.versions.electron)) ||
256
+ env.DEV_FAST_REVIEW_DESKTOP_COMMAND?.trim()
257
+ )
258
+ return true;
259
+
260
+ if (platform === "linux")
261
+ return apps.some(({ linuxLauncher }) => exists(linuxLauncher));
262
+
263
+ // Spotlight can be off on a headless Mac, so the usual folders come first.
264
+ return (
265
+ apps.some(({ macApp }) =>
266
+ ["/Applications", path.join(homedir(), "Applications")].some((dir) =>
267
+ exists(path.join(dir, macApp)),
268
+ ),
269
+ ) ||
270
+ apps.some(({ bundleId }) => (input.spotlight ?? spotlightFinds)(bundleId))
271
+ );
272
+ }
273
+
274
+ function spotlightFinds(bundleId: string): boolean {
275
+ try {
276
+ return (
277
+ execFileSync(
278
+ "/usr/bin/mdfind",
279
+ [`kMDItemCFBundleIdentifier == '${bundleId}'`],
280
+ { encoding: "utf8", timeout: 2_000 },
281
+ ).trim() !== ""
282
+ );
283
+ } catch {
284
+ return false;
285
+ }
286
+ }
287
+
230
288
  export function launchDesktopApplication(
231
289
  input: LaunchDesktopApplicationInput = {},
232
290
  ): DesktopLaunchAttempt {