v0.4.0 f6d8405

mention.spec.ts RawHistoryFolder

1import { describe, it, expect, beforeEach } from 'vitest';
2import {
3	MENTION_USERNAME_MAX_LENGTH,
4	USERNAME_PATTERN,
5	MAX_MENTION_SUGGESTIONS,
6	MENTION_PICKER,
7	MENTION_REGEX_PLAIN,
8	MENTION_REGEX_HTML,
9	isWordStart,
10	findMentionContext,
11	highlightMentionsPlain,
12	highlightMentionsHtml,
13	highlightMentionsInput,
14	containsMention,
15	calculatePickerHeight,
16	calculatePickerWidth
17} from './mention';
18
19describe('mention constants', () => {
20	it('has correct username max length', () => {
21		expect(MENTION_USERNAME_MAX_LENGTH).toBe(32);
22	});
23
24	it('has correct max suggestions', () => {
25		expect(MAX_MENTION_SUGGESTIONS).toBe(6);
26	});
27
28	it('has correct picker dimensions', () => {
29		expect(MENTION_PICKER.ITEM_HEIGHT).toBe(32);
30		expect(MENTION_PICKER.PADDING).toBe(12);
31		expect(MENTION_PICKER.EMPTY_STATE_HEIGHT).toBe(44);
32		expect(MENTION_PICKER.MIN_WIDTH).toBe(220);
33	});
34});
35
36describe('USERNAME_PATTERN', () => {
37	it('matches valid usernames', () => {
38		expect(USERNAME_PATTERN.test('alice')).toBe(true);
39		expect(USERNAME_PATTERN.test('Bob123')).toBe(true);
40		expect(USERNAME_PATTERN.test('user_name')).toBe(true);
41		expect(USERNAME_PATTERN.test('user-name')).toBe(true);
42		expect(USERNAME_PATTERN.test('A')).toBe(true);
43		expect(USERNAME_PATTERN.test('a'.repeat(32))).toBe(true);
44	});
45
46	it('rejects invalid usernames', () => {
47		expect(USERNAME_PATTERN.test('')).toBe(false);
48		expect(USERNAME_PATTERN.test('a'.repeat(33))).toBe(false);
49		expect(USERNAME_PATTERN.test('user name')).toBe(false);
50		expect(USERNAME_PATTERN.test('user.name')).toBe(false);
51		expect(USERNAME_PATTERN.test('@alice')).toBe(false);
52		expect(USERNAME_PATTERN.test('alice!')).toBe(false);
53	});
54});
55
56describe('isWordStart', () => {
57	it('returns true at position 0', () => {
58		expect(isWordStart('hello', 0)).toBe(true);
59		expect(isWordStart('@alice', 0)).toBe(true);
60	});
61
62	it('returns true after whitespace', () => {
63		expect(isWordStart('hello @alice', 6)).toBe(true);
64		expect(isWordStart('hi\t@bob', 3)).toBe(true);
65		expect(isWordStart('hi\n@bob', 3)).toBe(true);
66	});
67
68	it('returns false mid-word', () => {
69		expect(isWordStart('hello', 2)).toBe(false);
70		expect(isWordStart('test@alice', 4)).toBe(false);
71	});
72});
73
74describe('findMentionContext', () => {
75	it('finds mention at start of input', () => {
76		const result = findMentionContext('@alice', 6);
77		expect(result).toEqual({ atIndex: 0, query: 'alice' });
78	});
79
80	it('finds mention after space', () => {
81		const result = findMentionContext('hello @bob', 10);
82		expect(result).toEqual({ atIndex: 6, query: 'bob' });
83	});
84
85	it('finds partial mention (typing in progress)', () => {
86		const result = findMentionContext('hello @al', 9);
87		expect(result).toEqual({ atIndex: 6, query: 'al' });
88	});
89
90	it('finds empty query (just @)', () => {
91		const result = findMentionContext('hello @', 7);
92		expect(result).toEqual({ atIndex: 6, query: '' });
93	});
94
95	it('returns null when no @', () => {
96		expect(findMentionContext('hello world', 11)).toBeNull();
97	});
98
99	it('returns null when @ is mid-word', () => {
100		expect(findMentionContext('test@alice', 10)).toBeNull();
101	});
102
103	it('returns null when query contains space', () => {
104		expect(findMentionContext('@alice bob', 10)).toBeNull();
105	});
106
107	it('returns null when query exceeds max length', () => {
108		const longName = 'a'.repeat(33);
109		expect(findMentionContext(`@${longName}`, longName.length + 1)).toBeNull();
110	});
111
112	it('returns null when query has invalid characters', () => {
113		expect(findMentionContext('@alice!', 7)).toBeNull();
114		expect(findMentionContext('@alice.bob', 10)).toBeNull();
115	});
116
117	it('handles cursor in middle of mention', () => {
118		const result = findMentionContext('@alice hello', 4);
119		expect(result).toEqual({ atIndex: 0, query: 'ali' });
120	});
121});
122
123describe('MENTION_REGEX_PLAIN', () => {
124	beforeEach(() => {
125		// Reset regex lastIndex for global regex
126		MENTION_REGEX_PLAIN.lastIndex = 0;
127	});
128
129	it('matches mention at start', () => {
130		const matches = '@alice hello'.match(MENTION_REGEX_PLAIN);
131		expect(matches).toContain('@alice');
132	});
133
134	it('matches mention after space', () => {
135		const matches = 'hello @bob there'.match(MENTION_REGEX_PLAIN);
136		expect(matches).toContain(' @bob');
137	});
138
139	it('matches multiple mentions', () => {
140		const text = '@alice and @bob are here';
141		const matches = text.match(MENTION_REGEX_PLAIN);
142		expect(matches).toHaveLength(2);
143	});
144
145	it('does not match mid-word @', () => {
146		const matches = 'email@test.com'.match(MENTION_REGEX_PLAIN);
147		expect(matches).toBeNull();
148	});
149});
150
151describe('MENTION_REGEX_HTML', () => {
152	beforeEach(() => {
153		MENTION_REGEX_HTML.lastIndex = 0;
154	});
155
156	it('matches mention after HTML tag', () => {
157		const matches = '<p>@alice</p>'.match(MENTION_REGEX_HTML);
158		expect(matches).toContain('>@alice');
159	});
160
161	it('matches mention at start', () => {
162		const matches = '@bob said'.match(MENTION_REGEX_HTML);
163		expect(matches).toContain('@bob');
164	});
165});
166
167describe('highlightMentionsPlain', () => {
168	it('wraps mention in span', () => {
169		const result = highlightMentionsPlain('@alice');
170		expect(result).toBe('<span class="mention-token">@alice</span>');
171	});
172
173	it('preserves text around mention', () => {
174		const result = highlightMentionsPlain('hello @bob there');
175		expect(result).toBe('hello <span class="mention-token">@bob</span> there');
176	});
177
178	it('handles multiple mentions', () => {
179		const result = highlightMentionsPlain('@alice and @bob');
180		expect(result).toContain('<span class="mention-token">@alice</span>');
181		expect(result).toContain('<span class="mention-token">@bob</span>');
182	});
183
184	it('does not highlight mid-word @', () => {
185		const result = highlightMentionsPlain('email@test.com');
186		expect(result).toBe('email@test.com');
187	});
188});
189
190describe('highlightMentionsHtml', () => {
191	it('wraps mention after HTML tag', () => {
192		const result = highlightMentionsHtml('<p>@alice</p>');
193		expect(result).toBe('<p><span class="mention-token">@alice</span></p>');
194	});
195
196	it('handles mention at start', () => {
197		const result = highlightMentionsHtml('@bob said');
198		expect(result).toBe('<span class="mention-token">@bob</span> said');
199	});
200});
201
202describe('highlightMentionsInput', () => {
203	it('uses input-mention class', () => {
204		const result = highlightMentionsInput('@alice');
205		expect(result).toBe('<span class="input-mention">@alice</span>');
206	});
207});
208
209describe('containsMention', () => {
210	it('detects mention (case-insensitive by default)', () => {
211		expect(containsMention('hello @Alice', 'alice')).toBe(true);
212		expect(containsMention('hello @ALICE', 'alice')).toBe(true);
213		expect(containsMention('hello @alice', 'ALICE')).toBe(true);
214	});
215
216	it('respects case sensitivity when enabled', () => {
217		expect(containsMention('hello @Alice', 'alice', true)).toBe(false);
218		expect(containsMention('hello @alice', 'alice', true)).toBe(true);
219	});
220
221	it('returns false when no mention', () => {
222		expect(containsMention('hello world', 'alice')).toBe(false);
223	});
224
225	it('returns false for partial match', () => {
226		expect(containsMention('hello @alicexyz', 'alice')).toBe(false);
227	});
228
229	it('handles special regex characters in username', () => {
230		expect(containsMention('hello @user-name', 'user-name')).toBe(true);
231		expect(containsMention('hello @user_name', 'user_name')).toBe(true);
232	});
233
234	it('requires word boundary before @', () => {
235		expect(containsMention('test@alice', 'alice')).toBe(false);
236	});
237});
238
239describe('calculatePickerHeight', () => {
240	it('returns empty state height for 0 suggestions', () => {
241		expect(calculatePickerHeight(0)).toBe(MENTION_PICKER.EMPTY_STATE_HEIGHT);
242	});
243
244	it('calculates height for single suggestion', () => {
245		expect(calculatePickerHeight(1)).toBe(MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING);
246	});
247
248	it('calculates height for multiple suggestions', () => {
249		expect(calculatePickerHeight(3)).toBe(3 * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING);
250	});
251
252	it('caps at max suggestions', () => {
253		const maxHeight = MAX_MENTION_SUGGESTIONS * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING;
254		expect(calculatePickerHeight(10)).toBe(maxHeight);
255		expect(calculatePickerHeight(100)).toBe(maxHeight);
256	});
257});
258
259describe('calculatePickerWidth', () => {
260	it('returns minimum width for small content', () => {
261		expect(calculatePickerWidth(50, 30)).toBe(MENTION_PICKER.MIN_WIDTH);
262	});
263
264	it('calculates width for larger content', () => {
265		const nameWidth = 150;
266		const statusWidth = 50;
267		const expected = nameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING;
268		expect(calculatePickerWidth(nameWidth, statusWidth)).toBe(expected);
269	});
270
271	it('rounds up to nearest integer', () => {
272		const result = calculatePickerWidth(150.5, 50.3);
273		expect(Number.isInteger(result)).toBe(true);
274	});
275});

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 9

↑↓ moveenter openesc close