detached cce43cf

feat: Implement message caching for improved chat performance

- Add message cache initialization on server start
- Fetch and cache messages from database with room-specific support
- Enhance GET endpoint to use cached messages when available
- Implement fallback to database query if cache initialization fails
- Add logging for cache initialization and message retrieval
- Support room-specific message fetching with default room handling
commit
cce43cf979b5b6675cca5ac82dadadffd388e127
author
Kit · · 2025-02-14 13:48 UTC
parent
28660c8
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
2 files · +244 -14
A src/lib/cache/message-cache.ts +175 -0
@@ -0,0 +1,175 @@
1+import type { Message } from '$lib/types/chat';
2+import { DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
3+
4+interface RoomCache {
5+ messages: Message[];
6+ lastUpdated: number;
7+}
8+
9+class MessageCache {
10+ private static instance: MessageCache;
11+ private rooms: Map<string, RoomCache>;
12+ private readonly maxMessagesPerRoom: number;
13+ private readonly maxRooms: number;
14+ private readonly cacheExpiryMs: number;
15+
16+ private constructor() {
17+ this.rooms = new Map();
18+ this.maxMessagesPerRoom = 100; // Keep last 100 messages per room
19+ this.maxRooms = 100; // Maximum number of rooms to cache
20+ this.cacheExpiryMs = 30 * 60 * 1000; // 30 minutes cache expiry
21+ console.debug('[MessageCache] Initialized with settings:', {
22+ maxMessagesPerRoom: this.maxMessagesPerRoom,
23+ maxRooms: this.maxRooms,
24+ cacheExpiryMs: this.cacheExpiryMs
25+ });
26+ }
27+
28+ public static getInstance(): MessageCache {
29+ if (!MessageCache.instance) {
30+ MessageCache.instance = new MessageCache();
31+ }
32+ return MessageCache.instance;
33+ }
34+
35+ private cleanExpiredRooms(): void {
36+ const now = Date.now();
37+ let expiredCount = 0;
38+
39+ for (const [roomId, cache] of this.rooms.entries()) {
40+ if (now - cache.lastUpdated > this.cacheExpiryMs) {
41+ this.rooms.delete(roomId);
42+ expiredCount++;
43+ }
44+ }
45+
46+ if (expiredCount > 0) {
47+ console.debug('[MessageCache] Cleaned expired rooms:', { expiredCount });
48+ }
49+ }
50+
51+ private ensureRoomCapacity(): void {
52+ // If we're over capacity, remove the least recently used rooms
53+ if (this.rooms.size > this.maxRooms) {
54+ const sortedRooms = Array.from(this.rooms.entries())
55+ .sort(([, a], [, b]) => a.lastUpdated - b.lastUpdated);
56+
57+ const toRemove = sortedRooms.slice(0, sortedRooms.length - this.maxRooms);
58+ for (const [roomId] of toRemove) {
59+ this.rooms.delete(roomId);
60+ }
61+
62+ console.debug('[MessageCache] Removed LRU rooms:', {
63+ removedCount: toRemove.length,
64+ remainingRooms: this.rooms.size
65+ });
66+ }
67+ }
68+
69+ public async initialize(messages: Message[]): Promise<void> {
70+ console.debug('[MessageCache] Initializing with messages:', messages.length);
71+ // Group messages by chat room
72+ const messagesByRoom = new Map<string, Message[]>();
73+
74+ for (const message of messages) {
75+ const roomMessages = messagesByRoom.get(message.chatRoomId) || [];
76+ roomMessages.push(message);
77+ messagesByRoom.set(message.chatRoomId, roomMessages);
78+ }
79+
80+ const now = Date.now();
81+
82+ // Sort messages by timestamp and limit to maxMessagesPerRoom
83+ for (const [roomId, roomMessages] of messagesByRoom) {
84+ roomMessages.sort((a, b) => a.timestamp - b.timestamp);
85+ this.rooms.set(roomId, {
86+ messages: roomMessages.slice(-this.maxMessagesPerRoom),
87+ lastUpdated: now
88+ });
89+ }
90+
91+ this.ensureRoomCapacity();
92+ console.debug('[MessageCache] Initialization complete:', {
93+ roomCount: this.rooms.size,
94+ totalMessages: messages.length
95+ });
96+ }
97+
98+ public getMessages(roomId: string = DEFAULT_CHAT_ROOM_ID, limit?: number): Message[] {
99+ const roomCache = this.rooms.get(roomId);
100+ if (!roomCache) {
101+ return [];
102+ }
103+
104+ // Update last accessed time
105+ roomCache.lastUpdated = Date.now();
106+
107+ if (limit) {
108+ return roomCache.messages.slice(-limit);
109+ }
110+ return [...roomCache.messages]; // Return a copy to prevent external modifications
111+ }
112+
113+ public addMessage(message: Message): void {
114+ // Clean expired rooms periodically
115+ this.cleanExpiredRooms();
116+
117+ const roomCache = this.rooms.get(message.chatRoomId) || {
118+ messages: [],
119+ lastUpdated: Date.now()
120+ };
121+
122+ roomCache.messages.push(message);
123+ roomCache.lastUpdated = Date.now();
124+
125+ // Keep only the last maxMessagesPerRoom messages
126+ if (roomCache.messages.length > this.maxMessagesPerRoom) {
127+ roomCache.messages.shift(); // Remove oldest message
128+ }
129+
130+ this.rooms.set(message.chatRoomId, roomCache);
131+ this.ensureRoomCapacity();
132+
133+ console.debug('[MessageCache] Added message to cache:', {
134+ messageId: message.id,
135+ roomId: message.chatRoomId,
136+ roomMessageCount: roomCache.messages.length,
137+ totalRooms: this.rooms.size
138+ });
139+ }
140+
141+ public getRoomMessageCount(roomId: string): number {
142+ return this.rooms.get(roomId)?.messages.length || 0;
143+ }
144+
145+ public clear(): void {
146+ this.rooms.clear();
147+ console.debug('[MessageCache] Cache cleared');
148+ }
149+
150+ public getCacheStats(): {
151+ roomCount: number;
152+ totalMessages: number;
153+ roomStats: { roomId: string; messageCount: number; lastUpdated: number }[];
154+ } {
155+ let totalMessages = 0;
156+ const roomStats = [];
157+
158+ for (const [roomId, cache] of this.rooms.entries()) {
159+ totalMessages += cache.messages.length;
160+ roomStats.push({
161+ roomId,
162+ messageCount: cache.messages.length,
163+ lastUpdated: cache.lastUpdated
164+ });
165+ }
166+
167+ return {
168+ roomCount: this.rooms.size,
169+ totalMessages,
170+ roomStats: roomStats.sort((a, b) => b.lastUpdated - a.lastUpdated)
171+ };
172+ }
173+}
174+
175+export const messageCache = MessageCache.getInstance(); ⏎̸
@@ -0,0 +1,175 @@
1+import type { Message } from '$lib/types/chat';
2+import { DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
3+
4+interface RoomCache {
5+ messages: Message[];
6+ lastUpdated: number;
7+}
8+
9+class MessageCache {
10+ private static instance: MessageCache;
11+ private rooms: Map<string, RoomCache>;
12+ private readonly maxMessagesPerRoom: number;
13+ private readonly maxRooms: number;
14+ private readonly cacheExpiryMs: number;
15+
16+ private constructor() {
17+ this.rooms = new Map();
18+ this.maxMessagesPerRoom = 100; // Keep last 100 messages per room
19+ this.maxRooms = 100; // Maximum number of rooms to cache
20+ this.cacheExpiryMs = 30 * 60 * 1000; // 30 minutes cache expiry
21+ console.debug('[MessageCache] Initialized with settings:', {
22+ maxMessagesPerRoom: this.maxMessagesPerRoom,
23+ maxRooms: this.maxRooms,
24+ cacheExpiryMs: this.cacheExpiryMs
25+ });
26+ }
27+
28+ public static getInstance(): MessageCache {
29+ if (!MessageCache.instance) {
30+ MessageCache.instance = new MessageCache();
31+ }
32+ return MessageCache.instance;
33+ }
34+
35+ private cleanExpiredRooms(): void {
36+ const now = Date.now();
37+ let expiredCount = 0;
38+
39+ for (const [roomId, cache] of this.rooms.entries()) {
40+ if (now - cache.lastUpdated > this.cacheExpiryMs) {
41+ this.rooms.delete(roomId);
42+ expiredCount++;
43+ }
44+ }
45+
46+ if (expiredCount > 0) {
47+ console.debug('[MessageCache] Cleaned expired rooms:', { expiredCount });
48+ }
49+ }
50+
51+ private ensureRoomCapacity(): void {
52+ // If we're over capacity, remove the least recently used rooms
53+ if (this.rooms.size > this.maxRooms) {
54+ const sortedRooms = Array.from(this.rooms.entries())
55+ .sort(([, a], [, b]) => a.lastUpdated - b.lastUpdated);
56+
57+ const toRemove = sortedRooms.slice(0, sortedRooms.length - this.maxRooms);
58+ for (const [roomId] of toRemove) {
59+ this.rooms.delete(roomId);
60+ }
61+
62+ console.debug('[MessageCache] Removed LRU rooms:', {
63+ removedCount: toRemove.length,
64+ remainingRooms: this.rooms.size
65+ });
66+ }
67+ }
68+
69+ public async initialize(messages: Message[]): Promise<void> {
70+ console.debug('[MessageCache] Initializing with messages:', messages.length);
71+ // Group messages by chat room
72+ const messagesByRoom = new Map<string, Message[]>();
73+
74+ for (const message of messages) {
75+ const roomMessages = messagesByRoom.get(message.chatRoomId) || [];
76+ roomMessages.push(message);
77+ messagesByRoom.set(message.chatRoomId, roomMessages);
78+ }
79+
80+ const now = Date.now();
81+
82+ // Sort messages by timestamp and limit to maxMessagesPerRoom
83+ for (const [roomId, roomMessages] of messagesByRoom) {
84+ roomMessages.sort((a, b) => a.timestamp - b.timestamp);
85+ this.rooms.set(roomId, {
86+ messages: roomMessages.slice(-this.maxMessagesPerRoom),
87+ lastUpdated: now
88+ });
89+ }
90+
91+ this.ensureRoomCapacity();
92+ console.debug('[MessageCache] Initialization complete:', {
93+ roomCount: this.rooms.size,
94+ totalMessages: messages.length
95+ });
96+ }
97+
98+ public getMessages(roomId: string = DEFAULT_CHAT_ROOM_ID, limit?: number): Message[] {
99+ const roomCache = this.rooms.get(roomId);
100+ if (!roomCache) {
101+ return [];
102+ }
103+
104+ // Update last accessed time
105+ roomCache.lastUpdated = Date.now();
106+
107+ if (limit) {
108+ return roomCache.messages.slice(-limit);
109+ }
110+ return [...roomCache.messages]; // Return a copy to prevent external modifications
111+ }
112+
113+ public addMessage(message: Message): void {
114+ // Clean expired rooms periodically
115+ this.cleanExpiredRooms();
116+
117+ const roomCache = this.rooms.get(message.chatRoomId) || {
118+ messages: [],
119+ lastUpdated: Date.now()
120+ };
121+
122+ roomCache.messages.push(message);
123+ roomCache.lastUpdated = Date.now();
124+
125+ // Keep only the last maxMessagesPerRoom messages
126+ if (roomCache.messages.length > this.maxMessagesPerRoom) {
127+ roomCache.messages.shift(); // Remove oldest message
128+ }
129+
130+ this.rooms.set(message.chatRoomId, roomCache);
131+ this.ensureRoomCapacity();
132+
133+ console.debug('[MessageCache] Added message to cache:', {
134+ messageId: message.id,
135+ roomId: message.chatRoomId,
136+ roomMessageCount: roomCache.messages.length,
137+ totalRooms: this.rooms.size
138+ });
139+ }
140+
141+ public getRoomMessageCount(roomId: string): number {
142+ return this.rooms.get(roomId)?.messages.length || 0;
143+ }
144+
145+ public clear(): void {
146+ this.rooms.clear();
147+ console.debug('[MessageCache] Cache cleared');
148+ }
149+
150+ public getCacheStats(): {
151+ roomCount: number;
152+ totalMessages: number;
153+ roomStats: { roomId: string; messageCount: number; lastUpdated: number }[];
154+ } {
155+ let totalMessages = 0;
156+ const roomStats = [];
157+
158+ for (const [roomId, cache] of this.rooms.entries()) {
159+ totalMessages += cache.messages.length;
160+ roomStats.push({
161+ roomId,
162+ messageCount: cache.messages.length,
163+ lastUpdated: cache.lastUpdated
164+ });
165+ }
166+
167+ return {
168+ roomCount: this.rooms.size,
169+ totalMessages,
170+ roomStats: roomStats.sort((a, b) => b.lastUpdated - a.lastUpdated)
171+ };
172+ }
173+}
174+
175+export const messageCache = MessageCache.getInstance(); ⏎̸
M src/routes/api/chat/messages/+server.ts +69 -14
@@ -14,6 +14,7 @@ import { eq } from 'drizzle-orm';
1414 import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
1515 import { users } from '$lib/db/schema';
1616 import { ensureDefaultChatRoom } from '$lib/db/schema';
17+import { messageCache } from '$lib/cache/message-cache';
1718
1819 // Rate limiting configuration
1920 const INITIAL_COOLDOWN = 1000; // 1 second
@@ -47,9 +48,39 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
4748 return { canSend: true };
4849 }
4950
50-// GET endpoint: fetch messages from the DB
51+// Initialize message cache on server start
52+let isCacheInitialized = false;
53+async function initializeCache() {
54+ if (isCacheInitialized) return;
55+
56+ try {
57+ console.log('[Chat] Initializing message cache');
58+ // Fetch messages from all rooms, limited to last 100 per room
59+ const fetchedMessages = await db.select()
60+ .from(messages)
61+ .orderBy(desc(messages.timestamp))
62+ .limit(500); // Increased limit to accommodate multiple rooms
63+
64+ await messageCache.initialize(fetchedMessages.reverse());
65+ isCacheInitialized = true;
66+
67+ // Log cache stats after initialization
68+ const stats = messageCache.getCacheStats();
69+ console.log('[Chat] Message cache initialized successfully:', {
70+ roomCount: stats.roomCount,
71+ totalMessages: stats.totalMessages
72+ });
73+ } catch (error) {
74+ console.error('[Chat] Failed to initialize message cache:', error);
75+ // Don't set isCacheInitialized to true on error
76+ }
77+}
78+
79+// GET endpoint: fetch messages from cache or DB
5180 export async function GET({ request, locals }) {
5281 const isPublic = new URL(request.url).searchParams.get('public') === 'true';
82+ const roomId = new URL(request.url).searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
83+
5384 if (!isPublic && !locals.user) {
5485 throw error(401, 'Authentication required');
5586 }
@@ -58,22 +89,40 @@ export async function GET({ request, locals }) {
5889 // Ensure default chat room exists
5990 await ensureDefaultChatRoom(db);
6091
61- console.log('[Chat] Fetching messages', { isPublic });
62- const query = db.select()
63- .from(messages)
64- .orderBy(desc(messages.timestamp));
65-
66- if (isPublic) {
67- query.limit(10);
68- } else {
69- query.limit(50);
92+ // Initialize cache if needed
93+ if (!isCacheInitialized) {
94+ await initializeCache();
7095 }
96+
97+ console.log('[Chat] Fetching messages', {
98+ isPublic,
99+ fromCache: isCacheInitialized,
100+ roomId
101+ });
102+
103+ let fetchedMessages: Message[];
104+ if (isCacheInitialized) {
105+ // Get from cache
106+ fetchedMessages = messageCache.getMessages(roomId, isPublic ? 10 : 50);
107+ } else {
108+ // Fallback to DB if cache initialization failed
109+ const query = db.select()
110+ .from(messages)
111+ .where(eq(messages.chatRoomId, roomId))
112+ .orderBy(desc(messages.timestamp));
71113
72- const fetchedMessages = await query;
114+ if (isPublic) {
115+ query.limit(10);
116+ } else {
117+ query.limit(50);
118+ }
119+
120+ fetchedMessages = (await query).reverse();
121+ }
73122
74123 const response: GetMessagesResponse = {
75124 success: true,
76- messages: fetchedMessages.reverse()
125+ messages: fetchedMessages
77126 };
78127
79128 return new Response(
@@ -83,8 +132,8 @@ export async function GET({ request, locals }) {
83132 headers: { 'Content-Type': 'application/json' }
84133 }
85134 );
86- } catch {
87- console.log('[Chat] Error fetching messages');
135+ } catch (error) {
136+ console.log('[Chat] Error fetching messages:', error);
88137 const errorResponse: GetMessagesResponse = {
89138 success: false,
90139 error: 'Failed to fetch messages'
@@ -187,6 +236,7 @@ export async function POST({ request, locals }: { request: Request, locals: App.
187236 timestamp: Date.now()
188237 };
189238
239+ // Save to DB
190240 await db.insert(messages).values(newMessage);
191241 console.log('[Chat] Message saved in DB', {
192242 messageId: newMessage.id,
@@ -195,6 +245,11 @@ export async function POST({ request, locals }: { request: Request, locals: App.
195245 timestamp: newMessage.timestamp
196246 });
197247
248+ // Add to cache if initialized
249+ if (isCacheInitialized) {
250+ messageCache.addMessage(newMessage);
251+ }
252+
198253 // Broadcast the new message via the unified SSE emitter
199254 sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
200255
@@ -14,6 +14,7 @@ import { eq } from 'drizzle-orm';
14 import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';14 import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
15 import { users } from '$lib/db/schema';15 import { users } from '$lib/db/schema';
16 import { ensureDefaultChatRoom } from '$lib/db/schema';16 import { ensureDefaultChatRoom } from '$lib/db/schema';
17+import { messageCache } from '$lib/cache/message-cache';
17 18
18 // Rate limiting configuration19 // Rate limiting configuration
19 const INITIAL_COOLDOWN = 1000; // 1 second20 const INITIAL_COOLDOWN = 1000; // 1 second
@@ -47,9 +48,39 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
47 return { canSend: true };48 return { canSend: true };
48 }49 }
49 50
50-// GET endpoint: fetch messages from the DB51+// Initialize message cache on server start
52+let isCacheInitialized = false;
53+async function initializeCache() {
54+ if (isCacheInitialized) return;
55+
56+ try {
57+ console.log('[Chat] Initializing message cache');
58+ // Fetch messages from all rooms, limited to last 100 per room
59+ const fetchedMessages = await db.select()
60+ .from(messages)
61+ .orderBy(desc(messages.timestamp))
62+ .limit(500); // Increased limit to accommodate multiple rooms
63+
64+ await messageCache.initialize(fetchedMessages.reverse());
65+ isCacheInitialized = true;
66+
67+ // Log cache stats after initialization
68+ const stats = messageCache.getCacheStats();
69+ console.log('[Chat] Message cache initialized successfully:', {
70+ roomCount: stats.roomCount,
71+ totalMessages: stats.totalMessages
72+ });
73+ } catch (error) {
74+ console.error('[Chat] Failed to initialize message cache:', error);
75+ // Don't set isCacheInitialized to true on error
76+ }
77+}
78+
79+// GET endpoint: fetch messages from cache or DB
51 export async function GET({ request, locals }) {80 export async function GET({ request, locals }) {
52 const isPublic = new URL(request.url).searchParams.get('public') === 'true';81 const isPublic = new URL(request.url).searchParams.get('public') === 'true';
82+ const roomId = new URL(request.url).searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
83+
53 if (!isPublic && !locals.user) {84 if (!isPublic && !locals.user) {
54 throw error(401, 'Authentication required');85 throw error(401, 'Authentication required');
55 }86 }
@@ -58,22 +89,40 @@ export async function GET({ request, locals }) {
58 // Ensure default chat room exists89 // Ensure default chat room exists
59 await ensureDefaultChatRoom(db);90 await ensureDefaultChatRoom(db);
60 91
61- console.log('[Chat] Fetching messages', { isPublic });92+ // Initialize cache if needed
62- const query = db.select()93+ if (!isCacheInitialized) {
63- .from(messages)94+ await initializeCache();
64- .orderBy(desc(messages.timestamp));
65-
66- if (isPublic) {
67- query.limit(10);
68- } else {
69- query.limit(50);
70 }95 }
96+
97+ console.log('[Chat] Fetching messages', {
98+ isPublic,
99+ fromCache: isCacheInitialized,
100+ roomId
101+ });
102+
103+ let fetchedMessages: Message[];
104+ if (isCacheInitialized) {
105+ // Get from cache
106+ fetchedMessages = messageCache.getMessages(roomId, isPublic ? 10 : 50);
107+ } else {
108+ // Fallback to DB if cache initialization failed
109+ const query = db.select()
110+ .from(messages)
111+ .where(eq(messages.chatRoomId, roomId))
112+ .orderBy(desc(messages.timestamp));
71 113
72- const fetchedMessages = await query;114+ if (isPublic) {
115+ query.limit(10);
116+ } else {
117+ query.limit(50);
118+ }
119+
120+ fetchedMessages = (await query).reverse();
121+ }
73 122
74 const response: GetMessagesResponse = {123 const response: GetMessagesResponse = {
75 success: true,124 success: true,
76- messages: fetchedMessages.reverse()125+ messages: fetchedMessages
77 };126 };
78 127
79 return new Response(128 return new Response(
@@ -83,8 +132,8 @@ export async function GET({ request, locals }) {
83 headers: { 'Content-Type': 'application/json' }132 headers: { 'Content-Type': 'application/json' }
84 }133 }
85 );134 );
86- } catch {135+ } catch (error) {
87- console.log('[Chat] Error fetching messages');136+ console.log('[Chat] Error fetching messages:', error);
88 const errorResponse: GetMessagesResponse = {137 const errorResponse: GetMessagesResponse = {
89 success: false,138 success: false,
90 error: 'Failed to fetch messages'139 error: 'Failed to fetch messages'
@@ -187,6 +236,7 @@ export async function POST({ request, locals }: { request: Request, locals: App.
187 timestamp: Date.now()236 timestamp: Date.now()
188 };237 };
189 238
239+ // Save to DB
190 await db.insert(messages).values(newMessage);240 await db.insert(messages).values(newMessage);
191 console.log('[Chat] Message saved in DB', { 241 console.log('[Chat] Message saved in DB', {
192 messageId: newMessage.id,242 messageId: newMessage.id,
@@ -195,6 +245,11 @@ export async function POST({ request, locals }: { request: Request, locals: App.
195 timestamp: newMessage.timestamp245 timestamp: newMessage.timestamp
196 });246 });
197 247
248+ // Add to cache if initialized
249+ if (isCacheInitialized) {
250+ messageCache.addMessage(newMessage);
251+ }
252+
198 // Broadcast the new message via the unified SSE emitter253 // Broadcast the new message via the unified SSE emitter
199 sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });254 sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
200 255

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