detached c4d09f0

feat(chat-room): authentic AIM people list and XP-style registration notice

commit
c4d09f0792bc33f6d30caf3aff75ed9b6e048c1e
author
Kit · · 2026-09-27 13:25 UTC
parent
8702b01
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
1 file · +94 -137
M src/lib/components/chat-room.svelte +94 -137
@@ -15,7 +15,6 @@
1515 import XpWindow from './desktop/xp-window.svelte';
1616 import { desktop } from '$lib/states/desktop.svelte';
1717 import { useAimClient } from '$lib/aim/client';
18- import { getBuddyIcon } from '$lib/aim/buddy-icons';
1918 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
2019 import FormattedMessage from './formatted-message.svelte';
2120 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
@@ -214,11 +213,6 @@
214213 userMenu = null;
215214 }
216215
217- function statusIcon(status: string) {
218- const known: AimStatus[] = ['online', 'away', 'busy', 'idle', 'offline'];
219- return `/aim/status-${known.includes(status as AimStatus) ? status : 'offline'}.svg`;
220- }
221-
222216 function statusLabel(user: SafeUser) {
223217 const label = STATUS_LABELS_FR[(user.status as AimStatus) ?? 'offline'] ?? user.status;
224218 if (user.status === 'away' && user.awayMessage) return `${label} : ${user.awayMessage}`;
@@ -304,7 +298,7 @@
304298
305299 // User counts
306300 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
307- const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
301+ const selectedUser = $derived(onlineUsers.find((u) => u.id === selectedUserId) ?? null);
308302
309303 const mentionableUsers = $derived(
310304 onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)
@@ -1123,10 +1117,9 @@
11231117 {/if}
11241118 {#if showRegistrationPrompt}
11251119 <div class="registration-prompt">
1126- <p>
1127- 👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire le
1128- reste du chat !
1129- </p>
1120+ <img src="/aim/xp-info-16.png" alt="" width="16" height="16" />
1121+ <span>Vous devez être inscrit pour lire l'historique complet de ce salon.</span>
1122+ <button type="button" onclick={openSignup}>S'inscrire</button>
11301123 </div>
11311124 {/if}
11321125 {#each visibleMessages as message (message.id)}
@@ -1355,50 +1348,20 @@
13551348 {/if}
13561349 </div>
13571350
1358- <!-- Participants (AIM chat room people list) -->
1359- <div
1360- class="sunken-panel users-list"
1361- class:mobile={isMobile}
1362- class:hidden={isMobile && !showUserList}
1363- >
1364- <p class="section-header">Participants ({usersOnline.length})</p>
1365- {#each usersOnline as user (user.id)}
1366- <div
1367- class="user {user.status}"
1368- class:selected={selectedUserId === user.id}
1369- class:me={currentUser?.id === user.id}
1370- role="button"
1371- tabindex="0"
1372- title={statusLabel(user)}
1373- onclick={(e) => {
1374- e.stopPropagation();
1375- selectedUserId = user.id;
1376- closeUserMenu();
1377- }}
1378- ondblclick={() => openImWith(user)}
1379- oncontextmenu={(e) => openUserMenu(e, user)}
1380- onkeydown={(e) => {
1381- if (e.key === 'Enter') openImWith(user);
1382- }}
1383- >
1384- <span
1385- class="buddy-tile"
1386- style="background: {getBuddyIcon(user.buddyIcon).bg}"
1387- aria-hidden="true">{getBuddyIcon(user.buddyIcon).emoji}</span
1388- >
1389- <img class="status-icon" src={statusIcon(user.status)} alt="" width="12" height="12" />
1390- <span class="nickname">{user.nickname}</span>
1391- </div>
1392- {/each}
1393-
1394- {#if usersOffline.length > 0}
1395- <div class="section-separator"></div>
1396- <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p>
1397- {#each usersOffline as user (user.id)}
1351+ <!-- People here (AIM chat room) -->
1352+ <div class="people-panel" class:mobile={isMobile} class:hidden={isMobile && !showUserList}>
1353+ <p class="people-count">
1354+ {usersOnline.length}
1355+ {usersOnline.length === 1 ? 'personne ici' : 'personnes ici'}
1356+ </p>
1357+ <div class="sunken-panel users-list" role="listbox" aria-label="Personnes dans le salon">
1358+ {#each usersOnline as user (user.id)}
13981359 <div
1399- class="user offline"
1360+ class="user {user.status}"
14001361 class:selected={selectedUserId === user.id}
1401- role="button"
1362+ class:me={currentUser?.id === user.id}
1363+ role="option"
1364+ aria-selected={selectedUserId === user.id}
14021365 tabindex="0"
14031366 title={statusLabel(user)}
14041367 onclick={(e) => {
@@ -1412,11 +1375,34 @@
14121375 if (e.key === 'Enter') openImWith(user);
14131376 }}
14141377 >
1415- <img class="status-icon" src={statusIcon('offline')} alt="" width="12" height="12" />
14161378 <span class="nickname">{user.nickname}</span>
14171379 </div>
14181380 {/each}
1419- {/if}
1381+ </div>
1382+ <div class="people-actions">
1383+ <button
1384+ type="button"
1385+ disabled={!selectedUser || selectedUser.id === currentUser?.id}
1386+ onclick={() => selectedUser && openImWith(selectedUser)}>IM</button
1387+ >
1388+ <button
1389+ type="button"
1390+ disabled={!selectedUser}
1391+ onclick={() =>
1392+ selectedUser &&
1393+ desktop.openProfile({
1394+ userId: selectedUser.id as Id<'users'>,
1395+ nickname: selectedUser.nickname
1396+ })}>Infos</button
1397+ >
1398+ <button
1399+ type="button"
1400+ disabled={!selectedUser || selectedUser.id === currentUser?.id}
1401+ onclick={() =>
1402+ selectedUser && void aim.addBuddy(selectedUser.id as Id<'users'>).catch(console.error)}
1403+ >Ajouter</button
1404+ >
1405+ </div>
14201406 </div>
14211407
14221408 {#if userMenu}
@@ -1533,26 +1519,56 @@
15331519 flex-direction: column;
15341520 }
15351521
1536- .users-list {
1537- width: 10.5rem;
1522+ .people-panel {
1523+ width: 9.5rem;
15381524 flex: 0 0 auto;
1539- padding: 0.4rem;
1525+ display: flex;
1526+ flex-direction: column;
1527+ min-height: 0;
1528+ }
1529+
1530+ .people-count {
1531+ margin: 0 0 3px 0;
1532+ font-weight: bold;
1533+ font-size: 0.8rem;
1534+ white-space: nowrap;
1535+ }
1536+
1537+ .users-list {
1538+ flex: 1 1 auto;
1539+ min-height: 0;
1540+ padding: 2px;
15401541 overflow-y: auto;
15411542 font-size: 0.85rem;
15421543 }
15431544
1545+ .people-actions {
1546+ display: flex;
1547+ gap: 3px;
1548+ margin-top: 4px;
1549+ }
1550+
1551+ .people-actions button {
1552+ flex: 1 1 0;
1553+ min-width: 0;
1554+ padding: 0 4px;
1555+ font-size: 0.75rem;
1556+ }
1557+
15441558 .user {
15451559 display: flex;
15461560 align-items: center;
1547- gap: 4px;
15481561 padding: 1px 3px;
1549- cursor: pointer;
1562+ cursor: default;
15501563 border: 1px dotted transparent;
15511564 user-select: none;
1565+ overflow: hidden;
15521566 }
15531567
1554- .user:hover {
1555- background: #e8eefb;
1568+ .user .nickname {
1569+ overflow: hidden;
1570+ text-overflow: ellipsis;
1571+ white-space: nowrap;
15561572 }
15571573
15581574 .user.selected {
@@ -1575,22 +1591,6 @@
15751591 color: #fff;
15761592 }
15771593
1578- .buddy-tile {
1579- display: inline-flex;
1580- align-items: center;
1581- justify-content: center;
1582- width: 16px;
1583- height: 16px;
1584- font-size: 11px;
1585- border: 1px solid rgba(0, 0, 0, 0.25);
1586- border-radius: 2px;
1587- flex: 0 0 16px;
1588- }
1589-
1590- .status-icon {
1591- flex: 0 0 12px;
1592- }
1593-
15941594 .user-menu {
15951595 position: absolute;
15961596 z-index: 50;
@@ -1715,29 +1715,6 @@
17151715 -webkit-text-fill-color: transparent !important;
17161716 }
17171717
1718- .section-header {
1719- margin: 0 0 0.25rem 0;
1720- font-weight: bold;
1721- font-size: 0.75rem;
1722- text-transform: uppercase;
1723- letter-spacing: 0.05em;
1724- color: var(--color-muted-foreground);
1725- }
1726-
1727- .offline-header {
1728- opacity: 0.7;
1729- }
1730-
1731- .section-separator {
1732- border-top: 1px solid var(--color-border);
1733- margin: 0.5rem 0;
1734- }
1735-
1736- .user.offline {
1737- color: var(--color-muted-foreground);
1738- font-style: italic;
1739- }
1740-
17411718 .sunken-panel {
17421719 background: white;
17431720 border: 0.125rem inset #dfdfdf;
@@ -1788,7 +1765,7 @@
17881765 flex-direction: column;
17891766 }
17901767
1791- .users-list.mobile {
1768+ .people-panel.mobile {
17921769 width: 100%;
17931770 max-height: 30vh;
17941771 }
@@ -1926,45 +1903,25 @@
19261903 }
19271904
19281905 .registration-prompt {
1929- background: #fff3e0;
1930- color: #e65100;
1931- padding: 1rem;
1932- margin-bottom: 1rem;
1933- border-radius: 4px;
1934- text-align: center;
1935- font-size: 0.95rem;
1936- border: 1px solid #ffe0b2;
1937- animation: fadeIn 0.3s ease-out;
1938- }
1939-
1940- .registration-prompt p {
1941- margin: 0;
1942- }
1943-
1944- @keyframes fadeIn {
1945- from {
1946- opacity: 0;
1947- transform: translateY(-10px);
1948- }
1949- to {
1950- opacity: 1;
1951- transform: translateY(0);
1952- }
1906+ display: flex;
1907+ align-items: center;
1908+ gap: 8px;
1909+ background: #ece9d8;
1910+ color: #000;
1911+ padding: 6px 8px;
1912+ margin-bottom: 6px;
1913+ border: 1px solid #aca899;
1914+ font-size: 0.85rem;
19531915 }
19541916
1955- .registration-prompt .link-button {
1956- color: #e65100;
1957- text-decoration: underline;
1958- cursor: pointer;
1959- background: none;
1960- border: none;
1961- padding: 0;
1962- font: inherit;
1917+ .registration-prompt span {
1918+ flex: 1;
19631919 }
19641920
1965- .registration-prompt .link-button:hover {
1966- color: #ef6c00;
1967- text-decoration: none;
1921+ .registration-prompt button {
1922+ min-width: 0;
1923+ padding: 0 10px;
1924+ white-space: nowrap;
19681925 }
19691926
19701927 .chat-window.minimized {
@@ -15,7 +15,6 @@
15 import XpWindow from './desktop/xp-window.svelte';15 import XpWindow from './desktop/xp-window.svelte';
16 import { desktop } from '$lib/states/desktop.svelte';16 import { desktop } from '$lib/states/desktop.svelte';
17 import { useAimClient } from '$lib/aim/client';17 import { useAimClient } from '$lib/aim/client';
18- import { getBuddyIcon } from '$lib/aim/buddy-icons';
19 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';18 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
20 import FormattedMessage from './formatted-message.svelte';19 import FormattedMessage from './formatted-message.svelte';
21 import TextFormattingToolbar from './text-formatting-toolbar.svelte';20 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
@@ -214,11 +213,6 @@
214 userMenu = null;213 userMenu = null;
215 }214 }
216 215
217- function statusIcon(status: string) {
218- const known: AimStatus[] = ['online', 'away', 'busy', 'idle', 'offline'];
219- return `/aim/status-${known.includes(status as AimStatus) ? status : 'offline'}.svg`;
220- }
221-
222 function statusLabel(user: SafeUser) {216 function statusLabel(user: SafeUser) {
223 const label = STATUS_LABELS_FR[(user.status as AimStatus) ?? 'offline'] ?? user.status;217 const label = STATUS_LABELS_FR[(user.status as AimStatus) ?? 'offline'] ?? user.status;
224 if (user.status === 'away' && user.awayMessage) return `${label} : ${user.awayMessage}`;218 if (user.status === 'away' && user.awayMessage) return `${label} : ${user.awayMessage}`;
@@ -304,7 +298,7 @@
304 298
305 // User counts299 // User counts
306 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));300 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
307- const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));301+ const selectedUser = $derived(onlineUsers.find((u) => u.id === selectedUserId) ?? null);
308 302
309 const mentionableUsers = $derived(303 const mentionableUsers = $derived(
310 onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)304 onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)
@@ -1123,10 +1117,9 @@
1123 {/if}1117 {/if}
1124 {#if showRegistrationPrompt}1118 {#if showRegistrationPrompt}
1125 <div class="registration-prompt">1119 <div class="registration-prompt">
1126- <p>1120+ <img src="/aim/xp-info-16.png" alt="" width="16" height="16" />
1127- 👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire le1121+ <span>Vous devez être inscrit pour lire l'historique complet de ce salon.</span>
1128- reste du chat !1122+ <button type="button" onclick={openSignup}>S'inscrire</button>
1129- </p>
1130 </div>1123 </div>
1131 {/if}1124 {/if}
1132 {#each visibleMessages as message (message.id)}1125 {#each visibleMessages as message (message.id)}
@@ -1355,50 +1348,20 @@
1355 {/if}1348 {/if}
1356 </div>1349 </div>
1357 1350
1358- <!-- Participants (AIM chat room people list) -->1351+ <!-- People here (AIM chat room) -->
1359- <div1352+ <div class="people-panel" class:mobile={isMobile} class:hidden={isMobile && !showUserList}>
1360- class="sunken-panel users-list"1353+ <p class="people-count">
1361- class:mobile={isMobile}1354+ {usersOnline.length}
1362- class:hidden={isMobile && !showUserList}1355+ {usersOnline.length === 1 ? 'personne ici' : 'personnes ici'}
1363- >1356+ </p>
1364- <p class="section-header">Participants ({usersOnline.length})</p>1357+ <div class="sunken-panel users-list" role="listbox" aria-label="Personnes dans le salon">
1365- {#each usersOnline as user (user.id)}1358+ {#each usersOnline as user (user.id)}
1366- <div
1367- class="user {user.status}"
1368- class:selected={selectedUserId === user.id}
1369- class:me={currentUser?.id === user.id}
1370- role="button"
1371- tabindex="0"
1372- title={statusLabel(user)}
1373- onclick={(e) => {
1374- e.stopPropagation();
1375- selectedUserId = user.id;
1376- closeUserMenu();
1377- }}
1378- ondblclick={() => openImWith(user)}
1379- oncontextmenu={(e) => openUserMenu(e, user)}
1380- onkeydown={(e) => {
1381- if (e.key === 'Enter') openImWith(user);
1382- }}
1383- >
1384- <span
1385- class="buddy-tile"
1386- style="background: {getBuddyIcon(user.buddyIcon).bg}"
1387- aria-hidden="true">{getBuddyIcon(user.buddyIcon).emoji}</span
1388- >
1389- <img class="status-icon" src={statusIcon(user.status)} alt="" width="12" height="12" />
1390- <span class="nickname">{user.nickname}</span>
1391- </div>
1392- {/each}
1393-
1394- {#if usersOffline.length > 0}
1395- <div class="section-separator"></div>
1396- <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p>
1397- {#each usersOffline as user (user.id)}
1398 <div1359 <div
1399- class="user offline"1360+ class="user {user.status}"
1400 class:selected={selectedUserId === user.id}1361 class:selected={selectedUserId === user.id}
1401- role="button"1362+ class:me={currentUser?.id === user.id}
1363+ role="option"
1364+ aria-selected={selectedUserId === user.id}
1402 tabindex="0"1365 tabindex="0"
1403 title={statusLabel(user)}1366 title={statusLabel(user)}
1404 onclick={(e) => {1367 onclick={(e) => {
@@ -1412,11 +1375,34 @@
1412 if (e.key === 'Enter') openImWith(user);1375 if (e.key === 'Enter') openImWith(user);
1413 }}1376 }}
1414 >1377 >
1415- <img class="status-icon" src={statusIcon('offline')} alt="" width="12" height="12" />
1416 <span class="nickname">{user.nickname}</span>1378 <span class="nickname">{user.nickname}</span>
1417 </div>1379 </div>
1418 {/each}1380 {/each}
1419- {/if}1381+ </div>
1382+ <div class="people-actions">
1383+ <button
1384+ type="button"
1385+ disabled={!selectedUser || selectedUser.id === currentUser?.id}
1386+ onclick={() => selectedUser && openImWith(selectedUser)}>IM</button
1387+ >
1388+ <button
1389+ type="button"
1390+ disabled={!selectedUser}
1391+ onclick={() =>
1392+ selectedUser &&
1393+ desktop.openProfile({
1394+ userId: selectedUser.id as Id<'users'>,
1395+ nickname: selectedUser.nickname
1396+ })}>Infos</button
1397+ >
1398+ <button
1399+ type="button"
1400+ disabled={!selectedUser || selectedUser.id === currentUser?.id}
1401+ onclick={() =>
1402+ selectedUser && void aim.addBuddy(selectedUser.id as Id<'users'>).catch(console.error)}
1403+ >Ajouter</button
1404+ >
1405+ </div>
1420 </div>1406 </div>
1421 1407
1422 {#if userMenu}1408 {#if userMenu}
@@ -1533,26 +1519,56 @@
1533 flex-direction: column;1519 flex-direction: column;
1534 }1520 }
1535 1521
1536- .users-list {1522+ .people-panel {
1537- width: 10.5rem;1523+ width: 9.5rem;
1538 flex: 0 0 auto;1524 flex: 0 0 auto;
1539- padding: 0.4rem;1525+ display: flex;
1526+ flex-direction: column;
1527+ min-height: 0;
1528+ }
1529+
1530+ .people-count {
1531+ margin: 0 0 3px 0;
1532+ font-weight: bold;
1533+ font-size: 0.8rem;
1534+ white-space: nowrap;
1535+ }
1536+
1537+ .users-list {
1538+ flex: 1 1 auto;
1539+ min-height: 0;
1540+ padding: 2px;
1540 overflow-y: auto;1541 overflow-y: auto;
1541 font-size: 0.85rem;1542 font-size: 0.85rem;
1542 }1543 }
1543 1544
1545+ .people-actions {
1546+ display: flex;
1547+ gap: 3px;
1548+ margin-top: 4px;
1549+ }
1550+
1551+ .people-actions button {
1552+ flex: 1 1 0;
1553+ min-width: 0;
1554+ padding: 0 4px;
1555+ font-size: 0.75rem;
1556+ }
1557+
1544 .user {1558 .user {
1545 display: flex;1559 display: flex;
1546 align-items: center;1560 align-items: center;
1547- gap: 4px;
1548 padding: 1px 3px;1561 padding: 1px 3px;
1549- cursor: pointer;1562+ cursor: default;
1550 border: 1px dotted transparent;1563 border: 1px dotted transparent;
1551 user-select: none;1564 user-select: none;
1565+ overflow: hidden;
1552 }1566 }
1553 1567
1554- .user:hover {1568+ .user .nickname {
1555- background: #e8eefb;1569+ overflow: hidden;
1570+ text-overflow: ellipsis;
1571+ white-space: nowrap;
1556 }1572 }
1557 1573
1558 .user.selected {1574 .user.selected {
@@ -1575,22 +1591,6 @@
1575 color: #fff;1591 color: #fff;
1576 }1592 }
1577 1593
1578- .buddy-tile {
1579- display: inline-flex;
1580- align-items: center;
1581- justify-content: center;
1582- width: 16px;
1583- height: 16px;
1584- font-size: 11px;
1585- border: 1px solid rgba(0, 0, 0, 0.25);
1586- border-radius: 2px;
1587- flex: 0 0 16px;
1588- }
1589-
1590- .status-icon {
1591- flex: 0 0 12px;
1592- }
1593-
1594 .user-menu {1594 .user-menu {
1595 position: absolute;1595 position: absolute;
1596 z-index: 50;1596 z-index: 50;
@@ -1715,29 +1715,6 @@
1715 -webkit-text-fill-color: transparent !important;1715 -webkit-text-fill-color: transparent !important;
1716 }1716 }
1717 1717
1718- .section-header {
1719- margin: 0 0 0.25rem 0;
1720- font-weight: bold;
1721- font-size: 0.75rem;
1722- text-transform: uppercase;
1723- letter-spacing: 0.05em;
1724- color: var(--color-muted-foreground);
1725- }
1726-
1727- .offline-header {
1728- opacity: 0.7;
1729- }
1730-
1731- .section-separator {
1732- border-top: 1px solid var(--color-border);
1733- margin: 0.5rem 0;
1734- }
1735-
1736- .user.offline {
1737- color: var(--color-muted-foreground);
1738- font-style: italic;
1739- }
1740-
1741 .sunken-panel {1718 .sunken-panel {
1742 background: white;1719 background: white;
1743 border: 0.125rem inset #dfdfdf;1720 border: 0.125rem inset #dfdfdf;
@@ -1788,7 +1765,7 @@
1788 flex-direction: column;1765 flex-direction: column;
1789 }1766 }
1790 1767
1791- .users-list.mobile {1768+ .people-panel.mobile {
1792 width: 100%;1769 width: 100%;
1793 max-height: 30vh;1770 max-height: 30vh;
1794 }1771 }
@@ -1926,45 +1903,25 @@
1926 }1903 }
1927 1904
1928 .registration-prompt {1905 .registration-prompt {
1929- background: #fff3e0;1906+ display: flex;
1930- color: #e65100;1907+ align-items: center;
1931- padding: 1rem;1908+ gap: 8px;
1932- margin-bottom: 1rem;1909+ background: #ece9d8;
1933- border-radius: 4px;1910+ color: #000;
1934- text-align: center;1911+ padding: 6px 8px;
1935- font-size: 0.95rem;1912+ margin-bottom: 6px;
1936- border: 1px solid #ffe0b2;1913+ border: 1px solid #aca899;
1937- animation: fadeIn 0.3s ease-out;1914+ font-size: 0.85rem;
1938- }
1939-
1940- .registration-prompt p {
1941- margin: 0;
1942- }
1943-
1944- @keyframes fadeIn {
1945- from {
1946- opacity: 0;
1947- transform: translateY(-10px);
1948- }
1949- to {
1950- opacity: 1;
1951- transform: translateY(0);
1952- }
1953 }1915 }
1954 1916
1955- .registration-prompt .link-button {1917+ .registration-prompt span {
1956- color: #e65100;1918+ flex: 1;
1957- text-decoration: underline;
1958- cursor: pointer;
1959- background: none;
1960- border: none;
1961- padding: 0;
1962- font: inherit;
1963 }1919 }
1964 1920
1965- .registration-prompt .link-button:hover {1921+ .registration-prompt button {
1966- color: #ef6c00;1922+ min-width: 0;
1967- text-decoration: none;1923+ padding: 0 10px;
1924+ white-space: nowrap;
1968 }1925 }
1969 1926
1970 .chat-window.minimized {1927 .chat-window.minimized {

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