main f6d8405

mention.ts RawHistoryFolder

1/**
2 * Mention Utilities
3 *
4 * Consolidated constants, regex patterns, and pure functions for @mention handling.
5 * Used by chat-room.svelte, text-formatter.ts, and formatted-message.svelte.
6 */
7
8// ============ CONSTANTS ============
9
10export const MENTION_USERNAME_MAX_LENGTH = 32;
11
12export const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/;
13
14export const MAX_MENTION_SUGGESTIONS = 6;
15
16export const MENTION_PICKER = {
17	ITEM_HEIGHT: 32,
18	PADDING: 12,
19	EMPTY_STATE_HEIGHT: 44,
20	HORIZONTAL_PADDING: 68,
21	MIN_WIDTH: 220,
22	OFFSET_GAP: 6,
23	EDGE_MARGIN: 8
24} as const;
25
26// ============ REGEX PATTERNS ============
27
28/**
29 * Regex for matching @mentions in plain text.
30 * Captures: $1 = prefix (space or start), $2 = username
31 */
32export const MENTION_REGEX_PLAIN = /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g;
33
34/**
35 * Regex for matching @mentions in HTML context (after sanitization).
36 * Handles cases where mentions appear after HTML tags like <br> or </p>.
37 * Captures: $1 = prefix (space, start, or >), $2 = username
38 */
39export const MENTION_REGEX_HTML = /(^|[\s>])@([a-zA-Z0-9_-]{1,32})(?=$|[\s<]|[^a-zA-Z0-9_-])/g;
40
41// ============ PURE UTILITY FUNCTIONS ============
42
43/**
44 * Checks if the given index is at the start of a word.
45 * A word starts at position 0 or after whitespace.
46 */
47export function isWordStart(value: string, index: number): boolean {
48	if (index <= 0) return true;
49	return /\s/.test(value[index - 1]);
50}
51
52/**
53 * Finds mention context at the cursor position.
54 * Returns the @ index and the partial query string, or null if no valid mention context.
55 */
56export function findMentionContext(
57	value: string,
58	cursorIndex: number
59): { atIndex: number; query: string } | null {
60	const beforeCursor = value.slice(0, cursorIndex);
61	const atIndex = beforeCursor.lastIndexOf('@');
62
63	if (atIndex === -1) return null;
64	if (!isWordStart(value, atIndex)) return null;
65
66	const query = value.slice(atIndex + 1, cursorIndex);
67
68	// Query cannot contain whitespace
69	if (query.includes(' ') || query.includes('\n') || query.includes('\t')) return null;
70
71	// Query cannot exceed max length
72	if (query.length > MENTION_USERNAME_MAX_LENGTH) return null;
73
74	// If there's a query, it must match the username pattern
75	// Empty query is allowed (shows all users when just typing @)
76	if (query && !USERNAME_PATTERN.test(query)) return null;
77
78	return { atIndex, query };
79}
80
81/**
82 * Highlights @mentions in plain text by wrapping them in spans.
83 * Text must be HTML-escaped before calling this function.
84 */
85export function highlightMentionsPlain(escapedText: string): string {
86	return escapedText.replace(MENTION_REGEX_PLAIN, '$1<span class="mention-token">@$2</span>');
87}
88
89/**
90 * Highlights @mentions in HTML content (post-sanitization).
91 * Used in text-formatter.ts after rehype processing.
92 */
93export function highlightMentionsHtml(html: string): string {
94	return html.replace(MENTION_REGEX_HTML, '$1<span class="mention-token">@$2</span>');
95}
96
97/**
98 * Highlights @mentions for input field display (with different class).
99 */
100export function highlightMentionsInput(escapedText: string): string {
101	return escapedText.replace(MENTION_REGEX_PLAIN, '$1<span class="input-mention">@$2</span>');
102}
103
104/**
105 * Checks if content contains a mention of the given username.
106 * Case-insensitive by default.
107 */
108export function containsMention(
109	content: string,
110	username: string,
111	caseSensitive: boolean = false
112): boolean {
113	const escapedUsername = username.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
114	const flags = caseSensitive ? '' : 'i';
115	const mentionRegex = new RegExp(`(?:^|\\s)@(${escapedUsername})(?=$|\\s|[^a-zA-Z0-9_-])`, flags);
116	return mentionRegex.test(content);
117}
118
119/**
120 * Calculates the height of the mention picker based on suggestion count.
121 */
122export function calculatePickerHeight(suggestionCount: number): number {
123	if (suggestionCount === 0) {
124		return MENTION_PICKER.EMPTY_STATE_HEIGHT;
125	}
126	return (
127		Math.min(suggestionCount, MAX_MENTION_SUGGESTIONS) * MENTION_PICKER.ITEM_HEIGHT +
128		MENTION_PICKER.PADDING
129	);
130}
131
132/**
133 * Calculates the width of the mention picker based on content.
134 * @param maxNameWidth - Maximum width of username text in pixels
135 * @param statusWidth - Width of status text in pixels
136 */
137export function calculatePickerWidth(maxNameWidth: number, statusWidth: number): number {
138	return Math.max(
139		MENTION_PICKER.MIN_WIDTH,
140		Math.ceil(maxNameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING)
141	);
142}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close