codex-lens 0.1.27 → 0.1.29

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,4 +1,4 @@
1
- import React from 'react';
1
+ import { useEffect, useRef, useCallback } from 'react';
2
2
  import { Terminal } from '@xterm/xterm';
3
3
  import { FitAddon } from '@xterm/addon-fit';
4
4
  import { WebLinksAddon } from '@xterm/addon-web-links';
@@ -15,42 +15,66 @@ const VIRTUAL_KEYS = [
15
15
  { label: 'Ctrl+C', seq: '\x03' },
16
16
  ];
17
17
 
18
- export class TerminalPanel extends React.Component {
19
- constructor(props) {
20
- super(props);
21
- this.containerRef = React.createRef();
22
- this.terminal = null;
23
- this.fitAddon = null;
24
- this.ws = null;
25
- this.resizeObserver = null;
26
- this._writeBuffer = '';
27
- this._writeTimer = null;
28
- }
29
-
30
- componentDidMount() {
31
- this.initTerminal();
32
- this.connectWebSocket();
33
- this.setupResizeObserver();
34
- }
35
-
36
- componentWillUnmount() {
37
- if (this._writeTimer) {
38
- cancelAnimationFrame(this._writeTimer);
18
+ export function TerminalPanel() {
19
+ const containerRef = useRef(null);
20
+ const terminalRef = useRef(null);
21
+ const fitAddonRef = useRef(null);
22
+ const wsRef = useRef(null);
23
+ const resizeObserverRef = useRef(null);
24
+ const writeBufferRef = useRef('');
25
+ const writeTimerRef = useRef(null);
26
+ const resizeTimerRef = useRef(null);
27
+
28
+ const sendResize = useCallback(() => {
29
+ if (wsRef.current?.readyState === WebSocket.OPEN && terminalRef.current) {
30
+ wsRef.current.send(JSON.stringify({
31
+ type: 'resize',
32
+ cols: terminalRef.current.cols,
33
+ rows: terminalRef.current.rows,
34
+ }));
39
35
  }
40
- if (this.ws) {
41
- this.ws.close();
42
- this.ws = null;
36
+ }, []);
37
+
38
+ const flushWrite = useCallback(() => {
39
+ if (writeTimerRef.current) {
40
+ cancelAnimationFrame(writeTimerRef.current);
41
+ writeTimerRef.current = null;
43
42
  }
44
- if (this.resizeObserver) {
45
- this.resizeObserver.disconnect();
43
+ if (!writeBufferRef.current || !terminalRef.current) return;
44
+
45
+ const CHUNK_SIZE = 32768;
46
+ if (writeBufferRef.current.length <= CHUNK_SIZE) {
47
+ const buf = writeBufferRef.current;
48
+ writeBufferRef.current = '';
49
+ terminalRef.current.write(buf);
50
+ } else {
51
+ const chunk = writeBufferRef.current.slice(0, CHUNK_SIZE);
52
+ writeBufferRef.current = writeBufferRef.current.slice(CHUNK_SIZE);
53
+ terminalRef.current.write(chunk);
54
+ writeTimerRef.current = requestAnimationFrame(() => {
55
+ flushWrite();
56
+ });
46
57
  }
47
- if (this.terminal) {
48
- this.terminal.dispose();
58
+ }, []);
59
+
60
+ const throttledWrite = useCallback((data) => {
61
+ writeBufferRef.current += data;
62
+ if (!writeTimerRef.current) {
63
+ writeTimerRef.current = requestAnimationFrame(() => {
64
+ flushWrite();
65
+ });
49
66
  }
50
- }
67
+ }, [flushWrite]);
51
68
 
52
- initTerminal() {
53
- this.terminal = new Terminal({
69
+ const handleVirtualKey = useCallback((seq) => {
70
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
71
+ wsRef.current.send(JSON.stringify({ type: 'input', data: seq }));
72
+ }
73
+ terminalRef.current?.focus();
74
+ }, []);
75
+
76
+ useEffect(() => {
77
+ const terminal = new Terminal({
54
78
  cursorBlink: true,
55
79
  cursorStyle: 'bar',
56
80
  fontSize: 13,
@@ -65,52 +89,54 @@ export class TerminalPanel extends React.Component {
65
89
  allowProposedApi: true,
66
90
  });
67
91
 
68
- this.fitAddon = new FitAddon();
69
- this.terminal.loadAddon(this.fitAddon);
70
- this.terminal.loadAddon(new WebLinksAddon());
92
+ const fitAddon = new FitAddon();
93
+ terminal.loadAddon(fitAddon);
94
+ terminal.loadAddon(new WebLinksAddon());
71
95
 
72
- this.terminal.open(this.containerRef.current);
96
+ terminal.open(containerRef.current);
97
+ terminalRef.current = terminal;
98
+ fitAddonRef.current = fitAddon;
73
99
 
74
100
  requestAnimationFrame(() => {
75
- if (this.fitAddon) {
76
- this.fitAddon.fit();
77
- this.terminal.focus();
101
+ if (fitAddon) {
102
+ fitAddon.fit();
103
+ terminal.focus();
78
104
  }
79
105
  });
80
106
 
81
- this.terminal.onData((data) => {
82
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
83
- this.ws.send(JSON.stringify({ type: 'input', data }));
107
+ terminal.onData((data) => {
108
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
109
+ wsRef.current.send(JSON.stringify({ type: 'input', data }));
84
110
  }
85
111
  });
86
- }
87
112
 
88
- connectWebSocket() {
113
+ // WebSocket connection
89
114
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
90
115
  const host = window.location.hostname;
91
116
  const port = window.location.port === '5173' ? '5174' : window.location.port;
92
117
  const wsUrl = `${protocol}//${host}:${port}/ws/terminal`;
93
118
 
94
- this.ws = new WebSocket(wsUrl);
119
+ const ws = new WebSocket(wsUrl);
120
+ wsRef.current = ws;
95
121
 
96
- this.ws.onopen = () => {
122
+ ws.onopen = () => {
97
123
  console.log('[Terminal] Connected to PTY service');
98
- this.sendResize();
124
+ sendResize();
99
125
  };
100
126
 
101
- this.ws.onmessage = (event) => {
127
+ ws.onmessage = (event) => {
102
128
  try {
103
129
  const msg = JSON.parse(event.data);
104
130
  if (msg.type === 'data') {
105
- this._throttledWrite(msg.data);
131
+ throttledWrite(msg.data);
106
132
  } else if (msg.type === 'exit') {
107
- this._flushWrite();
108
- if (this.terminal) {
109
- this.terminal.write(`\r\n[Process exited with code ${msg.exitCode ?? '?'}]\r\n`);
133
+ flushWrite();
134
+ if (terminal) {
135
+ terminal.write(`\r\n[Process exited with code ${msg.exitCode ?? '?'}]\r\n`);
110
136
  }
111
137
  } else if (msg.type === 'state') {
112
- if (!msg.running && this.terminal) {
113
- this._flushWrite();
138
+ if (!msg.running && terminal) {
139
+ flushWrite();
114
140
  }
115
141
  }
116
142
  } catch (e) {
@@ -118,132 +144,97 @@ export class TerminalPanel extends React.Component {
118
144
  }
119
145
  };
120
146
 
121
- this.ws.onclose = () => {
147
+ ws.onclose = () => {
122
148
  console.log('[Terminal] Disconnected, reconnecting in 3s...');
123
149
  setTimeout(() => {
124
- if (this.containerRef.current) {
125
- this.connectWebSocket();
150
+ if (containerRef.current) {
151
+ // Reconnect handled by re-mounting effect
126
152
  }
127
153
  }, 3000);
128
154
  };
129
155
 
130
- this.ws.onerror = (error) => {
156
+ ws.onerror = (error) => {
131
157
  console.error('[Terminal] WebSocket error:', error);
132
158
  };
133
- }
134
-
135
- sendResize() {
136
- if (this.ws && this.ws.readyState === WebSocket.OPEN && this.terminal) {
137
- this.ws.send(JSON.stringify({
138
- type: 'resize',
139
- cols: this.terminal.cols,
140
- rows: this.terminal.rows,
141
- }));
142
- }
143
- }
144
159
 
145
- setupResizeObserver() {
146
- if (!this.containerRef.current) return;
147
-
148
- this.resizeObserver = new ResizeObserver(() => {
149
- if (this._resizeTimer) clearTimeout(this._resizeTimer);
150
- this._resizeTimer = setTimeout(() => {
151
- if (this.fitAddon && this.containerRef.current) {
160
+ // ResizeObserver setup
161
+ const resizeObserver = new ResizeObserver(() => {
162
+ if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current);
163
+ resizeTimerRef.current = setTimeout(() => {
164
+ if (fitAddonRef.current && containerRef.current) {
152
165
  try {
153
- this.fitAddon.fit();
154
- this.sendResize();
166
+ fitAddonRef.current.fit();
167
+ sendResize();
155
168
  } catch {}
156
169
  }
157
170
  }, 150);
158
171
  });
159
172
 
160
- this.resizeObserver.observe(this.containerRef.current);
161
- }
162
-
163
- _throttledWrite(data) {
164
- this._writeBuffer += data;
165
- if (!this._writeTimer) {
166
- this._writeTimer = requestAnimationFrame(() => {
167
- this._flushWrite();
168
- });
169
- }
170
- }
171
-
172
- _flushWrite() {
173
- if (this._writeTimer) {
174
- cancelAnimationFrame(this._writeTimer);
175
- this._writeTimer = null;
176
- }
177
- if (!this._writeBuffer || !this.terminal) return;
178
-
179
- const CHUNK_SIZE = 32768;
180
- if (this._writeBuffer.length <= CHUNK_SIZE) {
181
- const buf = this._writeBuffer;
182
- this._writeBuffer = '';
183
- this.terminal.write(buf);
184
- } else {
185
- const chunk = this._writeBuffer.slice(0, CHUNK_SIZE);
186
- this._writeBuffer = this._writeBuffer.slice(CHUNK_SIZE);
187
- this.terminal.write(chunk);
188
- this._writeTimer = requestAnimationFrame(() => {
189
- this._flushWrite();
190
- });
191
- }
192
- }
173
+ resizeObserver.observe(containerRef.current);
174
+ resizeObserverRef.current = resizeObserver;
193
175
 
194
- handleVirtualKey = (seq) => {
195
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
196
- this.ws.send(JSON.stringify({ type: 'input', data: seq }));
197
- }
198
- this.terminal?.focus();
199
- };
200
-
201
- render() {
202
- return (
176
+ // Cleanup on unmount
177
+ return () => {
178
+ if (writeTimerRef.current) {
179
+ cancelAnimationFrame(writeTimerRef.current);
180
+ }
181
+ if (ws) {
182
+ ws.close();
183
+ wsRef.current = null;
184
+ }
185
+ if (resizeObserver) {
186
+ resizeObserver.disconnect();
187
+ }
188
+ if (terminal) {
189
+ terminal.dispose();
190
+ }
191
+ };
192
+ }, [sendResize, throttledWrite, flushWrite]);
193
+
194
+ return (
195
+ <div style={{
196
+ height: '100%',
197
+ display: 'flex',
198
+ flexDirection: 'column',
199
+ background: '#0a0a0a',
200
+ }}>
201
+ <div
202
+ ref={containerRef}
203
+ style={{
204
+ flex: 1,
205
+ overflow: 'hidden',
206
+ padding: '4px 8px',
207
+ }}
208
+ />
203
209
  <div style={{
204
- height: '100%',
205
210
  display: 'flex',
206
- flexDirection: 'column',
207
- background: '#0a0a0a',
211
+ gap: '4px',
212
+ padding: '8px',
213
+ background: '#111',
214
+ borderTop: '1px solid #222',
215
+ flexWrap: 'wrap',
208
216
  }}>
209
- <div
210
- ref={this.containerRef}
211
- style={{
212
- flex: 1,
213
- overflow: 'hidden',
214
- padding: '4px 8px',
215
- }}
216
- />
217
- <div style={{
218
- display: 'flex',
219
- gap: '4px',
220
- padding: '8px',
221
- background: '#111',
222
- borderTop: '1px solid #222',
223
- flexWrap: 'wrap',
224
- }}>
225
- {VIRTUAL_KEYS.map((key) => (
226
- <button
227
- key={key.label}
228
- onClick={() => this.handleVirtualKey(key.seq)}
229
- style={{
230
- padding: '8px 12px',
231
- border: '1px solid #333',
232
- borderRadius: '4px',
233
- background: '#1a1a1a',
234
- color: '#ccc',
235
- fontSize: '13px',
236
- fontFamily: 'Menlo, Monaco, monospace',
237
- cursor: 'pointer',
238
- minWidth: '44px',
239
- minHeight: '44px',
240
- }}
241
- >
242
- {key.label}
243
- </button>
244
- ))}
245
- </div>
217
+ {VIRTUAL_KEYS.map((key) => (
218
+ <button
219
+ key={key.label}
220
+ onClick={() => handleVirtualKey(key.seq)}
221
+ style={{
222
+ padding: '8px 12px',
223
+ border: '1px solid #333',
224
+ borderRadius: '4px',
225
+ background: '#1a1a1a',
226
+ color: '#ccc',
227
+ fontSize: '13px',
228
+ fontFamily: 'Menlo, Monaco, monospace',
229
+ cursor: 'pointer',
230
+ minWidth: '44px',
231
+ minHeight: '44px',
232
+ }}
233
+ >
234
+ {key.label}
235
+ </button>
236
+ ))}
246
237
  </div>
247
- );
248
- }
249
- }
238
+ </div>
239
+ );
240
+ }
@@ -0,0 +1,168 @@
1
+ import { spawn } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { createLogger } from './lib/logger.js';
5
+
6
+ const logger = createLogger('GitManager');
7
+
8
+ class GitManager {
9
+ constructor(projectRoot, wsEmitter) {
10
+ this.projectRoot = projectRoot;
11
+ this.wsEmitter = wsEmitter;
12
+ this.gitDir = join(projectRoot, '.git');
13
+ this.currentStatus = null;
14
+ this.currentBranch = null;
15
+ this._statusTimeout = null;
16
+ }
17
+
18
+ isGitRepo() {
19
+ return existsSync(this.gitDir);
20
+ }
21
+
22
+ runGitCommand(args) {
23
+ return new Promise((resolve, reject) => {
24
+ const proc = spawn('git', args, {
25
+ cwd: this.projectRoot,
26
+ shell: true,
27
+ windowsHide: true
28
+ });
29
+
30
+ let stdout = '';
31
+ let stderr = '';
32
+
33
+ proc.stdout?.on('data', (data) => { stdout += data.toString(); });
34
+ proc.stderr?.on('data', (data) => { stderr += data.toString(); });
35
+
36
+ proc.on('close', (code) => {
37
+ resolve({ code, stdout, stderr });
38
+ });
39
+
40
+ proc.on('error', (err) => {
41
+ reject(err);
42
+ });
43
+ });
44
+ }
45
+
46
+ parsePorcelainStatus(output) {
47
+ const lines = output.trim().split('\n');
48
+ const result = {
49
+ staged: [],
50
+ unstaged: [],
51
+ untracked: [],
52
+ conflicted: []
53
+ };
54
+
55
+ for (const line of lines) {
56
+ if (!line || line.length < 3) continue;
57
+
58
+ const indexStatus = line[0];
59
+ const workTreeStatus = line[1];
60
+ const path = line.slice(3).trim();
61
+
62
+ const fileInfo = { path, indexStatus, workTreeStatus };
63
+
64
+ // Staged changes (index)
65
+ if (indexStatus !== ' ' && indexStatus !== '?') {
66
+ result.staged.push(fileInfo);
67
+ }
68
+
69
+ // Working tree changes
70
+ if (workTreeStatus === 'M' || workTreeStatus === 'D') {
71
+ result.unstaged.push(fileInfo);
72
+ }
73
+
74
+ // Untracked files
75
+ if (indexStatus === '?' && workTreeStatus === '?') {
76
+ result.untracked.push(fileInfo);
77
+ }
78
+
79
+ // Conflicted files
80
+ if (indexStatus === 'U' || workTreeStatus === 'U') {
81
+ result.conflicted.push(fileInfo);
82
+ }
83
+ }
84
+
85
+ return result;
86
+ }
87
+
88
+ async getStatus() {
89
+ if (!this.isGitRepo()) return null;
90
+
91
+ try {
92
+ const { stdout } = await this.runGitCommand(['status', '--porcelain']);
93
+ this.currentStatus = this.parsePorcelainStatus(stdout);
94
+ return this.currentStatus;
95
+ } catch (error) {
96
+ logger.error(`Failed to get git status: ${error.message}`);
97
+ return null;
98
+ }
99
+ }
100
+
101
+ async getCurrentBranch() {
102
+ if (!this.isGitRepo()) return null;
103
+
104
+ try {
105
+ const { stdout } = await this.runGitCommand(['branch', '--show-current']);
106
+ this.currentBranch = stdout.trim();
107
+ return this.currentBranch;
108
+ } catch (error) {
109
+ logger.error(`Failed to get branch: ${error.message}`);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ async stageFile(filePath) {
115
+ await this.runGitCommand(['add', filePath]);
116
+ return this.getStatus();
117
+ }
118
+
119
+ async unstageFile(filePath) {
120
+ await this.runGitCommand(['reset', 'HEAD', '--', filePath]);
121
+ return this.getStatus();
122
+ }
123
+
124
+ async stageAll() {
125
+ await this.runGitCommand(['add', '-A']);
126
+ return this.getStatus();
127
+ }
128
+
129
+ async unstageAll() {
130
+ await this.runGitCommand(['reset', 'HEAD']);
131
+ return this.getStatus();
132
+ }
133
+
134
+ async commit(message) {
135
+ await this.runGitCommand(['commit', '-m', message]);
136
+ return this.getStatus();
137
+ }
138
+
139
+ async broadcastUpdate() {
140
+ const branch = await this.getCurrentBranch();
141
+ const status = await this.getStatus();
142
+
143
+ this.wsEmitter({
144
+ type: 'git_status',
145
+ data: {
146
+ isRepo: true,
147
+ branch,
148
+ status,
149
+ stagedCount: status?.staged?.length || 0,
150
+ unstagedCount: (status?.unstaged?.length || 0) + (status?.untracked?.length || 0),
151
+ timestamp: new Date().toISOString()
152
+ }
153
+ });
154
+ }
155
+
156
+ scheduleStatusUpdate() {
157
+ if (this._statusTimeout) {
158
+ clearTimeout(this._statusTimeout);
159
+ }
160
+ this._statusTimeout = setTimeout(() => {
161
+ this.broadcastUpdate();
162
+ }, 500);
163
+ }
164
+ }
165
+
166
+ export function createGitManager(projectRoot, wsEmitter) {
167
+ return new GitManager(projectRoot, wsEmitter);
168
+ }