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

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