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/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: s.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