workmap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workmap/__init__.py +30 -0
- workmap/__main__.py +15 -0
- workmap/actions.py +350 -0
- workmap/audit.py +153 -0
- workmap/cli.py +789 -0
- workmap/config.py +589 -0
- workmap/demo.py +94 -0
- workmap/drivers/__init__.py +93 -0
- workmap/drivers/apple_terminal.py +578 -0
- workmap/layout.py +64 -0
- workmap/model.py +719 -0
- workmap/multiplexer.py +201 -0
- workmap/procs.py +504 -0
- workmap/scan.py +413 -0
- workmap/setup.py +400 -0
- workmap/shell.py +117 -0
- workmap/terminal.py +50 -0
- workmap/themes.py +45 -0
- workmap/tui/__init__.py +6 -0
- workmap/tui/app.py +1090 -0
- workmap/tui/onboarding.py +266 -0
- workmap/tui/text.py +156 -0
- workmap/tui/widgets.py +189 -0
- workmap-0.1.0.dist-info/METADATA +258 -0
- workmap-0.1.0.dist-info/RECORD +28 -0
- workmap-0.1.0.dist-info/WHEEL +4 -0
- workmap-0.1.0.dist-info/entry_points.txt +2 -0
- workmap-0.1.0.dist-info/licenses/LICENSE +21 -0
workmap/multiplexer.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""What a terminal multiplexer is holding, and the only place that runs `tmux`.
|
|
2
|
+
|
|
3
|
+
`terminal.py` is the emulator you are sitting in front of. This is the one
|
|
4
|
+
running inside it. `drivers/` is the only place that speaks AppleScript; this
|
|
5
|
+
is the only place that speaks tmux, and the parallel is deliberate.
|
|
6
|
+
|
|
7
|
+
It is one module rather than a package with a contract, because it only ever
|
|
8
|
+
*reads*. workmap will never retitle a pane, move one or close one, so there is
|
|
9
|
+
nothing here shaped like the driver contract. It becomes a package the day two
|
|
10
|
+
modules would have to answer the same question two different ways. That is not
|
|
11
|
+
a claim about how many multiplexers exist: it is that a multiplexer nobody has
|
|
12
|
+
written code for is protected by construction, because anything that cannot be
|
|
13
|
+
asked ends up in `unasked` and keeps everything under it. A missing
|
|
14
|
+
implementation degrades to the old behaviour rather than to danger, which is
|
|
15
|
+
what makes one module enough.
|
|
16
|
+
|
|
17
|
+
The question is: **which live panes exist, and which process does each hold.**
|
|
18
|
+
|
|
19
|
+
A pane matters because it is somewhere a person can still get back to. That is
|
|
20
|
+
what makes it different from a closed tab, and it is why an unattached pane
|
|
21
|
+
counts: surviving the terminal is the whole point of a multiplexer, and `tmux
|
|
22
|
+
attach` reaches a detached session, which is exactly what an orphan cannot
|
|
23
|
+
offer.
|
|
24
|
+
|
|
25
|
+
Finding the servers and finding their sockets are two problems, and only the
|
|
26
|
+
first is easy. The process table already names every running multiplexer, so
|
|
27
|
+
there is nothing to search for. For the sockets, the obvious answer, listing
|
|
28
|
+
tmux's socket directory, is the wrong one: it misses `tmux -S /some/path`
|
|
29
|
+
entirely, it misses a `$TMUX_TMPDIR` set in somebody else's environment, and
|
|
30
|
+
it finds sockets of servers that have died, which are then asked and answer
|
|
31
|
+
nothing. Ask the running servers instead. One `lsof` over the pids already in
|
|
32
|
+
hand returns each one's socket path, wherever it lives. Measured: a `-S` path
|
|
33
|
+
comes back the same way. It can still name a socket that has been unlinked,
|
|
34
|
+
because a server that unlinks its own socket is still holding it open, and
|
|
35
|
+
that costs one query that fails into the protective answer rather than a whole
|
|
36
|
+
directory of them.
|
|
37
|
+
|
|
38
|
+
The obvious cheaper rule, "a process with a controlling terminal is one
|
|
39
|
+
somebody can get back to", was measured and is false. A child of a pty whose
|
|
40
|
+
master has closed keeps reporting that tty in `ps`, indefinitely: with the
|
|
41
|
+
master open it read ttys010, and after closing the master, and again a second
|
|
42
|
+
later, it still read ttys010. A closed Terminal tab and a closed tmux pane are
|
|
43
|
+
exactly that shape, so a tty proves nothing about whether anybody can reach a
|
|
44
|
+
process, and a tool that believed otherwise would go blind to the very
|
|
45
|
+
orphans it exists to find. tmux naming a pane is evidence. A tty is not.
|
|
46
|
+
|
|
47
|
+
Measured on this machine: `live_panes()` over ~1000 processes takes 1.8ms and
|
|
48
|
+
starts no subprocess when no multiplexer is running, and 28.6ms when one is
|
|
49
|
+
(one `lsof` at ~19ms, one `tmux` query at ~6ms), against a scan of about a
|
|
50
|
+
second.
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
from dataclasses import dataclass, field
|
|
55
|
+
|
|
56
|
+
from .config import addressable
|
|
57
|
+
from .model import multiplexer_name
|
|
58
|
+
from .procs import run_partial, unix_socket_paths
|
|
59
|
+
|
|
60
|
+
# Only tmux can say which processes its panes hold. Measured against the
|
|
61
|
+
# screen this machine ships (4.00.03): `screen -ls` names sessions and says
|
|
62
|
+
# attached or detached and stops there; `screen -Q`, which could have
|
|
63
|
+
# answered, arrived in 4.5; and screen 4 talks over FIFOs rather than unix
|
|
64
|
+
# sockets, so there is not even a socket to find. zellij, dtach, abduco and
|
|
65
|
+
# byobu are not measured at all. All five degrade the same way, to being asked
|
|
66
|
+
# nothing, which leaves their whole server holding everything under it. That
|
|
67
|
+
# is the conservative answer, and for screen it is the only one available.
|
|
68
|
+
CAN_BE_ASKED = frozenset({"tmux"})
|
|
69
|
+
|
|
70
|
+
# pane_pid, pane_dead and session_attached are fixed-width fields and go
|
|
71
|
+
# first; the name goes last, because a session name is text somebody chose and
|
|
72
|
+
# may contain spaces, and this is read with a bounded split. Same reason `ps`
|
|
73
|
+
# is asked for `lstart` in front of the command in procs.process_snapshot().
|
|
74
|
+
_PANE_FORMAT = ("#{pane_pid} #{pane_dead} #{session_attached} "
|
|
75
|
+
"#{session_name}:#{window_index}.#{pane_index}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Pane:
|
|
80
|
+
pid: int
|
|
81
|
+
where: str # "tmux work:1.0", for the sentence a person reads
|
|
82
|
+
attached: bool
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Panes:
|
|
87
|
+
"""What the multiplexers on this machine are holding.
|
|
88
|
+
|
|
89
|
+
Three answers rather than two, because a server that answered and a server
|
|
90
|
+
that could not be asked hold different amounts:
|
|
91
|
+
|
|
92
|
+
live each pane holds its own subtree
|
|
93
|
+
asked these servers hold only what they are showing
|
|
94
|
+
unasked these servers hold everything under them, as they always did
|
|
95
|
+
"""
|
|
96
|
+
live: list = field(default_factory=list)
|
|
97
|
+
asked: list = field(default_factory=list)
|
|
98
|
+
unasked: list = field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def live_panes(rows: list[dict]) -> Panes:
|
|
102
|
+
"""Every pane a running multiplexer holds, and how much each server keeps.
|
|
103
|
+
|
|
104
|
+
`unasked` is the important part. A multiplexer that could not be
|
|
105
|
+
questioned is not the same as one holding nothing, and it has to be
|
|
106
|
+
treated as holding everything under it. Every way of failing to ask ends
|
|
107
|
+
up there and none of them has to be recognised separately: the multiplexer
|
|
108
|
+
is not tmux, `lsof` could not read the process, `tmux` is not on PATH, the
|
|
109
|
+
server went away between the process table and the query, the socket path
|
|
110
|
+
was too long for the kernel, the query timed out, or the answer did not
|
|
111
|
+
parse. The set is built by starting with every multiplexer and taking away
|
|
112
|
+
only the ones that named at least one live pane, so a way of failing that
|
|
113
|
+
nobody thought of still lands in it.
|
|
114
|
+
"""
|
|
115
|
+
mux = [(r["pid"], multiplexer_name(r["cmd"])) for r in rows
|
|
116
|
+
if multiplexer_name(r["cmd"])]
|
|
117
|
+
if not mux:
|
|
118
|
+
# The reason this reads the process table first: on a machine with no
|
|
119
|
+
# multiplexer running, a scan does not gain a single subprocess.
|
|
120
|
+
# Checked by a test that fails if anything is run here.
|
|
121
|
+
return Panes()
|
|
122
|
+
|
|
123
|
+
askable = [pid for pid, name in mux if name in CAN_BE_ASKED]
|
|
124
|
+
unasked = {pid for pid, name in mux if name not in CAN_BE_ASKED}
|
|
125
|
+
|
|
126
|
+
sockets = unix_socket_paths(askable)
|
|
127
|
+
live: list = []
|
|
128
|
+
answered: set = set()
|
|
129
|
+
for path in sorted({p for paths in sockets.values() for p in paths}):
|
|
130
|
+
found = _panes_on(path)
|
|
131
|
+
if found:
|
|
132
|
+
answered.add(path)
|
|
133
|
+
live.extend(found)
|
|
134
|
+
|
|
135
|
+
asked = []
|
|
136
|
+
for pid in askable:
|
|
137
|
+
# A server counts as asked only if one of its own sockets named a live
|
|
138
|
+
# pane. A tmux *client* holds a connection rather than a listening
|
|
139
|
+
# socket, so it usually names nothing and lands in `unasked`, which
|
|
140
|
+
# costs nothing: a client's subtree is the connection and no more, and
|
|
141
|
+
# the terminal it is sitting in already holds it.
|
|
142
|
+
if any(path in answered for path in sockets.get(pid, ())):
|
|
143
|
+
asked.append(pid)
|
|
144
|
+
else:
|
|
145
|
+
unasked.add(pid)
|
|
146
|
+
return Panes(live=live, asked=asked, unasked=sorted(unasked))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _panes_on(socket_path: str) -> list:
|
|
150
|
+
"""The live panes on one socket. Empty means the server could not be asked.
|
|
151
|
+
|
|
152
|
+
A live tmux server always has at least one pane: it exits when its last
|
|
153
|
+
one closes. So an empty answer is never a server holding nothing, it is a
|
|
154
|
+
query that failed, and confusing the two would drop the hold on every
|
|
155
|
+
process in every pane of a live server. `tmux` prints "no server running"
|
|
156
|
+
and "error connecting" to stderr and exits non-zero with nothing on
|
|
157
|
+
stdout, so there is no separate error to read: the emptiness is the
|
|
158
|
+
signal, and reading it that way covers every other way of coming back with
|
|
159
|
+
nothing as well.
|
|
160
|
+
"""
|
|
161
|
+
raw = run_partial(
|
|
162
|
+
["tmux", "-S", socket_path, "list-panes", "-a", "-F", _PANE_FORMAT],
|
|
163
|
+
timeout=3.0,
|
|
164
|
+
)
|
|
165
|
+
return parse_panes(raw)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_panes(raw: str) -> list:
|
|
169
|
+
"""tmux's answer, read back.
|
|
170
|
+
|
|
171
|
+
Kept apart from running tmux so it can be tested against output a real
|
|
172
|
+
tmux produced rather than output somebody invented.
|
|
173
|
+
"""
|
|
174
|
+
out: list = []
|
|
175
|
+
for line in raw.splitlines():
|
|
176
|
+
parts = line.split(None, 3)
|
|
177
|
+
if len(parts) < 4 or not parts[0].isdigit():
|
|
178
|
+
continue
|
|
179
|
+
# A dead pane is one tmux is still showing after its process exited,
|
|
180
|
+
# which `remain-on-exit` leaves behind. Its pane_pid names a process
|
|
181
|
+
# that is gone, and macOS hands pids back out, so treating it as live
|
|
182
|
+
# would sooner or later hold an unrelated process that inherited the
|
|
183
|
+
# number.
|
|
184
|
+
if parts[1] != "0":
|
|
185
|
+
continue
|
|
186
|
+
out.append(Pane(pid=int(parts[0]),
|
|
187
|
+
where=f"tmux {_plain(parts[3])}",
|
|
188
|
+
attached=parts[2] != "0"))
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _plain(name: str) -> str:
|
|
193
|
+
"""A session name with nothing in it that can move a cursor.
|
|
194
|
+
|
|
195
|
+
This ends up beside a confirmation prompt somebody is about to answer, and
|
|
196
|
+
a session name is text they typed, so it can carry an escape sequence
|
|
197
|
+
exactly as a profile name or a project name can. Cleaned at this boundary
|
|
198
|
+
rather than at each place it is printed, by the same rule that decides
|
|
199
|
+
whether a project name is safe to put on a terminal.
|
|
200
|
+
"""
|
|
201
|
+
return name if addressable(name) else "a session"
|