Skip to content

Commit 3c3b66c

Browse files
Copilotalexr00
andauthored
Fix duplicate comment threads in diff editor (#8739)
* Initial plan * Initial plan Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Fix duplicate comment threads in diff editor Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * Address CCR comment * Add a test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com>
1 parent 7cdbb9c commit 3c3b66c

3 files changed

Lines changed: 101 additions & 5 deletions

File tree

src/test/view/reviewCommentController.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ class TestReviewCommentController extends ReviewCommentController {
5050
public workspaceFileChangeCommentThreads() {
5151
return this._workspaceFileChangeCommentThreads;
5252
}
53+
public obsoleteFileChangeCommentThreads() {
54+
return this._obsoleteFileChangeCommentThreads;
55+
}
56+
public reviewSchemeFileChangeCommentThreads() {
57+
return this._reviewSchemeFileChangeCommentThreads;
58+
}
59+
public seedWorkspaceThreads(path: string, threads: GHPRCommentThread[]) {
60+
this._workspaceFileChangeCommentThreads[path] = threads;
61+
}
62+
public seedObsoleteThreads(path: string, threads: GHPRCommentThread[]) {
63+
this._obsoleteFileChangeCommentThreads[path] = threads;
64+
}
65+
public callFindMatchingThread(thread: import('../../common/comment').IReviewThread) {
66+
return (this as any)._findMatchingThread(thread);
67+
}
5368
}
5469

5570
describe('ReviewCommentController', function () {
@@ -340,4 +355,61 @@ describe('ReviewCommentController', function () {
340355
assert.strictEqual(workspaceFileChangeCommentThreads[fileName].length, 1);
341356
});
342357
});
358+
359+
describe('_findMatchingThread', function () {
360+
it('returns the moved thread when an existing workspace thread becomes outdated', function () {
361+
const fileName = 'data/products.json';
362+
const uri = vscode.Uri.parse(`${repository.rootUri.toString()}/${fileName}`);
363+
const reviewModel = new ReviewModel();
364+
const reviewCommentController = new TestReviewCommentController(
365+
reviewManager,
366+
manager,
367+
repository,
368+
reviewModel,
369+
gitApiImpl,
370+
telemetry,
371+
);
372+
373+
const threadA = createGHPRCommentThread('thread-A', uri);
374+
const threadB = createGHPRCommentThread('thread-B', uri);
375+
const existingObsolete = createGHPRCommentThread('thread-C', uri);
376+
377+
reviewCommentController.seedWorkspaceThreads(fileName, [threadA, threadB]);
378+
reviewCommentController.seedObsoleteThreads(fileName, [existingObsolete]);
379+
380+
const reviewThread: import('../../common/comment').IReviewThread = {
381+
id: 'thread-B',
382+
isResolved: false,
383+
viewerCanResolve: false,
384+
viewerCanUnresolve: false,
385+
path: fileName,
386+
diffSide: DiffSide.RIGHT,
387+
startLine: 1,
388+
endLine: 1,
389+
originalStartLine: 1,
390+
originalEndLine: 1,
391+
isOutdated: true,
392+
comments: [],
393+
subjectType: SubjectType.LINE,
394+
};
395+
396+
const result = reviewCommentController.callFindMatchingThread(reviewThread);
397+
398+
// The matching thread should be the moved thread, not `undefined` or the wrong one.
399+
assert.strictEqual(result.threadMap, reviewCommentController.obsoleteFileChangeCommentThreads());
400+
assert.ok(result.index > -1, 'index should point to the moved thread');
401+
assert.strictEqual(result.threadMap[fileName][result.index], threadB);
402+
403+
// Workspace map no longer contains the moved thread.
404+
const workspace = reviewCommentController.workspaceFileChangeCommentThreads();
405+
assert.strictEqual(workspace[fileName].length, 1);
406+
assert.strictEqual(workspace[fileName][0], threadA);
407+
408+
// Obsolete map now contains the moved thread appended after the pre-existing one.
409+
const obsolete = reviewCommentController.obsoleteFileChangeCommentThreads();
410+
assert.strictEqual(obsolete[fileName].length, 2);
411+
assert.strictEqual(obsolete[fileName][0], existingObsolete);
412+
assert.strictEqual(obsolete[fileName][1], threadB);
413+
});
414+
});
343415
});

src/view/pullRequestCommentController.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,17 @@ export class PullRequestCommentController extends CommentControllerBase implemen
230230
private async onDidChangeReviewThreads(e: ReviewThreadChangeEvent): Promise<void> {
231231
for (const thread of e.added) {
232232
const fileName = thread.path;
233+
const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT);
234+
235+
// Defensive: if a comment thread for this review thread id already exists in the cache
236+
// (e.g. because addThreadsForEditors already created it from the cache before this event was
237+
// processed), update it in place instead of creating a duplicate VS Code comment thread.
238+
const existing = this._commentThreadCache[key]?.find(t => t.gitHubThreadId === thread.id);
239+
if (existing) {
240+
updateThread(this._context, existing, thread, this._githubRepositories);
241+
continue;
242+
}
243+
233244
const index = this._pendingCommentThreadAdds.findIndex(t => {
234245
const samePath = this._folderRepoManager.gitRelativeRootPath(t.uri.path) === thread.path;
235246
const sameLine = (t.range === undefined && thread.subjectType === SubjectType.FILE) || (t.range && t.range.end.line + 1 === thread.endLine);
@@ -270,9 +281,8 @@ export class PullRequestCommentController extends CommentControllerBase implemen
270281
}
271282

272283
if (!newThread) {
273-
return;
284+
continue;
274285
}
275-
const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT);
276286
if (this._commentThreadCache[key]) {
277287
this._commentThreadCache[key].push(newThread);
278288
} else {

src/view/reviewCommentController.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,17 @@ export class ReviewCommentController extends CommentControllerBase implements Co
280280
for (const thread of e.added) {
281281
const { path } = thread;
282282

283+
// Defensive: if a comment thread for this review thread id already exists in any of the
284+
// thread maps (e.g. because doInitializeCommentThreads already created it from the cache
285+
// before this event was processed), update it in place instead of creating a duplicate
286+
// VS Code comment thread.
287+
const existingMatch = this._findMatchingThread(thread);
288+
if (existingMatch.index > -1) {
289+
const matchingThread = existingMatch.threadMap[thread.path][existingMatch.index];
290+
updateThread(this._context, matchingThread, thread, githubRepositories);
291+
continue;
292+
}
293+
283294
const index = await arrayFindIndexAsync(this._pendingCommentThreadAdds, async t => {
284295
const fileName = this._folderRepoManager.gitRelativeRootPath(t.uri.path);
285296
if (fileName !== thread.path) {
@@ -358,13 +369,16 @@ export class ReviewCommentController extends CommentControllerBase implements Co
358369
let index = threadMap[thread.path]?.findIndex(t => t.gitHubThreadId === thread.id) ?? -1;
359370
if ((index === -1) && thread.isOutdated) {
360371
// The thread has become outdated and needs to be moved to the obsolete threads.
361-
index = this._workspaceFileChangeCommentThreads[thread.path]?.findIndex(t => t.gitHubThreadId === thread.id) ?? -1;
362-
if (index > -1) {
363-
const matchingThread = this._workspaceFileChangeCommentThreads[thread.path]!.splice(index, 1)[0];
372+
const workspaceIndex = this._workspaceFileChangeCommentThreads[thread.path]?.findIndex(t => t.gitHubThreadId === thread.id) ?? -1;
373+
if (workspaceIndex > -1) {
374+
const matchingThread = this._workspaceFileChangeCommentThreads[thread.path]!.splice(workspaceIndex, 1)[0];
364375
if (!this._obsoleteFileChangeCommentThreads[thread.path]) {
365376
this._obsoleteFileChangeCommentThreads[thread.path] = [];
366377
}
367378
this._obsoleteFileChangeCommentThreads[thread.path]!.push(matchingThread);
379+
// `threadMap` already references `_obsoleteFileChangeCommentThreads`; the matching
380+
// thread is now the last element of the obsolete array for this path.
381+
index = this._obsoleteFileChangeCommentThreads[thread.path]!.length - 1;
368382
}
369383
}
370384
return { threadMap, index };

0 commit comments

Comments
 (0)