@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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lanes-install.mjs +158 -0
  4. package/lib/plan.mjs +65 -0
  5. package/package.json +37 -0
  6. package/payload/lanes/README.md +152 -0
  7. package/payload/lanes/VERSION +1 -0
  8. package/payload/lanes/bin/lanes +1035 -0
  9. package/payload/lanes/client/ghostty/common.conf +22 -0
  10. package/payload/lanes/client/ghostty/macos.conf +6 -0
  11. package/payload/lanes/client/ghostty/omarchy.conf +19 -0
  12. package/payload/lanes/client/install.sh +16 -0
  13. package/payload/lanes/client/lanes-launch +315 -0
  14. package/payload/lanes/client/lanes-upload +147 -0
  15. package/payload/lanes/client/lanes-window +36 -0
  16. package/payload/lanes/client/macos-helper.applescript +14 -0
  17. package/payload/lanes/client/omarchy/discover.sh +56 -0
  18. package/payload/lanes/client/omarchy/lanes.conf +5 -0
  19. package/payload/lanes/client/omarchy/lanes.desktop +9 -0
  20. package/payload/lanes/client/omarchy/lanes.lua +14 -0
  21. package/payload/lanes/client/ssh.stanza.in +54 -0
  22. package/payload/lanes/container/env.sh +16 -0
  23. package/payload/lanes/container/lane +268 -0
  24. package/payload/lanes/container/lane-run +34 -0
  25. package/payload/lanes/docs/ARCHITECTURE.md +93 -0
  26. package/payload/lanes/docs/HOST-GUARD.md +108 -0
  27. package/payload/lanes/docs/INSTALL.md +192 -0
  28. package/payload/lanes/docs/RUNBOOK.md +385 -0
  29. package/payload/lanes/docs/SHARING.md +124 -0
  30. package/payload/lanes/docs/ZMX-RING0.md +51 -0
  31. package/payload/lanes/docs/images/desktop.png +0 -0
  32. package/payload/lanes/docs/images/iphone.png +0 -0
  33. package/payload/lanes/holders/exec.sh +21 -0
  34. package/payload/lanes/holders/tmux.sh +111 -0
  35. package/payload/lanes/holders/zmx.sh +22 -0
  36. package/payload/lanes/holders.lock +5 -0
  37. package/payload/lanes/install/enable-phone.sh +43 -0
  38. package/payload/lanes/install/enable-web.sh +16 -0
  39. package/payload/lanes/install/host-install.sh +72 -0
  40. package/payload/lanes/install/services.py +61 -0
  41. package/payload/lanes/install.sh +603 -0
  42. package/payload/lanes/libexec/cursor_commit.py +159 -0
  43. package/payload/lanes/libexec/cursor_sync.py +166 -0
  44. package/payload/lanes/libexec/guard.py +231 -0
  45. package/payload/lanes/libexec/lanes_lib.py +610 -0
  46. package/payload/lanes/libexec/legacy_sessions.py +130 -0
  47. package/payload/lanes/libexec/session_actions.py +276 -0
  48. package/payload/lanes/libexec/sidebar +8 -0
  49. package/payload/lanes/libexec/sidebar.py +872 -0
  50. package/payload/lanes/libexec/statusd.py +764 -0
  51. package/payload/lanes/libexec/tmpscan.py +921 -0
  52. package/payload/lanes/libexec/view.sh +179 -0
  53. package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
  54. package/payload/lanes/site/README.md +28 -0
  55. package/payload/lanes/site/beyondwork/known_hosts +1 -0
  56. package/payload/lanes/site/beyondwork/site.conf +11 -0
  57. package/payload/lanes/site/site.example.conf +10 -0
  58. package/payload/lanes/sudo/host-guard.sh +356 -0
  59. package/payload/lanes/sudo/one-time-root.sh +32 -0
  60. package/payload/lanes/systemd/lanes-ensure.service +7 -0
  61. package/payload/lanes/systemd/lanes-ensure.timer +10 -0
  62. package/payload/lanes/systemd/lanes-gc.service +7 -0
  63. package/payload/lanes/systemd/lanes-gc.timer +9 -0
  64. package/payload/lanes/systemd/lanes-guard.service +8 -0
  65. package/payload/lanes/systemd/lanes-guard.timer +10 -0
  66. package/payload/lanes/systemd/lanes-mobile.service +23 -0
  67. package/payload/lanes/systemd/lanes-statusd.service +15 -0
  68. package/payload/lanes/systemd/lanes-sync.service +10 -0
  69. package/payload/lanes/systemd/lanes-sync.timer +10 -0
  70. package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
  71. package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
  72. package/payload/lanes/systemd/lanes-web.service +13 -0
  73. package/payload/lanes/systemd/lanes.slice +11 -0
  74. package/payload/lanes/web/Caddyfile +37 -0
  75. package/payload/lanes/web/README.md +35 -0
  76. package/payload/lanes/web/icon-192.png +0 -0
  77. package/payload/lanes/web/icon.svg +4 -0
  78. package/payload/lanes/web/index.html +30 -0
  79. package/payload/lanes/web/manifest.webmanifest +15 -0
  80. package/payload/lanes/web/mobile.css +301 -0
  81. package/payload/lanes/web/mobile.js +367 -0
  82. package/payload/lanes/workspaces.example/README.md +27 -0
  83. package/payload/lanes/workspaces.example/catalog.json +7 -0
  84. package/payload/lanes/workspaces.example/commit-targets.json +8 -0
  85. package/payload/lanes/workspaces.example/terminals.example.json +19 -0
  86. package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
  87. package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
  88. package/payload/lanes/zellij/config.kdl +360 -0
  89. package/payload/manifest.json +31 -0
@@ -0,0 +1,367 @@
1
+ /* Lanes mobile shell. Uses the public window hooks in the pinned Zellij 0.45.1 client.
2
+ * Authentication, terminal input, rendering, and transport remain in the upstream client. */
3
+ (() => {
4
+ 'use strict';
5
+ const isPhone = /iPhone|iPod|Android.*Mobile/i.test(navigator.userAgent) ||
6
+ (matchMedia('(pointer: coarse)').matches && innerWidth <= 600);
7
+ if (!isPhone) return;
8
+ const root = document.documentElement;
9
+ root.classList.add('lanes-mobile');
10
+
11
+ // Only a browser's native tap/focus on an input may summon the keyboard. Zellij
12
+ // otherwise refocuses a hidden input on touchstart, touchend, click and server
13
+ // SetSoftKeyboard messages. Install before its first terminal or login modal.
14
+ const elementFocus = HTMLElement.prototype.focus;
15
+ HTMLElement.prototype.focus = function(...args) {
16
+ const textInput = this instanceof HTMLInputElement &&
17
+ ['text', 'search', 'email', 'number', 'password', 'tel', 'url'].includes(this.type);
18
+ if (textInput || this instanceof HTMLTextAreaElement || this.isContentEditable) return;
19
+ return elementFocus.apply(this, args);
20
+ };
21
+ Object.defineProperty(window, '__zjSoftKbdEnabled', {
22
+ configurable: false,
23
+ get: () => false,
24
+ set: () => {},
25
+ });
26
+ try {
27
+ const theme = localStorage.getItem('zellij:mobile-theme') || 'dark';
28
+ localStorage.setItem('zellij:mobile-theme', theme);
29
+ root.dataset.zjTheme = theme;
30
+ } catch (_) {}
31
+ const icons = {
32
+ workspaces: '<rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/>',
33
+ lanes: '<path d="M8 5h13M8 12h13M8 19h13"/><path d="M3 5h.01M3 12h.01M3 19h.01"/>',
34
+ panes: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M12 4v16"/>',
35
+ keyboard: '<rect x="2" y="5" width="20" height="14" rx="3"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 13h.01M10 13h.01M14 13h.01M18 13h.01M8 16h8"/>',
36
+ more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
37
+ close: '<path d="m6 6 12 12M6 18 18 6"/>',
38
+ arrow: '<path d="m9 5 7 7-7 7"/>',
39
+ check: '<path d="m5 12 4 4L19 6"/>',
40
+ search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
41
+ };
42
+ const svg = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name]}</svg>`;
43
+ const make = (tag, cls, text) => {
44
+ const el = document.createElement(tag);
45
+ if (cls) el.className = cls;
46
+ if (text !== undefined) el.textContent = text;
47
+ return el;
48
+ };
49
+ const text = (el, value) => { if (el.textContent !== value) el.textContent = value; };
50
+ const button = (label, icon, action) => {
51
+ const el = make('button', 'lanes-button');
52
+ el.type = 'button';
53
+ el.setAttribute('aria-label', label);
54
+ el.innerHTML = svg(icon);
55
+ el.append(make('span', '', label));
56
+ el.addEventListener('click', action);
57
+ return el;
58
+ };
59
+ let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, typingField;
60
+ let laneFilterBar, laneFilterInput, laneFilterPrev = '';
61
+ let api, term, previousCards = '', sheetOpen = false, returnFocus;
62
+ let fontSize = 16;
63
+ try {
64
+ const stored = Number(localStorage.getItem('lanes:font-size'));
65
+ if (stored >= 14 && stored <= 24) fontSize = stored;
66
+ } catch (_) {}
67
+ const state = () => api?.getState();
68
+ const data = () => state()?.data;
69
+ const nativeClick = selector => document.querySelector(selector)?.click();
70
+ const resize = () => window.dispatchEvent(new Event('zellij:rendering-resize'));
71
+
72
+ function viewport() {
73
+ const vv = window.visualViewport;
74
+ root.style.setProperty('--lanes-vh', `${vv?.height ?? innerHeight}px`);
75
+ root.style.setProperty('--lanes-vw', `${vv?.width ?? innerWidth}px`);
76
+ root.style.setProperty('--lanes-offset-top', `${vv?.offsetTop ?? 0}px`);
77
+ root.style.setProperty('--lanes-offset-left', `${vv?.offsetLeft ?? 0}px`);
78
+ // Browser toolbars alone must not put the UI into keyboard mode.
79
+ const occluded = !!vv && innerHeight - vv.height - vv.offsetTop > 120;
80
+ // Zellij's keyboard input lives in a closed shadow root, so activeElement is its host.
81
+ const focused = window.__zjSoftKbdCapture?.isFocused || document.activeElement?.matches('input, textarea');
82
+ setKeyboard(occluded && focused);
83
+ }
84
+ function setKeyboard(visible) {
85
+ if (root.classList.contains('lanes-keyboard') !== !!visible) {
86
+ root.classList.toggle('lanes-keyboard', !!visible);
87
+ resize();
88
+ }
89
+ typingField?.classList.toggle('is-typing', !!window.__zjSoftKbdCapture?.isFocused);
90
+ }
91
+ function buildTypingField() {
92
+ const capture = window.__zjSoftKbdCapture?.element;
93
+ const host = document.getElementById('zj-mobile-capture-host');
94
+ if (!capture || !host) return null;
95
+ const field = make('div', 'lanes-terminal-input');
96
+ const label = make('span', 'lanes-input-label');
97
+ label.innerHTML = svg('keyboard');
98
+ label.append(make('span', '', 'Type here'));
99
+ label.setAttribute('aria-hidden', 'true');
100
+ // Keep the original input and its event handlers inside its closed shadow
101
+ // root (including its protection from password-manager extensions). Make it
102
+ // a real, tappable field instead of a 1px invisible focus target. Keystrokes
103
+ // appear in the terminal; its internal padding/history stays invisible.
104
+ host.removeAttribute('aria-hidden');
105
+ host.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;pointer-events:auto;';
106
+ capture.removeAttribute('aria-hidden');
107
+ capture.setAttribute('aria-label', 'Type in terminal');
108
+ capture.tabIndex = 0;
109
+ capture.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;' +
110
+ 'font-size:16px;border:0;border-radius:12px;padding:0;margin:0;opacity:1;' +
111
+ 'background:transparent;color:transparent;caret-color:transparent;outline:none;' +
112
+ 'pointer-events:auto;user-select:text;-webkit-user-select:text;';
113
+ field.append(label, host);
114
+ capture.addEventListener('focus', () => field.classList.add('is-typing'));
115
+ capture.addEventListener('blur', () => field.classList.remove('is-typing'));
116
+ return field;
117
+ }
118
+ function focusTab(tab) {
119
+ const pane = data()?.panes.find(p => p.tab_position === tab?.position && !p.is_plugin);
120
+ if (!pane) return;
121
+ window.__zjSendControl?.({type: 'FocusPane', pane_id: pane.pane_id, is_plugin: false});
122
+ closeSheet();
123
+ }
124
+ function currentTab() {
125
+ return data()?.tabs.find(t => t.position === data()?.active_pane?.tab_position);
126
+ }
127
+ function workspaces() {
128
+ const tabs = data()?.tabs || [];
129
+ const pickers = tabs.filter(t => t.name.endsWith(' ☰'));
130
+ const names = new Set(pickers.map(t => t.name.slice(0, -2)));
131
+ return [
132
+ ...pickers.map(t => ({tab: t, name: t.name.slice(0, -2), picker: true})),
133
+ ...tabs.filter(t => !t.name.endsWith(' ☰') && !names.has(t.name)).map(t => ({tab: t, name: t.name, picker: false})),
134
+ ];
135
+ }
136
+ function openSheet() {
137
+ returnFocus = document.activeElement;
138
+ window.__zjSoftKbdCapture?.element.blur();
139
+ document.activeElement?.blur();
140
+ sheetOpen = true;
141
+ sheet.hidden = false;
142
+ sheet.setAttribute('aria-modal', 'true');
143
+ document.getElementById('terminal').setAttribute('inert', '');
144
+ search.value = '';
145
+ previousCards = '';
146
+ renderCards();
147
+ sheet.querySelector('button').focus({preventScroll: true});
148
+ }
149
+ function closeSheet() {
150
+ if (!sheetOpen) return;
151
+ sheetOpen = false;
152
+ sheet.hidden = true;
153
+ document.getElementById('terminal').removeAttribute('inert');
154
+ document.activeElement?.blur();
155
+ if (returnFocus?.isConnected && returnFocus.tagName === 'BUTTON') returnFocus.focus({preventScroll: true});
156
+ }
157
+ function renderCards() {
158
+ if (!sheetOpen) return;
159
+ const items = workspaces();
160
+ const activeName = (currentTab()?.name || '').replace(/ ☰$/, '');
161
+ const query = search.value.trim().toLocaleLowerCase();
162
+ const signature = JSON.stringify([items, activeName, query]);
163
+ if (signature === previousCards) return;
164
+ previousCards = signature;
165
+ list.replaceChildren();
166
+ text(count, `${items.length} workspaces`);
167
+ for (const item of items.filter(w => w.name.toLocaleLowerCase().includes(query))) {
168
+ const active = item.name === activeName;
169
+ const card = make('button', `lanes-workspace${active ? ' is-current' : ''}`);
170
+ card.type = 'button';
171
+ card.setAttribute('aria-label', `${item.name}${active ? ', current workspace' : ''}`);
172
+ if (active) card.setAttribute('aria-current', 'true');
173
+ const monogram = make('span', 'lanes-monogram', Array.from(item.name)[0]?.toUpperCase() || 'L');
174
+ const copy = make('span', 'lanes-workspace-copy');
175
+ copy.append(make('strong', '', item.name), make('small', '', item.picker ? 'Choose a lane' : 'Open workspace'));
176
+ const arrow = make('span', 'lanes-workspace-arrow');
177
+ arrow.innerHTML = svg(active ? 'check' : 'arrow');
178
+ card.append(monogram, copy, arrow);
179
+ card.addEventListener('click', () => focusTab(item.tab));
180
+ list.append(card);
181
+ }
182
+ if (!list.childElementCount) list.append(make('p', 'lanes-empty', 'No matching workspaces'));
183
+ }
184
+ function build() {
185
+ shell = make('div', 'lanes-shell zj-mobile-chrome');
186
+ shell.id = 'lanes-shell';
187
+ const header = make('header', 'lanes-header');
188
+ const brand = make('img', 'lanes-brand');
189
+ brand.src = '/lanes-assets/icon.svg'; brand.alt = 'Lanes';
190
+ const context = make('button', 'lanes-context');
191
+ context.type = 'button'; context.setAttribute('aria-label', 'Choose workspace');
192
+ heading = make('strong', '', 'Lanes'); subtitle = make('span', '', 'Connecting…');
193
+ context.append(heading, subtitle); context.addEventListener('click', openSheet);
194
+ const more = button('More options', 'more', () => nativeClick('.zj-hamburger'));
195
+ more.classList.add('lanes-more');
196
+ header.append(brand, context, more);
197
+ dock = make('nav', 'lanes-dock'); dock.setAttribute('aria-label', 'Workspace controls');
198
+ const spaces = button('Workspaces', 'workspaces', openSheet);
199
+ lanesButton = button('Lanes', 'lanes', () => {
200
+ const name = (currentTab()?.name || '').replace(/ ☰$/, '');
201
+ const picker = data()?.tabs.find(t => t.name === `${name} ☰`);
202
+ if (picker) focusTab(picker); else openSheet();
203
+ });
204
+ const panes = button('Panes', 'panes', () => nativeClick('.zj-pane'));
205
+ typingField = buildTypingField();
206
+ laneFilterBar = buildLaneFilterBar();
207
+ dock.append(spaces, lanesButton, panes, laneFilterBar, typingField);
208
+ sheet = make('div', 'lanes-sheet'); sheet.hidden = true;
209
+ sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-labelledby', 'lanes-sheet-title');
210
+ const panel = make('section', 'lanes-sheet-panel');
211
+ const sheetHead = make('div', 'lanes-sheet-head');
212
+ const titleGroup = make('div');
213
+ const title = make('h2', '', 'Workspaces'); title.id = 'lanes-sheet-title';
214
+ count = make('p', '', ''); titleGroup.append(title, count);
215
+ const close = button('Close workspaces', 'close', closeSheet);
216
+ close.classList.add('lanes-close'); sheetHead.append(titleGroup, close);
217
+ const searchWrap = make('label', 'lanes-search');
218
+ searchWrap.innerHTML = svg('search');
219
+ search = make('input'); search.type = 'search'; search.placeholder = 'Find a workspace';
220
+ search.autocomplete = 'off'; search.spellcheck = false;
221
+ search.setAttribute('aria-label', 'Find a workspace');
222
+ search.addEventListener('input', renderCards); searchWrap.append(search);
223
+ list = make('div', 'lanes-workspaces');
224
+ const sessionButton = make('button', 'lanes-session-link', 'Switch session');
225
+ sessionButton.addEventListener('click', () => {closeSheet(); nativeClick('.zj-session');});
226
+ panel.append(sheetHead, searchWrap, list, sessionButton); sheet.append(panel);
227
+ sheet.addEventListener('click', ev => {if (ev.target === sheet) closeSheet();});
228
+ sheet.addEventListener('keydown', ev => {
229
+ if (ev.key === 'Escape') {ev.preventDefault(); closeSheet();}
230
+ if (ev.key !== 'Tab') return;
231
+ const controls = [...sheet.querySelectorAll('button, input')];
232
+ const first = controls[0], last = controls.at(-1);
233
+ if (ev.shiftKey && document.activeElement === first) {ev.preventDefault(); last.focus();}
234
+ else if (!ev.shiftKey && document.activeElement === last) {ev.preventDefault(); first.focus();}
235
+ });
236
+ shell.append(header, dock, sheet); document.body.append(shell);
237
+ root.classList.add('lanes-ready');
238
+ // Upstream cancels pointerdown on extra keys, which also suppresses their
239
+ // touch click in Safari. Cancel mousedown instead: keep typing focus while
240
+ // allowing the native click to activate CTRL, arrows, and the other keys.
241
+ for (const key of document.querySelectorAll('.zj-mobile-modbar button')) {
242
+ key.addEventListener('pointerdown', ev => ev.stopImmediatePropagation(), true);
243
+ key.addEventListener('mousedown', ev => ev.preventDefault());
244
+ }
245
+ installFontControls();
246
+ resize();
247
+ }
248
+ function sendToTerminal(bytes) {
249
+ // Inject bytes into the active Zellij terminal. Try the control API first,
250
+ // then fall back to the keyboard capture element.
251
+ if (window.__zjSendControl) {
252
+ try { window.__zjSendControl({type: 'Write', bytes}); return; } catch (_) {}
253
+ }
254
+ const el = window.__zjSoftKbdCapture?.element;
255
+ if (!el) return;
256
+ const text = String.fromCharCode(...bytes);
257
+ el.focus({preventScroll: true});
258
+ try { document.execCommand('insertText', false, text); } catch (_) {}
259
+ }
260
+ function applyLaneFilter(query) {
261
+ const esc = [0x1b], slash = [0x2f];
262
+ const bytes = [...esc, ...slash, ...Array.from(query).map(c => c.charCodeAt(0) & 0x7f).filter(b => b >= 0x20)];
263
+ sendToTerminal(bytes);
264
+ }
265
+ function clearLaneFilter() {
266
+ sendToTerminal([0x1b]); // Esc clears the sidebar's filter mode
267
+ laneFilterPrev = '';
268
+ if (laneFilterInput) laneFilterInput.value = '';
269
+ }
270
+ function buildLaneFilterBar() {
271
+ const bar = make('div', 'lanes-filter-bar');
272
+ bar.setAttribute('aria-label', 'Filter lanes');
273
+ bar.setAttribute('hidden', '');
274
+ bar.innerHTML = svg('search');
275
+ laneFilterInput = make('input');
276
+ laneFilterInput.type = 'search'; laneFilterInput.placeholder = 'Filter lanes';
277
+ laneFilterInput.autocomplete = 'off'; laneFilterInput.spellcheck = false;
278
+ laneFilterInput.setAttribute('aria-label', 'Filter lanes in this workspace');
279
+ laneFilterInput.addEventListener('input', () => {
280
+ const q = laneFilterInput.value;
281
+ if (q !== laneFilterPrev) { applyLaneFilter(q); laneFilterPrev = q; }
282
+ });
283
+ laneFilterInput.addEventListener('keydown', ev => {
284
+ if (ev.key === 'Escape') { ev.preventDefault(); clearLaneFilter(); laneFilterInput.blur(); }
285
+ if (ev.key === 'Enter') { ev.preventDefault(); laneFilterInput.blur(); }
286
+ });
287
+ bar.append(laneFilterInput);
288
+ return bar;
289
+ }
290
+ function updateLaneFilterBar(isPicker) {
291
+ if (!laneFilterBar) return;
292
+ laneFilterBar.hidden = !isPicker;
293
+ if (!isPicker && laneFilterPrev) clearLaneFilter();
294
+ }
295
+ function applyFont() {
296
+ term.options.fontSize = fontSize;
297
+ term.options.lineHeight = 1.1;
298
+ try {localStorage.setItem('lanes:font-size', String(fontSize));} catch (_) {}
299
+ text(document.getElementById('lanes-font-value'), `${fontSize} px`);
300
+ resize();
301
+ }
302
+ function installFontControls() {
303
+ const row = make('div', 'lanes-font-controls');
304
+ row.append(make('span', '', 'Text size'));
305
+ const smaller = make('button', '', 'A−'), larger = make('button', '', 'A+');
306
+ smaller.setAttribute('aria-label', 'Smaller terminal text');
307
+ larger.setAttribute('aria-label', 'Larger terminal text');
308
+ const value = make('output', '', `${fontSize} px`); value.id = 'lanes-font-value';
309
+ smaller.addEventListener('click', () => {fontSize = Math.max(14, fontSize - 1); applyFont();});
310
+ larger.addEventListener('click', () => {fontSize = Math.min(24, fontSize + 1); applyFont();});
311
+ row.append(smaller, value, larger);
312
+ document.querySelector('.zj-mobile-menu')?.prepend(row);
313
+ }
314
+ function refresh() {
315
+ if (!api || !shell) return;
316
+ const active = !!state()?.active;
317
+ root.classList.toggle('lanes-ready', active);
318
+ shell.hidden = !active;
319
+ const tab = currentTab();
320
+ const name = tab?.name.replace(/ ☰$/, '') || 'Lanes';
321
+ const picker = tab?.name.endsWith(' ☰');
322
+ const pane = data()?.panes.find(p => p.pane_id === data()?.active_pane?.pane_id && !p.is_plugin);
323
+ text(heading, name);
324
+ text(subtitle, picker ? 'Choose a lane to continue' : pane?.title || 'Your workspace');
325
+ lanesButton.setAttribute('aria-pressed', String(!!picker));
326
+ updateLaneFilterBar(picker);
327
+ document.title = `${name} · Lanes`;
328
+ renderCards();
329
+ }
330
+ function connect() {
331
+ if (api || !window.__zjMobileUi || !window.term || !window.__zjSoftKbdCapture?.element) return;
332
+ // Wait for server state: resizing before the control socket opens makes 0.45.1
333
+ // throw InvalidStateError and leaves the server with the old terminal size.
334
+ if (!window.__zjMobileUi.getState()?.data?.tabs?.length) return;
335
+ api = window.__zjMobileUi; term = window.term;
336
+ build(); applyFont();
337
+ const setData = api.setData;
338
+ api.setData = function(value) {setData.call(api, value); refresh();};
339
+ const nativeRoot = document.getElementById('zj-mobile-root');
340
+ new MutationObserver(refresh).observe(nativeRoot, {attributes: true, attributeFilter: ['class'], subtree: true});
341
+ refresh(); viewport();
342
+ }
343
+ function styleLogin() {
344
+ const token = document.getElementById('token');
345
+ if (!token || token.dataset.lanes) return;
346
+ token.dataset.lanes = 'true';
347
+ token.autocomplete = 'current-password'; token.setAttribute('aria-label', 'Login token');
348
+ token.closest('.security-modal-content').querySelector('h3').textContent = 'Connect to Lanes';
349
+ token.closest('.security-modal-content').querySelector('.submit-btn').textContent = 'Connect';
350
+ }
351
+ viewport();
352
+ window.addEventListener('resize', viewport);
353
+ window.visualViewport?.addEventListener('resize', viewport);
354
+ window.visualViewport?.addEventListener('scroll', viewport);
355
+ window.addEventListener('zellij:soft-keyboard-visibility', () => {
356
+ viewport();
357
+ // A rotation also shrinks the viewport. Correct upstream's height-only keyboard heuristic.
358
+ requestAnimationFrame(() => window.__zjSendControl?.({
359
+ type: 'SoftKeyboardVisibilityChanged', visible: root.classList.contains('lanes-keyboard'),
360
+ }));
361
+ });
362
+ document.addEventListener('focusout', () => requestAnimationFrame(viewport));
363
+ new MutationObserver(() => {styleLogin(); connect();}).observe(document.body, {childList: true});
364
+ // The upstream window hooks are installed just after it appends its mobile root.
365
+ const awaitingClient = setInterval(() => {connect(); if (api) clearInterval(awaitingClient);}, 200);
366
+ styleLogin(); connect();
367
+ })();
@@ -0,0 +1,27 @@
1
+ # Site data: which boards exist
2
+
3
+ The server reads its boards from a **site directory**, not from the package:
4
+
5
+ 1. `$LANES_WORKSPACES`, if set;
6
+ 2. `~/.config/lanes/workspaces/` on the server, if it contains a `catalog.json`;
7
+ 3. `workspaces/` inside the checkout (what a single-site checkout uses).
8
+
9
+ Copy this folder to `~/.config/lanes/workspaces/` and edit it.
10
+
11
+ | File | What it says |
12
+ |---|---|
13
+ | `catalog.json` | The boards, in tab order: `id` (also the uploads/watch-folder name), `title` (the tab), `manifest`. |
14
+ | `workspaces.<board>.json` | Where the board's lanes come from: one or more `repositories`, each naming the workspace **container** (`host`) and the board's terminal registry (`registry`: the repository's `.vscode/terminals.json`). Optional per repository: `prefix` (prepended to lane titles), `displayNames`, `bindingOverrides` (tmux socket/session for a terminal whose registry entry has none), `cursor` (a second file to project the board into, for boards whose registry lives outside a repository). `monitor` adds the full-pane Throttle monitor tab. |
15
+ | `commit-targets.json` | Only for `lanes cursor-commit`: repository, branch and author for committing the normalized registries. |
16
+
17
+ A lane is whatever the registry says it is: `lanes sync` (every two minutes, read-only) turns each terminal into a
18
+ lane with a stable id `<board>--<slug of the terminal name>`. Adding a lane = adding a terminal to the repository's
19
+ `.vscode/terminals.json`; nothing in this folder changes.
20
+
21
+ After editing: `lanes sync && lanes doctor`.
22
+
23
+ `terminals.example.json` is a complete starting registry: copy it to the repository's
24
+ `.vscode/terminals.json` inside its container. Edit the command and explicit `tmux` binding together.
25
+ For the Product example, its Shell becomes `product--shell`. Registry sync only discovers the lane;
26
+ opening the editor terminal (or `lane open product--shell`) starts its saved recipe if absent.
27
+ The optional `monitor` configuration is needed only if you run Throttle in that container.
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 1,
3
+ "workspaces": [
4
+ { "id": "product", "title": "Product", "manifest": "workspaces.product.json" },
5
+ { "id": "research", "title": "Research", "manifest": "workspaces.research.json" }
6
+ ]
7
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "author": { "name": "Your Name", "email": "you@example.com" },
3
+ "targets": {
4
+ "product": { "container": "coder-dev-work", "folder": "/home/coder/product", "branch": "main" },
5
+ "research": { "container": "coder-dev-personal", "folder": "/home/coder/research", "branch": "main", "options": ["remote-only"] }
6
+ },
7
+ "skip": { "some-board": "why its registry is not committed" }
8
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "terminals": [
3
+ {
4
+ "name": "Shell",
5
+ "command": "TMUX= tmux -L project new -ADs shell bash -l",
6
+ "focus": true
7
+ }
8
+ ],
9
+ "lanes": {
10
+ "version": 1,
11
+ "terminals": {
12
+ "Shell": {
13
+ "holder": "tmux",
14
+ "tmux": { "socketName": "project", "sessionName": "shell" },
15
+ "create": "TMUX= tmux -L project new -ADs shell bash -l"
16
+ }
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "session": "product",
3
+ "remoteShell": {
4
+ "name": "Shell",
5
+ "cwd": "/home/coder/product"
6
+ },
7
+ "repositories": [
8
+ {
9
+ "id": "product",
10
+ "host": "coder-dev-work",
11
+ "registry": "/home/coder/product/.vscode/terminals.json",
12
+ "prefix": ""
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "session": "research",
3
+ "remoteShell": { "name": "Shell", "cwd": "/home/coder/research" },
4
+ "repositories": [
5
+ {
6
+ "id": "research",
7
+ "host": "coder-dev-personal",
8
+ "registry": "/home/coder/research/.vscode/terminals.json",
9
+ "prefix": "",
10
+ "displayNames": { "A very long terminal name": "Short name" },
11
+ "bindingOverrides": {
12
+ "A lane without its own binding": { "tmux": { "socketName": "research_waves", "sessionName": "research_lane_1" } }
13
+ }
14
+ }
15
+ ]
16
+ }