detached c76e7e6

fix(deploy): harden the Node container after review

Bundle every dependency so the runtime image ships only build/, drop the unused Turso/drizzle stack, add cache headers for static assets, an /api/health route, env-driven log level, ORIGIN-based CSRF check, and read the client address only for API routes.
commit
c76e7e652765798490ba9d459168c1bb4f2875ce
author
Kit · · 2026-10-12 12:51 UTC
parent
5d60e39
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
11 files · +100 -1109
M .dockerignore +3 -1
@@ -2,10 +2,12 @@ node_modules
22 .svelte-kit
33 build
44 .git
5+.github
56 .worktrees
67 .env
78 .env.*
8-!.env.example
9+.env example
10+*.md
911 docs
1012 .claude
1113 .cursor
@@ -2,10 +2,12 @@ node_modules
2 .svelte-kit2 .svelte-kit
3 build3 build
4 .git4 .git
5+.github
5 .worktrees6 .worktrees
6 .env7 .env
7 .env.*8 .env.*
8-!.env.example9+.env example
10+*.md
9 docs11 docs
10 .claude12 .claude
11 .cursor13 .cursor
M .github/dependabot.yml +0 -8
@@ -40,14 +40,6 @@ updates:
4040 update-types:
4141 - 'minor'
4242 - 'patch'
43- # Group Drizzle ORM related packages
44- drizzle:
45- patterns:
46- - 'drizzle-*'
47- - '@libsql/*'
48- update-types:
49- - 'minor'
50- - 'patch'
5143 # Set PR limits to avoid overwhelming the team
5244 open-pull-requests-limit: 8
5345 # Ignore packages that require manual updates or are pinned for specific reasons
@@ -40,14 +40,6 @@ updates:
40 update-types:40 update-types:
41 - 'minor'41 - 'minor'
42 - 'patch'42 - 'patch'
43- # Group Drizzle ORM related packages
44- drizzle:
45- patterns:
46- - 'drizzle-*'
47- - '@libsql/*'
48- update-types:
49- - 'minor'
50- - 'patch'
51 # Set PR limits to avoid overwhelming the team43 # Set PR limits to avoid overwhelming the team
52 open-pull-requests-limit: 844 open-pull-requests-limit: 8
53 # Ignore packages that require manual updates or are pinned for specific reasons45 # Ignore packages that require manual updates or are pinned for specific reasons
M .github/workflows/ci.yml +0 -2
@@ -18,8 +18,6 @@ on:
1818
1919 env:
2020 # Dummy values for svelte-check type generation
21- TURSO_DATABASE_URL: 'libsql://dummy.turso.io'
22- TURSO_AUTH_TOKEN: 'dummy-token'
2321 TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA'
2422 PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA'
2523 PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001'
@@ -18,8 +18,6 @@ on:
18 18
19 env:19 env:
20 # Dummy values for svelte-check type generation20 # Dummy values for svelte-check type generation
21- TURSO_DATABASE_URL: 'libsql://dummy.turso.io'
22- TURSO_AUTH_TOKEN: 'dummy-token'
23 TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA'21 TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA'
24 PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA'22 PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA'
25 PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001'23 PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001'
M Dockerfile +4 -11
@@ -8,25 +8,18 @@ RUN pnpm fetch
88 COPY . .
99 RUN pnpm install --frozen-lockfile --offline && pnpm run build
1010
11-FROM node:22-alpine AS deps
12-WORKDIR /app
13-ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
14-RUN corepack enable
15-COPY package.json pnpm-lock.yaml ./
16-RUN pnpm install --prod --frozen-lockfile --ignore-scripts
17-
11+# Every dependency is bundled into build/, so the runtime image needs no node_modules
1812 FROM node:22-alpine
1913 WORKDIR /app
2014 ENV NODE_ENV=production \
2115 HOST=0.0.0.0 \
2216 PORT=3000
2317
24-COPY --from=deps /app/node_modules ./node_modules
2518 COPY --from=build /app/build ./build
26-COPY package.json ./
19+COPY package.json server.js ./
2720
2821 USER node
2922 EXPOSE 3000
3023 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
31- CMD node -e "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
32-CMD ["node", "build"]
24+ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
25+CMD ["node", "server.js"]
@@ -8,25 +8,18 @@ RUN pnpm fetch
8 COPY . .8 COPY . .
9 RUN pnpm install --frozen-lockfile --offline && pnpm run build9 RUN pnpm install --frozen-lockfile --offline && pnpm run build
10 10
11-FROM node:22-alpine AS deps11+# Every dependency is bundled into build/, so the runtime image needs no node_modules
12-WORKDIR /app
13-ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
14-RUN corepack enable
15-COPY package.json pnpm-lock.yaml ./
16-RUN pnpm install --prod --frozen-lockfile --ignore-scripts
17-
18 FROM node:22-alpine12 FROM node:22-alpine
19 WORKDIR /app13 WORKDIR /app
20 ENV NODE_ENV=production \14 ENV NODE_ENV=production \
21 HOST=0.0.0.0 \15 HOST=0.0.0.0 \
22 PORT=300016 PORT=3000
23 17
24-COPY --from=deps /app/node_modules ./node_modules
25 COPY --from=build /app/build ./build18 COPY --from=build /app/build ./build
26-COPY package.json ./19+COPY package.json server.js ./
27 20
28 USER node21 USER node
29 EXPOSE 300022 EXPOSE 3000
30 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \23 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
31- CMD node -e "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"24+ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
32-CMD ["node", "build"]25+CMD ["node", "server.js"]
M package.json +13 -25
@@ -16,15 +16,12 @@
1616 "format": "prettier --write .",
1717 "lint": "prettier --check . && eslint .",
1818 "test:unit": "vitest",
19- "test": "npm run test:unit -- --run",
20- "db:generate": "npx drizzle-kit generate",
21- "db:push": "npx drizzle-kit push",
22- "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'",
23- "db:flush": "tsx utils/db-flush.ts"
19+ "test": "npm run test:unit -- --run"
2420 },
2521 "devDependencies": {
2622 "@eslint/compat": "^2.1.1",
2723 "@eslint/js": "^10.0.1",
24+ "@lucide/svelte": "^0.564.0",
2825 "@sveltejs/adapter-node": "^5.5.7",
2926 "@sveltejs/kit": "^2.70.3",
3027 "@sveltejs/vite-plugin-svelte": "^6.2.4",
@@ -34,15 +31,23 @@
3431 "@tailwindcss/vite": "^4.3.3",
3532 "@types/node": "^25.9.9",
3633 "autoprefixer": "^10.6.1",
37- "drizzle-kit": "^0.31.11",
34+ "convex": "^1.46.0",
35+ "convex-helpers": "^0.1.128",
36+ "convex-svelte": "^0.0.12",
3837 "eslint": "^10.12.0",
3938 "eslint-config-prettier": "^10.1.8",
4039 "eslint-plugin-svelte": "^3.23.1",
4140 "globals": "^17.13.0",
4241 "jsdom": "^28.1.0",
42+ "pino": "^10.4.0",
4343 "prettier": "^3.9.10",
4444 "prettier-plugin-svelte": "^3.5.2",
4545 "prettier-plugin-tailwindcss": "^0.7.4",
46+ "rehype": "^13.0.2",
47+ "rehype-sanitize": "^6.0.0",
48+ "rehype-stringify": "^10.0.1",
49+ "remark-parse": "^11.0.0",
50+ "remark-rehype": "^11.1.2",
4651 "svelte": "^5.57.2",
4752 "svelte-check": "^4.7.6",
4853 "tailwindcss": "^4.3.3",
@@ -51,26 +56,9 @@
5156 "tw-animate-css": "^1.4.0",
5257 "typescript": "^5.9.3",
5358 "typescript-eslint": "^8.71.1",
54- "vite": "^7.3.7",
55- "vitest": "^4.1.11"
56- },
57- "dependencies": {
58- "@libsql/client": "^0.17.4",
59- "@lucide/svelte": "^0.564.0",
60- "convex": "^1.46.0",
61- "convex-helpers": "^0.1.128",
62- "convex-svelte": "^0.0.12",
63- "dotenv": "^17.4.2",
64- "drizzle-orm": "^0.45.4",
65- "pino": "^10.4.0",
66- "pino-pretty": "^13.2.0",
67- "rehype": "^13.0.2",
68- "rehype-sanitize": "^6.0.0",
69- "rehype-stringify": "^10.0.1",
70- "remark-parse": "^11.0.0",
71- "remark-rehype": "^11.1.2",
7259 "unified": "^11.0.5",
73- "uuid": "^13.0.2",
60+ "vite": "^7.3.7",
61+ "vitest": "^4.1.11",
7462 "xp.css": "^0.2.6",
7563 "zod": "^4.6.6"
7664 },
@@ -16,15 +16,12 @@
16 "format": "prettier --write .",16 "format": "prettier --write .",
17 "lint": "prettier --check . && eslint .",17 "lint": "prettier --check . && eslint .",
18 "test:unit": "vitest",18 "test:unit": "vitest",
19- "test": "npm run test:unit -- --run",19+ "test": "npm run test:unit -- --run"
20- "db:generate": "npx drizzle-kit generate",
21- "db:push": "npx drizzle-kit push",
22- "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'",
23- "db:flush": "tsx utils/db-flush.ts"
24 },20 },
25 "devDependencies": {21 "devDependencies": {
26 "@eslint/compat": "^2.1.1",22 "@eslint/compat": "^2.1.1",
27 "@eslint/js": "^10.0.1",23 "@eslint/js": "^10.0.1",
24+ "@lucide/svelte": "^0.564.0",
28 "@sveltejs/adapter-node": "^5.5.7",25 "@sveltejs/adapter-node": "^5.5.7",
29 "@sveltejs/kit": "^2.70.3",26 "@sveltejs/kit": "^2.70.3",
30 "@sveltejs/vite-plugin-svelte": "^6.2.4",27 "@sveltejs/vite-plugin-svelte": "^6.2.4",
@@ -34,15 +31,23 @@
34 "@tailwindcss/vite": "^4.3.3",31 "@tailwindcss/vite": "^4.3.3",
35 "@types/node": "^25.9.9",32 "@types/node": "^25.9.9",
36 "autoprefixer": "^10.6.1",33 "autoprefixer": "^10.6.1",
37- "drizzle-kit": "^0.31.11",34+ "convex": "^1.46.0",
35+ "convex-helpers": "^0.1.128",
36+ "convex-svelte": "^0.0.12",
38 "eslint": "^10.12.0",37 "eslint": "^10.12.0",
39 "eslint-config-prettier": "^10.1.8",38 "eslint-config-prettier": "^10.1.8",
40 "eslint-plugin-svelte": "^3.23.1",39 "eslint-plugin-svelte": "^3.23.1",
41 "globals": "^17.13.0",40 "globals": "^17.13.0",
42 "jsdom": "^28.1.0",41 "jsdom": "^28.1.0",
42+ "pino": "^10.4.0",
43 "prettier": "^3.9.10",43 "prettier": "^3.9.10",
44 "prettier-plugin-svelte": "^3.5.2",44 "prettier-plugin-svelte": "^3.5.2",
45 "prettier-plugin-tailwindcss": "^0.7.4",45 "prettier-plugin-tailwindcss": "^0.7.4",
46+ "rehype": "^13.0.2",
47+ "rehype-sanitize": "^6.0.0",
48+ "rehype-stringify": "^10.0.1",
49+ "remark-parse": "^11.0.0",
50+ "remark-rehype": "^11.1.2",
46 "svelte": "^5.57.2",51 "svelte": "^5.57.2",
47 "svelte-check": "^4.7.6",52 "svelte-check": "^4.7.6",
48 "tailwindcss": "^4.3.3",53 "tailwindcss": "^4.3.3",
@@ -51,26 +56,9 @@
51 "tw-animate-css": "^1.4.0",56 "tw-animate-css": "^1.4.0",
52 "typescript": "^5.9.3",57 "typescript": "^5.9.3",
53 "typescript-eslint": "^8.71.1",58 "typescript-eslint": "^8.71.1",
54- "vite": "^7.3.7",
55- "vitest": "^4.1.11"
56- },
57- "dependencies": {
58- "@libsql/client": "^0.17.4",
59- "@lucide/svelte": "^0.564.0",
60- "convex": "^1.46.0",
61- "convex-helpers": "^0.1.128",
62- "convex-svelte": "^0.0.12",
63- "dotenv": "^17.4.2",
64- "drizzle-orm": "^0.45.4",
65- "pino": "^10.4.0",
66- "pino-pretty": "^13.2.0",
67- "rehype": "^13.0.2",
68- "rehype-sanitize": "^6.0.0",
69- "rehype-stringify": "^10.0.1",
70- "remark-parse": "^11.0.0",
71- "remark-rehype": "^11.1.2",
72 "unified": "^11.0.5",59 "unified": "^11.0.5",
73- "uuid": "^13.0.2",60+ "vite": "^7.3.7",
61+ "vitest": "^4.1.11",
74 "xp.css": "^0.2.6",62 "xp.css": "^0.2.6",
75 "zod": "^4.6.6"63 "zod": "^4.6.6"
76 },64 },
M pnpm-lock.yaml +43 -1055 generated file: collapsed
A server.js +25 -0
@@ -0,0 +1,25 @@
1+// Production entry: adds cache headers for static assets, then delegates to SvelteKit.
2+// Copied into the image next to build/ (see Dockerfile).
3+import http from 'node:http';
4+import { handler } from './build/handler.js';
5+
6+const host = process.env.HOST ?? '0.0.0.0';
7+const port = Number(process.env.PORT ?? 3000);
8+
9+const cacheRules = [
10+ [/^\/fonts\//, 'public, max-age=31536000, immutable'],
11+ [/^\/(aim|desktop|sounds|smileys)\/|^\/favicon\.png$/, 'public, max-age=2592000']
12+];
13+
14+const server = http.createServer((req, res) => {
15+ const path = req.url?.split('?')[0] ?? '/';
16+ const rule = cacheRules.find(([pattern]) => pattern.test(path));
17+ if (rule) res.setHeader('Cache-Control', rule[1]);
18+ handler(req, res);
19+});
20+
21+server.listen(port, host, () => console.log(`Listening on http://${host}:${port}`));
22+
23+for (const signal of ['SIGINT', 'SIGTERM']) {
24+ process.on(signal, () => server.close(() => process.exit(0)));
25+}
@@ -0,0 +1,25 @@
1+// Production entry: adds cache headers for static assets, then delegates to SvelteKit.
2+// Copied into the image next to build/ (see Dockerfile).
3+import http from 'node:http';
4+import { handler } from './build/handler.js';
5+
6+const host = process.env.HOST ?? '0.0.0.0';
7+const port = Number(process.env.PORT ?? 3000);
8+
9+const cacheRules = [
10+ [/^\/fonts\//, 'public, max-age=31536000, immutable'],
11+ [/^\/(aim|desktop|sounds|smileys)\/|^\/favicon\.png$/, 'public, max-age=2592000']
12+];
13+
14+const server = http.createServer((req, res) => {
15+ const path = req.url?.split('?')[0] ?? '/';
16+ const rule = cacheRules.find(([pattern]) => pattern.test(path));
17+ if (rule) res.setHeader('Cache-Control', rule[1]);
18+ handler(req, res);
19+});
20+
21+server.listen(port, host, () => console.log(`Listening on http://${host}:${port}`));
22+
23+for (const signal of ['SIGINT', 'SIGTERM']) {
24+ process.on(signal, () => server.close(() => process.exit(0)));
25+}
M src/hooks.server.ts +5 -4
@@ -85,7 +85,6 @@ export const handle: Handle = async ({ event, resolve }) => {
8585 // CSRF protection: validate Origin header for state-changing requests
8686 if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
8787 const origin = event.request.headers.get('origin');
88- const host = event.request.headers.get('host');
8988
9089 if (!origin) {
9190 // Browsers always send Origin on state-changing requests.
@@ -98,11 +97,12 @@ export const handle: Handle = async ({ event, resolve }) => {
9897 }
9998
10099 try {
100+ // event.url comes from ORIGIN in production, so proxies rewriting Host do not matter
101101 const originUrl = new URL(origin);
102- if (originUrl.host !== host) {
102+ if (originUrl.origin !== event.url.origin) {
103103 log.warn('CSRF check failed - origin mismatch', {
104- origin: originUrl.host,
105- host
104+ origin: originUrl.origin,
105+ expected: event.url.origin
106106 });
107107 return new Response('Forbidden', { status: 403 });
108108 }
@@ -144,6 +144,7 @@ export const handle: Handle = async ({ event, resolve }) => {
144144 // Public routes that don't require authentication
145145 const publicRoutes = [
146146 '/',
147+ '/api/health',
147148 '/api/session/login',
148149 '/api/session/validate',
149150 '/api/register',
@@ -85,7 +85,6 @@ export const handle: Handle = async ({ event, resolve }) => {
85 // CSRF protection: validate Origin header for state-changing requests85 // CSRF protection: validate Origin header for state-changing requests
86 if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {86 if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
87 const origin = event.request.headers.get('origin');87 const origin = event.request.headers.get('origin');
88- const host = event.request.headers.get('host');
89 88
90 if (!origin) {89 if (!origin) {
91 // Browsers always send Origin on state-changing requests.90 // Browsers always send Origin on state-changing requests.
@@ -98,11 +97,12 @@ export const handle: Handle = async ({ event, resolve }) => {
98 }97 }
99 98
100 try {99 try {
100+ // event.url comes from ORIGIN in production, so proxies rewriting Host do not matter
101 const originUrl = new URL(origin);101 const originUrl = new URL(origin);
102- if (originUrl.host !== host) {102+ if (originUrl.origin !== event.url.origin) {
103 log.warn('CSRF check failed - origin mismatch', {103 log.warn('CSRF check failed - origin mismatch', {
104- origin: originUrl.host,104+ origin: originUrl.origin,
105- host105+ expected: event.url.origin
106 });106 });
107 return new Response('Forbidden', { status: 403 });107 return new Response('Forbidden', { status: 403 });
108 }108 }
@@ -144,6 +144,7 @@ export const handle: Handle = async ({ event, resolve }) => {
144 // Public routes that don't require authentication144 // Public routes that don't require authentication
145 const publicRoutes = [145 const publicRoutes = [
146 '/',146 '/',
147+ '/api/health',
147 '/api/session/login',148 '/api/session/login',
148 '/api/session/validate',149 '/api/session/validate',
149 '/api/register',150 '/api/register',
M src/lib/api/rate-limiter.ts +2 -1
@@ -98,7 +98,6 @@ function isRateLimited(
9898 }
9999
100100 export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
101- const ip = event.getClientAddress();
102101 const url = new URL(event.request.url);
103102 const isPublic = url.searchParams.get('public') === 'true';
104103
@@ -107,6 +106,8 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
107106 return null;
108107 }
109108
109+ const ip = event.getClientAddress();
110+
110111 const endpointType = getEndpointType(url.pathname, isPublic);
111112 const { limited, retryAfter } = isRateLimited(ip, endpointType);
112113
@@ -98,7 +98,6 @@ function isRateLimited(
98 }98 }
99 99
100 export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {100 export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
101- const ip = event.getClientAddress();
102 const url = new URL(event.request.url);101 const url = new URL(event.request.url);
103 const isPublic = url.searchParams.get('public') === 'true';102 const isPublic = url.searchParams.get('public') === 'true';
104 103
@@ -107,6 +106,8 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
107 return null;106 return null;
108 }107 }
109 108
109+ const ip = event.getClientAddress();
110+
110 const endpointType = getEndpointType(url.pathname, isPublic);111 const endpointType = getEndpointType(url.pathname, isPublic);
111 const { limited, retryAfter } = isRateLimited(ip, endpointType);112 const { limited, retryAfter } = isRateLimited(ip, endpointType);
112 113
M src/lib/utils/logger.server.ts +2 -2
@@ -1,9 +1,9 @@
11 import pino from 'pino';
2+import { dev } from '$app/environment';
23
34 // JSON logs on stdout, collected by the container runtime
4-// Use 'debug' level - filtering happens at log time based on env
55 const logger = pino({
6- level: 'debug',
6+ level: process.env.LOG_LEVEL ?? (dev ? 'debug' : 'info'),
77 formatters: {
88 level: (label) => ({ level: label })
99 }
@@ -1,9 +1,9 @@
1 import pino from 'pino';1 import pino from 'pino';
2+import { dev } from '$app/environment';
2 3
3 // JSON logs on stdout, collected by the container runtime4 // JSON logs on stdout, collected by the container runtime
4-// Use 'debug' level - filtering happens at log time based on env
5 const logger = pino({5 const logger = pino({
6- level: 'debug',6+ level: process.env.LOG_LEVEL ?? (dev ? 'debug' : 'info'),
7 formatters: {7 formatters: {
8 level: (label) => ({ level: label })8 level: (label) => ({ level: label })
9 }9 }
A src/routes/api/health/+server.ts +3 -0
@@ -0,0 +1,3 @@
1+import type { RequestHandler } from './$types';
2+
3+export const GET: RequestHandler = () => new Response('ok');
@@ -0,0 +1,3 @@
1+import type { RequestHandler } from './$types';
2+
3+export const GET: RequestHandler = () => new Response('ok');

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