zerogterm 0.4.0-alpha.1 → 0.6.0-alpha.1

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.
@@ -1,7 +1,9 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { existsSync } from 'node:fs';
3
4
  import { createRequire } from 'node:module';
4
5
  import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
5
7
  import { promisify } from 'node:util';
6
8
  import { defaultShellBackend, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
7
9
  const execFileAsync = promisify(execFile);
@@ -14,6 +16,37 @@ const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
14
16
  * -chosen config file (hence ProxyCommand) without any shell involvement.
15
17
  */
16
18
  const SSH_TARGET = /^(?:([A-Za-z0-9][A-Za-z0-9._-]*)@)?([A-Za-z0-9][A-Za-z0-9.-]*)(?::(\d{1,5}))?$/;
19
+ function resolveSshExecutable() {
20
+ if (process.platform !== 'win32')
21
+ return 'ssh';
22
+ const windowsRoot = process.env.SystemRoot ?? 'C:\\Windows';
23
+ const candidates = [
24
+ join(windowsRoot, 'System32', 'OpenSSH', 'ssh.exe'),
25
+ join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'usr', 'bin', 'ssh.exe'),
26
+ join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Git', 'usr', 'bin', 'ssh.exe')
27
+ ];
28
+ return candidates.find((candidate) => existsSync(candidate)) ?? 'ssh.exe';
29
+ }
30
+ /**
31
+ * Size a pty starts at when the pane could not be measured — a placeholder,
32
+ * not a preference. Panes pass their real size through attach().
33
+ */
34
+ const FALLBACK_SIZE = { cols: 120, rows: 32 };
35
+ /**
36
+ * A size a pty can actually be given. Sizes cross the IPC boundary from the
37
+ * renderer, and a zero or fractional one is rejected by ConPTY (and makes
38
+ * every full-screen program wrap in the wrong place on Unix).
39
+ */
40
+ function usableSize(size) {
41
+ if (!size)
42
+ return undefined;
43
+ const { cols, rows } = size;
44
+ if (!Number.isInteger(cols) || !Number.isInteger(rows))
45
+ return undefined;
46
+ if (cols < 2 || rows < 2 || cols > 2000 || rows > 2000)
47
+ return undefined;
48
+ return { cols, rows };
49
+ }
17
50
  export function validateSessionName(name) {
18
51
  const value = name.trim();
19
52
  if (!NAME.test(value)) {
@@ -176,10 +209,23 @@ export class ScreenService {
176
209
  this.onEvent?.('created', session, true);
177
210
  return session;
178
211
  }
179
- attach(id, onData, onExit) {
212
+ /**
213
+ * Attach a pane to a session, starting its pty if it does not have one.
214
+ *
215
+ * `size` is the pane's own measurement. A pty spawned at some stock size and
216
+ * resized a moment later shows its first frame at the wrong width, and a
217
+ * full-screen program redrawing over that frame leaves pieces of it behind —
218
+ * so the shell is started at the size it will actually be displayed at.
219
+ */
220
+ attach(id, onData, onExit, size) {
180
221
  const existing = this.getSession(id);
181
- if (this.ptys.has(id))
222
+ if (this.ptys.has(id)) {
223
+ // Re-attaching pane may be a different size than the one that started it.
224
+ const measured = usableSize(size);
225
+ if (measured)
226
+ this.resize(id, measured.cols, measured.rows);
182
227
  return { ...existing, status: 'connected' };
228
+ }
183
229
  if (id.startsWith('local:')) {
184
230
  const fallback = this.fallbackLocalSessions.get(id);
185
231
  if (fallback) {
@@ -188,14 +234,14 @@ export class ScreenService {
188
234
  const shell = isLocalShellBackend(fallback.backend)
189
235
  ? resolveShellBackend(fallback.backend, fallback.wslDistribution)
190
236
  : defaultShellBackend();
191
- this.spawnCommand(id, shell.executable, shell.args, onData, onExit, fallback.cwd);
237
+ this.spawnCommand(id, shell.executable, shell.args, onData, onExit, fallback.cwd, size);
192
238
  this.onEvent?.('attached', fallback, true);
193
239
  return { ...fallback };
194
240
  }
195
241
  // Session ids cross the IPC boundary from the renderer, so re-validate
196
242
  // rather than trusting that createLocal produced this one.
197
243
  const name = validateSessionName(id.slice('local:'.length));
198
- this.spawnCommand(id, 'screen', ['-x', name], onData, onExit);
244
+ this.spawnCommand(id, 'screen', ['-x', name], onData, onExit, homedir(), size);
199
245
  this.onEvent?.('attached', existing, true);
200
246
  return { ...existing, status: 'connected', persistence: 'screen' };
201
247
  }
@@ -207,7 +253,7 @@ export class ScreenService {
207
253
  session.status = 'connected';
208
254
  session.lastSeen = new Date().toISOString();
209
255
  try {
210
- this.spawnCommand(id, 'ssh', args, onData, onExit);
256
+ this.spawnCommand(id, resolveSshExecutable(), args, onData, onExit, homedir(), size);
211
257
  }
212
258
  catch (error) {
213
259
  session.status = 'error';
@@ -239,11 +285,12 @@ export class ScreenService {
239
285
  throw new Error(`Unknown session: ${id}`);
240
286
  return session;
241
287
  }
242
- spawnCommand(sessionId, file, args, onData, onExit, cwd = homedir()) {
288
+ spawnCommand(sessionId, file, args, onData, onExit, cwd = homedir(), size) {
289
+ const { cols, rows } = usableSize(size) ?? FALLBACK_SIZE;
243
290
  const proc = this.spawnPty(file, args, {
244
291
  name: 'xterm-256color',
245
- cols: 120,
246
- rows: 32,
292
+ cols,
293
+ rows,
247
294
  cwd,
248
295
  env: process.env
249
296
  });
@@ -259,7 +306,7 @@ export class ScreenService {
259
306
  }
260
307
  }
261
308
  };
262
- this.ptys.set(sessionId, { handle, kind: sessionId.startsWith('ssh:') ? 'ssh' : 'local' });
309
+ this.ptys.set(sessionId, { handle, kind: sessionId.startsWith('ssh:') ? 'ssh' : 'local', cols, rows });
263
310
  proc.onData(onData);
264
311
  proc.onExit(() => {
265
312
  this.ptys.delete(sessionId);
@@ -281,8 +328,25 @@ export class ScreenService {
281
328
  write(sessionId, data) {
282
329
  this.ptys.get(sessionId)?.handle.write(data);
283
330
  }
331
+ /**
332
+ * Tell a session's pty how big its pane is now.
333
+ *
334
+ * A resize is not free and not invisible: it makes ConPTY reflow and re-emit
335
+ * its screen, and every full-screen program redraw on SIGWINCH. Panes refit
336
+ * on layout, font and status changes, which mostly produce the size the pty
337
+ * already has, so a repeat of the current size is dropped rather than paid
338
+ * for in a torn redraw.
339
+ */
284
340
  resize(sessionId, cols, rows) {
285
- this.ptys.get(sessionId)?.handle.resize(cols, rows);
341
+ const pty = this.ptys.get(sessionId);
342
+ if (!pty)
343
+ return;
344
+ const size = usableSize({ cols, rows });
345
+ if (!size || (size.cols === pty.cols && size.rows === pty.rows))
346
+ return;
347
+ pty.cols = size.cols;
348
+ pty.rows = size.rows;
349
+ pty.handle.resize(size.cols, size.rows);
286
350
  }
287
351
  detach(sessionId) {
288
352
  this.ptys.get(sessionId)?.handle.kill();