buddy-list.svelte RawHistoryFolder

1<script lang="ts">
2	import { onMount } from 'svelte';
3	import { SvelteSet, SvelteMap } from 'svelte/reactivity';
4	import { useQuery } from 'convex-svelte';
5	import { api } from '../../../convex/_generated/api';
6	import type { Id } from '../../../convex/_generated/dataModel';
7	import type { Buddy, AimStatus } from '$lib/types/aim';
8	import { DEFAULT_BUDDY_GROUPS, STATUS_LABELS_FR } from '$lib/types/aim';
9	import { playSound } from '$lib/aim/sounds.svelte';
10	import { authQuery, useAimClient, convexAvailable } from '$lib/aim/client';
11	import { chatState } from '$lib/states/chat.svelte';
12	import { desktop } from '$lib/states/desktop.svelte';
13	import XpWindow from './xp-window.svelte';
14	import { browser } from '$app/environment';
15
16	// Dock top-right like the real AIM buddy list, with breathing room above and
17	// above the taskbar (30px) below.
18	const BUDDY_LIST_WIDTH = 230;
19	const buddyListHeight = browser
20		? Math.max(360, Math.min(520, window.innerHeight - 30 - 2 * 40))
21		: 520;
22
23	const aim = useAimClient();
24	const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({}));
25	const directQuery = authQuery(api.aim.getMyDirectRooms, () => ({}));
26	const meQuery = authQuery(api.aim.getMe, () => ({}));
27	const roomsQuery = convexAvailable ? useQuery(api.aim.getGroupRooms, {}) : null;
28	const usersQuery = convexAvailable ? useQuery(api.queries.getUsersPublic, {}) : null;
29	const me = $derived(chatState.getCurrentUser());
30	const buddies = $derived(buddyQuery?.data ?? []);
31	const collapsed = new SvelteSet<string>();
32	const flashing = new SvelteSet<string>();
33	let selectedId = $state<Id<'users'> | null>(null);
34	let editMode = $state(false);
35	let panel = $state<'im' | 'add' | null>(null);
36	let nickname = $state('');
37	let addId = $state('');
38	let addGroup = $state<string>('Buddies');
39	let customGroup = $state('');
40	let error = $state('');
41	let dropdownMenu = $state<'aim' | 'contacts' | 'help' | null>(null);
42	let context = $state<{ buddy: Buddy; x: number; y: number } | null>(null);
43	let menuBar: HTMLDivElement;
44	let contextElement = $state<HTMLDivElement>();
45	let previous = new SvelteMap<string, AimStatus>();
46	let initialized = false;
47	const timers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
48
49	const sortedGroups = $derived.by(() => {
50		const groups = new SvelteMap<string, Buddy[]>();
51		for (const name of DEFAULT_BUDDY_GROUPS) groups.set(name, []);
52		for (const buddy of buddies) {
53			const name = buddy.group || 'Buddies';
54			if (!groups.has(name)) groups.set(name, []);
55			if (buddy.status !== 'offline') groups.get(name)!.push(buddy);
56		}
57		const rank: Record<AimStatus, number> = { online: 0, away: 1, idle: 2, busy: 3, offline: 4 };
58		return [...groups.entries()]
59			.sort(([a], [b]) => {
60				const ai = DEFAULT_BUDDY_GROUPS.indexOf(a as (typeof DEFAULT_BUDDY_GROUPS)[number]);
61				const bi = DEFAULT_BUDDY_GROUPS.indexOf(b as (typeof DEFAULT_BUDDY_GROUPS)[number]);
62				return ai !== -1 && bi !== -1
63					? ai - bi
64					: ai !== -1
65						? -1
66						: bi !== -1
67							? 1
68							: a.localeCompare(b);
69			})
70			.map(([name, members]) => ({
71				name,
72				members: members.sort(
73					(a, b) => rank[a.status] - rank[b.status] || a.nickname.localeCompare(b.nickname)
74				),
75				total: buddies.filter((b) => (b.group || 'Buddies') === name).length
76			}));
77	});
78	const offline = $derived(
79		buddies
80			.filter((b) => b.status === 'offline')
81			.sort((a, b) => a.nickname.localeCompare(b.nickname))
82	);
83	const availableUsers = $derived(
84		(usersQuery?.data ?? [])
85			.filter((u) => u.id !== me?.id && !buddies.some((b) => b.id === u.id))
86			.sort((a, b) => a.nickname.localeCompare(b.nickname))
87	);
88	const selectedBuddy = $derived(buddies.find((b) => b.id === selectedId));
89
90	onMount(() => {
91		try {
92			const stored = JSON.parse(localStorage.getItem('pdraim-buddylist-collapsed') ?? '[]');
93			if (Array.isArray(stored))
94				for (const name of stored) if (typeof name === 'string') collapsed.add(name);
95		} catch {
96			/* Ignore malformed saved preferences. */
97		}
98		return () => {
99			for (const timer of timers.values()) clearTimeout(timer);
100		};
101	});
102
103	$effect(() => {
104		const data = buddyQuery?.data;
105		if (!me || !data) {
106			previous = new SvelteMap<string, AimStatus>();
107			initialized = false;
108			return;
109		}
110		const next = new SvelteMap<string, AimStatus>();
111		for (const buddy of data) {
112			next.set(buddy.id, buddy.status);
113			const old = previous.get(buddy.id);
114			if (!initialized || old === undefined || old === buddy.status) continue;
115			if (old === 'offline' && buddy.status !== 'offline') {
116				playSound('doorOpen');
117				flashing.add(buddy.id);
118				const existing = timers.get(buddy.id);
119				if (existing) clearTimeout(existing);
120				timers.set(
121					buddy.id,
122					setTimeout(() => {
123						flashing.delete(buddy.id);
124						timers.delete(buddy.id);
125					}, 1500)
126				);
127			} else if (old !== 'offline' && buddy.status === 'offline') playSound('doorSlam');
128		}
129		previous = next;
130		initialized = true;
131	});
132
133	function toggleGroup(name: string) {
134		if (collapsed.has(name)) collapsed.delete(name);
135		else collapsed.add(name);
136		localStorage.setItem('pdraim-buddylist-collapsed', JSON.stringify([...collapsed]));
137	}
138	function openPanel(which: 'im' | 'add') {
139		panel = panel === which ? null : which;
140		error = '';
141		dropdownMenu = null;
142	}
143	async function openIm(id: Id<'users'>, name: string) {
144		try {
145			const room = await aim.getOrCreateDirectRoom(id);
146			desktop.openIm({ roomId: room.id, otherUserId: id, otherNickname: name });
147			panel = null;
148		} catch {
149			error = 'Impossible d’ouvrir la conversation.';
150		}
151	}
152	async function startIm() {
153		const user = usersQuery?.data?.find(
154			(u) => u.nickname.toLocaleLowerCase() === nickname.trim().toLocaleLowerCase()
155		);
156		if (!user) {
157			error = 'Pseudo introuvable.';
158			return;
159		}
160		await openIm(user.id, user.nickname);
161	}
162	async function addBuddy() {
163		const user = availableUsers.find((u) => u.id === addId);
164		const group = addGroup === 'Autre…' ? customGroup.trim() : addGroup;
165		if (!user || !group) {
166			error = 'Choisissez un contact et un groupe.';
167			return;
168		}
169		try {
170			await aim.addBuddy(user.id, group);
171			panel = null;
172			addId = '';
173			error = '';
174		} catch {
175			error = 'Impossible d’ajouter ce contact.';
176		}
177	}
178	async function removeBuddy(id: Id<'users'>) {
179		context = null;
180		try {
181			await aim.removeBuddy(id);
182			if (selectedId === id) selectedId = null;
183		} catch {
184			error = 'Impossible de retirer ce contact.';
185		}
186	}
187	function showContext(event: MouseEvent, buddy: Buddy) {
188		event.preventDefault();
189		selectedId = buddy.id;
190		dropdownMenu = null;
191		context = {
192			buddy,
193			x: Math.min(event.clientX, window.innerWidth - 190),
194			y: Math.min(event.clientY, window.innerHeight - 95)
195		};
196	}
197	function outside(event: MouseEvent) {
198		const target = event.target as Node;
199		if (dropdownMenu && !menuBar?.contains(target)) dropdownMenu = null;
200		if (context && !contextElement?.contains(target)) context = null;
201	}
202	function escape(event: KeyboardEvent) {
203		if (event.key === 'Escape') {
204			dropdownMenu = null;
205			context = null;
206			panel = null;
207		}
208	}
209	async function logout() {
210		dropdownMenu = null;
211		try {
212			const response = await fetch('/api/session/logout', { method: 'POST' });
213			if (!response.ok) throw new Error('logout failed');
214			window.location.reload();
215		} catch {
216			error = 'Déconnexion impossible.';
217		}
218	}
219	function tooltip(buddy: Buddy) {
220		return `${STATUS_LABELS_FR[buddy.status]}${buddy.status === 'offline' && buddy.lastSeen ? ` — Dernière visite : ${new Date(buddy.lastSeen).toLocaleString('fr-FR')}` : ''}`;
221	}
222</script>
223
224<svelte:window onclick={outside} onkeydown={escape} />
225
226{#snippet buddyRow(buddy: Buddy)}
227	<div class="row" class:selected={selectedId === buddy.id} class:flash={flashing.has(buddy.id)}>
228		<button
229			class="row-action"
230			class:offline={buddy.status === 'offline'}
231			class:away={buddy.status === 'away'}
232			title={tooltip(buddy)}
233			onclick={() => (selectedId = buddy.id)}
234			ondblclick={() => openIm(buddy.id, buddy.nickname)}
235			oncontextmenu={(e) => showContext(e, buddy)}
236		>
237			<img src={`/aim/status-${buddy.status}.svg`} alt="" width="12" height="12" />
238			<span class="truncate">{buddy.nickname}{buddy.status === 'idle' ? ' (Inactif)' : ''}</span>
239			{#if buddy.status === 'away'}<img
240					src="/aim/xp-note-16.png"
241					alt=""
242					width="12"
243					height="12"
244				/>{/if}
245		</button>
246		{#if editMode}<button
247				class="remove"
248				aria-label={`Retirer ${buddy.nickname}`}
249				title={`Retirer ${buddy.nickname}`}
250				onclick={() => removeBuddy(buddy.id)}>✕</button
251			>{/if}
252	</div>
253{/snippet}
254
255<XpWindow
256	id="buddy-list"
257	width={BUDDY_LIST_WIDTH}
258	height={buddyListHeight}
259	minWidth={BUDDY_LIST_WIDTH}
260	showMaximize={false}
261	x={browser ? Math.max(8, window.innerWidth - BUDDY_LIST_WIDTH - 24) : undefined}
262	y={browser ? 40 : undefined}
263>
264	{#snippet menu()}
265		<div class="menus" bind:this={menuBar}>
266			<div class="menu-wrap">
267				<button
268					aria-expanded={dropdownMenu === 'aim'}
269					onclick={() => (dropdownMenu = dropdownMenu === 'aim' ? null : 'aim')}>Mon AIM</button
270				>
271				{#if dropdownMenu === 'aim'}<div class="dropdown">
272						<button
273							onclick={() => {
274								dropdownMenu = null;
275								desktop.openAway();
276							}}>Message d’absence…</button
277						>
278						<button
279							disabled={!me}
280							onclick={() => {
281								dropdownMenu = null;
282								if (me)
283									desktop.openProfile({ userId: me.id as Id<'users'>, nickname: me.nickname });
284							}}>Modifier mon profil…</button
285						>
286						<button
287							onclick={() => {
288								dropdownMenu = null;
289								desktop.openPreferences();
290							}}>Préférences…</button
291						>
292						<hr />
293						<button onclick={logout}>Se déconnecter</button>
294					</div>{/if}
295			</div>
296			<div class="menu-wrap">
297				<button
298					aria-expanded={dropdownMenu === 'contacts'}
299					onclick={() => (dropdownMenu = dropdownMenu === 'contacts' ? null : 'contacts')}
300					>Contacts</button
301				>
302				{#if dropdownMenu === 'contacts'}<div class="dropdown">
303						<button onclick={() => openPanel('add')}>Ajouter un contact…</button><button
304							onclick={() => {
305								dropdownMenu = null;
306								desktop.openNewRoom();
307							}}>Nouveau salon…</button
308						>
309					</div>{/if}
310			</div>
311			<div class="menu-wrap">
312				<button
313					aria-expanded={dropdownMenu === 'help'}
314					onclick={() => (dropdownMenu = dropdownMenu === 'help' ? null : 'help')}>Aide</button
315				>
316				{#if dropdownMenu === 'help'}<div class="dropdown">
317						<button
318							onclick={() => {
319								dropdownMenu = null;
320								desktop.openAbout();
321							}}>À propos de PDR AIM</button
322						>
323					</div>{/if}
324			</div>
325		</div>
326	{/snippet}
327	<div class="buddy-content">
328		<header class="identity">
329			{#if me}
330				<img class="icon" src="/aim/running-man-32.png" alt="" width="32" height="32" />
331				<div class="identity-text">
332					<strong>{me.nickname}</strong><span title={meQuery?.data?.awayMessage ?? ''}
333						>{meQuery?.data?.status === 'away'
334							? `Absent${meQuery.data.awayMessage ? ` : ${meQuery.data.awayMessage}` : ''}`
335							: (STATUS_LABELS_FR[(meQuery?.data?.status ?? me.status) as AimStatus] ??
336								'En ligne')}</span
337					>
338				</div>
339			{:else}
340				<div>
341					<button onclick={() => desktop.openLogin('signin')}>Se connecter</button>
342					<p>Connectez-vous pour voir vos contacts.</p>
343				</div>
344			{/if}
345		</header>
346		<div class="tabs">
347			<button class:active={!editMode} aria-pressed={!editMode} onclick={() => (editMode = false)}
348				>Online</button
349			><button class:active={editMode} aria-pressed={editMode} onclick={() => (editMode = true)}
350				>List Setup</button
351			>
352		</div>
353		<div class="sunken-panel tree">
354			{#if me}
355				{#each sortedGroups as group (group.name)}
356					<button
357						class="group"
358						aria-expanded={!collapsed.has(group.name)}
359						onclick={() => toggleGroup(group.name)}
360						>{collapsed.has(group.name) ? '▶' : '▼'}
361						{group.name} ({group.members.filter((buddy) => buddy.status === 'online')
362							.length}/{group.total})</button
363					>
364					{#if !collapsed.has(group.name)}{#each group.members as buddy (buddy.id)}{@render buddyRow(
365								buddy
366							)}{/each}{/if}
367				{/each}
368				<button
369					class="group"
370					aria-expanded={!collapsed.has('Offline')}
371					onclick={() => toggleGroup('Offline')}
372					>{collapsed.has('Offline') ? '▶' : '▼'} Offline ({offline.length})</button
373				>
374				{#if !collapsed.has('Offline')}{#each offline as buddy (buddy.id)}{@render buddyRow(
375							buddy
376						)}{/each}{/if}
377				<button
378					class="group"
379					aria-expanded={!collapsed.has('Salons')}
380					onclick={() => toggleGroup('Salons')}
381					>{collapsed.has('Salons') ? '▶' : '▼'} Salons ({roomsQuery?.data?.length ?? 0})</button
382				>
383				{#if !collapsed.has('Salons')}{#each [...(roomsQuery?.data ?? [])].sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name)) as room (room.id)}<button
384							class="list-entry"
385							onclick={() => desktop.openChatRoom(room.id, room.name)}
386							><img src="/aim/chat-room-16.png" alt="" width="12" height="12" />
387							<span class="truncate">{room.name}</span></button
388						>{/each}{/if}
389				<button
390					class="group"
391					aria-expanded={!collapsed.has('Conversations')}
392					onclick={() => toggleGroup('Conversations')}
393					>{collapsed.has('Conversations') ? '▶' : '▼'} Conversations ({directQuery?.data?.length ??
394						0})</button
395				>
396				{#if !collapsed.has('Conversations')}{#each directQuery?.data ?? [] as room (room.id)}{#if room.other}<button
397								class="list-entry"
398								class:unread={room.unreadCount > 0}
399								onclick={() =>
400									desktop.openIm({
401										roomId: room.id,
402										otherUserId: room.other!.id,
403										otherNickname: room.other!.nickname
404									})}
405								><img src="/aim/running-man-16.png" alt="" width="12" height="12" />
406								<span class="truncate"
407									>{room.other.nickname}{room.unreadCount
408										? ` (${room.unreadCount >= 100 ? '99+' : room.unreadCount})`
409										: ''}</span
410								></button
411							>{/if}{/each}{/if}
412			{/if}
413		</div>
414		{#if me && panel === 'im'}<form
415				class="inline-panel"
416				onsubmit={(e) => {
417					e.preventDefault();
418					void startIm();
419				}}
420			>
421				<label for="im-nick">Pseudo du contact</label><input
422					id="im-nick"
423					type="text"
424					bind:value={nickname}
425					autocomplete="off"
426				/><button type="submit">Ouvrir IM</button>
427			</form>{/if}
428		{#if me && panel === 'add'}<form
429				class="inline-panel"
430				onsubmit={(e) => {
431					e.preventDefault();
432					void addBuddy();
433				}}
434			>
435				<label for="add-user">Contact</label><select id="add-user" bind:value={addId}
436					><option value="">Choisir…</option>{#each availableUsers as user (user.id)}<option
437							value={user.id}>{user.nickname}</option
438						>{/each}</select
439				><label for="add-group">Groupe</label><select id="add-group" bind:value={addGroup}
440					>{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option
441						>{/each}<option value="Autre…">Autre…</option></select
442				>{#if addGroup === 'Autre…'}<input
443						type="text"
444						aria-label="Nom du groupe"
445						bind:value={customGroup}
446					/>{/if}<button type="submit">Ajouter</button>
447			</form>{/if}
448		{#if error}<p class="error" role="alert">{error}</p>{/if}
449		<div class="toolbar">
450			<button disabled={!me} onclick={() => openPanel('im')}
451				><img src="/aim/running-man-48.png" alt="" />IM</button
452			>
453			<button disabled={!me} onclick={() => desktop.openNewRoom()}
454				><img src="/aim/chat-room-48.png" alt="" />Chat</button
455			>
456			<button
457				disabled={!selectedBuddy}
458				onclick={() => {
459					if (selectedBuddy)
460						desktop.openProfile({ userId: selectedBuddy.id, nickname: selectedBuddy.nickname });
461				}}><img src="/aim/profile-48.png" alt="" />Infos</button
462			>
463			<button disabled={!me} onclick={() => desktop.openAway()}
464				><img src="/aim/xp-note-48.png" alt="" />Absent</button
465			>
466			<button disabled={!me} onclick={() => openPanel('add')}
467				><img src="/aim/add-buddy-48.png" alt="" />Ajouter</button
468			>
469		</div>
470	</div>
471</XpWindow>
472
473{#if context}<div
474		class="context dropdown"
475		bind:this={contextElement}
476		style:left={`${Math.max(0, context.x)}px`}
477		style:top={`${Math.max(0, context.y)}px`}
478	>
479		<button
480			onclick={() => {
481				if (context) void openIm(context.buddy.id, context.buddy.nickname);
482				context = null;
483			}}>Envoyer un message instantané</button
484		>
485		<button
486			onclick={() => {
487				if (context)
488					desktop.openProfile({ userId: context.buddy.id, nickname: context.buddy.nickname });
489				context = null;
490			}}>Infos sur {context.buddy.nickname}</button
491		>
492		<button
493			onclick={() => {
494				if (context) void removeBuddy(context.buddy.id);
495			}}>Retirer de la liste</button
496		>
497	</div>{/if}
498
499<style>
500	.buddy-content {
501		display: flex;
502		flex-direction: column;
503		flex: 1;
504		min-height: 0;
505		font:
506			11px Tahoma,
507			'Pixelated MS Sans Serif',
508			sans-serif;
509	}
510	.identity {
511		display: flex;
512		align-items: center;
513		gap: 6px;
514		min-height: 49px;
515		padding: 4px;
516		background: linear-gradient(#ffe880, #f6c700);
517		border-bottom: 1px solid #c59d00;
518	}
519	.identity p {
520		margin: 3px 0 0;
521	}
522	.icon {
523		flex: none;
524	}
525	.identity-text {
526		min-width: 0;
527		display: flex;
528		flex-direction: column;
529		gap: 3px;
530	}
531	.identity-text strong {
532		display: flex;
533		align-items: center;
534		white-space: nowrap;
535		overflow: hidden;
536		text-overflow: ellipsis;
537	}
538	.identity-text span,
539	.truncate {
540		overflow: hidden;
541		text-overflow: ellipsis;
542		white-space: nowrap;
543	}
544	.tabs {
545		display: flex;
546		padding-top: 4px;
547	}
548	.tabs button {
549		border: 1px solid #aaa;
550		border-bottom: 0;
551		border-radius: 4px 4px 0 0;
552		padding: 3px 9px;
553		min-width: 0;
554		font-size: 11px;
555		background: #dedbd0;
556	}
557	.tabs button.active {
558		background: #fff;
559		font-weight: bold;
560	}
561	.tree {
562		flex: 1;
563		min-height: 0;
564		overflow: auto;
565		background: #fff;
566		padding: 2px;
567	}
568	.group,
569	.list-entry,
570	.row-action {
571		display: flex;
572		align-items: center;
573		width: 100%;
574		min-width: 0;
575		text-align: left;
576		border: 0;
577		border-radius: 0;
578		box-shadow: none;
579		font: inherit;
580		cursor: default;
581	}
582	.group {
583		background: #ece9d8;
584		font-weight: bold;
585		padding: 3px 2px;
586		margin-top: 2px;
587	}
588	.list-entry,
589	.row-action {
590		background: transparent;
591		padding: 2px 2px 2px 12px;
592		gap: 4px;
593	}
594	.list-entry:hover,
595	.row-action:hover,
596	.row.selected .row-action {
597		background: #316ac5;
598		color: #fff;
599	}
600	.row {
601		display: flex;
602		align-items: center;
603	}
604	.row-action {
605		flex: 1;
606	}
607	.row-action.offline,
608	.row-action.away {
609		color: #777;
610		font-style: italic;
611	}
612	.row-action:hover,
613	.row.selected .row-action {
614		color: #fff;
615	}
616	.row-action img,
617	.list-entry img {
618		flex: none;
619	}
620	.remove {
621		min-width: 17px;
622		padding: 0;
623		border: 0;
624		box-shadow: none;
625		color: #900;
626	}
627	.unread {
628		font-weight: bold;
629	}
630	.inline-panel {
631		display: flex;
632		flex-direction: column;
633		gap: 3px;
634		padding: 4px;
635		background: #ece9d8;
636	}
637	.inline-panel input,
638	.inline-panel select {
639		width: 100%;
640		box-sizing: border-box;
641	}
642	.error {
643		color: #a00;
644		margin: 2px;
645	}
646	.toolbar {
647		display: flex;
648		justify-content: space-between;
649		border-top: 1px solid #aca899;
650		padding-top: 3px;
651	}
652	.toolbar button {
653		display: flex;
654		align-items: center;
655		flex-direction: column;
656		gap: 2px;
657		min-width: 0;
658		padding: 2px;
659		font: inherit;
660	}
661	.toolbar img {
662		width: 24px;
663		height: 24px;
664	}
665	.menus {
666		display: flex;
667	}
668	.menu-wrap {
669		position: relative;
670	}
671	.dropdown {
672		position: absolute;
673		z-index: 10000;
674		top: 100%;
675		left: 0;
676		min-width: 170px;
677		padding: 2px;
678		background: #fff;
679		border: 1px solid #aca899;
680		box-shadow: 2px 2px 4px #888;
681	}
682	.dropdown button {
683		display: block;
684		width: 100%;
685		min-width: 0;
686		padding: 4px 8px;
687		text-align: left;
688		border: 0;
689		box-shadow: none;
690		background: #fff;
691		color: #000;
692		white-space: nowrap;
693		font:
694			11px Tahoma,
695			sans-serif;
696	}
697	.dropdown button:hover:not(:disabled) {
698		background: #316ac5;
699		color: #fff;
700	}
701	.dropdown hr {
702		margin: 2px 4px;
703	}
704	.context {
705		position: fixed;
706		top: auto;
707		min-width: 185px;
708	}
709	@keyframes sign-on {
710		0%,
711		75% {
712			background: #fff199;
713		}
714		100% {
715			background: transparent;
716		}
717	}
718	.row.flash {
719		animation: sign-on 1.5s ease-out;
720	}
721</style>

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