Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Ent

Typing **#123** in a composer looks up that exact issue or pull request in the /dev/null repository, `dev-null-sh/dev-null.sh`. The matching title appears beneath the number; click it or use Enter or Tab to insert its GitHub URL, just like choosing an emoji suggestion. A space or punctuation leaves the number as typed and dismisses the suggestion. Missing numbers and failed lookups leave the text unchanged. Code, URL fragments, and references qualified with another repository are not expanded.

Sources: [body renderer](resources/js/components/commit-body.tsx), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).
A hex color such as `#ff8a4c` or `#fff` shows with a small swatch of the color, outside code. A color written only with digits counts when it has 6 or 8 digits or starts with a zero, which issue numbers here don't: `#000`, `#000000` and `#333333` are colors, while `#123` and `#333` stay plain text.

Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).

### Draft previews

Expand Down
87 changes: 81 additions & 6 deletions resources/js/components/linkified-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import AppWordmark from '@/components/app-wordmark';
import { CertificateText } from '@/components/certificate-text';
import { GitHubIcon } from '@/components/github-icon';
import { UserPreview } from '@/components/user-preview';
import { typedProse } from '@/lib/code';
import { typedProse, withoutCode } from '@/lib/code';
import type { Color } from '@/lib/colors';
import { colorsIn } from '@/lib/colors';
import { mentionPattern } from '@/lib/mentions';
import {
hrefFor,
Expand Down Expand Up @@ -257,19 +259,23 @@ function withPasteis(text: string, pasteisMode: boolean): ReactNode {
);
}

function withWordmarks(text: string, pasteisMode: boolean): ReactNode[] {
function withWordmarks(
text: string,
offset: number,
pasteisMode: boolean,
): ReactNode[] {
return text
.split(
/(?<![\p{L}\p{M}\p{N}_./-])(\/dev\/null)(?![\p{L}\p{M}\p{N}_/-])/u,
)
.map((part, index) =>
index % 2 === 0 ? (
<Fragment key={index}>
<Fragment key={`text-${offset}-${index}`}>
{withPasteis(part, pasteisMode)}
</Fragment>
) : (
<span
key={`wordmark-${index}`}
key={`wordmark-${offset}-${index}`}
title={part}
className="font-semibold tracking-tight whitespace-nowrap"
>
Expand All @@ -279,10 +285,60 @@ function withWordmarks(text: string, pasteisMode: boolean): ReactNode[] {
);
}

/**
* The hex colors found in the whole text that fall in this slice of it, as their value with a swatch of the color.
*/
function withColors(
text: string,
offset: number,
colors: Color[],
pasteisMode: boolean,
): ReactNode[] {
const parts: ReactNode[] = [];
let position = 0;

for (const color of colors) {
const index = color.index - offset;

if (index < position || index + color.length > text.length) {
continue;
}

parts.push(
...withWordmarks(
text.slice(position, index),
offset + position,
pasteisMode,
),
<span
key={`color-${color.index}`}
className="whitespace-nowrap"
data-test="color"
>
<span
aria-hidden="true"
className="mr-0.5 inline-block size-[0.75em] rounded-sm border border-border align-[-0.05em]"
style={{ backgroundColor: `#${color.value}` }}
/>
{text.slice(index, index + color.length)}
</span>,
);

position = index + color.length;
}

parts.push(
...withWordmarks(text.slice(position), offset + position, pasteisMode),
);

return parts;
}

function withMentions(
text: string,
offset: number,
mentions: Commit['mentions'],
colors: Color[],
pasteisMode: boolean,
isStatic: boolean,
): ReactNode[] {
Expand All @@ -297,7 +353,12 @@ function withMentions(
: null;

parts.push(
...withWordmarks(text.slice(position, match.index), pasteisMode),
...withColors(
text.slice(position, match.index),
offset + position,
colors,
pasteisMode,
),
isStatic || !userId ? (
match[0]
) : (
Expand All @@ -320,7 +381,14 @@ function withMentions(
position = match.index + match[0].length;
}

parts.push(...withWordmarks(text.slice(position), pasteisMode));
parts.push(
...withColors(
text.slice(position),
offset + position,
colors,
pasteisMode,
),
);

return parts;
}
Expand All @@ -330,13 +398,18 @@ export function LinkifiedText({
mentions,
pasteisMode = false,
isStatic = false,
startsText = false,
}: {
text: string;
mentions: Commit['mentions'];
pasteisMode?: boolean;
isStatic?: boolean;
// Whether the text is a whole body, so its start is the start of a line.
startsText?: boolean;
}) {
const parts: ReactNode[] = [];
// Colors are found in the prose only, for callers that pass a whole body, code included.
const colors = colorsIn(withoutCode(text), startsText);
let position = 0;

for (const match of text.matchAll(urlPattern)) {
Expand All @@ -355,6 +428,7 @@ export function LinkifiedText({
text.slice(position, match.index),
position,
mentions,
colors,
pasteisMode,
isStatic,
),
Expand Down Expand Up @@ -400,6 +474,7 @@ export function LinkifiedText({
text.slice(position),
position,
mentions,
colors,
pasteisMode,
isStatic,
),
Expand Down
13 changes: 13 additions & 0 deletions resources/js/lib/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ export function mapProse(
.join('');
}

/**
* The text with its committed code blanked out to spaces of the same length, so what is found in the prose keeps its
* place in the text.
*/
export function withoutCode(text: string): string {
return text
.split(codePattern)
.map((part, index) =>
index % 2 === 0 ? part : ' '.repeat(part.length),
)
.join('');
}

/**
* Applies the callback to the prose being typed only, leaving the code as written.
*/
Expand Down
30 changes: 30 additions & 0 deletions resources/js/lib/colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// A hex color like "#ff8a4c" or "#fff", which the web shows next to a swatch of it. It has 3, 4, 6 or 8 hex digits. One
// of 3 or 4 digits also needs a letter or a leading zero, so "#123" stays an issue reference while "#000" is black: issue
// numbers never start with a zero, and none are 6 or 8 digits long. It is a whole word like a hashtag: it starts at the start,
// after whitespace or after the ">" that starts a quoted line, and only punctuation may follow it before the next
// space, so neither "#fff-x" nor "#ff8a4c.com" is a color.
const colorPattern =
// oxlint-disable-next-line no-misleading-character-class
/(?:(?<!\S)|(?<=\n>))[#\uFF03]([0-9a-f]{8}|[0-9a-f]{6}|(?=[0-9]*[a-f]|0)[0-9a-f]{3,4})(?=\p{P}*(?:[\s\u00AD\u2060\u200A-\u200D\u20E2]|$))/giu;

export type Color = {
value: string;
index: number;
length: number;
};

/**
* The hex colors in a run of prose, where they are and how long they are with the "#". When the prose starts a whole
* body, its start is the start of a line, so a ">" there starts a quoted line.
*/
export function colorsIn(text: string, startsText = false): Color[] {
const start = startsText ? 1 : 0;

return [...(startsText ? `\n${text}` : text).matchAll(colorPattern)].map(
(match) => ({
value: match[1],
index: match.index - start,
length: match[0].length,
}),
);
}
14 changes: 12 additions & 2 deletions resources/js/lib/issue-reference.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { typedProse } from '@/lib/code';
import { typedProse, withoutCode } from '@/lib/code';
import { colorsIn } from '@/lib/colors';

export function issueReferenceBeforeCaret(text: string, caret: number) {
const prose = typedProse(text.slice(0, caret)).at(-1) ?? '';
Expand All @@ -12,8 +13,17 @@ export function issueReferenceBeforeCaret(text: string, caret: number) {
return null;
}

const start = caret - match[1].length - 1;

// A color like "#333333" is all digits too, but it is never an issue.
if (
colorsIn(withoutCode(text), true).some((color) => color.index === start)
) {
return null;
}

return {
start: caret - match[1].length - 1,
start,
query: match[1],
};
}
1 change: 1 addition & 0 deletions resources/js/pages/log/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ function Suggestions({ suggestions }: { suggestions: Commit[] }) {
<LinkifiedText
text={emojify(commit.body)}
mentions={commit.mentions}
startsText
/>
</p>
</div>
Expand Down
72 changes: 72 additions & 0 deletions tests/Unit/Colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { colorsIn } from '../../resources/js/lib/colors.ts';

const values = (text: string) => colorsIn(text).map((color) => color.value);

await test('finds hex colors and where they are', () => {
assert.deepEqual(
colorsIn(
'Brand orange is #ff8a4c, white is #fff, and #ff8a4c80 or #fffa',
),
[
{ value: 'ff8a4c', index: 16, length: 7 },
{ value: 'fff', index: 34, length: 4 },
{ value: 'ff8a4c80', index: 44, length: 9 },
{ value: 'fffa', index: 57, length: 5 },
],
);
});

await test('finds a hex color as a whole word, with punctuation after it left out', () => {
const cases: [string, string[]][] = [
['#FFF', ['FFF']],
['Is it #fff?!', ['fff']],
['(#ff8a4c)', []],
['Line\n>#fff', ['fff']],
['#ff8a4c', ['ff8a4c']],
['Tab\t#abc', ['abc']],
];

for (const [text, expected] of cases) {
assert.deepEqual(values(text), expected, text);
}
});

await test('ignores what only looks like a hex color', () => {
for (const text of [
'Issue #123',
'Gray #333 or #9999',
'#fffff and #fffffff',
'#ff8a4g',
'#fff-x',
'#ff8a4c.com',
'a#fff',
'C#fff',
'##fff',
'>>#fff',
]) {
assert.deepEqual(values(text), [], text);
}
});

await test('finds a hex color after the ">" that starts a whole body', () => {
assert.deepEqual(colorsIn('>#fff', true), [
{ value: 'fff', index: 1, length: 4 },
]);
assert.deepEqual(colorsIn('>#fff'), []);
assert.deepEqual(colorsIn('>>#fff', true), []);
});

await test('finds digit-only hex colors that cannot be issue numbers', () => {
assert.deepEqual(
values('#000, #0000, #000000, #00000000, #333333 and #99999999'),
['000', '0000', '000000', '00000000', '333333', '99999999'],
);
assert.deepEqual(values('#00000 and #0000000'), []);
});

await test('ends a hex color at a zero-width character, as a hashtag ends', () => {
assert.deepEqual(values('#fff\u200Bnext and #abc\u2060'), ['fff', 'abc']);
assert.deepEqual(values('#fff\u200Cx'), ['fff']);
});
25 changes: 25 additions & 0 deletions tests/Unit/IssueReference.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,28 @@ test('preserves offsets after code and non-ASCII text, and chooses the latest re
query: '456',
});
});

test('does not look up digit-only colors as issues', () => {
for (const [text, caret] of [
['#333333', 7],
['See #99999999', 13],
['Gray is #333333.', 15],
['#333333`code`', 7],
['#99999999`a b` done', 9],
] as const) {
assert.equal(issueReferenceBeforeCaret(text, caret), null, text);
}

for (const [text, caret, query] of [
['See #123', 8, '123'],
['See (#333333', 12, '333333'],
['#333333.com', 7, '333333'],
['#333333`code', 7, '333333'],
] as const) {
assert.equal(
issueReferenceBeforeCaret(text, caret)?.query,
query,
text,
);
}
});
Loading
Loading