@vibelord/lanes 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lanes-install.mjs +158 -0
- package/lib/plan.mjs +65 -0
- package/package.json +37 -0
- package/payload/lanes/README.md +152 -0
- package/payload/lanes/VERSION +1 -0
- package/payload/lanes/bin/lanes +1035 -0
- package/payload/lanes/client/ghostty/common.conf +22 -0
- package/payload/lanes/client/ghostty/macos.conf +6 -0
- package/payload/lanes/client/ghostty/omarchy.conf +19 -0
- package/payload/lanes/client/install.sh +16 -0
- package/payload/lanes/client/lanes-launch +315 -0
- package/payload/lanes/client/lanes-upload +147 -0
- package/payload/lanes/client/lanes-window +36 -0
- package/payload/lanes/client/macos-helper.applescript +14 -0
- package/payload/lanes/client/omarchy/discover.sh +56 -0
- package/payload/lanes/client/omarchy/lanes.conf +5 -0
- package/payload/lanes/client/omarchy/lanes.desktop +9 -0
- package/payload/lanes/client/omarchy/lanes.lua +14 -0
- package/payload/lanes/client/ssh.stanza.in +54 -0
- package/payload/lanes/container/env.sh +16 -0
- package/payload/lanes/container/lane +268 -0
- package/payload/lanes/container/lane-run +34 -0
- package/payload/lanes/docs/ARCHITECTURE.md +93 -0
- package/payload/lanes/docs/HOST-GUARD.md +108 -0
- package/payload/lanes/docs/INSTALL.md +192 -0
- package/payload/lanes/docs/RUNBOOK.md +385 -0
- package/payload/lanes/docs/SHARING.md +124 -0
- package/payload/lanes/docs/ZMX-RING0.md +51 -0
- package/payload/lanes/docs/images/desktop.png +0 -0
- package/payload/lanes/docs/images/iphone.png +0 -0
- package/payload/lanes/holders/exec.sh +21 -0
- package/payload/lanes/holders/tmux.sh +111 -0
- package/payload/lanes/holders/zmx.sh +22 -0
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-phone.sh +43 -0
- package/payload/lanes/install/enable-web.sh +16 -0
- package/payload/lanes/install/host-install.sh +72 -0
- package/payload/lanes/install/services.py +61 -0
- package/payload/lanes/install.sh +603 -0
- package/payload/lanes/libexec/cursor_commit.py +159 -0
- package/payload/lanes/libexec/cursor_sync.py +166 -0
- package/payload/lanes/libexec/guard.py +231 -0
- package/payload/lanes/libexec/lanes_lib.py +610 -0
- package/payload/lanes/libexec/legacy_sessions.py +130 -0
- package/payload/lanes/libexec/session_actions.py +276 -0
- package/payload/lanes/libexec/sidebar +8 -0
- package/payload/lanes/libexec/sidebar.py +872 -0
- package/payload/lanes/libexec/statusd.py +764 -0
- package/payload/lanes/libexec/tmpscan.py +921 -0
- package/payload/lanes/libexec/view.sh +179 -0
- package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
- package/payload/lanes/site/README.md +28 -0
- package/payload/lanes/site/beyondwork/known_hosts +1 -0
- package/payload/lanes/site/beyondwork/site.conf +11 -0
- package/payload/lanes/site/site.example.conf +10 -0
- package/payload/lanes/sudo/host-guard.sh +356 -0
- package/payload/lanes/sudo/one-time-root.sh +32 -0
- package/payload/lanes/systemd/lanes-ensure.service +7 -0
- package/payload/lanes/systemd/lanes-ensure.timer +10 -0
- package/payload/lanes/systemd/lanes-gc.service +7 -0
- package/payload/lanes/systemd/lanes-gc.timer +9 -0
- package/payload/lanes/systemd/lanes-guard.service +8 -0
- package/payload/lanes/systemd/lanes-guard.timer +10 -0
- package/payload/lanes/systemd/lanes-mobile.service +23 -0
- package/payload/lanes/systemd/lanes-statusd.service +15 -0
- package/payload/lanes/systemd/lanes-sync.service +10 -0
- package/payload/lanes/systemd/lanes-sync.timer +10 -0
- package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
- package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
- package/payload/lanes/systemd/lanes-web.service +13 -0
- package/payload/lanes/systemd/lanes.slice +11 -0
- package/payload/lanes/web/Caddyfile +37 -0
- package/payload/lanes/web/README.md +35 -0
- package/payload/lanes/web/icon-192.png +0 -0
- package/payload/lanes/web/icon.svg +4 -0
- package/payload/lanes/web/index.html +30 -0
- package/payload/lanes/web/manifest.webmanifest +15 -0
- package/payload/lanes/web/mobile.css +301 -0
- package/payload/lanes/web/mobile.js +367 -0
- package/payload/lanes/workspaces.example/README.md +27 -0
- package/payload/lanes/workspaces.example/catalog.json +7 -0
- package/payload/lanes/workspaces.example/commit-targets.json +8 -0
- package/payload/lanes/workspaces.example/terminals.example.json +19 -0
- package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
- package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
- package/payload/lanes/zellij/config.kdl +360 -0
- package/payload/manifest.json +31 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lane sidebar: a plain terminal program (no zellij plugin, no curses).
|
|
3
|
+
|
|
4
|
+
Shows one workspace's lanes with color, icon and live status. Selecting a lane writes the view's
|
|
5
|
+
target file and signals the viewport supervisor; the zellij layout is never touched.
|
|
6
|
+
Fully usable from the keyboard (touch/mouse is a bonus):
|
|
7
|
+
↑/↓ j/k move · Enter/→/Space open · J/K open next/previous lane · / filter · Esc clear · g/G · r reload
|
|
8
|
+
2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions)
|
|
9
|
+
Upload box (bottom): click it or press U (Ctrl-Alt-u from anywhere), then DROP FILES anywhere on the window. A
|
|
10
|
+
terminal delivers a drop as a paste of the LOCAL path into the focused pane, so the box must have focus first; the
|
|
11
|
+
file itself is then sent by the upload helper on your machine (it only reads folders you allowed there).
|
|
12
|
+
Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
|
|
13
|
+
when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
|
|
14
|
+
Run via libexec/sidebar (a loop), so it never leaves its pane empty.
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import select
|
|
20
|
+
import shlex
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import termios
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
import tty
|
|
28
|
+
import unicodedata
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
import lanes_lib as L # noqa: E402
|
|
32
|
+
import session_actions as A # noqa: E402
|
|
33
|
+
|
|
34
|
+
STATE = os.environ.get('LANES_STATE') or os.path.expanduser('~/.local/state/lanes')
|
|
35
|
+
# The six words statusd publishes (alignment contract A3) and the helper word for an `exec` lane. There is no
|
|
36
|
+
# seventh: statusd's zmx observer used to publish `idle`, which no other surface knew (DEF-823).
|
|
37
|
+
GLYPHS = {'active': ('●', '32'), 'quiet': ('○', '2'), 'missing': ('✖', '31'), 'absent': ('–', '2'),
|
|
38
|
+
'dead': ('!', '33'), 'helper': ('·', '2'), 'unknown': ('?', '2')}
|
|
39
|
+
# The phone sidebar spells the same six words (plus `helper`) in full. The keys are GLYPHS's keys and the
|
|
40
|
+
# vocabulary gate pins that: this map kept `idle` for a whole release after DEF-823 removed it from GLYPHS,
|
|
41
|
+
# because the gate read GLYPHS alone and the map was a literal buried inside `render_mobile` (DEF-983).
|
|
42
|
+
MOBILE_LABELS = {'active': 'Active', 'quiet': 'Quiet', 'missing': 'Container offline',
|
|
43
|
+
'absent': 'Not started', 'dead': 'Ended', 'helper': 'Tool',
|
|
44
|
+
'unknown': 'Checking status'}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def viewer_mark(status):
|
|
48
|
+
"""The mark after a lane's name when other sessions view it: one dot per viewer, at most three."""
|
|
49
|
+
count = int((status or {}).get('viewers') or 0)
|
|
50
|
+
return '·' * min(3, count)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_json(path):
|
|
54
|
+
try:
|
|
55
|
+
with open(path) as handle:
|
|
56
|
+
return json.load(handle)
|
|
57
|
+
except (OSError, ValueError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def mtime(path):
|
|
62
|
+
try:
|
|
63
|
+
return os.stat(path).st_mtime_ns
|
|
64
|
+
except OSError:
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cell_width(text):
|
|
69
|
+
width = 0
|
|
70
|
+
for ch in text:
|
|
71
|
+
if unicodedata.combining(ch) or ch in '️':
|
|
72
|
+
continue
|
|
73
|
+
width += 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1
|
|
74
|
+
return width
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def clip(text, width):
|
|
78
|
+
out, used = [], 0
|
|
79
|
+
for ch in text:
|
|
80
|
+
w = cell_width(ch)
|
|
81
|
+
if used + w > width:
|
|
82
|
+
break
|
|
83
|
+
out.append(ch)
|
|
84
|
+
used += w
|
|
85
|
+
return ''.join(out), used
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
HEALTH_STALE = 30 # seconds; statusd rewrites $STATE/health every cycle, at most ~12 s apart when it slows itself down
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def health_banner(health, written, now):
|
|
92
|
+
"""The footer warning; '' = nothing to say. `health` = L.read_health(), `written` = the file's mtime in seconds
|
|
93
|
+
(0 = no such file: a statusd from before it existed — nothing is known, so nothing is claimed).
|
|
94
|
+
statusd is what freezes on a thrashing host, so the age is measured HERE, with our clock, from the file's mtime.
|
|
95
|
+
The age comes first: a sidebar is 36 cells wide, and "what you see is N minutes old" must survive the clipping."""
|
|
96
|
+
if os.path.exists(os.path.join(STATE, 'flags', 'sockets-lost')):
|
|
97
|
+
# Short, and first: it explains every "not running" on the screen. The fix is one command on the server.
|
|
98
|
+
return '⚠ lane sockets deleted — lanes run; on the server: lanes sockets repair'
|
|
99
|
+
if not written:
|
|
100
|
+
return ''
|
|
101
|
+
parts, age = [], int(now - written)
|
|
102
|
+
if age > HEALTH_STALE:
|
|
103
|
+
parts.append(f'status {age // 60} m old' if age >= 60 else f'status {age // 10 * 10} s old')
|
|
104
|
+
level = health.get('level', 'ok')
|
|
105
|
+
if level != 'ok':
|
|
106
|
+
parts.append('host under memory pressure' if level == 'critical' else 'host busy')
|
|
107
|
+
return '⚠ ' + ' · '.join(parts) if parts else ''
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
SLOTS = ['main', 'b', 'c']
|
|
111
|
+
BOX_ROWS = 4
|
|
112
|
+
PASTE_START, PASTE_END = '\033[200~', '\033[201~'
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Sidebar:
|
|
116
|
+
def __init__(self, device, workspace, slot='main', viewports='1'):
|
|
117
|
+
self.device, self.workspace, self.slot = device, workspace, slot
|
|
118
|
+
self.mobile = bool((read_json(os.path.join(STATE, 'devices', device + '.json')) or {}).get('mobile'))
|
|
119
|
+
self.mobile_owner, self.mobile_titles, self.next_mobile_poll = False, {}, 0
|
|
120
|
+
try:
|
|
121
|
+
self.viewports = max(1, min(len(SLOTS), int(viewports)))
|
|
122
|
+
except ValueError:
|
|
123
|
+
self.viewports = 1
|
|
124
|
+
self.view_dir = os.path.join(STATE, 'views', device)
|
|
125
|
+
self.target_file = self.slot_file(slot, 'target')
|
|
126
|
+
self.pid_file = self.slot_file(slot, 'pid')
|
|
127
|
+
self.registry_path = os.path.join(STATE, 'registry.json')
|
|
128
|
+
self.status_path = os.path.join(STATE, 'status.json')
|
|
129
|
+
self.health_path = os.path.join(STATE, 'health')
|
|
130
|
+
self.health, self.health_stamp, self.banner = {}, None, ''
|
|
131
|
+
self.entries, self.title, self.errors = [], workspace, []
|
|
132
|
+
self.status, self.unregistered, self.zmx = {}, [], []
|
|
133
|
+
self.cursor, self.top = 0, 0
|
|
134
|
+
self.filter, self.filtering = '', False
|
|
135
|
+
self.renaming, self.rename_value, self.rename_entry = False, '', None
|
|
136
|
+
self.promoting, self.promote_entry = False, None
|
|
137
|
+
self.stamp = (0, 0, 0)
|
|
138
|
+
self._prev_entry_count = -1 # for scroll memory
|
|
139
|
+
self.size = (0, 0)
|
|
140
|
+
self.dirty = True
|
|
141
|
+
self.message = ''
|
|
142
|
+
self.box, self.box_top = False, 10 ** 6
|
|
143
|
+
self.entry_top, self.entry_height = 2, 1
|
|
144
|
+
self.armed, self.paste_buffer = False, None
|
|
145
|
+
self.upload = None # {'id', 'spool', 'names', 'at'} while a request is in flight
|
|
146
|
+
self.upload_note, self.upload_note_at = '', 0
|
|
147
|
+
|
|
148
|
+
def slot_file(self, slot, kind):
|
|
149
|
+
return os.path.join(self.view_dir, f'{self.workspace}.{slot}.{kind}')
|
|
150
|
+
|
|
151
|
+
def slot_target(self, slot):
|
|
152
|
+
try:
|
|
153
|
+
with open(self.slot_file(slot, 'target')) as handle:
|
|
154
|
+
return handle.readline().strip()
|
|
155
|
+
except OSError:
|
|
156
|
+
return ''
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------- data
|
|
159
|
+
def reload(self, force=False):
|
|
160
|
+
stamp = (mtime(self.registry_path), mtime(self.status_path),
|
|
161
|
+
sum(mtime(self.slot_file(slot, 'target')) for slot in SLOTS[:self.viewports]))
|
|
162
|
+
if stamp == self.stamp and not force:
|
|
163
|
+
return
|
|
164
|
+
if stamp[0] != self.stamp[0] or force:
|
|
165
|
+
registry = read_json(self.registry_path) or {}
|
|
166
|
+
boards = registry.get('workspaces', [])
|
|
167
|
+
self.mobile_owner = self.mobile and bool(boards) and boards[0]['id'] == self.workspace
|
|
168
|
+
self.mobile_titles = {w.get('title') or w['id']: w['id'] for w in boards}
|
|
169
|
+
self.mobile_titles.update({tool['tab']: tool['view'] for tool in registry.get('tools', [])})
|
|
170
|
+
for workspace in registry.get('workspaces', []):
|
|
171
|
+
if workspace.get('id') == self.workspace:
|
|
172
|
+
self.title = workspace.get('title') or self.workspace
|
|
173
|
+
self.entries = list(workspace.get('lanes', [])) + list(workspace.get('extras', []))
|
|
174
|
+
self.errors = workspace.get('errors', [])
|
|
175
|
+
break
|
|
176
|
+
if stamp[1] != self.stamp[1] or force:
|
|
177
|
+
status = read_json(self.status_path) or {}
|
|
178
|
+
self.status = status.get('lanes', {})
|
|
179
|
+
self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
|
|
180
|
+
self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
|
|
181
|
+
self.stamp = stamp
|
|
182
|
+
self.dirty = True
|
|
183
|
+
# Scroll memory: preserve scroll top when list length is stable.
|
|
184
|
+
# Cursor is already clamped by render() on every draw.
|
|
185
|
+
new_count = len(self.visible())
|
|
186
|
+
if new_count != self._prev_entry_count:
|
|
187
|
+
self._prev_entry_count = new_count
|
|
188
|
+
self.top = 0 # reset scroll position on structural change
|
|
189
|
+
|
|
190
|
+
def poll_mobile_view(self):
|
|
191
|
+
"""One sidebar observes the phone's web client and the first display of each full-width view.
|
|
192
|
+
|
|
193
|
+
Safari bypasses `lanes attach`, which normally sets clients/<device>.ready. Hidden mobile tabs have a
|
|
194
|
+
plausible placeholder size, so they also need a per-workspace first-display gate before attaching tmux.
|
|
195
|
+
"""
|
|
196
|
+
if not self.mobile_owner or time.monotonic() < self.next_mobile_poll:
|
|
197
|
+
return
|
|
198
|
+
self.next_mobile_poll = time.monotonic() + 1
|
|
199
|
+
command = [os.environ.get('LANES_ZELLIJ') or 'zellij', '-s', self.device, 'action']
|
|
200
|
+
ready = os.path.join(STATE, 'clients', self.device + '.ready')
|
|
201
|
+
if not os.path.exists(ready):
|
|
202
|
+
rc, out, _err = L.run_bounded(command + ['list-clients'], 2)
|
|
203
|
+
if rc != 0 or not any(re.match(r'^\s*\d+\s', line) for line in out.splitlines()):
|
|
204
|
+
return
|
|
205
|
+
L.atomic_write(Path(ready), f'{time.time()}\n')
|
|
206
|
+
rc, out, _err = L.run_bounded(command + ['list-tabs', '-j'], 2)
|
|
207
|
+
if rc != 0:
|
|
208
|
+
return
|
|
209
|
+
try:
|
|
210
|
+
title = next(tab['name'] for tab in json.loads(out) if tab.get('active'))
|
|
211
|
+
except (ValueError, KeyError, StopIteration, TypeError):
|
|
212
|
+
return
|
|
213
|
+
workspace = self.mobile_titles.get(title) # the "<workspace> ☰" picker tab never attaches a lane
|
|
214
|
+
if workspace:
|
|
215
|
+
shown = Path(self.view_dir) / f'{workspace}.main.shown'
|
|
216
|
+
if not shown.exists():
|
|
217
|
+
L.atomic_write(shown, f'{time.time()}\n')
|
|
218
|
+
|
|
219
|
+
def poll_health(self):
|
|
220
|
+
"""Keep the footer banner current. Deliberately NOT part of reload()'s stamp: the health file changes every
|
|
221
|
+
statusd cycle and would repaint every sidebar every cycle — only a changed banner TEXT marks the screen
|
|
222
|
+
dirty. Polled every tick, because the case that matters is the one where no file changes at all any more."""
|
|
223
|
+
stamp = mtime(self.health_path)
|
|
224
|
+
if stamp != self.health_stamp:
|
|
225
|
+
self.health_stamp, self.health = stamp, L.read_health(STATE)
|
|
226
|
+
banner = health_banner(self.health, stamp / 1e9, time.time())
|
|
227
|
+
if banner != self.banner:
|
|
228
|
+
self.banner, self.dirty = banner, True
|
|
229
|
+
|
|
230
|
+
def current_target(self):
|
|
231
|
+
try:
|
|
232
|
+
with open(self.target_file) as handle:
|
|
233
|
+
return handle.readline().strip()
|
|
234
|
+
except OSError:
|
|
235
|
+
try:
|
|
236
|
+
with open(os.path.join(STATE, 'defaults', self.workspace)) as handle:
|
|
237
|
+
return handle.readline().strip()
|
|
238
|
+
except OSError:
|
|
239
|
+
return ''
|
|
240
|
+
|
|
241
|
+
def visible(self):
|
|
242
|
+
entries = list(self.entries)
|
|
243
|
+
if self.zmx:
|
|
244
|
+
entries.append({'id': '', 'divider': True, 'name': f'zmx lanes ({len(self.zmx)})'})
|
|
245
|
+
entries += self.zmx
|
|
246
|
+
# Everything statusd lists as unregistered is shown, `view-…` names included: under the viewer rule
|
|
247
|
+
# those are orphaned or operator-made sessions that are ALIVE, and nothing alive is ever hidden.
|
|
248
|
+
entries.append({'id': '', 'divider': True, 'name': f'Unregistered ({len(self.unregistered)})'})
|
|
249
|
+
entries.append({'id': self.workspace + '--new-shell', 'name': 'New +', 'action': 'new-shell', 'icon': '+'})
|
|
250
|
+
entries += self.unregistered
|
|
251
|
+
if not self.filter:
|
|
252
|
+
return entries
|
|
253
|
+
needle = self.filter.lower()
|
|
254
|
+
scored = [(self._fuzzy_score(needle, e.get('name', '').lower()), e)
|
|
255
|
+
for e in entries if not e.get('divider')]
|
|
256
|
+
return [e for score, e in sorted(
|
|
257
|
+
((s, e) for s, e in scored if s > 0), key=lambda t: -t[0])]
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _fuzzy_score(needle, name):
|
|
261
|
+
"""Score how well `needle` matches `name` (both lower-case). Returns 0 if no fuzzy match.
|
|
262
|
+
Characters of needle must appear in name in order; bonus for prefix or word-boundary hits."""
|
|
263
|
+
if not needle:
|
|
264
|
+
return 0
|
|
265
|
+
if needle in name:
|
|
266
|
+
return 1000 + (500 if name.startswith(needle) else 0)
|
|
267
|
+
pos, score = 0, 0
|
|
268
|
+
for ch in needle:
|
|
269
|
+
found = name.find(ch, pos)
|
|
270
|
+
if found < 0:
|
|
271
|
+
return 0
|
|
272
|
+
# Bonus for boundary: start of string, or after a non-alphanumeric separator
|
|
273
|
+
if found == 0 or not name[found - 1].isalnum():
|
|
274
|
+
score += 10
|
|
275
|
+
pos = found + 1
|
|
276
|
+
return max(1, score)
|
|
277
|
+
|
|
278
|
+
# -------------------------------------------------------------- actions
|
|
279
|
+
def open_lane(self, entry, slot=None):
|
|
280
|
+
if entry.get('divider'):
|
|
281
|
+
return
|
|
282
|
+
slot = slot or self.slot
|
|
283
|
+
if slot not in SLOTS[:self.viewports]:
|
|
284
|
+
self.message = 'this view has no such viewport (open a wide view: lanes-window w2)'
|
|
285
|
+
self.dirty = True
|
|
286
|
+
return
|
|
287
|
+
if entry.get('action') == 'new-shell':
|
|
288
|
+
self.new_shell(slot)
|
|
289
|
+
return
|
|
290
|
+
target_file, pid_file = self.slot_file(slot, 'target'), self.slot_file(slot, 'pid')
|
|
291
|
+
if self.slot_target(slot) == entry['id']:
|
|
292
|
+
self.focus_lane(slot)
|
|
293
|
+
self.filter, self.filtering, self.dirty = '', False, True
|
|
294
|
+
return
|
|
295
|
+
os.makedirs(self.view_dir, exist_ok=True)
|
|
296
|
+
tmp = target_file + f'.{os.getpid()}.tmp'
|
|
297
|
+
with open(tmp, 'w') as handle:
|
|
298
|
+
handle.write(entry['id'] + '\n')
|
|
299
|
+
os.replace(tmp, target_file)
|
|
300
|
+
try:
|
|
301
|
+
with open(pid_file) as handle:
|
|
302
|
+
pid = int(handle.read().strip())
|
|
303
|
+
with open(f'/proc/{pid}/cmdline', 'rb') as handle:
|
|
304
|
+
if b'view.sh' in handle.read():
|
|
305
|
+
os.kill(pid, signal.SIGUSR1)
|
|
306
|
+
except (OSError, ValueError):
|
|
307
|
+
self.message = 'view not running yet'
|
|
308
|
+
self.focus_lane(slot)
|
|
309
|
+
self.filter, self.filtering = '', False
|
|
310
|
+
self.dirty = True
|
|
311
|
+
|
|
312
|
+
def new_shell(self, slot=None):
|
|
313
|
+
self.message = 'Creating shell…'
|
|
314
|
+
self.render()
|
|
315
|
+
try:
|
|
316
|
+
entry = A.new_shell(self.workspace)
|
|
317
|
+
self.unregistered.append(entry)
|
|
318
|
+
self.status[entry['id']] = {'state': 'quiet'}
|
|
319
|
+
self.open_lane(entry, slot)
|
|
320
|
+
self.message = 'Created ' + entry['name']
|
|
321
|
+
except (L.LanesError, OSError, ValueError) as error:
|
|
322
|
+
self.message = str(error)
|
|
323
|
+
self.dirty = True
|
|
324
|
+
|
|
325
|
+
def close_lane(self, entry):
|
|
326
|
+
if not entry.get('closable'):
|
|
327
|
+
return
|
|
328
|
+
self.message = 'Closing ' + entry['name'] + '…'
|
|
329
|
+
self.render()
|
|
330
|
+
try:
|
|
331
|
+
A.close_session(self.workspace, entry)
|
|
332
|
+
self.unregistered = [e for e in self.unregistered if e['id'] != entry['id']]
|
|
333
|
+
self.status.pop(entry['id'], None)
|
|
334
|
+
self.message = 'Closed ' + entry['name']
|
|
335
|
+
except (L.LanesError, OSError, ValueError) as error:
|
|
336
|
+
self.message = str(error)
|
|
337
|
+
self.dirty = True
|
|
338
|
+
|
|
339
|
+
def start_rename(self, entry):
|
|
340
|
+
"""Begin inline rename of the lane under the cursor."""
|
|
341
|
+
if entry.get('divider') or entry.get('action'):
|
|
342
|
+
return
|
|
343
|
+
self.renaming = True
|
|
344
|
+
self.rename_entry = entry
|
|
345
|
+
self.rename_value = entry.get('name', '')
|
|
346
|
+
self.dirty = True
|
|
347
|
+
|
|
348
|
+
def commit_rename(self):
|
|
349
|
+
"""Confirm the in-progress rename."""
|
|
350
|
+
entry = self.rename_entry
|
|
351
|
+
new_name = self.rename_value.strip()
|
|
352
|
+
self.renaming, self.rename_entry, self.rename_value = False, None, ''
|
|
353
|
+
if not new_name or not entry:
|
|
354
|
+
self.dirty = True
|
|
355
|
+
return
|
|
356
|
+
try:
|
|
357
|
+
if entry.get('closable') or entry.get('id', '').find('--x-') >= 0:
|
|
358
|
+
# Unregistered session
|
|
359
|
+
updated = A.rename_session(self.workspace, entry, new_name)
|
|
360
|
+
for lst in (self.unregistered, self.zmx):
|
|
361
|
+
for i, e in enumerate(lst):
|
|
362
|
+
if e['id'] == entry['id']:
|
|
363
|
+
lst[i] = dict(e, name=new_name)
|
|
364
|
+
self.message = f'Renamed → {new_name}'
|
|
365
|
+
else:
|
|
366
|
+
# Registered lane: write displayNames override to the manifest + sync
|
|
367
|
+
source = entry.get('source')
|
|
368
|
+
registry_name = entry.get('registry_name') or entry.get('name', '')
|
|
369
|
+
if not source:
|
|
370
|
+
raise L.LanesError('No manifest source for this lane; edit terminals.json directly')
|
|
371
|
+
A.rename_registered(source, registry_name, new_name)
|
|
372
|
+
# Trigger a registry rebuild
|
|
373
|
+
import subprocess as _sp
|
|
374
|
+
_sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
375
|
+
'bin', 'lanes'), 'sync'],
|
|
376
|
+
stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
|
|
377
|
+
self.message = f'Renamed → {new_name} (sync triggered)'
|
|
378
|
+
except (L.LanesError, OSError, ValueError) as error:
|
|
379
|
+
self.message = str(error)
|
|
380
|
+
self.dirty = True
|
|
381
|
+
|
|
382
|
+
def start_promote(self, entry):
|
|
383
|
+
"""Begin promotion confirmation for an unregistered session."""
|
|
384
|
+
if entry.get('divider') or entry.get('action'):
|
|
385
|
+
return
|
|
386
|
+
if not entry.get('closable') and entry.get('holder') != 'tmux':
|
|
387
|
+
self.message = 'Only tmux sessions can be promoted'
|
|
388
|
+
self.dirty = True
|
|
389
|
+
return
|
|
390
|
+
self.promoting = True
|
|
391
|
+
self.promote_entry = entry
|
|
392
|
+
self.dirty = True
|
|
393
|
+
|
|
394
|
+
def commit_promote(self):
|
|
395
|
+
"""Confirm the in-progress promotion (caller has already checked 'y')."""
|
|
396
|
+
entry = self.promote_entry
|
|
397
|
+
self.promoting, self.promote_entry = False, None
|
|
398
|
+
if not entry:
|
|
399
|
+
self.dirty = True
|
|
400
|
+
return
|
|
401
|
+
try:
|
|
402
|
+
registry = read_json(self.registry_path) or {}
|
|
403
|
+
A.promote_to_registered(self.workspace, entry, registry)
|
|
404
|
+
# Trigger a registry rebuild so the lane moves from unregistered → registered
|
|
405
|
+
import subprocess as _sp
|
|
406
|
+
_sp.Popen([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
407
|
+
'bin', 'lanes'), 'sync'],
|
|
408
|
+
stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
|
|
409
|
+
self.unregistered = [e for e in self.unregistered if e['id'] != entry['id']]
|
|
410
|
+
self.message = f'Promoted — reload in a moment'
|
|
411
|
+
self.reload(force=True)
|
|
412
|
+
except (L.LanesError, OSError, ValueError) as error:
|
|
413
|
+
self.message = str(error)
|
|
414
|
+
self.dirty = True
|
|
415
|
+
|
|
416
|
+
def spools(self):
|
|
417
|
+
"""Where an upload request may be spooled, best first. A suffixed view (mac-2, mac-w2) belongs to the machine
|
|
418
|
+
whose base device (mac) `lanes attach` recorded in devices/<device>.json: that machine's ONE helper serves
|
|
419
|
+
the base spool. The view's own spool stays as the fallback (an older client runs a helper per window).
|
|
420
|
+
Read per drop, not at start: the base is learnt at the first attach, and this sidebar is older than that."""
|
|
421
|
+
own = os.path.join(STATE, 'upload-requests', self.device)
|
|
422
|
+
meta = read_json(os.path.join(STATE, 'devices', self.device + '.json'))
|
|
423
|
+
base = meta.get('base') if isinstance(meta, dict) else None
|
|
424
|
+
if isinstance(base, str) and base and self.device.startswith(base + '-'): # a prefix of our own, valid name
|
|
425
|
+
return [os.path.join(STATE, 'upload-requests', base), own]
|
|
426
|
+
return [own]
|
|
427
|
+
|
|
428
|
+
def dropped(self, text):
|
|
429
|
+
"""A drop arrives as a paste of local path(s). Ask this device's upload helper to send them."""
|
|
430
|
+
self.armed = False
|
|
431
|
+
try:
|
|
432
|
+
tokens = shlex.split(text.strip())
|
|
433
|
+
except ValueError:
|
|
434
|
+
tokens = text.split()
|
|
435
|
+
paths = []
|
|
436
|
+
for token in tokens:
|
|
437
|
+
if token.startswith('file://'):
|
|
438
|
+
token = re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), token[7:])
|
|
439
|
+
if token.startswith('/') and '\n' not in token:
|
|
440
|
+
paths.append(token)
|
|
441
|
+
if not paths or len(paths) != len(tokens):
|
|
442
|
+
self.upload_note, self.upload_note_at = 'that was not a file drop', time.time()
|
|
443
|
+
return
|
|
444
|
+
spool = next((s for s in self.spools() if time.time() - mtime(os.path.join(s, 'agent.alive')) / 1e9 <= 12), None)
|
|
445
|
+
if not spool: # no helper is serving either spool
|
|
446
|
+
self.upload_note, self.upload_note_at = 'no upload helper on this device (reopen lanes-launch)', time.time()
|
|
447
|
+
return
|
|
448
|
+
request = f'{int(time.time() * 1000)}-{os.getpid()}'
|
|
449
|
+
# The sidecar names the view that asked — the device whose lane gets the paste (`lanes upload --request`).
|
|
450
|
+
# Written before the .req exists: a helper takes a .req the moment it appears.
|
|
451
|
+
with open(os.path.join(spool, f'{request}.dev'), 'w') as handle:
|
|
452
|
+
handle.write(self.device + '\n')
|
|
453
|
+
tmp = os.path.join(spool, f'.{request}.tmp')
|
|
454
|
+
with open(tmp, 'w') as handle:
|
|
455
|
+
handle.write('\n'.join([self.workspace, '1'] + paths) + '\n')
|
|
456
|
+
os.replace(tmp, os.path.join(spool, f'{request}.req'))
|
|
457
|
+
self.upload = {'id': request, 'spool': spool, 'names': [os.path.basename(p) for p in paths], 'at': time.time()}
|
|
458
|
+
|
|
459
|
+
def poll_upload(self):
|
|
460
|
+
if not self.upload:
|
|
461
|
+
return
|
|
462
|
+
base = os.path.join(self.upload['spool'], self.upload['id']) # the results land where the request went
|
|
463
|
+
progress_at = mtime(base + '.progress') / 1e9
|
|
464
|
+
if progress_at > self.upload.get('progress_at', 0):
|
|
465
|
+
progress = read_json(base + '.progress') or {}
|
|
466
|
+
self.upload.update(progress_at=progress_at, bytes=progress.get('bytes', 0))
|
|
467
|
+
note = None
|
|
468
|
+
if os.path.exists(base + '.done'):
|
|
469
|
+
note = '✓ ' + ', '.join(self.upload['names'])[:60]
|
|
470
|
+
self.focus_lane()
|
|
471
|
+
elif os.path.exists(base + '.err'):
|
|
472
|
+
try:
|
|
473
|
+
with open(base + '.err') as handle:
|
|
474
|
+
note = '✖ ' + handle.readline().strip()[:70]
|
|
475
|
+
except OSError:
|
|
476
|
+
note = '✖ upload failed'
|
|
477
|
+
elif time.time() - max(self.upload['at'], progress_at) > 120:
|
|
478
|
+
note = '✖ upload timed out'
|
|
479
|
+
if note:
|
|
480
|
+
for suffix in ('.done', '.err', '.sent', '.req', '.dev', '.progress'):
|
|
481
|
+
try:
|
|
482
|
+
os.unlink(base + suffix)
|
|
483
|
+
except OSError:
|
|
484
|
+
pass
|
|
485
|
+
self.upload, self.upload_note, self.upload_note_at, self.dirty = None, note, time.time(), True
|
|
486
|
+
|
|
487
|
+
def focus_lane(self, slot=None):
|
|
488
|
+
if os.environ.get('ZELLIJ'):
|
|
489
|
+
actions = ([['go-to-tab-name', self.title]] if self.mobile else
|
|
490
|
+
[['move-focus', 'right']] * (SLOTS.index(slot or self.slot) + 1))
|
|
491
|
+
for action in actions:
|
|
492
|
+
try:
|
|
493
|
+
subprocess.run([os.environ.get('LANES_ZELLIJ') or 'zellij', 'action', *action], timeout=3,
|
|
494
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
495
|
+
except (OSError, subprocess.SubprocessError):
|
|
496
|
+
break
|
|
497
|
+
|
|
498
|
+
def box_lines(self, cols):
|
|
499
|
+
"""The upload box: BOX_ROWS lines, last rows of the sidebar."""
|
|
500
|
+
inner = max(4, cols - 2)
|
|
501
|
+
if self.upload:
|
|
502
|
+
spin = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[int(time.time() * 8) % 10]
|
|
503
|
+
body, style = [f'{spin} sending {len(self.upload["names"])} file(s)…', ', '.join(self.upload['names'])], '33'
|
|
504
|
+
if self.upload.get('bytes'):
|
|
505
|
+
body[1] = f'{self.upload["bytes"] / 1048576:.1f} MiB · ' + body[1]
|
|
506
|
+
elif self.armed:
|
|
507
|
+
body, style = ['⬇ DROP FILES NOW', 'anywhere on this window · Esc'], '1;32'
|
|
508
|
+
elif self.upload_note and time.time() - self.upload_note_at < 8:
|
|
509
|
+
body, style = [self.upload_note, f'→ {self.workspace}/uploads'], '36'
|
|
510
|
+
else:
|
|
511
|
+
body, style = ['⬆ uploads — click, then drop', f'→ {self.workspace}/uploads (U)'], '2'
|
|
512
|
+
lines = [f'\033[{style}m┌{"─" * inner}┐\033[0m']
|
|
513
|
+
for text in body[:BOX_ROWS - 2]:
|
|
514
|
+
text, used = clip(' ' + text, inner)
|
|
515
|
+
lines.append(f'\033[{style}m│\033[0m\033[{style}m{text}{" " * (inner - used)}\033[0m\033[{style}m│\033[0m')
|
|
516
|
+
lines.append(f'\033[{style}m└{"─" * inner}┘\033[0m')
|
|
517
|
+
return lines
|
|
518
|
+
|
|
519
|
+
# ------------------------------------------------------------ rendering
|
|
520
|
+
def render(self):
|
|
521
|
+
try:
|
|
522
|
+
cols, rows = os.get_terminal_size(sys.stdout.fileno())
|
|
523
|
+
except OSError:
|
|
524
|
+
cols, rows = 36, 24
|
|
525
|
+
self.size = (cols, rows)
|
|
526
|
+
if self.mobile:
|
|
527
|
+
self.render_mobile(cols, rows)
|
|
528
|
+
return
|
|
529
|
+
entries = self.visible()
|
|
530
|
+
self.box = rows >= 14
|
|
531
|
+
body = max(1, rows - 3 - (BOX_ROWS if self.box else 0))
|
|
532
|
+
self.box_top = 2 + body # 1-based screen row where the box starts
|
|
533
|
+
self.cursor = max(0, min(self.cursor, len(entries) - 1))
|
|
534
|
+
if self.cursor < self.top:
|
|
535
|
+
self.top = self.cursor
|
|
536
|
+
if self.cursor >= self.top + body:
|
|
537
|
+
self.top = self.cursor - body + 1
|
|
538
|
+
target = self.current_target()
|
|
539
|
+
others = {slot: self.slot_target(slot) for slot in SLOTS[1:self.viewports]}
|
|
540
|
+
out = ['\033[?25l\033[H']
|
|
541
|
+
counts = sum(1 for e in self.entries if self.status.get(e['id'], {}).get('state') == 'active')
|
|
542
|
+
header, used = clip(f' {self.title}', cols - 8)
|
|
543
|
+
out.append(f'\033[1m{header}\033[0m\033[2m{" " * max(1, cols - used - 7)}{counts:>2}/{len(self.entries):<3}\033[0m\033[K\r\n')
|
|
544
|
+
for row in range(body):
|
|
545
|
+
index = self.top + row
|
|
546
|
+
if index >= len(entries):
|
|
547
|
+
out.append('\033[K\r\n')
|
|
548
|
+
continue
|
|
549
|
+
entry = entries[index]
|
|
550
|
+
if entry.get('divider'):
|
|
551
|
+
label, used_cols = clip(f'── {entry["name"]} ', cols - 1)
|
|
552
|
+
out.append(f'\033[2m{label}{"─" * max(0, cols - 1 - used_cols)}\033[0m\033[K\r\n')
|
|
553
|
+
continue
|
|
554
|
+
if entry.get('action'):
|
|
555
|
+
marker = '▌' if index == self.cursor else ' '
|
|
556
|
+
label, _ = clip(marker + ' New +', cols - 1)
|
|
557
|
+
out.append(f'\033[32m{label}\033[0m\033[K\r\n')
|
|
558
|
+
continue
|
|
559
|
+
observed = self.status.get(entry['id'], {})
|
|
560
|
+
state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
|
|
561
|
+
glyph, glyph_color = GLYPHS.get(state, GLYPHS['unknown'])
|
|
562
|
+
# stale = statusd got no answer about this lane and republished its LAST state: same glyph, dimmed, and a
|
|
563
|
+
# '?' in the spare cell after it (no cell is added: rows, clicks and the upload box stay where they are).
|
|
564
|
+
tail = ' '
|
|
565
|
+
if observed.get('stale') and glyph != '?':
|
|
566
|
+
glyph_color, tail = '2;' + glyph_color, '\033[2m?'
|
|
567
|
+
is_target, is_cursor = entry['id'] == target, index == self.cursor
|
|
568
|
+
shown = ''.join(mark for slot, mark in zip(SLOTS[1:self.viewports], '²³') if others.get(slot) == entry['id'])
|
|
569
|
+
shown += viewer_mark(self.status.get(entry['id']))
|
|
570
|
+
marker = '▌' if is_cursor else ' '
|
|
571
|
+
icon = entry.get('icon') or '💻'
|
|
572
|
+
icon_pad = ' ' * (3 - cell_width(icon))
|
|
573
|
+
name_width = max(1, cols - (12 if entry.get('closable') else 9))
|
|
574
|
+
# Status age suffix: show how long ago the lane was last active (quiet lanes only, compact)
|
|
575
|
+
age_suffix = ''
|
|
576
|
+
if state == 'quiet':
|
|
577
|
+
activity = observed.get('activity')
|
|
578
|
+
if activity:
|
|
579
|
+
age_s = int(time.time() - activity)
|
|
580
|
+
if age_s >= 60:
|
|
581
|
+
age_suffix = f' {age_s // 60}m'
|
|
582
|
+
elif age_s >= 10:
|
|
583
|
+
age_suffix = f' {age_s}s'
|
|
584
|
+
name, name_used = clip(entry.get('name', entry['id']) + shown + age_suffix, name_width)
|
|
585
|
+
color = entry.get('color')
|
|
586
|
+
style = ('1;' if is_target else '') + ('4;' if is_cursor else '')
|
|
587
|
+
fg = f'38;5;{color}' if isinstance(color, int) else '39'
|
|
588
|
+
background = '' # theme-agnostic: the cursor row is marked by ▌ + underline, not a fixed color
|
|
589
|
+
pad = ' ' * max(0, name_width - name_used)
|
|
590
|
+
close = f'\033[{max(1, cols - 2)}G\033[31m×\033[0m' if entry.get('closable') else ''
|
|
591
|
+
out.append(f'{background}\033[36m{marker}\033[39m{icon}{icon_pad}\033[{style}{fg}m{name}\033[22;24;39m{pad} '
|
|
592
|
+
f'\033[{glyph_color}m{glyph}\033[0m{background}{tail}\033[0m{close}\033[K\r\n')
|
|
593
|
+
footer_style = '2'
|
|
594
|
+
if self.renaming:
|
|
595
|
+
footer = f' Rename: {self.rename_value}█'
|
|
596
|
+
footer_style = '36'
|
|
597
|
+
elif self.promoting:
|
|
598
|
+
name = self.promote_entry.get('name', '') if self.promote_entry else ''
|
|
599
|
+
footer = f' Promote {name!r} to registered? y/n'
|
|
600
|
+
footer_style = '33'
|
|
601
|
+
elif self.filtering or self.filter:
|
|
602
|
+
footer = f' /{self.filter}'
|
|
603
|
+
elif self.banner: # above everything but the filter being typed: a frozen picture must never look live
|
|
604
|
+
footer, footer_style = ' ' + self.banner, '33'
|
|
605
|
+
elif self.errors:
|
|
606
|
+
footer = ' ⚠ registry: ' + self.errors[0]
|
|
607
|
+
elif self.message:
|
|
608
|
+
footer = ' ' + self.message
|
|
609
|
+
else:
|
|
610
|
+
footer = ' ⏎ open · + new · x close · R rename · / find'
|
|
611
|
+
footer, _ = clip(footer, cols - 1)
|
|
612
|
+
if self.box:
|
|
613
|
+
for line in self.box_lines(cols):
|
|
614
|
+
out.append(line + '\033[K\r\n')
|
|
615
|
+
out.append(f'\033[2m{"─" * cols}\033[0m\r\n\033[{footer_style}m{footer}\033[0m\033[K\033[J')
|
|
616
|
+
sys.stdout.write(''.join(out))
|
|
617
|
+
sys.stdout.flush()
|
|
618
|
+
self.dirty = False
|
|
619
|
+
|
|
620
|
+
def render_mobile(self, cols, rows):
|
|
621
|
+
"""Three terminal rows per lane make the entire row a finger-sized touch target."""
|
|
622
|
+
entries = self.visible()
|
|
623
|
+
self.box = False # the Mac file-drop helper is not available in a phone browser
|
|
624
|
+
self.entry_top, self.entry_height = 3, 3 if rows >= 8 else 1
|
|
625
|
+
body = max(1, (rows - 3) // self.entry_height)
|
|
626
|
+
self.box_top = self.entry_top + body * self.entry_height
|
|
627
|
+
self.cursor = max(0, min(self.cursor, len(entries) - 1))
|
|
628
|
+
self.top = max(0, min(self.top, self.cursor))
|
|
629
|
+
if self.cursor >= self.top + body:
|
|
630
|
+
self.top = self.cursor - body + 1
|
|
631
|
+
target = self.current_target()
|
|
632
|
+
out = ['\033[?25l\033[H']
|
|
633
|
+
|
|
634
|
+
def line(value='', style='0', closable=False):
|
|
635
|
+
value, _ = clip(value, max(1, cols - (5 if closable else 1)))
|
|
636
|
+
close = f'\033[{max(1, cols - 2)}G\033[31m×\033[0m' if closable else ''
|
|
637
|
+
out.append(f'\033[{style}m{value}\033[0m{close}\033[K\r\n')
|
|
638
|
+
|
|
639
|
+
count = sum(not e.get('divider') and not e.get('action') for e in entries)
|
|
640
|
+
line(f' LANES · {count}', '1;36')
|
|
641
|
+
line()
|
|
642
|
+
for index in range(self.top, self.top + body):
|
|
643
|
+
if index >= len(entries):
|
|
644
|
+
for _ in range(self.entry_height):
|
|
645
|
+
line()
|
|
646
|
+
continue
|
|
647
|
+
entry = entries[index]
|
|
648
|
+
observed = self.status.get(entry['id'], {})
|
|
649
|
+
lane_state = observed.get('state', 'helper' if entry.get('holder') == 'exec' else 'unknown')
|
|
650
|
+
selected = entry['id'] == target
|
|
651
|
+
marker = '›' if index == self.cursor else ' '
|
|
652
|
+
if entry.get('divider'):
|
|
653
|
+
line(' ' + entry['name'], '2')
|
|
654
|
+
elif entry.get('action'):
|
|
655
|
+
line(f'{marker} New +', '1;32')
|
|
656
|
+
else:
|
|
657
|
+
# The viewer dots are the sidebar's rule, not the desktop render's: the phone is the one
|
|
658
|
+
# surface where the operator cannot also see the Studio tab, so a lane a browser views has
|
|
659
|
+
# to say so HERE too (DEF-983).
|
|
660
|
+
line(f'{marker} {entry.get("icon") or "▧"} {entry.get("name", entry["id"])}'
|
|
661
|
+
+ viewer_mark(observed),
|
|
662
|
+
'1;32' if selected or entry.get('action') else '1', entry.get('closable', False))
|
|
663
|
+
if self.entry_height > 1:
|
|
664
|
+
label = MOBILE_LABELS.get(lane_state, lane_state)
|
|
665
|
+
detail = ('Viewing · ' if selected else '') + label
|
|
666
|
+
if entry.get('action'):
|
|
667
|
+
detail = 'Create a persistent shell'
|
|
668
|
+
if observed.get('stale'):
|
|
669
|
+
detail += ' · status delayed'
|
|
670
|
+
line(' ' + detail if not entry.get('divider') else '', '2')
|
|
671
|
+
line()
|
|
672
|
+
for _ in range(max(0, rows - (2 + body * self.entry_height + 1))):
|
|
673
|
+
line()
|
|
674
|
+
footer = (' /' + self.filter if self.filtering or self.filter else
|
|
675
|
+
' ' + self.banner if self.banner else
|
|
676
|
+
' ' + self.errors[0] if self.errors else
|
|
677
|
+
' ' + self.message if self.message else ' Swipe to scroll · Tap to open')
|
|
678
|
+
footer, _ = clip(footer, max(1, cols - 1))
|
|
679
|
+
out.append(f'\033[2m{footer}\033[0m\033[K\033[J')
|
|
680
|
+
sys.stdout.write(''.join(out))
|
|
681
|
+
sys.stdout.flush()
|
|
682
|
+
self.dirty = False
|
|
683
|
+
|
|
684
|
+
# ---------------------------------------------------------------- input
|
|
685
|
+
def handle(self, data):
|
|
686
|
+
# Bracketed paste (a drop, or a real paste) can span several reads: buffer until its end marker.
|
|
687
|
+
if self.paste_buffer is not None:
|
|
688
|
+
self.paste_buffer += data
|
|
689
|
+
data = ''
|
|
690
|
+
elif PASTE_START in data:
|
|
691
|
+
before, _, after = data.partition(PASTE_START)
|
|
692
|
+
self.paste_buffer, data = after, before
|
|
693
|
+
if self.paste_buffer is not None and PASTE_END in self.paste_buffer:
|
|
694
|
+
pasted, _, tail = self.paste_buffer.partition(PASTE_END)
|
|
695
|
+
self.paste_buffer = None
|
|
696
|
+
self.dropped(pasted)
|
|
697
|
+
self.dirty = True
|
|
698
|
+
data += tail
|
|
699
|
+
entries = self.visible()
|
|
700
|
+
i = 0
|
|
701
|
+
while i < len(data):
|
|
702
|
+
ch = data[i]
|
|
703
|
+
if ch == '\033':
|
|
704
|
+
rest = data[i + 1:]
|
|
705
|
+
if rest.startswith('[<'): # SGR mouse
|
|
706
|
+
end = next((k for k, c in enumerate(rest) if c in 'Mm'), -1)
|
|
707
|
+
if end < 0:
|
|
708
|
+
break
|
|
709
|
+
try:
|
|
710
|
+
button, _x, y = (int(v) for v in rest[2:end].split(';'))
|
|
711
|
+
except ValueError:
|
|
712
|
+
button, y = -1, 0
|
|
713
|
+
pressed = rest[end] == 'M'
|
|
714
|
+
if button == 64:
|
|
715
|
+
self.cursor -= 3
|
|
716
|
+
elif button == 65:
|
|
717
|
+
self.cursor += 3
|
|
718
|
+
elif button == 0 and pressed and self.box and y >= self.box_top:
|
|
719
|
+
self.armed = True # the click also gave this pane the focus: the next drop lands here
|
|
720
|
+
elif button == 0 and pressed:
|
|
721
|
+
index = self.top + (y - self.entry_top) // self.entry_height
|
|
722
|
+
if 0 <= index < len(entries) and self.entry_top <= y < self.box_top:
|
|
723
|
+
self.cursor = index
|
|
724
|
+
if entries[index].get('closable') and _x >= max(1, self.size[0] - 4):
|
|
725
|
+
self.close_lane(entries[index])
|
|
726
|
+
else:
|
|
727
|
+
self.open_lane(entries[index])
|
|
728
|
+
i += end + 2
|
|
729
|
+
self.dirty = True
|
|
730
|
+
continue
|
|
731
|
+
if rest[:2] in ('[A', 'OA'):
|
|
732
|
+
self.cursor -= 1; i += 3
|
|
733
|
+
elif rest[:2] in ('[B', 'OB'):
|
|
734
|
+
self.cursor += 1; i += 3
|
|
735
|
+
elif rest[:2] in ('[C', 'OC'):
|
|
736
|
+
if entries:
|
|
737
|
+
self.open_lane(entries[self.cursor])
|
|
738
|
+
i += 3
|
|
739
|
+
elif rest[:2] == '[5':
|
|
740
|
+
self.cursor -= 10; i += 4
|
|
741
|
+
elif rest[:2] == '[6':
|
|
742
|
+
self.cursor += 10; i += 4
|
|
743
|
+
elif rest[:1] == '[': # any other CSI: skip to its final byte
|
|
744
|
+
end = next((k for k, c in enumerate(rest[1:], 1) if '@' <= c <= '~'), len(rest) - 1)
|
|
745
|
+
i += end + 2
|
|
746
|
+
else:
|
|
747
|
+
if self.armed:
|
|
748
|
+
self.armed = False
|
|
749
|
+
self.focus_lane()
|
|
750
|
+
if self.renaming:
|
|
751
|
+
self.renaming, self.rename_entry, self.rename_value = False, None, ''
|
|
752
|
+
self.message = 'Cancelled'
|
|
753
|
+
elif self.promoting:
|
|
754
|
+
self.promoting, self.promote_entry = False, None
|
|
755
|
+
self.message = 'Cancelled'
|
|
756
|
+
self.filter, self.filtering = '', False
|
|
757
|
+
i += 1
|
|
758
|
+
self.dirty = True
|
|
759
|
+
continue
|
|
760
|
+
if self.renaming:
|
|
761
|
+
if ch in '\r\n':
|
|
762
|
+
self.commit_rename()
|
|
763
|
+
elif ch in '\x7f\x08':
|
|
764
|
+
self.rename_value = self.rename_value[:-1]
|
|
765
|
+
elif ch == '\x1b': # handled above; here for completeness
|
|
766
|
+
pass
|
|
767
|
+
elif ch.isprintable():
|
|
768
|
+
self.rename_value += ch
|
|
769
|
+
elif self.promoting:
|
|
770
|
+
if ch in 'yY':
|
|
771
|
+
self.commit_promote()
|
|
772
|
+
else:
|
|
773
|
+
self.promoting, self.promote_entry = False, None
|
|
774
|
+
self.message = 'Cancelled'
|
|
775
|
+
elif self.filtering:
|
|
776
|
+
if ch in '\r\n':
|
|
777
|
+
self.filtering = False
|
|
778
|
+
if entries:
|
|
779
|
+
self.open_lane(entries[min(self.cursor, len(entries) - 1)])
|
|
780
|
+
elif ch in '\x7f\x08':
|
|
781
|
+
self.filter = self.filter[:-1]
|
|
782
|
+
elif ch.isprintable():
|
|
783
|
+
self.filter += ch
|
|
784
|
+
self.cursor = 0
|
|
785
|
+
elif ch in '\r\n ':
|
|
786
|
+
if entries:
|
|
787
|
+
self.open_lane(entries[self.cursor])
|
|
788
|
+
elif ch in '23' and self.viewports > 1:
|
|
789
|
+
if entries:
|
|
790
|
+
self.open_lane(entries[self.cursor], SLOTS[int(ch) - 1])
|
|
791
|
+
elif ch in 'JK': # open the next / previous lane relative to the one being shown
|
|
792
|
+
lanes = [e for e in entries if not e.get('divider')]
|
|
793
|
+
if lanes:
|
|
794
|
+
ids = [e['id'] for e in lanes]
|
|
795
|
+
target = self.current_target()
|
|
796
|
+
index = ids.index(target) if target in ids else -1
|
|
797
|
+
entry = lanes[(index + (1 if ch == 'J' else -1)) % len(lanes)]
|
|
798
|
+
self.cursor = entries.index(entry)
|
|
799
|
+
self.open_lane(entry)
|
|
800
|
+
elif ch == 'j':
|
|
801
|
+
self.cursor += 1
|
|
802
|
+
elif ch == 'k':
|
|
803
|
+
self.cursor -= 1
|
|
804
|
+
elif ch == 'g':
|
|
805
|
+
self.cursor = 0
|
|
806
|
+
elif ch == 'G':
|
|
807
|
+
self.cursor = len(entries) - 1
|
|
808
|
+
elif ch == '/':
|
|
809
|
+
self.filtering, self.filter, self.cursor = True, '', 0
|
|
810
|
+
elif ch == 'U':
|
|
811
|
+
self.armed = True
|
|
812
|
+
elif ch == '+':
|
|
813
|
+
self.new_shell()
|
|
814
|
+
elif ch == 'x' and entries:
|
|
815
|
+
self.close_lane(entries[min(self.cursor, len(entries) - 1)])
|
|
816
|
+
elif ch == 'R' and entries:
|
|
817
|
+
entry = entries[min(self.cursor, len(entries) - 1)]
|
|
818
|
+
self.start_rename(entry)
|
|
819
|
+
elif ch == 'P' and entries:
|
|
820
|
+
entry = entries[min(self.cursor, len(entries) - 1)]
|
|
821
|
+
self.start_promote(entry)
|
|
822
|
+
elif ch == 'r':
|
|
823
|
+
self.reload(force=True)
|
|
824
|
+
self.dirty = True
|
|
825
|
+
i += 1
|
|
826
|
+
|
|
827
|
+
def run(self):
|
|
828
|
+
fd = sys.stdin.fileno()
|
|
829
|
+
saved = termios.tcgetattr(fd)
|
|
830
|
+
try:
|
|
831
|
+
tty.setraw(fd)
|
|
832
|
+
sys.stdout.write('\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[2J')
|
|
833
|
+
self.reload(force=True)
|
|
834
|
+
target = self.current_target()
|
|
835
|
+
for index, entry in enumerate(self.visible()):
|
|
836
|
+
if entry['id'] == target:
|
|
837
|
+
self.cursor = index
|
|
838
|
+
last_size = None
|
|
839
|
+
while True:
|
|
840
|
+
self.reload()
|
|
841
|
+
self.poll_mobile_view()
|
|
842
|
+
try:
|
|
843
|
+
size = tuple(os.get_terminal_size(sys.stdout.fileno()))
|
|
844
|
+
except OSError:
|
|
845
|
+
size = None
|
|
846
|
+
if size != last_size:
|
|
847
|
+
last_size, self.dirty = size, True
|
|
848
|
+
self.poll_upload()
|
|
849
|
+
self.poll_health()
|
|
850
|
+
if self.upload or self.armed or (self.upload_note and time.time() - self.upload_note_at < 9):
|
|
851
|
+
self.dirty = True
|
|
852
|
+
if self.dirty:
|
|
853
|
+
self.render()
|
|
854
|
+
ready, _, _ = select.select([fd], [], [], 0.15 if self.upload else 1.0)
|
|
855
|
+
if ready:
|
|
856
|
+
data = os.read(fd, 4096)
|
|
857
|
+
if not data:
|
|
858
|
+
time.sleep(1) # pty closed: let the wrapper loop decide
|
|
859
|
+
return
|
|
860
|
+
self.message = ''
|
|
861
|
+
self.handle(data.decode('utf-8', 'replace'))
|
|
862
|
+
finally:
|
|
863
|
+
sys.stdout.write('\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h')
|
|
864
|
+
sys.stdout.flush()
|
|
865
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
if __name__ == '__main__':
|
|
869
|
+
if len(sys.argv) < 3:
|
|
870
|
+
sys.exit('usage: sidebar.py <device> <workspace> [slot]')
|
|
871
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
872
|
+
Sidebar(*sys.argv[1:5]).run()
|