feat/focused-dot 6314afc

fswatch.go RawHistoryFolder

1// Package fswatch reports changes to one file with inotify.
2//
3// It watches the parent directory, so a file that is replaced by a rename (the
4// way editors and NeferWL write) is noticed, and it follows symlinks, so a
5// dotfile-managed file is noticed at its target.
6package fswatch
7
8import (
9	"context"
10	"errors"
11	"os"
12	"path/filepath"
13	"slices"
14	"time"
15	"unsafe"
16
17	"golang.org/x/sys/unix"
18)
19
20const (
21	// retryEvery is how often a missing directory is looked for again.
22	retryEvery = 2 * time.Second
23	// pollEvery bounds how long a wait lasts, so ctx is noticed.
24	pollEvery = 200 // ms
25	// mask is every way a file can be written, replaced or removed. IN_MODIFY
26	// restarts the debounce on each write, so a truncating in-place save is
27	// never read half written.
28	mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
29)
30
31// files lists path and every symlink target it goes through.
32func files(path string) []string {
33	out := []string{filepath.Clean(path)}
34	for len(out) <= 40 { // the kernel's symlink limit
35		cur := out[len(out)-1]
36		target, err := os.Readlink(cur)
37		if err != nil {
38			break
39		}
40		if !filepath.IsAbs(target) {
41			target = filepath.Join(filepath.Dir(cur), target)
42		}
43		target = filepath.Clean(target)
44		if slices.Contains(out, target) {
45			break
46		}
47		out = append(out, target)
48	}
49	return out
50}
51
52// Watch sends on out after path changed and stayed quiet for debounce. A
53// debounce of 0 reports each change at once. The send never blocks: if out is
54// full, the pending signal already says "something changed", which is all a
55// reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends.
56func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error {
57	fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
58	if err != nil {
59		return err
60	}
61	defer unix.Close(fd)
62
63	names := map[int32]map[string]bool{} // watch -> file names that matter in its directory
64	var watched []string
65	var retry, pending time.Time
66	rewatch := true
67	buf := make([]byte, 4096)
68	fds := make([]unix.PollFd, 1)
69
70	for ctx.Err() == nil {
71		if !retry.IsZero() && !time.Now().Before(retry) {
72			rewatch = true
73		}
74		if rewatch {
75			rewatch, retry = false, time.Time{}
76			for wd := range names {
77				_, _ = unix.InotifyRmWatch(fd, uint32(wd))
78			}
79			clear(names)
80			watched = files(path)
81			for _, file := range watched {
82				wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
83				if errors.Is(err, unix.ENOENT) {
84					retry = time.Now().Add(retryEvery)
85					continue
86				}
87				if err != nil {
88					return err
89				}
90				if names[int32(wd)] == nil {
91					names[int32(wd)] = map[string]bool{}
92				}
93				names[int32(wd)][filepath.Base(file)] = true
94			}
95		}
96
97		timeout := pollEvery
98		if !retry.IsZero() {
99			timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
100		}
101		if !pending.IsZero() {
102			remaining := time.Until(pending)
103			if remaining <= 0 {
104				pending = time.Time{}
105				select {
106				case out <- struct{}{}:
107				default:
108				}
109				continue
110			}
111			timeout = min(timeout, int(remaining.Milliseconds())+1)
112		}
113
114		fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
115		if _, err = unix.Poll(fds, timeout); err != nil {
116			if err == unix.EINTR {
117				continue
118			}
119			return err
120		}
121		if fds[0].Revents&unix.POLLIN == 0 {
122			continue
123		}
124		for {
125			n, err := unix.Read(fd, buf)
126			if err == unix.EAGAIN || n == 0 {
127				break
128			}
129			if err == unix.EINTR {
130				continue
131			}
132			if err != nil {
133				return err
134			}
135			for off := 0; off+unix.SizeofInotifyEvent <= n; {
136				ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
137				size := unix.SizeofInotifyEvent + int(ev.Len)
138				if off+size > n {
139					break
140				}
141				// One of our watches vanished (its directory was removed).
142				if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil {
143					rewatch = true
144				}
145				if ev.Len > 0 {
146					name := buf[off+unix.SizeofInotifyEvent : off+size]
147					for len(name) > 0 && name[len(name)-1] == 0 {
148						name = name[:len(name)-1]
149					}
150					if names[ev.Wd][string(name)] && ev.Mask&mask != 0 {
151						pending = time.Now().Add(debounce)
152						if !slices.Equal(watched, files(path)) {
153							rewatch = true // a symlink now points elsewhere
154						}
155					}
156				}
157				off += size
158			}
159		}
160	}
161	return nil
162}

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 5

Tags 4

↑↓ moveenter openesc close