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.
- package/README.md +68 -6
- package/dist/main/main/agent-hook-server.js +166 -0
- package/dist/main/main/editable.js +31 -0
- package/dist/main/main/host-label-store.js +110 -0
- package/dist/main/main/local-fs.js +57 -6
- package/dist/main/main/main.js +122 -104
- package/dist/main/main/pane-link.js +335 -0
- package/dist/main/main/preload.cjs +17 -0
- package/dist/main/main/remote-file.js +97 -0
- package/dist/main/main/sftp-service.js +12 -0
- package/dist/main/main/workspace-store.js +7 -0
- package/dist/main/shared/editing.js +11 -0
- package/dist/main/shared/files.js +11 -0
- package/dist/renderer/assets/index-B7wWHnX0.js +149 -0
- package/dist/renderer/assets/index-DJfpGOKA.css +1 -0
- package/dist/renderer/index.html +2 -2
- package/package.json +25 -1
- package/dist/renderer/assets/index-BovKjPi0.css +0 -1
- package/dist/renderer/assets/index-DYB0NFWF.js +0 -142
|
@@ -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('/'))
|