v0.11.0 0633e44

convert.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package ermitegit
4
5import (
6	"bytes"
7	"context"
8	"errors"
9	"fmt"
10	"io"
11	"strings"
12	"time"
13
14	git "bnema.dev/ermite-git"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20const (
21	headsPrefix = "refs/heads/"
22	tagsPrefix  = "refs/tags/"
23)
24
25func domainID(id git.ObjectID) (domain.ObjectID, error) { return domain.ParseObjectID(id.String()) }
26
27// listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag
28// follows to its commit when it leads to one; HEAD takes the ids of its
29// branch or tag, and only the name of an unborn branch.
30//
31//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
32func listRefs(r *git.Repo) ([]domain.Ref, error) {
33	var refs []domain.Ref
34	byName := make(map[string]domain.Ref)
35	for _, prefix := range [...]string{headsPrefix, tagsPrefix} {
36		kind := domain.RefKindBranch
37		if prefix == tagsPrefix {
38			kind = domain.RefKindTag
39		}
40		for ref, err := range r.Refs(prefix) {
41			if err != nil {
42				return nil, fmt.Errorf("list refs: %w", err)
43			}
44			out := domain.Ref{Name: ref.Name, ShortName: ref.Name[len(prefix):], Kind: kind}
45			var err2 error
46			if out.ID, err2 = domainID(ref.ID); err2 != nil {
47				return nil, err2
48			}
49			commit, err := r.PeelRef(ref)
50			switch {
51			case err == nil:
52				if out.CommitID, err2 = domainID(commit); err2 != nil {
53					return nil, err2
54				}
55			case errors.Is(err, git.ErrWrongType):
56				// A tag of a tree or a blob has no commit.
57			case errors.Is(err, git.ErrNotFound):
58				// The ref exists and its object does not: the repository is
59				// damaged, which is not a missing repository or revision, so
60				// the error must not match domain.ErrNotFound (gitcli fails
61				// the same way). %v hides git.ErrNotFound from mapErr.
62				return nil, fmt.Errorf("ref %s points to a missing object: %v", ref.Name, err)
63			default:
64				return nil, fmt.Errorf("follow %s: %w", ref.Name, err)
65			}
66			refs = append(refs, out)
67			byName[out.Name] = out
68		}
69	}
70	head := domain.Ref{Name: "HEAD", Kind: domain.RefKindHead}
71	target, _, err := r.Head()
72	if err != nil {
73		return nil, fmt.Errorf("read HEAD: %w", err)
74	}
75	if b, ok := byName[target]; ok && target != "" {
76		head.ShortName, head.ID, head.CommitID = b.ShortName, b.ID, b.CommitID
77	} else if short, ok := cutPrefix(target, headsPrefix); ok {
78		head.ShortName = short // unborn branch
79	}
80	return append([]domain.Ref{head}, refs...), nil
81}
82
83func cutPrefix(s, prefix string) (string, bool) {
84	if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
85		return s[len(prefix):], true
86	}
87	return s, false
88}
89
90// resolved is a revision resolved to a commit.
91type resolved struct {
92	commit git.ObjectID
93	ref    string
94}
95
96func (r resolved) domain() (domain.ResolvedRevision, error) {
97	id, err := domainID(r.commit)
98	return domain.ResolvedRevision{CommitID: id, Ref: r.ref}, err
99}
100
101// peelRef reads a ref and peels it to a commit. ok is false when the ref does
102// not exist or does not lead to a commit.
103func peelRef(r *git.Repo, name string) (_ git.ObjectID, ok bool, _ error) {
104	// LookupRefID allocates nothing on a miss, which is the common case of the
105	// candidates of resolveRevision (heads/x, then tags/x, then refs/...).
106	id, found, err := r.LookupRefID(name)
107	if err != nil || !found {
108		return git.ObjectID{}, false, err
109	}
110	return peelID(r, id)
111}
112
113func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) {
114	commit, err := r.PeelToCommit(id)
115	if errors.Is(err, git.ErrNotFound) || errors.Is(err, git.ErrWrongType) {
116		return git.ObjectID{}, false, nil
117	}
118	if err != nil {
119		return git.ObjectID{}, false, err
120	}
121	return commit, true, nil
122}
123
124// resolveRevision tries the candidates of gitcli in the same order, which
125// the port documents: HEAD is HEAD; a full object id is an object before it
126// is a ref; then the branch, then the tag; then a full ref name and an
127// abbreviated id as written. It does not use Repo.Resolve, whose order is
128// git's own (tags before branches, no ref for a missing full id).
129//
130//hexcheck:ignore suspicious-business-logic-in-adapter tries the candidates of the port in its documented order, like gitcli; the rule is the port contract
131func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {
132	s := rev.String()
133	if rev.IsHead() || s == "HEAD" {
134		if c, ok, err := peelRef(r, "HEAD"); err != nil || ok {
135			return resolved{commit: c}, err
136		}
137		return resolved{}, notFound(rev)
138	}
139	if rev.IsObjectID() {
140		id, err := git.ParseID(s)
141		if err != nil {
142			return resolved{}, err
143		}
144		if c, ok, err := peelID(r, id); err != nil || ok {
145			return resolved{commit: c}, err
146		}
147	}
148	for _, name := range [...]string{headsPrefix + s, tagsPrefix + s} {
149		if c, ok, err := peelRef(r, name); err != nil || ok {
150			return resolved{commit: c, ref: name}, err
151		}
152	}
153	switch {
154	case len(s) > 5 && s[:5] == "refs/":
155		if c, ok, err := peelRef(r, s); err != nil || ok {
156			return resolved{commit: c, ref: s}, err
157		}
158	case rev.IsAbbrevID():
159		id, err := r.ResolveCommitPrefix(s)
160		if err == nil {
161			// The prefix may name an annotated tag: git peels it.
162			id, err = r.PeelToCommit(id)
163		}
164		switch {
165		case err == nil:
166			return resolved{commit: id}, nil
167		case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):
168		default:
169			return resolved{}, err
170		}
171	}
172	return resolved{}, notFound(rev)
173}
174
175func notFound(rev domain.Revision) error {
176	return fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
177}
178
179// canonMode is the mode git reports for a tree entry: legacy permission bits
180// are normalized (tree-walk.c canon_mode).
181func canonMode(m git.Mode) uint32 {
182	switch {
183	case m.IsRegular():
184		if m&0o100 != 0 {
185			return 0o100755
186		}
187		return 0o100644
188	case m.IsSymlink():
189		return 0o120000
190	case m.IsTree():
191		return 0o040000
192	}
193	return 0o160000
194}
195
196// commitTree resolves rev and reads the root tree of the commit.
197func commitTree(r *git.Repo, rev domain.Revision) (domain.ObjectID, git.ObjectID, error) {
198	res, err := resolveRevision(r, rev)
199	if err != nil {
200		return domain.ObjectID{}, git.ObjectID{}, err
201	}
202	var c git.Commit
203	if err := r.ReadCommit(res.commit, &c); err != nil {
204		return domain.ObjectID{}, git.ObjectID{}, err
205	}
206	id, err := domainID(res.commit)
207	return id, c.Tree, err
208}
209
210// lookup finds the entry at a path of a commit tree. A path that does not
211// exist, and a submodule (whose commit is not in the repository), are
212// domain.ErrNotFound like in gitcli.
213func lookup(r *git.Repo, tree git.ObjectID, path domain.TreePath) (git.Entry, error) {
214	e, err := r.LookupPath(tree, path.String())
215	if errors.Is(err, git.ErrNotFound) {
216		return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
217	}
218	if err != nil {
219		return git.Entry{}, err
220	}
221	if e.Mode.IsGitlink() {
222		return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
223	}
224	return e, nil
225}
226
227// getTree lists a directory.
228//
229//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
230func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath, includeLast bool) (domain.Tree, error) {
231	commit, root, err := commitTree(r, rev)
232	if err != nil {
233		return domain.Tree{}, err
234	}
235	dir, err := lookup(r, root, path)
236	if err != nil {
237		return domain.Tree{}, err
238	}
239	if !dir.Mode.IsTree() {
240		return domain.Tree{}, domain.ErrNotDirectory
241	}
242	treeID, err := domainID(dir.ID)
243	if err != nil {
244		return domain.Tree{}, err
245	}
246	tree := domain.Tree{CommitID: commit, ID: treeID}
247	// The ids, the names and the paths of all the entries share one backing
248	// string, cut into substrings.
249	type span struct {
250		name, path, id [2]int
251		mode           uint32
252		kind           domain.EntryKind
253	}
254	var (
255		spans []span
256		buf   []byte
257		bad   error
258	)
259	prefix := path.String()
260	err = r.ReadTree(dir.ID, func(e git.Entry) bool {
261		mode := canonMode(e.Mode)
262		kind := domain.EntryKindOf(mode)
263		if kind == "" {
264			bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
265			return false
266		}
267		var sp span
268		sp.mode, sp.kind = mode, kind
269		sp.id[0] = len(buf)
270		buf = e.ID.AppendHex(buf)
271		sp.id[1] = len(buf)
272		sp.name[0] = len(buf)
273		buf = append(buf, e.Name...)
274		sp.name[1] = len(buf)
275		if prefix == "" {
276			sp.path = sp.name
277		} else {
278			sp.path[0] = len(buf)
279			buf = append(buf, prefix...)
280			buf = append(buf, '/')
281			buf = append(buf, e.Name...)
282			sp.path[1] = len(buf)
283		}
284		spans = append(spans, sp)
285		return true
286	})
287	if err == nil {
288		err = bad
289	}
290	if err != nil {
291		return domain.Tree{}, fmt.Errorf("list tree %q: %w", path, err)
292	}
293	text := string(buf)
294	tree.Entries = make([]domain.TreeEntry, len(spans))
295	for i, sp := range spans {
296		id, err := domain.ParseObjectID(text[sp.id[0]:sp.id[1]])
297		if err != nil {
298			return domain.Tree{}, err
299		}
300		tree.Entries[i] = domain.TreeEntry{
301			Name: text[sp.name[0]:sp.name[1]], Path: text[sp.path[0]:sp.path[1]],
302			Kind: sp.kind, Mode: sp.mode, ID: id,
303		}
304	}
305	domain.SortTreeEntries(tree.Entries)
306	tree.BoundTree()
307	// The size of a file costs a header read: only for the entries that stay.
308	for i, e := range tree.Entries {
309		if e.Kind == domain.EntryDirectory || e.Kind == domain.EntrySubmodule {
310			continue
311		}
312		id, err := git.ParseID(e.ID.String())
313		if err != nil {
314			return domain.Tree{}, err
315		}
316		_, size, err := r.ObjectHeader(id)
317		if err != nil {
318			return domain.Tree{}, fmt.Errorf("size of %q: %w", e.Path, err)
319		}
320		tree.Entries[i].Size = size
321	}
322	if includeLast {
323		if err := fillLastCommits(ctx, r, commit, path, &tree); err != nil {
324			return domain.Tree{}, fmt.Errorf("last commits of %q: %w", path, err)
325		}
326	}
327	return tree, nil
328}
329
330// pathChunk is the size of the blocks that hold the bytes of the paths.
331const pathChunk = 32 << 10
332
333// pathWalker lists the files below a tree, depth first, in tree order.
334type pathWalker struct {
335	ctx      context.Context
336	r        *git.Repo
337	maxPaths int
338	maxBytes int
339
340	// list collects the paths. Their bytes live in chunks: a chunk is filled
341	// by a strings.Builder, so a path is read back as a string without a copy,
342	// and a full chunk is left to the paths that point into it instead of
343	// being grown and copied.
344	list   domain.PathList
345	chunk  strings.Builder
346	prefix []byte // "dir/sub/" of the directory being read
347	bad    error
348}
349
350// listPaths lists the files and symbolic links of a revision recursively.
351func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) {
352	commit, root, err := commitTree(r, rev)
353	if err != nil {
354		return domain.PathList{}, err
355	}
356	treeID, err := domainID(root)
357	if err != nil {
358		return domain.PathList{}, err
359	}
360	w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes}
361	w.list.CommitID, w.list.TreeID = commit, treeID
362	if err := w.walk(root); err != nil {
363		return domain.PathList{}, err
364	}
365	return w.list, nil
366}
367
368// walk lists one directory. The recursion is inside the callback of ReadTree,
369// where the entries of a subdirectory come at the place of the subdirectory,
370// like in `git ls-tree -r`; the callback can only say stop, so the reasons to
371// stop are kept in the walker. Every ReadTree takes its own pooled buffer, so
372// the nesting is safe.
373func (w *pathWalker) walk(tree git.ObjectID) error {
374	if err := w.ctx.Err(); err != nil {
375		return err
376	}
377	err := w.r.ReadTree(tree, func(e git.Entry) bool {
378		switch {
379		case e.Mode.IsGitlink():
380			return true
381		case e.Mode.IsTree():
382			mark := len(w.prefix)
383			w.prefix = append(w.prefix, e.Name...)
384			// The paths below a directory that is not addressable are not
385			// either: this also bounds the depth of the recursion.
386			if !domain.IsAddressablePathBytes(w.prefix) {
387				w.prefix = w.prefix[:mark]
388				return true
389			}
390			w.prefix = append(w.prefix, '/')
391			err := w.walk(e.ID)
392			w.prefix = w.prefix[:mark]
393			if err != nil {
394				w.bad = err
395				return false
396			}
397			return !w.list.Truncated
398		case e.Mode.IsRegular(), e.Mode.IsSymlink():
399			return w.add(e.Name)
400		}
401		w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
402		return false
403	})
404	if err == nil {
405		err = w.bad
406	}
407	return err
408}
409
410// add lists the file name of the current directory, and says whether the walk
411// goes on. PathList.Add filters the paths that are not addressable; their
412// bytes stay in the chunk, which is rare enough not to matter.
413func (w *pathWalker) add(name []byte) bool {
414	mark := len(w.prefix)
415	w.prefix = append(w.prefix, name...)
416	path := w.prefix
417	w.prefix = w.prefix[:mark]
418	if w.chunk.Cap()-w.chunk.Len() < len(path) {
419		w.chunk = strings.Builder{}
420		w.chunk.Grow(max(pathChunk, len(path)))
421	}
422	start := w.chunk.Len()
423	w.chunk.Write(path)
424	return w.list.Add(w.chunk.String()[start:], w.maxPaths, w.maxBytes)
425}
426
427// fillLastCommits sets the last commit of the first MaxLastCommitEntries
428// entries with one walk.
429//
430//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
431func fillLastCommits(ctx context.Context, r *git.Repo, commit domain.ObjectID, dir domain.TreePath, tree *domain.Tree) error {
432	n := len(tree.Entries)
433	if n > domain.MaxLastCommitEntries {
434		n = domain.MaxLastCommitEntries
435		tree.LastCommitsOmitted = true
436	}
437	if n == 0 {
438		return nil
439	}
440	start, err := git.ParseID(commit.String())
441	if err != nil {
442		return err
443	}
444	names := make([]string, n)
445	for i := range n {
446		names[i] = tree.Entries[i].Name
447	}
448	ids, err := r.LastCommits(ctx, start, dir.String(), names)
449	if err != nil {
450		return err
451	}
452	var c git.Commit
453	seen := make(map[git.ObjectID]domain.Commit, 8)
454	for i, id := range ids {
455		if id.IsZero() {
456			return fmt.Errorf("no commit touches %q: %w", tree.Entries[i].Path, domain.ErrNotFound)
457		}
458		dc, ok := seen[id]
459		if !ok {
460			if err := r.ReadCommit(id, &c); err != nil {
461				return err
462			}
463			if dc, err = convertCommit(&c); err != nil {
464				return err
465			}
466			seen[id] = dc
467		}
468		cp := dc
469		tree.Entries[i].LastCommit = &cp
470	}
471	return nil
472}
473
474// convertCommit builds a domain commit. A commit in another encoding than
475// UTF-8 is converted by git and not by the library: errFallback.
476//
477// The ids, the names, the emails and the message share one backing string, cut
478// into substrings: one allocation for the text of a commit instead of one per
479// field.
480//
481//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
482func convertCommit(c *git.Commit) (domain.Commit, error) {
483	if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) {
484		return domain.Commit{}, errFallback
485	}
486	buf := make([]byte, 0, 40*(1+len(c.Parents))+len(c.Author.Name)+len(c.Author.Email)+
487		len(c.Committer.Name)+len(c.Committer.Email)+len(c.Message))
488	buf = c.ID.AppendHex(buf)
489	for _, p := range c.Parents {
490		buf = p.AppendHex(buf)
491	}
492	end := [5]int{}
493	for i, f := range [...][]byte{c.Author.Name, c.Author.Email, c.Committer.Name, c.Committer.Email, c.Message} {
494		if i == 0 || i == 2 {
495			f = trimNameRight(f)
496		}
497		buf = append(buf, f...)
498		end[i] = len(buf)
499	}
500	text := string(buf)
501
502	id, err := domain.ParseObjectID(text[:40])
503	if err != nil {
504		return domain.Commit{}, err
505	}
506	out := domain.Commit{ID: id}
507	if len(c.Parents) > 0 {
508		out.Parents = make([]domain.ObjectID, len(c.Parents))
509		for i := range c.Parents {
510			if out.Parents[i], err = domain.ParseObjectID(text[40*(i+1) : 40*(i+2)]); err != nil {
511				return domain.Commit{}, err
512			}
513		}
514	}
515	at := 40 * (1 + len(c.Parents))
516	out.Author = domain.Signature{
517		Name: domain.ValidGitText(text[at:end[0]]), Email: domain.ValidGitText(text[end[0]:end[1]]),
518		Time: time.Unix(c.Author.Time, 0).UTC(),
519	}
520	out.Committer = domain.Signature{
521		Name: domain.ValidGitText(text[end[1]:end[2]]), Email: domain.ValidGitText(text[end[2]:end[3]]),
522		Time: time.Unix(c.Committer.Time, 0).UTC(),
523	}
524	out.Message, out.Summary = domain.SplitCommitMessage(text[end[3]:])
525	return out, nil
526}
527
528// trimNameRight drops the trailing whitespace of a name the way git does (the
529// library trims spaces only).
530func trimNameRight(b []byte) []byte { return bytes.TrimRight(b, " \t\r\n") }
531
532func isUTF8Label(b []byte) bool {
533	return bytes.EqualFold(b, []byte("utf-8")) || bytes.EqualFold(b, []byte("utf8"))
534}
535
536// listCommits returns a page of history, one commit more than the page to
537// know whether the history goes on.
538func listCommits(ctx context.Context, r *git.Repo, q ports.CommitQuery) (ports.CommitPage, error) {
539	res, err := resolveRevision(r, q.Revision)
540	if err != nil {
541		return ports.CommitPage{}, err
542	}
543	head, err := res.domain()
544	if err != nil {
545		return ports.CommitPage{}, err
546	}
547	page := ports.CommitPage{Head: head.CommitID, Commits: make([]domain.Commit, 0, min(q.Limit, 64))}
548	opts := git.WalkOptions{Skip: q.Skip, Limit: q.Limit + 1, Path: q.Path.String()}
549	for c, err := range r.Walk(ctx, res.commit, opts) {
550		if err != nil {
551			return ports.CommitPage{}, err
552		}
553		if len(page.Commits) == q.Limit {
554			page.HasMore = true
555			break
556		}
557		dc, err := convertCommit(c)
558		if err != nil {
559			return ports.CommitPage{}, err
560		}
561		page.Commits = append(page.Commits, dc)
562	}
563	if len(page.Commits) == 0 {
564		page.Commits = nil // like gitcli: an empty page has no slice
565	}
566	return page, nil
567}
568
569// openBlob describes the file at path and opens its content. The first
570// BinarySniffLen bytes are read to decide Binary and MediaType, then given
571// back to the reader.
572//
573//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
574func openBlob(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
575	if path.IsRoot() {
576		return domain.BlobInfo{}, nil, domain.ErrNotFile
577	}
578	_, root, err := commitTree(r, rev)
579	if err != nil {
580		return domain.BlobInfo{}, nil, err
581	}
582	e, err := lookup(r, root, path)
583	if err != nil {
584		return domain.BlobInfo{}, nil, err
585	}
586	if e.Mode.IsTree() {
587		return domain.BlobInfo{}, nil, domain.ErrNotFile
588	}
589	id, err := domainID(e.ID)
590	if err != nil {
591		return domain.BlobInfo{}, nil, err
592	}
593	size, rc, err := r.OpenBlob(e.ID)
594	if err != nil {
595		return domain.BlobInfo{}, nil, err
596	}
597	info := domain.BlobInfo{ID: id, Path: path.String(), Size: size, Mode: canonMode(e.Mode)}
598	head := make([]byte, min(size, int64(domain.BinarySniffLen)))
599	if _, err := io.ReadFull(rc, head); err != nil {
600		_ = rc.Close()
601		return domain.BlobInfo{}, nil, fmt.Errorf("read blob: %w", err)
602	}
603	info.SniffContent(head)
604	return info, &blobReader{ctx: ctx, head: head, rc: rc}, nil
605}
606
607// blobReader replays the bytes read to sniff the content, then the rest of
608// the blob. It stops when the context ends, as a gitcli stream does.
609type blobReader struct {
610	ctx  context.Context
611	head []byte
612	rc   io.ReadCloser
613}
614
615func (b *blobReader) Read(p []byte) (int, error) {
616	if err := b.ctx.Err(); err != nil {
617		return 0, err
618	}
619	if len(b.head) > 0 {
620		n := copy(p, b.head)
621		b.head = b.head[n:]
622		return n, nil
623	}
624	return b.rc.Read(p)
625}
626
627func (b *blobReader) Close() error { return b.rc.Close() }

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 2

Tags 45

↑↓ moveenter openesc close