v0.1.0 d9218d6

statefile.go RawHistoryFolder

1// Package statefile publishes core's state as JSON for scripts, and reads it
2// back for the `neferwl state` command. The JSON format is defined here,
3// not in ports: it is a public interface scripts depend on.
4package statefile
5
6import (
7	"context"
8	"encoding/json"
9	"errors"
10	"fmt"
11	"os"
12	"path/filepath"
13	"strconv"
14	"strings"
15
16	"github.com/bnema/neferwl/internal/ports"
17	"github.com/bnema/zerowrap"
18)
19
20// Env names the state file in the environment of processes neferwl starts.
21const Env = "NEFERWL_STATE"
22
23// Path is the state file of the session on a wayland socket: one per
24// session, so nested or test instances never overwrite each other.
25func Path(runtimeDir, socket string) (string, error) {
26	socket = filepath.Base(socket)
27	if runtimeDir == "" || socket == "" || socket == "." || socket == "/" {
28		return "", errors.New("XDG_RUNTIME_DIR and WAYLAND_DISPLAY must be set")
29	}
30	return filepath.Join(runtimeDir, "neferwl", socket+".json"), nil
31}
32
33// State is the file format.
34type State struct {
35	Output  string   `json:"output"`
36	Outputs []Output `json:"outputs"`
37	Window  *Window  `json:"window"`
38	Windows []Window `json:"windows"`
39}
40
41// Output is one output: its active numbered workspace (0 while a hidden one
42// is shown), how many numbered workspaces it has, and the name of the
43// workspace on screen.
44type Output struct {
45	Name      string `json:"name"`
46	Active    int    `json:"active"`
47	Count     int    `json:"count"`
48	Workspace string `json:"workspace"`
49}
50
51// Window is one mapped window.
52type Window struct {
53	ID        uint64 `json:"id"`
54	AppID     string `json:"app_id"`
55	PID       int    `json:"pid"`
56	Output    string `json:"output"`
57	Workspace int    `json:"workspace"`
58	Visible   bool   `json:"visible"`
59}
60
61func fromPorts(st ports.State) State {
62	out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))}
63	for _, o := range st.Outputs {
64		out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace})
65	}
66	for _, w := range st.Windows {
67		out.Windows = append(out.Windows, window(w))
68	}
69	if st.Window != nil {
70		w := window(*st.Window)
71		out.Window = &w
72	}
73	return out
74}
75
76func window(w ports.WindowState) Window {
77	return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible}
78}
79
80// Run writes every state it receives to path until ctx ends, then removes
81// the file: a stale state must not outlive the session.
82func Run(ctx context.Context, path string, states <-chan ports.State, log zerowrap.Logger) error {
83	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
84		return err
85	}
86	// A file left by a crashed session on this socket is not ours.
87	_ = os.Remove(path)
88	defer os.Remove(path)
89	for {
90		select {
91		case <-ctx.Done():
92			return nil
93		case st := <-states:
94			if err := write(path, fromPorts(st)); err != nil {
95				// Scripts lose their state; the session goes on.
96				log.Warn().Err(err).Str("path", path).Msg("write state")
97			}
98		}
99	}
100}
101
102// write replaces the file atomically: readers never see half a state.
103func write(path string, st State) error {
104	data, err := json.Marshal(st)
105	if err != nil {
106		return err
107	}
108	f, err := os.CreateTemp(filepath.Dir(path), ".state-*.json")
109	if err != nil {
110		return err
111	}
112	_, werr := f.Write(append(data, '\n'))
113	cerr := f.Close()
114	if err := errors.Join(werr, cerr); err != nil {
115		os.Remove(f.Name())
116		return err
117	}
118	return os.Rename(f.Name(), path)
119}
120
121// Read loads the state file.
122func Read(path string) (State, error) {
123	var st State
124	data, err := os.ReadFile(path)
125	if err != nil {
126		return st, err
127	}
128	return st, json.Unmarshal(data, &st)
129}
130
131// procTree finds the parent of a process.
132type procTree interface {
133	Parent(pid int) (int, bool)
134}
135
136// linuxProcTree reads /proc/<pid>/stat.
137type linuxProcTree struct{}
138
139func (linuxProcTree) Parent(pid int) (int, bool) {
140	data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
141	if err != nil {
142		return 0, false
143	}
144	// "pid (comm) state ppid ...": comm may hold spaces and parentheses.
145	i := strings.LastIndexByte(string(data), ')')
146	if i < 0 {
147		return 0, false
148	}
149	f := strings.Fields(string(data[i+1:]))
150	if len(f) < 2 {
151		return 0, false
152	}
153	ppid, err := strconv.Atoi(f[1])
154	return ppid, err == nil && ppid > 0
155}
156
157// ErrNoWindow means no window belongs to the process or its ancestors.
158var ErrNoWindow = errors.New("no window for this process")
159
160// OutputOf finds the output showing a process: the window of the process
161// itself or of its nearest ancestor, e.g. the terminal of a shell. Only
162// application windows count: bars (layer surfaces) have no single output.
163// A process with windows on several outputs resolves to its focused window,
164// else a visible one. PIDs are those of the compositor's PID namespace.
165func OutputOf(st State, pid int) (Output, error) {
166	return outputOf(st, pid, linuxProcTree{})
167}
168
169func outputOf(st State, pid int, tree procTree) (Output, error) {
170	byPID := map[int]Window{}
171	for _, w := range st.Windows {
172		if w.PID <= 0 {
173			continue
174		}
175		cur, seen := byPID[w.PID]
176		if !seen || better(st, w, cur) {
177			byPID[w.PID] = w
178		}
179	}
180	// A depth limit guards against a cycle from a reused PID mid-walk.
181	for range 64 {
182		if w, ok := byPID[pid]; ok {
183			for _, o := range st.Outputs {
184				if o.Name == w.Output {
185					return o, nil
186				}
187			}
188			return Output{}, fmt.Errorf("output %q not in state", w.Output)
189		}
190		parent, ok := tree.Parent(pid)
191		if !ok || parent == pid {
192			break
193		}
194		pid = parent
195	}
196	return Output{}, ErrNoWindow
197}
198
199// better prefers the focused window, then a visible one.
200func better(st State, a, b Window) bool {
201	focused := func(w Window) bool { return st.Window != nil && st.Window.ID == w.ID }
202	if focused(a) != focused(b) {
203		return focused(a)
204	}
205	return a.Visible && !b.Visible
206}

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 25

Tags 10

↑↓ moveenter openesc close