codex-lens 0.1.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/LICENSE +21 -0
- package/README.md +2 -0
- package/build.js +67 -0
- package/dist/aggregator.js +537 -0
- package/dist/cli.js +73 -0
- package/dist/lib/diff-builder.js +109 -0
- package/dist/lib/log-manager.js +124 -0
- package/dist/lib/sse-parser.js +71 -0
- package/dist/main.css +658 -0
- package/dist/main.js +31446 -0
- package/dist/proxy.js +129 -0
- package/dist/public/lib/xterm/addon-fit.js +2 -0
- package/dist/public/lib/xterm/addon-web-links.js +2 -0
- package/dist/public/lib/xterm/xterm.css +218 -0
- package/dist/public/lib/xterm/xterm.js +2 -0
- package/dist/watcher.js +230 -0
- package/package.json +46 -0
- package/public/lib/xterm/addon-fit.js +2 -0
- package/public/lib/xterm/addon-web-links.js +2 -0
- package/public/lib/xterm/xterm.css +218 -0
- package/public/lib/xterm/xterm.js +2 -0
- package/src/aggregator.js +576 -0
- package/src/cli.js +92 -0
- package/src/components/App.jsx +528 -0
- package/src/components/TerminalPanel.jsx +249 -0
- package/src/global.css +521 -0
- package/src/index.html +12 -0
- package/src/lib/diff-builder.js +126 -0
- package/src/lib/log-manager.js +140 -0
- package/src/lib/logger.js +88 -0
- package/src/lib/sse-parser.js +79 -0
- package/src/main.jsx +9 -0
- package/src/proxy.js +156 -0
- package/src/pty-manager.js +183 -0
- package/src/snapshot-manager.js +230 -0
- package/src/watcher.js +218 -0
- package/vite.config.js +39 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { TerminalPanel } from './TerminalPanel';
|
|
3
|
+
|
|
4
|
+
export function App() {
|
|
5
|
+
const [files, setFiles] = useState([]);
|
|
6
|
+
const [tabs, setTabs] = useState([]);
|
|
7
|
+
const [activeTabId, setActiveTabId] = useState(null);
|
|
8
|
+
const [recentChanges, setRecentChanges] = useState([]);
|
|
9
|
+
const [wsStatus, setWsStatus] = useState('disconnected');
|
|
10
|
+
const [contextMenu, setContextMenu] = useState(null);
|
|
11
|
+
const [taskStatus, setTaskStatus] = useState('idle');
|
|
12
|
+
const [taskId, setTaskId] = useState(null);
|
|
13
|
+
const [showRollbackConfirm, setShowRollbackConfirm] = useState(false);
|
|
14
|
+
const [version, setVersion] = useState(null);
|
|
15
|
+
const [latestVersion, setLatestVersion] = useState(null);
|
|
16
|
+
const [hasUpdate, setHasUpdate] = useState(false);
|
|
17
|
+
const wsRef = useRef(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
fetchStatus();
|
|
21
|
+
connectWebSocket();
|
|
22
|
+
document.addEventListener('click', handleDocumentClick);
|
|
23
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
24
|
+
|
|
25
|
+
return () => {
|
|
26
|
+
document.removeEventListener('click', handleDocumentClick);
|
|
27
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
28
|
+
if (wsRef.current) {
|
|
29
|
+
wsRef.current.close();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
async function fetchStatus() {
|
|
35
|
+
try {
|
|
36
|
+
const port = window.location.port === '5173' ? '5174' : window.location.port;
|
|
37
|
+
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
|
|
38
|
+
const response = await fetch(`${protocol}//${window.location.hostname}:${port}/api/status`);
|
|
39
|
+
if (response.ok) {
|
|
40
|
+
const data = await response.json();
|
|
41
|
+
setVersion(data.version);
|
|
42
|
+
setLatestVersion(data.latestVersion);
|
|
43
|
+
setHasUpdate(data.hasUpdate);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error('Failed to fetch status:', error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleDocumentClick() {
|
|
51
|
+
setContextMenu(null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleKeyDown(e) {
|
|
55
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'Enter') {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
handleStartTask();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (taskStatus === 'running' && e.ctrlKey && e.key === 'Enter') {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
handleCompleteTask();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (taskStatus === 'running' && e.ctrlKey && e.key === 'z') {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
setShowRollbackConfirm(true);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!activeTabId) return;
|
|
74
|
+
|
|
75
|
+
if (e.ctrlKey && e.key === 'w') {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
closeTab(activeTabId);
|
|
78
|
+
} else if (e.ctrlKey && e.key === 'Tab') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
if (e.shiftKey) {
|
|
81
|
+
switchToPrevTab();
|
|
82
|
+
} else {
|
|
83
|
+
switchToNextTab();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function connectWebSocket() {
|
|
89
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
90
|
+
const host = window.location.hostname;
|
|
91
|
+
const port = window.location.port === '5173' ? '5174' : window.location.port;
|
|
92
|
+
const wsUrl = `${protocol}//${host}:${port}/ws`;
|
|
93
|
+
|
|
94
|
+
const ws = new WebSocket(wsUrl);
|
|
95
|
+
|
|
96
|
+
ws.onopen = () => {
|
|
97
|
+
console.log('Connected to Codex Viewer');
|
|
98
|
+
setWsStatus('connected');
|
|
99
|
+
ws.send(JSON.stringify({ type: 'get_task_status' }));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
ws.onclose = () => {
|
|
103
|
+
console.log('Disconnected from Codex Viewer');
|
|
104
|
+
setWsStatus('disconnected');
|
|
105
|
+
setTimeout(connectWebSocket, 3000);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
ws.onerror = (error) => {
|
|
109
|
+
console.error('WebSocket error:', error);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
ws.onmessage = (event) => {
|
|
113
|
+
try {
|
|
114
|
+
const msg = JSON.parse(event.data);
|
|
115
|
+
handleMessage(msg);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.error('Failed to parse message:', e);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
wsRef.current = ws;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function handleMessage(msg) {
|
|
125
|
+
switch (msg.type) {
|
|
126
|
+
case 'file_change':
|
|
127
|
+
handleFileChange(msg.data);
|
|
128
|
+
break;
|
|
129
|
+
case 'file_tree':
|
|
130
|
+
setFiles(msg.data);
|
|
131
|
+
break;
|
|
132
|
+
case 'file_content':
|
|
133
|
+
openFileInTab(msg.data);
|
|
134
|
+
break;
|
|
135
|
+
case 'task_status':
|
|
136
|
+
setTaskStatus(msg.data.status);
|
|
137
|
+
setTaskId(msg.data.taskId);
|
|
138
|
+
break;
|
|
139
|
+
case 'task_started':
|
|
140
|
+
setTaskStatus('running');
|
|
141
|
+
setTaskId(msg.data.taskId);
|
|
142
|
+
alert(`任务已创建,快照包含 ${msg.data.filesCount} 个文件`);
|
|
143
|
+
break;
|
|
144
|
+
case 'task_rolled_back':
|
|
145
|
+
setTaskStatus('idle');
|
|
146
|
+
setTaskId(null);
|
|
147
|
+
setTabs([]);
|
|
148
|
+
setActiveTabId(null);
|
|
149
|
+
alert(`已撤回 ${msg.data.restoredCount} 个文件`);
|
|
150
|
+
setShowRollbackConfirm(false);
|
|
151
|
+
break;
|
|
152
|
+
case 'task_completed':
|
|
153
|
+
setTaskStatus('idle');
|
|
154
|
+
setTaskId(null);
|
|
155
|
+
alert('任务已完成,修改已保留');
|
|
156
|
+
break;
|
|
157
|
+
case 'connected':
|
|
158
|
+
console.log('Server confirmed connection');
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
console.log('Unknown message type:', msg.type);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function handleStartTask() {
|
|
166
|
+
if (taskStatus === 'running') return;
|
|
167
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
168
|
+
wsRef.current.send(JSON.stringify({ type: 'start_task' }));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function handleRollbackTask() {
|
|
173
|
+
if (taskStatus !== 'running') return;
|
|
174
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
175
|
+
wsRef.current.send(JSON.stringify({ type: 'rollback_task' }));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function handleCompleteTask() {
|
|
180
|
+
if (taskStatus !== 'running') return;
|
|
181
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
182
|
+
wsRef.current.send(JSON.stringify({ type: 'complete_task' }));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function openFileInTab(data) {
|
|
187
|
+
const fileName = data.path.split(/[/\\]/).pop();
|
|
188
|
+
const existingTab = tabs.find(t => t.path === data.path);
|
|
189
|
+
|
|
190
|
+
if (existingTab) {
|
|
191
|
+
setActiveTabId(existingTab.id);
|
|
192
|
+
} else {
|
|
193
|
+
const newTab = {
|
|
194
|
+
id: Date.now().toString(),
|
|
195
|
+
path: data.path,
|
|
196
|
+
name: fileName,
|
|
197
|
+
content: data.content,
|
|
198
|
+
diff: data.diff || null,
|
|
199
|
+
isDiff: !!data.diff
|
|
200
|
+
};
|
|
201
|
+
setTabs(prev => [...prev, newTab]);
|
|
202
|
+
setActiveTabId(newTab.id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function handleFileChange(data) {
|
|
207
|
+
const fileName = data.path.split(/[/\\]/).pop();
|
|
208
|
+
const existingTab = tabs.find(t => t.path === data.path);
|
|
209
|
+
|
|
210
|
+
if (existingTab) {
|
|
211
|
+
setTabs(prev => prev.map(t =>
|
|
212
|
+
t.path === data.path
|
|
213
|
+
? { ...t, content: data.newContent, diff: data.diff, isDiff: true }
|
|
214
|
+
: t
|
|
215
|
+
));
|
|
216
|
+
if (activeTabId === existingTab.id) {
|
|
217
|
+
setActiveTabId(existingTab.id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
setRecentChanges(prev => {
|
|
222
|
+
const filtered = prev.filter(c => c.path !== data.path);
|
|
223
|
+
return [{ path: data.path, time: Date.now(), diff: data.diff }, ...filtered].slice(0, 10);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handleFileClick(path) {
|
|
228
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
229
|
+
wsRef.current.send(JSON.stringify({ type: 'open_file', data: path }));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function closeTab(tabId) {
|
|
234
|
+
const tabIndex = tabs.findIndex(t => t.id === tabId);
|
|
235
|
+
const newTabs = tabs.filter(t => t.id !== tabId);
|
|
236
|
+
|
|
237
|
+
if (tabs.length === 1) {
|
|
238
|
+
setTabs([]);
|
|
239
|
+
setActiveTabId(null);
|
|
240
|
+
} else if (activeTabId === tabId) {
|
|
241
|
+
if (tabIndex >= newTabs.length) {
|
|
242
|
+
setActiveTabId(newTabs[newTabs.length - 1].id);
|
|
243
|
+
} else {
|
|
244
|
+
setActiveTabId(newTabs[tabIndex].id);
|
|
245
|
+
}
|
|
246
|
+
setTabs(newTabs);
|
|
247
|
+
} else {
|
|
248
|
+
setTabs(newTabs);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function closeOtherTabs(keepTabId) {
|
|
253
|
+
const keepTab = tabs.find(t => t.id === keepTabId);
|
|
254
|
+
setTabs(keepTab ? [keepTab] : []);
|
|
255
|
+
setActiveTabId(keepTabId);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function closeAllTabs() {
|
|
259
|
+
setTabs([]);
|
|
260
|
+
setActiveTabId(null);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function switchToNextTab() {
|
|
264
|
+
if (tabs.length <= 1) return;
|
|
265
|
+
const currentIndex = tabs.findIndex(t => t.id === activeTabId);
|
|
266
|
+
const nextIndex = (currentIndex + 1) % tabs.length;
|
|
267
|
+
setActiveTabId(tabs[nextIndex].id);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function switchToPrevTab() {
|
|
271
|
+
if (tabs.length <= 1) return;
|
|
272
|
+
const currentIndex = tabs.findIndex(t => t.id === activeTabId);
|
|
273
|
+
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
|
274
|
+
setActiveTabId(tabs[prevIndex].id);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function handleContextMenu(e, tabId) {
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
setContextMenu({
|
|
280
|
+
x: e.clientX,
|
|
281
|
+
y: e.clientY,
|
|
282
|
+
tabId
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const activeTab = tabs.find(t => t.id === activeTabId);
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div className="app-container">
|
|
290
|
+
<LeftPanel
|
|
291
|
+
files={files}
|
|
292
|
+
recentChanges={recentChanges}
|
|
293
|
+
activeFile={activeTab?.path || null}
|
|
294
|
+
onFileClick={handleFileClick}
|
|
295
|
+
/>
|
|
296
|
+
<div className="panel middle-panel">
|
|
297
|
+
<TaskBar
|
|
298
|
+
taskStatus={taskStatus}
|
|
299
|
+
onStartTask={handleStartTask}
|
|
300
|
+
onRollback={() => setShowRollbackConfirm(true)}
|
|
301
|
+
onComplete={handleCompleteTask}
|
|
302
|
+
/>
|
|
303
|
+
<TabBar
|
|
304
|
+
tabs={tabs}
|
|
305
|
+
activeTabId={activeTabId}
|
|
306
|
+
onTabClick={setActiveTabId}
|
|
307
|
+
onTabClose={closeTab}
|
|
308
|
+
onContextMenu={handleContextMenu}
|
|
309
|
+
/>
|
|
310
|
+
<div className="panel-content code-panel">
|
|
311
|
+
{!activeTab ? (
|
|
312
|
+
<div className="empty-state">双击左侧文件查看内容...</div>
|
|
313
|
+
) : activeTab.isDiff && activeTab.diff ? (
|
|
314
|
+
<div className="diff-container">
|
|
315
|
+
{activeTab.diff.map((line, i) => (
|
|
316
|
+
<div
|
|
317
|
+
key={i}
|
|
318
|
+
className={`diff-line ${line.added ? 'added' : line.removed ? 'removed' : ''}`}
|
|
319
|
+
>
|
|
320
|
+
{line.content}
|
|
321
|
+
</div>
|
|
322
|
+
))}
|
|
323
|
+
</div>
|
|
324
|
+
) : (
|
|
325
|
+
<pre className="code-content">{activeTab.content}</pre>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
{showRollbackConfirm && (
|
|
329
|
+
<div className="modal-overlay" onClick={() => setShowRollbackConfirm(false)}>
|
|
330
|
+
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
|
331
|
+
<div className="modal-title">确认撤回</div>
|
|
332
|
+
<div className="modal-body">
|
|
333
|
+
确定要撤回所有修改吗?此操作将把项目恢复到任务开始前的状态,且无法撤销。
|
|
334
|
+
</div>
|
|
335
|
+
<div className="modal-buttons">
|
|
336
|
+
<button className="modal-btn modal-btn-cancel" onClick={() => setShowRollbackConfirm(false)}>
|
|
337
|
+
取消
|
|
338
|
+
</button>
|
|
339
|
+
<button className="modal-btn modal-btn-danger" onClick={handleRollbackTask}>
|
|
340
|
+
确认撤回
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
{contextMenu && (
|
|
348
|
+
<ContextMenu
|
|
349
|
+
x={contextMenu.x}
|
|
350
|
+
y={contextMenu.y}
|
|
351
|
+
onClose={() => setContextMenu(null)}
|
|
352
|
+
onCloseTab={() => {
|
|
353
|
+
closeTab(contextMenu.tabId);
|
|
354
|
+
setContextMenu(null);
|
|
355
|
+
}}
|
|
356
|
+
onCloseOtherTabs={() => {
|
|
357
|
+
closeOtherTabs(contextMenu.tabId);
|
|
358
|
+
setContextMenu(null);
|
|
359
|
+
}}
|
|
360
|
+
onCloseAllTabs={() => {
|
|
361
|
+
closeAllTabs();
|
|
362
|
+
setContextMenu(null);
|
|
363
|
+
}}
|
|
364
|
+
/>
|
|
365
|
+
)}
|
|
366
|
+
<div className="panel right-panel">
|
|
367
|
+
<div className="panel-header">
|
|
368
|
+
<span>Codex 终端</span>
|
|
369
|
+
<span className={`ws-status ${wsStatus}`}></span>
|
|
370
|
+
<span className="version-info">
|
|
371
|
+
{version && <span className="version-number">v{version}</span>}
|
|
372
|
+
{hasUpdate && latestVersion && (
|
|
373
|
+
<span className="update-badge" title={`可用版本: ${latestVersion}`}>
|
|
374
|
+
更新可用
|
|
375
|
+
</span>
|
|
376
|
+
)}
|
|
377
|
+
</span>
|
|
378
|
+
</div>
|
|
379
|
+
<div className="terminal-wrapper">
|
|
380
|
+
<TerminalPanel />
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function TaskBar({ taskStatus, onStartTask, onRollback, onComplete }) {
|
|
388
|
+
return (
|
|
389
|
+
<div className="task-bar">
|
|
390
|
+
<div className={`task-status ${taskStatus}`}>
|
|
391
|
+
{taskStatus === 'idle' ? '空闲' : '任务进行中'}
|
|
392
|
+
</div>
|
|
393
|
+
<div className="task-buttons">
|
|
394
|
+
{taskStatus === 'idle' ? (
|
|
395
|
+
<button className="task-btn task-btn-start" onClick={onStartTask} title="Ctrl+Shift+Enter">
|
|
396
|
+
开始任务
|
|
397
|
+
</button>
|
|
398
|
+
) : (
|
|
399
|
+
<>
|
|
400
|
+
<button className="task-btn task-btn-rollback" onClick={onRollback} title="Ctrl+Z">
|
|
401
|
+
撤回
|
|
402
|
+
</button>
|
|
403
|
+
<button className="task-btn task-btn-complete" onClick={onComplete} title="Ctrl+Enter">
|
|
404
|
+
完成
|
|
405
|
+
</button>
|
|
406
|
+
</>
|
|
407
|
+
)}
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function TabBar({ tabs, activeTabId, onTabClick, onTabClose, onContextMenu }) {
|
|
414
|
+
return (
|
|
415
|
+
<div className="tab-bar">
|
|
416
|
+
{tabs.map(tab => (
|
|
417
|
+
<div
|
|
418
|
+
key={tab.id}
|
|
419
|
+
className={`tab ${activeTabId === tab.id ? 'active' : ''}`}
|
|
420
|
+
onClick={() => onTabClick(tab.id)}
|
|
421
|
+
onContextMenu={(e) => onContextMenu(e, tab.id)}
|
|
422
|
+
>
|
|
423
|
+
<span className="tab-name">{tab.name}</span>
|
|
424
|
+
<button
|
|
425
|
+
className="tab-close"
|
|
426
|
+
onClick={(e) => {
|
|
427
|
+
e.stopPropagation();
|
|
428
|
+
onTabClose(tab.id);
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
×
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
))}
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function ContextMenu({ x, y, onClose, onCloseTab, onCloseOtherTabs, onCloseAllTabs }) {
|
|
440
|
+
return (
|
|
441
|
+
<div className="context-menu" style={{ left: x, top: y }} onClick={e => e.stopPropagation()}>
|
|
442
|
+
<div className="context-menu-item" onClick={onCloseTab}>关闭标签页</div>
|
|
443
|
+
<div className="context-menu-item" onClick={onCloseOtherTabs}>关闭其他标签页</div>
|
|
444
|
+
<div className="context-menu-item" onClick={onCloseAllTabs}>关闭全部标签页</div>
|
|
445
|
+
</div>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function LeftPanel({ files, recentChanges, activeFile, onFileClick }) {
|
|
450
|
+
const [expandedDirs, setExpandedDirs] = useState({});
|
|
451
|
+
|
|
452
|
+
function toggleDir(path) {
|
|
453
|
+
setExpandedDirs(prev => ({
|
|
454
|
+
...prev,
|
|
455
|
+
[path]: !prev[path]
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function renderFileTree(items, depth = 0) {
|
|
460
|
+
return items.map((item, i) => {
|
|
461
|
+
const isDir = item.type === 'directory';
|
|
462
|
+
const isExpanded = expandedDirs[item.path];
|
|
463
|
+
const indent = 8 + depth * 16;
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<React.Fragment key={item.path}>
|
|
467
|
+
<div
|
|
468
|
+
className={`file-item ${activeFile === item.path ? 'active' : ''}`}
|
|
469
|
+
onClick={() => isDir ? toggleDir(item.path) : null}
|
|
470
|
+
onDoubleClick={() => !isDir && onFileClick(item.path)}
|
|
471
|
+
style={{ paddingLeft: `${indent}px` }}
|
|
472
|
+
>
|
|
473
|
+
<span className="file-icon">
|
|
474
|
+
{isDir ? (isExpanded ? '📂' : '📁') : getFileIcon(item.type)}
|
|
475
|
+
</span>
|
|
476
|
+
<span className="file-name">{item.name}</span>
|
|
477
|
+
</div>
|
|
478
|
+
{isDir && isExpanded && item.children && renderFileTree(item.children, depth + 1)}
|
|
479
|
+
</React.Fragment>
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return (
|
|
485
|
+
<div className="panel left-panel">
|
|
486
|
+
<div className="panel-header">
|
|
487
|
+
文件浏览器
|
|
488
|
+
</div>
|
|
489
|
+
<div className="panel-content">
|
|
490
|
+
{recentChanges.length > 0 && (
|
|
491
|
+
<div className="section">
|
|
492
|
+
<div className="section-title">最近修改</div>
|
|
493
|
+
{recentChanges.map((change, i) => (
|
|
494
|
+
<div
|
|
495
|
+
key={i}
|
|
496
|
+
className={`file-item ${activeFile === change.path ? 'active' : ''}`}
|
|
497
|
+
onDoubleClick={() => onFileClick(change.path)}
|
|
498
|
+
>
|
|
499
|
+
<span className="file-icon">📝</span>
|
|
500
|
+
<span className="file-name">{change.path.split(/[/\\]/).pop()}</span>
|
|
501
|
+
</div>
|
|
502
|
+
))}
|
|
503
|
+
</div>
|
|
504
|
+
)}
|
|
505
|
+
<div className="section">
|
|
506
|
+
<div className="section-title">项目文件</div>
|
|
507
|
+
{files.length === 0 ? (
|
|
508
|
+
<div className="empty-state">
|
|
509
|
+
{recentChanges.length === 0 ? '等待文件变化...' : '暂无其他文件'}
|
|
510
|
+
</div>
|
|
511
|
+
) : (
|
|
512
|
+
renderFileTree(files)
|
|
513
|
+
)}
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function getFileIcon(type) {
|
|
521
|
+
switch (type) {
|
|
522
|
+
case 'directory': return '📁';
|
|
523
|
+
case 'file': return '📄';
|
|
524
|
+
default: return '📄';
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export default App;
|