51.2 KB1533 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {TypeaheadResult} from 'isl-components/Types';
9import type {Serializable} from 'isl/src/serialize';
10import type {
11 ChangedFile,
12 ClientToServerMessage,
13 CodeReviewProviderSpecificClientToServerMessages,
14 Disposable,
15 FetchedCommits,
16 FetchedUncommittedChanges,
17 FileABugProgress,
18 LandInfo,
19 MergeConflicts,
20 PlatformSpecificClientToServerMessages,
21 RepositoryError,
22 Result,
23 ServerToClientMessage,
24 StableLocationData,
25 SubmodulesByRoot,
26} from 'isl/src/types';
27import type {EjecaError, EjecaReturn} from 'shared/ejeca';
28import {ejeca} from 'shared/ejeca';
29import type {ExportStack, ImportedStack} from 'shared/types/stack';
30import type {ClientConnection} from '.';
31import type {RepositoryReference} from './RepositoryCache';
32import type {ServerSideTracker} from './analytics/serverSideTracker';
33import type {Logger} from './logger';
34import type {ServerPlatform} from './serverPlatform';
35import type {RepositoryContext} from './serverTypes';
36
37import type {InternalTypes} from 'isl/src/InternalTypes';
38import {deserializeFromString, serializeToString} from 'isl/src/serialize';
39import type {PartiallySelectedDiffCommit} from 'isl/src/stackEdit/diffSplitTypes';
40import {Readable} from 'node:stream';
41import path from 'path';
42import {beforeRevsetForComparison} from 'shared/Comparison';
43import {base64Decode, notEmpty, randomId} from 'shared/utils';
44import {generatedFilesDetector} from './GeneratedFiles';
45import {Internal} from './Internal';
46import {Repository, absolutePathForFileInRepo, readGroveConfig} from './Repository';
47import {repositoryCache} from './RepositoryCache';
48import {firstOfIterable, parseExecJson} from './utils';
49
50export type IncomingMessage = ClientToServerMessage;
51export type OutgoingMessage = ServerToClientMessage;
52
53type GeneralMessage = IncomingMessage &
54 (
55 | {type: 'heartbeat'}
56 | {type: 'stress'}
57 | {type: 'changeCwd'}
58 | {type: 'requestRepoInfo'}
59 | {type: 'requestApplicationInfo'}
60 | {type: 'fileBugReport'}
61 | {type: 'track'}
62 | {type: 'clientReady'}
63 | {type: 'fetchGroveOwners'}
64 | {type: 'createGroveRepo'}
65 );
66type WithRepoMessage = Exclude<IncomingMessage, GeneralMessage>;
67
68/**
69 * Message passing channel built on top of ClientConnection.
70 * Use to send and listen for well-typed events with the client
71 *
72 * Note: you must set the current repository to start sending data back to the client.
73 */
74export default class ServerToClientAPI {
75 private listenersByType = new Map<
76 string,
77 Set<(message: IncomingMessage) => void | Promise<void>>
78 >();
79 private incomingListener: Disposable;
80
81 /** Disposables that must be disposed whenever the current repo is changed */
82 private repoDisposables: Array<Disposable> = [];
83 private subscriptions = new Map<string, Disposable>();
84 private activeRepoRef: RepositoryReference | undefined;
85
86 private queuedMessages: Array<IncomingMessage> = [];
87 private currentState:
88 | {type: 'loading'}
89 | {type: 'repo'; repo: Repository; ctx: RepositoryContext}
90 | {type: 'error'; error: RepositoryError} = {type: 'loading'};
91
92 private pageId = randomId();
93
94 constructor(
95 private platform: ServerPlatform,
96 private connection: ClientConnection,
97 private tracker: ServerSideTracker,
98 private logger: Logger,
99 ) {
100 this.incomingListener = this.connection.onDidReceiveMessage(buf => {
101 const message = buf.toString('utf-8');
102 const data = deserializeFromString(message) as IncomingMessage;
103
104 // When the client is connected, we want to immediately start listening to messages.
105 // However, we can't properly respond to these messages until we have a repository set up.
106 // Queue up messages until a repository is set.
107 if (this.currentState.type === 'loading') {
108 this.queuedMessages.push(data);
109 } else {
110 try {
111 this.handleIncomingMessage(data);
112 } catch (err) {
113 connection.logger?.error('error handling incoming message: ', data, err);
114 }
115 }
116 });
117 }
118
119 private setRepoError(error: RepositoryError) {
120 this.disposeRepoDisposables();
121
122 this.currentState = {type: 'error', error};
123
124 this.tracker.context.setRepo(undefined);
125
126 this.processQueuedMessages();
127 }
128
129 private setCurrentRepo(repo: Repository, ctx: RepositoryContext) {
130 this.disposeRepoDisposables();
131
132 this.currentState = {type: 'repo', repo, ctx};
133
134 this.tracker.context.setRepo(repo);
135
136 if (repo.codeReviewProvider != null) {
137 this.repoDisposables.push(
138 repo.codeReviewProvider.onChangeDiffSummaries(value => {
139 this.postMessage({type: 'fetchedDiffSummaries', summaries: value});
140 }),
141 );
142 if (repo.codeReviewProvider.onChangeCanopySignals) {
143 this.repoDisposables.push(
144 repo.codeReviewProvider.onChangeCanopySignals(runs => {
145 this.postMessage({type: 'fetchedCanopySignals', runs});
146 }),
147 );
148 }
149 }
150
151 repo.ref();
152 this.repoDisposables.push({dispose: () => repo.unref()});
153
154 // Send initial watchman status and subscribe to changes
155 this.postMessage({type: 'watchmanStatus', status: repo.watchForChanges.watchman.status});
156 repo.watchForChanges.onWatchmanStatusChange = status => {
157 this.postMessage({type: 'watchmanStatus', status});
158 };
159 this.repoDisposables.push({dispose: () => {
160 repo.watchForChanges.onWatchmanStatusChange = undefined;
161 }});
162
163 repo.fetchAndSetRecommendedBookmarks(async bookmarks => {
164 await this.connection.readySignal?.promise;
165 this.postMessage({type: 'fetchedRecommendedBookmarks', bookmarks});
166 });
167
168 repo.fetchAndSetHiddenMasterConfig(async (config, odType) => {
169 await this.connection.readySignal?.promise;
170 this.postMessage({
171 type: 'fetchedHiddenMasterBranchConfig',
172 config,
173 odType,
174 cwd: ctx.cwd,
175 });
176 });
177
178 this.processQueuedMessages();
179 }
180
181 postMessage(message: OutgoingMessage) {
182 this.connection.postMessage(serializeToString(message));
183 }
184
185 /** Get a repository reference for a given cwd, and set that as the active repo. */
186 setActiveRepoForCwd(newCwd: string) {
187 if (this.activeRepoRef !== undefined) {
188 this.activeRepoRef.unref();
189 }
190 this.logger.info(`Setting active repo cwd to ${newCwd}`);
191 // Set as loading right away while we determine the new cwd's repo
192 // This ensures new messages coming in will be queued and handled only with the new repository
193 this.currentState = {type: 'loading'};
194 const command = this.connection.command ?? 'sl';
195 const ctx: RepositoryContext = {
196 cwd: newCwd,
197 cmd: command,
198 logger: this.logger,
199 tracker: this.tracker,
200 };
201 this.activeRepoRef = repositoryCache.getOrCreate(ctx);
202 this.activeRepoRef.promise.then(repoOrError => {
203 if (repoOrError instanceof Repository) {
204 this.setCurrentRepo(repoOrError, ctx);
205 } else {
206 this.setRepoError(repoOrError);
207 }
208 });
209 }
210
211 dispose() {
212 this.incomingListener.dispose();
213 this.disposeRepoDisposables();
214
215 if (this.activeRepoRef !== undefined) {
216 this.activeRepoRef.unref();
217 }
218 }
219
220 private disposeRepoDisposables() {
221 this.repoDisposables.forEach(disposable => disposable.dispose());
222 this.repoDisposables = [];
223
224 this.subscriptions.forEach(sub => sub.dispose());
225 this.subscriptions.clear();
226 }
227
228 private processQueuedMessages() {
229 for (const message of this.queuedMessages) {
230 try {
231 this.handleIncomingMessage(message);
232 } catch (err) {
233 this.connection.logger?.error('error handling queued message: ', message, err);
234 }
235 }
236 this.queuedMessages = [];
237 }
238
239 private handleIncomingMessage(data: IncomingMessage) {
240 this.handleIncomingGeneralMessage(data as GeneralMessage);
241 const {currentState} = this;
242 switch (currentState.type) {
243 case 'repo': {
244 const {repo, ctx} = currentState;
245 this.handleIncomingMessageWithRepo(data as WithRepoMessage, repo, ctx);
246 break;
247 }
248
249 // If the repo is in the loading or error state, the client may still send
250 // platform messages such as `platform/openExternal` that should be processed.
251 case 'loading':
252 case 'error':
253 if (data.type.startsWith('platform/')) {
254 this.platform.handleMessageFromClient(
255 /*repo=*/ undefined,
256 // even if we don't have a repo, we can still make a RepositoryContext to execute commands
257 {
258 cwd: this.connection.cwd,
259 cmd: this.connection.command ?? 'sl',
260 logger: this.logger,
261 tracker: this.tracker,
262 },
263 data as PlatformSpecificClientToServerMessages,
264 message => this.postMessage(message),
265 (dispose: () => unknown) => {
266 this.repoDisposables.push({dispose});
267 },
268 );
269 this.notifyListeners(data);
270 }
271 break;
272 }
273 }
274
275 /**
276 * Handle messages which can be handled regardless of if a repo was successfully created or not
277 */
278 private handleIncomingGeneralMessage(data: GeneralMessage) {
279 switch (data.type) {
280 case 'heartbeat': {
281 this.postMessage({type: 'heartbeat', id: data.id});
282 break;
283 }
284 case 'stress': {
285 this.postMessage(data);
286 break;
287 }
288 case 'track': {
289 this.tracker.trackData(data.data);
290 break;
291 }
292 case 'clientReady': {
293 this.connection.readySignal?.resolve();
294 break;
295 }
296 case 'changeCwd': {
297 this.setActiveRepoForCwd(data.cwd);
298 break;
299 }
300 case 'requestRepoInfo': {
301 switch (this.currentState.type) {
302 case 'repo':
303 this.postMessage({
304 type: 'repoInfo',
305 info: this.currentState.repo.info,
306 cwd: this.currentState.ctx.cwd,
307 });
308 break;
309 case 'error':
310 this.postMessage({type: 'repoInfo', info: this.currentState.error});
311 break;
312 }
313 break;
314 }
315 case 'requestApplicationInfo': {
316 this.postMessage({
317 type: 'applicationInfo',
318 info: {
319 platformName: this.platform.platformName,
320 version: this.connection.version,
321 logFilePath: this.connection.logFileLocation ?? '(no log file, logging to stdout)',
322 },
323 });
324 break;
325 }
326 case 'fileBugReport': {
327 const maybeRepo = this.currentState.type === 'repo' ? this.currentState.repo : undefined;
328 const ctx: RepositoryContext =
329 this.currentState.type === 'repo'
330 ? this.currentState.ctx
331 : {
332 // cwd is only needed to run graphql query, here it's just best-effort
333 cwd: maybeRepo?.initialConnectionContext.cwd ?? process.cwd(),
334 cmd: this.connection.command ?? 'sl',
335 logger: this.logger,
336 tracker: this.tracker,
337 };
338 Internal.fileABug?.(
339 ctx,
340 this.platform.platformName,
341 data.data,
342 data.uiState,
343 // Use repo for rage, if available.
344 maybeRepo,
345 data.collectRage,
346 (progress: FileABugProgress) => {
347 this.connection.logger?.info('file a bug progress: ', JSON.stringify(progress));
348 this.postMessage({type: 'fileBugReportProgress', ...progress});
349 },
350 );
351 break;
352 }
353 case 'fetchGroveOwners': {
354 const groveConfig = readGroveConfig(this.logger);
355 if (!groveConfig.hub || !groveConfig.token) {
356 this.postMessage({type: 'fetchedGroveOwners', owners: []});
357 break;
358 }
359 (async () => {
360 try {
361 const owners: Array<{name: string; type: 'user' | 'org'}> = [];
362 if (groveConfig.username) {
363 owners.push({name: groveConfig.username, type: 'user'});
364 }
365 const res = await fetch(`${groveConfig.hub}/api/orgs`, {
366 headers: {Authorization: `Bearer ${groveConfig.token}`},
367 });
368 if (res.ok) {
369 const data = (await res.json()) as {orgs: Array<{name: string}>};
370 for (const org of data.orgs) {
371 owners.push({name: org.name, type: 'org'});
372 }
373 }
374 this.postMessage({type: 'fetchedGroveOwners', owners});
375 } catch (err) {
376 this.logger.error('Failed to fetch Grove owners:', err);
377 this.postMessage({type: 'fetchedGroveOwners', owners: []});
378 }
379 })();
380 break;
381 }
382 case 'createGroveRepo': {
383 const groveConfig = readGroveConfig(this.logger);
384 if (!groveConfig.hub || !groveConfig.token) {
385 this.postMessage({
386 type: 'createdGroveRepo',
387 result: {error: new Error('Not logged in to Grove. Run `grove auth login` first.')},
388 });
389 break;
390 }
391 const owner = data.owner ?? groveConfig.username;
392 if (!owner) {
393 this.postMessage({
394 type: 'createdGroveRepo',
395 result: {error: new Error('No owner specified and no username found in Grove config.')},
396 });
397 break;
398 }
399 const repoName = data.name;
400 (async () => {
401 try {
402 // Create the repo on Grove
403 const res = await fetch(`${groveConfig.hub}/api/repos`, {
404 method: 'POST',
405 headers: {
406 Authorization: `Bearer ${groveConfig.token}`,
407 'Content-Type': 'application/json',
408 },
409 body: JSON.stringify({name: repoName, owner}),
410 });
411 if (!res.ok) {
412 const body = await res.text();
413 throw new Error(`Failed to create repository: ${res.status} ${body}`);
414 }
415
416 const cwd = this.connection.cwd;
417 const cmd = this.connection.command ?? 'sl';
418 const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
419
420 // If sl init created a git-backed repo, re-init with correct format
421 const fs = await import('fs');
422 const storeRequires = path.join(cwd, '.sl', 'store', 'requires');
423 try {
424 const requires = fs.readFileSync(storeRequires, 'utf8');
425 if (requires.includes('git')) {
426 this.logger.info(
427 'Re-initializing repo: current store is git-backed, need native format for Grove',
428 );
429 fs.rmSync(path.join(cwd, '.sl'), {recursive: true, force: true});
430 await ejeca(
431 cmd,
432 [
433 'init',
434 '--config',
435 'init.prefer-git=false',
436 '--config',
437 'format.use-remotefilelog=true',
438 cwd,
439 ],
440 {cwd},
441 );
442 }
443 } catch {
444 // No .sl/store/requires means no repo yet — init fresh
445 if (!fs.existsSync(path.join(cwd, '.sl'))) {
446 await ejeca(
447 cmd,
448 [
449 'init',
450 '--config',
451 'init.prefer-git=false',
452 '--config',
453 'format.use-remotefilelog=true',
454 cwd,
455 ],
456 {cwd},
457 );
458 }
459 }
460
461 // Configure sapling remote
462 const configPairs = [
463 ['paths.default', `mononoke://grove.host:8443/${repoName}`],
464 ['remotefilelog.reponame', repoName],
465 ['grove.owner', owner],
466 ['edenapi.url', 'https://grove.host:8443/edenapi/'],
467 ['web.cacerts', `${homedir}/.grove/tls/ca.crt`],
468 ['auth.grove.prefix', 'https://grove.host'],
469 ['auth.grove.cert', `${homedir}/.grove/tls/client.crt`],
470 ['auth.grove.key', `${homedir}/.grove/tls/client.key`],
471 ['auth.grove.cacerts', `${homedir}/.grove/tls/ca.crt`],
472 ['auth.grove-mononoke.prefix', 'mononoke://grove.host'],
473 ['auth.grove-mononoke.cert', `${homedir}/.grove/tls/client.crt`],
474 ['auth.grove-mononoke.key', `${homedir}/.grove/tls/client.key`],
475 ['auth.grove-mononoke.cacerts', `${homedir}/.grove/tls/ca.crt`],
476 ['clone.use-commit-graph', 'true'],
477 ['remotenames.selectivepulldefault', 'main'],
478 ['push.edenapi', 'true'],
479 ['push.to', 'main'],
480 ];
481 for (const [key, value] of configPairs) {
482 await ejeca(cmd, ['config', '--local', key, value], {cwd});
483 }
484
485 // Pull the seed commit and check out main
486 try {
487 await ejeca(cmd, ['pull'], {cwd});
488 await ejeca(cmd, ['goto', 'main'], {cwd});
489 } catch (pullErr) {
490 this.logger.info('Pull after repo creation failed (may not be seeded yet):', pullErr);
491 }
492
493 // Re-detect repo info and send it to the client
494 const ctx = {
495 cwd,
496 cmd,
497 logger: this.logger,
498 tracker: this.tracker,
499 };
500 const newInfo = await Repository.getRepoInfo(ctx);
501 this.postMessage({type: 'repoInfo', info: newInfo, cwd});
502
503 // Also re-initialize the repo in the cache so subscriptions work
504 this.setActiveRepoForCwd(cwd);
505
506 this.postMessage({
507 type: 'createdGroveRepo',
508 result: {value: {owner, repo: repoName}},
509 });
510 } catch (err) {
511 this.logger.error('Failed to create Grove repo:', err);
512 this.postMessage({
513 type: 'createdGroveRepo',
514 result: {error: err instanceof Error ? err : new Error(String(err))},
515 });
516 }
517 })();
518 break;
519 }
520 }
521 }
522
523 private handleMaybeForgotOperation(operationId: string, repo: Repository) {
524 if (repo.getRunningOperation()?.id !== operationId) {
525 this.postMessage({type: 'operationProgress', id: operationId, kind: 'forgot'});
526 }
527 }
528
529 /**
530 * Handle messages which require a repository to have been successfully set up to run
531 */
532 private handleIncomingMessageWithRepo(
533 data: WithRepoMessage,
534 repo: Repository,
535 ctx: RepositoryContext,
536 ) {
537 const {cwd, logger} = ctx;
538 switch (data.type) {
539 case 'subscribe': {
540 const {subscriptionID, kind} = data;
541 switch (kind) {
542 case 'uncommittedChanges': {
543 const postUncommittedChanges = (result: FetchedUncommittedChanges) => {
544 this.postMessage({
545 type: 'subscriptionResult',
546 kind: 'uncommittedChanges',
547 subscriptionID,
548 data: result,
549 });
550 };
551
552 const uncommittedChanges = repo.getUncommittedChanges();
553 if (uncommittedChanges != null) {
554 postUncommittedChanges(uncommittedChanges);
555 }
556 const disposables: Array<Disposable> = [];
557
558 // send changes as they come in from watchman
559 disposables.push(repo.subscribeToUncommittedChanges(postUncommittedChanges));
560 // trigger a fetch on startup
561 repo.fetchUncommittedChanges();
562
563 disposables.push(
564 repo.subscribeToUncommittedChangesBeginFetching(() =>
565 this.postMessage({type: 'beganFetchingUncommittedChangesEvent'}),
566 ),
567 );
568 this.subscriptions.set(subscriptionID, {
569 dispose: () => {
570 disposables.forEach(d => d.dispose());
571 },
572 });
573 break;
574 }
575 case 'smartlogCommits': {
576 const postSmartlogCommits = (result: FetchedCommits) => {
577 this.postMessage({
578 type: 'subscriptionResult',
579 kind: 'smartlogCommits',
580 subscriptionID,
581 data: result,
582 });
583 };
584
585 const smartlogCommits = repo.getSmartlogCommits();
586 if (smartlogCommits != null) {
587 postSmartlogCommits(smartlogCommits);
588 }
589 const disposables: Array<Disposable> = [];
590 // send changes as they come from file watcher
591 disposables.push(repo.subscribeToSmartlogCommitsChanges(postSmartlogCommits));
592
593 // trigger fetch on startup
594 repo.fetchSmartlogCommits();
595
596 disposables.push(
597 repo.subscribeToSmartlogCommitsBeginFetching(() =>
598 this.postMessage({type: 'beganFetchingSmartlogCommitsEvent'}),
599 ),
600 );
601
602 this.subscriptions.set(subscriptionID, {
603 dispose: () => {
604 disposables.forEach(d => d.dispose());
605 },
606 });
607 break;
608 }
609 case 'mergeConflicts': {
610 const postMergeConflicts = (conflicts: MergeConflicts | undefined) => {
611 this.postMessage({
612 type: 'subscriptionResult',
613 kind: 'mergeConflicts',
614 subscriptionID,
615 data: conflicts,
616 });
617 };
618
619 const mergeConflicts = repo.getMergeConflicts();
620 if (mergeConflicts != null) {
621 postMergeConflicts(mergeConflicts);
622 }
623
624 this.subscriptions.set(subscriptionID, repo.onChangeConflictState(postMergeConflicts));
625 break;
626 }
627 case 'submodules': {
628 const postSubmodules = (submodulesByRoot: SubmodulesByRoot) => {
629 this.postMessage({
630 type: 'subscriptionResult',
631 kind: 'submodules',
632 subscriptionID,
633 data: submodulesByRoot,
634 });
635 };
636 const submoduleMap = repo.getSubmoduleMap();
637 if (submoduleMap !== undefined) {
638 postSubmodules(submoduleMap);
639 }
640 repo.fetchSubmoduleMap();
641
642 const disposable = repo.subscribeToSubmodulesChanges(postSubmodules);
643 this.subscriptions.set(subscriptionID, {
644 dispose: () => {
645 disposable.dispose();
646 },
647 });
648 break;
649 }
650 case 'subscribedFullRepoBranches': {
651 const fullRepoBranchModule = repo.fullRepoBranchModule;
652 if (fullRepoBranchModule == null) {
653 return;
654 }
655
656 const postSubscribedFullRepoBranches = (
657 result: Array<InternalTypes['FullRepoBranch']>,
658 ) => {
659 this.postMessage({
660 type: 'subscriptionResult',
661 kind: 'subscribedFullRepoBranches',
662 subscriptionID,
663 data: result,
664 });
665 };
666
667 const subscribedFullRepoBranches = fullRepoBranchModule.getSubscribedFullRepoBranches();
668 if (subscribedFullRepoBranches != null) {
669 postSubscribedFullRepoBranches(subscribedFullRepoBranches);
670 }
671 const disposables: Array<Disposable> = [];
672 // send changes as they come from file watcher
673 disposables.push(
674 fullRepoBranchModule.subscribeToSubscribedFullRepoBranchesChanges(
675 postSubscribedFullRepoBranches,
676 ),
677 );
678 // trigger a fetch on startup
679 fullRepoBranchModule.pullSubscribedFullRepoBranches();
680
681 disposables.push(
682 fullRepoBranchModule.subscribeToSubscribedFullRepoBranchesBeginFetching(() =>
683 this.postMessage({type: 'beganFetchingSubscribedFullRepoBranchesEvent'}),
684 ),
685 );
686
687 this.subscriptions.set(subscriptionID, {
688 dispose: () => {
689 disposables.forEach(d => d.dispose());
690 },
691 });
692 break;
693 }
694 }
695 break;
696 }
697 case 'unsubscribe': {
698 const subscription = this.subscriptions.get(data.subscriptionID);
699 subscription?.dispose();
700 this.subscriptions.delete(data.subscriptionID);
701 break;
702 }
703 case 'runOperation': {
704 const {operation} = data;
705 repo.runOrQueueOperation(ctx, operation, progress => {
706 this.postMessage({type: 'operationProgress', ...progress});
707 if (progress.kind === 'queue') {
708 this.tracker.track('QueueOperation', {extras: {operation: operation.trackEventName}});
709 }
710 });
711 break;
712 }
713 case 'abortRunningOperation': {
714 const {operationId} = data;
715 repo.abortRunningOperation(operationId);
716 this.handleMaybeForgotOperation(operationId, repo);
717 break;
718 }
719 case 'getConfig': {
720 repo
721 .getConfig(ctx, data.name)
722 .catch(() => undefined)
723 .then(value => {
724 logger.info('got config', data.name, value);
725 this.postMessage({type: 'gotConfig', name: data.name, value});
726 });
727 break;
728 }
729 case 'setConfig': {
730 logger.info('set config', data.name, data.value);
731 repo.setConfig(ctx, 'user', data.name, data.value).catch(err => {
732 logger.error('error setting config', data.name, data.value, err);
733 });
734 break;
735 }
736 case 'setDebugLogging': {
737 logger.info('set debug', data.name, data.enabled);
738 if (data.name === 'debug' || data.name === 'verbose') {
739 ctx[data.name] = !!data.enabled;
740 }
741 break;
742 }
743 case 'requestComparison': {
744 const {comparison} = data;
745 const diff: Promise<Result<string>> = repo
746 .runDiff(ctx, comparison)
747 .then(value => ({value}))
748 .catch(error => {
749 logger?.error('error running diff', error.toString());
750 return {error};
751 });
752 diff.then(data =>
753 this.postMessage({
754 type: 'comparison',
755 comparison,
756 data: {diff: data},
757 }),
758 );
759 break;
760 }
761 case 'requestComparisonContextLines': {
762 const {
763 id: {path: relativePath, comparison},
764 // This is the line number in the "before" side of the comparison
765 start,
766 // This is the number of context lines to fetch
767 numLines,
768 } = data;
769
770 const absolutePath = path.join(repo.info.repoRoot, relativePath);
771
772 // TODO: For context lines, before/after sides of the comparison
773 // are identical... except for line numbers.
774 // Typical comparisons with '.' would be much faster (nearly instant)
775 // by reading from the filesystem rather than using cat,
776 // we just need the caller to ask with "after" line numbers instead of "before".
777 // Note: we would still need to fall back to cat for comparisons that do not involve
778 // the working copy.
779 const cat: Promise<string> = repo.cat(
780 ctx,
781 absolutePath,
782 beforeRevsetForComparison(comparison),
783 );
784
785 cat
786 .then(content =>
787 this.postMessage({
788 type: 'comparisonContextLines',
789 lines: {value: content.split('\n').slice(start - 1, start - 1 + numLines)},
790 path: relativePath,
791 }),
792 )
793 .catch((error: Error) =>
794 this.postMessage({
795 type: 'comparisonContextLines',
796 lines: {error},
797 path: relativePath,
798 }),
799 );
800 break;
801 }
802 case 'requestMissedOperationProgress': {
803 const {operationId} = data;
804 this.handleMaybeForgotOperation(operationId, repo);
805 break;
806 }
807 case 'refresh': {
808 logger?.log('refresh requested');
809 repo.fetchAndSetRecommendedBookmarks(bookmarks => {
810 this.postMessage({type: 'fetchedRecommendedBookmarks', bookmarks});
811 });
812 repo.fetchSmartlogCommits();
813 repo.fetchUncommittedChanges();
814 repo.fetchSubmoduleMap();
815 repo.checkForMergeConflicts();
816 repo.fullRepoBranchModule?.pullSubscribedFullRepoBranches();
817 repo.codeReviewProvider?.triggerDiffSummariesFetch(repo.getAllDiffIds());
818 repo.initialConnectionContext.tracker.track('DiffFetchSource', {
819 extras: {source: 'manual_refresh'},
820 });
821 generatedFilesDetector.clear(); // allow generated files to be rechecked
822 break;
823 }
824 case 'pageVisibility': {
825 repo.setPageFocus(this.pageId, data.state);
826 break;
827 }
828 case 'uploadFile': {
829 const {id, filename, b64Content} = data;
830 const payload = base64Decode(b64Content);
831 const uploadFile = Internal.uploadFile;
832 if (uploadFile == null) {
833 return;
834 }
835 this.tracker
836 .operation('UploadImage', 'UploadImageError', {}, () =>
837 uploadFile(this.logger, {filename, data: payload}),
838 )
839 .then((result: string) => {
840 this.logger.info('successfully uploaded file', filename, result);
841 this.postMessage({type: 'uploadFileResult', id, result: {value: result}});
842 })
843 .catch((error: Error) => {
844 this.logger.info('error uploading file', filename, error);
845 this.postMessage({type: 'uploadFileResult', id, result: {error}});
846 });
847 break;
848 }
849 case 'fetchCommitMessageTemplate': {
850 this.handleFetchCommitMessageTemplate(repo, ctx);
851 break;
852 }
853 case 'fetchShelvedChanges': {
854 repo
855 .getShelvedChanges(ctx)
856 .then(shelvedChanges => {
857 this.postMessage({
858 type: 'fetchedShelvedChanges',
859 shelvedChanges: {value: shelvedChanges},
860 });
861 })
862 .catch(err => {
863 logger?.error('Could not fetch shelved changes', err);
864 this.postMessage({type: 'fetchedShelvedChanges', shelvedChanges: {error: err}});
865 });
866 break;
867 }
868 case 'fetchLatestCommit': {
869 repo
870 .lookupCommits(ctx, [data.revset])
871 .then(commits => {
872 const commit = firstOfIterable(commits.values());
873 if (commit == null) {
874 throw new Error(`No commit found for revset ${data.revset}`);
875 }
876 this.postMessage({
877 type: 'fetchedLatestCommit',
878 revset: data.revset,
879 info: {value: commit},
880 });
881 })
882 .catch(err => {
883 this.postMessage({
884 type: 'fetchedLatestCommit',
885 revset: data.revset,
886 info: {error: err as Error},
887 });
888 });
889 break;
890 }
891 case 'fetchPendingSignificantLinesOfCode':
892 {
893 repo
894 .fetchPendingSignificantLinesOfCode(ctx, data.hash, data.includedFiles)
895 .then(value => {
896 this.postMessage({
897 type: 'fetchedPendingSignificantLinesOfCode',
898 requestId: data.requestId,
899 hash: data.hash,
900 result: {value: value ?? 0},
901 });
902 })
903 .catch(err => {
904 this.postMessage({
905 type: 'fetchedPendingSignificantLinesOfCode',
906 hash: data.hash,
907 requestId: data.requestId,
908 result: {error: err as Error},
909 });
910 });
911 }
912 break;
913 case 'fetchSignificantLinesOfCode':
914 {
915 repo
916 .fetchSignificantLinesOfCode(ctx, data.hash, data.excludedFiles)
917 .then(value => {
918 this.postMessage({
919 type: 'fetchedSignificantLinesOfCode',
920 hash: data.hash,
921 result: {value: value ?? 0},
922 });
923 })
924 .catch(err => {
925 this.postMessage({
926 type: 'fetchedSignificantLinesOfCode',
927 hash: data.hash,
928 result: {error: err as Error},
929 });
930 });
931 }
932 break;
933 case 'fetchPendingAmendSignificantLinesOfCode':
934 {
935 repo
936 .fetchPendingAmendSignificantLinesOfCode(ctx, data.hash, data.includedFiles)
937 .then(value => {
938 this.postMessage({
939 type: 'fetchedPendingAmendSignificantLinesOfCode',
940 requestId: data.requestId,
941 hash: data.hash,
942 result: {value: value ?? 0},
943 });
944 })
945 .catch(err => {
946 this.postMessage({
947 type: 'fetchedPendingAmendSignificantLinesOfCode',
948 hash: data.hash,
949 requestId: data.requestId,
950 result: {error: err as Error},
951 });
952 });
953 }
954 break;
955 case 'fetchCommitChangedFiles': {
956 repo
957 .getAllChangedFiles(ctx, data.hash)
958 .then(files => {
959 this.postMessage({
960 type: 'fetchedCommitChangedFiles',
961 hash: data.hash,
962 result: {
963 value: {
964 filesSample: data.limit != null ? files.slice(0, data.limit) : files,
965 totalFileCount: files.length,
966 },
967 },
968 });
969 })
970 .catch(err => {
971 this.postMessage({
972 type: 'fetchedCommitChangedFiles',
973 hash: data.hash,
974 result: {error: err as Error},
975 });
976 });
977 break;
978 }
979 case 'fetchCommitCloudState': {
980 repo.getCommitCloudState(ctx).then(state => {
981 this.postMessage({
982 type: 'fetchedCommitCloudState',
983 state: {value: state},
984 });
985 });
986 break;
987 }
988 case 'fetchGeneratedStatuses': {
989 generatedFilesDetector
990 .queryFilesGenerated(repo, ctx, repo.info.repoRoot, data.paths)
991 .then(results => {
992 this.postMessage({type: 'fetchedGeneratedStatuses', results});
993 });
994 break;
995 }
996 case 'typeahead': {
997 // Current repo's code review provider should be able to handle all
998 // TypeaheadKinds for the fields in its defined schema.
999 repo.codeReviewProvider?.typeahead?.(data.kind, data.query, cwd)?.then(result =>
1000 this.postMessage({
1001 type: 'typeaheadResult',
1002 id: data.id,
1003 result,
1004 }),
1005 );
1006 break;
1007 }
1008 case 'fetchDiffSummaries': {
1009 repo.codeReviewProvider?.triggerDiffSummariesFetch(data.diffIds ?? repo.getAllDiffIds());
1010 break;
1011 }
1012 case 'fetchCanopySignals': {
1013 repo.codeReviewProvider?.triggerCanopySignalsFetch?.();
1014 break;
1015 }
1016 case 'fetchLandInfo': {
1017 repo.codeReviewProvider
1018 ?.fetchLandInfo?.(data.topOfStack)
1019 ?.then((landInfo: LandInfo) => {
1020 this.postMessage({
1021 type: 'fetchedLandInfo',
1022 topOfStack: data.topOfStack,
1023 landInfo: {value: landInfo},
1024 });
1025 })
1026 .catch(err => {
1027 this.postMessage({
1028 type: 'fetchedLandInfo',
1029 topOfStack: data.topOfStack,
1030 landInfo: {error: err as Error},
1031 });
1032 });
1033
1034 break;
1035 }
1036 case 'confirmLand': {
1037 if (data.landConfirmationInfo == null) {
1038 break;
1039 }
1040 repo.codeReviewProvider
1041 ?.confirmLand?.(data.landConfirmationInfo)
1042 ?.then((result: Result<undefined>) => {
1043 this.postMessage({
1044 type: 'confirmedLand',
1045 result,
1046 });
1047 });
1048 break;
1049 }
1050 case 'fetchAvatars': {
1051 repo.codeReviewProvider?.fetchAvatars?.(data.authors)?.then(avatars => {
1052 this.postMessage({
1053 type: 'fetchedAvatars',
1054 avatars,
1055 authors: data.authors,
1056 });
1057 });
1058 break;
1059 }
1060 case 'fetchDiffComments': {
1061 repo.codeReviewProvider
1062 ?.fetchComments?.(data.diffId)
1063 ?.then(comments => {
1064 this.postMessage({
1065 type: 'fetchedDiffComments',
1066 diffId: data.diffId,
1067 comments: {value: comments},
1068 });
1069 })
1070 .catch(error => {
1071 this.postMessage({
1072 type: 'fetchedDiffComments',
1073 diffId: data.diffId,
1074 comments: {error},
1075 });
1076 });
1077 break;
1078 }
1079 case 'renderMarkup': {
1080 repo.codeReviewProvider
1081 ?.renderMarkup?.(data.markup)
1082 ?.then(html => {
1083 this.postMessage({
1084 type: 'renderedMarkup',
1085 id: data.id,
1086 html,
1087 });
1088 })
1089 ?.catch(err => {
1090 this.logger.error('Error rendering markup:', err);
1091 });
1092 break;
1093 }
1094 case 'getSuggestedReviewers': {
1095 repo.codeReviewProvider?.getSuggestedReviewers?.(data.context).then(reviewers => {
1096 this.postMessage({
1097 type: 'gotSuggestedReviewers',
1098 reviewers,
1099 key: data.key,
1100 });
1101 });
1102 break;
1103 }
1104 case 'updateRemoteDiffMessage': {
1105 repo.codeReviewProvider
1106 ?.updateDiffMessage?.(data.diffId, data.title, data.description)
1107 ?.catch(err => err)
1108 ?.then((error: string | undefined) => {
1109 if (error != null) {
1110 this.logger.error('Error updating remote diff message:', error);
1111 }
1112 this.postMessage({type: 'updatedRemoteDiffMessage', diffId: data.diffId, error});
1113 });
1114 break;
1115 }
1116 case 'loadMoreCommits': {
1117 const rangeInDays = repo.nextVisibleCommitRangeInDays();
1118 this.postMessage({type: 'commitsShownRange', rangeInDays});
1119 this.postMessage({type: 'beganLoadingMoreCommits'});
1120 repo.fetchSmartlogCommits();
1121 this.tracker.track('LoadMoreCommits', {extras: {daysToFetch: rangeInDays ?? 'Infinity'}});
1122 return;
1123 }
1124 case 'exportStack': {
1125 const {revs, assumeTracked} = data;
1126 const assumeTrackedArgs = (assumeTracked ?? []).map(path => `--assume-tracked=${path}`);
1127 const exec = repo.runCommand(
1128 ['debugexportstack', '-r', revs, ...assumeTrackedArgs],
1129 'ExportStackCommand',
1130 ctx,
1131 undefined,
1132 /* don't timeout */ 0,
1133 );
1134 const reply = (stack?: ExportStack, error?: string) => {
1135 this.postMessage({
1136 type: 'exportedStack',
1137 assumeTracked: assumeTracked ?? [],
1138 revs,
1139 stack: stack ?? [],
1140 error,
1141 });
1142 };
1143 parseExecJson(exec, reply);
1144 break;
1145 }
1146 case 'importStack': {
1147 const stdinStream = Readable.from(JSON.stringify(data.stack));
1148 const exec = repo.runCommand(
1149 ['debugimportstack'],
1150 'ImportStackCommand',
1151 ctx,
1152 {stdin: stdinStream},
1153 /* don't timeout */ 0,
1154 );
1155 const reply = (imported?: ImportedStack, error?: string) => {
1156 this.postMessage({type: 'importedStack', imported: imported ?? [], error});
1157 };
1158 parseExecJson(exec, reply);
1159 break;
1160 }
1161 case 'fetchQeFlag': {
1162 Internal.fetchQeFlag?.(repo.initialConnectionContext, data.name).then((passes: boolean) => {
1163 this.logger.info(`qe flag ${data.name} ${passes ? 'PASSES' : 'FAILS'}`);
1164 this.postMessage({type: 'fetchedQeFlag', name: data.name, passes});
1165 });
1166 break;
1167 }
1168 case 'fetchFeatureFlag': {
1169 Internal.fetchFeatureFlag?.(repo.initialConnectionContext, data.name).then(
1170 (passes: boolean) => {
1171 this.logger.info(`feature flag ${data.name} ${passes ? 'PASSES' : 'FAILS'}`);
1172 this.postMessage({type: 'fetchedFeatureFlag', name: data.name, passes});
1173 },
1174 );
1175 break;
1176 }
1177 case 'bulkFetchFeatureFlags': {
1178 Internal.bulkFetchFeatureFlags?.(repo.initialConnectionContext, data.names).then(
1179 (result: Record<string, boolean>) => {
1180 this.logger.info(`feature flags ${JSON.stringify(result, null, 2)}`);
1181 this.postMessage({type: 'bulkFetchedFeatureFlags', id: data.id, result});
1182 },
1183 );
1184 break;
1185 }
1186 case 'fetchInternalUserInfo': {
1187 Internal.fetchUserInfo?.(repo.initialConnectionContext).then((info: Serializable) => {
1188 this.logger.info('user info:', info);
1189 this.postMessage({type: 'fetchedInternalUserInfo', info});
1190 });
1191 break;
1192 }
1193 case 'fetchAndSetStables': {
1194 Internal.fetchStableLocations?.(ctx, data.additionalStables).then(
1195 (stables: StableLocationData | undefined) => {
1196 this.logger.info('fetched stable locations', stables);
1197 if (stables == null) {
1198 return;
1199 }
1200 this.postMessage({type: 'fetchedStables', stables});
1201 repo.stableLocations = [
1202 ...stables.stables,
1203 ...stables.special,
1204 ...Object.values(stables.manual),
1205 ]
1206 .map(stable => stable?.value)
1207 .filter(notEmpty);
1208 repo.fetchSmartlogCommits();
1209 },
1210 );
1211 break;
1212 }
1213 case 'fetchStableLocationAutocompleteOptions': {
1214 Internal.fetchStableLocationAutocompleteOptions?.(ctx).then(
1215 (result: Result<Array<TypeaheadResult>>) => {
1216 this.postMessage({type: 'fetchedStableLocationAutocompleteOptions', result});
1217 },
1218 );
1219 break;
1220 }
1221 case 'fetchDevEnvType': {
1222 if (Internal.getDevEnvType == null) {
1223 break;
1224 }
1225
1226 Internal.getDevEnvType()
1227 .catch((error: Error) => {
1228 this.logger.error('Error getting dev env type:', error);
1229 return 'error';
1230 })
1231 .then((result: string) => {
1232 this.postMessage({
1233 type: 'fetchedDevEnvType',
1234 envType: result,
1235 id: data.id,
1236 });
1237 });
1238 break;
1239 }
1240 case 'splitCommitWithAI': {
1241 Internal.splitCommitWithAI?.(ctx, data.diffCommit, data.args).then(
1242 (result: Result<ReadonlyArray<PartiallySelectedDiffCommit>>) => {
1243 this.postMessage({
1244 type: 'splitCommitWithAI',
1245 id: data.id,
1246 result,
1247 });
1248 },
1249 );
1250 break;
1251 }
1252 case 'fetchActiveAlerts': {
1253 repo
1254 .getActiveAlerts(ctx)
1255 .then(alerts => {
1256 if (alerts.length === 0) {
1257 return;
1258 }
1259 this.postMessage({
1260 type: 'fetchedActiveAlerts',
1261 alerts,
1262 });
1263 })
1264 .catch(err => {
1265 this.logger.error('Failed to fetch active alerts:', err);
1266 });
1267 break;
1268 }
1269 case 'gotUiState': {
1270 break;
1271 }
1272 case 'getConfiguredMergeTool': {
1273 repo.getMergeTool(ctx).then((tool: string | null) => {
1274 this.postMessage({
1275 type: 'gotConfiguredMergeTool',
1276 tool: tool ?? undefined,
1277 });
1278 });
1279 break;
1280 }
1281 case 'fetchGkDetails': {
1282 Internal.fetchGkDetails?.(ctx, data.name)
1283 .then((gk: InternalTypes['InternalGatekeeper']) => {
1284 this.postMessage({type: 'fetchedGkDetails', id: data.id, result: {value: gk}});
1285 })
1286 .catch((err: unknown) => {
1287 logger?.error('Could not fetch GK details', err);
1288 this.postMessage({
1289 type: 'fetchedGkDetails',
1290 id: data.id,
1291 result: {error: err as Error},
1292 });
1293 });
1294 break;
1295 }
1296 case 'fetchJkDetails': {
1297 Internal.fetchJustKnobsByNames?.(ctx, data.names)
1298 .then((jk: InternalTypes['InternalJustknob']) => {
1299 this.postMessage({type: 'fetchedJkDetails', id: data.id, result: {value: jk}});
1300 })
1301 .catch((err: unknown) => {
1302 logger?.error('Could not fetch JK details', err);
1303 this.postMessage({
1304 type: 'fetchedJkDetails',
1305 id: data.id,
1306 result: {error: err as Error},
1307 });
1308 });
1309 break;
1310 }
1311 case 'fetchKnobsetDetails': {
1312 Internal.fetchKnobset?.(ctx, data.configPath)
1313 .then((knobset: InternalTypes['InternalKnobset']) => {
1314 this.postMessage({
1315 type: 'fetchedKnobsetDetails',
1316 id: data.id,
1317 result: {value: knobset},
1318 });
1319 })
1320 .catch((err: unknown) => {
1321 logger?.error('Could not fetch knobset details', err);
1322 this.postMessage({
1323 type: 'fetchedKnobsetDetails',
1324 id: data.id,
1325 result: {error: err as Error},
1326 });
1327 });
1328 break;
1329 }
1330 case 'fetchQeDetails': {
1331 Internal.fetchQeMetadata?.(ctx, data.name)
1332 .then((qe: InternalTypes['InternalQuickExperiment']) => {
1333 this.postMessage({
1334 type: 'fetchedQeDetails',
1335 id: data.id,
1336 result: {value: qe},
1337 });
1338 })
1339 .catch((err: unknown) => {
1340 logger?.error('Could not fetch QE details', err);
1341 this.postMessage({
1342 type: 'fetchedQeDetails',
1343 id: data.id,
1344 result: {error: err as Error},
1345 });
1346 });
1347 break;
1348 }
1349 case 'fetchABPropDetails': {
1350 Internal.fetchABPropMetadata?.(ctx, data.name)
1351 .then((abprop: InternalTypes['InternalMetaConfig']) => {
1352 this.postMessage({
1353 type: 'fetchedABPropDetails',
1354 id: data.id,
1355 result: {value: abprop},
1356 });
1357 })
1358 .catch((err: unknown) => {
1359 logger?.error('Could not fetch ABProp details', err);
1360 this.postMessage({
1361 type: 'fetchedABPropDetails',
1362 id: data.id,
1363 result: {error: err as Error},
1364 });
1365 });
1366 break;
1367 }
1368 case 'getRepoUrlAtHash': {
1369 const args = ['url', '--rev', data.revset];
1370 // validate that the path is a valid file in repo
1371 if (data.path != null && absolutePathForFileInRepo(data.path, repo) != null) {
1372 args.push(`path:${data.path}`);
1373 }
1374 repo
1375 .runCommand(args, 'RepoUrlCommand', ctx)
1376 .then(result => {
1377 this.postMessage({
1378 type: 'gotRepoUrlAtHash',
1379 url: {value: result.stdout},
1380 });
1381 })
1382 .catch((err: EjecaError) => {
1383 this.logger.error('Failed to get repo url at hash:', err);
1384 this.postMessage({
1385 type: 'gotRepoUrlAtHash',
1386 url: {error: err},
1387 });
1388 });
1389 break;
1390 }
1391 case 'fetchTaskDetails': {
1392 Internal.getTask?.(ctx, data.taskNumber).then(
1393 (task: InternalTypes['InternalTaskDetails']) => {
1394 this.postMessage({type: 'fetchedTaskDetails', id: data.id, result: {value: task}});
1395 },
1396 );
1397 break;
1398 }
1399 case 'runDevmateCommand': {
1400 Internal.runDevmateCommand?.(data.args, data.cwd)
1401 .then((result: EjecaReturn) => {
1402 this.postMessage({
1403 type: 'devmateCommandResult',
1404 result: {type: 'value', stdout: result.stdout, requestId: data.requestId},
1405 });
1406 })
1407 .catch((error: EjecaError) => {
1408 this.postMessage({
1409 type: 'devmateCommandResult',
1410 result: {type: 'error', stderr: error.stderr, requestId: data.requestId},
1411 });
1412 });
1413 break;
1414 }
1415 case 'fetchSubscribedFullRepoBranches': {
1416 Internal.fetchSubscribedFullRepoBranches?.(ctx, repo)
1417 .then((branches: Array<InternalTypes['FullRepoBranch']>) => {
1418 this.postMessage({
1419 type: 'fetchedSubscribedFullRepoBranches',
1420 result: {value: branches},
1421 });
1422 })
1423 .catch((error: EjecaError) => {
1424 this.postMessage({
1425 type: 'fetchedSubscribedFullRepoBranches',
1426 result: {error},
1427 });
1428 });
1429 break;
1430 }
1431 case 'fetchFullRepoBranchAllChangedFiles': {
1432 Internal.getFullRepoBranchAllChangedFiles?.(ctx, data.fullRepoBranch)
1433 .then((paths: Array<ChangedFile>) => {
1434 this.postMessage({
1435 type: 'fetchedFullRepoBranchAllChangedFiles',
1436 id: data.id,
1437 result: {value: paths},
1438 });
1439 })
1440 .catch((error: EjecaError) => {
1441 this.postMessage({
1442 type: 'fetchedFullRepoBranchAllChangedFiles',
1443 id: data.id,
1444 result: {error},
1445 });
1446 });
1447 break;
1448 }
1449 case 'fetchFullRepoBranchMergeSubtreePaths': {
1450 Internal.getFullRepoBranchMergeSubtreePaths?.(ctx, data.fullRepoBranch, data.paths)
1451 .then((paths: Array<string>) => {
1452 this.postMessage({
1453 type: 'fetchedFullRepoBranchMergeSubtreePaths',
1454 id: data.id,
1455 result: {value: paths},
1456 });
1457 })
1458 .catch((error: EjecaError) => {
1459 this.postMessage({
1460 type: 'fetchedFullRepoBranchMergeSubtreePaths',
1461 id: data.id,
1462 result: {error},
1463 });
1464 });
1465 break;
1466 }
1467 case 'subscribeToFullRepoBranch': {
1468 Internal.subscribeToFullRepoBranch?.(ctx, repo, data.fullRepoBranch);
1469 break;
1470 }
1471 case 'unsubscribeToFullRepoBranch': {
1472 Internal.unsubscribeToFullRepoBranch?.(ctx, repo, data.fullRepoBranch);
1473 break;
1474 }
1475 default: {
1476 if (
1477 repo.codeReviewProvider?.handleClientToServerMessage?.(data, message =>
1478 this.postMessage(message),
1479 ) === true
1480 ) {
1481 break;
1482 }
1483 this.platform.handleMessageFromClient(
1484 repo,
1485 ctx,
1486 data as Exclude<typeof data, CodeReviewProviderSpecificClientToServerMessages>,
1487 message => this.postMessage(message),
1488 (dispose: () => unknown) => {
1489 this.repoDisposables.push({dispose});
1490 },
1491 );
1492 break;
1493 }
1494 }
1495
1496 this.notifyListeners(data);
1497 }
1498
1499 private notifyListeners(data: IncomingMessage): void {
1500 const listeners = this.listenersByType.get(data.type);
1501 if (listeners) {
1502 listeners.forEach(handle => handle(data));
1503 }
1504 }
1505
1506 private async handleFetchCommitMessageTemplate(repo: Repository, ctx: RepositoryContext) {
1507 const {logger} = ctx;
1508 try {
1509 const [result, customTemplate] = await Promise.all([
1510 repo.runCommand(['debugcommitmessage', 'isl'], 'FetchCommitTemplateCommand', ctx),
1511 Internal.getCustomDefaultCommitTemplate?.(repo.initialConnectionContext),
1512 ]);
1513
1514 let template = result.stdout
1515 .replace(repo.IGNORE_COMMIT_MESSAGE_LINES_REGEX, '')
1516 .replace(/^<Replace this line with a title. Use 1 line only, 67 chars or less>/, '');
1517
1518 if (customTemplate && customTemplate?.trim() !== '') {
1519 template = customTemplate as string;
1520
1521 this.tracker.track('UseCustomCommitMessageTemplate');
1522 }
1523
1524 this.postMessage({
1525 type: 'fetchedCommitMessageTemplate',
1526 template,
1527 });
1528 } catch (err) {
1529 logger?.error('Could not fetch commit message template', err);
1530 }
1531 }
1532}
1533