zerogterm 0.9.0-alpha.1 → 0.9.2-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.
@@ -14,6 +14,8 @@ const api = {
14
14
  answerForwardPrompt: (id, answer) => ipcRenderer.invoke('forwards:answerPrompt', id, answer),
15
15
  loadForwards: () => ipcRenderer.invoke('forwards:load'),
16
16
  saveForwards: (file) => ipcRenderer.invoke('forwards:save', file),
17
+ loadHostLabels: () => ipcRenderer.invoke('hostLabels:load'),
18
+ saveHostLabels: (file) => ipcRenderer.invoke('hostLabels:save', file),
17
19
  onForwardEvent: (callback) => {
18
20
  const listener = (_event, payload) => callback(payload);
19
21
  ipcRenderer.on('forwards:event', listener);
@@ -49,6 +51,17 @@ const api = {
49
51
  ipcRenderer.on('mcp:execution', listener);
50
52
  return () => ipcRenderer.removeListener('mcp:execution', listener);
51
53
  },
54
+ launchLinkedAgent: (sessionId, agentCommand) => ipcRenderer.invoke('paneLinks:launchAgent', sessionId, agentCommand),
55
+ listPaneLinks: () => ipcRenderer.invoke('paneLinks:list'),
56
+ linkPanes: (a, b, cap) => ipcRenderer.invoke('paneLinks:link', a, b, cap),
57
+ relayLastReply: (linkId, fromSessionId) => ipcRenderer.invoke('paneLinks:relayLast', linkId, fromSessionId),
58
+ unlinkPanes: (linkId) => ipcRenderer.invoke('paneLinks:unlink', linkId),
59
+ resumePaneLink: (linkId) => ipcRenderer.invoke('paneLinks:resume', linkId),
60
+ onPaneLinks: (callback) => {
61
+ const listener = (_event, snapshot) => callback(snapshot);
62
+ ipcRenderer.on('paneLinks:changed', listener);
63
+ return () => ipcRenderer.removeListener('paneLinks:changed', listener);
64
+ },
52
65
  listBackends: () => ipcRenderer.invoke('sessions:backends'),
53
66
  listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
54
67
  createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
@@ -86,6 +99,8 @@ const api = {
86
99
  createLocalDirectory: (path) => ipcRenderer.invoke('fs:mkdirLocal', path),
87
100
  renameLocalEntry: (from, to) => ipcRenderer.invoke('fs:renameLocal', from, to),
88
101
  removeLocalEntry: (path, kind) => ipcRenderer.invoke('fs:removeLocal', path, kind),
102
+ readLocalFile: (path) => ipcRenderer.invoke('fs:readLocalFile', path),
103
+ writeLocalFile: (path, text, expectedMtimeMs, overwrite) => ipcRenderer.invoke('fs:writeLocalFile', path, text, expectedMtimeMs, overwrite),
89
104
  sftpOpen: (target, cwd) => ipcRenderer.invoke('sftp:open', target, cwd),
90
105
  sftpList: (sessionId, path) => ipcRenderer.invoke('sftp:list', sessionId, path),
91
106
  sftpMkdir: (sessionId, path) => ipcRenderer.invoke('sftp:mkdir', sessionId, path),
@@ -94,6 +109,8 @@ const api = {
94
109
  sftpUpload: (sessionId, localPath, remoteDir) => ipcRenderer.invoke('sftp:upload', sessionId, localPath, remoteDir),
95
110
  sftpDownload: (sessionId, remotePath, localDir, kind) => ipcRenderer.invoke('sftp:download', sessionId, remotePath, localDir, kind),
96
111
  sftpAnswerPrompt: (sessionId, answer) => ipcRenderer.invoke('sftp:answerPrompt', sessionId, answer),
112
+ sftpReadFile: (sessionId, path) => ipcRenderer.invoke('sftp:readFile', sessionId, path),
113
+ sftpWriteFile: (sessionId, path, text, opened, overwrite) => ipcRenderer.invoke('sftp:writeFile', sessionId, path, text, opened, overwrite),
97
114
  sftpClose: (sessionId) => ipcRenderer.invoke('sftp:close', sessionId),
98
115
  onSftpEvent: (callback) => {
99
116
  const listener = (_event, payload) => callback(payload);
@@ -0,0 +1,97 @@
1
+ // Reading and writing one text file on an SSH host, for the editor.
2
+ //
3
+ // sftp has no "send me the bytes" command, only get and put, so a read is a
4
+ // download to a private temp directory and a save is an upload from one. Each is
5
+ // cleaned up whatever happens: a copy of someone's `.env` left in a temp folder
6
+ // is the worst thing this module could do.
7
+ //
8
+ // A save is checked against the host the same way a local one is checked against
9
+ // the disk, but by *content*: sftp's listing times are only minute-accurate, so
10
+ // the file is fetched again and compared with what the editor opened. That costs
11
+ // a second transfer and cannot be wrong.
12
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { EDIT_CONFLICT_MESSAGE, MAX_EDIT_BYTES } from '../shared/editing.js';
16
+ import { baseName, parentRemote } from '../shared/files.js';
17
+ import { checkEditableSize, decodeEditable, megabytes } from './editable.js';
18
+ import { isSafeRemotePath } from './sftp-protocol.js';
19
+ async function withTempDir(run) {
20
+ // mkdtemp makes the directory private to this user where the platform has
21
+ // such a thing.
22
+ const dir = await mkdtemp(join(tmpdir(), 'zerog-remote-edit-'));
23
+ try {
24
+ return await run(dir);
25
+ }
26
+ finally {
27
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
28
+ }
29
+ }
30
+ function requireEditablePath(path) {
31
+ if (!path.startsWith('/'))
32
+ throw new Error('Only absolute remote paths can be edited.');
33
+ if (!isSafeRemotePath(path)) {
34
+ throw new Error('That file name has a quote, backslash, wildcard or control character in it, which this editor cannot open over SSH yet.');
35
+ }
36
+ }
37
+ /** The file's bytes, fetched into a temp directory and read back out of it. */
38
+ async function fetch(transport, sessionId, path) {
39
+ return withTempDir(async (dir) => {
40
+ await transport.download(sessionId, path, dir, 'file');
41
+ const local = join(dir, baseName(path));
42
+ const info = await stat(local);
43
+ // The listing could not vouch for the size of a symlink, so the real one is
44
+ // checked here too — after the fact, but before the bytes are read.
45
+ checkEditableSize(info.size);
46
+ return readFile(local);
47
+ });
48
+ }
49
+ /**
50
+ * Read a remote file as text, or say why it cannot be edited.
51
+ *
52
+ * The size is taken from a listing first, so a large file is refused before it
53
+ * is downloaded rather than after.
54
+ */
55
+ export async function readRemoteFile(transport, sessionId, path) {
56
+ requireEditablePath(path);
57
+ const listing = await transport.list(sessionId, parentRemote(path));
58
+ const entry = listing.entries.find((candidate) => candidate.name === baseName(path));
59
+ if (!entry)
60
+ throw new Error('That file does not exist on the host.');
61
+ if (entry.kind === 'directory')
62
+ throw new Error('That is not a file.');
63
+ // A symlink's listed size is the link's own, so it is checked after the fetch.
64
+ if (entry.kind === 'file')
65
+ checkEditableSize(entry.size);
66
+ const bytes = await fetch(transport, sessionId, path);
67
+ return { path, text: decodeEditable(bytes), size: bytes.length };
68
+ }
69
+ /**
70
+ * Save the editor's text to a remote file.
71
+ *
72
+ * `opened` is the exact text the editor read, and the save is refused if the host
73
+ * now holds something else. `overwrite` is the user saying they have seen that.
74
+ *
75
+ * The upload is a plain `put` with no `-p`: that leaves an existing file's
76
+ * permissions alone, where preserving the temp file's would reset them — a
77
+ * `.env` that was 600 must not come back world-readable.
78
+ */
79
+ export async function writeRemoteFile(transport, sessionId, path, text, opened, overwrite = false) {
80
+ requireEditablePath(path);
81
+ if (Buffer.byteLength(text, 'utf8') > MAX_EDIT_BYTES) {
82
+ throw new Error(`That is more than ${megabytes(MAX_EDIT_BYTES)}, which is more than the editor saves.`);
83
+ }
84
+ if (!overwrite) {
85
+ // Not the same failure as "changed": a file that has gone is reported as
86
+ // gone, by the transfer's own error, rather than as a conflict.
87
+ const now = decodeEditable(await fetch(transport, sessionId, path));
88
+ if (now !== opened)
89
+ throw new Error(EDIT_CONFLICT_MESSAGE);
90
+ }
91
+ await withTempDir(async (dir) => {
92
+ const local = join(dir, baseName(path));
93
+ await writeFile(local, text, 'utf8');
94
+ await transport.putFile(sessionId, local, path);
95
+ });
96
+ return { size: Buffer.byteLength(text, 'utf8') };
97
+ }
@@ -196,6 +196,18 @@ export class SftpService {
196
196
  const command = `put -p ${quoteLocalPath(localPath)} ${quoteRemotePath(destination)}`;
197
197
  await this.enqueue(connection, () => this.checked(connection, command, TRANSFER_IDLE_MS, true));
198
198
  }
199
+ /**
200
+ * Upload one file to an exact remote path.
201
+ *
202
+ * No `-p`, unlike upload(): that would stamp the remote file with the local
203
+ * file's permissions, and the editor's local file is a temp copy. Without it an
204
+ * existing remote file keeps its own mode — a 600 `.env` stays 600.
205
+ */
206
+ async putFile(sessionId, localPath, remotePath) {
207
+ const connection = this.get(sessionId);
208
+ const command = `put ${quoteLocalPath(localPath)} ${quoteRemotePath(remotePath)}`;
209
+ await this.enqueue(connection, () => this.checked(connection, command, TRANSFER_IDLE_MS, true));
210
+ }
199
211
  async download(sessionId, remotePath, localDir, kind) {
200
212
  const connection = this.get(sessionId);
201
213
  const name = baseName(remotePath);
@@ -129,6 +129,13 @@ function normalizeView(value, memberIds) {
129
129
  const browsers = normalizeBrowsers(item.browsers, memberIds);
130
130
  if (browsers)
131
131
  view.browsers = browsers;
132
+ // Slot order matters, so the list is kept as given, minus anything the
133
+ // workspace does not hold and any repeat.
134
+ const visible = Array.isArray(item.visiblePanes)
135
+ ? [...new Set(item.visiblePanes.map(member).filter((id) => id !== undefined))].slice(0, 2)
136
+ : [];
137
+ if (visible.length)
138
+ view.visiblePanes = visible;
132
139
  return view;
133
140
  }
134
141
  /**
@@ -0,0 +1,11 @@
1
+ // What the built-in editor and the main process both need to agree on.
2
+ /** The largest file the editor will open or save. Past this it is not a text file to edit by hand. */
3
+ export const MAX_EDIT_BYTES = 1024 * 1024;
4
+ /**
5
+ * What a refused save says when the file changed after it was loaded.
6
+ *
7
+ * The renderer tells this apart from any other failure by the text, because an
8
+ * IPC rejection carries nothing but its message — and the two deserve different
9
+ * responses: a conflict offers to overwrite or reload, a failure just says so.
10
+ */
11
+ export const EDIT_CONFLICT_MESSAGE = 'This file changed on disk after it was opened.';
@@ -11,6 +11,17 @@ export function sortEntries(entries) {
11
11
  return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
12
12
  });
13
13
  }
14
+ /**
15
+ * Is this entry a dotfile?
16
+ *
17
+ * Neither backend reports a platform "hidden" attribute — the SFTP side is a
18
+ * parsed `ls` line, and Node's `fs.Stats` does not expose the Windows bit
19
+ * either — so the leading dot, the convention every listed shell already
20
+ * follows, is what "hidden" means here.
21
+ */
22
+ export function isHiddenName(name) {
23
+ return name.startsWith('.');
24
+ }
14
25
  /** Join a remote directory and a child name. Remote paths are always POSIX. */
15
26
  export function joinRemote(dir, name) {
16
27
  if (name.startsWith('/'))