From 7f6ee753bfbc260d6fb15d257204fd8a4dc3ffa2 Mon Sep 17 00:00:00 2001 From: Chidozie Ononiwu <31145988+chidozieononiwu@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:04:37 -0800 Subject: [PATCH] Codeline Search (#9605) * Review Search Box Logic to search codeLines * Coide Line Search Navigation * CodeLine Search Navigation * 'Improve CodeLine Scrolling * Search and Scroll * Improve Search functionality * Add tests for codeline serach --- .../Client/css/shared/theme-colors.scss | 2 + .../code-panel/code-panel.component.html | 2 +- .../code-panel/code-panel.component.scss | 14 +- .../code-panel/code-panel.component.spec.ts | 42 +++ .../code-panel/code-panel.component.ts | 254 ++++++++++++++++-- .../review-page-options.component.html | 16 +- .../review-page-options.component.scss | 9 + .../review-page-options.component.ts | 50 +++- .../review-page/review-page.component.html | 10 +- .../review-page/review-page.component.ts | 17 ++ .../src/app/_helpers/doubly-linkedlist.ts | 32 +++ .../src/app/_models/codeLineSearchInfo.ts | 16 ++ .../shared/review-page-layout.module.ts | 1 - .../app/_modules/shared/shared-app.module.ts | 8 + .../ClientSPA/src/ng-prime-overrides.scss | 8 + 15 files changed, 452 insertions(+), 29 deletions(-) create mode 100644 src/dotnet/APIView/ClientSPA/src/app/_helpers/doubly-linkedlist.ts create mode 100644 src/dotnet/APIView/ClientSPA/src/app/_models/codeLineSearchInfo.ts diff --git a/src/dotnet/APIView/APIViewWeb/Client/css/shared/theme-colors.scss b/src/dotnet/APIView/APIViewWeb/Client/css/shared/theme-colors.scss index 5975171ee3f..d9137f981bd 100644 --- a/src/dotnet/APIView/APIViewWeb/Client/css/shared/theme-colors.scss +++ b/src/dotnet/APIView/APIViewWeb/Client/css/shared/theme-colors.scss @@ -30,6 +30,7 @@ /*----Shadows and Glows------------------------------------------*/ --outer-glow: #{tint-color($primary, 80%)}; --shadow-color: rgba(#{$black}, .15); + --shadow-color-primary: 0 0 0 0.15rem #{rgba($primary, 0.5)}; --box-shadow: 0 .5rem 1rem #{rgba($black, .15)}; --box-shadow-sm: 0 .125rem .25rem #{rgba($black, .075)}; --box-shadow-lg: 0 1rem 3rem #{rgba($black, .175)}; @@ -120,6 +121,7 @@ /*----Shadows and Glows------------------------------------------*/ --outer-glow: #{tint-color($primary-color, 80%)}; --shadow-color: rgba(#{$white}, .15); + --shadow-color-primary: 0 0 0 0.15rem #{rgba($primary-color, 0.5)}; --box-shadow: 0 .5rem 1rem #{rgba($white, .15)}; --box-shadow-sm: 0 .125rem .25rem #{rgba($white, .075)}; --box-shadow-lg: 0 1rem 3rem #{rgba($white, .175)}; diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.html b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.html index 4c3c40f896c..9c74ef7d948 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.html +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.html @@ -2,7 +2,7 @@
Loading...
-
+
diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.scss b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.scss index 1fa6dfe80f0..a8b3bfb686a 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.scss +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.scss @@ -9,7 +9,7 @@ line-height: 1.5; display: block; - .viewport { + #viewport { overflow: auto; height: calc(100vh - 132px); will-change: scroll-position, contents; @@ -219,6 +219,18 @@ text-decoration: line-through; } + .codeline-search-match-highlight { + background-color: var(--primary-color); + color: var(--primary-btn-color); + padding: 0px; + margin: 0px; + + &.active { + background-color: red; + } + } + + .java { .javadoc { diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.spec.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.spec.ts index 77f6f293b27..e7983174a2a 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.spec.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.spec.ts @@ -115,5 +115,47 @@ describe('CodePanelComponent', () => { expect(clipboardSpy).toHaveBeenCalledWith('\ttoken1\ntoken2'); }); }); + + describe('searchCodePanelRowData', () => { + it('should be defined', () => { + expect(component.searchCodePanelRowData).toBeDefined(); + }); + + it('should handle no matches gracefully', () => { + const token1 = new StructuredToken(); + token1.value = 'token1'; + const token2 = new StructuredToken(); + token2.value = 'token2'; + const codePanelRowData1 = new CodePanelRowData(); + codePanelRowData1.rowOfTokens = [token1, token2]; + component.codePanelRowData = [codePanelRowData1]; + component.searchCodePanelRowData('nonexistent'); + expect(component.codeLineSearchMatchInfo?.length).toBeUndefined(); + }); + + it('should handle an empty search term', () => { + const token1 = new StructuredToken(); + token1.value = 'token1'; + const token2 = new StructuredToken(); + token2.value = 'token2'; + const codePanelRowData1 = new CodePanelRowData(); + codePanelRowData1.rowOfTokens = [token1, token2]; + component.codePanelRowData = [codePanelRowData1]; + component.searchCodePanelRowData(''); + expect(component.codeLineSearchMatchInfo?.length).toBeUndefined(); + }); + }); + + describe('highlightSearchMatches', () => { + it('should be defined', () => { + expect(component.highlightSearchMatches).toBeDefined(); + }); + + it('should clear previous highlights', () => { + spyOn(component, 'clearSearchMatchHighlights'); + component.highlightSearchMatches(); + expect(component.clearSearchMatchHighlights).toHaveBeenCalled(); + }); + }); }); diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.ts index 56f9b1f40a9..86d6bdf09c6 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.ts @@ -1,5 +1,5 @@ -import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; -import { take, takeUntil } from 'rxjs/operators'; +import { ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnChanges, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; +import { max, take, takeUntil } from 'rxjs/operators'; import { Datasource, IDatasource, SizeStrategy } from 'ngx-ui-scroll'; import { CommentsService } from 'src/app/_services/comments/comments.service'; import { getQueryParams } from 'src/app/_helpers/router-helpers'; @@ -16,6 +16,8 @@ import { SignalRService } from 'src/app/_services/signal-r/signal-r.service'; import { Subject } from 'rxjs'; import { CommentThreadUpdateAction, CommentUpdatesDto } from 'src/app/_dtos/commentThreadUpdateDto'; import { Menu } from 'primeng/menu'; +import { CodeLineSearchInfo, CodeLineSearchMatch } from 'src/app/_models/codeLineSearchInfo'; +import { DoublyLinkedList, DoublyLinkedListNode } from 'src/app/_helpers/doubly-linkedlist'; @Component({ selector: 'app-code-panel', @@ -35,8 +37,12 @@ export class CodePanelComponent implements OnChanges{ @Input() userProfile : UserProfile | undefined; @Input() showLineNumbers: boolean = true; @Input() loadFailed : boolean = false; + @Input() codeLineSearchText: string | undefined; + @Input() codeLineNavigationDirection: number | undefined; @Output() hasActiveConversationEmitter : EventEmitter = new EventEmitter(); + @Output() codeLineSearchInfoEmitter : EventEmitter = new EventEmitter(); + @ViewChildren(Menu) menus!: QueryList; noDiffInContentMessage : Message[] = [{ severity: 'info', icon:'bi bi-info-circle', detail: 'There is no difference between the two API revisions.' }]; @@ -48,6 +54,11 @@ export class CodePanelComponent implements OnChanges{ codePanelRowSource: IDatasource | undefined; CodePanelRowDatatype = CodePanelRowDatatype; + searchMatchedRowInfo: Map = new Map(); + codeLineSearchMatchInfo : DoublyLinkedList | undefined = undefined; + currentCodeLineSearchMatch: DoublyLinkedListNode | undefined = undefined; + codeLineSearchInfo: CodeLineSearchInfo | undefined = undefined; + destroy$ = new Subject(); commentThreadNavaigationPointer: number | undefined = undefined; @@ -56,7 +67,8 @@ export class CodePanelComponent implements OnChanges{ menuItemsLineActions: MenuItem[] = []; constructor(private changeDetectorRef: ChangeDetectorRef, private commentsService: CommentsService, - private signalRService: SignalRService, private route: ActivatedRoute, private router: Router, private messageService: MessageService) { } + private signalRService: SignalRService, private route: ActivatedRoute, private router: Router, + private messageService: MessageService, private elementRef: ElementRef) { } ngOnInit() { this.codeWindowHeight = `${window.innerHeight - 80}`; @@ -68,7 +80,7 @@ export class CodePanelComponent implements OnChanges{ ]; } - ngOnChanges(changes: SimpleChanges) { + async ngOnChanges(changes: SimpleChanges) { if (changes['codePanelRowData']) { if (changes['codePanelRowData'].currentValue.length > 0) { this.loadCodePanelViewPort(); @@ -86,6 +98,17 @@ export class CodePanelComponent implements OnChanges{ if (changes['loadFailed'] && changes['loadFailed'].currentValue) { this.isLoading = false; } + + if (changes['codeLineSearchText']) { + await this.searchCodePanelRowData(this.codeLineSearchText!); + } + + if (changes['codeLineNavigationDirection']) { + this.navigateToCodeLineWithSearchMatch( + changes['codeLineNavigationDirection'].previousValue, + changes['codeLineNavigationDirection'].currentValue + ); + } } onCodePanelItemClick(event: Event) { @@ -418,7 +441,9 @@ export class CodePanelComponent implements OnChanges{ }); } - scrollToNode(nodeIdHashed: string | undefined = undefined, nodeId: string | undefined = undefined) { + async scrollToNode( + nodeIdHashed: string | undefined = undefined, nodeId: string | undefined = undefined, + highlightRow: boolean = true, updateQueryParams: boolean = true): Promise { let index = 0; let scrollIndex : number | undefined = undefined; let indexesHighlighted : number[] = []; @@ -429,7 +454,11 @@ export class CodePanelComponent implements OnChanges{ if ((nodeIdHashed && this.codePanelRowData[index].nodeIdHashed === nodeIdHashed) || (nodeId && this.codePanelRowData[index].nodeId === nodeId)) { nodeIdHashed = this.codePanelRowData[index].nodeIdHashed; this.codePanelRowData[index].rowClasses = this.codePanelRowData[index].rowClasses || new Set(); - this.codePanelRowData[index].rowClasses.add('active'); + + if (highlightRow) { + this.codePanelRowData[index].rowClasses.add('active'); + } + indexesHighlighted.push(index); if (!scrollIndex) { scrollIndex = index; @@ -438,18 +467,34 @@ export class CodePanelComponent implements OnChanges{ index++; } if (scrollIndex) { - let scrollPadding = 0; - scrollPadding = (this.showNoDiffInContentMessage()) ? scrollPadding + 2 : scrollPadding; + scrollIndex = Math.max(scrollIndex - 4, 0); + + if (scrollIndex < this.codePanelRowSource?.adapter?.bufferInfo.firstIndex! || + scrollIndex > this.codePanelRowSource?.adapter?.bufferInfo.lastIndex! + ) { + await this.codePanelRowSource?.adapter?.reload(scrollIndex); + } else { + await this.codePanelRowSource?.adapter?.fix({ + scrollToItem: (item) => item.data.nodeIdHashed === nodeIdHashed, + scrollToItemOpt: { behavior: 'smooth', block: 'center' } + }); + } - this.codePanelRowSource?.adapter?.reload(scrollIndex - scrollPadding); let newQueryParams = getQueryParams(this.route); - newQueryParams[SCROLL_TO_NODE_QUERY_PARAM] = this.codePanelRowData[scrollIndex].nodeId; + if (updateQueryParams) { + newQueryParams[SCROLL_TO_NODE_QUERY_PARAM] = this.codePanelRowData[scrollIndex].nodeId; + } else { + newQueryParams[SCROLL_TO_NODE_QUERY_PARAM] = null; + } this.router.navigate([], { queryParams: newQueryParams, state: { skipStateUpdate: true } }); - setTimeout(() => { - indexesHighlighted.forEach((index) => { - this.codePanelRowData[index].rowClasses?.delete('active'); - }); - }, 1550); + + if (highlightRow) { + setTimeout(() => { + indexesHighlighted.forEach((index) => { + this.codePanelRowData[index].rowClasses?.delete('active'); + }); + }, 1550); + } } } @@ -652,6 +697,115 @@ export class CodePanelComponent implements OnChanges{ return this.codePanelData && !this.isLoading && this.isDiffView && !this.codePanelData?.hasDiff } + async searchCodePanelRowData(searchText: string) { + this.searchMatchedRowInfo.clear(); + if (!searchText || searchText.length === 0) { + this.clearSearchMatchHighlights(); + this.codeLineSearchMatchInfo = undefined; + this.currentCodeLineSearchMatch = undefined; + this.codeLineSearchInfo = undefined; + this.codeLineSearchInfoEmitter.emit(this.codeLineSearchInfo); + return; + } + + let hasMatch = false; + this.codeLineSearchMatchInfo = new DoublyLinkedList(); + + this.codePanelRowData.forEach((row, idx) => { + if (row.rowOfTokens && row.rowOfTokens.length > 0) { + let codeLineAsString = convertRowOfTokensToString(row.rowOfTokens); + const regex = new RegExp(searchText, "gi"); + const matches = [...codeLineAsString.matchAll(regex)]; + if (matches.length > 0) { + hasMatch = true; + this.searchMatchedRowInfo.set(row.nodeIdHashed!, matches); + matches.forEach((match, index) => { + const searchMatch = new CodeLineSearchMatch(idx, row.nodeIdHashed!, index); + this.codeLineSearchMatchInfo!.append(searchMatch); + }); + } + } + }); + + let currentMatch = 0; + let totalMatchCount = 0; + + if (hasMatch) { + this.currentCodeLineSearchMatch = this.codeLineSearchMatchInfo.head; + + if (this.currentCodeLineSearchMatch?.value.rowIndex! < this.codePanelRowSource?.adapter?.firstVisible.$index! || + this.currentCodeLineSearchMatch?.value.rowIndex! > this.codePanelRowSource?.adapter?.lastVisible.$index!) { + // Scroll first match into view + await this.scrollToNode(this.currentCodeLineSearchMatch!.value.nodeIdHashed, undefined, false, false); + await this.codePanelRowSource?.adapter?.relax(); + } + + currentMatch = this.currentCodeLineSearchMatch!.index + 1; + totalMatchCount = this.codeLineSearchMatchInfo.length; + this.highlightSearchMatches(); + this.highlightActiveSearchMatch(); + } else { + this.clearSearchMatchHighlights(); + this.codeLineSearchMatchInfo = undefined; + this.currentCodeLineSearchMatch = undefined; + } + + this.codeLineSearchInfo = { + currentMatch: currentMatch, + totalMatchCount: totalMatchCount + }; + this.codeLineSearchInfoEmitter.emit(this.codeLineSearchInfo); + } + + async highlightSearchMatches() { + this.clearSearchMatchHighlights(); + const codeLines = this.elementRef.nativeElement.querySelectorAll('.code-line'); + + codeLines.forEach((codeLine) => { + const nodeIdhashed = codeLine.getAttribute('data-node-id'); + if (nodeIdhashed && this.searchMatchedRowInfo.has(nodeIdhashed)) { + const tokens = codeLine.querySelectorAll('.code-line-content > span'); + const matches = this.searchMatchedRowInfo.get(nodeIdhashed)!; + + matches.forEach((match, index) => { + const matchStartIndex = match.index!; + const matchEndIndex = matchStartIndex + match[0].length; + + let currentOffset = 0; + tokens.forEach((token) => { + const tokenText = token.textContent || ''; + const tokenLength = tokenText.length; + + const tokenStart = currentOffset; + const tokenEnd = currentOffset + tokenLength; + + if (matchStartIndex < tokenEnd && matchEndIndex > tokenStart) { + const highlightStart = Math.max(0, matchStartIndex - tokenStart); + const highlightEnd = Math.min(tokenLength, matchEndIndex - tokenStart); + + const beforeMatch = tokenText.slice(0, highlightStart); + const matchText = tokenText.slice(highlightStart, highlightEnd); + const afterMatch = tokenText.slice(highlightEnd); + + token.innerHTML = `${beforeMatch}${matchText}${afterMatch}`; + } + + currentOffset += tokenLength; + }); + }); + } + }); + } + + async clearSearchMatchHighlights() { + this.elementRef.nativeElement.querySelectorAll('.codeline-search-match-highlight').forEach((element) => { + const parent = element.parentNode as HTMLElement; + if (parent) { + parent.innerHTML = parent.textContent || ''; + } + }); + } + private getCodeLineIndex(event: MenuItemCommandEvent) { const target = (event.originalEvent?.target as Element).closest("span") as Element; return target.getAttribute('data-item-id'); @@ -678,6 +832,62 @@ export class CodePanelComponent implements OnChanges{ navigator.clipboard.writeText(codeLineText); } + private highlightActiveSearchMatch(scrollIntoView: boolean = true) { + if (this.currentCodeLineSearchMatch) { + const nodeIdHashed = this.currentCodeLineSearchMatch.value.nodeIdHashed; + const matchId = this.currentCodeLineSearchMatch.value.matchId; + + const activeMatch = this.elementRef.nativeElement.querySelector('.codeline-search-match-highlight.active'); + if (activeMatch) { + activeMatch.classList.remove('active'); + } + const codeLine = this.elementRef.nativeElement.querySelector(`.code-line[data-node-id="${nodeIdHashed}"]`); + if (codeLine) { + const match = codeLine.querySelector(`.search-match-${matchId}`) as HTMLElement; + if (match) { + setTimeout(() => { + //this.elementRef.nativeElement.querySelector(`.code-line[data-node-id="${nodeIdHashed}"] .codeline-search-match-highlight.search-match-${matchId}`)!.classList.add('active'); + match.classList.add('active'); + if (scrollIntoView) { + match.scrollIntoView({ behavior: 'smooth', inline: 'center' }); + } + }, 0); + } + } + } + } + + /** + * Navigates to the next or previous code line that contains a search match but is outside the viewport + */ + private navigateToCodeLineWithSearchMatch(previousPosition: number, newPosition: number) { + if (this.currentCodeLineSearchMatch) { + const firstVisibleIndex = this.codePanelRowSource?.adapter?.firstVisible.$index!; + const lastVisibleIndex = this.codePanelRowSource?.adapter?.lastVisible.$index!; + let currentMatch = this.codeLineSearchInfo?.currentMatch!; + + if (!previousPosition || newPosition > previousPosition) { + this.currentCodeLineSearchMatch = this.currentCodeLineSearchMatch?.next!; + currentMatch++; + } else if (newPosition < previousPosition) { + this.currentCodeLineSearchMatch = this.currentCodeLineSearchMatch?.prev!; + currentMatch--; + } + + if (this.currentCodeLineSearchMatch && (this.currentCodeLineSearchMatch.value.rowIndex < firstVisibleIndex || this.currentCodeLineSearchMatch.value.rowIndex > lastVisibleIndex)) { + this.scrollToNode(this.currentCodeLineSearchMatch.value.nodeIdHashed, undefined, false, false); + this.codePanelRowSource?.adapter?.relax(); + } + + this.highlightActiveSearchMatch(); + this.codeLineSearchInfo = { + currentMatch: currentMatch, + totalMatchCount: this.codeLineSearchInfo?.totalMatchCount + }; + this.codeLineSearchInfoEmitter.emit(this.codeLineSearchInfo); + } + } + private findNextCommentThread (index: number) : CodePanelRowData | undefined { while (index < this.codePanelRowData.length) { if (this.codePanelRowData[index].type === CodePanelRowDatatype.CommentThread && !this.codePanelRowData![index].isResolvedCommentThread) { @@ -743,15 +953,23 @@ export class CodePanelComponent implements OnChanges{ this.hasActiveConversationEmitter.emit(hasActiveConversation); } - private loadCodePanelViewPort() { + private loadCodePanelViewPort() { this.setMaxLineNumberWidth(); this.initializeDataSource().then(() => { this.codePanelRowSource?.adapter?.init$.pipe(take(1)).subscribe(() => { this.isLoading = false; setTimeout(() => { this.scrollToNode(undefined, this.scrollToNodeId); + const viewport = this.elementRef.nativeElement.ownerDocument.getElementById('viewport'); + if (viewport) { + viewport.addEventListener('scroll', (event) => { + if (this.currentCodeLineSearchMatch) { + this.highlightSearchMatches(); + this.highlightActiveSearchMatch(false); + } + }); + } }, 500); - }); }).catch((error) => { console.error(error); @@ -841,4 +1059,4 @@ export class CodePanelComponent implements OnChanges{ this.destroy$.next(); this.destroy$.complete(); } -} +} \ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.html b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.html index 316f016810c..a94db26d439 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.html +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.html @@ -67,8 +67,22 @@ - +
    +
  • + + + + +
    + {{ codeLineSearchInfo.currentMatch }} of {{ codeLineSearchInfo.totalMatchCount }} + + + + + +
    +
  • diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.scss b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.scss index 473ca19f026..05e069420af 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.scss +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.scss @@ -66,4 +66,13 @@ color: yellow; } } + + .review-search-icon { + padding-inline-start: 0.2rem; + padding-inline-end: 0.2rem; + } + + .review-search-info { + padding-top: 0.55rem; + } } \ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.ts index 4b9e13ea485..d23297bdc8f 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.ts @@ -7,18 +7,19 @@ import { Review } from 'src/app/_models/review'; import { APIRevision } from 'src/app/_models/revision'; import { ConfigService } from 'src/app/_services/config/config.service'; import { APIRevisionsService } from 'src/app/_services/revisions/revisions.service'; -import { take } from 'rxjs'; +import { debounceTime, distinctUntilChanged, Subject, take, takeUntil } from 'rxjs'; import { UserProfile } from 'src/app/_models/userProfile'; import { PullRequestsService } from 'src/app/_services/pull-requests/pull-requests.service'; import { PullRequestModel } from 'src/app/_models/pullRequestModel'; -import { MenuItemCommandEvent } from 'primeng/api'; +import { FormControl } from '@angular/forms'; +import { CodeLineSearchInfo } from 'src/app/_models/codeLineSearchInfo'; @Component({ selector: 'app-review-page-options', templateUrl: './review-page-options.component.html', styleUrls: ['./review-page-options.component.scss'] }) -export class ReviewPageOptionsComponent implements OnInit, OnChanges{ +export class ReviewPageOptionsComponent implements OnInit, OnChanges { @Input() userProfile: UserProfile | undefined; @Input() isDiffView: boolean = false; @Input() contentHasDiff: boolean | undefined = false; @@ -31,7 +32,8 @@ export class ReviewPageOptionsComponent implements OnInit, OnChanges{ @Input() hasActiveConversation : boolean = false; @Input() hasHiddenAPIs : boolean = false; @Input() hasHiddenAPIThatIsDiff : boolean = false; - + @Input() codeLineSearchInfo : CodeLineSearchInfo | undefined = undefined; + @Output() diffStyleEmitter : EventEmitter = new EventEmitter(); @Output() showCommentsEmitter : EventEmitter = new EventEmitter(); @Output() showSystemCommentsEmitter : EventEmitter = new EventEmitter(); @@ -47,7 +49,11 @@ export class ReviewPageOptionsComponent implements OnInit, OnChanges{ @Output() commentThreadNavaigationEmitter : EventEmitter = new EventEmitter(); @Output() diffNavaigationEmitter : EventEmitter = new EventEmitter(); @Output() copyReviewTextEmitter : EventEmitter = new EventEmitter(); + @Output() codeLineSearchTextEmitter : EventEmitter = new EventEmitter(); + @Output() codeLineSearchNaviationEmmiter : EventEmitter = new EventEmitter(); + private destroy$ = new Subject(); + webAppUrl : string = this.configService.webAppUrl showCommentsSwitch : boolean = true; @@ -75,6 +81,8 @@ export class ReviewPageOptionsComponent implements OnInit, OnChanges{ reviewApprover: string = 'azure-sdk'; copyReviewTextButtonText : string = 'Copy review text'; + codeLineSearchText: FormControl = new FormControl(''); + associatedPullRequests : PullRequestModel[] = []; pullRequestsOfAssociatedAPIRevisions : PullRequestModel[] = []; CodeLineRowNavigationDirection = CodeLineRowNavigationDirection; @@ -110,6 +118,14 @@ export class ReviewPageOptionsComponent implements OnInit, OnChanges{ this.activeAPIRevision?.assignedReviewers.map(revision => this.selectedApprovers.push(revision.assingedTo)); this.setAPIRevisionApprovalStates(); this.setReviewApprovalStatus(); + + this.codeLineSearchText.valueChanges.pipe( + debounceTime(500), + distinctUntilChanged(), + takeUntil(this.destroy$) + ).subscribe((searchText: string) => { + this.codeLineSearchTextEmitter.emit(searchText); + }); } ngOnChanges(changes: SimpleChanges) { @@ -234,7 +250,6 @@ export class ReviewPageOptionsComponent implements OnInit, OnChanges{ this.showHiddenAPIEmitter.emit(event.checked); } - handleAssignedReviewersChange() { const existingApprovers = new Set(this.activeAPIRevision!.assignedReviewers.map(reviewer => reviewer.assingedTo)); @@ -346,6 +361,25 @@ export class ReviewPageOptionsComponent implements OnInit, OnChanges{ this.copyReviewTextEmitter.emit(true); } + clearReviewSearch() { + this.codeLineSearchText.setValue(''); + } + + navigateCommentThread(direction: CodeLineRowNavigationDirection) { + this.commentThreadNavaigationEmitter.emit(direction); + } + + /** + * Use positive number to navigate to the next search result and negative number to navigate to the previous search result + * @param number + */ + navigateSearch(number: 1 | -1) { + const navigationPosition = this.codeLineSearchInfo?.currentMatch! + number; + if (navigationPosition >= 1 && navigationPosition <= this.codeLineSearchInfo?.totalMatchCount!) { + this.codeLineSearchNaviationEmmiter.emit(navigationPosition!); + } + } + handleAPIRevisionApprovalAction() { if (!this.activeAPIRevisionIsApprovedByCurrentUser && (this.hasActiveConversation || this.hasFatalDiagnostics)) { this.showAPIRevisionApprovalModal = true; @@ -374,4 +408,10 @@ export class ReviewPageOptionsComponent implements OnInit, OnChanges{ let newQueryParams = getQueryParams(this.route); // this automatically excludes the nId query parameter this.router.navigate([], { queryParams: newQueryParams, state: { skipStateUpdate: true } }); } + + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.html b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.html index dce70bcb73d..2edb967ffe8 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.html +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.html @@ -29,7 +29,10 @@ [loadFailed]="loadFailed" [showLineNumbers]="showLineNumbers" [scrollToNodeIdHashed]="scrollToNodeIdHashed" [scrollToNodeId]="scrollToNodeId" - (hasActiveConversationEmitter)="handleHasActiveConversationEmitter($event)"> + [codeLineSearchText]="codeLineSearchText" + [codeLineNavigationDirection]="codeLineNavigationDirection" + (hasActiveConversationEmitter)="handleHasActiveConversationEmitter($event)" + (codeLineSearchInfoEmitter)="handleCodeLineSearchInfoEmitter($event)">
    @@ -46,6 +49,7 @@ [hasFatalDiagnostics]="hasFatalDiagnostics" [hasActiveConversation]="hasActiveConversation" [hasHiddenAPIs]="hasHiddenAPIs" + [codeLineSearchInfo]="codeLineSearchInfo" (showSystemCommentsEmitter)="handleShowSystemCommentsEmitter($event)" (showDocumentationEmitter)="handleShowDocumentationEmitter($event)" (showCommentsEmitter)="handleShowCommentsEmitter($event)" @@ -60,7 +64,9 @@ (disableCodeLinesLazyLoadingEmitter)="handleDisableCodeLinesLazyLoadingEmitter($event)" (commentThreadNavaigationEmitter)="handleCommentThreadNavaigationEmitter($event)" (diffNavaigationEmitter)="handleDiffNavaigationEmitter($event)" - (copyReviewTextEmitter)="handleCopyReviewTextEmitter($event)"> + (copyReviewTextEmitter)="handleCopyReviewTextEmitter($event)" + (codeLineSearchTextEmitter)="handleCodeLineSearchTextEmitter($event)" + (codeLineSearchNaviationEmmiter)="handleCodeLineSearchNavigationEmitter($event)">
diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts index 363a5150870..37ae132a06e 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts @@ -20,6 +20,7 @@ import { CommentItemModel, CommentType } from 'src/app/_models/commentItemModel' import { SignalRService } from 'src/app/_services/signal-r/signal-r.service'; import { SamplesRevisionService } from 'src/app/_services/samples/samples.service'; import { SamplesRevision } from 'src/app/_models/samples'; +import { CodeLineSearchInfo } from 'src/app/_models/codeLineSearchInfo'; @Component({ selector: 'app-review-page', @@ -52,6 +53,7 @@ export class ReviewPageComponent implements OnInit { preferredApprovers : string[] = []; hasFatalDiagnostics : boolean = false; hasActiveConversation : boolean = false; + codeLineSearchInfo : CodeLineSearchInfo | undefined = new CodeLineSearchInfo(); numberOfActiveConversation : number = 0; hasHiddenAPIs : boolean = false; hasHiddenAPIThatIsDiff : boolean = false; @@ -69,6 +71,9 @@ export class ReviewPageComponent implements OnInit { apiRevisionPageSize = 50; lastNodeIdUnhashedDiscarded = ''; + codeLineSearchText: string | undefined = undefined; + codeLineNavigationDirection: number | undefined = undefined; + private destroy$ = new Subject(); private destroyLoadAPIRevision$ : Subject | null = null; private destroyApiTreeBuilder$ : Subject | null = null; @@ -481,11 +486,23 @@ export class ReviewPageComponent implements OnInit { handleCopyReviewTextEmitter(event: boolean) { this.codePanelComponent.copyReviewTextToClipBoard(); } + + handleCodeLineSearchTextEmitter(searchText: string) { + this.codeLineSearchText = searchText; + } + + handleCodeLineSearchNavigationEmitter(direction: number) { + this.codeLineNavigationDirection = direction; + } handleHasActiveConversationEmitter(value: boolean) { this.hasActiveConversation = value; } + handleCodeLineSearchInfoEmitter(value: CodeLineSearchInfo) { + this.codeLineSearchInfo = value; + } + handleNumberOfActiveThreadsEmitter(value: number) { this.numberOfActiveConversation = value; this.createSideMenu(); diff --git a/src/dotnet/APIView/ClientSPA/src/app/_helpers/doubly-linkedlist.ts b/src/dotnet/APIView/ClientSPA/src/app/_helpers/doubly-linkedlist.ts new file mode 100644 index 00000000000..72935dafad5 --- /dev/null +++ b/src/dotnet/APIView/ClientSPA/src/app/_helpers/doubly-linkedlist.ts @@ -0,0 +1,32 @@ +export class DoublyLinkedList { + head: DoublyLinkedListNode | undefined = undefined; + tail: DoublyLinkedListNode | undefined = undefined; + length: number = 0; + + append(value: T): void { + const newNode = new DoublyLinkedListNode(value, this.length); + if (!this.head) { + this.head = newNode; + this.tail = newNode; + } else { + if (this.tail) { + this.tail.next = newNode; + newNode.prev = this.tail; + this.tail = newNode; + } + } + this.length++; + } +} + +export class DoublyLinkedListNode { + value: T; + next: DoublyLinkedListNode | undefined = undefined; + prev: DoublyLinkedListNode | undefined = undefined; + index: number; + + constructor(value: T, index: number) { + this.value = value; + this.index = index; + } +} \ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_models/codeLineSearchInfo.ts b/src/dotnet/APIView/ClientSPA/src/app/_models/codeLineSearchInfo.ts new file mode 100644 index 00000000000..55bb7405af4 --- /dev/null +++ b/src/dotnet/APIView/ClientSPA/src/app/_models/codeLineSearchInfo.ts @@ -0,0 +1,16 @@ +export class CodeLineSearchInfo { + currentMatch?: number; + totalMatchCount?: number +} + +export class CodeLineSearchMatch { + rowIndex: number; + nodeIdHashed: string; + matchId: number; + + constructor(rowIndex: number, nodeIdHashed: string, matchId: number) { + this.rowIndex = rowIndex; + this.nodeIdHashed = nodeIdHashed; + this.matchId = matchId; + } +} \ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/review-page-layout.module.ts b/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/review-page-layout.module.ts index c8bdaf51a03..91ce6382240 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/review-page-layout.module.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/review-page-layout.module.ts @@ -5,7 +5,6 @@ import { CommentThreadComponent } from 'src/app/_components/shared/comment-threa import { ReviewPageLayoutComponent } from 'src/app/_components/shared/review-page-layout/review-page-layout.component'; import { MarkdownToHtmlPipe } from 'src/app/_pipes/markdown-to-html.pipe'; import { EditorComponent } from 'src/app/_components/shared/editor/editor.component'; -import { EditorModule } from 'primeng/editor'; import { PanelModule } from 'primeng/panel'; import { MenuModule } from 'primeng/menu'; import { TimelineModule } from 'primeng/timeline'; diff --git a/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/shared-app.module.ts b/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/shared-app.module.ts index 1194015d03a..316bc55314e 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/shared-app.module.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/shared-app.module.ts @@ -13,6 +13,10 @@ import { DropdownModule } from 'primeng/dropdown'; import { MenubarModule } from 'primeng/menubar'; import { MultiSelectModule } from 'primeng/multiselect'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputGroupAddonModule } from 'primeng/inputgroupaddon'; import { SplitterModule } from 'primeng/splitter'; import { SidebarModule } from 'primeng/sidebar'; import { TimeagoModule } from 'ngx-timeago'; @@ -55,6 +59,8 @@ const monacoEditorConfig: NgxMonacoEditorConfig = { MessagesModule, MultiSelectModule, FormsModule, + IconFieldModule, + InputIconModule, FileUploadModule, ReactiveFormsModule, SelectButtonModule, @@ -76,6 +82,8 @@ const monacoEditorConfig: NgxMonacoEditorConfig = { MessagesModule, MultiSelectModule, FormsModule, + IconFieldModule, + InputIconModule, FileUploadModule, ReactiveFormsModule, SelectButtonModule, diff --git a/src/dotnet/APIView/ClientSPA/src/ng-prime-overrides.scss b/src/dotnet/APIView/ClientSPA/src/ng-prime-overrides.scss index 7c396a6d7a7..6748a2dd8bd 100644 --- a/src/dotnet/APIView/ClientSPA/src/ng-prime-overrides.scss +++ b/src/dotnet/APIView/ClientSPA/src/ng-prime-overrides.scss @@ -529,6 +529,14 @@ p-contextmenusub { color: var(--base-text-color); } +.p-inputgroup-addon { + background: var(--base-fg-color); + color: var(--base-text-color); + border-top: 1px solid var(--border-color); + border-left: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); +} + .p-inputswitch { width: 2.5rem; height: 1.5rem;