v0.1.4 2490b00

chat.svelte.ts RawHistoryFolder

1import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat';
2import { createSafeUser } from '../types/chat';
3import type { 
4    SendMessageRequest, 
5    SendMessageResponse, 
6    GetMessagesResponse 
7} from '../types/payloads';
8import { invalidate } from '$app/navigation';
9import { env } from '$env/dynamic/public';
10
11// Use the public environment variable with a fallback
12const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
13
14class ChatState {
15    private users = $state<SafeUser[]>([]);
16    private messages = $state<Message[]>([]);
17    private currentUser = $state<SafeUser | null>(null);
18    private eventSource: EventSource | null = null;
19    private reconnectAttempts = 0;
20    private maxReconnectAttempts = 5;
21    private reconnectDelay = 1000; // Start with 1 second
22    private isInitializing = $state(false);
23    private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
24    private userCache = $state<Record<string, SafeUser>>({});
25    private sseError = $state<string | null>(null);
26    private sseRetryAfter = $state<number | null>(null);
27    private connectionTimeout: ReturnType<typeof setTimeout> | null = null;
28    private isReconnecting = $state(false);
29    private isSettingUser = $state(false);
30    private isConnecting = $state(false);
31    private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null;
32    private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
33    private _lastEnrichmentKey: string = '';
34    // Flag to avoid re-registering SSE event handlers
35    private sseHandlersRegistered = false;
36    private lastBuddyListUpdate = 0;
37    private lastBuddyListHash = '';
38    private hasMoreMessages = $state(false);
39    private publicPollingInterval: ReturnType<typeof setInterval> | null = null;
40
41    public async reinitialize() {
42        if (this.isInitializing) {
43            console.debug('Reinitialize already in progress, skipping.');
44            return;
45        }
46        this.isInitializing = true;
47        console.debug('Reinitializing chat state...');
48        if (this.eventSource) {
49            this.eventSource.close();
50            this.eventSource = null;
51        }
52        this.reconnectAttempts = 0;
53        this.reconnectDelay = 1000;
54        this.messages = [];
55        // Ensure currentRoomId is set
56        this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
57        
58        if (this.currentUser) {
59            console.debug('Waiting for session cookie update before setting up SSE...');
60            await new Promise(resolve => setTimeout(resolve, 1000));
61            // First set up SSE to receive status updates
62            this.setupSSE();
63            // Then initialize messages and room users
64            await Promise.all([
65                this.initializeMessages(),
66                this.initializeRoomUsers()
67            ]);
68        }
69        this.isInitializing = false;
70    }
71
72    getSSEError() {
73        return {
74            error: this.sseError,
75            retryAfter: this.sseRetryAfter
76        };
77    }
78
79    // User methods
80    getCurrentUser() {
81        return this.currentUser;
82    }
83
84    async setCurrentUser(user: User | SafeUser | null) {
85        console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);
86        
87        // Prevent concurrent setCurrentUser calls
88        if (this.isSettingUser) {
89            console.debug('Already setting user, skipping');
90            return;
91        }
92        
93        this.isSettingUser = true;
94        
95        try {
96            // If user is null and we already have no user, skip
97            if (!user && !this.currentUser) {
98                console.debug('No user to set and no current user, skipping');
99                return;
100            }
101
102            // Convert to SafeUser if full User object is passed
103            const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;
104            
105            // If same user is being set, check SSE connection
106            if (safeUser?.id === this.currentUser?.id) {
107                if (!this.eventSource) {
108                    console.debug('Same user being set but no active SSE connection, reinitializing.');
109                } else {
110                    console.debug('Same user being set with active connection, skipping.');
111                    return;
112                }
113            }
114            
115            // Clear existing messages before setting new user
116            this.messages = [];
117            this.currentUser = safeUser;
118            
119            if (safeUser) {
120                // Update user cache with current user
121                this.userCache[safeUser.id] = safeUser;
122                
123                // Only reinitialize if we have a valid user
124                await this.reinitialize();
125            } else {
126                // Cleanup when user is removed
127                if (this.eventSource) {
128                    this.eventSource.close();
129                    this.eventSource = null;
130                }
131                this.messages = [];
132                this.users = [];
133                this.userCache = {};
134            }
135        } finally {
136            this.isSettingUser = false;
137        }
138    }
139
140    private async initializeRoomUsers() {
141        console.debug('Initializing room users for room:', this.currentRoomId);
142        try {
143            const isPublic = !this.currentUser;
144            const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
145            console.debug('Fetching room users from:', url);
146            
147            const response = await fetch(url, {
148                credentials: 'include'
149            });
150            
151            if (!response.ok) {
152                throw new Error('Failed to fetch room users');
153            }
154
155            const data = await response.json();
156            if (!data.success) {
157                throw new Error(data.error);
158            }
159
160            console.debug('Fetched room buddy list:', data.buddyList);
161            // Merge fetched buddy list with existing cached users to preserve user details even if offline
162            const mergedUsers = new Map<string, typeof data.buddyList[0]>();
163            // Add existing cached users
164            Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
165
166            // Overwrite/add with fetched buddy list
167            data.buddyList.forEach((user: typeof data.buddyList[0]) => {
168                mergedUsers.set(user.id, user);
169            });
170
171            this.users = Array.from(mergedUsers.values());
172
173            // Update cache with merged users
174            this.users.forEach(user => {
175                this.userCache[user.id] = user;
176            });
177        } catch (error) {
178            console.debug('Error fetching room users:', error);
179            this.users = [];
180        }
181    }
182
183    getOnlineUsers() {
184        // Sort users by status: online first, then away, then busy, then offline
185        return [...this.users].sort((a: SafeUser, b: SafeUser) => {
186            const statusOrder: Record<SafeUser['status'], number> = {
187                'online': 0,
188                'away': 1,
189                'busy': 2,
190                'offline': 3
191            };
192            return statusOrder[a.status] - statusOrder[b.status];
193        });
194    }
195
196    getUserById(userId: string) {
197        // First check the cache
198        if (this.userCache[userId]) {
199            return this.userCache[userId];
200        }
201
202        // Return a fallback object and cache it to avoid repeated fetches
203        const fallback: SafeUser = {
204            id: userId,
205            nickname: 'Unknown User',
206            status: 'offline',
207            lastSeen: null,
208            avatarUrl: null
209        };
210        this.userCache[userId] = fallback;
211        console.debug('getUserById: returning fallback for missing user', { userId, fallback });
212        return fallback;
213    }
214
215    updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
216        console.debug('Updating user status with:', {
217            userId,
218            status,
219            lastSeen,
220            currentCache: this.userCache[userId],
221            currentUsers: this.users.find(u => u.id === userId)
222        });
223
224        const existingUser = this.userCache[userId];
225        if (
226            existingUser &&
227            existingUser.status === status &&
228            lastSeen &&
229            Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000
230        ) {
231            console.debug('Skipping redundant status update for user:', userId);
232            return;
233        }
234        
235        // Try to get the user from cache first
236        let user: SafeUser | undefined = this.userCache[userId];
237        
238        // If not in cache, try to find in users array
239        if (!user) {
240            const foundUser = this.users.find((u: SafeUser) => u.id === userId);
241            if (foundUser) {
242                user = foundUser;
243            }
244        }
245        
246        const now = Date.now();
247        if (user) {
248            const updatedUser: SafeUser = {
249                ...user,
250                status,
251                lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
252            };
253            
254            // Update cache first
255            this.userCache[userId] = updatedUser;
256            
257            // Then update or add to users array
258            const index = this.users.findIndex((u: SafeUser) => u.id === userId);
259            if (index !== -1) {
260                // Create new array with updated user
261                this.users = [
262                    ...this.users.slice(0, index),
263                    updatedUser,
264                    ...this.users.slice(index + 1)
265                ];
266            } else {
267                this.users = [...this.users, updatedUser];
268            }
269            
270            console.debug('User status updated:', {
271                userId,
272                newStatus: status,
273                newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(),
274                updatedInCache: this.userCache[userId].status === status,
275                updatedInList: this.users.find(u => u.id === userId)?.status === status
276            });
277        } else {
278            // If user not found anywhere, fetch from API
279            console.debug('User not found in cache or list, fetching from API:', userId);
280            fetch(`/api/users/${userId}`, { credentials: 'include' })
281                .then(response => response.json())
282                .then(data => {
283                    if (data.success && data.user) {
284                        const newUser: SafeUser = {
285                            ...data.user,
286                            status,
287                            lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
288                        };
289                        // Update both cache and users array
290                        this.userCache[userId] = newUser;
291                        this.users = [...this.users, newUser];
292                        
293                        console.debug('User fetched and status updated:', {
294                            userId,
295                            status: newUser.status,
296                            lastSeen: new Date(newUser.lastSeen || now).toISOString(),
297                            addedToCache: !!this.userCache[userId],
298                            addedToList: this.users.some(u => u.id === userId)
299                        });
300                    }
301                })
302                .catch(error => console.error('Error fetching user data:', error));
303        }
304    }
305
306    // Message methods
307    getMessages() {
308        const key = this.messages.map(m => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
309        if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) {
310            return this._memoizedEnrichedMessages;
311        }
312        this._lastEnrichmentKey = key;
313        console.debug('Enriching messages with user data', {
314            messagesCount: this.messages.length,
315            userCacheSize: Object.keys(this.userCache).length
316        });
317        this._memoizedEnrichedMessages = this.enrichMessages(this.messages);
318        return this._memoizedEnrichedMessages;
319    }
320
321    public prependMessages(newMessages: Message[], hasMore?: boolean) {
322        console.debug('Prepending messages:', { count: newMessages.length });
323        // Get unique user IDs from new messages that aren't in the cache
324        const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId));
325        const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
326
327        // Fetch missing user data in parallel
328        if (missingUserIds.length > 0) {
329            console.debug('Fetching missing user data for IDs:', missingUserIds);
330            Promise.all(missingUserIds.map(async (userId) => {
331                try {
332                    const userResponse = await fetch(`/api/users/${userId}`, {
333                        credentials: 'include'
334                    });
335                    if (userResponse.ok) {
336                        const userData = await userResponse.json();
337                        if (userData.success && userData.user) {
338                            this.userCache[userId] = userData.user;
339                            // Only add to users array if not already present
340                            if (!this.users.some(u => u.id === userId)) {
341                                this.users = [...this.users, userData.user];
342                            }
343                        }
344                    }
345                } catch (error) {
346                    console.debug('Error fetching user data:', { userId, error });
347                }
348            }));
349        }
350
351        // Ensure incoming messages are sorted in ascending order before prepending
352        const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
353        const uniqueNewMessages = sortedNewMessages.filter(msg => !this.messages.some(m => m.id === msg.id));
354        this.messages = [...uniqueNewMessages, ...this.messages];
355        
356        // Update hasMore if provided
357        if (hasMore !== undefined) {
358            this.hasMoreMessages = hasMore;
359        }
360    }
361
362    // Add method to update user cache for public access
363    updateUserCache(users: (User | SafeUser)[]) {
364        console.debug('Updating user cache with users:', users.map(u => ({ ...u, password: '[REDACTED]' })));
365        // Merge incoming users with the existing cache, ensuring we only store SafeUser objects
366        users.forEach(user => {
367            const safeUser = 'password' in user ? createSafeUser(user) : user;
368            if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {
369                this.userCache[user.id] = safeUser;
370                // Update users array if not already present or if status changed
371                const existingIndex = this.users.findIndex(u => u.id === user.id);
372                if (existingIndex === -1) {
373                    this.users = [...this.users, safeUser];
374                } else if (safeUser.status !== this.users[existingIndex].status) {
375                    this.users = [
376                        ...this.users.slice(0, existingIndex),
377                        safeUser,
378                        ...this.users.slice(existingIndex + 1)
379                    ];
380                }
381            }
382        });
383    }
384
385    // Add method to update online users for public access
386    updateOnlineUsers(users: (User | SafeUser)[]) {
387        const now = Date.now();
388        // Convert all users to SafeUser and create a hash
389        const safeUsers = users.map(user => 'password' in user ? createSafeUser(user) : user);
390        const newHash = JSON.stringify(safeUsers);
391        
392        // Only update if the data has changed
393        if (newHash !== this.lastBuddyListHash) {
394            const statusCounts = safeUsers.reduce((acc, user) => {
395                acc[user.status] = (acc[user.status] || 0) + 1;
396                return acc;
397            }, {} as Record<string, number>);
398
399            console.debug('Updating online users (data changed):', {
400                total: safeUsers.length,
401                byStatus: statusCounts,
402                timestamp: new Date(now).toISOString()
403            });
404
405            this.users = safeUsers;
406            // Also update cache
407            safeUsers.forEach(user => {
408                this.userCache[user.id] = user;
409            });
410            this.lastBuddyListHash = newHash;
411            this.lastBuddyListUpdate = now;
412        }
413    }
414
415    // Add method to update messages for public access
416    updateMessages(messages: Message[]) {
417        // Deduplicate the messages by their id in case duplicates exist
418        const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values());
419        // Sort messages in ascending order (oldest first, newest at bottom)
420        this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
421    }
422
423    // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE.
424    async initializeMessages() {
425        try {
426            // Build the URL with appropriate parameters
427            const params = new URLSearchParams();
428            if (!this.currentUser) {
429                params.append('public', 'true');
430            }
431            params.append('roomId', this.currentRoomId);
432
433            const response = await fetch(`/api/chat/messages?${params}`, {
434                credentials: 'include'
435            });
436            if (!response.ok) throw new Error('Failed to fetch messages');
437            
438            const data = await response.json() as GetMessagesResponse;
439            if (!data.success) {
440                throw new Error(data.error);
441            }
442
443            // Get unique user IDs from messages that aren't in the cache
444            const uniqueUserIds = new Set(data.messages.map(msg => msg.senderId));
445            const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
446
447            // Fetch missing user data in parallel
448            if (missingUserIds.length > 0) {
449                console.debug('Fetching missing user data for IDs:', missingUserIds);
450                await Promise.all(missingUserIds.map(async (userId) => {
451                    try {
452                        const userResponse = await fetch(`/api/users/${userId}`, {
453                            credentials: 'include'
454                        });
455                        if (userResponse.ok) {
456                            const userData = await userResponse.json();
457                            if (userData.success && userData.user) {
458                                this.userCache[userId] = userData.user;
459                                // Only add to users array if not already present
460                                if (!this.users.some(u => u.id === userId)) {
461                                    this.users = [...this.users, userData.user];
462                                }
463                            }
464                        }
465                    } catch (error) {
466                        console.debug('Error fetching user data:', { userId, error });
467                    }
468                }));
469            }
470
471            // Sort messages in ascending order (oldest first, most recent at the bottom)
472            this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
473            
474            // Update hasMore based on response
475            if ('hasMore' in data) {
476                this.hasMoreMessages = data.hasMore;
477            }
478
479            console.debug('Messages initialized:', {
480                count: data.messages.length,
481                isAuthenticated: !!this.currentUser,
482                hasMore: this.hasMoreMessages
483            });
484        } catch (error) {
485            console.debug('Error fetching initial messages:', error);
486            this.messages = [];
487            this.hasMoreMessages = false;
488        }
489    }
490
491    async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> {
492        const user = this.getCurrentUser();
493        if (!user) {
494            console.debug('Cannot send message: No current user');
495            return {
496                success: false,
497                error: 'Not logged in'
498            };
499        }
500        
501        try {
502            const payload: SendMessageRequest = {
503                content,
504                type,
505                userId: user.id,
506                chatRoomId: DEFAULT_CHAT_ROOM_ID
507            };
508
509            console.debug('Sending message with payload:', payload);
510
511            const response = await fetch('/api/chat/messages', {
512                method: 'POST',
513                headers: {
514                    'Content-Type': 'application/json',
515                },
516                credentials: 'include',
517                body: JSON.stringify(payload)
518            });
519
520            if (!response.ok) {
521                const errorData = await response.json();
522                throw new Error(errorData.error || 'Failed to save message');
523            }
524
525            const data = await response.json() as SendMessageResponse;
526            if (!data.success) {
527                throw new Error(data.error);
528            }
529
530            console.debug('Message sent successfully:', data.message);
531            await invalidate('chat:messages');
532            return data;
533        } catch (error) {
534            console.debug('Error sending message:', error);
535            return {
536                success: false,
537                error: 'Failed to send message'
538            };
539        }
540    }
541
542    private setupSSE() {
543        // Prevent creating multiple SSE connections if one is already active.
544        if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
545            console.debug('setupSSE: Active SSE connection exists, skipping setup.');
546            return;
547        }
548        // Clear any existing timeouts
549        if (this.connectionTimeout) {
550            clearTimeout(this.connectionTimeout);
551            this.connectionTimeout = null;
552        }
553        
554        if (this.connectionAttemptTimeout) {
555            clearTimeout(this.connectionAttemptTimeout);
556            this.connectionAttemptTimeout = null;
557        }
558        
559        // Debounce connection attempts
560        this.connectionAttemptTimeout = setTimeout(() => {
561            if (!this.currentUser) {
562                console.debug('No current user, skipping SSE connection');
563                return;
564            }
565            
566            if (this.isConnecting) {
567                console.debug('Already connecting to SSE, skipping');
568                return;
569            }
570            
571            this.connectSSE();
572        }, 1000);
573    }
574
575    private async connectSSE() {
576        if (this.isConnecting) {
577            console.debug('Already connecting to SSE, skipping');
578            return;
579        }
580        
581        this.isConnecting = true;
582        
583        try {
584            // Ensure clean disconnect of any existing connection
585            if (this.eventSource) {
586                console.debug('Closing existing SSE connection');
587                this.eventSource.close();
588                this.eventSource = null;
589            }
590            
591            console.debug('Connecting to SSE...');
592            this.eventSource = new EventSource('/api/sse', { withCredentials: true });
593            // Reset the handlers flag for the new connection
594            this.sseHandlersRegistered = false;
595            
596            this.eventSource.onopen = () => {
597                console.debug('SSE connection established');
598                this.reconnectAttempts = 0;
599                this.reconnectDelay = 1000;
600                this.sseError = null;
601                this.sseRetryAfter = null;
602                this.isReconnecting = false;
603            };
604            
605            // Set up error handling
606            this.eventSource.onerror = (error) => {
607                console.debug('SSE connection error:', error);
608                if (this.eventSource?.readyState === EventSource.CLOSED) {
609                    this.handleDisconnection();
610                }
611            };
612            
613            // Register event handlers (only once)
614            this.setupMessageHandlers();
615            
616        } catch (error) {
617            console.debug('Error setting up SSE connection:', error);
618            this.handleDisconnection();
619        } finally {
620            this.isConnecting = false;
621        }
622    }
623
624    // Register SSE event handlers only once per connection.
625    private setupMessageHandlers() {
626        if (this.sseHandlersRegistered || !this.eventSource) {
627            return;
628        }
629
630        // Listen for chat messages
631        this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {
632            try {
633                const messageData = JSON.parse(event.data) as Message;
634                console.debug('Received chat message via SSE:', messageData);
635                // Deduplicate and update messages array with the new message
636                this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values());
637                // Sort messages so the newest are at the bottom
638                this.messages.sort((a, b) => a.timestamp - b.timestamp);
639                // Ensure the sender's data is available
640                await this.ensureUserData(messageData.senderId);
641            } catch (error) {
642                console.debug('Error handling SSE chat message:', error);
643            }
644        });
645        
646        // Modify buddy list update handler
647        this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => {
648            try {
649                const buddyList = JSON.parse(event.data) as SafeUser[];
650                this.updateOnlineUsers(buddyList);
651            } catch (error) {
652                console.debug('Error handling buddy list update via SSE:', error);
653            }
654        });
655
656        this.sseHandlersRegistered = true;
657    }
658
659    private async ensureUserData(userId: string) {
660        if (this.userCache[userId]) return;
661        
662        console.debug('Fetching user data for:', userId);
663        try {
664            const response = await fetch(`/api/users/${userId}`, {
665                credentials: 'include'
666            });
667            if (response.ok) {
668                const userData = await response.json();
669                if (userData.success && userData.user) {
670                    this.userCache[userId] = userData.user;
671                    if (!this.users.some(u => u.id === userId)) {
672                        this.users = [...this.users, userData.user];
673                    }
674                }
675            }
676        } catch (error) {
677            console.debug('Error fetching user data:', error);
678        }
679    }
680
681    private handleDisconnection() {
682        console.debug('SSE connection closed, reconnecting...');
683        this.isReconnecting = true;
684        this.reconnectAttempts++;
685        if (this.reconnectAttempts > this.maxReconnectAttempts) {
686            console.debug('Max reconnect attempts reached, giving up');
687            if (this.eventSource) {
688                this.eventSource.close();
689            }
690            this.eventSource = null;
691            return;
692        }
693        this.reconnectDelay *= 2; // Exponential backoff
694        // Ensure any existing connection is properly closed before reconnecting
695        if (this.eventSource) {
696            this.eventSource.close();
697            this.eventSource = null;
698        }
699        // Reset the handlers flag so that the new connection gets its event listeners registered fresh.
700        this.sseHandlersRegistered = false;
701        this.setupSSE();
702    }
703
704    enrichMessages(messages: Message[]): EnrichedMessage[] {
705        console.debug('Enriching messages with user data', { messagesCount: messages.length, userCacheSize: Object.keys(this.userCache).length });
706        return messages.map(message => ({
707            ...message,
708            user: this.userCache[message.senderId] || { 
709                id: message.senderId,
710                nickname: 'Unknown User',
711                status: 'offline',
712                avatarUrl: null,
713                lastSeen: null
714            }
715        }));
716    }
717
718    getDefaultChatRoomId() {
719        return DEFAULT_CHAT_ROOM_ID;
720    }
721}
722
723export const chatState = new ChatState();

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