v0.9.2 49bea57

lsp_test.go RawHistoryFolder

1package lsp
2
3import (
4	"bufio"
5	"bytes"
6	"context"
7	"encoding/json"
8	"io"
9	"os"
10	"path/filepath"
11	"runtime"
12	"strconv"
13	"strings"
14	"testing"
15	"time"
16)
17
18func TestFakeLSPProcess(t *testing.T) {
19	if os.Getenv("ZUT_FAKE_LSP") != "1" {
20		return
21	}
22	reader := bufio.NewReader(os.Stdin)
23	for {
24		payload, err := ReadMessage(reader)
25		if err != nil {
26			return
27		}
28		var message rpcEnvelope
29		if json.Unmarshal(payload, &message) != nil {
30			continue
31		}
32		switch message.Method {
33		case "initialize":
34			response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"capabilities":{"definitionProvider":true}}}`)
35			_ = WriteMessage(os.Stdout, response)
36		case "test/echo":
37			response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":{"echo":true}}`)
38			_ = WriteMessage(os.Stdout, response)
39		case "shutdown":
40			response := []byte(`{"jsonrpc":"2.0","id":` + string(message.ID) + `,"result":null}`)
41			_ = WriteMessage(os.Stdout, response)
42		case "textDocument/didOpen":
43			var params struct {
44				TextDocument struct {
45					URI string `json:"uri"`
46				} `json:"textDocument"`
47			}
48			_ = json.Unmarshal(message.Params, &params)
49			response := map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": map[string]any{"uri": params.TextDocument.URI, "diagnostics": []any{map[string]any{"range": map[string]any{"start": map[string]int{"line": 0, "character": 0}, "end": map[string]int{"line": 0, "character": 1}}, "severity": 1, "code": "FAKE", "message": "fake problem"}}}}
50			data, _ := json.Marshal(response)
51			_ = WriteMessage(os.Stdout, data)
52		}
53	}
54}
55
56func TestClientFakeStdioRPC(t *testing.T) {
57	root := t.TempDir()
58	script := os.Args[0]
59	spec := ServerConfig{ID: "fake", Kind: "lsp", Command: script, Args: []string{"-test.run=TestFakeLSPProcess"}, Env: map[string]string{"ZUT_FAKE_LSP": "1"}}
60	diagnostics := make(chan []Diagnostic, 1)
61	client, err := NewClientWithContext(context.Background(), spec, root, ClientOptions{OnDiagnostics: func(value []Diagnostic) { diagnostics <- value }})
62	if err != nil {
63		t.Fatal(err)
64	}
65	t.Cleanup(func() { _ = client.Close() })
66	result, err := client.Request(context.Background(), "test/echo", map[string]any{})
67	if err != nil || !bytes.Contains(result, []byte(`"echo":true`)) {
68		t.Fatalf("echo result = %s, err = %v", result, err)
69	}
70	path := filepath.Join(root, "main.go")
71	if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil {
72		t.Fatal(err)
73	}
74	if err := client.DidOpen(path, "go", "package main\n"); err != nil {
75		t.Fatal(err)
76	}
77	select {
78	case values := <-diagnostics:
79		if len(values) != 1 || values[0].Code != "FAKE" || values[0].Path != path {
80			t.Fatalf("diagnostics = %#v", values)
81		}
82	case <-time.After(2 * time.Second):
83		t.Fatal("timed out waiting for fake diagnostics")
84	}
85}
86
87func TestFileURIPathRoundTrip(t *testing.T) {
88	path := filepath.Join(t.TempDir(), "space name", "main.go")
89	uri, err := pathToURI(path)
90	if err != nil {
91		t.Fatal(err)
92	}
93	if !strings.HasPrefix(uri, "file:///") {
94		t.Fatalf("URI = %q, want an absolute file URI", uri)
95	}
96	got, err := uriToPath(uri)
97	if err != nil {
98		t.Fatal(err)
99	}
100	abs, err := filepath.Abs(path)
101	if err != nil {
102		t.Fatal(err)
103	}
104	if filepath.Clean(got) != filepath.Clean(abs) {
105		t.Fatalf("round-trip path = %q, want %q", got, abs)
106	}
107	if runtime.GOOS != "windows" {
108		posixPath, err := uriToPath("file:///x:tmp/main.go")
109		if err != nil || posixPath != "/x:tmp/main.go" {
110			t.Fatalf("POSIX drive-like URI = %q, err = %v", posixPath, err)
111		}
112	}
113	if runtime.GOOS == "windows" {
114		unc := `\\server\share\main.go`
115		uncURI, err := pathToURI(unc)
116		if err != nil {
117			t.Fatal(err)
118		}
119		if !strings.HasPrefix(uncURI, "file://server/") {
120			t.Fatalf("UNC URI = %q", uncURI)
121		}
122		uncPath, err := uriToPath(uncURI)
123		if err != nil || filepath.Clean(uncPath) != filepath.Clean(unc) {
124			t.Fatalf("UNC round trip = %q, err = %v", uncPath, err)
125		}
126	}
127}
128
129func TestFramingRejectsMalformedAndOversizedMessages(t *testing.T) {
130	cases := []string{
131		"\r\n{}",
132		"Content-Length: -1\r\n\r\n",
133		"Content-Length: nope\r\n\r\n",
134		"Content-Length: 1\r\n" + strings.Repeat("X", maxHeaderBytes) + "\r\n",
135		"Content-Length: " + strconv.Itoa(maxMessageBytes+1) + "\r\n\r\n",
136	}
137	for _, wire := range cases {
138		if _, err := ReadMessage(bufio.NewReader(strings.NewReader(wire))); err == nil {
139			t.Fatalf("accepted malformed frame %q", wire[:min(len(wire), 80)])
140		}
141	}
142	var out bytes.Buffer
143	if err := WriteMessage(&out, make([]byte, maxMessageBytes+1)); err == nil {
144		t.Fatal("accepted oversized outbound frame")
145	}
146}
147
148func TestFramingRoundTripWithAdditionalHeader(t *testing.T) {
149	payload := []byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`)
150	wire := append([]byte("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n"), Frame(payload)...)
151	got, err := ReadMessage(bufio.NewReader(bytes.NewReader(wire)))
152	if err != nil {
153		t.Fatal(err)
154	}
155	if !bytes.Equal(got, payload) {
156		t.Fatalf("payload = %q, want %q", got, payload)
157	}
158}
159
160func TestZutHomeEnvironmentPrecedence(t *testing.T) {
161	configuredHome := filepath.Join(t.TempDir(), "zut")
162	xdgHome := filepath.Join(t.TempDir(), "state")
163	t.Setenv("ZUT_HOME", configuredHome)
164	t.Setenv("XDG_STATE_HOME", xdgHome)
165	if got := zutHome(); got != configuredHome {
166		t.Fatalf("ZUT_HOME path = %q, want %q", got, configuredHome)
167	}
168	t.Setenv("ZUT_HOME", "")
169	if got := zutHome(); got != filepath.Join(xdgHome, "zut") {
170		t.Fatalf("XDG_STATE_HOME path = %q, want %q", got, filepath.Join(xdgHome, "zut"))
171	}
172}
173
174func TestLoadConfigMergesGlobalProjectAndBuiltins(t *testing.T) {
175	root := t.TempDir()
176	zutHome := t.TempDir()
177	t.Setenv("ZUT_HOME", zutHome)
178	if err := os.WriteFile(filepath.Join(zutHome, "lsp.json"), []byte(`{"servers":{"gopls":{"settings":{"global":true},"args":["--global"]}}}`), 0o644); err != nil {
179		t.Fatal(err)
180	}
181	if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
182		t.Fatal(err)
183	}
184	if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"gopls":{"command":"local-gopls","args":["--local"],"settings":{"local":true}},"sarif-lint":{"command":"reviewdog","kind":"cli","parser":"sarif","fileTypes":[".go"]}}`), 0o644); err != nil {
185		t.Fatal(err)
186	}
187	config, err := LoadConfig(root)
188	if err != nil {
189		t.Fatal(err)
190	}
191	gopls := config.Servers["gopls"]
192	if gopls.Command != "local-gopls" || len(gopls.Args) != 1 || gopls.Args[0] != "--local" {
193		t.Fatalf("gopls override = %#v", gopls)
194	}
195	if len(gopls.FileTypes) == 0 || len(gopls.RootMarkers) == 0 {
196		t.Fatalf("builtin selectors were not retained: %#v", gopls)
197	}
198	if gopls.Settings["global"] != true || gopls.Settings["local"] != true {
199		t.Fatalf("settings did not merge: %#v", gopls.Settings)
200	}
201	if config.Servers["sarif-lint"].Kind != "cli" || config.Servers["sarif-lint"].Parser != "sarif" {
202		t.Fatalf("custom linter = %#v", config.Servers["sarif-lint"])
203	}
204	selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
205	var foundGopls, foundSarif bool
206	for _, server := range selected {
207		foundGopls = foundGopls || server.ID == "gopls"
208		foundSarif = foundSarif || server.ID == "sarif-lint"
209	}
210	if !foundGopls || !foundSarif {
211		t.Fatalf("selected servers = %#v", selected)
212	}
213}
214
215func TestLoadConfigAcceptsProviderArrays(t *testing.T) {
216	root := t.TempDir()
217	zutHome := t.TempDir()
218	t.Setenv("ZUT_HOME", zutHome)
219	if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
220		t.Fatal(err)
221	}
222	configFile := `{
223		"autoDetect": true,
224		"providers": [
225			"gopls",
226			{"id":"gopls","args":["serve"]},
227			{"id":"custom-lint","kind":"cli","command":"custom-lint","fileTypes":[".go"],"rootMarkers":["go.mod"],"cli":{"parser":"generic","mode":"files"}}
228		]
229	}`
230	if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(configFile), 0o644); err != nil {
231		t.Fatal(err)
232	}
233	config, err := LoadConfig(root)
234	if err != nil {
235		t.Fatal(err)
236	}
237	if got := config.Servers["gopls"].Args; len(got) != 1 || got[0] != "serve" {
238		t.Fatalf("gopls provider override = %#v", got)
239	}
240	lint := config.Servers["custom-lint"]
241	if lint.Kind != "cli" || lint.Parser != "generic" || lint.Mode != "files" {
242		t.Fatalf("custom provider = %#v", lint)
243	}
244}
245
246func TestLoadConfigAutoDetectFalseKeepsExplicitProviders(t *testing.T) {
247	root := t.TempDir()
248	t.Setenv("ZUT_HOME", t.TempDir())
249	if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n"), 0o644); err != nil {
250		t.Fatal(err)
251	}
252	if err := os.WriteFile(filepath.Join(root, "lsp.json"), []byte(`{"autoDetect":false,"providers":["gopls"]}`), 0o644); err != nil {
253		t.Fatal(err)
254	}
255	config, err := LoadConfig(root)
256	if err != nil {
257		t.Fatal(err)
258	}
259	selected := config.ApplicableServers(root, filepath.Join(root, "main.go"))
260	if len(selected) != 1 || selected[0].ID != "gopls" {
261		t.Fatalf("explicit providers with autoDetect=false = %#v", selected)
262	}
263}
264
265func TestResolveCommandPrefersWorkspaceTools(t *testing.T) {
266	if runtime.GOOS == "windows" {
267		t.Skip("executable bits are not represented on Windows")
268	}
269	root := t.TempDir()
270	bin := filepath.Join(root, "node_modules", ".bin")
271	if err := os.MkdirAll(bin, 0o755); err != nil {
272		t.Fatal(err)
273	}
274	command := filepath.Join(bin, "fake-lsp")
275	if err := os.WriteFile(command, []byte("#!/bin/sh\n"), 0o755); err != nil {
276		t.Fatal(err)
277	}
278	got, err := ResolveCommand(root, "fake-lsp")
279	if err != nil {
280		t.Fatal(err)
281	}
282	if got != command {
283		t.Fatalf("resolved %q, want %q", got, command)
284	}
285}
286
287func TestParsers(t *testing.T) {
288	root := t.TempDir()
289	eslint := `[ {"filePath":"src/a.js","messages":[{"ruleId":"no-x","severity":2,"message":"bad","line":2,"column":3}] } ]`
290	got := ParseDiagnostics("eslint-json", "eslint", root, eslint)
291	if len(got) != 1 || got[0].Severity != "error" || got[0].Range.Start.Line != 1 {
292		t.Fatalf("eslint = %#v", got)
293	}
294	golangci := `{"Issues":[{"FromLinter":"govet","Text":"bad","Severity":"warning","Pos":{"Filename":"a.go","Line":3,"Column":4}}]}`
295	if got = ParseDiagnostics("golangci-lint-json", "golangci", root, golangci); len(got) != 1 || got[0].Severity != "warning" {
296		t.Fatalf("golangci = %#v", got)
297	}
298	ruff := `[{"code":"F401","message":"unused","filename":"a.py","location":{"row":1,"column":2}}]`
299	if got = ParseDiagnostics("ruff-json", "ruff", root, ruff); len(got) != 1 || got[0].Code != "F401" {
300		t.Fatalf("ruff = %#v", got)
301	}
302	sarif := `{"runs":[{"results":[{"ruleId":"R1","level":"error","message":{"text":"bad"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"a.c"},"region":{"startLine":4,"startColumn":2}}}]}]}]}`
303	if got = ParseDiagnostics("sarif", "scan", root, sarif); len(got) != 1 || got[0].Range.Start.Line != 3 {
304		t.Fatalf("sarif = %#v", got)
305	}
306	if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: warning: bad"); len(got) != 1 || got[0].Severity != "warning" {
307		t.Fatalf("generic = %#v", got)
308	}
309	if got = ParseDiagnostics("generic", "lint", root, "a.go:5:6: cannot find name"); len(got) != 1 || got[0].Message != "cannot find name" {
310		t.Fatalf("generic message = %#v", got)
311	}
312}
313
314func TestDiagnosticDedupUsesCodeAndStartPosition(t *testing.T) {
315	path := filepath.Join(t.TempDir(), "main.go")
316	base := Diagnostic{Path: path, Severity: "error", Code: "E1", Message: "same", Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 1, Character: 3}}}
317	endChanged := base
318	endChanged.Range.End.Character = 20
319	otherCode := base
320	otherCode.Code = "E2"
321	otherStart := base
322	otherStart.Range.Start.Line++
323	if got := DeduplicateDiagnostics([]Diagnostic{base, endChanged, otherCode, otherStart}); len(got) != 3 {
324		t.Fatalf("deduplicated diagnostics = %#v, want three reports", got)
325	}
326}
327
328func TestManagerClearsPublishedDiagnostics(t *testing.T) {
329	manager := NewManager()
330	root := t.TempDir()
331	path := filepath.Join(root, "main.go")
332	if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil {
333		t.Fatal(err)
334	}
335	ws, err := manager.workspace(root)
336	if err != nil {
337		t.Fatal(err)
338	}
339	manager.storeDiagnostics(ws, "fake", []Diagnostic{{Path: path, Message: "problem"}})
340	if got := manager.snapshot(ws, path); len(got) != 1 {
341		t.Fatalf("stored diagnostics = %#v", got)
342	}
343	manager.storeDiagnostics(ws, "fake", []Diagnostic{{Path: path, Clear: true}})
344	if got := manager.snapshot(ws, path); len(got) != 0 {
345		t.Fatalf("cleared diagnostics = %#v", got)
346	}
347}
348
349func TestDiagnosticsDedupAndGrouping(t *testing.T) {
350	root := t.TempDir()
351	makeDiag := func(path, message string, line int, server string) Diagnostic {
352		return Diagnostic{Path: filepath.Join(root, path), Severity: "error", Code: "E", Message: message, Server: server, Range: Range{Start: Position{Line: line}}}
353	}
354	input := []Diagnostic{makeDiag("a.go", "same", 0, "gopls"), makeDiag("a.go", "same", 0, "eslint"), makeDiag("a.go", `Cannot find module "missing"`, 1, "gopls"), makeDiag("a.go", "cascade one", 2, "gopls"), makeDiag("a.go", "cascade two", 3, "gopls"), makeDiag("b.go", "repeat", 1, "a"), makeDiag("c.go", "repeat", 2, "b"), makeDiag("d.go", "repeat", 3, "c")}
355	if got := DeduplicateDiagnostics(input); len(got) != len(input)-1 {
356		t.Fatalf("dedup count = %d", len(got))
357	}
358	text := SummarizeDiagnostics(input, root, 50)
359	if !strings.Contains(text, "repeated") || !strings.Contains(text, "SECONDARY") {
360		t.Fatalf("summary lacks grouping:\n%s", text)
361	}
362}
363
364func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) {
365	root := t.TempDir()
366	path := filepath.Join(root, "a.txt")
367	if err := os.WriteFile(path, []byte("a😀b\n"), 0o644); err != nil {
368		t.Fatal(err)
369	}
370	uri := URIForPath(path)
371	ed := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: Range{Start: Position{Line: 0, Character: 1}, End: Position{Line: 0, Character: 3}}, NewText: "X"}}}}
372	if err := ApplyWorkspaceEdit(root, ed); err != nil {
373		t.Fatal(err)
374	}
375	data, _ := os.ReadFile(path)
376	if string(data) != "aXb\n" {
377		t.Fatalf("edited data = %q", data)
378	}
379	outside := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(filepath.Join(filepath.Dir(root), "outside.txt")): {{Range: Range{}, NewText: "x"}}}}
380	if err := ApplyWorkspaceEdit(root, outside); err == nil {
381		t.Fatal("outside edit was accepted")
382	}
383}
384
385func TestWorkspaceEditPreflightsAllTargets(t *testing.T) {
386	root := t.TempDir()
387	inside := filepath.Join(root, "inside.txt")
388	outside := filepath.Join(t.TempDir(), "outside.txt")
389	if err := os.WriteFile(inside, []byte("old\n"), 0o644); err != nil {
390		t.Fatal(err)
391	}
392	if err := os.WriteFile(outside, []byte("old\n"), 0o644); err != nil {
393		t.Fatal(err)
394	}
395	rootReal, err := filepath.EvalSymlinks(root)
396	if err != nil {
397		t.Fatal(err)
398	}
399	_, err = prepareEditTargets(rootReal, map[string][]TextEdit{
400		URIForPath(inside):  {{Range: Range{}, NewText: "inside"}},
401		URIForPath(outside): {{Range: Range{}, NewText: "outside"}},
402	}, nil)
403	if err == nil {
404		t.Fatal("workspace edit accepted an unsafe target after a safe target")
405	}
406}
407
408func TestApplyWorkspaceEditFollowsInWorkspaceSymlink(t *testing.T) {
409	if runtime.GOOS == "windows" {
410		t.Skip("symlink permissions vary on Windows")
411	}
412	root := t.TempDir()
413	target := filepath.Join(root, "target.txt")
414	link := filepath.Join(root, "link.txt")
415	if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
416		t.Fatal(err)
417	}
418	if err := os.Symlink(target, link); err != nil {
419		t.Fatal(err)
420	}
421	edit := WorkspaceEdit{Changes: map[string][]TextEdit{URIForPath(link): {{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 3}}, NewText: "new"}}}}
422	if err := ApplyWorkspaceEdit(root, edit); err != nil {
423		t.Fatal(err)
424	}
425	data, err := os.ReadFile(target)
426	if err != nil {
427		t.Fatal(err)
428	}
429	if string(data) != "new\n" {
430		t.Fatalf("target data = %q", data)
431	}
432	info, err := os.Lstat(link)
433	if err != nil {
434		t.Fatal(err)
435	}
436	if info.Mode()&os.ModeSymlink == 0 {
437		t.Fatal("workspace edit replaced the symlink")
438	}
439}
440
441func TestLimitedBufferCapsReaderCopy(t *testing.T) {
442	var buffer limitedBuffer
443	buffer.limit = 4
444	copied, err := io.Copy(&buffer, strings.NewReader("123456"))
445	if err != nil {
446		t.Fatal(err)
447	}
448	if copied != 6 || buffer.String() != "1234" {
449		t.Fatalf("copied %d bytes into %q", copied, buffer.String())
450	}
451}
452
453func TestDiagnosticJSONCodeNumber(t *testing.T) {
454	var diagnostic Diagnostic
455	if err := json.Unmarshal([]byte(`{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":2,"code":123,"message":"warning"}`), &diagnostic); err != nil {
456		t.Fatal(err)
457	}
458	if diagnostic.Code != "123" || diagnostic.Severity != "warning" {
459		t.Fatalf("diagnostic = %#v", diagnostic)
460	}
461}
462
463func TestManagerClosePreventsNewWorkspaces(t *testing.T) {
464	manager := NewManager()
465	root := t.TempDir()
466	if err := manager.Close(); err != nil {
467		t.Fatal(err)
468	}
469	if err := manager.Close(); err != nil {
470		t.Fatal(err)
471	}
472	if _, err := manager.Config(root); err == nil {
473		t.Fatal("closed manager accepted a new workspace")
474	}
475	if err := manager.Reload(root); err == nil {
476		t.Fatal("closed manager accepted a reload")
477	}
478}
479
480func TestReduceDiagnosticsSuppressesLocationOnlyChanges(t *testing.T) {
481	manager := NewManager()
482	root := t.TempDir()
483	path := filepath.Join(root, "main.go")
484	first := Diagnostic{Path: path, Severity: "error", Message: "type mismatch", Range: Range{Start: Position{Line: 1, Character: 2}}}
485	if got := manager.ReduceDiagnostics(root, path, []Diagnostic{first}); len(got) != 1 {
486		t.Fatalf("first reduction returned %d diagnostics, want 1", len(got))
487	}
488	shifted := first
489	shifted.Range.Start.Line = 20
490	if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 0 {
491		t.Fatalf("shifted diagnostic was replayed: %#v", got)
492	}
493	if got := manager.ReduceDiagnostics(root, path, nil); len(got) != 0 {
494		t.Fatalf("clear returned %#v", got)
495	}
496	if got := manager.ReduceDiagnostics(root, path, []Diagnostic{shifted}); len(got) != 1 {
497		t.Fatalf("diagnostic did not reappear after clear: %#v", got)
498	}
499}

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 1

Tags 476

↑↓ moveenter openesc close