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/tui/app.py
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
"""The Desk: screen state, key handling, and frame assembly."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import select
|
|
6
|
+
import sys
|
|
7
|
+
import termios
|
|
8
|
+
import tty
|
|
9
|
+
|
|
10
|
+
from .. import actions, procs, scan, terminal
|
|
11
|
+
from ..themes import PROFILE_ANSI
|
|
12
|
+
from ..model import Project, Session, fmt_mb, fmt_mem, held_note, plural
|
|
13
|
+
from ..config import SESSION_VISIBLE, roots_advice
|
|
14
|
+
from ..model import clean_label
|
|
15
|
+
from .text import (ALT_OFF, ALT_ON, AMBER, BOLD, BORDER, CLEAR_LINE, DIM, GOOD,
|
|
16
|
+
HIDE, HOME, RESET, REV, SHOW, TAG, cell, disp_width, fg,
|
|
17
|
+
pad_ansi, strip_ansi, visible_len, wrap_plain)
|
|
18
|
+
from .widgets import (CHIP_W, META_GAP, PROJ_W, RAIL_OFF, RAIL_ON, RAIL_W,
|
|
19
|
+
RAM_W, TABLE_MIN, THEME_W, box_bot, box_row, box_top,
|
|
20
|
+
boxed, chip, cmd_cell, fit_strip, flat_panel,
|
|
21
|
+
frame_width, inner_width, longest_label, panel,
|
|
22
|
+
project_row, session_row, side_by_side)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
UNNAMED = "unsorted" # projects with no root of their own
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def term_size() -> tuple[int, int]:
|
|
29
|
+
try:
|
|
30
|
+
sz = os.get_terminal_size()
|
|
31
|
+
return max(40, sz.columns), max(12, sz.lines)
|
|
32
|
+
except Exception:
|
|
33
|
+
return 80, 24
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Desk:
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.projects: list[Project] = []
|
|
39
|
+
self.mem: dict = {}
|
|
40
|
+
self.tracked = 0
|
|
41
|
+
self.bg_all = 0 # orphan count from the last refresh (render must not rescan)
|
|
42
|
+
self.profiles: list[str] = []
|
|
43
|
+
self.cursor = 0 # index into projects, or -1 if nothing selected
|
|
44
|
+
self.scroll = 0 # first body line index into full body buffer
|
|
45
|
+
self.expanded: set[str] = set()
|
|
46
|
+
self.mode = "list" # list | color
|
|
47
|
+
self.color_cursor = 0
|
|
48
|
+
self.message = ""
|
|
49
|
+
self.pending: str | None = None # confirm: kill_bg | stop | close_wins
|
|
50
|
+
self._armed_sessions: list[Session] = [] # exactly what `y` will kill
|
|
51
|
+
self.blocked = "" # why Terminal couldn't be read, if it couldn't
|
|
52
|
+
self.blocked_fix = "" # and what the reader can do about that one
|
|
53
|
+
self.advice = "" # why nothing can be an orphan, if nothing can
|
|
54
|
+
self.held = "" # why something running is not on the desk
|
|
55
|
+
self._fd = sys.stdin.fileno()
|
|
56
|
+
self._old = None
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------ data
|
|
59
|
+
def refresh_data(self) -> None:
|
|
60
|
+
"""Full desk scan. Call on start / r / after mutating actions, never on ↑↓."""
|
|
61
|
+
keep = self.current().name if self.current() else None
|
|
62
|
+
cleared = self.cursor < 0
|
|
63
|
+
# One build_projects() pass (snapshot() would scan again for dict export).
|
|
64
|
+
held: list = []
|
|
65
|
+
self.projects = [p for p in scan.build_projects(held=held)
|
|
66
|
+
if p.sessions]
|
|
67
|
+
# Immediately: every driver call writes the same status global, and
|
|
68
|
+
# list_profiles() below is cheaper than the desk query and can succeed
|
|
69
|
+
# where it failed. Reading afterwards erased the failure this field
|
|
70
|
+
# exists to report.
|
|
71
|
+
status = terminal.terminal_status()
|
|
72
|
+
self.mem = procs.mem_summary()
|
|
73
|
+
self.tracked = sum(p.mb for p in self.projects)
|
|
74
|
+
self.bg_all = sum(
|
|
75
|
+
1 for p in self.projects for s in p.sessions if s.kind == "bg"
|
|
76
|
+
)
|
|
77
|
+
self.profiles = [p["name"] for p in terminal.list_profiles()]
|
|
78
|
+
# An empty desk and a wedged Terminal look identical otherwise. So do
|
|
79
|
+
# an empty desk and a desk with no roots configured, where nothing can
|
|
80
|
+
# ever be an orphan.
|
|
81
|
+
self.blocked = "" if status["ok"] else status["reason"]
|
|
82
|
+
self.blocked_fix = "" if status["ok"] else status.get("fix", "")
|
|
83
|
+
self.advice = terminal.unsupported_host() or roots_advice()
|
|
84
|
+
# Why the desk is shorter than the machine, if it is.
|
|
85
|
+
self.held = held_note(held)
|
|
86
|
+
if cleared:
|
|
87
|
+
self.cursor = -1
|
|
88
|
+
elif keep is not None:
|
|
89
|
+
for i, p in enumerate(self.projects):
|
|
90
|
+
if p.name == keep:
|
|
91
|
+
self.cursor = i
|
|
92
|
+
break
|
|
93
|
+
else:
|
|
94
|
+
self.cursor = 0 if self.projects else -1
|
|
95
|
+
elif not self.projects:
|
|
96
|
+
self.cursor = -1
|
|
97
|
+
elif self.cursor >= len(self.projects):
|
|
98
|
+
self.cursor = len(self.projects) - 1
|
|
99
|
+
elif self.cursor < 0:
|
|
100
|
+
self.cursor = 0
|
|
101
|
+
|
|
102
|
+
def current(self) -> Project | None:
|
|
103
|
+
if self.cursor < 0 or not self.projects:
|
|
104
|
+
return None
|
|
105
|
+
if self.cursor >= len(self.projects):
|
|
106
|
+
return None
|
|
107
|
+
return self.projects[self.cursor]
|
|
108
|
+
|
|
109
|
+
def rail(self, profile: str) -> int:
|
|
110
|
+
return PROFILE_ANSI.get(profile, 250)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def label_of(p: Project) -> str:
|
|
114
|
+
return p.name or UNNAMED
|
|
115
|
+
|
|
116
|
+
# --------------------------------------------------------------- commands
|
|
117
|
+
def always_items(self) -> list[tuple[str, str, str]]:
|
|
118
|
+
items = [
|
|
119
|
+
("t", "rename all windows", "rename windows"),
|
|
120
|
+
("O", "organize all", "organize all"),
|
|
121
|
+
("r", "refresh", "refresh"),
|
|
122
|
+
("q", "quit workmap", "quit"),
|
|
123
|
+
]
|
|
124
|
+
if self.current() is None:
|
|
125
|
+
# Nothing selected: `k` means every project, and there is no
|
|
126
|
+
# selection to clear, so don't offer a key that does nothing.
|
|
127
|
+
count = f" {DIM}·{self.bg_all}{RESET}" if self.bg_all else ""
|
|
128
|
+
items.insert(1, ("k", "quit all orphans" + count,
|
|
129
|
+
"quit orphans" + count))
|
|
130
|
+
else:
|
|
131
|
+
items.insert(3, ("Esc", "clear selection", "clear pick"))
|
|
132
|
+
return items
|
|
133
|
+
|
|
134
|
+
def project_items(self, p: Project) -> list[tuple[str, str, str]]:
|
|
135
|
+
bg_n = sum(1 for s in p.sessions if s.kind == "bg")
|
|
136
|
+
tag = f" {DIM}·{bg_n}{RESET}" if bg_n else ""
|
|
137
|
+
expanded = p.name in self.expanded
|
|
138
|
+
return [
|
|
139
|
+
("f", "bring to front", "bring to front"),
|
|
140
|
+
("o", "organize windows", "organize"),
|
|
141
|
+
("c", "change theme", "change theme"),
|
|
142
|
+
("x", "collapse sessions" if expanded else "expand sessions",
|
|
143
|
+
"collapse" if expanded else "expand"),
|
|
144
|
+
("k", "quit orphans" + tag, "quit orphans" + tag),
|
|
145
|
+
("s", "stop servers + orphans", "stop servers"),
|
|
146
|
+
("w", "close windows", "close windows"),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
def _command_block(self, cols: int) -> list[str]:
|
|
150
|
+
"""Always / For <project> as two distinct panels, stacked or side by side."""
|
|
151
|
+
frame = frame_width(cols)
|
|
152
|
+
inner = frame - 4
|
|
153
|
+
p = self.current()
|
|
154
|
+
always = self.always_items()
|
|
155
|
+
title_a = f"{DIM}Always{RESET}"
|
|
156
|
+
title_p = ""
|
|
157
|
+
if p is not None:
|
|
158
|
+
title_p = (
|
|
159
|
+
f"{DIM}For{RESET} {fg(self.rail(p.profile))}{BOLD}"
|
|
160
|
+
f"{self.label_of(p)}{RESET}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if not boxed(cols):
|
|
164
|
+
w = inner_width(cols)
|
|
165
|
+
out = flat_panel(w, title_a, always, short=True)
|
|
166
|
+
if p is not None:
|
|
167
|
+
out += [""] + flat_panel(w, title_p, self.project_items(p),
|
|
168
|
+
short=True)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
if p is None:
|
|
172
|
+
block = panel(frame, title_a, always,
|
|
173
|
+
columns=2 if inner >= 2 * (CHIP_W + 16) + 2 else 1)
|
|
174
|
+
if not self.projects:
|
|
175
|
+
return block
|
|
176
|
+
hint = "↑↓ pick a project for its own commands"
|
|
177
|
+
return block + [f" {DIM}{cell(hint, max(0, cols - 2))}{RESET}"]
|
|
178
|
+
|
|
179
|
+
proj = self.project_items(p)
|
|
180
|
+
for short in (False, True):
|
|
181
|
+
need_a = 4 + CHIP_W + longest_label(always, short)
|
|
182
|
+
need_p = 4 + CHIP_W + longest_label(proj, short)
|
|
183
|
+
if need_a + 1 + need_p <= frame:
|
|
184
|
+
slack = frame - 1 - need_a - need_p
|
|
185
|
+
lw = need_a + slack // 2
|
|
186
|
+
rw = frame - 1 - lw
|
|
187
|
+
tall = max(len(always), len(proj))
|
|
188
|
+
return side_by_side(
|
|
189
|
+
panel(lw, title_a, always, short=short, min_rows=tall), lw,
|
|
190
|
+
panel(rw, title_p, proj, short=short, min_rows=tall), rw,
|
|
191
|
+
)
|
|
192
|
+
cols_n = 2 if inner >= 2 * (CHIP_W + longest_label(proj, True)) + 2 else 1
|
|
193
|
+
return (
|
|
194
|
+
panel(frame, title_a, always, columns=cols_n, short=True)
|
|
195
|
+
+ panel(frame, title_p, proj, columns=cols_n, short=True)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Both keys that answer, named. `_handle_list` takes Return for yes and
|
|
199
|
+
# Esc for cancel as well as the letters, and the screen used to offer
|
|
200
|
+
# only the letters: a keystroke that sends SIGTERM was on no screen
|
|
201
|
+
# anywhere. Setup has had the rule that a prompt names what Enter does
|
|
202
|
+
# since its first version; this is the prompt where it matters most.
|
|
203
|
+
CONFIRM_KEYS = (("y", "yes or Enter"), ("n", "cancel or Esc"))
|
|
204
|
+
|
|
205
|
+
def _confirm_block(self, cols: int) -> list[str]:
|
|
206
|
+
frame = frame_width(cols)
|
|
207
|
+
inner = frame - 4
|
|
208
|
+
if not boxed(cols):
|
|
209
|
+
w = inner_width(cols)
|
|
210
|
+
return (
|
|
211
|
+
[f" {AMBER}{BOLD}Confirm{RESET}"]
|
|
212
|
+
+ [f" {ln}" for ln in wrap_plain(self.message, w - 1)]
|
|
213
|
+
+ [" " + fit_strip(self.CONFIRM_KEYS, w - 1)]
|
|
214
|
+
)
|
|
215
|
+
lines = [box_top(inner, title=f"{AMBER}{BOLD}Confirm{RESET}")]
|
|
216
|
+
for line in wrap_plain(self.message, inner):
|
|
217
|
+
lines.append(box_row(inner, line))
|
|
218
|
+
for line in self._armed_preview(inner):
|
|
219
|
+
lines.append(box_row(inner, line))
|
|
220
|
+
lines.append(box_row(inner, ""))
|
|
221
|
+
keys = "".join(cmd_cell(k, label, CHIP_W + 13)
|
|
222
|
+
for k, label in self.CONFIRM_KEYS)
|
|
223
|
+
lines.append(box_row(inner, keys))
|
|
224
|
+
lines.append(box_bot(inner))
|
|
225
|
+
return lines
|
|
226
|
+
|
|
227
|
+
# How many armed processes to name before summarising the rest.
|
|
228
|
+
PREVIEW_ROWS = 5
|
|
229
|
+
|
|
230
|
+
def _armed_preview(self, inner: int) -> list[str]:
|
|
231
|
+
"""Name the processes `y` will signal.
|
|
232
|
+
|
|
233
|
+
A count and a size are not consent. The whole class of bug this tool
|
|
234
|
+
shipped with was killing the wrong thing while reporting a plausible
|
|
235
|
+
number, and a number is exactly what the user could not check.
|
|
236
|
+
"""
|
|
237
|
+
rows: list[tuple[int, str]] = []
|
|
238
|
+
for s in self._armed_sessions:
|
|
239
|
+
for pid in s.pids:
|
|
240
|
+
rows.append((pid, s.cmds.get(pid) or s.label))
|
|
241
|
+
if not rows:
|
|
242
|
+
return []
|
|
243
|
+
# What the word means, here, where it is being acted on. The desk
|
|
244
|
+
# carries a legend saying it, and `_footer` returns this block
|
|
245
|
+
# instead of the legend, so the definition was on screen for as long
|
|
246
|
+
# as the reader was browsing and gone the moment they were asked.
|
|
247
|
+
# Same sentence `workmap kill -n` already prints above the same list.
|
|
248
|
+
why = "These have no Terminal window left to close:"
|
|
249
|
+
out = ["", f"{DIM}{cell(why, inner)}{RESET}"]
|
|
250
|
+
for pid, cmd in rows[: self.PREVIEW_ROWS]:
|
|
251
|
+
out.append(f"{DIM}{cell(f'{pid:>7} {cmd}', inner)}{RESET}")
|
|
252
|
+
hidden = len(rows) - self.PREVIEW_ROWS
|
|
253
|
+
if hidden > 0:
|
|
254
|
+
# What the number counts, and how to reach the rest from here.
|
|
255
|
+
# It used to name `workmap kill -n` alone, which cannot be run
|
|
256
|
+
# from inside a full-screen program: a reader told there is more
|
|
257
|
+
# they cannot see, by a prompt they cannot leave, answers "no".
|
|
258
|
+
# The footer cannot scroll, so saying how to get out is the
|
|
259
|
+
# honest way to finish it.
|
|
260
|
+
more = f"+{hidden} more; press n and run `workmap kill -n`"
|
|
261
|
+
out.append(f"{DIM}{cell(more, inner)}{RESET}")
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
def _compact_block(self, cols: int) -> list[str]:
|
|
265
|
+
"""Short terminals: one command strip so the list keeps the screen."""
|
|
266
|
+
p = self.current()
|
|
267
|
+
if self.pending:
|
|
268
|
+
bits = list(self.CONFIRM_KEYS)
|
|
269
|
+
elif p is None:
|
|
270
|
+
bits = [("k", "quit all orphans"), ("t", "rename tabs"),
|
|
271
|
+
("r", "refresh"), ("q", "quit")]
|
|
272
|
+
else:
|
|
273
|
+
bits = [("f", "front"), ("k", "orphans"), ("c", "theme"),
|
|
274
|
+
("r", "refresh"), ("q", "quit")]
|
|
275
|
+
strip = fit_strip(bits, cols - 2)
|
|
276
|
+
head = (
|
|
277
|
+
f"{AMBER}{cell(self.message, cols - 2)}{RESET}" if self.pending else ""
|
|
278
|
+
)
|
|
279
|
+
return ([f" {head}"] if head else []) + [f" {strip}"]
|
|
280
|
+
|
|
281
|
+
# ----------------------------------------------------------------- render
|
|
282
|
+
def _headline(self) -> tuple[str, str]:
|
|
283
|
+
"""The two figures the header exists to show, wherever it is drawn.
|
|
284
|
+
|
|
285
|
+
One function for both widths. The wide header was rewritten to drop
|
|
286
|
+
"tracked" and "free", and the narrow one was not, so a terminal under
|
|
287
|
+
46 columns still said "workmap · 2.1G tracked · free 214M" long after
|
|
288
|
+
the reasons for removing both were written down next door.
|
|
289
|
+
|
|
290
|
+
The second figure is empty when Terminal could not be read: an empty
|
|
291
|
+
desk and an unreadable one look identical from here, and "nothing
|
|
292
|
+
orphaned" is a claim about a machine nobody managed to look at.
|
|
293
|
+
"""
|
|
294
|
+
held = f"{BOLD}{fmt_mb(self.tracked)}{RESET} {DIM}in use{RESET}"
|
|
295
|
+
if self.blocked:
|
|
296
|
+
return held, ""
|
|
297
|
+
orphan_mb = sum(s.mb for p in self.projects
|
|
298
|
+
for s in p.sessions if s.kind == "bg")
|
|
299
|
+
# Amber only when there is something to reclaim, so the eye goes to it
|
|
300
|
+
# exactly when pressing `k` would do something.
|
|
301
|
+
freeable = (f"{AMBER}{fmt_mb(orphan_mb)} orphaned{RESET}" if orphan_mb
|
|
302
|
+
else f"{DIM}nothing orphaned{RESET}")
|
|
303
|
+
return held, freeable
|
|
304
|
+
|
|
305
|
+
def _top_bar(self, inner: int) -> str:
|
|
306
|
+
"""Two numbers worth having, in words that mean something.
|
|
307
|
+
|
|
308
|
+
This used to read "2.1G tracked free 214M swap 6.5G", which is
|
|
309
|
+
three figures a reader cannot act on. "tracked" is an internal word for
|
|
310
|
+
the sum of what happens to be on screen. Worse, macOS keeps free
|
|
311
|
+
memory deliberately low, so "free 214M" looks alarming on a machine
|
|
312
|
+
that is perfectly happy, and reading it as a problem is the wrong
|
|
313
|
+
lesson to teach on the first screen.
|
|
314
|
+
|
|
315
|
+
What is worth knowing is how much the sessions are holding, and how
|
|
316
|
+
much of that is already dead and can be had back. The second number is
|
|
317
|
+
the one the whole tool exists to produce.
|
|
318
|
+
"""
|
|
319
|
+
held, freeable = self._headline()
|
|
320
|
+
swap_raw = self.mem.get("swap_mb", "?")
|
|
321
|
+
try:
|
|
322
|
+
swap_warn = float(swap_raw) > 3000
|
|
323
|
+
except (TypeError, ValueError):
|
|
324
|
+
swap_warn = False
|
|
325
|
+
swap_bit = f"{AMBER if swap_warn else DIM}swap {fmt_mem(swap_raw)}{RESET}"
|
|
326
|
+
title = f"{BOLD}workmap{RESET}"
|
|
327
|
+
stats = [
|
|
328
|
+
f"{held} {freeable} {swap_bit}" if freeable else
|
|
329
|
+
f"{held} {swap_bit}",
|
|
330
|
+
f"{held} {freeable}" if freeable else f"{held}",
|
|
331
|
+
f"{held}",
|
|
332
|
+
]
|
|
333
|
+
for tail in stats:
|
|
334
|
+
if 4 + visible_len(title) + visible_len(tail) + 4 <= inner + 2:
|
|
335
|
+
return box_top(inner, title=title, tail=tail)
|
|
336
|
+
return box_top(inner, title=title)
|
|
337
|
+
|
|
338
|
+
def _column_head(self, inner: int) -> str:
|
|
339
|
+
gutter = max(META_GAP, inner - TABLE_MIN + META_GAP)
|
|
340
|
+
return (
|
|
341
|
+
" " * RAIL_W
|
|
342
|
+
+ DIM
|
|
343
|
+
+ cell("PROJECT", PROJ_W)
|
|
344
|
+
+ " "
|
|
345
|
+
+ cell("THEME", THEME_W)
|
|
346
|
+
+ " " * gutter
|
|
347
|
+
+ cell("RAM", RAM_W, align="right")
|
|
348
|
+
+ RESET
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def _header_lines(self, cols: int) -> list[str]:
|
|
352
|
+
inner = inner_width(cols)
|
|
353
|
+
p = self.current()
|
|
354
|
+
if not boxed(cols):
|
|
355
|
+
if self.mode == "color":
|
|
356
|
+
who = self.label_of(p) if p else ""
|
|
357
|
+
return [f"{BOLD}Theme{RESET} {DIM}for{RESET} {BOLD}{who}{RESET}",
|
|
358
|
+
DIM + "─" * inner + RESET]
|
|
359
|
+
held, freeable = self._headline()
|
|
360
|
+
head = f"{BOLD}workmap{RESET} {held}"
|
|
361
|
+
if freeable and visible_len(head) + visible_len(freeable) + 3 <= inner:
|
|
362
|
+
head = f"{head} {freeable}"
|
|
363
|
+
if not self.projects:
|
|
364
|
+
return [head, DIM + "─" * inner + RESET]
|
|
365
|
+
return [head, self._column_head(inner)]
|
|
366
|
+
if self.mode == "color":
|
|
367
|
+
who = self.label_of(p) if p else ""
|
|
368
|
+
return [
|
|
369
|
+
box_top(inner, title=f"{BOLD}Theme{RESET}",
|
|
370
|
+
tail=f"{DIM}for{RESET} {BOLD}{who}{RESET}" if who else ""),
|
|
371
|
+
box_row(inner, f"{DIM}↑↓ choose Enter apply Esc cancel{RESET}"),
|
|
372
|
+
]
|
|
373
|
+
if not self.projects: # no rows to head
|
|
374
|
+
return [self._top_bar(inner)]
|
|
375
|
+
return [self._top_bar(inner), box_row(inner, self._column_head(inner))]
|
|
376
|
+
|
|
377
|
+
def _project_block(self, p: Project, selected: bool,
|
|
378
|
+
inner: int) -> list[str]:
|
|
379
|
+
# The colour rail is the project's theme; it thickens on selection so
|
|
380
|
+
# the highlight reads without touching column positions.
|
|
381
|
+
c = self.rail(p.profile)
|
|
382
|
+
rail = fg(c) + (RAIL_ON if selected else RAIL_OFF) + RESET
|
|
383
|
+
name = self.label_of(p)
|
|
384
|
+
out = [
|
|
385
|
+
project_row(name, p.profile, fmt_mb(p.mb), inner,
|
|
386
|
+
rail=rail, selected=selected)
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
show_all = p.name in self.expanded
|
|
390
|
+
visible = p.sessions if show_all else p.sessions[: SESSION_VISIBLE]
|
|
391
|
+
hidden = 0 if show_all else max(0, len(p.sessions) - SESSION_VISIBLE)
|
|
392
|
+
|
|
393
|
+
for j, s in enumerate(visible):
|
|
394
|
+
last = j == len(visible) - 1 and hidden == 0
|
|
395
|
+
branch = "└─" if last else "├─"
|
|
396
|
+
# A window is the norm and goes unmarked; only flag the exception,
|
|
397
|
+
# in the same word the command uses: an "orphan" is what `k` quits.
|
|
398
|
+
where = "" if s.kind == "window" else "orphaned"
|
|
399
|
+
label = clean_label(s.label.replace("\n", " "))
|
|
400
|
+
out.append(session_row(rail, f" {branch} {label}", where,
|
|
401
|
+
fmt_mb(s.mb), inner))
|
|
402
|
+
if hidden:
|
|
403
|
+
out.append(session_row(rail, f" └─ +{hidden} more", "x to show",
|
|
404
|
+
"", inner))
|
|
405
|
+
return out
|
|
406
|
+
|
|
407
|
+
def _color_body(self, inner: int) -> list[str]:
|
|
408
|
+
p = self.current()
|
|
409
|
+
if not p:
|
|
410
|
+
return [f"{DIM}No project selected.{RESET}"]
|
|
411
|
+
names = self.profiles or list(PROFILE_ANSI.keys())
|
|
412
|
+
lines = []
|
|
413
|
+
for i, name in enumerate(names):
|
|
414
|
+
c = self.rail(name)
|
|
415
|
+
cur = i == self.color_cursor
|
|
416
|
+
check = "●" if name == p.profile else "○"
|
|
417
|
+
body = f"{fg(c)}{check}{RESET} " + cell(name, max(1, inner - 5))
|
|
418
|
+
lines.append(f" {REV} {RESET} {body}" if cur else f" {body}")
|
|
419
|
+
return lines
|
|
420
|
+
|
|
421
|
+
def _build_body(self, cols: int) -> tuple[list[str], list[tuple[int, int]]]:
|
|
422
|
+
"""The scrollable region, and where each project sits inside it.
|
|
423
|
+
|
|
424
|
+
The spans come back with the lines rather than being left on the
|
|
425
|
+
instance: scrolling needs to know where the selected project starts,
|
|
426
|
+
and reading that from state written by the *last* render is only
|
|
427
|
+
correct as long as nobody reorders draw(). Returning it removes the
|
|
428
|
+
rule instead of documenting it.
|
|
429
|
+
"""
|
|
430
|
+
inner = inner_width(cols)
|
|
431
|
+
if self.mode == "color":
|
|
432
|
+
return self._color_body(inner), []
|
|
433
|
+
if not self.projects:
|
|
434
|
+
if self.blocked:
|
|
435
|
+
# The follow-up belongs to the reason. Only one of the
|
|
436
|
+
# driver's failures is a permission problem, and these lines
|
|
437
|
+
# sent everyone to Automation whatever had gone wrong.
|
|
438
|
+
# Wrapped, not cut. This is the one screen whose whole job
|
|
439
|
+
# is an instruction, and the instruction is 65 columns while
|
|
440
|
+
# the panel is 62, so it arrived as "... > Automat..." at
|
|
441
|
+
# every terminal size. The word it lost was the name of the
|
|
442
|
+
# pane the reader has to go and find.
|
|
443
|
+
fix = getattr(self, "blocked_fix", "")
|
|
444
|
+
said = [f"{DIM}{ln}{RESET}"
|
|
445
|
+
for ln in wrap_plain(f"{self.blocked}.", inner)]
|
|
446
|
+
said += [f"{DIM}{ln}{RESET}"
|
|
447
|
+
for ln in wrap_plain(fix, inner)] if fix else []
|
|
448
|
+
return [
|
|
449
|
+
"",
|
|
450
|
+
f"{AMBER}{BOLD}Can't read Terminal{RESET}",
|
|
451
|
+
"",
|
|
452
|
+
] + said + [
|
|
453
|
+
f"{DIM}Press{RESET} {BOLD}r{RESET}{DIM} to try again.{RESET}",
|
|
454
|
+
"",
|
|
455
|
+
], []
|
|
456
|
+
msg = self.message or "Nothing open right now"
|
|
457
|
+
# Only ever name things this package ships. This used to say
|
|
458
|
+
# "Start one with `work <name>`", which was a shell function on the
|
|
459
|
+
# author's machine, not part of workmap and not installable with
|
|
460
|
+
# it. The empty desk is the first screen a new user sees, which
|
|
461
|
+
# makes it the worst possible place to point at something that
|
|
462
|
+
# does not exist for them.
|
|
463
|
+
return [
|
|
464
|
+
"",
|
|
465
|
+
f"{BOLD}{msg}{RESET}",
|
|
466
|
+
"",
|
|
467
|
+
f"{DIM}Open a Terminal window in a project directory,{RESET}",
|
|
468
|
+
f"{DIM}then press{RESET} {BOLD}r{RESET} {DIM}to remap the desk{RESET}",
|
|
469
|
+
"",
|
|
470
|
+
], []
|
|
471
|
+
body: list[str] = []
|
|
472
|
+
spans: list[tuple[int, int]] = []
|
|
473
|
+
for i, p in enumerate(self.projects):
|
|
474
|
+
if body:
|
|
475
|
+
body.append("") # one breath between projects, none at the ends
|
|
476
|
+
start = len(body)
|
|
477
|
+
body.extend(self._project_block(p, i == self.cursor, inner))
|
|
478
|
+
spans.append((start, len(body)))
|
|
479
|
+
return body, spans
|
|
480
|
+
|
|
481
|
+
def _scroll_for(self, body_len: int, view_h: int,
|
|
482
|
+
spans: list[tuple[int, int]]) -> int:
|
|
483
|
+
"""Where the viewport has to sit for the selection to be on screen."""
|
|
484
|
+
scroll = self.scroll
|
|
485
|
+
def clamp(v: int) -> int:
|
|
486
|
+
return max(0, min(v, max(0, body_len - view_h)))
|
|
487
|
+
if view_h <= 0:
|
|
488
|
+
return scroll
|
|
489
|
+
if self.mode == "color":
|
|
490
|
+
row = self.color_cursor
|
|
491
|
+
if row < scroll:
|
|
492
|
+
scroll = row
|
|
493
|
+
elif row >= scroll + view_h:
|
|
494
|
+
scroll = row - view_h + 1
|
|
495
|
+
return clamp(scroll)
|
|
496
|
+
if self.cursor < 0 or not (0 <= self.cursor < len(spans)):
|
|
497
|
+
return clamp(scroll)
|
|
498
|
+
start, end = spans[self.cursor]
|
|
499
|
+
if start < scroll:
|
|
500
|
+
scroll = start
|
|
501
|
+
elif end > scroll + view_h:
|
|
502
|
+
# Never past the project's own row. It carries the name, the theme
|
|
503
|
+
# and the total, and a project with more sessions than the viewport
|
|
504
|
+
# has lines used to scroll all three off the top so that its last
|
|
505
|
+
# session could be on screen: you were looking at rows belonging to
|
|
506
|
+
# something the screen no longer named.
|
|
507
|
+
scroll = min(start, max(0, end - view_h))
|
|
508
|
+
if start >= scroll + view_h:
|
|
509
|
+
scroll = start
|
|
510
|
+
return clamp(scroll)
|
|
511
|
+
|
|
512
|
+
def _footer(self, cols: int) -> list[str]:
|
|
513
|
+
if self.mode == "color":
|
|
514
|
+
return []
|
|
515
|
+
if self.pending:
|
|
516
|
+
return [""] + self._confirm_block(cols)
|
|
517
|
+
# Wrapped rather than cut. Every one of these is a sentence written
|
|
518
|
+
# to explain something, and at Terminal's own 80 columns they were
|
|
519
|
+
# all cut at about the point the explanation started: "Closed 2 of 3
|
|
520
|
+
# windows for acme-api. Terminal is asking about what's still run…"
|
|
521
|
+
# is the reader left with a window that would not close and a
|
|
522
|
+
# sentence that stops mid-word.
|
|
523
|
+
def said(text, colour):
|
|
524
|
+
return [f" {colour}{ln}{RESET}"
|
|
525
|
+
for ln in wrap_plain(text, max(1, cols - 2))]
|
|
526
|
+
|
|
527
|
+
if self.blocked and self.projects:
|
|
528
|
+
note = said(self.blocked, AMBER)
|
|
529
|
+
elif self.advice and not self.message:
|
|
530
|
+
note = said(self.advice, AMBER)
|
|
531
|
+
elif self.message and self.projects:
|
|
532
|
+
note = said(self.message, GOOD)
|
|
533
|
+
elif self.held:
|
|
534
|
+
# Above the legend, because the legend explains a word that is on
|
|
535
|
+
# screen and this explains something that is not. A desk with
|
|
536
|
+
# nothing on it and a desk whose orphans are all inside a tmux
|
|
537
|
+
# pane drew the same picture.
|
|
538
|
+
note = said(self.held, AMBER)
|
|
539
|
+
elif self.bg_all:
|
|
540
|
+
# The row above the commands was a blank line for spacing. It is
|
|
541
|
+
# better spent saying what the word on those rows means: the first
|
|
542
|
+
# person shown this screen asked what a marked row was, and there
|
|
543
|
+
# was nowhere on it that said.
|
|
544
|
+
legend = ("orphaned: still running, with no Terminal window left "
|
|
545
|
+
"to close")
|
|
546
|
+
note = said(legend, DIM)
|
|
547
|
+
else:
|
|
548
|
+
note = [""] # one breath between the map and its commands
|
|
549
|
+
return note + self._command_block(cols)
|
|
550
|
+
|
|
551
|
+
def _fit_footer(self, cols: int, rows: int, chrome: int,
|
|
552
|
+
body_n: int) -> list[str]:
|
|
553
|
+
"""Full panels only when a real list still fits above them."""
|
|
554
|
+
full = self._footer(cols)
|
|
555
|
+
compact = self._compact_block(cols) if self.mode != "color" else []
|
|
556
|
+
# A confirmation is modal: _handle_list ignores every other key while
|
|
557
|
+
# one is up, so there is no list behind it worth keeping. Budgeting it
|
|
558
|
+
# like ordinary footer chrome meant that at Terminal's own default
|
|
559
|
+
# 80x24, arming "quit all 24 orphans" dropped the panel naming them and
|
|
560
|
+
# asked for a SIGKILL against a count and a size. That is precisely
|
|
561
|
+
# what _armed_preview exists to prevent.
|
|
562
|
+
if self.pending and chrome + len(full) + 1 <= rows:
|
|
563
|
+
return full
|
|
564
|
+
min_body = min(10, max(3, body_n))
|
|
565
|
+
if chrome + len(full) + min_body <= rows:
|
|
566
|
+
return full
|
|
567
|
+
if compact and chrome + len(compact) + 1 <= rows:
|
|
568
|
+
return compact
|
|
569
|
+
if self.mode == "color":
|
|
570
|
+
return []
|
|
571
|
+
return [f" {chip('q', 3)} quit {chip('r', 3)} refresh"]
|
|
572
|
+
|
|
573
|
+
def draw(self) -> None:
|
|
574
|
+
"""Paint one frame. Always paints something.
|
|
575
|
+
|
|
576
|
+
Composing the frame touches every formatting path in the package. If
|
|
577
|
+
one of them raises, a blank screen is the worst possible answer: the
|
|
578
|
+
user is left with a frozen desk and no way to tell whether the tool is
|
|
579
|
+
working. Show the error and keep the keys live instead.
|
|
580
|
+
"""
|
|
581
|
+
cols, rows = term_size()
|
|
582
|
+
try:
|
|
583
|
+
frame_lines = self._compose(cols, rows)
|
|
584
|
+
except (KeyboardInterrupt, SystemExit):
|
|
585
|
+
raise
|
|
586
|
+
except Exception as exc: # noqa: BLE001 - see docstring
|
|
587
|
+
frame_lines = self._error_frame(exc, cols)
|
|
588
|
+
|
|
589
|
+
out = [HOME, HIDE]
|
|
590
|
+
for r in range(rows):
|
|
591
|
+
out.append(f"\033[{r + 1};1H{CLEAR_LINE}")
|
|
592
|
+
if r < len(frame_lines):
|
|
593
|
+
out.append(pad_ansi(frame_lines[r], cols).rstrip() + RESET)
|
|
594
|
+
sys.stdout.write("".join(out))
|
|
595
|
+
sys.stdout.flush()
|
|
596
|
+
|
|
597
|
+
def _error_frame(self, exc: Exception, cols: int) -> list[str]:
|
|
598
|
+
"""The last-resort screen: plain strings, no layout helpers."""
|
|
599
|
+
return [
|
|
600
|
+
f"{AMBER}{BOLD}workmap hit an error drawing this screen{RESET}",
|
|
601
|
+
"",
|
|
602
|
+
f"{DIM}{type(exc).__name__}: {exc}{RESET}"[: cols + 20],
|
|
603
|
+
"",
|
|
604
|
+
f"{BOLD}r{RESET}{DIM} to rescan {RESET}{BOLD}q{RESET}{DIM} to quit{RESET}",
|
|
605
|
+
]
|
|
606
|
+
|
|
607
|
+
def _compose(self, cols: int, rows: int) -> list[str]:
|
|
608
|
+
inner = inner_width(cols)
|
|
609
|
+
narrow = not boxed(cols)
|
|
610
|
+
|
|
611
|
+
header = self._header_lines(cols)
|
|
612
|
+
body, spans = self._build_body(cols)
|
|
613
|
+
close = [] if narrow else [box_bot(inner)]
|
|
614
|
+
footer = self._fit_footer(cols, rows, len(header) + len(close), len(body))
|
|
615
|
+
|
|
616
|
+
view_h = max(1, rows - len(header) - len(close) - len(footer))
|
|
617
|
+
self.scroll = self._scroll_for(len(body), view_h, spans)
|
|
618
|
+
|
|
619
|
+
window = body[self.scroll : self.scroll + view_h]
|
|
620
|
+
more_below = self.scroll + len(window) < len(body)
|
|
621
|
+
more_above = self.scroll > 0
|
|
622
|
+
hint = ("↑" if more_above else " ") + ("↓" if more_below else " ")
|
|
623
|
+
if close and (more_above or more_below):
|
|
624
|
+
close = [box_bot(inner, tail=f"{DIM}{hint.strip()} more{RESET}")]
|
|
625
|
+
|
|
626
|
+
rendered = (
|
|
627
|
+
[pad_ansi(ln, inner) for ln in window] if narrow
|
|
628
|
+
else [box_row(inner, ln) for ln in window]
|
|
629
|
+
)
|
|
630
|
+
if narrow and (more_above or more_below):
|
|
631
|
+
# The hint rides the bottom edge, and a narrow terminal has no
|
|
632
|
+
# edges. Without this the list simply stopped, with nothing saying
|
|
633
|
+
# there was more of it: the one place the desk can hide a project
|
|
634
|
+
# is the one place it never said so.
|
|
635
|
+
rendered = rendered[:-1] + [
|
|
636
|
+
f"{DIM}{hint.strip()} more{RESET}"] if len(rendered) > 1 else rendered
|
|
637
|
+
return header + rendered + close + footer
|
|
638
|
+
|
|
639
|
+
# ------------------------------------------------------------------ input
|
|
640
|
+
def run(self) -> None:
|
|
641
|
+
self._old = termios.tcgetattr(self._fd)
|
|
642
|
+
sys.stdout.write(ALT_ON + HIDE)
|
|
643
|
+
sys.stdout.flush()
|
|
644
|
+
try:
|
|
645
|
+
tty.setcbreak(self._fd)
|
|
646
|
+
# Paint immediately so large windows aren't a blank void during scan.
|
|
647
|
+
self.message = "Scanning Terminal…"
|
|
648
|
+
self.draw()
|
|
649
|
+
self._guard(self.refresh_data)
|
|
650
|
+
if self.message == "Scanning Terminal…":
|
|
651
|
+
self.message = ""
|
|
652
|
+
while True:
|
|
653
|
+
self._guard(self.draw)
|
|
654
|
+
try:
|
|
655
|
+
key = self._read_key()
|
|
656
|
+
except (KeyboardInterrupt, SystemExit):
|
|
657
|
+
raise
|
|
658
|
+
except Exception:
|
|
659
|
+
# No way left to read the keyboard, so there is no way for
|
|
660
|
+
# the user to quit. Leaving is the only honest response.
|
|
661
|
+
break
|
|
662
|
+
if not self._guard(self._handle_key, key, default=True):
|
|
663
|
+
break
|
|
664
|
+
except KeyboardInterrupt:
|
|
665
|
+
pass
|
|
666
|
+
finally:
|
|
667
|
+
if self._old is not None:
|
|
668
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)
|
|
669
|
+
sys.stdout.write(ALT_OFF + SHOW + RESET)
|
|
670
|
+
sys.stdout.flush()
|
|
671
|
+
|
|
672
|
+
def _handle_key(self, key: str) -> bool:
|
|
673
|
+
"""Dispatch one key, then say anything the action left to be said.
|
|
674
|
+
|
|
675
|
+
The notice is picked up here rather than inside `t`, `o` and `O`
|
|
676
|
+
because it belongs to the run and not to the key: three keys reach the
|
|
677
|
+
profile rewrite today, and the fourth will be added by somebody who
|
|
678
|
+
has not read this. It is appended rather than substituted, so pressing
|
|
679
|
+
`t` still tells you how many windows were renamed.
|
|
680
|
+
"""
|
|
681
|
+
handle = (self._handle_color if self.mode == "color"
|
|
682
|
+
else self._handle_list)
|
|
683
|
+
alive = handle(key)
|
|
684
|
+
notice = actions.take_title_notice()
|
|
685
|
+
if notice:
|
|
686
|
+
self.message = f"{self.message} {notice}".strip()
|
|
687
|
+
return alive
|
|
688
|
+
|
|
689
|
+
def _guard(self, fn, *args, default=None):
|
|
690
|
+
"""Run one step of the loop; survive anything it raises.
|
|
691
|
+
|
|
692
|
+
Every refresh shells out to ps, lsof, top and AppleScript. A single
|
|
693
|
+
malformed line or an OS hiccup used to end the session with a
|
|
694
|
+
traceback, which for a tool you leave open all day is the difference
|
|
695
|
+
between a blip and losing your place. Errors become a message on the
|
|
696
|
+
bar; the loop keeps going so `r` and `q` still work.
|
|
697
|
+
|
|
698
|
+
SystemExit and KeyboardInterrupt are deliberately not caught: those
|
|
699
|
+
are someone asking to leave.
|
|
700
|
+
"""
|
|
701
|
+
try:
|
|
702
|
+
return fn(*args)
|
|
703
|
+
except (KeyboardInterrupt, SystemExit):
|
|
704
|
+
raise
|
|
705
|
+
except Exception as exc: # noqa: BLE001 - a TUI must not die here
|
|
706
|
+
self.pending = None
|
|
707
|
+
self._armed_sessions = []
|
|
708
|
+
self.message = f"{type(exc).__name__}: {exc}"[:200] or "error"
|
|
709
|
+
return default
|
|
710
|
+
|
|
711
|
+
def _read_key(self) -> str:
|
|
712
|
+
ch = os.read(self._fd, 1)
|
|
713
|
+
if ch == b"\x1b":
|
|
714
|
+
rest = b""
|
|
715
|
+
if select.select([self._fd], [], [], 0.05)[0]:
|
|
716
|
+
rest += os.read(self._fd, 8)
|
|
717
|
+
if rest.startswith(b"[A"):
|
|
718
|
+
return "up"
|
|
719
|
+
if rest.startswith(b"[B"):
|
|
720
|
+
return "down"
|
|
721
|
+
if rest.startswith(b"[C"):
|
|
722
|
+
return "right"
|
|
723
|
+
if rest.startswith(b"[D"):
|
|
724
|
+
return "left"
|
|
725
|
+
return "esc"
|
|
726
|
+
try:
|
|
727
|
+
return ch.decode()
|
|
728
|
+
except Exception:
|
|
729
|
+
return ""
|
|
730
|
+
|
|
731
|
+
def _move(self, delta: int) -> None:
|
|
732
|
+
n = len(self.projects)
|
|
733
|
+
if n == 0:
|
|
734
|
+
self.cursor = -1
|
|
735
|
+
return
|
|
736
|
+
if self.cursor < 0:
|
|
737
|
+
self.cursor = 0 if delta > 0 else n - 1
|
|
738
|
+
else:
|
|
739
|
+
self.cursor = (self.cursor + delta) % n
|
|
740
|
+
self.pending = None
|
|
741
|
+
|
|
742
|
+
def _drop_what_was_typed_before_the_question(self) -> None:
|
|
743
|
+
"""Throw away input that arrived before this prompt was on screen.
|
|
744
|
+
|
|
745
|
+
_read_key takes one byte at a time out of whatever the tty already
|
|
746
|
+
holds, so two adjacent bytes of a paste were a whole authorisation:
|
|
747
|
+
`s`, `w` or `k` arms, and the Return that ends any pasted command line
|
|
748
|
+
answers yes. Nobody read the prompt, because it had not been drawn
|
|
749
|
+
yet. Pasting `git status` and a newline into the desk was enough.
|
|
750
|
+
|
|
751
|
+
An answer has to be typed after the question, so anything already
|
|
752
|
+
queued when it goes up is not an answer to it.
|
|
753
|
+
"""
|
|
754
|
+
fd = getattr(self, "_fd", None)
|
|
755
|
+
if fd is None:
|
|
756
|
+
return
|
|
757
|
+
try:
|
|
758
|
+
while select.select([fd], [], [], 0)[0]:
|
|
759
|
+
if not os.read(fd, 4096):
|
|
760
|
+
break
|
|
761
|
+
except (OSError, ValueError):
|
|
762
|
+
pass
|
|
763
|
+
|
|
764
|
+
def _arm(self, kind: str, message: str,
|
|
765
|
+
sessions: list[Session] | None = None) -> None:
|
|
766
|
+
# Pin the sessions now: `y` must kill what the prompt just quoted.
|
|
767
|
+
self.pending = kind
|
|
768
|
+
self.message = message
|
|
769
|
+
self._armed_sessions = list(sessions or [])
|
|
770
|
+
self._drop_what_was_typed_before_the_question()
|
|
771
|
+
|
|
772
|
+
def _cancel_pending(self) -> None:
|
|
773
|
+
self.pending = None
|
|
774
|
+
self._armed_sessions = []
|
|
775
|
+
self.message = "Cancelled, nothing was quit"
|
|
776
|
+
|
|
777
|
+
def _confirm_pending(self) -> None:
|
|
778
|
+
kind = self.pending
|
|
779
|
+
self.pending = None
|
|
780
|
+
p = self.current()
|
|
781
|
+
if kind == "kill_bg_all":
|
|
782
|
+
result = actions.kill_sessions(self._armed_sessions,
|
|
783
|
+
reason="orphans:all")
|
|
784
|
+
self.refresh_data()
|
|
785
|
+
self.message = (
|
|
786
|
+
f"Quit {plural(result['sessions'], 'orphan')} across every "
|
|
787
|
+
f"project, freeing about {fmt_mb(result['mb'])}"
|
|
788
|
+
)
|
|
789
|
+
return
|
|
790
|
+
if kind == "kill_bg" and p:
|
|
791
|
+
result = actions.kill_sessions(self._armed_sessions,
|
|
792
|
+
reason=f"orphans:{p.name}")
|
|
793
|
+
self.refresh_data()
|
|
794
|
+
self.message = (
|
|
795
|
+
f"Quit {plural(result['sessions'], 'orphan')} on "
|
|
796
|
+
f"{self.label_of(p)}, freeing about {fmt_mb(result['mb'])}"
|
|
797
|
+
)
|
|
798
|
+
return
|
|
799
|
+
if kind == "stop" and p:
|
|
800
|
+
result = actions.stop_project_stack(p.name, projects=self.projects)
|
|
801
|
+
quit_n = result["background"]["sessions"]
|
|
802
|
+
self.refresh_data()
|
|
803
|
+
if result["servers_stopped"]:
|
|
804
|
+
self.message = (f"Stopped the servers and "
|
|
805
|
+
f"{plural(quit_n, 'orphan')} on "
|
|
806
|
+
f"{self.label_of(p)}.")
|
|
807
|
+
else:
|
|
808
|
+
# `devstack` is a separate tool this key asks to bring servers
|
|
809
|
+
# down, and it is named nowhere a user would have read. Say
|
|
810
|
+
# what happened to them, not what the program is called.
|
|
811
|
+
why = result["reason"]
|
|
812
|
+
if not why:
|
|
813
|
+
note = "There were no servers to stop."
|
|
814
|
+
elif why == "devstack not installed":
|
|
815
|
+
note = ("No server manager is installed, so only the "
|
|
816
|
+
"orphans were quit.")
|
|
817
|
+
elif why == "devstack timed out":
|
|
818
|
+
note = ("The server manager took too long, so only the "
|
|
819
|
+
"orphans were quit.")
|
|
820
|
+
else:
|
|
821
|
+
note = f"The server manager could not stop them: {why}."
|
|
822
|
+
self.message = (f"Quit {plural(quit_n, 'orphan')} on "
|
|
823
|
+
f"{self.label_of(p)}. {note}")
|
|
824
|
+
return
|
|
825
|
+
if kind == "close_wins" and p:
|
|
826
|
+
asked = list(p.window_ids)
|
|
827
|
+
n = actions.close_project_windows(p.name, projects=self.projects)
|
|
828
|
+
# Before the refresh, which runs its own queries over the same
|
|
829
|
+
# status global. close_windows() answers 0 both for "none of them
|
|
830
|
+
# closed" and for "Terminal never answered", and only one of those
|
|
831
|
+
# means Terminal is holding a sheet open.
|
|
832
|
+
asked_ok = terminal.terminal_status()
|
|
833
|
+
self.refresh_data()
|
|
834
|
+
if not asked_ok["ok"]:
|
|
835
|
+
self.message = (f"Couldn't ask Terminal to close those "
|
|
836
|
+
f"windows: {asked_ok['reason']}.")
|
|
837
|
+
return
|
|
838
|
+
# Which of them are still there, asked fresh, rather than
|
|
839
|
+
# `asked - n`. A window id comes off the last scan, so one of
|
|
840
|
+
# them can have been closed by hand between that scan and this
|
|
841
|
+
# keypress: it is a window that had already gone, not a window
|
|
842
|
+
# Terminal is holding a sheet over, and saying otherwise points
|
|
843
|
+
# the reader at a dialog that is not on their screen.
|
|
844
|
+
after = next((q for q in self.projects if q.name == p.name), None)
|
|
845
|
+
waiting = len(set(asked) & set(after.window_ids)) if after else 0
|
|
846
|
+
if waiting:
|
|
847
|
+
# Terminal is holding a sheet open on these, asking whether to
|
|
848
|
+
# terminate what is running in them. Saying so beats a number
|
|
849
|
+
# that does not match what is on screen.
|
|
850
|
+
self.message = (
|
|
851
|
+
f"Closed {n} of {plural(len(asked), 'window')} for "
|
|
852
|
+
f"{self.label_of(p)}. Terminal is asking about what's "
|
|
853
|
+
f"still running in the other {waiting}."
|
|
854
|
+
)
|
|
855
|
+
else:
|
|
856
|
+
self.message = (
|
|
857
|
+
f"Closed {plural(n, 'Terminal window')} for "
|
|
858
|
+
f"{self.label_of(p)}.")
|
|
859
|
+
return
|
|
860
|
+
self.message = ""
|
|
861
|
+
|
|
862
|
+
def _handle_list(self, key: str) -> bool:
|
|
863
|
+
if self.pending:
|
|
864
|
+
if key in ("y", "Y", "\r", "\n"):
|
|
865
|
+
self._confirm_pending()
|
|
866
|
+
return True
|
|
867
|
+
if key in ("n", "N", "esc"):
|
|
868
|
+
self._cancel_pending()
|
|
869
|
+
return True
|
|
870
|
+
if key not in ("up", "down"):
|
|
871
|
+
return True # modal: ignore everything else
|
|
872
|
+
# Navigating away is a decision too, so drop the prompt and move on.
|
|
873
|
+
# Only ever resolves toward "don't kill anything".
|
|
874
|
+
self._cancel_pending()
|
|
875
|
+
self.message = ""
|
|
876
|
+
|
|
877
|
+
if key in ("q", "Q", "\x03"):
|
|
878
|
+
return False
|
|
879
|
+
if key == "esc":
|
|
880
|
+
self.cursor = -1
|
|
881
|
+
self.message = ""
|
|
882
|
+
self.pending = None
|
|
883
|
+
return True
|
|
884
|
+
if key in ("r", "R"):
|
|
885
|
+
self.refresh_data()
|
|
886
|
+
self.message = "Remapped the desk"
|
|
887
|
+
return True
|
|
888
|
+
if key == "up":
|
|
889
|
+
self._move(-1)
|
|
890
|
+
self.message = ""
|
|
891
|
+
return True
|
|
892
|
+
if key == "down":
|
|
893
|
+
self._move(1)
|
|
894
|
+
self.message = ""
|
|
895
|
+
return True
|
|
896
|
+
|
|
897
|
+
p = self.current()
|
|
898
|
+
|
|
899
|
+
# General commands (footer), nothing to do with the selection
|
|
900
|
+
if key == "t":
|
|
901
|
+
n = actions.retitle_all(projects=self.projects)
|
|
902
|
+
self.refresh_data()
|
|
903
|
+
self.message = (f"Renamed {plural(n, 'Terminal window')} to "
|
|
904
|
+
f"project + what.")
|
|
905
|
+
return True
|
|
906
|
+
|
|
907
|
+
if key in ("k", "K"):
|
|
908
|
+
# One key, one meaning: selected project → that project only;
|
|
909
|
+
# nothing selected → all orphans. (No overlapping k / K.)
|
|
910
|
+
if p is not None:
|
|
911
|
+
sessions = [s for s in p.sessions if s.kind == "bg"]
|
|
912
|
+
if not sessions:
|
|
913
|
+
self.message = (f"Nothing on {self.label_of(p)} is "
|
|
914
|
+
f"running without a window")
|
|
915
|
+
return True
|
|
916
|
+
mb = sum(s.mb for s in sessions)
|
|
917
|
+
self._arm(
|
|
918
|
+
"kill_bg",
|
|
919
|
+
f"Quit {plural(len(sessions), 'orphan')} on "
|
|
920
|
+
f"{self.label_of(p)}, holding about {fmt_mb(mb)}?",
|
|
921
|
+
sessions,
|
|
922
|
+
)
|
|
923
|
+
return True
|
|
924
|
+
sessions = [
|
|
925
|
+
s for proj in self.projects for s in proj.sessions if s.kind == "bg"
|
|
926
|
+
]
|
|
927
|
+
if not sessions:
|
|
928
|
+
self.message = ("Nothing anywhere is running without a "
|
|
929
|
+
"window, so there is nothing to quit")
|
|
930
|
+
return True
|
|
931
|
+
mb = sum(s.mb for s in sessions)
|
|
932
|
+
self._arm(
|
|
933
|
+
"kill_bg_all",
|
|
934
|
+
f"Quit all {plural(len(sessions), 'orphan')}, across every "
|
|
935
|
+
f"project, holding about {fmt_mb(mb)}?",
|
|
936
|
+
sessions,
|
|
937
|
+
)
|
|
938
|
+
return True
|
|
939
|
+
|
|
940
|
+
if key == "O":
|
|
941
|
+
result = actions.organize_all(projects=self.projects)
|
|
942
|
+
self.refresh_data()
|
|
943
|
+
if not result.get("ok"):
|
|
944
|
+
self.message = (f"Nothing to lay out: "
|
|
945
|
+
f"{result.get('reason', 'no Terminal windows')}.")
|
|
946
|
+
else:
|
|
947
|
+
self.message = (
|
|
948
|
+
f"Laid out {plural(result.get('projects', 0), 'project')}, "
|
|
949
|
+
f"each in its own region of the screen"
|
|
950
|
+
)
|
|
951
|
+
stuck = actions.stuck_words(result)
|
|
952
|
+
if stuck:
|
|
953
|
+
self.message = f"{self.message}. {stuck}"
|
|
954
|
+
return True
|
|
955
|
+
|
|
956
|
+
# Project-only commands
|
|
957
|
+
if not p:
|
|
958
|
+
return True
|
|
959
|
+
|
|
960
|
+
if key in ("f", "F"):
|
|
961
|
+
if p.window_ids:
|
|
962
|
+
actions.focus_project(p)
|
|
963
|
+
self.message = f"Brought {self.label_of(p)} to the front"
|
|
964
|
+
else:
|
|
965
|
+
self.message = (f"{self.label_of(p)} has no Terminal "
|
|
966
|
+
f"windows open")
|
|
967
|
+
return True
|
|
968
|
+
if key == "o":
|
|
969
|
+
if not p.window_ids:
|
|
970
|
+
self.message = (f"{self.label_of(p)} has no Terminal "
|
|
971
|
+
f"windows to lay out.")
|
|
972
|
+
return True
|
|
973
|
+
result = actions.organize_project(p.name, projects=self.projects)
|
|
974
|
+
self.refresh_data()
|
|
975
|
+
if not result.get("ok"):
|
|
976
|
+
self.message = (f"Nothing to lay out for {self.label_of(p)}: "
|
|
977
|
+
f"{result.get('reason', 'no Terminal windows')}.")
|
|
978
|
+
elif result.get("merged"):
|
|
979
|
+
self.message = f"Gathered {self.label_of(p)} into one window"
|
|
980
|
+
else:
|
|
981
|
+
stuck = actions.stuck_words(result)
|
|
982
|
+
if stuck:
|
|
983
|
+
self.message = (
|
|
984
|
+
f"Laid out {result.get('placed', 0)} of "
|
|
985
|
+
f"{plural(result.get('windows', 0), 'window')} for "
|
|
986
|
+
f"{self.label_of(p)}. {stuck}"
|
|
987
|
+
)
|
|
988
|
+
else:
|
|
989
|
+
self.message = (
|
|
990
|
+
f"Laid out {plural(result.get('windows', 0), 'window')} "
|
|
991
|
+
f"for {self.label_of(p)}"
|
|
992
|
+
)
|
|
993
|
+
return True
|
|
994
|
+
if key in ("c", "C"):
|
|
995
|
+
self.mode = "color"
|
|
996
|
+
self.scroll = 0
|
|
997
|
+
try:
|
|
998
|
+
self.color_cursor = self.profiles.index(p.profile)
|
|
999
|
+
except ValueError:
|
|
1000
|
+
self.color_cursor = 0
|
|
1001
|
+
self.message = ""
|
|
1002
|
+
return True
|
|
1003
|
+
if key in ("x", "X", " "):
|
|
1004
|
+
if p.name in self.expanded:
|
|
1005
|
+
self.expanded.discard(p.name)
|
|
1006
|
+
else:
|
|
1007
|
+
self.expanded.add(p.name)
|
|
1008
|
+
return True
|
|
1009
|
+
if key in ("s", "S"):
|
|
1010
|
+
n = sum(1 for s in p.sessions if s.kind == "bg")
|
|
1011
|
+
self._arm(
|
|
1012
|
+
"stop",
|
|
1013
|
+
f"Stop {self.label_of(p)}'s servers and quit its "
|
|
1014
|
+
f"{plural(n, 'orphan')}?",
|
|
1015
|
+
[s for s in p.sessions if s.kind == "bg"],
|
|
1016
|
+
)
|
|
1017
|
+
return True
|
|
1018
|
+
if key in ("w", "W"):
|
|
1019
|
+
if not p.window_ids:
|
|
1020
|
+
self.message = (f"{self.label_of(p)} has no Terminal "
|
|
1021
|
+
f"windows to close")
|
|
1022
|
+
return True
|
|
1023
|
+
self._arm(
|
|
1024
|
+
"close_wins",
|
|
1025
|
+
# Not "nothing is lost": closing a window does end what is
|
|
1026
|
+
# running in it, which is exactly why Terminal puts up a sheet
|
|
1027
|
+
# first. Say who asks and when.
|
|
1028
|
+
f"Close {plural(len(p.window_ids), 'Terminal window')} for "
|
|
1029
|
+
f"{self.label_of(p)}? Terminal will ask before ending "
|
|
1030
|
+
f"anything still running.",
|
|
1031
|
+
)
|
|
1032
|
+
return True
|
|
1033
|
+
return True
|
|
1034
|
+
|
|
1035
|
+
def _handle_color(self, key: str) -> bool:
|
|
1036
|
+
names = self.profiles or list(PROFILE_ANSI.keys())
|
|
1037
|
+
if key in ("esc", "q", "Q"):
|
|
1038
|
+
self.mode = "list"
|
|
1039
|
+
self.scroll = 0
|
|
1040
|
+
return True
|
|
1041
|
+
if key == "up":
|
|
1042
|
+
self.color_cursor = (self.color_cursor - 1) % max(1, len(names))
|
|
1043
|
+
return True
|
|
1044
|
+
if key == "down":
|
|
1045
|
+
self.color_cursor = (self.color_cursor + 1) % max(1, len(names))
|
|
1046
|
+
return True
|
|
1047
|
+
if key in ("\r", "\n", " "):
|
|
1048
|
+
p = self.current()
|
|
1049
|
+
if p and names:
|
|
1050
|
+
profile = names[self.color_cursor]
|
|
1051
|
+
actions.set_project_profile(p.name, profile, apply=True,
|
|
1052
|
+
projects=self.projects)
|
|
1053
|
+
self.refresh_data()
|
|
1054
|
+
self.message = f"{self.label_of(p)} is now {profile}"
|
|
1055
|
+
self.mode = "list"
|
|
1056
|
+
self.scroll = 0
|
|
1057
|
+
return True
|
|
1058
|
+
return True
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
class NoTerminalToDrawOn(Exception):
|
|
1062
|
+
"""There is no terminal here, or there is one that will not go raw.
|
|
1063
|
+
|
|
1064
|
+
Carried to the caller rather than handled here. Falling back to the text
|
|
1065
|
+
snapshot meant this module importing cli, which is the top layer reaching
|
|
1066
|
+
back into an entry point: the one dependency the layer order forbids and
|
|
1067
|
+
the layering test could not see, because `cli` is not one of the layers.
|
|
1068
|
+
Deciding what to do instead of the map is the entry point's job.
|
|
1069
|
+
"""
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def run() -> int:
|
|
1073
|
+
"""Draw the interactive map.
|
|
1074
|
+
|
|
1075
|
+
Raises NoTerminalToDrawOn if this is not a terminal it can take over.
|
|
1076
|
+
"""
|
|
1077
|
+
if not sys.stdin.isatty():
|
|
1078
|
+
raise NoTerminalToDrawOn(
|
|
1079
|
+
"The map needs a terminal to draw on, and this is being piped. "
|
|
1080
|
+
"Here it is as text; `workmap list` prints the same thing without "
|
|
1081
|
+
"this line.")
|
|
1082
|
+
try:
|
|
1083
|
+
Desk().run()
|
|
1084
|
+
except (termios.error, OSError) as exc:
|
|
1085
|
+
# isatty() said yes but the terminal will not go raw: a pty that does
|
|
1086
|
+
# not support it, a detached session, some CI shells.
|
|
1087
|
+
raise NoTerminalToDrawOn(
|
|
1088
|
+
f"workmap could not take this terminal over ({exc}). "
|
|
1089
|
+
f"Here is the same map as text.") from exc
|
|
1090
|
+
return 0
|