glad-web 1.0.7
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 +182 -0
- package/README.zh-CN.md +182 -0
- package/assets/logo.svg +40 -0
- package/bin/cli.js +65 -0
- package/lib/ai-tools/demo/enhanced-demo.js +625 -0
- package/lib/ai-tools/demo/index.js +24 -0
- package/lib/ai-tools/demo/responses.js +88 -0
- package/lib/ai-tools/detector.js +76 -0
- package/lib/ai-tools/registry.js +300 -0
- package/lib/commands/config.js +78 -0
- package/lib/commands/tools.js +128 -0
- package/lib/commands/web.js +454 -0
- package/lib/config/constants.js +17 -0
- package/lib/config/manager.js +71 -0
- package/lib/git/service.js +79 -0
- package/lib/schedule/job-runner.js +162 -0
- package/lib/schedule/job-store.js +150 -0
- package/lib/schedule/key-sequences.js +49 -0
- package/lib/schedule/scheduler-service.js +39 -0
- package/lib/session/buffer.js +102 -0
- package/lib/session/pty-manager.js +241 -0
- package/lib/session/rendered-history.js +225 -0
- package/lib/session/session-manager.js +382 -0
- package/lib/session/text-history.js +274 -0
- package/lib/utils/logger.js +74 -0
- package/lib/utils/pid.js +67 -0
- package/lib/utils/validation.js +53 -0
- package/lib/web/gitgraph.js +273 -0
- package/lib/web/index.html +1470 -0
- package/lib/workspace/service.js +76 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1470 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>Glad - AI Sessions</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
|
+
<link rel="manifest" href="/manifest.json">
|
|
9
|
+
<link rel="apple-touch-icon" href="/logo.svg">
|
|
10
|
+
<link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css" />
|
|
11
|
+
<style>
|
|
12
|
+
:root { --primary: #007aff; --bg: #000; --card-bg: #1c1c1e; --text: #fff; --text-dim: #8e8e93; --terminal-controls-rest-height: 108px; }
|
|
13
|
+
body, html { margin: 0; padding: 0; height: 100dvh; width: 100vw; background: var(--bg); color: var(--text); overflow: hidden; overflow-anchor: none; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
|
14
|
+
.view { display: none; height: 100%; width: 100%; flex-direction: column; }
|
|
15
|
+
.view.active { display: flex; }
|
|
16
|
+
#lobby { overflow-y: auto; padding: 20px; padding-top: env(safe-area-inset-top); box-sizing: border-box; }
|
|
17
|
+
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
|
18
|
+
.header h1 { font-size: 28px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 10px; }
|
|
19
|
+
.header-logo { width: 32px; height: 32px; flex-shrink: 0; }
|
|
20
|
+
.btn-new { background: var(--primary); color: #fff; border: none; padding: 8px 11px; border-radius: 16px; font-weight: 700; font-size: 13px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; min-height: 32px; white-space: nowrap; }
|
|
21
|
+
.btn-retry { background: #333; color: #fff; border: none; padding: 8px 16px; border-radius: 20px; margin-top: 10px; cursor: pointer; }
|
|
22
|
+
.session-card { background: var(--card-bg); border-radius: 12px; padding: 16px; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; transition: transform 0.1s; position: relative; }
|
|
23
|
+
.session-card:active { transform: scale(0.98); }
|
|
24
|
+
.completion-dot { width: 9px; height: 9px; border-radius: 50%; background: #ff3b30; flex-shrink: 0; }
|
|
25
|
+
.session-info { flex: 1; min-width: 0; }
|
|
26
|
+
.session-info h3 { margin: 0 0 4px 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
|
|
27
|
+
.session-info p { margin: 0; font-size: 13px; color: var(--text-dim); }
|
|
28
|
+
.session-actions { display: flex; gap: 12px; align-items: center; margin-left: 10px; }
|
|
29
|
+
.btn-join { background: rgba(255,255,255,0.1); border: none; color: var(--primary); padding: 8px 14px; border-radius: 18px; font-weight: 600; font-size: 14px; cursor: pointer; }
|
|
30
|
+
.icon-btn { color: var(--text-dim); background: none; border: none; padding: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
|
31
|
+
.icon-btn:active { color: var(--text); }
|
|
32
|
+
.btn-delete { color: #ff3b30; }
|
|
33
|
+
#modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 10000; display: none; align-items: center; justify-content: center; padding: 20px; }
|
|
34
|
+
#tool-modal { background: var(--card-bg); width: 100%; max-width: 400px; border-radius: 16px; padding: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
|
|
35
|
+
.tool-item { padding: 12px; border-bottom: 1px solid #333; cursor: pointer; display: flex; align-items: center; border-radius: 8px; margin-top: 4px; }
|
|
36
|
+
.tool-item:hover { background: rgba(255,255,255,0.05); }
|
|
37
|
+
.tool-icon { width: 32px; height: 32px; background: #333; border-radius: 8px; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
|
|
38
|
+
#terminal-view { background: #000; overflow-anchor: none; position: relative; }
|
|
39
|
+
#top-ui { flex-shrink: 0; background: #121212; border-bottom: 1px solid #222; z-index: 2000; padding-top: env(safe-area-inset-top); position: relative; }
|
|
40
|
+
#nav-bar { display: flex; align-items: center; padding: 8px 14px; border-bottom: 1px solid #222; }
|
|
41
|
+
#back-btn { color: var(--primary); font-size: 16px; font-weight: 500; display: flex; align-items: center; text-decoration: none; background: none; border: none; padding: 0; cursor: pointer; flex-shrink: 0; }
|
|
42
|
+
#session-title { flex: 1; text-align: center; font-weight: 600; font-size: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 10px; min-width: 0; }
|
|
43
|
+
#terminal-controls { position: absolute; top: 100%; left: 0; right: 0; z-index: 2200; background: linear-gradient(180deg, rgba(18,18,18,0.98) 0%, rgba(18,18,18,0.92) 78%, rgba(18,18,18,0) 100%); border-bottom: 1px solid rgba(255,255,255,0.06); }
|
|
44
|
+
#terminal-controls-spacer { flex: 0 0 var(--terminal-controls-rest-height); height: var(--terminal-controls-rest-height); background: #000; }
|
|
45
|
+
#input-row { display: flex; align-items: flex-end; padding: 10px 14px 8px 14px; }
|
|
46
|
+
#cmd-input { flex: 1; min-height: 38px; max-height: 150px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #fff; padding: 9px 16px; font-size: 16px; outline: none; resize: none; overflow-y: auto; line-height: 20px; box-sizing: border-box; transition: background 0.18s ease, border-color 0.18s ease; }
|
|
47
|
+
#cmd-input::placeholder { color: rgba(255,255,255,0.45); }
|
|
48
|
+
#cmd-input:focus { background: rgba(255,255,255,0.1); border-color: rgba(0,122,255,0.42); color: #fff; }
|
|
49
|
+
#send-btn { width: 44px; height: 38px; margin-left: 10px; background: #007aff; border: none; border-radius: 19px; color: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
|
|
50
|
+
#shortcut-rail { display: flex; gap: 28px; overflow-x: auto; overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; scrollbar-width: none; scroll-snap-type: x mandatory; padding: 0 14px 12px 14px; box-sizing: border-box; scroll-padding-left: 14px; scroll-padding-right: 14px; }
|
|
51
|
+
#shortcut-rail::-webkit-scrollbar { display: none; }
|
|
52
|
+
.shortcut-group { flex: 0 0 calc(100vw - 28px); display: grid; gap: 8px; scroll-snap-align: start; }
|
|
53
|
+
.simple-shortcuts { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
54
|
+
.complex-shortcuts { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
|
55
|
+
.key-btn, .command-key { flex: 0 0 auto; min-width: 0; height: 40px; border: 1px solid rgba(255,255,255,0.11); border-radius: 12px; background: rgba(255,255,255,0.08); color: #d1d5db; font-size: 12px; font-weight: 700; letter-spacing: 0; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; white-space: nowrap; }
|
|
56
|
+
.key-btn { font-size: 11px; }
|
|
57
|
+
.shortcut-group .key-btn, .shortcut-group .command-key { width: 100%; }
|
|
58
|
+
.key-btn:active, .command-key:active { background: rgba(255,255,255,0.14); color: #fff; }
|
|
59
|
+
.key-btn.active { background: rgba(0, 122, 255, 0.25); border-color: #007aff; color: #00a2ff; }
|
|
60
|
+
#terminal-container { flex: 1; min-height: 0; width: 100%; background: #000; position: relative; overflow: hidden; overflow-anchor: none; }
|
|
61
|
+
#terminal { height: 100%; width: 100%; }
|
|
62
|
+
#terminal .xterm, #terminal .xterm-viewport, #terminal .xterm-screen, #terminal .xterm-rows, #terminal .xterm-rows span { user-select: text !important; -webkit-user-select: text !important; -webkit-touch-callout: default; touch-action: pan-y; }
|
|
63
|
+
#terminal .xterm-viewport { overflow-anchor: none; }
|
|
64
|
+
#terminal .xterm-helper-textarea { user-select: none !important; -webkit-user-select: none !important; -webkit-touch-callout: none; }
|
|
65
|
+
#scroll-controls { position: fixed; right: 14px; top: 60%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 18px; z-index: 3000; }
|
|
66
|
+
.scroll-btn { --charge: 0deg; width: 56px; height: 56px; background: rgba(30, 30, 30, 0.5); backdrop-filter: blur(15px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 28px; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 20px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); cursor: pointer; position: relative; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; }
|
|
67
|
+
.scroll-btn::before { content: ''; position: absolute; inset: 3px; border-radius: 50%; background: conic-gradient(var(--primary) var(--charge), transparent 0deg); opacity: 0; transition: opacity 0.12s ease; }
|
|
68
|
+
.scroll-btn::after { content: ''; position: absolute; inset: 7px; border-radius: 50%; background: rgba(30, 30, 30, 0.72); border: 1px solid rgba(255,255,255,0.08); }
|
|
69
|
+
.scroll-btn.charging::before { opacity: 1; }
|
|
70
|
+
.scroll-btn span { position: relative; z-index: 1; }
|
|
71
|
+
#debug-log { position: fixed; bottom: 0; left: 0; right: 0; max-height: 100px; background: rgba(0,0,0,0.8); color: #0f0; font-size: 10px; overflow-y: auto; padding: 5px; z-index: 9999; display: none; pointer-events: none; }
|
|
72
|
+
|
|
73
|
+
#history-content { flex: 1; background: #000; color: #d1d5db; padding: 14px; overflow-y: auto; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text !important; -webkit-user-select: text !important; box-sizing: border-box; }
|
|
74
|
+
#history-content.nowrap { white-space: pre; overflow-wrap: normal; overflow-x: auto; }
|
|
75
|
+
.history-meta { color: var(--text-dim); font-size: 11px; padding: 8px 14px; border-bottom: 1px solid rgba(255,255,255,0.06); background: #0d0d0d; }
|
|
76
|
+
|
|
77
|
+
.tab-bar { display: flex; border-bottom: 1px solid rgba(255,255,255,0.1); background: var(--bg); }
|
|
78
|
+
.tab-btn { flex: 1; text-align: center; padding: 12px 0; font-size: 14px; font-weight: 600; color: var(--text-dim); background: none; border: none; cursor: pointer; position: relative; }
|
|
79
|
+
.tab-btn.active { color: var(--text); }
|
|
80
|
+
.tab-btn.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); height: 2px; width: 40px; background: var(--primary); border-radius: 2px; }
|
|
81
|
+
.list-item { display: flex; align-items: center; padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s; }
|
|
82
|
+
.list-item:hover { background: rgba(255,255,255,0.05); }
|
|
83
|
+
.dir-icon { color: var(--primary); margin-right: 12px; }
|
|
84
|
+
.file-icon { color: var(--text-dim); margin-right: 12px; }
|
|
85
|
+
.btn-toggle { background: var(--card-bg); border-radius: 6px; padding: 4px; display: flex; gap: 4px; margin: 10px; }
|
|
86
|
+
.btn-toggle button { flex: 1; padding: 6px 0; border-radius: 4px; border: none; background: transparent; color: var(--text-dim); font-size: 13px; font-weight: 600; cursor: pointer; }
|
|
87
|
+
.btn-toggle button.active { background: var(--primary); color: #fff; }
|
|
88
|
+
.lobby-tabs { display: flex; background: var(--card-bg); border-radius: 8px; padding: 4px; margin-bottom: 16px; gap: 4px; }
|
|
89
|
+
.lobby-tab { flex: 1; border: none; border-radius: 6px; background: transparent; color: var(--text-dim); padding: 8px; font-size: 14px; font-weight: 700; cursor: pointer; }
|
|
90
|
+
.lobby-tab.active { background: var(--primary); color: #fff; }
|
|
91
|
+
.schedule-card { background: var(--card-bg); border-radius: 12px; padding: 16px; margin-bottom: 12px; }
|
|
92
|
+
.schedule-row { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; }
|
|
93
|
+
.schedule-meta { margin-top: 6px; color: var(--text-dim); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
94
|
+
.schedule-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
95
|
+
.small-btn { border: none; border-radius: 16px; padding: 7px 10px; font-weight: 700; font-size: 12px; color: #fff; background: rgba(255,255,255,0.1); cursor: pointer; }
|
|
96
|
+
.small-btn.primary { background: var(--primary); }
|
|
97
|
+
.small-btn.danger { background: rgba(255,59,48,0.18); color: #ff6b61; }
|
|
98
|
+
.form-field { margin-bottom: 12px; }
|
|
99
|
+
.form-field label { display:block; margin-bottom: 6px; font-size: 13px; color: var(--text-dim); font-weight: 600; }
|
|
100
|
+
.form-field input, .form-field select, .form-field textarea { width: 100%; box-sizing: border-box; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); color: #fff; border-radius: 8px; padding: 9px 10px; font-size: 14px; outline: none; }
|
|
101
|
+
.weekday-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
|
|
102
|
+
.weekday-btn { border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.05); color: var(--text-dim); border-radius: 8px; padding: 8px 0; font-size: 12px; font-weight: 700; cursor: pointer; }
|
|
103
|
+
.weekday-btn.active { border-color: var(--primary); background: rgba(0,122,255,0.25); color: #fff; }
|
|
104
|
+
#schedule-modal { background: var(--card-bg); width: 100%; max-width: 720px; max-height: 92dvh; overflow-y: auto; border-radius: 16px; padding: 18px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); box-sizing: border-box; }
|
|
105
|
+
.step-card { border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 10px; margin-bottom: 8px; background: rgba(255,255,255,0.04); }
|
|
106
|
+
.step-grid { display: grid; grid-template-columns: minmax(110px, 150px) 1fr auto; gap: 8px; align-items: center; }
|
|
107
|
+
@media (max-width: 640px) {
|
|
108
|
+
.schedule-row { flex-direction: column; }
|
|
109
|
+
.schedule-actions { justify-content: flex-start; }
|
|
110
|
+
.step-grid { grid-template-columns: 1fr; }
|
|
111
|
+
}
|
|
112
|
+
</style>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<div id="debug-log"></div>
|
|
116
|
+
|
|
117
|
+
<!-- Lobby View -->
|
|
118
|
+
<div id="lobby-view" class="view active">
|
|
119
|
+
<div id="lobby">
|
|
120
|
+
<div class="header">
|
|
121
|
+
<h1><img class="header-logo" src="/logo.svg" alt="">Glad</h1>
|
|
122
|
+
<div style="display:flex; gap:6px;">
|
|
123
|
+
<button class="btn-new" onclick="showToolModal()" title="New session"><span>+</span><span>Session</span></button>
|
|
124
|
+
<button class="btn-new" onclick="showScheduleModal()" title="New scheduler"><span>+</span><span>Sched</span></button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="lobby-tabs">
|
|
128
|
+
<button id="lobby-tab-sessions" class="lobby-tab active" onclick="switchLobbyTab('sessions')">Sessions</button>
|
|
129
|
+
<button id="lobby-tab-schedules" class="lobby-tab" onclick="switchLobbyTab('schedules')">Schedules</button>
|
|
130
|
+
</div>
|
|
131
|
+
<div id="sessions-list">
|
|
132
|
+
<p style="color:#888; text-align:center; margin-top:50px;">Loading sessions...</p>
|
|
133
|
+
</div>
|
|
134
|
+
<div id="schedules-list" style="display:none;">
|
|
135
|
+
<p style="color:#888; text-align:center; margin-top:50px;">Loading schedules...</p>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<!-- Terminal View -->
|
|
141
|
+
<div id="terminal-view" class="view">
|
|
142
|
+
<div id="top-ui">
|
|
143
|
+
<div id="nav-bar">
|
|
144
|
+
<button id="back-btn" onclick="showLobby()">
|
|
145
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
|
146
|
+
Lobby
|
|
147
|
+
</button>
|
|
148
|
+
<div id="session-title">Terminal</div>
|
|
149
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
150
|
+
<button class="icon-btn" onclick="showHistory()" title="History" style="color: var(--primary); font-size: 12px; font-weight: 700; background: rgba(0, 122, 255, 0.1); padding: 5px 9px; border-radius: 15px; gap: 4px;">
|
|
151
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"></path><polyline points="3 3 3 9 9 9"></polyline><path d="M12 7v5l3 2"></path></svg>
|
|
152
|
+
HIS
|
|
153
|
+
</button>
|
|
154
|
+
<button class="icon-btn" onclick="showGitPreview()" title="Git" style="color: var(--primary); font-size: 12px; font-weight: 700; background: rgba(0, 122, 255, 0.1); padding: 5px 9px; border-radius: 15px; gap: 4px;">
|
|
155
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"></circle><circle cx="18" cy="6" r="2.5"></circle><circle cx="12" cy="18" r="2.5"></circle><path d="M8.2 7.6 11 15.7"></path><path d="M15.8 7.6 13 15.7"></path></svg>
|
|
156
|
+
GIT
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
<div id="terminal-controls">
|
|
161
|
+
<div id="input-row">
|
|
162
|
+
<textarea id="cmd-input" rows="1" placeholder="Type a message..."></textarea>
|
|
163
|
+
<button id="send-btn">
|
|
164
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
<div id="shortcut-rail">
|
|
168
|
+
<div class="shortcut-group simple-shortcuts">
|
|
169
|
+
<div class="key-btn special-key" data-key="up">↑</div>
|
|
170
|
+
<div class="key-btn special-key" data-key="down">↓</div>
|
|
171
|
+
<div class="key-btn" data-key="ctrl">Ctrl</div>
|
|
172
|
+
<div class="key-btn special-key" data-key="enter">⏎</div>
|
|
173
|
+
<div class="key-btn" data-key="esc">Esc</div>
|
|
174
|
+
<div class="key-btn" data-key="tab">Tab</div>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="shortcut-group complex-shortcuts">
|
|
177
|
+
<div class="key-btn" data-key="ctrl-c">Ctrl+C</div>
|
|
178
|
+
<div class="key-btn" data-key="ctrl-y" data-role="tool-action-shortcut" title="Send Ctrl+Y">Ctrl+Y</div>
|
|
179
|
+
<button class="command-key" data-command="/model" title="Send /model">/model</button>
|
|
180
|
+
<button class="command-key" data-command="/resume" title="Send /resume">/resume</button>
|
|
181
|
+
<button class="command-key" data-command="/stat" title="Send /stat">/stat</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
<div id="scroll-controls">
|
|
187
|
+
<div class="scroll-btn" id="scroll-up"><span>▲</span></div>
|
|
188
|
+
<div class="scroll-btn" id="scroll-down"><span>▼</span></div>
|
|
189
|
+
</div>
|
|
190
|
+
<div id="terminal-controls-spacer"></div>
|
|
191
|
+
<div id="terminal-container"><div id="terminal"></div></div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<!-- Plain Text History View -->
|
|
195
|
+
<div id="history-view" class="view">
|
|
196
|
+
<div style="flex-shrink: 0; background: #121212; border-bottom: 1px solid #222; padding-top: env(safe-area-inset-top);">
|
|
197
|
+
<div style="display: flex; align-items: center; padding: 8px 14px;">
|
|
198
|
+
<button class="icon-btn" onclick="showTerminal()" style="color: var(--primary); font-size: 16px; font-weight: 500;">
|
|
199
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
|
200
|
+
Terminal
|
|
201
|
+
</button>
|
|
202
|
+
<div style="flex: 1; text-align: center; font-weight: 600; font-size: 16px;">History</div>
|
|
203
|
+
<button class="icon-btn" onclick="loadHistory()" title="Refresh" style="color: var(--primary); margin-right: 8px;">
|
|
204
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><polyline points="21 3 21 9 15 9"></polyline></svg>
|
|
205
|
+
</button>
|
|
206
|
+
<button class="icon-btn" onclick="copyHistoryContent()" title="Copy all" style="color: var(--text-dim); margin-right: 8px;">
|
|
207
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
208
|
+
</button>
|
|
209
|
+
<button id="history-wrap-btn" class="icon-btn" onclick="toggleHistoryWrap()" title="Toggle wrap" style="color: var(--text-dim);">
|
|
210
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="4" x2="4" y2="20"></line><line x1="20" y1="4" x2="20" y2="20"></line><path d="M8 8h7a3 3 0 0 1 0 6H9"></path><polyline points="11 11 8 14 11 17"></polyline></svg>
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
<div id="history-meta" class="history-meta">Loading...</div>
|
|
215
|
+
<pre id="history-content">Loading...</pre>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<!-- Git View -->
|
|
219
|
+
<div id="git-view" class="view">
|
|
220
|
+
<div style="flex-shrink: 0; background: #121212; border-bottom: 1px solid #222; padding-top: env(safe-area-inset-top);">
|
|
221
|
+
<div style="display: flex; align-items: center; padding: 8px 14px;">
|
|
222
|
+
<button class="icon-btn" onclick="showTerminal()" style="color: var(--primary); font-size: 16px; font-weight: 500;">
|
|
223
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
|
224
|
+
Terminal
|
|
225
|
+
</button>
|
|
226
|
+
<div style="flex: 1; text-align: center; font-weight: 600; font-size: 16px;">Git Status</div>
|
|
227
|
+
<button class="icon-btn" onclick="loadGitStatus()" style="color: var(--primary);">
|
|
228
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><polyline points="21 3 21 9 15 9"></polyline></svg>
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
<div class="tab-bar">
|
|
233
|
+
<button id="tab-changes" class="tab-btn active" onclick="switchGitTab('changes')">Changes</button>
|
|
234
|
+
<button id="tab-directories" class="tab-btn" onclick="switchGitTab('directories')">Directories</button>
|
|
235
|
+
<button id="tab-graph" class="tab-btn" onclick="switchGitTab('graph')">Graph</button>
|
|
236
|
+
</div>
|
|
237
|
+
<div id="git-content" style="flex: 1; overflow-y: auto; padding: 0; background: var(--bg);">
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<!-- Tool Modal -->
|
|
242
|
+
<div id="modal-overlay" onclick="closeToolModal(event)">
|
|
243
|
+
<div id="tool-modal" onclick="event.stopPropagation()">
|
|
244
|
+
<h2 style="margin-top:0">Create Session</h2>
|
|
245
|
+
<div style="margin-bottom: 15px;">
|
|
246
|
+
<label style="display:block; margin-bottom: 8px; font-size: 14px; color: var(--text-dim);">Working Directory (Optional):</label>
|
|
247
|
+
<div style="margin-bottom: 8px; color: var(--text-dim); font-size: 12px; line-height: 1.4;">
|
|
248
|
+
Default: <span id="default-cwd" style="font-family: monospace; color: #d1d5db; overflow-wrap: anywhere;">Loading...</span>
|
|
249
|
+
</div>
|
|
250
|
+
<textarea id="cwd-field" rows="2" placeholder="Optional path" style="width: 100%; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; padding: 10px; font-size: 14px; box-sizing: border-box; resize: vertical; font-family: monospace; outline: none;"></textarea>
|
|
251
|
+
</div>
|
|
252
|
+
<div id="tools-list" style="max-height: 300px; overflow-y: auto;">
|
|
253
|
+
<p>Detecting tools...</p>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<!-- Schedule Modal -->
|
|
259
|
+
<div id="schedule-modal-overlay" onclick="closeScheduleModal(event)" style="position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 10001; display: none; align-items: center; justify-content: center; padding: 16px;">
|
|
260
|
+
<div id="schedule-modal" onclick="event.stopPropagation()">
|
|
261
|
+
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:14px;">
|
|
262
|
+
<h2 id="schedule-modal-title" style="margin:0; font-size:20px;">Schedule</h2>
|
|
263
|
+
<button class="icon-btn" onclick="closeScheduleModal()" style="color: var(--text-dim); font-size: 24px;">×</button>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="form-field">
|
|
266
|
+
<label>Name</label>
|
|
267
|
+
<input id="schedule-name" type="text" placeholder="Scheduled task">
|
|
268
|
+
</div>
|
|
269
|
+
<div class="form-field">
|
|
270
|
+
<label>Tool</label>
|
|
271
|
+
<select id="schedule-tool"></select>
|
|
272
|
+
</div>
|
|
273
|
+
<div class="form-field">
|
|
274
|
+
<label>Working Directory</label>
|
|
275
|
+
<input id="schedule-cwd" type="text" placeholder="Leave empty for default directory">
|
|
276
|
+
</div>
|
|
277
|
+
<div class="form-field">
|
|
278
|
+
<label>Trigger Time</label>
|
|
279
|
+
<input id="schedule-time" type="time" value="09:00">
|
|
280
|
+
</div>
|
|
281
|
+
<div class="form-field">
|
|
282
|
+
<label>Weekdays</label>
|
|
283
|
+
<div id="schedule-weekdays" class="weekday-grid"></div>
|
|
284
|
+
</div>
|
|
285
|
+
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; margin: 14px 0 8px;">
|
|
286
|
+
<div style="font-weight:700;">Steps</div>
|
|
287
|
+
<select id="add-step-type" style="background:rgba(255,255,255,0.08); color:#fff; border:1px solid rgba(255,255,255,0.12); border-radius:8px; padding:8px;" onchange="addScheduleStep(this.value); this.value='';">
|
|
288
|
+
<option value="">Add step</option>
|
|
289
|
+
<option value="sleep">Sleep</option>
|
|
290
|
+
<option value="sendText">Send text</option>
|
|
291
|
+
<option value="sendKey">Send key</option>
|
|
292
|
+
<option value="keyDown">Key down</option>
|
|
293
|
+
<option value="keyUp">Key up</option>
|
|
294
|
+
<option value="stop">Stop</option>
|
|
295
|
+
<option value="closeSession">Close session</option>
|
|
296
|
+
</select>
|
|
297
|
+
</div>
|
|
298
|
+
<div id="schedule-steps"></div>
|
|
299
|
+
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:16px;">
|
|
300
|
+
<button class="small-btn" onclick="closeScheduleModal()">Cancel</button>
|
|
301
|
+
<button class="small-btn primary" onclick="saveSchedule()">Save</button>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<script defer src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
|
|
307
|
+
<script defer src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
|
308
|
+
<script src="/gitgraph.js"></script>
|
|
309
|
+
<script>
|
|
310
|
+
let currentSocket = null, term = null, fitAddon = null, activeSessionId = null, activeToolKey = null;
|
|
311
|
+
let appConfig = null;
|
|
312
|
+
let sessionPollTimer = null;
|
|
313
|
+
let lobbyTab = 'sessions';
|
|
314
|
+
let scheduleTools = [];
|
|
315
|
+
let editingScheduleId = null;
|
|
316
|
+
let editingSteps = [];
|
|
317
|
+
let selectedWeekdays = [1, 2, 3, 4, 5];
|
|
318
|
+
const modifiers = { ctrl: false };
|
|
319
|
+
|
|
320
|
+
function log(msg) {
|
|
321
|
+
const el = document.getElementById('debug-log');
|
|
322
|
+
const entry = document.createElement('div');
|
|
323
|
+
entry.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
|
|
324
|
+
el.appendChild(entry);
|
|
325
|
+
console.log(msg);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
|
|
329
|
+
const controller = new AbortController();
|
|
330
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
331
|
+
try {
|
|
332
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
333
|
+
clearTimeout(id);
|
|
334
|
+
return response;
|
|
335
|
+
} catch (e) {
|
|
336
|
+
clearTimeout(id);
|
|
337
|
+
if (e && e.name === 'AbortError') {
|
|
338
|
+
throw new Error(`Request timed out after ${Math.round(timeout / 1000)}s`);
|
|
339
|
+
}
|
|
340
|
+
throw e;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function escapeHtml(value = '') {
|
|
345
|
+
return String(value)
|
|
346
|
+
.replace(/&/g, '&')
|
|
347
|
+
.replace(/</g, '<')
|
|
348
|
+
.replace(/>/g, '>')
|
|
349
|
+
.replace(/"/g, '"')
|
|
350
|
+
.replace(/'/g, ''');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function encodePathValue(path = '') {
|
|
354
|
+
return encodeURIComponent(path).replace(/'/g, '%27');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function decodePathValue(path = '') {
|
|
358
|
+
return decodeURIComponent(path);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function formatWeekdays(days = []) {
|
|
362
|
+
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
363
|
+
return days.map(day => labels[day]).filter(Boolean).join(', ') || 'No days';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function formatDateTime(value) {
|
|
367
|
+
return value ? new Date(value).toLocaleString() : 'Not scheduled';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function switchLobbyTab(tab) {
|
|
371
|
+
lobbyTab = tab;
|
|
372
|
+
document.getElementById('lobby-tab-sessions').classList.toggle('active', tab === 'sessions');
|
|
373
|
+
document.getElementById('lobby-tab-schedules').classList.toggle('active', tab === 'schedules');
|
|
374
|
+
document.getElementById('sessions-list').style.display = tab === 'sessions' ? '' : 'none';
|
|
375
|
+
document.getElementById('schedules-list').style.display = tab === 'schedules' ? '' : 'none';
|
|
376
|
+
if (tab === 'sessions') refreshSessionsNow();
|
|
377
|
+
if (tab === 'schedules') loadSchedules();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function loadSchedules() {
|
|
381
|
+
const list = document.getElementById('schedules-list');
|
|
382
|
+
list.innerHTML = '<p style="color:#888; text-align:center; margin-top:50px;">Loading schedules...</p>';
|
|
383
|
+
try {
|
|
384
|
+
await ensureScheduleTools().catch(() => {});
|
|
385
|
+
const res = await fetchWithTimeout('/api/schedules');
|
|
386
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
387
|
+
const schedules = await res.json();
|
|
388
|
+
if (!schedules.length) {
|
|
389
|
+
list.innerHTML = '<p style="color:#888; text-align:center; margin-top:50px;">No scheduled tasks</p>';
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let html = '';
|
|
394
|
+
schedules.forEach(job => {
|
|
395
|
+
const encodedId = encodePathValue(job.id);
|
|
396
|
+
const tool = scheduleTools.find(t => t.key === job.target.toolKey);
|
|
397
|
+
const statusColor = job.lastRunStatus === 'failed' ? '#ff6b61' : job.running ? '#5ac8fa' : 'var(--text-dim)';
|
|
398
|
+
html += `<div class="schedule-card">
|
|
399
|
+
<div class="schedule-row">
|
|
400
|
+
<div style="flex:1; min-width:0;">
|
|
401
|
+
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
|
|
402
|
+
<strong>${escapeHtml(job.name)}</strong>
|
|
403
|
+
<span style="font-size:11px; color:${job.enabled ? '#34c759' : '#8e8e93'}; border:1px solid currentColor; border-radius:10px; padding:1px 7px;">${job.enabled ? 'ON' : 'OFF'}</span>
|
|
404
|
+
</div>
|
|
405
|
+
<div class="schedule-meta">
|
|
406
|
+
${escapeHtml(job.schedule.time)} | ${escapeHtml(formatWeekdays(job.schedule.weekdays))}<br>
|
|
407
|
+
${escapeHtml(tool ? tool.displayName : job.target.toolKey)} | ${escapeHtml(job.target.workingDirectory || 'Default directory')}<br>
|
|
408
|
+
Next: ${escapeHtml(formatDateTime(job.nextRunAt))}<br>
|
|
409
|
+
<span style="color:${statusColor};">Last: ${escapeHtml(job.lastRunStatus || 'idle')}${job.lastRunMessage ? ' - ' + escapeHtml(job.lastRunMessage) : ''}</span>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
<div class="schedule-actions">
|
|
413
|
+
<button class="small-btn primary" onclick="simulateSchedule(decodePathValue('${encodedId}'))">Test</button>
|
|
414
|
+
<button class="small-btn" onclick="editSchedule(decodePathValue('${encodedId}'))">Edit</button>
|
|
415
|
+
<button class="small-btn" onclick="duplicateSchedule(decodePathValue('${encodedId}'))">Copy</button>
|
|
416
|
+
<button class="small-btn" onclick="toggleSchedule(decodePathValue('${encodedId}'), ${job.enabled ? 'false' : 'true'})">${job.enabled ? 'Disable' : 'Enable'}</button>
|
|
417
|
+
<button class="small-btn danger" onclick="deleteSchedule(decodePathValue('${encodedId}'))">Delete</button>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
</div>`;
|
|
421
|
+
});
|
|
422
|
+
list.innerHTML = html;
|
|
423
|
+
} catch (e) {
|
|
424
|
+
list.innerHTML = `<div style="color:#ff3b30; text-align:center; margin-top:50px;"><p>Failed to load: ${escapeHtml(e.message)}</p><button class="btn-retry" onclick="loadSchedules()">Retry</button></div>`;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function loadSessions() {
|
|
429
|
+
log('Loading sessions...');
|
|
430
|
+
const list = document.getElementById('sessions-list');
|
|
431
|
+
try {
|
|
432
|
+
const res = await fetchWithTimeout('/api/sessions');
|
|
433
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
434
|
+
const sessions = await res.json();
|
|
435
|
+
if (!sessions || sessions.length === 0) {
|
|
436
|
+
list.innerHTML = '<p style="color:#888; text-align:center; margin-top:50px;">No active sessions</p>';
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let html = '';
|
|
441
|
+
sessions.forEach(s => {
|
|
442
|
+
const workingDirectory = s.workingDirectory || 'Unknown directory';
|
|
443
|
+
const encodedName = encodePathValue(s.name);
|
|
444
|
+
html += `<div class="session-card">
|
|
445
|
+
<div class="session-info">
|
|
446
|
+
<h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''} <button class="icon-btn" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
|
|
447
|
+
<p>${escapeHtml(s.tool)}</p>
|
|
448
|
+
<p>${new Date(s.startTime).toLocaleTimeString()}</p>
|
|
449
|
+
<p title="${escapeHtml(workingDirectory)}" style="font-family: monospace; overflow-wrap: anywhere;">${escapeHtml(workingDirectory)}</p>
|
|
450
|
+
</div>
|
|
451
|
+
<div class="session-actions">
|
|
452
|
+
<button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
|
|
453
|
+
<button class="icon-btn btn-delete" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
|
|
454
|
+
</div>
|
|
455
|
+
</div>`;
|
|
456
|
+
});
|
|
457
|
+
list.innerHTML = html;
|
|
458
|
+
} catch (e) {
|
|
459
|
+
list.innerHTML = `<div style="color:#ff3b30; text-align:center; margin-top:50px;"><p>Failed to load: ${e.message}</p><button class="btn-retry" onclick="loadSessions()">Retry</button></div>`;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function renameSession(id, oldName, e) {
|
|
464
|
+
e.stopPropagation();
|
|
465
|
+
const newName = prompt('Rename session', oldName);
|
|
466
|
+
if (newName && newName !== oldName) {
|
|
467
|
+
try {
|
|
468
|
+
await fetchWithTimeout('/api/sessions/' + id, {
|
|
469
|
+
method: 'PATCH',
|
|
470
|
+
headers: { 'Content-Type': 'application/json' },
|
|
471
|
+
body: JSON.stringify({ name: newName })
|
|
472
|
+
});
|
|
473
|
+
refreshSessionsNow();
|
|
474
|
+
} catch (e) { alert('Rename failed'); }
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function showToolModal() {
|
|
479
|
+
document.getElementById('modal-overlay').style.display = 'flex';
|
|
480
|
+
const list = document.getElementById('tools-list');
|
|
481
|
+
loadAppConfig();
|
|
482
|
+
try {
|
|
483
|
+
const res = await fetchWithTimeout('/api/tools');
|
|
484
|
+
const tools = await res.json();
|
|
485
|
+
|
|
486
|
+
let html = '';
|
|
487
|
+
tools.forEach(t => {
|
|
488
|
+
const versionLabel = t.version && t.version !== 'unknown' ? `v${t.version}` : 'version unknown';
|
|
489
|
+
html += `<div class="tool-item" onclick='createSession(${JSON.stringify(t.key)}, ${JSON.stringify(t.displayName)})'>
|
|
490
|
+
<div class="tool-icon">${t.displayName[0]}</div>
|
|
491
|
+
<div>
|
|
492
|
+
<div style="font-weight:600">${t.displayName}</div>
|
|
493
|
+
<div style="font-size:12px; color:#888">${versionLabel}</div>
|
|
494
|
+
</div>
|
|
495
|
+
</div>`;
|
|
496
|
+
});
|
|
497
|
+
list.innerHTML = html;
|
|
498
|
+
} catch (e) {
|
|
499
|
+
list.innerHTML = `<p style="color:#ff3b30">Detection failed: ${e.message}</p>`;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function loadAppConfig() {
|
|
504
|
+
if (appConfig) {
|
|
505
|
+
document.getElementById('default-cwd').textContent = appConfig.defaultWorkingDirectory || 'Current folder';
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
const res = await fetchWithTimeout('/api/config');
|
|
510
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
511
|
+
appConfig = await res.json();
|
|
512
|
+
document.getElementById('default-cwd').textContent = appConfig.defaultWorkingDirectory || 'Current folder';
|
|
513
|
+
} catch (e) {
|
|
514
|
+
document.getElementById('default-cwd').textContent = 'Current folder';
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function closeToolModal(e) {
|
|
519
|
+
if (e.target.id === 'modal-overlay') document.getElementById('modal-overlay').style.display = 'none';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isLobbyVisible() {
|
|
523
|
+
return document.getElementById('lobby-view').classList.contains('active');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function scheduleSessionPolling() {
|
|
527
|
+
clearTimeout(sessionPollTimer);
|
|
528
|
+
sessionPollTimer = null;
|
|
529
|
+
if (!isLobbyVisible()) return;
|
|
530
|
+
|
|
531
|
+
const delay = document.hidden ? 30000 : 10000;
|
|
532
|
+
sessionPollTimer = setTimeout(async () => {
|
|
533
|
+
await loadSessions();
|
|
534
|
+
scheduleSessionPolling();
|
|
535
|
+
}, delay);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function refreshSessionsNow() {
|
|
539
|
+
clearTimeout(sessionPollTimer);
|
|
540
|
+
sessionPollTimer = null;
|
|
541
|
+
if (!isLobbyVisible()) return;
|
|
542
|
+
if (lobbyTab === 'schedules') {
|
|
543
|
+
await loadSchedules();
|
|
544
|
+
scheduleSessionPolling();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
await loadSessions();
|
|
548
|
+
scheduleSessionPolling();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function markCompletionRead(id) {
|
|
552
|
+
try {
|
|
553
|
+
await fetchWithTimeout('/api/sessions/' + id + '/completion/read', { method: 'POST' });
|
|
554
|
+
} catch (e) {
|
|
555
|
+
log('Failed to mark completion read: ' + e.message);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function createSession(toolKey, sessionName) {
|
|
560
|
+
try {
|
|
561
|
+
const workingDirectory = document.getElementById('cwd-field').value;
|
|
562
|
+
const res = await fetchWithTimeout('/api/sessions', {
|
|
563
|
+
method: 'POST',
|
|
564
|
+
headers: { 'Content-Type': 'application/json' },
|
|
565
|
+
body: JSON.stringify({ toolKey, workingDirectory })
|
|
566
|
+
});
|
|
567
|
+
const data = await res.json();
|
|
568
|
+
if (!res.ok || !data.id) throw new Error(data.error || 'Failed to create session');
|
|
569
|
+
document.getElementById('modal-overlay').style.display = 'none';
|
|
570
|
+
refreshSessionsNow();
|
|
571
|
+
} catch (e) { alert('Failed to create session: ' + e.message); }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function ensureScheduleTools() {
|
|
575
|
+
if (scheduleTools.length) return;
|
|
576
|
+
const res = await fetchWithTimeout('/api/tools');
|
|
577
|
+
scheduleTools = await res.json();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function defaultSchedule() {
|
|
581
|
+
return {
|
|
582
|
+
id: null,
|
|
583
|
+
name: 'Scheduled Task',
|
|
584
|
+
enabled: true,
|
|
585
|
+
schedule: { time: '09:00', weekdays: [1, 2, 3, 4, 5] },
|
|
586
|
+
target: { toolKey: (scheduleTools[0] && scheduleTools[0].key) || 'demo', workingDirectory: '' },
|
|
587
|
+
steps: [
|
|
588
|
+
{ type: 'sleep', seconds: 60 },
|
|
589
|
+
{ type: 'sendText', text: 'hello' },
|
|
590
|
+
{ type: 'sleep', seconds: 1 },
|
|
591
|
+
{ type: 'sendKey', key: 'enter' },
|
|
592
|
+
{ type: 'sleep', seconds: 60 },
|
|
593
|
+
{ type: 'closeSession' }
|
|
594
|
+
]
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function showScheduleModal(job = null) {
|
|
599
|
+
try {
|
|
600
|
+
await ensureScheduleTools();
|
|
601
|
+
loadAppConfig();
|
|
602
|
+
} catch (e) {
|
|
603
|
+
alert('Failed to load tools');
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const data = job || defaultSchedule();
|
|
608
|
+
editingScheduleId = data.id || null;
|
|
609
|
+
editingSteps = (data.steps || []).map(step => ({ ...step }));
|
|
610
|
+
selectedWeekdays = [...(data.schedule.weekdays || [1, 2, 3, 4, 5])];
|
|
611
|
+
document.getElementById('schedule-modal-title').textContent = editingScheduleId ? 'Edit Schedule' : 'New Schedule';
|
|
612
|
+
document.getElementById('schedule-name').value = data.name || 'Scheduled Task';
|
|
613
|
+
document.getElementById('schedule-cwd').value = data.target.workingDirectory || '';
|
|
614
|
+
document.getElementById('schedule-time').value = data.schedule.time || '09:00';
|
|
615
|
+
|
|
616
|
+
const toolSelect = document.getElementById('schedule-tool');
|
|
617
|
+
toolSelect.innerHTML = scheduleTools.map(t => `<option value="${escapeHtml(t.key)}">${escapeHtml(t.displayName)}</option>`).join('');
|
|
618
|
+
const requestedTool = data.target.toolKey || '';
|
|
619
|
+
toolSelect.value = scheduleTools.some(t => t.key === requestedTool) ? requestedTool : ((scheduleTools[0] && scheduleTools[0].key) || 'demo');
|
|
620
|
+
renderWeekdays();
|
|
621
|
+
renderScheduleSteps();
|
|
622
|
+
document.getElementById('schedule-modal-overlay').style.display = 'flex';
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function closeScheduleModal(e) {
|
|
626
|
+
if (!e || e.target.id === 'schedule-modal-overlay') {
|
|
627
|
+
document.getElementById('schedule-modal-overlay').style.display = 'none';
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function renderWeekdays() {
|
|
632
|
+
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
633
|
+
document.getElementById('schedule-weekdays').innerHTML = labels.map((label, day) => {
|
|
634
|
+
const active = selectedWeekdays.includes(day);
|
|
635
|
+
return `<button type="button" class="weekday-btn ${active ? 'active' : ''}" onclick="toggleWeekday(${day})">${label}</button>`;
|
|
636
|
+
}).join('');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function toggleWeekday(day) {
|
|
640
|
+
if (selectedWeekdays.includes(day)) {
|
|
641
|
+
selectedWeekdays = selectedWeekdays.filter(value => value !== day);
|
|
642
|
+
} else {
|
|
643
|
+
selectedWeekdays.push(day);
|
|
644
|
+
selectedWeekdays.sort((a, b) => a - b);
|
|
645
|
+
}
|
|
646
|
+
renderWeekdays();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function addScheduleStep(type) {
|
|
650
|
+
if (!type) return;
|
|
651
|
+
const defaults = {
|
|
652
|
+
sleep: { type: 'sleep', seconds: 1 },
|
|
653
|
+
sendText: { type: 'sendText', text: '' },
|
|
654
|
+
sendKey: { type: 'sendKey', key: 'enter' },
|
|
655
|
+
keyDown: { type: 'keyDown', key: 'ctrl' },
|
|
656
|
+
keyUp: { type: 'keyUp', key: 'ctrl' },
|
|
657
|
+
stop: { type: 'stop' },
|
|
658
|
+
closeSession: { type: 'closeSession' }
|
|
659
|
+
};
|
|
660
|
+
editingSteps.push(defaults[type] || { type });
|
|
661
|
+
renderScheduleSteps();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function updateStep(index, field, value) {
|
|
665
|
+
if (!editingSteps[index]) return;
|
|
666
|
+
if (field === 'seconds') editingSteps[index][field] = Math.max(0, Number(value) || 0);
|
|
667
|
+
else editingSteps[index][field] = value;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function moveStep(index, delta) {
|
|
671
|
+
const nextIndex = index + delta;
|
|
672
|
+
if (nextIndex < 0 || nextIndex >= editingSteps.length) return;
|
|
673
|
+
const [step] = editingSteps.splice(index, 1);
|
|
674
|
+
editingSteps.splice(nextIndex, 0, step);
|
|
675
|
+
renderScheduleSteps();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function copyStep(index) {
|
|
679
|
+
editingSteps.splice(index + 1, 0, { ...editingSteps[index] });
|
|
680
|
+
renderScheduleSteps();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function removeStep(index) {
|
|
684
|
+
editingSteps.splice(index, 1);
|
|
685
|
+
renderScheduleSteps();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function stepInputHtml(step, index) {
|
|
689
|
+
if (step.type === 'sleep') {
|
|
690
|
+
return `<input type="number" min="0" step="0.1" value="${escapeHtml(step.seconds || 0)}" onchange="updateStep(${index}, 'seconds', this.value)">`;
|
|
691
|
+
}
|
|
692
|
+
if (step.type === 'sendText') {
|
|
693
|
+
return `<textarea rows="2" placeholder="Text to send" onchange="updateStep(${index}, 'text', this.value)" oninput="updateStep(${index}, 'text', this.value)">${escapeHtml(step.text || '')}</textarea>`;
|
|
694
|
+
}
|
|
695
|
+
if (step.type === 'sendKey') {
|
|
696
|
+
return `<select onchange="updateStep(${index}, 'key', this.value)">
|
|
697
|
+
${['enter','tab','esc','up','down','left','right','backspace','delete','home','end','ctrl+c','ctrl+d','ctrl+l'].map(key => `<option value="${key}" ${step.key === key ? 'selected' : ''}>${key}</option>`).join('')}
|
|
698
|
+
</select>`;
|
|
699
|
+
}
|
|
700
|
+
if (step.type === 'keyDown' || step.type === 'keyUp') {
|
|
701
|
+
return `<select onchange="updateStep(${index}, 'key', this.value)">
|
|
702
|
+
${['ctrl','alt'].map(key => `<option value="${key}" ${step.key === key ? 'selected' : ''}>${key}</option>`).join('')}
|
|
703
|
+
</select>`;
|
|
704
|
+
}
|
|
705
|
+
if (step.type === 'closeSession') {
|
|
706
|
+
return '<span style="color:var(--text-dim); font-size:13px;">Close the session and end this run</span>';
|
|
707
|
+
}
|
|
708
|
+
return '<span style="color:var(--text-dim); font-size:13px;">End this run</span>';
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function renderScheduleSteps() {
|
|
712
|
+
const container = document.getElementById('schedule-steps');
|
|
713
|
+
if (!editingSteps.length) {
|
|
714
|
+
container.innerHTML = '<p style="color:var(--text-dim);">No steps yet.</p>';
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
container.innerHTML = editingSteps.map((step, index) => `
|
|
718
|
+
<div class="step-card">
|
|
719
|
+
<div class="step-grid">
|
|
720
|
+
<select onchange="editingSteps[${index}] = { type: this.value }; addDefaultStepFields(${index}); renderScheduleSteps();">
|
|
721
|
+
${['sleep','sendText','sendKey','keyDown','keyUp','stop','closeSession'].map(type => `<option value="${type}" ${step.type === type ? 'selected' : ''}>${type}</option>`).join('')}
|
|
722
|
+
</select>
|
|
723
|
+
<div>${stepInputHtml(step, index)}</div>
|
|
724
|
+
<div style="display:flex; gap:6px; justify-content:flex-end;">
|
|
725
|
+
<button class="small-btn" onclick="moveStep(${index}, -1)">↑</button>
|
|
726
|
+
<button class="small-btn" onclick="moveStep(${index}, 1)">↓</button>
|
|
727
|
+
<button class="small-btn" onclick="copyStep(${index})">Copy</button>
|
|
728
|
+
<button class="small-btn danger" onclick="removeStep(${index})">Del</button>
|
|
729
|
+
</div>
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
`).join('');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function addDefaultStepFields(index) {
|
|
736
|
+
const type = editingSteps[index].type;
|
|
737
|
+
if (type === 'sleep') editingSteps[index].seconds = 1;
|
|
738
|
+
if (type === 'sendText') editingSteps[index].text = '';
|
|
739
|
+
if (type === 'sendKey') editingSteps[index].key = 'enter';
|
|
740
|
+
if (type === 'keyDown' || type === 'keyUp') editingSteps[index].key = 'ctrl';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function collectSchedulePayload() {
|
|
744
|
+
if (!selectedWeekdays.length) throw new Error('Select at least one weekday');
|
|
745
|
+
return {
|
|
746
|
+
name: document.getElementById('schedule-name').value.trim() || 'Scheduled Task',
|
|
747
|
+
enabled: true,
|
|
748
|
+
schedule: {
|
|
749
|
+
time: document.getElementById('schedule-time').value || '09:00',
|
|
750
|
+
weekdays: selectedWeekdays
|
|
751
|
+
},
|
|
752
|
+
target: {
|
|
753
|
+
toolKey: document.getElementById('schedule-tool').value,
|
|
754
|
+
workingDirectory: document.getElementById('schedule-cwd').value.trim()
|
|
755
|
+
},
|
|
756
|
+
steps: editingSteps
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function saveSchedule() {
|
|
761
|
+
try {
|
|
762
|
+
const payload = collectSchedulePayload();
|
|
763
|
+
const url = editingScheduleId ? `/api/schedules/${editingScheduleId}` : '/api/schedules';
|
|
764
|
+
const method = editingScheduleId ? 'PATCH' : 'POST';
|
|
765
|
+
const res = await fetchWithTimeout(url, {
|
|
766
|
+
method,
|
|
767
|
+
headers: { 'Content-Type': 'application/json' },
|
|
768
|
+
body: JSON.stringify(payload)
|
|
769
|
+
});
|
|
770
|
+
if (!res.ok) throw new Error((await res.json()).error || 'Save failed');
|
|
771
|
+
closeScheduleModal();
|
|
772
|
+
switchLobbyTab('schedules');
|
|
773
|
+
} catch (e) {
|
|
774
|
+
alert(e.message);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function editSchedule(id) {
|
|
779
|
+
const res = await fetchWithTimeout(`/api/schedules/${id}`);
|
|
780
|
+
if (!res.ok) return alert('Failed to load schedule');
|
|
781
|
+
showScheduleModal(await res.json());
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function simulateSchedule(id) {
|
|
785
|
+
try {
|
|
786
|
+
const res = await fetchWithTimeout(`/api/schedules/${id}/simulate`, { method: 'POST' }, 30000);
|
|
787
|
+
const data = await res.json();
|
|
788
|
+
if (!res.ok) throw new Error(data.error || 'Test failed');
|
|
789
|
+
await loadSchedules();
|
|
790
|
+
if (data.sessionId) {
|
|
791
|
+
const jobRes = await fetchWithTimeout(`/api/schedules/${id}`).catch(() => null);
|
|
792
|
+
const job = jobRes && jobRes.ok ? await jobRes.json() : null;
|
|
793
|
+
joinSession(data.sessionId, 'Schedule Test', job?.target?.toolKey || null);
|
|
794
|
+
}
|
|
795
|
+
} catch (e) {
|
|
796
|
+
alert(e.message);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function duplicateSchedule(id) {
|
|
801
|
+
await fetchWithTimeout(`/api/schedules/${id}/duplicate`, { method: 'POST' });
|
|
802
|
+
loadSchedules();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function toggleSchedule(id, enabled) {
|
|
806
|
+
await fetchWithTimeout(`/api/schedules/${id}/enabled`, {
|
|
807
|
+
method: 'PATCH',
|
|
808
|
+
headers: { 'Content-Type': 'application/json' },
|
|
809
|
+
body: JSON.stringify({ enabled })
|
|
810
|
+
});
|
|
811
|
+
loadSchedules();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function deleteSchedule(id) {
|
|
815
|
+
if (!confirm('Delete scheduled task?')) return;
|
|
816
|
+
await fetchWithTimeout(`/api/schedules/${id}`, { method: 'DELETE' });
|
|
817
|
+
loadSchedules();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function deleteSession(id, e) {
|
|
821
|
+
e.stopPropagation(); if (!confirm('Terminate session?')) return;
|
|
822
|
+
try { await fetchWithTimeout('/api/sessions/' + id, { method: 'DELETE' }); refreshSessionsNow(); } catch (e) { alert('Failed to delete'); }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function showLobby() {
|
|
826
|
+
if (currentSocket) { currentSocket.close(); currentSocket = null; }
|
|
827
|
+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
828
|
+
document.getElementById('lobby-view').classList.add('active');
|
|
829
|
+
refreshSessionsNow();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function updateToolShortcuts(toolKey) {
|
|
833
|
+
const actionBtn = document.querySelector('[data-role="tool-action-shortcut"]');
|
|
834
|
+
if (!actionBtn) return;
|
|
835
|
+
|
|
836
|
+
if (toolKey === 'codex') {
|
|
837
|
+
actionBtn.dataset.key = 'codex-app';
|
|
838
|
+
actionBtn.textContent = '/app';
|
|
839
|
+
actionBtn.title = 'Send /app';
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
actionBtn.dataset.key = 'ctrl-y';
|
|
844
|
+
actionBtn.textContent = 'Ctrl+Y';
|
|
845
|
+
actionBtn.title = 'Send Ctrl+Y';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function joinSession(id, sessionName, toolKey = null) {
|
|
849
|
+
activeSessionId = id;
|
|
850
|
+
activeToolKey = toolKey;
|
|
851
|
+
clearTimeout(sessionPollTimer);
|
|
852
|
+
sessionPollTimer = null;
|
|
853
|
+
markCompletionRead(id);
|
|
854
|
+
document.getElementById('session-title').innerText = sessionName;
|
|
855
|
+
updateToolShortcuts(activeToolKey);
|
|
856
|
+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
857
|
+
document.getElementById('terminal-view').classList.add('active');
|
|
858
|
+
initTerminal(id);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function initTerminal(sessionId) {
|
|
862
|
+
if (typeof Terminal === 'undefined') {
|
|
863
|
+
alert('Terminal library not loaded yet.');
|
|
864
|
+
showLobby();
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
function isTerminalAutoResponse(data) {
|
|
868
|
+
return /^\x1b\[\?[\d;]*c$/.test(data) || /^\x1b\[>[\d;]*c$/.test(data);
|
|
869
|
+
}
|
|
870
|
+
if (!term) {
|
|
871
|
+
term = new Terminal({ theme: { background: '#000', foreground: '#fff', cursor: '#0f0' }, cursorBlink: true, fontSize: 14, cursorStyle: 'bar', scrollback: 10000 });
|
|
872
|
+
fitAddon = new FitAddon.FitAddon();
|
|
873
|
+
term.loadAddon(fitAddon);
|
|
874
|
+
term.open(document.getElementById('terminal'));
|
|
875
|
+
term.onData(data => {
|
|
876
|
+
if (isTerminalAutoResponse(data)) return;
|
|
877
|
+
let finalData = data;
|
|
878
|
+
if (modifiers.ctrl && data.length === 1) {
|
|
879
|
+
const code = data.toLowerCase().charCodeAt(0);
|
|
880
|
+
if (code >= 97 && code <= 122) finalData = String.fromCharCode(code - 96);
|
|
881
|
+
}
|
|
882
|
+
sendWS(finalData);
|
|
883
|
+
});
|
|
884
|
+
} else { term.clear(); }
|
|
885
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
886
|
+
currentSocket = new WebSocket(protocol + '//' + window.location.host + '?sessionId=' + sessionId);
|
|
887
|
+
currentSocket.onopen = () => {
|
|
888
|
+
syncLayout();
|
|
889
|
+
setTimeout(() => logViewSnapshot('ws-open'), 300);
|
|
890
|
+
};
|
|
891
|
+
currentSocket.onmessage = (e) => {
|
|
892
|
+
const msg = JSON.parse(e.data);
|
|
893
|
+
if (msg.type === 'output') term.write(msg.data);
|
|
894
|
+
if (msg.type === 'exit') showLobby();
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function sendWS(data) {
|
|
899
|
+
if (currentSocket && currentSocket.readyState === 1) currentSocket.send(JSON.stringify({ type: 'input', data }));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
let layoutTimer = null;
|
|
903
|
+
let layoutKeepAtBottom = false;
|
|
904
|
+
|
|
905
|
+
function isTerminalAtBottom() {
|
|
906
|
+
if (!term || !term.buffer || !term.buffer.active) return true;
|
|
907
|
+
const active = term.buffer.active;
|
|
908
|
+
return active.viewportY >= active.baseY - 1;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function scrollTerminalViewportToBottom() {
|
|
912
|
+
if (!term) return;
|
|
913
|
+
term.scrollToBottom();
|
|
914
|
+
const viewport = document.querySelector('#terminal .xterm-viewport');
|
|
915
|
+
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function restoreTerminalBottomSoon() {
|
|
919
|
+
scrollTerminalViewportToBottom();
|
|
920
|
+
requestAnimationFrame(() => {
|
|
921
|
+
scrollTerminalViewportToBottom();
|
|
922
|
+
requestAnimationFrame(scrollTerminalViewportToBottom);
|
|
923
|
+
});
|
|
924
|
+
setTimeout(scrollTerminalViewportToBottom, 120);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function syncLayout(options = {}) {
|
|
928
|
+
if (!fitAddon || !currentSocket) return;
|
|
929
|
+
const keepAtBottom = typeof options.keepAtBottom === 'boolean' ? options.keepAtBottom : isTerminalAtBottom();
|
|
930
|
+
layoutKeepAtBottom = layoutKeepAtBottom || keepAtBottom;
|
|
931
|
+
clearTimeout(layoutTimer);
|
|
932
|
+
layoutTimer = setTimeout(() => {
|
|
933
|
+
const shouldRestoreBottom = layoutKeepAtBottom;
|
|
934
|
+
layoutKeepAtBottom = false;
|
|
935
|
+
fitAddon.fit();
|
|
936
|
+
if (shouldRestoreBottom) restoreTerminalBottomSoon();
|
|
937
|
+
if (currentSocket.readyState === 1) currentSocket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
938
|
+
}, 50);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function previewText(text, maxChars = 320) {
|
|
942
|
+
if (!text) return '';
|
|
943
|
+
const normalized = String(text)
|
|
944
|
+
.replace(/\r/g, '\\r')
|
|
945
|
+
.replace(/\n/g, '\\n')
|
|
946
|
+
.replace(/\t/g, '\\t')
|
|
947
|
+
.replace(/\x1b/g, '\\x1b');
|
|
948
|
+
return normalized.length > maxChars ? normalized.slice(-maxChars) : normalized;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function logClientDebug(event, payload) {
|
|
952
|
+
try {
|
|
953
|
+
await fetch('/api/debug/client-log', {
|
|
954
|
+
method: 'POST',
|
|
955
|
+
headers: { 'Content-Type': 'application/json' },
|
|
956
|
+
body: JSON.stringify({ sessionId: activeSessionId, event, payload })
|
|
957
|
+
});
|
|
958
|
+
} catch (_) {}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function snapshotTerminalBuffer(maxLines = 12) {
|
|
962
|
+
if (!term || !term.buffer || !term.buffer.active) return null;
|
|
963
|
+
const active = term.buffer.active;
|
|
964
|
+
const totalLines = active.length || 0;
|
|
965
|
+
const start = Math.max(0, totalLines - maxLines);
|
|
966
|
+
const tailLines = [];
|
|
967
|
+
for (let i = start; i < totalLines; i++) {
|
|
968
|
+
const line = active.getLine(i);
|
|
969
|
+
if (!line) continue;
|
|
970
|
+
tailLines.push(line.translateToString(true));
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
cols: term.cols,
|
|
974
|
+
rows: term.rows,
|
|
975
|
+
baseY: active.baseY,
|
|
976
|
+
viewportY: active.viewportY,
|
|
977
|
+
cursorX: active.cursorX,
|
|
978
|
+
cursorY: active.cursorY,
|
|
979
|
+
totalLines,
|
|
980
|
+
tailPreview: previewText(tailLines.join('\n'))
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function logViewSnapshot(event) {
|
|
985
|
+
logClientDebug(event, {
|
|
986
|
+
sessionId: activeSessionId,
|
|
987
|
+
terminalSnapshot: snapshotTerminalBuffer()
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Navigation
|
|
992
|
+
function showTerminal() {
|
|
993
|
+
clearTimeout(sessionPollTimer);
|
|
994
|
+
sessionPollTimer = null;
|
|
995
|
+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
996
|
+
document.getElementById('terminal-view').classList.add('active');
|
|
997
|
+
logViewSnapshot('show-terminal');
|
|
998
|
+
syncLayout();
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function showGitPreview() {
|
|
1002
|
+
clearTimeout(sessionPollTimer);
|
|
1003
|
+
sessionPollTimer = null;
|
|
1004
|
+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
1005
|
+
document.getElementById('git-view').classList.add('active');
|
|
1006
|
+
switchGitTab('changes');
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
let historyWrapEnabled = true;
|
|
1010
|
+
|
|
1011
|
+
function showHistory() {
|
|
1012
|
+
clearTimeout(sessionPollTimer);
|
|
1013
|
+
sessionPollTimer = null;
|
|
1014
|
+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
1015
|
+
document.getElementById('history-view').classList.add('active');
|
|
1016
|
+
logViewSnapshot('show-history-before-load');
|
|
1017
|
+
loadHistory();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function toggleHistoryWrap() {
|
|
1021
|
+
historyWrapEnabled = !historyWrapEnabled;
|
|
1022
|
+
const contentEl = document.getElementById('history-content');
|
|
1023
|
+
contentEl.classList.toggle('nowrap', !historyWrapEnabled);
|
|
1024
|
+
document.getElementById('history-wrap-btn').style.color = historyWrapEnabled ? 'var(--primary)' : 'var(--text-dim)';
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async function loadHistory() {
|
|
1028
|
+
const contentEl = document.getElementById('history-content');
|
|
1029
|
+
const metaEl = document.getElementById('history-meta');
|
|
1030
|
+
if (!activeSessionId) {
|
|
1031
|
+
metaEl.textContent = 'No active session.';
|
|
1032
|
+
contentEl.textContent = '';
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
metaEl.textContent = 'Loading...';
|
|
1037
|
+
try {
|
|
1038
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/history`, {}, 30000);
|
|
1039
|
+
const data = await res.json();
|
|
1040
|
+
if (!data.success) throw new Error(data.error || 'Failed to load history');
|
|
1041
|
+
|
|
1042
|
+
const updatedAt = data.updatedAt ? new Date(data.updatedAt).toLocaleTimeString() : 'unknown';
|
|
1043
|
+
const size = typeof data.bytes === 'number' ? `${Math.round(data.bytes / 1024)} KB` : 'unknown size';
|
|
1044
|
+
const truncated = data.truncated ? ' | truncated' : '';
|
|
1045
|
+
metaEl.textContent = `${data.tool || 'Session'} | ${data.lines || 0} lines | ${size} | updated ${updatedAt}${truncated}`;
|
|
1046
|
+
contentEl.textContent = data.text || 'No readable history yet.';
|
|
1047
|
+
contentEl.classList.toggle('nowrap', !historyWrapEnabled);
|
|
1048
|
+
document.getElementById('history-wrap-btn').style.color = historyWrapEnabled ? 'var(--primary)' : 'var(--text-dim)';
|
|
1049
|
+
logClientDebug('history-loaded', {
|
|
1050
|
+
meta: metaEl.textContent,
|
|
1051
|
+
historyLines: data.lines || 0,
|
|
1052
|
+
historyBytes: data.bytes || 0,
|
|
1053
|
+
historyTail: previewText(data.text || '')
|
|
1054
|
+
});
|
|
1055
|
+
requestAnimationFrame(() => {
|
|
1056
|
+
contentEl.scrollTop = contentEl.scrollHeight;
|
|
1057
|
+
});
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
metaEl.textContent = 'Failed to load history.';
|
|
1060
|
+
contentEl.textContent = e.message;
|
|
1061
|
+
logClientDebug('history-load-failed', { message: e.message });
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function copyHistoryContent() {
|
|
1066
|
+
const contentEl = document.getElementById('history-content');
|
|
1067
|
+
const text = contentEl.textContent || '';
|
|
1068
|
+
if (!text || text === 'Loading...' || text === 'No readable history yet.') {
|
|
1069
|
+
alert('No history to copy.');
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
try {
|
|
1074
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
1075
|
+
await navigator.clipboard.writeText(text);
|
|
1076
|
+
} else {
|
|
1077
|
+
const range = document.createRange();
|
|
1078
|
+
range.selectNode(contentEl);
|
|
1079
|
+
window.getSelection().removeAllRanges();
|
|
1080
|
+
window.getSelection().addRange(range);
|
|
1081
|
+
document.execCommand('copy');
|
|
1082
|
+
window.getSelection().removeAllRanges();
|
|
1083
|
+
}
|
|
1084
|
+
alert('History copied.');
|
|
1085
|
+
} catch (e) {
|
|
1086
|
+
alert('Copy failed. You can select the history text manually.');
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const inputEl = document.getElementById('cmd-input');
|
|
1091
|
+
let keepTerminalBottomForNextInput = false;
|
|
1092
|
+
|
|
1093
|
+
function markInputEditStart() {
|
|
1094
|
+
keepTerminalBottomForNextInput = keepTerminalBottomForNextInput || isTerminalAtBottom();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
inputEl.addEventListener('beforeinput', markInputEditStart);
|
|
1098
|
+
inputEl.addEventListener('input', function() {
|
|
1099
|
+
const keepAtBottom = keepTerminalBottomForNextInput || isTerminalAtBottom();
|
|
1100
|
+
keepTerminalBottomForNextInput = false;
|
|
1101
|
+
this.style.height = 'auto';
|
|
1102
|
+
this.style.height = Math.min(this.scrollHeight, 150) + 'px';
|
|
1103
|
+
if (keepAtBottom) restoreTerminalBottomSoon();
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
function performSend() {
|
|
1107
|
+
const val = inputEl.value;
|
|
1108
|
+
if (val) {
|
|
1109
|
+
const formattedVal = val.replace(/\n/g, '\r');
|
|
1110
|
+
sendWS(formattedVal);
|
|
1111
|
+
inputEl.value = '';
|
|
1112
|
+
inputEl.style.height = '38px';
|
|
1113
|
+
restoreTerminalBottomSoon();
|
|
1114
|
+
setTimeout(() => { sendWS('\r'); }, 1000);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
document.getElementById('send-btn').addEventListener('click', performSend);
|
|
1119
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
1120
|
+
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); performSend(); }
|
|
1121
|
+
else if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete') {
|
|
1122
|
+
markInputEditStart();
|
|
1123
|
+
if (keepTerminalBottomForNextInput) requestAnimationFrame(restoreTerminalBottomSoon);
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
document.getElementById('shortcut-rail').addEventListener('click', (e) => {
|
|
1128
|
+
const commandBtn = e.target.closest('.command-key');
|
|
1129
|
+
if (commandBtn) {
|
|
1130
|
+
const command = commandBtn.dataset.command;
|
|
1131
|
+
if (command) {
|
|
1132
|
+
sendWS(command);
|
|
1133
|
+
setTimeout(() => { sendWS('\r'); }, 1000);
|
|
1134
|
+
}
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const keyBtn = e.target.closest('.key-btn'); if (!keyBtn) return;
|
|
1139
|
+
const key = keyBtn.dataset.key;
|
|
1140
|
+
if (key === 'ctrl') { modifiers[key] = !modifiers[key]; keyBtn.classList.toggle('active', modifiers[key]); return; }
|
|
1141
|
+
if (key === 'codex-app') {
|
|
1142
|
+
sendWS('/app');
|
|
1143
|
+
setTimeout(() => { sendWS('\r'); }, 1000);
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
const sequences = { up: '\x1b[A', down: '\x1b[B', enter: '\r', esc: '\x1b', tab: '\t', 'ctrl-c': '\x03', 'ctrl-y': '\x19' };
|
|
1147
|
+
if (sequences[key]) sendWS(sequences[key]);
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
const scrollChargeDurationMs = 6000;
|
|
1151
|
+
let isScrolling = false, scrollDir = 0, scrollSpeed = 0, rafId = null;
|
|
1152
|
+
let chargeBtn = null, chargeStartTime = 0, chargeRafId = null, chargePointerId = null, chargeCompleted = false;
|
|
1153
|
+
|
|
1154
|
+
function getTerminalViewport() {
|
|
1155
|
+
return document.querySelector('.xterm-viewport');
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function smoothScrollLoop() {
|
|
1159
|
+
if (!isScrolling) return;
|
|
1160
|
+
const viewport = getTerminalViewport();
|
|
1161
|
+
if (viewport) { viewport.scrollTop += (scrollDir * scrollSpeed); scrollSpeed = Math.min(30, scrollSpeed + 0.2); }
|
|
1162
|
+
rafId = requestAnimationFrame(smoothScrollLoop);
|
|
1163
|
+
}
|
|
1164
|
+
function startScroll(dir) { if (isScrolling) return; isScrolling = true; scrollDir = dir; scrollSpeed = 4; smoothScrollLoop(); }
|
|
1165
|
+
function stopScroll() { isScrolling = false; if (rafId) cancelAnimationFrame(rafId); }
|
|
1166
|
+
|
|
1167
|
+
function jumpTerminalScroll(dir) {
|
|
1168
|
+
const viewport = getTerminalViewport();
|
|
1169
|
+
if (!viewport) return;
|
|
1170
|
+
viewport.scrollTop = dir < 0 ? 0 : viewport.scrollHeight;
|
|
1171
|
+
if (term) {
|
|
1172
|
+
if (dir < 0) term.scrollToTop();
|
|
1173
|
+
else term.scrollToBottom();
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function resetChargeButton(btn) {
|
|
1178
|
+
if (!btn) return;
|
|
1179
|
+
btn.classList.remove('charging');
|
|
1180
|
+
btn.style.setProperty('--charge', '0deg');
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function stopScrollCharge() {
|
|
1184
|
+
stopScroll();
|
|
1185
|
+
if (chargeRafId) cancelAnimationFrame(chargeRafId);
|
|
1186
|
+
if (chargeBtn && chargePointerId !== null) {
|
|
1187
|
+
try { chargeBtn.releasePointerCapture(chargePointerId); } catch (_) {}
|
|
1188
|
+
}
|
|
1189
|
+
resetChargeButton(chargeBtn);
|
|
1190
|
+
chargeBtn = null;
|
|
1191
|
+
chargeStartTime = 0;
|
|
1192
|
+
chargeRafId = null;
|
|
1193
|
+
chargePointerId = null;
|
|
1194
|
+
chargeCompleted = false;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function updateScrollCharge() {
|
|
1198
|
+
if (!chargeBtn) return;
|
|
1199
|
+
const elapsed = performance.now() - chargeStartTime;
|
|
1200
|
+
const progress = Math.min(1, elapsed / scrollChargeDurationMs);
|
|
1201
|
+
chargeBtn.style.setProperty('--charge', (progress * 360) + 'deg');
|
|
1202
|
+
if (progress >= 1) {
|
|
1203
|
+
chargeCompleted = true;
|
|
1204
|
+
jumpTerminalScroll(scrollDir);
|
|
1205
|
+
stopScrollCharge();
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
chargeRafId = requestAnimationFrame(updateScrollCharge);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function startScrollCharge(e, dir) {
|
|
1212
|
+
e.preventDefault();
|
|
1213
|
+
stopScrollCharge();
|
|
1214
|
+
chargeBtn = e.currentTarget;
|
|
1215
|
+
chargePointerId = e.pointerId;
|
|
1216
|
+
chargeStartTime = performance.now();
|
|
1217
|
+
chargeCompleted = false;
|
|
1218
|
+
chargeBtn.classList.add('charging');
|
|
1219
|
+
chargeBtn.setPointerCapture(chargePointerId);
|
|
1220
|
+
startScroll(dir);
|
|
1221
|
+
updateScrollCharge();
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
document.getElementById('scroll-up').addEventListener('pointerdown', (e) => startScrollCharge(e, -1));
|
|
1225
|
+
document.getElementById('scroll-down').addEventListener('pointerdown', (e) => startScrollCharge(e, 1));
|
|
1226
|
+
window.addEventListener('pointerup', stopScrollCharge);
|
|
1227
|
+
window.addEventListener('pointercancel', stopScrollCharge);
|
|
1228
|
+
window.addEventListener('resize', syncLayout);
|
|
1229
|
+
|
|
1230
|
+
// Git Logic
|
|
1231
|
+
let currentGitTab = 'changes';
|
|
1232
|
+
let currentDirPath = '';
|
|
1233
|
+
|
|
1234
|
+
function switchGitTab(tab) {
|
|
1235
|
+
currentGitTab = tab;
|
|
1236
|
+
document.getElementById('tab-changes').classList.toggle('active', tab === 'changes');
|
|
1237
|
+
document.getElementById('tab-directories').classList.toggle('active', tab === 'directories');
|
|
1238
|
+
document.getElementById('tab-graph').classList.toggle('active', tab === 'graph');
|
|
1239
|
+
if (tab === 'changes') {
|
|
1240
|
+
loadGitStatus();
|
|
1241
|
+
} else if (tab === 'directories') {
|
|
1242
|
+
loadDirectories(currentDirPath);
|
|
1243
|
+
} else if (tab === 'graph') {
|
|
1244
|
+
loadGitGraph();
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
window.loadCommitDiff = async function(hash, btn) {
|
|
1250
|
+
if (!activeSessionId) return;
|
|
1251
|
+
const container = btn.nextElementSibling;
|
|
1252
|
+
btn.style.display = 'none';
|
|
1253
|
+
container.innerHTML = '<span style="color:var(--text-dim);">Loading diff...</span>';
|
|
1254
|
+
try {
|
|
1255
|
+
const res = await fetch(`/api/sessions/${activeSessionId}/git-show/${hash}`);
|
|
1256
|
+
const data = await res.json();
|
|
1257
|
+
if (data.success) {
|
|
1258
|
+
let diffHTML = '';
|
|
1259
|
+
const lines = data.stdout.split('\n');
|
|
1260
|
+
for (const line of lines) {
|
|
1261
|
+
let color = '#ccc';
|
|
1262
|
+
if (line.startsWith('+') && !line.startsWith('+++')) color = '#34c759';
|
|
1263
|
+
else if (line.startsWith('-') && !line.startsWith('---')) color = '#ff3b30';
|
|
1264
|
+
else if (line.startsWith('@@')) color = '#5ac8fa';
|
|
1265
|
+
else if (line.startsWith('diff') || line.startsWith('index') || line.startsWith('commit') || line.startsWith('Author') || line.startsWith('Date')) color = '#fff';
|
|
1266
|
+
diffHTML += `<div style="color:${color}; padding:0 4px; border-radius:2px;">${line.replace(/</g, '<').replace(/>/g, '>')}</div>`;
|
|
1267
|
+
}
|
|
1268
|
+
container.innerHTML = diffHTML;
|
|
1269
|
+
} else {
|
|
1270
|
+
container.innerHTML = `<span style="color:#ff3b30;">Error loading diff</span>`;
|
|
1271
|
+
}
|
|
1272
|
+
} catch (e) {
|
|
1273
|
+
container.innerHTML = `<span style="color:#ff3b30;">Network error</span>`;
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
async function loadGitGraph() {
|
|
1278
|
+
|
|
1279
|
+
if (!activeSessionId) return;
|
|
1280
|
+
const container = document.getElementById('git-content');
|
|
1281
|
+
container.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--text-dim);">Loading graph...</div>';
|
|
1282
|
+
try {
|
|
1283
|
+
const res = await fetch(`/api/sessions/${activeSessionId}/git-log`);
|
|
1284
|
+
const data = await res.json();
|
|
1285
|
+
if (data.success) {
|
|
1286
|
+
const renderer = new GitGraphRenderer(container);
|
|
1287
|
+
renderer.render(data.commits);
|
|
1288
|
+
} else {
|
|
1289
|
+
container.innerHTML = `<div style="padding: 20px; color: #ff3b30;">Error: ${data.error}</div>`;
|
|
1290
|
+
}
|
|
1291
|
+
} catch (e) {
|
|
1292
|
+
container.innerHTML = `<div style="padding: 20px; color: #ff3b30;">Failed to load graph</div>`;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
async function loadDirectories(path = '') {
|
|
1297
|
+
currentDirPath = path;
|
|
1298
|
+
const content = document.getElementById('git-content');
|
|
1299
|
+
content.innerHTML = '<p style="text-align:center;color:#888;padding:20px;">Loading...</p>';
|
|
1300
|
+
if (!activeSessionId) return;
|
|
1301
|
+
try {
|
|
1302
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/fs/dir?path=${encodeURIComponent(path)}`);
|
|
1303
|
+
const data = await res.json();
|
|
1304
|
+
if (!data.success) throw new Error(data.error);
|
|
1305
|
+
|
|
1306
|
+
let html = '<div>';
|
|
1307
|
+
if (path !== '') {
|
|
1308
|
+
const parts = path.split('/');
|
|
1309
|
+
parts.pop();
|
|
1310
|
+
const parentPath = parts.join('/');
|
|
1311
|
+
html += `<div class="list-item" onclick="loadDirectories(decodePathValue('${encodePathValue(parentPath)}'))">
|
|
1312
|
+
<svg class="dir-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
|
1313
|
+
<div style="font-size:14px; font-weight:500;">..</div>
|
|
1314
|
+
</div>`;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (data.files.length === 0) {
|
|
1318
|
+
html += '<p style="color:var(--text-dim); text-align:center; padding: 20px;">Empty directory</p>';
|
|
1319
|
+
} else {
|
|
1320
|
+
data.files.forEach(f => {
|
|
1321
|
+
const fullPath = path ? `${path}/${f.name}` : f.name;
|
|
1322
|
+
const encodedPath = encodePathValue(fullPath);
|
|
1323
|
+
const escapedName = escapeHtml(f.name);
|
|
1324
|
+
let colorStyle = '';
|
|
1325
|
+
let badgeHtml = '';
|
|
1326
|
+
if (f.gitStatus) {
|
|
1327
|
+
let color = '#fff';
|
|
1328
|
+
let label = f.gitStatus.trim();
|
|
1329
|
+
if (label.includes('U') || label.includes('?')) { colorStyle = 'color: #4ade80;'; color = '#4ade80'; if(label === '??') label = 'U'; }
|
|
1330
|
+
else if (label.includes('M')) { colorStyle = 'color: #f59e0b;'; color = '#f59e0b'; }
|
|
1331
|
+
else if (label.includes('D')) { colorStyle = 'color: #f87171;'; color = '#f87171'; }
|
|
1332
|
+
badgeHtml = `<span style="color:${color}; font-weight:700; font-size:10px; border:1px solid ${color}; padding:1px 4px; border-radius:3px; opacity:0.7; margin-left: auto;">${label}</span>`;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
if (f.isDirectory) {
|
|
1336
|
+
html += `<div class="list-item" onclick="loadDirectories(decodePathValue('${encodedPath}'))">
|
|
1337
|
+
<svg class="dir-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
|
|
1338
|
+
<div style="font-size:14px; font-weight:500; ${colorStyle}">${escapedName}</div>
|
|
1339
|
+
${badgeHtml}
|
|
1340
|
+
</div>`;
|
|
1341
|
+
} else {
|
|
1342
|
+
html += `<div class="list-item" onclick="showFileDetails(decodePathValue('${encodedPath}'), false)">
|
|
1343
|
+
<svg class="file-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
|
|
1344
|
+
<div style="font-size:14px; font-weight:500; ${colorStyle}">${escapedName}</div>
|
|
1345
|
+
${badgeHtml}
|
|
1346
|
+
</div>`;
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
html += '</div>';
|
|
1351
|
+
content.innerHTML = html;
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
content.innerHTML = `<p style="color:#ff3b30; padding:10px;">${e.message}</p>`;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async function loadGitStatus() {
|
|
1358
|
+
const content = document.getElementById('git-content');
|
|
1359
|
+
content.innerHTML = '<p style="text-align:center;color:#888;padding:20px;">Loading...</p>';
|
|
1360
|
+
if (!activeSessionId) return;
|
|
1361
|
+
try {
|
|
1362
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/git-status`);
|
|
1363
|
+
const data = await res.json();
|
|
1364
|
+
if (!data.success) {
|
|
1365
|
+
content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Git error: ${data.error}</p>`;
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
const files = Array.isArray(data.files) ? data.files : [];
|
|
1369
|
+
|
|
1370
|
+
if (files.length === 0) {
|
|
1371
|
+
content.innerHTML = '<p style="text-align:center;color:#888;padding:20px;">No changes detected.</p>';
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
let html = '<div style="padding:10px;"><div style="background:var(--card-bg); border-radius:12px; overflow:hidden;">';
|
|
1376
|
+
files.forEach((f, idx) => {
|
|
1377
|
+
const encodedPath = encodePathValue(f.path);
|
|
1378
|
+
const escapedPath = escapeHtml(f.path);
|
|
1379
|
+
const escapedBaseName = escapeHtml(f.path.split('/').pop() || f.path);
|
|
1380
|
+
let color = '#fff';
|
|
1381
|
+
let label = f.status;
|
|
1382
|
+
if (label.includes('M')) { color = '#f59e0b'; }
|
|
1383
|
+
else if (label.includes('A') || label === '??') { color = '#4ade80'; if(label === '??') label = 'U'; }
|
|
1384
|
+
else if (label.includes('D')) { color = '#f87171'; }
|
|
1385
|
+
const borderBottom = idx < files.length - 1 ? 'border-bottom: 1px solid rgba(255,255,255,0.05);' : '';
|
|
1386
|
+
html += `<div style="padding:14px; ${borderBottom} display:flex; justify-content:space-between; align-items:center; cursor:pointer;" onclick="showFileDetails(decodePathValue('${encodedPath}'), true)">
|
|
1387
|
+
<div style="flex:1; min-width:0; margin-right:10px;">
|
|
1388
|
+
<div style="font-size:14px; font-weight:500;">${escapedBaseName}</div>
|
|
1389
|
+
<div style="font-size:12px; color:var(--text-dim);">${escapedPath}</div>
|
|
1390
|
+
</div>
|
|
1391
|
+
<span style="color:${color}; font-weight:700; font-size:12px; border:1px solid ${color}; padding:2px 6px; border-radius:4px; opacity:0.8;">${label}</span>
|
|
1392
|
+
</div>`;
|
|
1393
|
+
});
|
|
1394
|
+
html += '</div></div>';
|
|
1395
|
+
content.innerHTML = html;
|
|
1396
|
+
} catch (e) {
|
|
1397
|
+
content.innerHTML = `<p style="color:#ff3b30; padding:10px;">${e.message}</p>`;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
let currentFilePath = '', currentFileDiff = '', currentFileContent = '', currentFileMode = 'diff';
|
|
1402
|
+
|
|
1403
|
+
async function showFileDetails(path, isFromChanges = true) {
|
|
1404
|
+
currentFilePath = path;
|
|
1405
|
+
currentFileMode = isFromChanges ? 'diff' : 'file';
|
|
1406
|
+
const content = document.getElementById('git-content');
|
|
1407
|
+
content.innerHTML = '<p style="text-align:center;color:#888;padding:20px;">Loading details...</p>';
|
|
1408
|
+
try {
|
|
1409
|
+
const [diffRes, fileRes] = await Promise.all([
|
|
1410
|
+
fetchWithTimeout(`/api/sessions/${activeSessionId}/git-diff-file?path=${encodeURIComponent(path)}`).catch(()=>({ok:false})),
|
|
1411
|
+
fetchWithTimeout(`/api/sessions/${activeSessionId}/file?path=${encodeURIComponent(path)}`).catch(()=>({ok:false}))
|
|
1412
|
+
]);
|
|
1413
|
+
let diffData = diffRes.ok ? await diffRes.json() : { success: false };
|
|
1414
|
+
let fileData = fileRes.ok ? await fileRes.json() : { success: false };
|
|
1415
|
+
currentFileDiff = (diffData.success && diffData.stdout) ? diffData.stdout : '';
|
|
1416
|
+
currentFileContent = fileData.success ? fileData.content : '';
|
|
1417
|
+
if (!currentFileDiff && isFromChanges && currentFileContent) currentFileDiff = 'Untracked file:\n\n' + currentFileContent;
|
|
1418
|
+
if (!currentFileDiff && !currentFileContent) {
|
|
1419
|
+
content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Failed to load details.</p>`;
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
if (!diffData.stdout && !isFromChanges) currentFileMode = 'file';
|
|
1423
|
+
renderFileDetails();
|
|
1424
|
+
} catch (e) { content.innerHTML = `<p style="color:#ff3b30; padding:10px;">${e.message}</p>`; }
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function setFileMode(mode) { currentFileMode = mode; renderFileDetails(); }
|
|
1428
|
+
|
|
1429
|
+
function renderFileDetails() {
|
|
1430
|
+
const content = document.getElementById('git-content');
|
|
1431
|
+
let html = `
|
|
1432
|
+
<div style="display:flex; align-items:center; background: var(--card-bg); padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.05);">
|
|
1433
|
+
<button class="icon-btn" onclick="switchGitTab(currentGitTab)" style="color: var(--primary); margin-right: 12px; font-weight:600; font-size:14px; display:flex; align-items:center;">
|
|
1434
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg> Back
|
|
1435
|
+
</button>
|
|
1436
|
+
<div style="flex:1; min-width:0;">
|
|
1437
|
+
<div style="font-weight:600; font-size:15px;">${escapeHtml(currentFilePath.split('/').pop() || currentFilePath)}</div>
|
|
1438
|
+
</div>
|
|
1439
|
+
</div>`;
|
|
1440
|
+
if (currentFileDiff && currentFileContent) {
|
|
1441
|
+
html += `<div class="btn-toggle">
|
|
1442
|
+
<button class="${currentFileMode === 'diff' ? 'active' : ''}" onclick="setFileMode('diff')">Diff</button>
|
|
1443
|
+
<button class="${currentFileMode === 'file' ? 'active' : ''}" onclick="setFileMode('file')">File</button>
|
|
1444
|
+
</div>`;
|
|
1445
|
+
}
|
|
1446
|
+
html += `<div style="padding: 10px;">`;
|
|
1447
|
+
if (currentFileMode === 'diff' && currentFileDiff) {
|
|
1448
|
+
html += `<div style="background:#0d0d0d; border-radius:8px; overflow-x:auto; font-family:monospace; font-size:12px; line-height:1.5;">`;
|
|
1449
|
+
currentFileDiff.split('\n').forEach(line => {
|
|
1450
|
+
let color = '#ccc', bg = 'transparent', borderLeft = '2px solid transparent';
|
|
1451
|
+
if (line.startsWith('+') && !line.startsWith('+++')) { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; }
|
|
1452
|
+
else if (line.startsWith('-') && !line.startsWith('---')) { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; }
|
|
1453
|
+
else if (line.startsWith('@@')) { color = '#60a5fa'; bg = 'rgba(96, 165, 250, 0.1)'; }
|
|
1454
|
+
html += `<div style="color:${color}; background:${bg}; border-left:${borderLeft}; white-space:pre-wrap; padding: 2px 8px;">${escapeHtml(line) || ' '}</div>`;
|
|
1455
|
+
});
|
|
1456
|
+
html += `</div>`;
|
|
1457
|
+
} else {
|
|
1458
|
+
html += `<pre style="background:#0d0d0d; border-radius:8px; overflow-x:auto; font-family:monospace; font-size:12px; line-height:1.5; padding: 12px; color: #ccc; margin: 0; white-space: pre-wrap; word-break: break-all;"><code>${escapeHtml(currentFileContent)}</code></pre>`;
|
|
1459
|
+
}
|
|
1460
|
+
html += `</div>`;
|
|
1461
|
+
content.innerHTML = html;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1465
|
+
refreshSessionsNow();
|
|
1466
|
+
document.addEventListener('visibilitychange', refreshSessionsNow);
|
|
1467
|
+
});
|
|
1468
|
+
</script>
|
|
1469
|
+
</body>
|
|
1470
|
+
</html>
|