main f2d166b

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	"time"
16
17	"github.com/bnema/neferwl/internal/adapters/workspaceid"
18	"github.com/bnema/neferwl/internal/ports"
19	"github.com/bnema/zerowrap"
20)
21
22// Env names the state file in the environment of processes neferwl starts.
23const Env = "NEFERWL_STATE"
24
25// Path is the state file of the session on a wayland socket: one per
26// session, so nested or test instances never overwrite each other.
27func Path(runtimeDir, socket string) (string, error) {
28	socket = filepath.Base(socket)
29	if runtimeDir == "" || socket == "" || socket == "." || socket == "/" {
30		return "", errors.New("XDG_RUNTIME_DIR and WAYLAND_DISPLAY must be set")
31	}
32	return filepath.Join(runtimeDir, "neferwl", socket+".json"), nil
33}
34
35// State is the file format.
36type State struct {
37	Output  string   `json:"output"`
38	Outputs []Output `json:"outputs"`
39	Window  *Window  `json:"window"`
40	Windows []Window `json:"windows"`
41	// Workspaces lists every workspace of every output.
42	Workspaces []Workspace `json:"workspaces"`
43}
44
45// Workspace is one workspace and its tiled columns, left to right. ID is
46// the workspace_id string of Output. Number is 0 for a hidden workspace.
47// Visible means on screen on its output.
48type Workspace struct {
49	ID       string   `json:"id"`
50	Number   int      `json:"number"`
51	Name     string   `json:"name"`
52	Output   string   `json:"output"`
53	Visible  bool     `json:"visible"`
54	Overflow string   `json:"overflow"`
55	Columns  []Column `json:"columns"`
56}
57
58// Column is the area of one column in logical pixels of its output, view
59// offset applied (it may be off-screen), and its windows top to bottom.
60type Column struct {
61	X       int    `json:"x"`
62	Y       int    `json:"y"`
63	Width   int    `json:"width"`
64	Height  int    `json:"height"`
65	Windows []Tile `json:"windows"`
66}
67
68// Tile is one tiled window and its rectangle.
69type Tile struct {
70	ID     uint64 `json:"id"`
71	X      int    `json:"x"`
72	Y      int    `json:"y"`
73	Width  int    `json:"width"`
74	Height int    `json:"height"`
75}
76
77// Output is one output: its active numbered workspace (0 while a hidden one
78// is shown), how many numbered workspaces it has, and the name and ID of the
79// workspace on screen. WorkspaceID is the same string ext_workspace_handle_v1
80// sends as its id (package workspaceid): "name:<name>" for a configured
81// workspace, else "<launch prefix>-<core ID>".
82type Output struct {
83	Name        string `json:"name"`
84	Active      int    `json:"active"`
85	Count       int    `json:"count"`
86	Workspace   string `json:"workspace"`
87	WorkspaceID string `json:"workspace_id"`
88}
89
90// Window is one mapped window.
91type Window struct {
92	ID        uint64 `json:"id"`
93	AppID     string `json:"app_id"`
94	PID       int    `json:"pid"`
95	Output    string `json:"output"`
96	Workspace int    `json:"workspace"`
97	// WorkspaceID is the ID of the window's workspace, numbered or hidden
98	// (see Output.WorkspaceID).
99	WorkspaceID string `json:"workspace_id"`
100	Visible     bool   `json:"visible"`
101	// Floating is set for native floats (dialogs) and stashed windows.
102	Floating bool `json:"floating"`
103	// StashIndex is the 1-based place of a stashed window in its
104	// workspace's stash of StashCount windows; both are 0 outside it.
105	StashIndex int `json:"stash_index"`
106	StashCount int `json:"stash_count"`
107	// Column and Row are the 1-based place of a tiled window; both are 0
108	// for floating windows.
109	Column int `json:"column"`
110	Row    int `json:"row"`
111	// Hidden is set for a stashed window while the stash is hidden.
112	Hidden bool `json:"hidden"`
113}
114
115func fromPorts(st ports.State, ids *workspaceid.IDs) State {
116	out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows)), Workspaces: make([]Workspace, 0, len(st.Workspaces))}
117	for _, o := range st.Outputs {
118		out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace, WorkspaceID: ids.ID(o.WorkspaceID, o.Workspace)})
119	}
120	for _, w := range st.Windows {
121		out.Windows = append(out.Windows, window(w, ids))
122	}
123	for _, w := range st.Workspaces {
124		ws := Workspace{ID: ids.ID(w.ID, w.Name), Number: w.Number, Name: w.Name, Output: w.Output, Visible: w.Visible, Overflow: w.Overflow, Columns: make([]Column, 0, len(w.Columns))}
125		for _, c := range w.Columns {
126			col := Column{X: c.Rect.X, Y: c.Rect.Y, Width: c.Rect.W, Height: c.Rect.H, Windows: make([]Tile, 0, len(c.Windows))}
127			for _, t := range c.Windows {
128				col.Windows = append(col.Windows, Tile{ID: uint64(t.ID), X: t.Rect.X, Y: t.Rect.Y, Width: t.Rect.W, Height: t.Rect.H})
129			}
130			ws.Columns = append(ws.Columns, col)
131		}
132		out.Workspaces = append(out.Workspaces, ws)
133	}
134	if st.Window != nil {
135		w := window(*st.Window, ids)
136		out.Window = &w
137	}
138	return out
139}
140
141func window(w ports.WindowState, ids *workspaceid.IDs) Window {
142	return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
143}
144
145// writeInterval is the least time between two writes of the file.
146const writeInterval = time.Second
147
148// Run writes the states it receives to path until ctx ends, then removes
149// the file: a stale state must not outlive the session. It writes at most
150// once per writeInterval: a state after a quiet interval is written at once,
151// others are coalesced and the latest is written when the interval ends.
152func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {
153	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
154		return err
155	}
156	// A file left by a crashed session on this socket is not ours.
157	_ = os.Remove(path)
158	defer os.Remove(path)
159	flush := func(st ports.State) {
160		if err := write(path, fromPorts(st, ids)); err != nil {
161			// Scripts lose their state; the session goes on.
162			log.Warn().Err(err).Str("path", path).Msg("write state")
163		}
164	}
165	timer := time.NewTimer(0)
166	defer timer.Stop()
167	<-timer.C
168	var (
169		last    time.Time
170		pending ports.State
171		waiting bool
172	)
173	for {
174		var due <-chan time.Time
175		if waiting {
176			due = timer.C
177		}
178		select {
179		case <-ctx.Done():
180			return nil
181		case st := <-states:
182			if waiting {
183				pending = st
184				continue
185			}
186			if wait := writeInterval - time.Since(last); !last.IsZero() && wait > 0 {
187				pending, waiting = st, true
188				timer.Reset(wait)
189				continue
190			}
191			flush(st)
192			last = time.Now()
193		case <-due:
194			waiting = false
195			flush(pending)
196			last = time.Now()
197		}
198	}
199}
200
201// write replaces the file atomically: readers never see half a state.
202func write(path string, st State) error {
203	data, err := json.Marshal(st)
204	if err != nil {
205		return err
206	}
207	f, err := os.CreateTemp(filepath.Dir(path), ".state-*.json")
208	if err != nil {
209		return err
210	}
211	_, werr := f.Write(append(data, '\n'))
212	cerr := f.Close()
213	if err := errors.Join(werr, cerr); err != nil {
214		os.Remove(f.Name())
215		return err
216	}
217	return os.Rename(f.Name(), path)
218}
219
220// Read loads the state file.
221func Read(path string) (State, error) {
222	var st State
223	data, err := os.ReadFile(path)
224	if err != nil {
225		return st, err
226	}
227	return st, json.Unmarshal(data, &st)
228}
229
230// procTree finds the parent of a process.
231type procTree interface {
232	Parent(pid int) (int, bool)
233}
234
235// linuxProcTree reads /proc/<pid>/stat.
236type linuxProcTree struct{}
237
238func (linuxProcTree) Parent(pid int) (int, bool) {
239	data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
240	if err != nil {
241		return 0, false
242	}
243	// "pid (comm) state ppid ...": comm may hold spaces and parentheses.
244	i := strings.LastIndexByte(string(data), ')')
245	if i < 0 {
246		return 0, false
247	}
248	f := strings.Fields(string(data[i+1:]))
249	if len(f) < 2 {
250		return 0, false
251	}
252	ppid, err := strconv.Atoi(f[1])
253	return ppid, err == nil && ppid > 0
254}
255
256// ErrNoWindow means no window belongs to the process or its ancestors.
257var ErrNoWindow = errors.New("no window for this process")
258
259// OutputOf finds the output showing a process: the window of the process
260// itself or of its nearest ancestor, e.g. the terminal of a shell. Only
261// application windows count: bars (layer surfaces) have no single output.
262// A process with windows on several outputs resolves to its focused window,
263// else a visible one. PIDs are those of the compositor's PID namespace.
264func OutputOf(st State, pid int) (Output, error) {
265	return outputOf(st, pid, linuxProcTree{})
266}
267
268func outputOf(st State, pid int, tree procTree) (Output, error) {
269	byPID := map[int]Window{}
270	for _, w := range st.Windows {
271		if w.PID <= 0 {
272			continue
273		}
274		cur, seen := byPID[w.PID]
275		if !seen || better(st, w, cur) {
276			byPID[w.PID] = w
277		}
278	}
279	// A depth limit guards against a cycle from a reused PID mid-walk.
280	for range 64 {
281		if w, ok := byPID[pid]; ok {
282			for _, o := range st.Outputs {
283				if o.Name == w.Output {
284					return o, nil
285				}
286			}
287			return Output{}, fmt.Errorf("output %q not in state", w.Output)
288		}
289		parent, ok := tree.Parent(pid)
290		if !ok || parent == pid {
291			break
292		}
293		pid = parent
294	}
295	return Output{}, ErrNoWindow
296}
297
298// better prefers the focused window, then a visible one.
299func better(st State, a, b Window) bool {
300	focused := func(w Window) bool { return st.Window != nil && st.Window.ID == w.ID }
301	if focused(a) != focused(b) {
302		return focused(a)
303	}
304	return a.Visible && !b.Visible
305}

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 26

Tags 10

↑↓ moveenter openesc close