termbeam 1.5.0 → 1.7.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/README.md +2 -32
- package/package.json +1 -1
- package/public/css/themes.css +217 -0
- package/public/index.html +42 -242
- package/public/js/keybar.js +180 -0
- package/public/js/search.js +95 -0
- package/public/js/shared.js +39 -0
- package/public/js/terminal-themes.js +291 -0
- package/public/js/themes.js +54 -0
- package/public/terminal.html +74 -873
- package/src/git.js +125 -0
- package/src/sessions.js +86 -1
package/src/git.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function git(cmd, cwd) {
|
|
5
|
+
return execSync(`git ${cmd}`, { cwd, stdio: 'pipe', timeout: 3000 }).toString().trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getGitInfo(cwd) {
|
|
9
|
+
try {
|
|
10
|
+
git('rev-parse --is-inside-work-tree', cwd);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const result = { branch: null, repoName: null, provider: null, status: null };
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const branch = git('branch --show-current', cwd);
|
|
19
|
+
if (branch) {
|
|
20
|
+
result.branch = branch;
|
|
21
|
+
} else {
|
|
22
|
+
// Detached HEAD — use short SHA
|
|
23
|
+
result.branch = `(${git('rev-parse --short HEAD', cwd)})`;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
/* empty repo */
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const remoteUrl = git('remote get-url origin', cwd);
|
|
31
|
+
const parsed = parseRemoteUrl(remoteUrl);
|
|
32
|
+
if (parsed) {
|
|
33
|
+
result.repoName = parsed.repoName;
|
|
34
|
+
result.provider = parsed.provider;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// No remote — use directory name
|
|
38
|
+
try {
|
|
39
|
+
const root = git('rev-parse --show-toplevel', cwd);
|
|
40
|
+
result.repoName = path.basename(root);
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let ahead = 0,
|
|
47
|
+
behind = 0;
|
|
48
|
+
try {
|
|
49
|
+
const counts = git('rev-list --left-right --count HEAD...@{upstream}', cwd);
|
|
50
|
+
[ahead, behind] = counts.split(/\s+/).map(Number);
|
|
51
|
+
} catch {
|
|
52
|
+
/* no upstream configured */
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const raw = git('status --porcelain', cwd);
|
|
57
|
+
result.status = parseStatus(raw, ahead, behind);
|
|
58
|
+
} catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseRemoteUrl(url) {
|
|
66
|
+
// Azure DevOps: https://dev.azure.com/org/project/_git/repo
|
|
67
|
+
const azureMatch = url.match(/dev\.azure\.com\/([^/]+\/[^/]+)\/_git\/([^/]+?)(?:\.git)?$/);
|
|
68
|
+
if (azureMatch) {
|
|
69
|
+
return { repoName: `${azureMatch[1]}/${azureMatch[2]}`, provider: 'Azure DevOps' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Azure DevOps (legacy): https://org.visualstudio.com/project/_git/repo
|
|
73
|
+
const vsMatch = url.match(/([^/.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/);
|
|
74
|
+
if (vsMatch) {
|
|
75
|
+
return { repoName: `${vsMatch[1]}/${vsMatch[2]}/${vsMatch[3]}`, provider: 'Azure DevOps' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// SSH: git@github.com:owner/repo.git
|
|
79
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
80
|
+
const match = url.match(/[@/]([^/:]+)[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
81
|
+
if (!match) return null;
|
|
82
|
+
|
|
83
|
+
const host = match[1];
|
|
84
|
+
const fullName = match[2];
|
|
85
|
+
|
|
86
|
+
let provider = host;
|
|
87
|
+
if (host.includes('github')) provider = 'GitHub';
|
|
88
|
+
else if (host.includes('gitlab')) provider = 'GitLab';
|
|
89
|
+
else if (host.includes('bitbucket')) provider = 'Bitbucket';
|
|
90
|
+
|
|
91
|
+
return { repoName: fullName, provider };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseStatus(output, ahead, behind) {
|
|
95
|
+
let modified = 0,
|
|
96
|
+
staged = 0,
|
|
97
|
+
untracked = 0;
|
|
98
|
+
|
|
99
|
+
if (output) {
|
|
100
|
+
const lines = output.split('\n').filter(Boolean);
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
const index = line[0];
|
|
103
|
+
const working = line[1];
|
|
104
|
+
if (index === '?' && working === '?') {
|
|
105
|
+
untracked++;
|
|
106
|
+
} else {
|
|
107
|
+
if (index !== ' ' && index !== '?') staged++;
|
|
108
|
+
if (working !== ' ' && working !== '?') modified++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const parts = [];
|
|
114
|
+
if (staged) parts.push(`${staged} staged`);
|
|
115
|
+
if (modified) parts.push(`${modified} modified`);
|
|
116
|
+
if (untracked) parts.push(`${untracked} untracked`);
|
|
117
|
+
if (ahead) parts.push(`${ahead}↑`);
|
|
118
|
+
if (behind) parts.push(`${behind}↓`);
|
|
119
|
+
const clean = !staged && !modified && !untracked && !ahead && !behind;
|
|
120
|
+
const summary = parts.length ? parts.join(', ') : 'clean';
|
|
121
|
+
|
|
122
|
+
return { clean, modified, staged, untracked, ahead: ahead || 0, behind: behind || 0, summary };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { getGitInfo, parseRemoteUrl, parseStatus };
|
package/src/sessions.js
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
|
+
const { execSync, exec } = require('child_process');
|
|
3
|
+
const fs = require('fs');
|
|
2
4
|
const pty = require('node-pty');
|
|
3
5
|
const log = require('./logger');
|
|
6
|
+
const { getGitInfo } = require('./git');
|
|
7
|
+
|
|
8
|
+
function getProcessCwd(pid) {
|
|
9
|
+
try {
|
|
10
|
+
if (process.platform === 'linux') {
|
|
11
|
+
return fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
12
|
+
}
|
|
13
|
+
if (process.platform === 'darwin') {
|
|
14
|
+
const out = execSync(`lsof -a -p ${pid} -d cwd -Fn`, {
|
|
15
|
+
stdio: 'pipe',
|
|
16
|
+
timeout: 2000,
|
|
17
|
+
}).toString();
|
|
18
|
+
const match = out.match(/\nn(.+)/);
|
|
19
|
+
if (match) return match[1];
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
/* process may have exited */
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Cache git info per session to avoid blocking the event loop on every list() call.
|
|
28
|
+
// lsof + git commands take ~200-500ms and block WebSocket traffic, causing
|
|
29
|
+
// xterm.js cursor position report responses to leak as visible text.
|
|
30
|
+
const _gitCache = new Map(); // sessionId -> { cwd, git, ts }
|
|
31
|
+
const GIT_CACHE_TTL = 5000;
|
|
32
|
+
|
|
33
|
+
function getCachedGitInfo(sessionId, pid, originalCwd) {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const cached = _gitCache.get(sessionId);
|
|
36
|
+
if (cached && now - cached.ts < GIT_CACHE_TTL) {
|
|
37
|
+
return { cwd: cached.cwd, git: cached.git };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Always refresh asynchronously to avoid blocking the event loop.
|
|
41
|
+
// Return stale data if available, or null on first call.
|
|
42
|
+
scheduleGitRefresh(sessionId, pid, originalCwd);
|
|
43
|
+
if (cached) return { cwd: cached.cwd, git: cached.git };
|
|
44
|
+
return { cwd: originalCwd, git: null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scheduleGitRefresh(sessionId, pid, originalCwd) {
|
|
48
|
+
// Mark as refreshing to prevent duplicate refreshes
|
|
49
|
+
const cached = _gitCache.get(sessionId);
|
|
50
|
+
if (cached && cached._refreshing) return;
|
|
51
|
+
if (cached) cached._refreshing = true;
|
|
52
|
+
|
|
53
|
+
// Use exec (async) for the lsof call to avoid blocking the event loop
|
|
54
|
+
const cmd =
|
|
55
|
+
process.platform === 'darwin'
|
|
56
|
+
? `lsof -a -p ${pid} -d cwd -Fn`
|
|
57
|
+
: process.platform === 'linux'
|
|
58
|
+
? `readlink /proc/${pid}/cwd`
|
|
59
|
+
: null;
|
|
60
|
+
|
|
61
|
+
if (!cmd) {
|
|
62
|
+
// Windows or unsupported — just refresh sync quickly
|
|
63
|
+
setImmediate(() => {
|
|
64
|
+
const git = getGitInfo(originalCwd);
|
|
65
|
+
_gitCache.set(sessionId, { cwd: originalCwd, git, ts: Date.now() });
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
exec(cmd, { timeout: 2000 }, (err, stdout) => {
|
|
71
|
+
let liveCwd = originalCwd;
|
|
72
|
+
if (!err && stdout) {
|
|
73
|
+
if (process.platform === 'darwin') {
|
|
74
|
+
const match = stdout.match(/\nn(.+)/);
|
|
75
|
+
if (match) liveCwd = match[1].trim();
|
|
76
|
+
} else {
|
|
77
|
+
liveCwd = stdout.trim();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const git = getGitInfo(liveCwd);
|
|
81
|
+
_gitCache.set(sessionId, { cwd: liveCwd, git, ts: Date.now() });
|
|
82
|
+
});
|
|
83
|
+
}
|
|
4
84
|
|
|
5
85
|
const SESSION_COLORS = [
|
|
6
86
|
'#4a9eff',
|
|
@@ -79,6 +159,7 @@ class SessionManager {
|
|
|
79
159
|
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
|
|
80
160
|
}
|
|
81
161
|
this.sessions.delete(id);
|
|
162
|
+
_gitCache.delete(id);
|
|
82
163
|
});
|
|
83
164
|
|
|
84
165
|
this.sessions.set(id, session);
|
|
@@ -102,6 +183,7 @@ class SessionManager {
|
|
|
102
183
|
const s = this.sessions.get(id);
|
|
103
184
|
if (!s) return false;
|
|
104
185
|
log.info(`Session "${s.name}" deleted (id=${id})`);
|
|
186
|
+
_gitCache.delete(id);
|
|
105
187
|
s.pty.kill();
|
|
106
188
|
return true;
|
|
107
189
|
}
|
|
@@ -109,16 +191,18 @@ class SessionManager {
|
|
|
109
191
|
list() {
|
|
110
192
|
const list = [];
|
|
111
193
|
for (const [id, s] of this.sessions) {
|
|
194
|
+
const { cwd, git } = getCachedGitInfo(id, s.pty.pid, s.cwd);
|
|
112
195
|
list.push({
|
|
113
196
|
id,
|
|
114
197
|
name: s.name,
|
|
115
|
-
cwd
|
|
198
|
+
cwd,
|
|
116
199
|
shell: s.shell,
|
|
117
200
|
pid: s.pty.pid,
|
|
118
201
|
clients: s.clients.size,
|
|
119
202
|
createdAt: s.createdAt,
|
|
120
203
|
color: s.color,
|
|
121
204
|
lastActivity: s.lastActivity,
|
|
205
|
+
git,
|
|
122
206
|
});
|
|
123
207
|
}
|
|
124
208
|
return list;
|
|
@@ -133,6 +217,7 @@ class SessionManager {
|
|
|
133
217
|
}
|
|
134
218
|
}
|
|
135
219
|
this.sessions.clear();
|
|
220
|
+
_gitCache.clear();
|
|
136
221
|
}
|
|
137
222
|
}
|
|
138
223
|
|