v0.2.0 c616855

fswatch.go RawHistoryFolder

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

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