Repository navigation
Suggest mode 4/9: inline marker primitive #80430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
63 commits
Select commit
Hold shift + click to select a range
75b01dd
Suggest mode 4/7: inline marker primitive
adamsilverstein b079a31
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein c2e863d
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 086fe9e
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 4e09140
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein d794763
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein b990210
Drop dependency-group comment blocks flagged by the new lint rule
adamsilverstein 15002f4
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 46878ea
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein bbe9009
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein a1523ca
Convert inline-markers primitive to TypeScript
adamsilverstein 6791175
Merge remote-tracking branch 'origin/trunk' into suggest/inline-markers
adamsilverstein 23b2454
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 27595d2
Merge remote-tracking branch 'origin/trunk' into suggest/inline-markers
adamsilverstein ff2dcd6
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein fa9f079
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein bf88272
Merge branch 'relocate/block-capture' into relocate/inline-markers
adamsilverstein ea4ab5d
Move the marker-selector helper into the inline marker layer
adamsilverstein 2eb3eac
Merge suggest/block-capture into suggest/inline-markers
adamsilverstein 5ca7f73
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 0da5cfa
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 06c7877
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 3090e1c
Name DOM unit tests for the jsdom Jest project
adamsilverstein d2d853a
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 8d19637
Inline markers: quote only owned characters and reject cross-attribut…
adamsilverstein a634d7f
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 1d73afa
Run the inline marker layer's unit tests under Vitest
adamsilverstein 27d33e1
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 76c093a
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 0d2ed93
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 0cc4a22
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein fec8dc6
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 9bff1db
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein d69336a
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 0681cf7
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein c2f35ab
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein ffb99ad
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein 2c92b5a
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein c85b895
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein 25bea9c
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein 2f0c8ca
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein 6b42ec8
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein eded596
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein da05506
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein 4470360
Inline markers: Read RichTextData records without an HTML round trip
adamsilverstein cf75ec5
Inline markers: Batch annotation writes for a range set
adamsilverstein 51a5b6b
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein 6aba744
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein 12f771f
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein bedb054
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein 49c6563
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein e8e5f48
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein f5ac338
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 501f3c6
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein f336ae2
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 96a9893
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein 796c0e3
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 56d22a5
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein d9e2b6f
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein f624d85
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein bde4bc7
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein b931b47
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein 8bf89ae
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
195 changes: 195 additions & 0 deletions
195
packages/editor/src/components/inline-markers/find-marker-range.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| import { create, RichTextData } from '@wordpress/rich-text'; | ||
|
|
||
| /** | ||
| * Read a block attribute value as a rich-text record. A plain string is | ||
| * parsed, after the cheap `quickReject` substring check. Shared | ||
| * by `findMarkerRange` and `findMarkerText` so both resolve offsets the same | ||
| * way from a single code path. | ||
| * | ||
| * @param value Block attribute value (RichTextData, string, or other). | ||
| * @param options Options. | ||
| * @param options.id Marker id (only its presence is checked here). | ||
| * @param options.quickReject Optional substring used to skip parsing when absent. | ||
| * @return Rich-text record, or null when there is nothing to search. | ||
| */ | ||
| function parseMarkerValue( | ||
| value: any, | ||
| { | ||
| id, | ||
| quickReject, | ||
| }: { id: number | string | null | undefined; quickReject?: string } | ||
| ) { | ||
| if ( id === undefined || id === null ) { | ||
| return null; | ||
| } | ||
| // `RichTextData` already holds a parsed record; serializing it only to | ||
| // parse it again cost two full passes per lookup. | ||
| if ( value instanceof RichTextData ) { | ||
| return { formats: value.formats, text: value.text }; | ||
| } | ||
| const html = typeof value === 'string' ? value : null; | ||
| if ( ! html ) { | ||
| return null; | ||
| } | ||
| // Cheap reject before the (relatively costly) rich-text parse. | ||
| if ( quickReject && html.indexOf( quickReject ) === -1 ) { | ||
| return null; | ||
| } | ||
| return create( { html } ); | ||
| } | ||
|
|
||
| /** | ||
| * Whether a character's format stack carries the marker with the given id. | ||
| * | ||
| * @param stack Formats applied to one character. | ||
| * @param formatType Rich-text format type to match. | ||
| * @param idAttribute Marker attribute holding the id. | ||
| * @param target Marker id, as a string. | ||
| * @return True when the stack holds the marker. | ||
| */ | ||
| function carriesId( | ||
| stack: any[] | undefined, | ||
| formatType: string, | ||
| idAttribute: string, | ||
| target: string | ||
| ): boolean { | ||
| return !! stack?.some( | ||
| ( f: any ) => | ||
| f.type === formatType && | ||
| f.attributes && | ||
| f.attributes[ idAttribute ] === target | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Find the character range of the marker matching `id` within an already-parsed | ||
| * rich-text record. | ||
| * | ||
| * The range spans from the first to the last character carrying the id, so a | ||
| * marker that rich-text split into non-contiguous runs for the same id (an edit | ||
| * inside the run, a nested-format grow, a serialization quirk) still resolves as | ||
| * one range. Returning only the first contiguous run — as an earlier version did | ||
| * — truncated accept/reject to a fragment of the marker. The gap between | ||
| * fragments may hold unmarked text or another marker, so consumers that act on | ||
| * characters rather than on the span (`findMarkerText`, `removeMarkedRange` in | ||
| * inline-suggestions) test each character with `carriesId`. | ||
| * | ||
| * @param record Rich-text record. | ||
| * @param formatType Rich-text format type to match. | ||
| * @param idAttribute Marker attribute holding the id. | ||
| * @param id Marker id to search for. | ||
| * @return Range or null when no marker is found. | ||
| */ | ||
| function rangeInRecord( | ||
| record: any, | ||
| formatType: string, | ||
| idAttribute: string, | ||
| id: number | string | ||
| ): { start: number; end: number } | null { | ||
| const target = String( id ); | ||
| const formats = record.formats; | ||
| let start = -1; | ||
| let end = -1; | ||
| for ( let i = 0; i < formats.length; i++ ) { | ||
| if ( carriesId( formats[ i ], formatType, idAttribute, target ) ) { | ||
| if ( start === -1 ) { | ||
| start = i; | ||
| } | ||
| end = i + 1; | ||
| } | ||
| } | ||
| if ( start === -1 ) { | ||
| return null; | ||
| } | ||
| return { start, end }; | ||
| } | ||
|
|
||
| /** | ||
| * Search a rich-text value for an inline marker (`<mark>` format) matching a | ||
| * given id and return its character range. | ||
| * | ||
| * This is the single place inline-marker offsets are resolved: positions are | ||
| * derived from the in-content marker on every read rather than stored, so a | ||
| * marker survives unrelated edits elsewhere in the same attribute. It is the | ||
| * intended swap point for a future CRDT-backed resolver. | ||
| * | ||
| * @param value Block attribute value (RichTextData, string, or other). | ||
| * @param options Options. | ||
| * @param options.formatType Rich-text format type to match (e.g. `core/note`). | ||
| * @param options.idAttribute Marker attribute holding the id. | ||
| * @param options.id Marker id to search for. | ||
| * @param options.quickReject Optional substring (e.g. the marker class) used to | ||
| * skip parsing when it is absent from the HTML. | ||
| * @return Range or null when no marker is found. | ||
| */ | ||
| export function findMarkerRange( | ||
| value: any, | ||
| { | ||
| formatType, | ||
| idAttribute = 'data-id', | ||
| id, | ||
| quickReject, | ||
| }: { | ||
| formatType: string; | ||
| idAttribute?: string; | ||
| id: number | string | null | undefined; | ||
| quickReject?: string; | ||
| } | ||
| ) { | ||
| const record = parseMarkerValue( value, { id, quickReject } ); | ||
| if ( ! record ) { | ||
| return null; | ||
| } | ||
| return rangeInRecord( record, formatType, idAttribute, id! ); | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the visible text wrapped by the marker matching `id`. Companion to | ||
| * `findMarkerRange`: where that returns offsets, this returns the marked text | ||
| * itself (e.g. for a sidebar summary of what a suggestion adds or removes). | ||
| * Returns an empty string when the marker can no longer be found. | ||
| * | ||
| * @param value Block attribute value (RichTextData, string, or other). | ||
| * @param options Options. | ||
| * @param options.formatType Rich-text format type to match. | ||
| * @param options.idAttribute Marker attribute holding the id. | ||
| * @param options.id Marker id to search for. | ||
| * @param options.quickReject Optional substring used to skip parsing when absent. | ||
| * @return The marked text, or '' when no marker is found. | ||
| */ | ||
| export function findMarkerText( | ||
| value: any, | ||
| { | ||
| formatType, | ||
| idAttribute = 'data-id', | ||
| id, | ||
| quickReject, | ||
| }: { | ||
| formatType: string; | ||
| idAttribute?: string; | ||
| id: number | string | null | undefined; | ||
| quickReject?: string; | ||
| } | ||
| ): string { | ||
| const record = parseMarkerValue( value, { id, quickReject } ); | ||
| if ( ! record ) { | ||
| return ''; | ||
| } | ||
| const range = rangeInRecord( record, formatType, idAttribute, id! ); | ||
| if ( ! range ) { | ||
| return ''; | ||
| } | ||
| // A fragmented marker's span can hold unmarked text or another marker's | ||
| // text; quote only the characters this marker owns, as accept and reject | ||
| // act on those. | ||
| const target = String( id ); | ||
| let text = ''; | ||
| for ( let i = range.start; i < range.end; i++ ) { | ||
| if ( | ||
| carriesId( record.formats[ i ], formatType, idAttribute, target ) | ||
| ) { | ||
| text += record.text[ i ]; | ||
| } | ||
| } | ||
| return text; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| /** | ||
| * Inline markers: a format-agnostic primitive for anchoring inline ranges to | ||
| * edit-surviving `<mark>` markers in block content. | ||
| * | ||
| * Positions are always derived from the in-content marker on read (never | ||
| * stored), so a marked range survives unrelated edits elsewhere in the same | ||
| * attribute. `findMarkerRange` is the single offset-resolution point and the | ||
| * intended swap point for a future CRDT-backed resolver. | ||
| * | ||
| * Consumed by Notes (`core/note`) and Suggestions (`core/suggestion`); each | ||
| * passes its own format type, id attribute, and annotation source so the two | ||
| * coexist on one block without colliding. | ||
| */ | ||
|
|
||
| export { findMarkerRange, findMarkerText } from './find-marker-range'; | ||
| export { getMarkerSelector } from './marker-selector'; | ||
| export { wrapInlineMarker } from './wrap-inline-marker'; | ||
| export { readInlineSelection } from './read-inline-selection'; | ||
| export { readInlineCaret } from './read-inline-caret'; | ||
| export { reconcileMarkerRemoval } from './reconcile-marker-removal'; | ||
| export { useAnnotateRanges } from './use-annotate-ranges'; |
37 changes: 37 additions & 0 deletions
37
packages/editor/src/components/inline-markers/marker-selector.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| /** | ||
| * Build the CSS selector that matches an in-content `<mark>` marker by its id. | ||
| * | ||
| * Both consumers of the inline-markers primitive serialize their marker as a | ||
| * `<mark>` carrying a class token and an id attribute (`wp-note` /`data-id` for | ||
| * Notes, `wp-suggestion` / `data-suggestion-id` for Suggestions), so resolving | ||
| * a marker element from the DOM — to tint it, to scroll to it, or to anchor a | ||
| * floating card to it — is the same operation for both. | ||
| * | ||
| * @param className Exact class token on the marker. | ||
| * @param idAttribute Attribute holding the marker id. | ||
| * @param id Marker id to match. | ||
| * @return Selector for the marker element(s). | ||
| */ | ||
| export function getMarkerSelector( | ||
| className: string, | ||
| idAttribute: string, | ||
| id: number | string | ||
| ): string { | ||
| /* | ||
| * The id is a server comment ID (always a positive integer), but the value | ||
| * composes a selector from stored data, so escape it defensively. | ||
| * | ||
| * Deliberately not `CSS.escape`: that escapes for *identifier* context, | ||
| * where a leading digit is illegal, so it renders the id 7 as `\37 `. That | ||
| * is valid, and matches, but it makes every generated rule unreadable. | ||
| * Inside a quoted attribute value the only characters that need escaping | ||
| * are the quote, the backslash, and raw line breaks (a parse error in a | ||
| * CSS string). | ||
| */ | ||
| const escapedId = String( id ).replace( /["\\\n\r\f]/g, ( char ) => | ||
| char === '"' || char === '\\' | ||
| ? `\\${ char }` | ||
| : `\\${ char.codePointAt( 0 )!.toString( 16 ) } ` | ||
| ); | ||
| return `mark.${ className }[${ idAttribute }="${ escapedId }"]`; | ||
| } |
40 changes: 40 additions & 0 deletions
40
packages/editor/src/components/inline-markers/read-inline-caret.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| /** | ||
| * Read the current caret (or selection) from block-editor selection state when | ||
| * it sits inside a single rich-text attribute, returning normalized anchor | ||
| * data. Unlike `readInlineSelection`, this accepts a *collapsed* caret | ||
| * (`start === end`) so callers driving insertion (typing) get a position even | ||
| * when nothing is selected. Returns null for block-level or cross-attribute | ||
| * selections. | ||
| * | ||
| * @param getSelectionStart Block-editor selector. | ||
| * @param getSelectionEnd Block-editor selector. | ||
| * @return `{ clientId, attributeKey, start, end }` or null. | ||
| */ | ||
| export function readInlineCaret( | ||
| getSelectionStart: () => any, | ||
| getSelectionEnd: () => any | ||
| ) { | ||
| const start = getSelectionStart(); | ||
| const end = getSelectionEnd(); | ||
| if ( | ||
| ! start?.clientId || | ||
| start.clientId !== end.clientId || | ||
| ! start.attributeKey || | ||
| start.attributeKey !== end.attributeKey || | ||
| start.offset === undefined || | ||
| end.offset === undefined | ||
| ) { | ||
| return null; | ||
| } | ||
| // Normalize direction so callers don't have to think about reversed ranges. | ||
| const [ startOffset, endOffset ] = | ||
| start.offset <= end.offset | ||
| ? [ start.offset, end.offset ] | ||
| : [ end.offset, start.offset ]; | ||
| return { | ||
| clientId: start.clientId, | ||
| attributeKey: start.attributeKey, | ||
| start: startOffset, | ||
| end: endOffset, | ||
| }; | ||
| } | ||
38 changes: 38 additions & 0 deletions
38
packages/editor/src/components/inline-markers/read-inline-selection.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| /** | ||
| * Read an inline selection from block-editor selection state, returning | ||
| * normalized anchor data when a non-collapsed selection sits inside a single | ||
| * rich-text attribute. Returns null for block-level or collapsed selections. | ||
| * | ||
| * @param getSelectionStart Block-editor selector. | ||
| * @param getSelectionEnd Block-editor selector. | ||
| * @return `{ clientId, attributeKey, start, end }` or null. | ||
| */ | ||
| export function readInlineSelection( | ||
| getSelectionStart: () => any, | ||
| getSelectionEnd: () => any | ||
| ) { | ||
| const start = getSelectionStart(); | ||
| const end = getSelectionEnd(); | ||
| if ( | ||
| ! start?.clientId || | ||
| start.clientId !== end.clientId || | ||
| ! start.attributeKey || | ||
|
adamsilverstein marked this conversation as resolved.
|
||
| start.attributeKey !== end.attributeKey || | ||
| start.offset === undefined || | ||
| end.offset === undefined || | ||
| start.offset === end.offset | ||
| ) { | ||
| return null; | ||
| } | ||
| // Normalize direction so callers don't have to think about reversed ranges. | ||
| const [ startOffset, endOffset ] = | ||
| start.offset < end.offset | ||
| ? [ start.offset, end.offset ] | ||
| : [ end.offset, start.offset ]; | ||
| return { | ||
| clientId: start.clientId, | ||
| attributeKey: start.attributeKey, | ||
| start: startOffset, | ||
| end: endOffset, | ||
| }; | ||
| } | ||
35 changes: 35 additions & 0 deletions
35
packages/editor/src/components/inline-markers/reconcile-marker-removal.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| /** | ||
| * Decide what to do with an anchored inline marker based on whether its | ||
| * in-content marker is still present. Pure so it can be unit-tested without | ||
| * React/stores. | ||
| * | ||
| * - `'anchor'`: the marker is present; the caller should record that it has | ||
| * been seen this session. | ||
| * - `'delete'`: the marker was seen earlier this session but is now gone (the | ||
| * user removed the marked text), so the linked record should be deleted. | ||
| * - `'skip'`: the marker can't be evaluated (not an inline marker, block not | ||
| * loaded yet) or is absent for a marker never observed this session (e.g. a | ||
| * legacy/never-anchored record), which keeps any fallback rather than being | ||
| * deleted. | ||
| * | ||
| * The session `Set` guard is what distinguishes a genuine removal (seen, now | ||
| * gone) from content that simply has not loaded its marker yet. | ||
| * | ||
| * @param markerPresent Whether the marker was found in content; null/undefined when undeterminable. | ||
| * @param id Stable marker id, used as the key in `anchored`. | ||
| * @param anchored Ids whose marker has been observed present this session. | ||
| * @return The action to take. | ||
| */ | ||
| export function reconcileMarkerRemoval( | ||
| markerPresent: boolean | null | undefined, | ||
| id: any, | ||
| anchored: Set< any > | ||
| ): 'anchor' | 'delete' | 'skip' { | ||
| if ( markerPresent === null || markerPresent === undefined ) { | ||
| return 'skip'; | ||
| } | ||
| if ( markerPresent ) { | ||
| return 'anchor'; | ||
| } | ||
| return anchored.has( id ) ? 'delete' : 'skip'; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.