detached 9e9498f

feat: looks like we have a first chat

commit
9e9498f4e99f71c38699a07856743fd6b334c458
author
Kit · · 2025-02-09 17:56 UTC
parent
b5d8846
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
8 files · +392 -3
M package.json +3 -0
@@ -42,5 +42,8 @@
4242 "typescript-eslint": "^8.20.0",
4343 "vite": "^6.0.0",
4444 "vitest": "^3.0.0"
45+ },
46+ "dependencies": {
47+ "xp.css": "^0.2.6"
4548 }
4649 }
@@ -42,5 +42,8 @@
42 "typescript-eslint": "^8.20.0",42 "typescript-eslint": "^8.20.0",
43 "vite": "^6.0.0",43 "vite": "^6.0.0",
44 "vitest": "^3.0.0"44 "vitest": "^3.0.0"
45+ },
46+ "dependencies": {
47+ "xp.css": "^0.2.6"
45 }48 }
46 }49 }
M pnpm-lock.yaml +9 -0 generated file: collapsed
M src/app.css +2 -0
@@ -1,3 +1,5 @@
11 @import 'tailwindcss/base';
22 @import 'tailwindcss/components';
33 @import 'tailwindcss/utilities';
4+@import 'xp.css';
5+
@@ -1,3 +1,5 @@
1 @import 'tailwindcss/base';1 @import 'tailwindcss/base';
2 @import 'tailwindcss/components';2 @import 'tailwindcss/components';
3 @import 'tailwindcss/utilities';3 @import 'tailwindcss/utilities';
4+@import 'xp.css';
5+
A src/lib/components/chat-room.svelte +196 -0
@@ -0,0 +1,196 @@
1+<script lang="ts">
2+import { chatState } from '../states/chat.svelte';
3+import type { ChatMessage, User } from '../types/chat';
4+import { onMount } from 'svelte';
5+
6+let windowWidth = $state(800);
7+let windowHeight = $state(600);
8+let windowX = $state(0);
9+let windowY = $state(0);
10+let currentMessage = $state('');
11+
12+// Reactive state using derived values
13+let messages = $derived(chatState.getMessages());
14+let onlineUsers = $derived(chatState.getOnlineUsers());
15+let currentUser = $derived(chatState.getCurrentUser());
16+
17+onMount(() => {
18+ const centerWindow = () => {
19+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
20+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
21+ };
22+ centerWindow();
23+ window.addEventListener('resize', centerWindow);
24+ return () => window.removeEventListener('resize', centerWindow);
25+});
26+
27+function handleSubmit() {
28+ if (currentMessage.trim()) {
29+ chatState.sendMessage(currentMessage);
30+ currentMessage = '';
31+ }
32+}
33+
34+function formatTimestamp(date: Date) {
35+ return date.toLocaleTimeString('en-US', {
36+ hour: 'numeric',
37+ minute: '2-digit',
38+ hour12: true
39+ });
40+}
41+
42+function getStatusIcon(status: User['status']) {
43+ switch (status) {
44+ case 'online': return '🟢';
45+ case 'away': return '🌙';
46+ case 'busy': return '🔴';
47+ default: return '⚫';
48+ }
49+}
50+</script>
51+
52+<div
53+ class="window"
54+ style:width="{windowWidth}px"
55+ style:height="{windowHeight}px"
56+ style:position="absolute"
57+ style:left="{windowX}px"
58+ style:top="{windowY}px"
59+>
60+ <div class="title-bar">
61+ <div class="title-bar-text">AIM Chat Room - {currentUser?.nickname}</div>
62+ <div class="title-bar-controls">
63+ <button aria-label="Minimize"></button>
64+ <button aria-label="Maximize"></button>
65+ <button aria-label="Close"></button>
66+ </div>
67+ </div>
68+
69+ <div class="window-body" style="display: flex; height: calc(100% - 32px); margin: 0; padding: 8px;">
70+ <!-- Chat content area -->
71+ <div style="flex: 1; display: flex; flex-direction: column; margin-right: 8px;">
72+ <div
73+ class="sunken-panel chat-area"
74+ style="flex: 1; margin-bottom: 8px; padding: 8px; overflow-y: auto;"
75+ >
76+ {#each messages as message}
77+ <div class="message {message.type} text">
78+ {#if message.type === 'emote'}
79+ <span class="timestamp">{formatTimestamp(message.timestamp)}</span>
80+ <span class="emote-text">{chatState.getUserById(message.userId)?.nickname} {message.content}</span>
81+ {:else}
82+ <span class="timestamp">{formatTimestamp(message.timestamp)}</span>
83+ <span class="nickname">{chatState.getUserById(message.userId)?.nickname}:</span>
84+ <span class="content">{message.content}</span>
85+ {/if}
86+ </div>
87+ {/each}
88+ </div>
89+
90+ <div class="field-row" style="margin: 0;">
91+ <input
92+ type="text"
93+ bind:value={currentMessage}
94+ style="flex: 1;"
95+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
96+ placeholder="Type a message..."
97+ />
98+ <button onclick={handleSubmit}>Send</button>
99+ </div>
100+ </div>
101+
102+ <!-- Online users list -->
103+ <div
104+ class="sunken-panel users-list"
105+ style="width: 150px; padding: 8px; overflow-y: auto;"
106+ >
107+ <p style="margin: 0 0 8px 0;"><strong>Buddy List</strong></p>
108+ {#each onlineUsers as user}
109+ <div class="user">
110+ <span class="status-icon">{getStatusIcon(user.status)}</span>
111+ <div class="user-info">
112+ <div class="nickname">{user.nickname}</div>
113+ {#if user.statusMessage}
114+ <div class="status-message">{user.statusMessage}</div>
115+ {/if}
116+ </div>
117+ </div>
118+ {/each}
119+ </div>
120+ </div>
121+</div>
122+
123+<style>
124+
125+
126+ .title-bar {
127+ height: 30px;
128+ }
129+
130+ .chat-area {
131+ font-family: 'Tahoma', sans-serif;
132+ line-height: 1.4;
133+ }
134+
135+ .message {
136+ margin-bottom: 8px;
137+ word-break: break-word;
138+ }
139+
140+ .message .timestamp {
141+ color: #666;
142+ font-size: 0.8em;
143+ margin-right: 8px;
144+ }
145+
146+ .message .nickname {
147+ font-weight: bold;
148+ color: #2d31a6;
149+ margin-right: 8px;
150+ }
151+
152+ .message.emote {
153+ color: #666;
154+ font-style: italic;
155+ }
156+
157+ .user {
158+ display: flex;
159+ align-items: flex-start;
160+ margin-bottom: 12px;
161+ gap: 6px;
162+ }
163+
164+ .status-icon {
165+ font-size: 0.8em;
166+ }
167+
168+ .user-info {
169+ flex: 1;
170+ min-width: 0;
171+ }
172+
173+ .user .nickname {
174+ font-weight: bold;
175+ margin-bottom: 2px;
176+ }
177+
178+ .user .status-message {
179+ font-size: 0.8em;
180+ color: #666;
181+ font-style: italic;
182+ overflow: hidden;
183+ text-overflow: ellipsis;
184+ white-space: nowrap;
185+ }
186+
187+ .sunken-panel {
188+ background: white;
189+ border: 2px inset #dfdfdf;
190+ }
191+
192+ input[type="text"] {
193+ font-size: 1rem;
194+ margin-right: 4px;
195+ }
196+</style>⏎̸
@@ -0,0 +1,196 @@
1+<script lang="ts">
2+import { chatState } from '../states/chat.svelte';
3+import type { ChatMessage, User } from '../types/chat';
4+import { onMount } from 'svelte';
5+
6+let windowWidth = $state(800);
7+let windowHeight = $state(600);
8+let windowX = $state(0);
9+let windowY = $state(0);
10+let currentMessage = $state('');
11+
12+// Reactive state using derived values
13+let messages = $derived(chatState.getMessages());
14+let onlineUsers = $derived(chatState.getOnlineUsers());
15+let currentUser = $derived(chatState.getCurrentUser());
16+
17+onMount(() => {
18+ const centerWindow = () => {
19+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
20+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
21+ };
22+ centerWindow();
23+ window.addEventListener('resize', centerWindow);
24+ return () => window.removeEventListener('resize', centerWindow);
25+});
26+
27+function handleSubmit() {
28+ if (currentMessage.trim()) {
29+ chatState.sendMessage(currentMessage);
30+ currentMessage = '';
31+ }
32+}
33+
34+function formatTimestamp(date: Date) {
35+ return date.toLocaleTimeString('en-US', {
36+ hour: 'numeric',
37+ minute: '2-digit',
38+ hour12: true
39+ });
40+}
41+
42+function getStatusIcon(status: User['status']) {
43+ switch (status) {
44+ case 'online': return '🟢';
45+ case 'away': return '🌙';
46+ case 'busy': return '🔴';
47+ default: return '⚫';
48+ }
49+}
50+</script>
51+
52+<div
53+ class="window"
54+ style:width="{windowWidth}px"
55+ style:height="{windowHeight}px"
56+ style:position="absolute"
57+ style:left="{windowX}px"
58+ style:top="{windowY}px"
59+>
60+ <div class="title-bar">
61+ <div class="title-bar-text">AIM Chat Room - {currentUser?.nickname}</div>
62+ <div class="title-bar-controls">
63+ <button aria-label="Minimize"></button>
64+ <button aria-label="Maximize"></button>
65+ <button aria-label="Close"></button>
66+ </div>
67+ </div>
68+
69+ <div class="window-body" style="display: flex; height: calc(100% - 32px); margin: 0; padding: 8px;">
70+ <!-- Chat content area -->
71+ <div style="flex: 1; display: flex; flex-direction: column; margin-right: 8px;">
72+ <div
73+ class="sunken-panel chat-area"
74+ style="flex: 1; margin-bottom: 8px; padding: 8px; overflow-y: auto;"
75+ >
76+ {#each messages as message}
77+ <div class="message {message.type} text">
78+ {#if message.type === 'emote'}
79+ <span class="timestamp">{formatTimestamp(message.timestamp)}</span>
80+ <span class="emote-text">{chatState.getUserById(message.userId)?.nickname} {message.content}</span>
81+ {:else}
82+ <span class="timestamp">{formatTimestamp(message.timestamp)}</span>
83+ <span class="nickname">{chatState.getUserById(message.userId)?.nickname}:</span>
84+ <span class="content">{message.content}</span>
85+ {/if}
86+ </div>
87+ {/each}
88+ </div>
89+
90+ <div class="field-row" style="margin: 0;">
91+ <input
92+ type="text"
93+ bind:value={currentMessage}
94+ style="flex: 1;"
95+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
96+ placeholder="Type a message..."
97+ />
98+ <button onclick={handleSubmit}>Send</button>
99+ </div>
100+ </div>
101+
102+ <!-- Online users list -->
103+ <div
104+ class="sunken-panel users-list"
105+ style="width: 150px; padding: 8px; overflow-y: auto;"
106+ >
107+ <p style="margin: 0 0 8px 0;"><strong>Buddy List</strong></p>
108+ {#each onlineUsers as user}
109+ <div class="user">
110+ <span class="status-icon">{getStatusIcon(user.status)}</span>
111+ <div class="user-info">
112+ <div class="nickname">{user.nickname}</div>
113+ {#if user.statusMessage}
114+ <div class="status-message">{user.statusMessage}</div>
115+ {/if}
116+ </div>
117+ </div>
118+ {/each}
119+ </div>
120+ </div>
121+</div>
122+
123+<style>
124+
125+
126+ .title-bar {
127+ height: 30px;
128+ }
129+
130+ .chat-area {
131+ font-family: 'Tahoma', sans-serif;
132+ line-height: 1.4;
133+ }
134+
135+ .message {
136+ margin-bottom: 8px;
137+ word-break: break-word;
138+ }
139+
140+ .message .timestamp {
141+ color: #666;
142+ font-size: 0.8em;
143+ margin-right: 8px;
144+ }
145+
146+ .message .nickname {
147+ font-weight: bold;
148+ color: #2d31a6;
149+ margin-right: 8px;
150+ }
151+
152+ .message.emote {
153+ color: #666;
154+ font-style: italic;
155+ }
156+
157+ .user {
158+ display: flex;
159+ align-items: flex-start;
160+ margin-bottom: 12px;
161+ gap: 6px;
162+ }
163+
164+ .status-icon {
165+ font-size: 0.8em;
166+ }
167+
168+ .user-info {
169+ flex: 1;
170+ min-width: 0;
171+ }
172+
173+ .user .nickname {
174+ font-weight: bold;
175+ margin-bottom: 2px;
176+ }
177+
178+ .user .status-message {
179+ font-size: 0.8em;
180+ color: #666;
181+ font-style: italic;
182+ overflow: hidden;
183+ text-overflow: ellipsis;
184+ white-space: nowrap;
185+ }
186+
187+ .sunken-panel {
188+ background: white;
189+ border: 2px inset #dfdfdf;
190+ }
191+
192+ input[type="text"] {
193+ font-size: 1rem;
194+ margin-right: 4px;
195+ }
196+</style>⏎̸
A src/lib/states/chat.svelte.ts +119 -0
@@ -0,0 +1,119 @@
1+import type { User, ChatMessage } from '../types/chat';
2+
3+// Mock users
4+const mockUsers: User[] = [
5+ {
6+ id: '1',
7+ nickname: 'CoolDude98',
8+ status: 'online',
9+ statusMessage: '🎮 Gaming all day!'
10+ },
11+ {
12+ id: '2',
13+ nickname: 'PixelPrincess',
14+ status: 'away',
15+ statusMessage: 'brb - food time'
16+ },
17+ {
18+ id: '3',
19+ nickname: 'WebSurfer2000',
20+ status: 'online',
21+ statusMessage: 'Surfing the information superhighway'
22+ },
23+ {
24+ id: '4',
25+ nickname: 'ChatBot3000',
26+ status: 'busy',
27+ statusMessage: '🤖 Computing...'
28+ }
29+];
30+
31+// Mock initial messages
32+const initialMessages: ChatMessage[] = [
33+ {
34+ id: '1',
35+ userId: '1',
36+ content: 'sup everyone! who\'s up for some gaming?',
37+ timestamp: new Date(Date.now() - 300000),
38+ type: 'chat'
39+ },
40+ {
41+ id: '2',
42+ userId: '2',
43+ content: 'is getting a snack',
44+ timestamp: new Date(Date.now() - 240000),
45+ type: 'emote'
46+ },
47+ {
48+ id: '3',
49+ userId: '3',
50+ content: 'Just downloaded the new Internet Explorer 6! So fast! 🚀',
51+ timestamp: new Date(Date.now() - 180000),
52+ type: 'chat'
53+ },
54+ {
55+ id: '4',
56+ userId: '4',
57+ content: '*beep boop* Greetings humans!',
58+ timestamp: new Date(Date.now() - 120000),
59+ type: 'chat'
60+ }
61+];
62+
63+// Helper function to generate message ID
64+const generateId = () => Math.random().toString(36).substring(2, 15);
65+
66+class ChatState {
67+ private users = $state(mockUsers);
68+ private messages = $state(initialMessages);
69+ private currentUser = $state(mockUsers[0]);
70+
71+ constructor() {
72+ // Initialize any necessary subscriptions or cleanup
73+ }
74+
75+ // User methods
76+ getCurrentUser() {
77+ return this.currentUser;
78+ }
79+
80+ getOnlineUsers() {
81+ return this.users.filter(user => user.status !== 'offline');
82+ }
83+
84+ getUserById(userId: string) {
85+ return this.users.find(user => user.id === userId);
86+ }
87+
88+ updateUserStatus(userId: string, status: User['status'], statusMessage?: string) {
89+ this.users = this.users.map(user =>
90+ user.id === userId
91+ ? { ...user, status, ...(statusMessage && { statusMessage }) }
92+ : user
93+ );
94+ }
95+
96+ // Message methods
97+ getMessages() {
98+ return this.messages;
99+ }
100+
101+ sendMessage(content: string, type: ChatMessage['type'] = 'chat') {
102+ if (!this.currentUser) return;
103+
104+ const newMessage: ChatMessage = {
105+ id: generateId(),
106+ userId: this.currentUser.id,
107+ content,
108+ timestamp: new Date(),
109+ type
110+ };
111+
112+ this.messages = [...this.messages, newMessage];
113+ }
114+
115+ // Add more methods as needed for your implementation
116+}
117+
118+// Export a singleton instance
119+export const chatState = new ChatState(); ⏎̸
@@ -0,0 +1,119 @@
1+import type { User, ChatMessage } from '../types/chat';
2+
3+// Mock users
4+const mockUsers: User[] = [
5+ {
6+ id: '1',
7+ nickname: 'CoolDude98',
8+ status: 'online',
9+ statusMessage: '🎮 Gaming all day!'
10+ },
11+ {
12+ id: '2',
13+ nickname: 'PixelPrincess',
14+ status: 'away',
15+ statusMessage: 'brb - food time'
16+ },
17+ {
18+ id: '3',
19+ nickname: 'WebSurfer2000',
20+ status: 'online',
21+ statusMessage: 'Surfing the information superhighway'
22+ },
23+ {
24+ id: '4',
25+ nickname: 'ChatBot3000',
26+ status: 'busy',
27+ statusMessage: '🤖 Computing...'
28+ }
29+];
30+
31+// Mock initial messages
32+const initialMessages: ChatMessage[] = [
33+ {
34+ id: '1',
35+ userId: '1',
36+ content: 'sup everyone! who\'s up for some gaming?',
37+ timestamp: new Date(Date.now() - 300000),
38+ type: 'chat'
39+ },
40+ {
41+ id: '2',
42+ userId: '2',
43+ content: 'is getting a snack',
44+ timestamp: new Date(Date.now() - 240000),
45+ type: 'emote'
46+ },
47+ {
48+ id: '3',
49+ userId: '3',
50+ content: 'Just downloaded the new Internet Explorer 6! So fast! 🚀',
51+ timestamp: new Date(Date.now() - 180000),
52+ type: 'chat'
53+ },
54+ {
55+ id: '4',
56+ userId: '4',
57+ content: '*beep boop* Greetings humans!',
58+ timestamp: new Date(Date.now() - 120000),
59+ type: 'chat'
60+ }
61+];
62+
63+// Helper function to generate message ID
64+const generateId = () => Math.random().toString(36).substring(2, 15);
65+
66+class ChatState {
67+ private users = $state(mockUsers);
68+ private messages = $state(initialMessages);
69+ private currentUser = $state(mockUsers[0]);
70+
71+ constructor() {
72+ // Initialize any necessary subscriptions or cleanup
73+ }
74+
75+ // User methods
76+ getCurrentUser() {
77+ return this.currentUser;
78+ }
79+
80+ getOnlineUsers() {
81+ return this.users.filter(user => user.status !== 'offline');
82+ }
83+
84+ getUserById(userId: string) {
85+ return this.users.find(user => user.id === userId);
86+ }
87+
88+ updateUserStatus(userId: string, status: User['status'], statusMessage?: string) {
89+ this.users = this.users.map(user =>
90+ user.id === userId
91+ ? { ...user, status, ...(statusMessage && { statusMessage }) }
92+ : user
93+ );
94+ }
95+
96+ // Message methods
97+ getMessages() {
98+ return this.messages;
99+ }
100+
101+ sendMessage(content: string, type: ChatMessage['type'] = 'chat') {
102+ if (!this.currentUser) return;
103+
104+ const newMessage: ChatMessage = {
105+ id: generateId(),
106+ userId: this.currentUser.id,
107+ content,
108+ timestamp: new Date(),
109+ type
110+ };
111+
112+ this.messages = [...this.messages, newMessage];
113+ }
114+
115+ // Add more methods as needed for your implementation
116+}
117+
118+// Export a singleton instance
119+export const chatState = new ChatState(); ⏎̸
A src/lib/types/chat.ts +19 -0
@@ -0,0 +1,19 @@
1+export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
2+
3+export interface User {
4+ id: string;
5+ nickname: string;
6+ status: UserStatus;
7+ statusMessage?: string;
8+ avatarUrl?: string;
9+}
10+
11+export type MessageType = 'chat' | 'emote' | 'system';
12+
13+export interface ChatMessage {
14+ id: string;
15+ userId: string;
16+ content: string;
17+ timestamp: Date;
18+ type: MessageType;
19+} ⏎̸
@@ -0,0 +1,19 @@
1+export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
2+
3+export interface User {
4+ id: string;
5+ nickname: string;
6+ status: UserStatus;
7+ statusMessage?: string;
8+ avatarUrl?: string;
9+}
10+
11+export type MessageType = 'chat' | 'emote' | 'system';
12+
13+export interface ChatMessage {
14+ id: string;
15+ userId: string;
16+ content: string;
17+ timestamp: Date;
18+ type: MessageType;
19+} ⏎̸
M src/routes/+layout.svelte +26 -1
@@ -3,4 +3,29 @@
33 let { children } = $props();
44 </script>
55
6-{@render children()}
6+<div class="windows-xp-bg">
7+ {@render children()}
8+</div>
9+
10+
11+<style>
12+ .windows-xp-bg {
13+ position: fixed;
14+ top: 0;
15+ left: 0;
16+ width: 100vw;
17+ height: 100vh;
18+ margin: 0;
19+ padding: 0;
20+ overflow: hidden;
21+ background: linear-gradient(
22+ to bottom,
23+ #2195f2 0%, /* Lighter blue at the top */
24+ #4aa6e2 25%, /* Mid-light blue */
25+ #7ab8e0 50%, /* Mid blue */
26+ #86c55c 75%, /* Blue-green transition */
27+ #90d047 100% /* Bright green at the bottom */
28+ );
29+ }
30+
31+</style>⏎̸
@@ -3,4 +3,29 @@
3 let { children } = $props();3 let { children } = $props();
4 </script>4 </script>
5 5
6-{@render children()}6+<div class="windows-xp-bg">
7+ {@render children()}
8+</div>
9+
10+
11+<style>
12+ .windows-xp-bg {
13+ position: fixed;
14+ top: 0;
15+ left: 0;
16+ width: 100vw;
17+ height: 100vh;
18+ margin: 0;
19+ padding: 0;
20+ overflow: hidden;
21+ background: linear-gradient(
22+ to bottom,
23+ #2195f2 0%, /* Lighter blue at the top */
24+ #4aa6e2 25%, /* Mid-light blue */
25+ #7ab8e0 50%, /* Mid blue */
26+ #86c55c 75%, /* Blue-green transition */
27+ #90d047 100% /* Bright green at the bottom */
28+ );
29+ }
30+
31+</style>⏎̸
M src/routes/+page.svelte +18 -2
@@ -1,2 +1,18 @@
1-<h1>Welcome to SvelteKit</h1>
2-<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
1+<script lang="ts">
2+ import ChatRoom from '$lib/components/chat-room.svelte';
3+</script>
4+
5+<div class="content-wrapper">
6+ <ChatRoom />
7+</div>
8+
9+<style>
10+ .content-wrapper {
11+ position: absolute;
12+ inset: 0;
13+ display: flex;
14+ align-items: center;
15+ justify-content: center;
16+ box-sizing: border-box;
17+ }
18+</style>⏎̸
@@ -1,2 +1,18 @@
1-<h1>Welcome to SvelteKit</h1>1+<script lang="ts">
2-<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>2+ import ChatRoom from '$lib/components/chat-room.svelte';
3+</script>
4+
5+<div class="content-wrapper">
6+ <ChatRoom />
7+</div>
8+
9+<style>
10+ .content-wrapper {
11+ position: absolute;
12+ inset: 0;
13+ display: flex;
14+ align-items: center;
15+ justify-content: center;
16+ box-sizing: border-box;
17+ }
18+</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