glad-web 1.0.17 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/README.zh-CN.md +2 -3
- package/lib/ai-tools/registry.js +0 -9
- package/lib/commands/web.js +44 -1
- package/lib/git/service.js +4 -0
- package/lib/session/session-manager.js +126 -1
- package/lib/web/gitgraph.js +26 -6
- package/lib/web/index.html +629 -69
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Glad is a local-first Web interface for terminal-based AI coding tools.
|
|
7
7
|
|
|
8
|
-
It lets you run interactive CLI tools such as **Claude Code**, **Aider**, **GitHub Copilot CLI**, and **
|
|
8
|
+
It lets you run interactive CLI tools such as **Claude Code**, **Aider**, **GitHub Copilot CLI**, and **Codex** on your machine, then access them through a clean browser UI from desktop or mobile devices on your local network.
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
@@ -122,7 +122,7 @@ glad tools detect
|
|
|
122
122
|
|
|
123
123
|
## Supported Tools
|
|
124
124
|
|
|
125
|
-
Glad currently auto-detects the
|
|
125
|
+
Glad currently auto-detects the 20 terminal AI tools defined in the code registry. The names below are the registry `displayName` values used by Glad:
|
|
126
126
|
|
|
127
127
|
| Tool | Detected command |
|
|
128
128
|
| --- | --- |
|
|
@@ -131,7 +131,6 @@ Glad currently auto-detects the 21 terminal AI tools defined in the code registr
|
|
|
131
131
|
| Codex | `codex` |
|
|
132
132
|
| Copilot | `copilot` |
|
|
133
133
|
| Cody | `cody chat` |
|
|
134
|
-
| Gemini | `gemini` |
|
|
135
134
|
| Antigravity | `agy` |
|
|
136
135
|
| Continue | `cn` |
|
|
137
136
|
| Cursor | `cursor-agent` |
|
package/README.zh-CN.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Glad 是一个面向终端 AI 编码工具的本地优先 Web 界面。
|
|
7
7
|
|
|
8
|
-
它让你可以在自己的机器上运行 **Claude Code**、**Aider**、**GitHub Copilot CLI**、**
|
|
8
|
+
它让你可以在自己的机器上运行 **Claude Code**、**Aider**、**GitHub Copilot CLI**、**Codex** 等交互式命令行工具,并通过一个适合桌面和移动端访问的浏览器界面来使用它们。
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
@@ -122,7 +122,7 @@ glad tools detect
|
|
|
122
122
|
|
|
123
123
|
## 支持的工具
|
|
124
124
|
|
|
125
|
-
Glad 当前会自动检测代码注册表中定义的
|
|
125
|
+
Glad 当前会自动检测代码注册表中定义的 20 个终端 AI 工具。下面的名称严格使用 Glad 注册表里的 `displayName`:
|
|
126
126
|
|
|
127
127
|
| 工具 | 检测命令 |
|
|
128
128
|
| --- | --- |
|
|
@@ -131,7 +131,6 @@ Glad 当前会自动检测代码注册表中定义的 21 个终端 AI 工具。
|
|
|
131
131
|
| Codex | `codex` |
|
|
132
132
|
| Copilot | `copilot` |
|
|
133
133
|
| Cody | `cody chat` |
|
|
134
|
-
| Gemini | `gemini` |
|
|
135
134
|
| Antigravity | `agy` |
|
|
136
135
|
| Continue | `cn` |
|
|
137
136
|
| Cursor | `cursor-agent` |
|
package/lib/ai-tools/registry.js
CHANGED
|
@@ -59,15 +59,6 @@ const AI_TOOLS = {
|
|
|
59
59
|
website: 'https://sourcegraph.com/cody',
|
|
60
60
|
checkInstalled: async () => await commandExists('cody')
|
|
61
61
|
},
|
|
62
|
-
'gemini': {
|
|
63
|
-
key: 'gemini',
|
|
64
|
-
command: 'gemini',
|
|
65
|
-
args: [],
|
|
66
|
-
displayName: 'Gemini',
|
|
67
|
-
description: 'Official Google Gemini CLI with 1M token context',
|
|
68
|
-
website: 'https://developers.google.com/gemini-code-assist',
|
|
69
|
-
checkInstalled: async () => await commandExists('gemini')
|
|
70
|
-
},
|
|
71
62
|
'antigravity': {
|
|
72
63
|
key: 'antigravity',
|
|
73
64
|
command: 'agy',
|
package/lib/commands/web.js
CHANGED
|
@@ -217,6 +217,40 @@ async function webCommand(options) {
|
|
|
217
217
|
res.json({ success: true });
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
app.get('/api/sessions/:id/timed-inputs', (req, res) => {
|
|
221
|
+
const items = sessionManager.listTimedInputs(req.params.id);
|
|
222
|
+
if (!items) return res.status(404).json({ error: 'Session not found' });
|
|
223
|
+
res.json({ success: true, items });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
app.post('/api/sessions/:id/timed-inputs', (req, res) => {
|
|
227
|
+
try {
|
|
228
|
+
const item = sessionManager.scheduleTimedInput(req.params.id, req.body || {});
|
|
229
|
+
if (!item) return res.status(404).json({ error: 'Session not found' });
|
|
230
|
+
res.json({ success: true, item });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
app.patch('/api/sessions/:id/timed-inputs/:inputId', (req, res) => {
|
|
237
|
+
try {
|
|
238
|
+
const item = sessionManager.updateTimedInput(req.params.id, req.params.inputId, req.body || {});
|
|
239
|
+
if (item === null) return res.status(404).json({ error: 'Session not found' });
|
|
240
|
+
if (!item) return res.status(404).json({ error: 'Timed input not found' });
|
|
241
|
+
res.json({ success: true, item });
|
|
242
|
+
} catch (e) {
|
|
243
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
app.delete('/api/sessions/:id/timed-inputs/:inputId', (req, res) => {
|
|
248
|
+
const cancelled = sessionManager.cancelTimedInput(req.params.id, req.params.inputId);
|
|
249
|
+
if (cancelled === null) return res.status(404).json({ error: 'Session not found' });
|
|
250
|
+
if (!cancelled) return res.status(404).json({ error: 'Timed input not found' });
|
|
251
|
+
res.json({ success: true });
|
|
252
|
+
});
|
|
253
|
+
|
|
220
254
|
// API: Delete/Kill session
|
|
221
255
|
app.delete('/api/sessions/:id', (req, res) => {
|
|
222
256
|
sessionManager.kill(req.params.id);
|
|
@@ -244,6 +278,15 @@ async function webCommand(options) {
|
|
|
244
278
|
res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
|
|
245
279
|
});
|
|
246
280
|
|
|
281
|
+
// API: Git Branch Name for Commit
|
|
282
|
+
app.get('/api/sessions/:id/git-branch/:hash', async (req, res) => {
|
|
283
|
+
const session = sessionManager.get(req.params.id);
|
|
284
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
285
|
+
const hash = req.params.hash;
|
|
286
|
+
const result = await gitService.nameRev(session.ptyManager.workingDir, hash);
|
|
287
|
+
res.json({ success: result.success, stdout: result.stdout, stderr: result.stderr });
|
|
288
|
+
});
|
|
289
|
+
|
|
247
290
|
// API: Git Log
|
|
248
291
|
app.get('/api/sessions/:id/git-log', async (req, res) => {
|
|
249
292
|
const session = sessionManager.get(req.params.id);
|
|
@@ -354,7 +397,7 @@ async function webCommand(options) {
|
|
|
354
397
|
// Send catchup output. TUI tools may skip the raw circular buffer, so fall
|
|
355
398
|
// back to the rendered/text history snapshot instead of reconnecting blank.
|
|
356
399
|
const catchup = sessionManager.getCatchupOutput(sessionId);
|
|
357
|
-
ws.needsTuiRedraw = ['antigravity', 'claude-code', 'codex'
|
|
400
|
+
ws.needsTuiRedraw = ['antigravity', 'claude-code', 'codex'].includes(session.tool.key)
|
|
358
401
|
&& (isReconnect || (catchup && catchup.source === 'rendered-history'));
|
|
359
402
|
if (ws.needsTuiRedraw) {
|
|
360
403
|
ws.send(JSON.stringify({ type: 'reset' }));
|
package/lib/git/service.js
CHANGED
|
@@ -70,6 +70,10 @@ class GitService {
|
|
|
70
70
|
: ['diff', '--no-ext-diff', '--', filePath];
|
|
71
71
|
return execFilePromise('git', args, cwd);
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
async nameRev(cwd, hash) {
|
|
75
|
+
return execFilePromise('git', ['name-rev', '--name-only', '--exclude=tags/*', hash], cwd);
|
|
76
|
+
}
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
module.exports = {
|
|
@@ -37,7 +37,10 @@ class SessionManager extends EventEmitter {
|
|
|
37
37
|
startTime: session.startTime,
|
|
38
38
|
toolKey: session.tool.key,
|
|
39
39
|
workingDirectory: session.ptyManager.workingDir,
|
|
40
|
-
hasUnreadCompletion: Boolean(session.hasUnreadCompletion)
|
|
40
|
+
hasUnreadCompletion: Boolean(session.hasUnreadCompletion),
|
|
41
|
+
timedInputCount: session.timedInputs
|
|
42
|
+
? Array.from(session.timedInputs.values()).filter(item => item.sendAt > Date.now()).length
|
|
43
|
+
: 0
|
|
41
44
|
}));
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -99,6 +102,7 @@ class SessionManager extends EventEmitter {
|
|
|
99
102
|
resizeOwner: null,
|
|
100
103
|
hasConnectedWebClient: false,
|
|
101
104
|
hasUnreadCompletion: false,
|
|
105
|
+
timedInputs: new Map(),
|
|
102
106
|
write: data => this.write(id, data),
|
|
103
107
|
isRunning: () => this.has(id) && ptyManager.isRunning(),
|
|
104
108
|
kill: () => this.kill(id)
|
|
@@ -167,10 +171,121 @@ class SessionManager extends EventEmitter {
|
|
|
167
171
|
return session;
|
|
168
172
|
}
|
|
169
173
|
|
|
174
|
+
listTimedInputs(id) {
|
|
175
|
+
const session = this.get(id);
|
|
176
|
+
if (!session) return null;
|
|
177
|
+
return Array.from(session.timedInputs.values()).map(item => ({
|
|
178
|
+
id: item.id,
|
|
179
|
+
text: item.text,
|
|
180
|
+
sendAt: item.sendAt,
|
|
181
|
+
createdAt: item.createdAt
|
|
182
|
+
})).sort((a, b) => a.sendAt - b.sendAt);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
scheduleTimedInput(id, input = {}) {
|
|
186
|
+
const session = this.get(id);
|
|
187
|
+
if (!session) return null;
|
|
188
|
+
|
|
189
|
+
const { text, sendAt, delay } = this.validateTimedInput(input);
|
|
190
|
+
|
|
191
|
+
const item = {
|
|
192
|
+
id: uuidv4(),
|
|
193
|
+
text,
|
|
194
|
+
sendAt,
|
|
195
|
+
createdAt: Date.now(),
|
|
196
|
+
timer: null
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
item.timer = setTimeout(() => {
|
|
200
|
+
this.executeTimedInput(session.id, item.id);
|
|
201
|
+
}, delay);
|
|
202
|
+
session.timedInputs.set(item.id, item);
|
|
203
|
+
return {
|
|
204
|
+
id: item.id,
|
|
205
|
+
text: item.text,
|
|
206
|
+
sendAt: item.sendAt,
|
|
207
|
+
createdAt: item.createdAt
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
updateTimedInput(id, inputId, input = {}) {
|
|
212
|
+
const session = this.get(id);
|
|
213
|
+
if (!session) return null;
|
|
214
|
+
const item = session.timedInputs.get(inputId);
|
|
215
|
+
if (!item) return false;
|
|
216
|
+
|
|
217
|
+
const { text, sendAt, delay } = this.validateTimedInput(input);
|
|
218
|
+
clearTimeout(item.timer);
|
|
219
|
+
item.text = text;
|
|
220
|
+
item.sendAt = sendAt;
|
|
221
|
+
item.updatedAt = Date.now();
|
|
222
|
+
item.timer = setTimeout(() => {
|
|
223
|
+
this.executeTimedInput(session.id, item.id);
|
|
224
|
+
}, delay);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
id: item.id,
|
|
228
|
+
text: item.text,
|
|
229
|
+
sendAt: item.sendAt,
|
|
230
|
+
createdAt: item.createdAt,
|
|
231
|
+
updatedAt: item.updatedAt
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
validateTimedInput(input = {}) {
|
|
236
|
+
const text = String(input.text || '');
|
|
237
|
+
const sendAt = Number(input.sendAt);
|
|
238
|
+
if (!text.trim()) {
|
|
239
|
+
const err = new Error('Text is required');
|
|
240
|
+
err.statusCode = 400;
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
if (!Number.isFinite(sendAt) || sendAt <= Date.now()) {
|
|
244
|
+
const err = new Error('Send time must be in the future');
|
|
245
|
+
err.statusCode = 400;
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const maxDelay = 30 * 24 * 60 * 60 * 1000;
|
|
250
|
+
const delay = sendAt - Date.now();
|
|
251
|
+
if (delay > maxDelay) {
|
|
252
|
+
const err = new Error('Send time must be within 30 days');
|
|
253
|
+
err.statusCode = 400;
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { text, sendAt, delay };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
cancelTimedInput(id, inputId) {
|
|
261
|
+
const session = this.get(id);
|
|
262
|
+
if (!session) return null;
|
|
263
|
+
const item = session.timedInputs.get(inputId);
|
|
264
|
+
if (!item) return false;
|
|
265
|
+
clearTimeout(item.timer);
|
|
266
|
+
session.timedInputs.delete(inputId);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
executeTimedInput(id, inputId) {
|
|
271
|
+
const session = this.get(id);
|
|
272
|
+
if (!session) return false;
|
|
273
|
+
const item = session.timedInputs.get(inputId);
|
|
274
|
+
if (!item) return false;
|
|
275
|
+
session.timedInputs.delete(inputId);
|
|
276
|
+
const formatted = item.text.replace(/\n/g, '\r');
|
|
277
|
+
this.write(id, formatted);
|
|
278
|
+
setTimeout(() => {
|
|
279
|
+
if (this.has(id)) this.write(id, '\r');
|
|
280
|
+
}, 1000);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
170
284
|
kill(id) {
|
|
171
285
|
const session = this.get(id);
|
|
172
286
|
if (!session) return false;
|
|
173
287
|
clearTimeout(session.completionTimer);
|
|
288
|
+
this.clearTimedInputs(session);
|
|
174
289
|
this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
|
|
175
290
|
this.disposeSessionHistory(session);
|
|
176
291
|
session.ptyManager.kill();
|
|
@@ -182,6 +297,7 @@ class SessionManager extends EventEmitter {
|
|
|
182
297
|
killAll() {
|
|
183
298
|
for (const session of this.sessions.values()) {
|
|
184
299
|
clearTimeout(session.completionTimer);
|
|
300
|
+
this.clearTimedInputs(session);
|
|
185
301
|
this.disposeSessionHistory(session);
|
|
186
302
|
session.ptyManager.kill();
|
|
187
303
|
}
|
|
@@ -332,6 +448,7 @@ class SessionManager extends EventEmitter {
|
|
|
332
448
|
if (!this.sessions.has(session.id)) return;
|
|
333
449
|
this.logger.info(`Session ${session.id} (${session.name}) exited.`);
|
|
334
450
|
clearTimeout(session.completionTimer);
|
|
451
|
+
this.clearTimedInputs(session);
|
|
335
452
|
this.disposeSessionHistory(session);
|
|
336
453
|
this.sessions.delete(session.id);
|
|
337
454
|
this.emit('exit', { sessionId: session.id, session });
|
|
@@ -357,6 +474,14 @@ class SessionManager extends EventEmitter {
|
|
|
357
474
|
}
|
|
358
475
|
}
|
|
359
476
|
|
|
477
|
+
clearTimedInputs(session) {
|
|
478
|
+
if (!session || !session.timedInputs) return;
|
|
479
|
+
for (const item of session.timedInputs.values()) {
|
|
480
|
+
clearTimeout(item.timer);
|
|
481
|
+
}
|
|
482
|
+
session.timedInputs.clear();
|
|
483
|
+
}
|
|
484
|
+
|
|
360
485
|
getSessionDiagnostics(session, extra = {}) {
|
|
361
486
|
return {
|
|
362
487
|
sessionId: session.id,
|
package/lib/web/gitgraph.js
CHANGED
|
@@ -175,13 +175,14 @@ class GitGraphRenderer {
|
|
|
175
175
|
|
|
176
176
|
let detailsHTML = `
|
|
177
177
|
<div style="display:flex; justify-content:space-between; margin-bottom:12px;">
|
|
178
|
-
<div>
|
|
179
|
-
<div style="font-weight:600; font-size:14px; margin-bottom:4px;">${commit.subject.replace(/</g, "<").replace(/>/g, ">")}</div>
|
|
178
|
+
<div style="flex: 1; padding-right: 16px; min-width: 0;">
|
|
179
|
+
<div style="font-weight:600; font-size:14px; margin-bottom:4px; word-break: break-word;">${commit.subject.replace(/</g, "<").replace(/>/g, ">")}</div>
|
|
180
180
|
<div style="color:var(--text-dim);">${commit.author} commited ${commit.time}</div>
|
|
181
|
+
<div id="branch-info-${commit.hash}" style="font-family:monospace; color:var(--primary); margin-top:6px; font-size:12px; font-weight:500;"></div>
|
|
181
182
|
</div>
|
|
182
|
-
<div style="text-align:right;">
|
|
183
|
+
<div style="text-align:right; flex-shrink: 0;">
|
|
183
184
|
<div style="font-family:monospace; color:var(--text-dim);">Commit: ${commit.hash}</div>
|
|
184
|
-
${commit.parents.length > 0 ? `<div style="font-family:monospace; color:var(--text-dim);">Parents: ${commit.parents.join(', ')}</div>` : ''}
|
|
185
|
+
${commit.parents.length > 0 ? `<div style="font-family:monospace; color:var(--text-dim); margin-top:2px;">Parents: ${commit.parents.join(', ')}</div>` : ''}
|
|
185
186
|
</div>
|
|
186
187
|
</div>
|
|
187
188
|
`;
|
|
@@ -189,13 +190,32 @@ class GitGraphRenderer {
|
|
|
189
190
|
// Add diff placeholder
|
|
190
191
|
detailsHTML += `
|
|
191
192
|
<div style="border-top:1px solid #333; padding-top:12px; margin-top:12px;">
|
|
192
|
-
<button onclick="window.loadCommitDiff('${commit.hash}'
|
|
193
|
-
<div class="diff-container" style="margin-top:12px; font-family:monospace; font-size:12px; white-space:pre-wrap; overflow-x:auto;"></div>
|
|
193
|
+
<button onclick="window.loadCommitDiff('${commit.hash}')" style="background:var(--primary); border:none; color:#fff; padding:6px 12px; border-radius:4px; font-size:12px; cursor:pointer;">View Full Diff</button>
|
|
194
194
|
</div>
|
|
195
195
|
`;
|
|
196
196
|
|
|
197
197
|
detailsDiv.innerHTML = detailsHTML;
|
|
198
198
|
rowDiv.parentNode.insertBefore(detailsDiv, rowDiv.nextSibling);
|
|
199
|
+
|
|
200
|
+
// Fetch branch info
|
|
201
|
+
if (window.activeSessionId) {
|
|
202
|
+
const branchContainer = document.getElementById(`branch-info-${commit.hash}`);
|
|
203
|
+
if (branchContainer) {
|
|
204
|
+
branchContainer.textContent = 'Loading branch...';
|
|
205
|
+
fetch(`/api/sessions/${window.activeSessionId}/git-branch/${commit.hash}`)
|
|
206
|
+
.then(res => res.json())
|
|
207
|
+
.then(data => {
|
|
208
|
+
if (data.success && data.stdout && data.stdout.trim()) {
|
|
209
|
+
branchContainer.textContent = `Branch: ${data.stdout.trim()}`;
|
|
210
|
+
} else {
|
|
211
|
+
branchContainer.textContent = '';
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
.catch(() => {
|
|
215
|
+
branchContainer.textContent = '';
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
199
219
|
};
|
|
200
220
|
|
|
201
221
|
rowDiv.appendChild(contentDiv);
|
package/lib/web/index.html
CHANGED
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
.session-info { flex: 1; min-width: 0; }
|
|
27
27
|
.session-info h3 { margin: 0 0 4px 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
|
|
28
28
|
.session-info p { margin: 0; font-size: 13px; color: var(--text-dim); }
|
|
29
|
+
.timer-count-badge { display: inline-flex; align-items: center; gap: 3px; color: #fff; background: rgba(0,122,255,0.22); border: 1px solid rgba(0,122,255,0.34); border-radius: 999px; padding: 2px 7px; font-size: 11px; font-weight: 800; flex-shrink: 0; }
|
|
30
|
+
.session-dir-row { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
|
|
31
|
+
.session-dir-row p { flex: 1; min-width: 0; font-family: monospace; overflow-wrap: anywhere; }
|
|
32
|
+
.copy-dir-btn { color: var(--text-dim); background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; width: 28px; height: 28px; padding: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; }
|
|
33
|
+
.copy-dir-btn:active { color: #fff; background: rgba(255,255,255,0.12); }
|
|
29
34
|
.session-actions { display: flex; gap: 12px; align-items: center; margin-left: 10px; }
|
|
30
35
|
.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; }
|
|
31
36
|
.icon-btn { color: var(--text-dim); background: none; border: none; padding: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
|
@@ -47,7 +52,19 @@
|
|
|
47
52
|
#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; }
|
|
48
53
|
#cmd-input::placeholder { color: rgba(255,255,255,0.45); }
|
|
49
54
|
#cmd-input:focus { background: rgba(255,255,255,0.1); border-color: rgba(0,122,255,0.42); color: #fff; }
|
|
55
|
+
#timer-btn { width: 38px; height: 38px; margin-left: 8px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #d1d5db; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
|
|
56
|
+
#timer-btn.active { color: #fff; border-color: rgba(0,122,255,0.45); background: rgba(0,122,255,0.22); }
|
|
50
57
|
#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; }
|
|
58
|
+
#timed-send-panel { display: none; margin: 0 14px 10px 14px; padding: 12px; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; background: rgba(28,28,30,0.98); box-sizing: border-box; }
|
|
59
|
+
#timed-send-panel.active { display: block; }
|
|
60
|
+
.timed-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: center; }
|
|
61
|
+
.timed-row select { min-width: 0; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); color: #fff; border-radius: 8px; padding: 8px 9px; font-size: 13px; outline: none; box-sizing: border-box; }
|
|
62
|
+
.timed-preview { margin-top: 8px; color: #d1d5db; font-size: 12px; line-height: 1.4; }
|
|
63
|
+
.timed-editor-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; }
|
|
64
|
+
#timed-tag-rail { position: absolute; left: 4px; top: 58px; display: flex; flex-direction: column; gap: 6px; z-index: 2400; pointer-events: none; }
|
|
65
|
+
.timed-tag { width: 54px; min-height: 34px; border: 1px solid rgba(255,255,255,0.14); border-left: 0; border-radius: 0 11px 11px 0; background: rgba(0,122,255,0.24); color: #fff; backdrop-filter: blur(12px); font-size: 12px; font-weight: 800; letter-spacing: 0; cursor: pointer; display: flex; align-items: center; justify-content: center; text-align: center; line-height: 1.08; white-space: pre-line; box-shadow: 0 8px 20px rgba(0,0,0,0.25); pointer-events: auto; }
|
|
66
|
+
.timed-tag.active { background: rgba(0,122,255,0.42); border-color: rgba(255,255,255,0.28); }
|
|
67
|
+
.timed-tag:active { transform: scale(0.96); }
|
|
51
68
|
#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; }
|
|
52
69
|
#shortcut-rail::-webkit-scrollbar { display: none; }
|
|
53
70
|
.shortcut-group { flex: 0 0 calc(100vw - 28px); display: grid; gap: 8px; scroll-snap-align: start; }
|
|
@@ -109,6 +126,7 @@
|
|
|
109
126
|
.schedule-row { flex-direction: column; }
|
|
110
127
|
.schedule-actions { justify-content: flex-start; }
|
|
111
128
|
.step-grid { grid-template-columns: 1fr; }
|
|
129
|
+
.timed-row { grid-template-columns: 1fr; }
|
|
112
130
|
}
|
|
113
131
|
</style>
|
|
114
132
|
</head>
|
|
@@ -159,12 +177,28 @@
|
|
|
159
177
|
</div>
|
|
160
178
|
</div>
|
|
161
179
|
<div id="terminal-controls">
|
|
180
|
+
<div id="timed-tag-rail"></div>
|
|
162
181
|
<div id="input-row">
|
|
163
182
|
<textarea id="cmd-input" rows="1" placeholder="Type a message..."></textarea>
|
|
183
|
+
<button id="timer-btn" title="Schedule send">
|
|
184
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
|
|
185
|
+
</button>
|
|
164
186
|
<button id="send-btn">
|
|
165
187
|
<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>
|
|
166
188
|
</button>
|
|
167
189
|
</div>
|
|
190
|
+
<div id="timed-send-panel">
|
|
191
|
+
<div class="timed-row">
|
|
192
|
+
<select id="timed-hours" onchange="updateTimedSendPreview()" title="Hours"></select>
|
|
193
|
+
<select id="timed-minutes" onchange="updateTimedSendPreview()" title="Minutes"></select>
|
|
194
|
+
<button id="timed-save-btn" class="small-btn primary" onclick="saveTimedSend()">Add Timer</button>
|
|
195
|
+
</div>
|
|
196
|
+
<div id="timed-preview" class="timed-preview"></div>
|
|
197
|
+
<div class="timed-editor-actions">
|
|
198
|
+
<button id="timed-cancel-edit-btn" class="small-btn" onclick="resetTimedEditor()" style="display:none;">Cancel Edit</button>
|
|
199
|
+
<button id="timed-delete-btn" class="small-btn danger" onclick="deleteEditingTimedInput()" style="display:none;">Delete</button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
168
202
|
<div id="shortcut-rail">
|
|
169
203
|
<div class="shortcut-group simple-shortcuts">
|
|
170
204
|
<div class="key-btn special-key" data-key="up">↑</div>
|
|
@@ -317,6 +351,9 @@
|
|
|
317
351
|
let editingScheduleId = null;
|
|
318
352
|
let editingSteps = [];
|
|
319
353
|
let selectedWeekdays = [1, 2, 3, 4, 5];
|
|
354
|
+
let timedSendRefreshTimer = null;
|
|
355
|
+
let timedTagTimer = null;
|
|
356
|
+
let editingTimedInputId = null;
|
|
320
357
|
const modifiers = { ctrl: false };
|
|
321
358
|
|
|
322
359
|
function log(msg) {
|
|
@@ -360,6 +397,39 @@
|
|
|
360
397
|
return decodeURIComponent(path);
|
|
361
398
|
}
|
|
362
399
|
|
|
400
|
+
async function copyTextToClipboard(text) {
|
|
401
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
402
|
+
await navigator.clipboard.writeText(text);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const el = document.createElement('textarea');
|
|
406
|
+
el.value = text;
|
|
407
|
+
el.setAttribute('readonly', '');
|
|
408
|
+
el.style.position = 'fixed';
|
|
409
|
+
el.style.opacity = '0';
|
|
410
|
+
document.body.appendChild(el);
|
|
411
|
+
el.select();
|
|
412
|
+
document.execCommand('copy');
|
|
413
|
+
document.body.removeChild(el);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function copySessionDirectory(directory, event) {
|
|
417
|
+
event.stopPropagation();
|
|
418
|
+
const btn = event.currentTarget;
|
|
419
|
+
try {
|
|
420
|
+
await copyTextToClipboard(directory);
|
|
421
|
+
const oldTitle = btn.title;
|
|
422
|
+
btn.title = 'Copied';
|
|
423
|
+
btn.style.color = '#fff';
|
|
424
|
+
setTimeout(() => {
|
|
425
|
+
btn.title = oldTitle || 'Copy directory';
|
|
426
|
+
btn.style.color = '';
|
|
427
|
+
}, 1200);
|
|
428
|
+
} catch (e) {
|
|
429
|
+
alert('Copy failed');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
363
433
|
function formatWeekdays(days = []) {
|
|
364
434
|
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
365
435
|
return days.map(day => labels[day]).filter(Boolean).join(', ') || 'No days';
|
|
@@ -443,12 +513,25 @@
|
|
|
443
513
|
sessions.forEach(s => {
|
|
444
514
|
const workingDirectory = s.workingDirectory || 'Unknown directory';
|
|
445
515
|
const encodedName = encodePathValue(s.name);
|
|
516
|
+
const encodedDir = encodePathValue(workingDirectory);
|
|
517
|
+
const timedInputCount = Number(s.timedInputCount) || 0;
|
|
518
|
+
const timerBadge = timedInputCount > 0
|
|
519
|
+
? `<span class="timer-count-badge" title="${timedInputCount} scheduled timer${timedInputCount > 1 ? 's' : ''}">
|
|
520
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
|
|
521
|
+
${timedInputCount}
|
|
522
|
+
</span>`
|
|
523
|
+
: '';
|
|
446
524
|
html += `<div class="session-card">
|
|
447
525
|
<div class="session-info">
|
|
448
|
-
<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>
|
|
526
|
+
<h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <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>
|
|
449
527
|
<p>${escapeHtml(s.tool)}</p>
|
|
450
528
|
<p>${new Date(s.startTime).toLocaleTimeString()}</p>
|
|
451
|
-
<
|
|
529
|
+
<div class="session-dir-row">
|
|
530
|
+
<button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
|
|
531
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" 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>
|
|
532
|
+
</button>
|
|
533
|
+
<p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
|
|
534
|
+
</div>
|
|
452
535
|
</div>
|
|
453
536
|
<div class="session-actions">
|
|
454
537
|
<button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
|
|
@@ -826,6 +909,8 @@
|
|
|
826
909
|
|
|
827
910
|
function showLobby() {
|
|
828
911
|
if (currentSocket) { currentSocket.close(); currentSocket = null; }
|
|
912
|
+
closeTimedSendPanel();
|
|
913
|
+
stopTimedInputTimers();
|
|
829
914
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
830
915
|
document.getElementById('lobby-view').classList.add('active');
|
|
831
916
|
refreshSessionsNow();
|
|
@@ -849,13 +934,6 @@
|
|
|
849
934
|
{ command: '/model' },
|
|
850
935
|
{ command: '/resume' },
|
|
851
936
|
{ command: '/stat' }
|
|
852
|
-
],
|
|
853
|
-
gemini: [
|
|
854
|
-
{ key: 'ctrl-c', label: 'Ctrl+C' },
|
|
855
|
-
{ key: 'ctrl-y', label: 'Ctrl+Y', title: 'Send Ctrl+Y' },
|
|
856
|
-
{ command: '/model' },
|
|
857
|
-
{ command: '/resume' },
|
|
858
|
-
{ command: '/stat' }
|
|
859
937
|
]
|
|
860
938
|
};
|
|
861
939
|
const shortcuts = toolShortcuts[toolKey] || defaultShortcuts;
|
|
@@ -882,11 +960,14 @@
|
|
|
882
960
|
}
|
|
883
961
|
|
|
884
962
|
function joinSession(id, sessionName, toolKey = null) {
|
|
963
|
+
stopTimedInputTimers();
|
|
885
964
|
activeSessionId = id;
|
|
965
|
+
window.activeSessionId = id;
|
|
886
966
|
activeToolKey = toolKey;
|
|
887
967
|
clearTimeout(sessionPollTimer);
|
|
888
968
|
sessionPollTimer = null;
|
|
889
969
|
markCompletionRead(id);
|
|
970
|
+
loadTimedInputs();
|
|
890
971
|
document.getElementById('session-title').innerText = sessionName;
|
|
891
972
|
updateToolShortcuts(activeToolKey);
|
|
892
973
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
@@ -1151,8 +1232,191 @@
|
|
|
1151
1232
|
setTimeout(() => { sendWS('\r'); }, 1000);
|
|
1152
1233
|
}
|
|
1153
1234
|
}
|
|
1235
|
+
|
|
1236
|
+
let timedInputs = [];
|
|
1237
|
+
|
|
1238
|
+
function initTimedDelaySelectors() {
|
|
1239
|
+
const hours = document.getElementById('timed-hours');
|
|
1240
|
+
const minutes = document.getElementById('timed-minutes');
|
|
1241
|
+
if (hours.options.length) return;
|
|
1242
|
+
for (let i = 0; i <= 23; i++) {
|
|
1243
|
+
hours.add(new Option(`${i} hr`, String(i)));
|
|
1244
|
+
}
|
|
1245
|
+
for (let i = 0; i <= 59; i++) {
|
|
1246
|
+
minutes.add(new Option(`${i} min`, String(i)));
|
|
1247
|
+
}
|
|
1248
|
+
hours.value = '0';
|
|
1249
|
+
minutes.value = '5';
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function getTimedDelayMs() {
|
|
1253
|
+
const hours = Number(document.getElementById('timed-hours').value);
|
|
1254
|
+
const minutes = Number(document.getElementById('timed-minutes').value);
|
|
1255
|
+
const delayMs = ((hours * 60) + minutes) * 60 * 1000;
|
|
1256
|
+
return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function parseSendAt() {
|
|
1260
|
+
const delayMs = getTimedDelayMs();
|
|
1261
|
+
return delayMs > 0 ? Date.now() + delayMs : null;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function setTimedDelayFromMs(ms) {
|
|
1265
|
+
const totalMinutes = Math.max(1, Math.ceil(ms / 60000));
|
|
1266
|
+
const hours = Math.min(23, Math.floor(totalMinutes / 60));
|
|
1267
|
+
const minutes = Math.min(59, totalMinutes - hours * 60);
|
|
1268
|
+
document.getElementById('timed-hours').value = String(hours);
|
|
1269
|
+
document.getElementById('timed-minutes').value = String(minutes);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function updateTimedSendPreview() {
|
|
1273
|
+
const preview = document.getElementById('timed-preview');
|
|
1274
|
+
const sendAt = parseSendAt();
|
|
1275
|
+
preview.textContent = sendAt
|
|
1276
|
+
? `Will run at ${new Date(sendAt).toLocaleString()}`
|
|
1277
|
+
: 'Choose at least 1 minute.';
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function formatCountdown(sendAt) {
|
|
1281
|
+
const remaining = Math.max(0, sendAt - Date.now());
|
|
1282
|
+
const totalSeconds = Math.ceil(remaining / 1000);
|
|
1283
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1284
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1285
|
+
const seconds = totalSeconds % 60;
|
|
1286
|
+
if (hours > 0) return `${hours}h\n${String(minutes).padStart(2, '0')}m`;
|
|
1287
|
+
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function renderTimedTags() {
|
|
1291
|
+
const rail = document.getElementById('timed-tag-rail');
|
|
1292
|
+
const now = Date.now();
|
|
1293
|
+
const activeItems = timedInputs.filter(item => item.sendAt > now);
|
|
1294
|
+
rail.innerHTML = activeItems.map(item => {
|
|
1295
|
+
const encodedId = encodePathValue(item.id);
|
|
1296
|
+
return `<button class="timed-tag${item.id === editingTimedInputId ? ' active' : ''}" title="${escapeHtml(item.text)}" onclick="editTimedInput(decodePathValue('${encodedId}'))">${escapeHtml(formatCountdown(item.sendAt))}</button>`;
|
|
1297
|
+
}).join('');
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function closeTimedSendPanel() {
|
|
1301
|
+
document.getElementById('timed-send-panel').classList.remove('active');
|
|
1302
|
+
document.getElementById('timer-btn').classList.remove('active');
|
|
1303
|
+
editingTimedInputId = null;
|
|
1304
|
+
renderTimedTags();
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function stopTimedInputTimers() {
|
|
1308
|
+
clearTimeout(timedSendRefreshTimer);
|
|
1309
|
+
timedSendRefreshTimer = null;
|
|
1310
|
+
if (timedTagTimer) clearInterval(timedTagTimer);
|
|
1311
|
+
timedTagTimer = null;
|
|
1312
|
+
timedInputs = [];
|
|
1313
|
+
renderTimedTags();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function resetTimedEditor(options = {}) {
|
|
1317
|
+
editingTimedInputId = null;
|
|
1318
|
+
document.getElementById('timed-save-btn').textContent = 'Add Timer';
|
|
1319
|
+
document.getElementById('timed-cancel-edit-btn').style.display = 'none';
|
|
1320
|
+
document.getElementById('timed-delete-btn').style.display = 'none';
|
|
1321
|
+
if (!options.keepInput) {
|
|
1322
|
+
inputEl.value = '';
|
|
1323
|
+
inputEl.style.height = '38px';
|
|
1324
|
+
}
|
|
1325
|
+
setTimedDelayFromMs(5 * 60 * 1000);
|
|
1326
|
+
updateTimedSendPreview();
|
|
1327
|
+
renderTimedTags();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function loadTimedInputs() {
|
|
1331
|
+
clearTimeout(timedSendRefreshTimer);
|
|
1332
|
+
timedSendRefreshTimer = null;
|
|
1333
|
+
if (!activeSessionId) return;
|
|
1334
|
+
|
|
1335
|
+
try {
|
|
1336
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/timed-inputs`);
|
|
1337
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
1338
|
+
const data = await res.json();
|
|
1339
|
+
timedInputs = data.items || [];
|
|
1340
|
+
renderTimedTags();
|
|
1341
|
+
if (!timedTagTimer) timedTagTimer = setInterval(renderTimedTags, 1000);
|
|
1342
|
+
timedSendRefreshTimer = setTimeout(loadTimedInputs, 15000);
|
|
1343
|
+
} catch (e) {
|
|
1344
|
+
timedInputs = [];
|
|
1345
|
+
renderTimedTags();
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function editTimedInput(id) {
|
|
1350
|
+
const item = timedInputs.find(value => value.id === id);
|
|
1351
|
+
if (!item) return;
|
|
1352
|
+
initTimedDelaySelectors();
|
|
1353
|
+
editingTimedInputId = id;
|
|
1354
|
+
inputEl.value = item.text || '';
|
|
1355
|
+
inputEl.style.height = 'auto';
|
|
1356
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + 'px';
|
|
1357
|
+
setTimedDelayFromMs(item.sendAt - Date.now());
|
|
1358
|
+
document.getElementById('timed-save-btn').textContent = 'Update Timer';
|
|
1359
|
+
document.getElementById('timed-cancel-edit-btn').style.display = '';
|
|
1360
|
+
document.getElementById('timed-delete-btn').style.display = '';
|
|
1361
|
+
document.getElementById('timed-send-panel').classList.add('active');
|
|
1362
|
+
document.getElementById('timer-btn').classList.add('active');
|
|
1363
|
+
updateTimedSendPreview();
|
|
1364
|
+
renderTimedTags();
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
async function saveTimedSend() {
|
|
1368
|
+
if (!activeSessionId) return alert('No active session');
|
|
1369
|
+
const text = inputEl.value;
|
|
1370
|
+
if (!text.trim()) return alert('Type a message first');
|
|
1371
|
+
const sendAt = parseSendAt();
|
|
1372
|
+
if (!sendAt) return alert('Choose at least 1 minute');
|
|
1373
|
+
|
|
1374
|
+
try {
|
|
1375
|
+
const url = editingTimedInputId
|
|
1376
|
+
? `/api/sessions/${activeSessionId}/timed-inputs/${editingTimedInputId}`
|
|
1377
|
+
: `/api/sessions/${activeSessionId}/timed-inputs`;
|
|
1378
|
+
const res = await fetchWithTimeout(url, {
|
|
1379
|
+
method: editingTimedInputId ? 'PATCH' : 'POST',
|
|
1380
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1381
|
+
body: JSON.stringify({ text, sendAt })
|
|
1382
|
+
});
|
|
1383
|
+
const data = await res.json();
|
|
1384
|
+
if (!res.ok || !data.success) throw new Error(data.error || 'Failed to save timer');
|
|
1385
|
+
resetTimedEditor();
|
|
1386
|
+
await loadTimedInputs();
|
|
1387
|
+
} catch (e) {
|
|
1388
|
+
alert('Save failed: ' + e.message);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
async function cancelTimedInput(id) {
|
|
1393
|
+
if (!activeSessionId) return;
|
|
1394
|
+
try {
|
|
1395
|
+
await fetchWithTimeout(`/api/sessions/${activeSessionId}/timed-inputs/${id}`, { method: 'DELETE' });
|
|
1396
|
+
if (editingTimedInputId === id) resetTimedEditor();
|
|
1397
|
+
loadTimedInputs();
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
alert('Delete failed');
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function deleteEditingTimedInput() {
|
|
1404
|
+
if (editingTimedInputId) cancelTimedInput(editingTimedInputId);
|
|
1405
|
+
}
|
|
1154
1406
|
|
|
1155
1407
|
document.getElementById('send-btn').addEventListener('click', performSend);
|
|
1408
|
+
document.getElementById('timer-btn').addEventListener('click', () => {
|
|
1409
|
+
const panel = document.getElementById('timed-send-panel');
|
|
1410
|
+
const isOpen = panel.classList.toggle('active');
|
|
1411
|
+
document.getElementById('timer-btn').classList.toggle('active', isOpen);
|
|
1412
|
+
if (isOpen) {
|
|
1413
|
+
initTimedDelaySelectors();
|
|
1414
|
+
resetTimedEditor({ keepInput: true });
|
|
1415
|
+
updateTimedSendPreview();
|
|
1416
|
+
loadTimedInputs();
|
|
1417
|
+
}
|
|
1418
|
+
else closeTimedSendPanel();
|
|
1419
|
+
});
|
|
1156
1420
|
inputEl.addEventListener('keydown', (e) => {
|
|
1157
1421
|
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); performSend(); }
|
|
1158
1422
|
else if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete') {
|
|
@@ -1283,34 +1547,90 @@
|
|
|
1283
1547
|
}
|
|
1284
1548
|
|
|
1285
1549
|
|
|
1286
|
-
|
|
1550
|
+
let currentCommitDiffHTML = '';
|
|
1551
|
+
let currentCommitHash = '';
|
|
1552
|
+
|
|
1553
|
+
window.loadCommitDiff = async function(hash) {
|
|
1287
1554
|
if (!activeSessionId) return;
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1290
|
-
|
|
1555
|
+
const content = document.getElementById('git-content');
|
|
1556
|
+
content.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--text-dim);">Loading diff...</div>';
|
|
1557
|
+
currentCommitHash = hash;
|
|
1558
|
+
|
|
1291
1559
|
try {
|
|
1292
1560
|
const res = await fetch(`/api/sessions/${activeSessionId}/git-show/${hash}`);
|
|
1293
1561
|
const data = await res.json();
|
|
1294
1562
|
if (data.success) {
|
|
1295
|
-
let
|
|
1563
|
+
let fileBlocks = [];
|
|
1564
|
+
let currentBlock = { name: 'Commit Details', lines: [] };
|
|
1565
|
+
fileBlocks.push(currentBlock);
|
|
1566
|
+
|
|
1296
1567
|
const lines = data.stdout.split('\n');
|
|
1297
1568
|
for (const line of lines) {
|
|
1298
|
-
|
|
1299
|
-
if (
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1569
|
+
const diffMatch = line.match(/^diff --git a\/(.+?) b\//);
|
|
1570
|
+
if (diffMatch) {
|
|
1571
|
+
currentBlock = { name: diffMatch[1], lines: [] };
|
|
1572
|
+
fileBlocks.push(currentBlock);
|
|
1573
|
+
}
|
|
1574
|
+
currentBlock.lines.push(line);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
let diffHTML = '';
|
|
1578
|
+
for (const block of fileBlocks) {
|
|
1579
|
+
if (block.lines.length === 0 || (block.lines.length === 1 && !block.lines[0])) continue;
|
|
1580
|
+
|
|
1581
|
+
let blockContent = '';
|
|
1582
|
+
let addCount = 0;
|
|
1583
|
+
let subCount = 0;
|
|
1584
|
+
for (const line of block.lines) {
|
|
1585
|
+
let color = '#ccc', bg = 'transparent', borderLeft = '2px solid transparent';
|
|
1586
|
+
if (line.startsWith('+') && !line.startsWith('+++')) { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; addCount++; }
|
|
1587
|
+
else if (line.startsWith('-') && !line.startsWith('---')) { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; subCount++; }
|
|
1588
|
+
else if (line.startsWith('@@')) { color = '#60a5fa'; bg = 'rgba(96, 165, 250, 0.1)'; }
|
|
1589
|
+
else if (line.startsWith('diff') || line.startsWith('index') || line.startsWith('commit') || line.startsWith('Author') || line.startsWith('Date')) color = '#fff';
|
|
1590
|
+
|
|
1591
|
+
blockContent += `<div style="color:${color}; background:${bg}; border-left:${borderLeft}; padding:2px 8px; white-space:pre-wrap; word-break:break-all;">${line.replace(/</g, '<').replace(/>/g, '>') || ' '}</div>`;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
const isOpen = block.name === 'Commit Details';
|
|
1595
|
+
const statHTML = block.name !== 'Commit Details' ? `<span style="margin-left: 12px; font-family: monospace; font-size: 12px;"><span style="color:#4ade80;">+${addCount}</span> <span style="color:#f87171; margin-left:6px;">-${subCount}</span></span>` : '';
|
|
1596
|
+
diffHTML += `
|
|
1597
|
+
<details ${isOpen ? 'open' : ''} style="margin-bottom: 8px; border: 1px solid #333; border-radius: 4px; overflow: hidden;">
|
|
1598
|
+
<summary style="background: #1e1e1e; padding: 6px 10px; cursor: pointer; color: #fff; font-weight: 500; font-size: 13px; outline: none; user-select: none;">
|
|
1599
|
+
${block.name === 'Commit Details' ? '📝 ' : '📄 '}${block.name.replace(/</g, '<').replace(/>/g, '>')}${statHTML}
|
|
1600
|
+
</summary>
|
|
1601
|
+
<div style="background: #0d0d0d; overflow-x: auto; font-family: monospace; font-size: 12px; line-height: 1.5; padding: 4px 0;">
|
|
1602
|
+
${blockContent}
|
|
1603
|
+
</div>
|
|
1604
|
+
</details>
|
|
1605
|
+
`;
|
|
1304
1606
|
}
|
|
1305
|
-
|
|
1607
|
+
currentCommitDiffHTML = diffHTML;
|
|
1608
|
+
renderCommitDiffFullView();
|
|
1306
1609
|
} else {
|
|
1307
|
-
|
|
1610
|
+
content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Error loading diff</p>`;
|
|
1308
1611
|
}
|
|
1309
1612
|
} catch (e) {
|
|
1310
|
-
|
|
1613
|
+
content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Network error</p>`;
|
|
1311
1614
|
}
|
|
1312
1615
|
};
|
|
1313
1616
|
|
|
1617
|
+
function renderCommitDiffFullView() {
|
|
1618
|
+
const content = document.getElementById('git-content');
|
|
1619
|
+
let html = `
|
|
1620
|
+
<div style="display:flex; align-items:center; background: var(--card-bg); padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.05); position: sticky; top: 0; z-index: 10;">
|
|
1621
|
+
<button class="icon-btn" onclick="switchGitTab('graph')" style="color: var(--primary); margin-right: 12px; font-weight:600; font-size:14px; display:flex; align-items:center;">
|
|
1622
|
+
<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
|
|
1623
|
+
</button>
|
|
1624
|
+
<div style="flex:1; min-width:0;">
|
|
1625
|
+
<div style="font-weight:600; font-size:15px; font-family: monospace;">Commit: ${currentCommitHash}</div>
|
|
1626
|
+
</div>
|
|
1627
|
+
</div>
|
|
1628
|
+
<div style="padding: 10px;">
|
|
1629
|
+
${currentCommitDiffHTML}
|
|
1630
|
+
</div>`;
|
|
1631
|
+
content.innerHTML = html;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1314
1634
|
async function loadGitGraph() {
|
|
1315
1635
|
|
|
1316
1636
|
if (!activeSessionId) return;
|
|
@@ -1396,8 +1716,13 @@
|
|
|
1396
1716
|
content.innerHTML = '<p style="text-align:center;color:#888;padding:20px;">Loading...</p>';
|
|
1397
1717
|
if (!activeSessionId) return;
|
|
1398
1718
|
try {
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1719
|
+
const [resStatus, resUnstaged, resStaged] = await Promise.all([
|
|
1720
|
+
fetchWithTimeout(`/api/sessions/${activeSessionId}/git-status`),
|
|
1721
|
+
fetchWithTimeout(`/api/sessions/${activeSessionId}/git-diff-numstat?staged=false`).catch(() => ({ok:false})),
|
|
1722
|
+
fetchWithTimeout(`/api/sessions/${activeSessionId}/git-diff-numstat?staged=true`).catch(() => ({ok:false}))
|
|
1723
|
+
]);
|
|
1724
|
+
|
|
1725
|
+
const data = await resStatus.json();
|
|
1401
1726
|
if (!data.success) {
|
|
1402
1727
|
content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Git error: ${data.error}</p>`;
|
|
1403
1728
|
return;
|
|
@@ -1409,92 +1734,327 @@
|
|
|
1409
1734
|
return;
|
|
1410
1735
|
}
|
|
1411
1736
|
|
|
1412
|
-
let
|
|
1737
|
+
let statsMap = {};
|
|
1738
|
+
const parseNumstat = (output) => {
|
|
1739
|
+
if (!output) return;
|
|
1740
|
+
output.split('\n').forEach(line => {
|
|
1741
|
+
const parts = line.split('\t');
|
|
1742
|
+
if (parts.length >= 3) {
|
|
1743
|
+
const added = parseInt(parts[0]) || 0;
|
|
1744
|
+
const removed = parseInt(parts[1]) || 0;
|
|
1745
|
+
const file = parts.slice(2).join('\t');
|
|
1746
|
+
if (!statsMap[file]) statsMap[file] = { added: 0, removed: 0 };
|
|
1747
|
+
statsMap[file].added += added;
|
|
1748
|
+
statsMap[file].removed += removed;
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
if (resUnstaged.ok) {
|
|
1754
|
+
const unstagedData = await resUnstaged.json();
|
|
1755
|
+
if (unstagedData.success) parseNumstat(unstagedData.stdout);
|
|
1756
|
+
}
|
|
1757
|
+
if (resStaged.ok) {
|
|
1758
|
+
const stagedData = await resStaged.json();
|
|
1759
|
+
if (stagedData.success) parseNumstat(stagedData.stdout);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
let html = '<div style="padding:10px;">';
|
|
1413
1763
|
files.forEach((f, idx) => {
|
|
1414
1764
|
const encodedPath = encodePathValue(f.path);
|
|
1415
1765
|
const escapedPath = escapeHtml(f.path);
|
|
1416
|
-
const escapedBaseName = escapeHtml(f.path.split('/').pop() || f.path);
|
|
1417
1766
|
let color = '#fff';
|
|
1418
1767
|
let label = f.status;
|
|
1419
|
-
if (label.includes('M')) { color = '#f59e0b'; }
|
|
1768
|
+
if (label.includes('M')) { color = '#f59e0b'; }
|
|
1420
1769
|
else if (label.includes('A') || label === '??') { color = '#4ade80'; if(label === '??') label = 'U'; }
|
|
1421
1770
|
else if (label.includes('D')) { color = '#f87171'; }
|
|
1422
|
-
const
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1771
|
+
const isUntracked = f.status === '??';
|
|
1772
|
+
const hasStaged = !isUntracked && f.status[0] && f.status[0] !== ' ';
|
|
1773
|
+
const hasUnstaged = !isUntracked && f.status[1] && f.status[1] !== ' ';
|
|
1774
|
+
|
|
1775
|
+
let statHTML = '';
|
|
1776
|
+
if (statsMap[f.path]) {
|
|
1777
|
+
const { added, removed } = statsMap[f.path];
|
|
1778
|
+
if (added > 0 || removed > 0) {
|
|
1779
|
+
statHTML = `<span style="margin-left: 12px; font-family: monospace; font-size: 12px; white-space: nowrap;"><span style="color:#4ade80;">+${added}</span> <span style="color:#f87171; margin-left:6px;">-${removed}</span></span>`;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
html += `<div style="margin-bottom: 8px; border: 1px solid #333; border-radius: 4px; background: #1e1e1e; overflow: hidden;">
|
|
1784
|
+
<div onclick="toggleInlineDiff('${encodedPath}', 'inline-diff-${idx}', ${!!hasStaged}, ${!!hasUnstaged}, ${isUntracked})" style="padding: 6px 10px; cursor: pointer; color: #fff; font-weight: 500; font-size: 13px; outline: none; user-select: none; display: flex; align-items: center;">
|
|
1785
|
+
<div style="flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">📄 ${escapedPath}${statHTML}</div>
|
|
1786
|
+
<span style="color:${color}; font-weight:700; font-size:10px; border:1px solid ${color}; padding:1px 4px; border-radius:3px; opacity:0.8; flex-shrink: 0; margin-left: 8px;">${label}</span>
|
|
1427
1787
|
</div>
|
|
1428
|
-
<
|
|
1788
|
+
<div id="inline-diff-${idx}" style="display: none;" data-loaded="false"></div>
|
|
1429
1789
|
</div>`;
|
|
1430
1790
|
});
|
|
1431
|
-
html += '</div
|
|
1791
|
+
html += '</div>';
|
|
1432
1792
|
content.innerHTML = html;
|
|
1433
1793
|
} catch (e) {
|
|
1434
1794
|
content.innerHTML = `<p style="color:#ff3b30; padding:10px;">${e.message}</p>`;
|
|
1435
1795
|
}
|
|
1436
1796
|
}
|
|
1437
1797
|
|
|
1438
|
-
|
|
1798
|
+
function buildFileDiffUrl(path, staged) {
|
|
1799
|
+
return `/api/sessions/${activeSessionId}/git-diff-file?path=${encodeURIComponent(path)}&staged=${staged ? 'true' : 'false'}`;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
async function loadFileChangeData(path, options = {}) {
|
|
1803
|
+
const hasStaged = !!options.hasStaged;
|
|
1804
|
+
const hasUnstaged = !!options.hasUnstaged;
|
|
1805
|
+
const isUntracked = !!options.isUntracked;
|
|
1806
|
+
const diffRequests = [];
|
|
1807
|
+
|
|
1808
|
+
if (hasStaged) {
|
|
1809
|
+
diffRequests.push({
|
|
1810
|
+
label: 'Staged changes',
|
|
1811
|
+
promise: fetchWithTimeout(buildFileDiffUrl(path, true)).catch(() => ({ok:false}))
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
if (hasUnstaged || (!hasStaged && !isUntracked)) {
|
|
1815
|
+
diffRequests.push({
|
|
1816
|
+
label: 'Unstaged changes',
|
|
1817
|
+
promise: fetchWithTimeout(buildFileDiffUrl(path, false)).catch(() => ({ok:false}))
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
const [diffResponses, fileRes] = await Promise.all([
|
|
1822
|
+
Promise.all(diffRequests.map(item => item.promise)),
|
|
1823
|
+
fetchWithTimeout(`/api/sessions/${activeSessionId}/file?path=${encodeURIComponent(path)}`).catch(() => ({ok:false}))
|
|
1824
|
+
]);
|
|
1825
|
+
|
|
1826
|
+
const diffParts = [];
|
|
1827
|
+
for (let i = 0; i < diffResponses.length; i++) {
|
|
1828
|
+
const res = diffResponses[i];
|
|
1829
|
+
const data = res.ok ? await res.json() : { success: false };
|
|
1830
|
+
if (data.success && data.stdout) {
|
|
1831
|
+
diffParts.push({ label: diffRequests[i].label, stdout: data.stdout });
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const fileData = fileRes.ok ? await fileRes.json() : { success: false };
|
|
1836
|
+
const content = fileData.success ? fileData.content : '';
|
|
1837
|
+
let diff = diffParts.map(part => (
|
|
1838
|
+
diffParts.length > 1 ? `# ${part.label}\n${part.stdout}` : part.stdout
|
|
1839
|
+
)).join('\n');
|
|
1840
|
+
if (!diff && isUntracked && content) diff = 'Untracked file:\n\n' + content;
|
|
1841
|
+
|
|
1842
|
+
return {
|
|
1843
|
+
diff,
|
|
1844
|
+
content,
|
|
1845
|
+
renderRawDiff: diffParts.length > 1
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
window.toggleInlineDiff = async function(encodedPath, containerId, hasStaged = false, hasUnstaged = true, isUntracked = false) {
|
|
1850
|
+
const container = document.getElementById(containerId);
|
|
1851
|
+
if (container.style.display === 'block') {
|
|
1852
|
+
container.style.display = 'none';
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
container.style.display = 'block';
|
|
1856
|
+
if (container.dataset.loaded === 'true') return;
|
|
1857
|
+
|
|
1858
|
+
const path = decodePathValue(encodedPath);
|
|
1859
|
+
container.innerHTML = '<div style="padding: 10px; color: var(--text-dim); text-align: center; font-size: 12px;">Loading...</div>';
|
|
1860
|
+
|
|
1861
|
+
try {
|
|
1862
|
+
const { diff: currentFileDiff, content: currentFileContent } = await loadFileChangeData(path, {
|
|
1863
|
+
hasStaged,
|
|
1864
|
+
hasUnstaged,
|
|
1865
|
+
isUntracked
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
if (!currentFileDiff && !currentFileContent) {
|
|
1869
|
+
container.innerHTML = `<div style="padding: 10px; color: #f87171; font-size: 12px; text-align:center;">No diff available</div>`;
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
let blockContent = '';
|
|
1874
|
+
currentFileDiff.split('\n').forEach(line => {
|
|
1875
|
+
let color = '#ccc', bg = 'transparent', borderLeft = '2px solid transparent';
|
|
1876
|
+
if (line.startsWith('+') && !line.startsWith('+++')) { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; }
|
|
1877
|
+
else if (line.startsWith('-') && !line.startsWith('---')) { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; }
|
|
1878
|
+
else if (line.startsWith('@@')) { color = '#60a5fa'; bg = 'rgba(96, 165, 250, 0.1)'; }
|
|
1879
|
+
|
|
1880
|
+
blockContent += `<div style="color:${color}; background:${bg}; border-left:${borderLeft}; padding:2px 8px; white-space:pre-wrap; word-break:break-all;">${escapeHtml(line) || ' '}</div>`;
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
container.dataset.loaded = 'true';
|
|
1884
|
+
container.innerHTML = `
|
|
1885
|
+
<div style="background: #0d0d0d; overflow-x: auto; font-family: monospace; font-size: 12px; line-height: 1.5; padding: 4px 0; border-top: 1px solid #333; max-height: 400px;">
|
|
1886
|
+
${blockContent}
|
|
1887
|
+
</div>
|
|
1888
|
+
<div style="padding: 8px; background: #1a1a1a; border-top: 1px solid #333; text-align: center;">
|
|
1889
|
+
<button onclick="showFileDetails(decodePathValue('${encodedPath}'), true, ${!!hasStaged}, ${!!hasUnstaged}, ${!!isUntracked})" style="background: var(--primary); border: none; color: #fff; padding: 6px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;">
|
|
1890
|
+
<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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
|
|
1891
|
+
View Full File
|
|
1892
|
+
</button>
|
|
1893
|
+
</div>
|
|
1894
|
+
`;
|
|
1895
|
+
} catch (e) {
|
|
1896
|
+
container.innerHTML = `<div style="padding: 10px; color: #f87171; font-size: 12px; text-align:center;">Error: ${e.message}</div>`;
|
|
1897
|
+
}
|
|
1898
|
+
};
|
|
1899
|
+
|
|
1900
|
+
let currentFilePath = '', currentFileDiff = '', currentFileContent = '', currentFileMode = 'diff', currentFileWrap = false, currentFontSize = 12, currentFileRenderRawDiff = false;
|
|
1439
1901
|
|
|
1440
|
-
|
|
1902
|
+
function changeFontSize(delta) {
|
|
1903
|
+
if (delta === 0) currentFontSize = 12;
|
|
1904
|
+
else currentFontSize = Math.max(8, Math.min(32, currentFontSize + delta));
|
|
1905
|
+
renderFileDetails();
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
async function showFileDetails(path, isFromChanges = true, hasStaged = false, hasUnstaged = true, isUntracked = false) {
|
|
1441
1909
|
currentFilePath = path;
|
|
1442
1910
|
currentFileMode = isFromChanges ? 'diff' : 'file';
|
|
1911
|
+
currentFileRenderRawDiff = false;
|
|
1443
1912
|
const content = document.getElementById('git-content');
|
|
1444
1913
|
content.innerHTML = '<p style="text-align:center;color:#888;padding:20px;">Loading details...</p>';
|
|
1445
1914
|
try {
|
|
1446
|
-
const
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
if (!currentFileDiff && isFromChanges && currentFileContent) currentFileDiff = 'Untracked file:\n\n' + currentFileContent;
|
|
1915
|
+
const detailData = await loadFileChangeData(path, {
|
|
1916
|
+
hasStaged: isFromChanges ? hasStaged : false,
|
|
1917
|
+
hasUnstaged: isFromChanges ? hasUnstaged : true,
|
|
1918
|
+
isUntracked: isFromChanges ? isUntracked : false
|
|
1919
|
+
});
|
|
1920
|
+
currentFileDiff = detailData.diff;
|
|
1921
|
+
currentFileContent = detailData.content;
|
|
1922
|
+
currentFileRenderRawDiff = detailData.renderRawDiff;
|
|
1455
1923
|
if (!currentFileDiff && !currentFileContent) {
|
|
1456
1924
|
content.innerHTML = `<p style="color:#ff3b30; padding:10px;">Failed to load details.</p>`;
|
|
1457
1925
|
return;
|
|
1458
1926
|
}
|
|
1459
|
-
if (!
|
|
1927
|
+
if (!currentFileDiff && !isFromChanges) currentFileMode = 'file';
|
|
1460
1928
|
renderFileDetails();
|
|
1461
1929
|
} catch (e) { content.innerHTML = `<p style="color:#ff3b30; padding:10px;">${e.message}</p>`; }
|
|
1462
1930
|
}
|
|
1463
1931
|
|
|
1464
|
-
function
|
|
1932
|
+
function toggleFileMode() { currentFileMode = currentFileMode === 'diff' ? 'file' : 'diff'; renderFileDetails(); }
|
|
1933
|
+
function toggleFileWrap() { currentFileWrap = !currentFileWrap; renderFileDetails(); }
|
|
1465
1934
|
|
|
1466
1935
|
function renderFileDetails() {
|
|
1467
1936
|
const content = document.getElementById('git-content');
|
|
1468
1937
|
let html = `
|
|
1469
|
-
<div style="display:flex; align-items:center; background: var(--card-bg); padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.05);">
|
|
1470
|
-
<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;">
|
|
1938
|
+
<div style="display:flex; align-items:center; background: var(--card-bg); padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.05); position: sticky; top: 0; z-index: 10;">
|
|
1939
|
+
<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; flex-shrink: 0;">
|
|
1471
1940
|
<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
|
|
1472
1941
|
</button>
|
|
1473
|
-
<div style="flex:1; min-width:0;">
|
|
1474
|
-
<div style="font-weight:600; font-size:15px;">${escapeHtml(currentFilePath.split('/').pop() || currentFilePath)}</div>
|
|
1475
|
-
</div
|
|
1476
|
-
|
|
1942
|
+
<div style="flex:1; min-width:0; margin-right: 12px;">
|
|
1943
|
+
<div style="font-weight:600; font-size:15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(currentFilePath)}">${escapeHtml(currentFilePath.split('/').pop() || currentFilePath)}</div>
|
|
1944
|
+
</div>`;
|
|
1945
|
+
|
|
1477
1946
|
if (currentFileDiff && currentFileContent) {
|
|
1478
|
-
html +=
|
|
1479
|
-
|
|
1480
|
-
<
|
|
1947
|
+
html += `
|
|
1948
|
+
<div style="display: flex; gap: 6px; flex-shrink: 0; align-items: center;">
|
|
1949
|
+
<div style="display: flex; gap: 2px; margin-right: 8px; background: rgba(0,0,0,0.3); padding: 2px; border-radius: 4px;">
|
|
1950
|
+
<button onclick="changeFontSize(-1)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Zoom Out" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">A-</button>
|
|
1951
|
+
<button onclick="changeFontSize(0)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Reset Size" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">${currentFontSize}</button>
|
|
1952
|
+
<button onclick="changeFontSize(1)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Zoom In" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">A+</button>
|
|
1953
|
+
</div>
|
|
1954
|
+
<button onclick="toggleFileMode()" style="background: ${currentFileMode === 'diff' ? 'var(--primary)' : 'rgba(255,255,255,0.1)'}; border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; outline: none;">
|
|
1955
|
+
Diff
|
|
1956
|
+
</button>
|
|
1957
|
+
<button onclick="toggleFileWrap()" style="background: ${currentFileWrap ? 'var(--primary)' : 'rgba(255,255,255,0.1)'}; border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; outline: none;">
|
|
1958
|
+
Wrap
|
|
1959
|
+
</button>
|
|
1960
|
+
</div>`;
|
|
1961
|
+
} else {
|
|
1962
|
+
html += `
|
|
1963
|
+
<div style="display: flex; gap: 6px; flex-shrink: 0; align-items: center;">
|
|
1964
|
+
<div style="display: flex; gap: 2px; margin-right: 8px; background: rgba(0,0,0,0.3); padding: 2px; border-radius: 4px;">
|
|
1965
|
+
<button onclick="changeFontSize(-1)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Zoom Out" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">A-</button>
|
|
1966
|
+
<button onclick="changeFontSize(0)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Reset Size" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">${currentFontSize}</button>
|
|
1967
|
+
<button onclick="changeFontSize(1)" style="background: transparent; border: none; color: #aaa; padding: 4px 8px; font-size: 12px; cursor: pointer; outline: none; border-radius: 3px;" title="Zoom In" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.color='#fff'" onmouseout="this.style.background='transparent';this.style.color='#aaa'">A+</button>
|
|
1968
|
+
</div>
|
|
1969
|
+
<button onclick="toggleFileWrap()" style="background: ${currentFileWrap ? 'var(--primary)' : 'rgba(255,255,255,0.1)'}; border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; outline: none;">
|
|
1970
|
+
Wrap
|
|
1971
|
+
</button>
|
|
1481
1972
|
</div>`;
|
|
1482
1973
|
}
|
|
1974
|
+
|
|
1975
|
+
html += `</div>`;
|
|
1976
|
+
|
|
1977
|
+
const wrapStyle = currentFileWrap ? 'white-space: pre-wrap; word-break: break-all;' : 'white-space: pre;';
|
|
1978
|
+
const innerWrapStyle = currentFileWrap ? '' : 'min-width: max-content;';
|
|
1483
1979
|
html += `<div style="padding: 10px;">`;
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1980
|
+
html += `<div style="background:#0d0d0d; border-radius:8px; overflow-x:auto; font-family:monospace; font-size:${currentFontSize}px; line-height:1.5; padding: 12px 0; margin: 0; -webkit-text-size-adjust: 100%; text-size-adjust: 100%;">`;
|
|
1981
|
+
html += `<div style="${innerWrapStyle}">`;
|
|
1982
|
+
|
|
1983
|
+
let mergedLines = [];
|
|
1984
|
+
const lines = currentFileContent ? currentFileContent.replace(/\r\n/g, '\n').split('\n') : [];
|
|
1985
|
+
|
|
1986
|
+
if (currentFileMode === 'diff' && currentFileDiff && !currentFileDiff.startsWith('Untracked file:')) {
|
|
1987
|
+
let diffHunks = [];
|
|
1988
|
+
let currentHunk = null;
|
|
1989
|
+
const diffLines = currentFileDiff.replace(/\r\n/g, '\n').split('\n');
|
|
1990
|
+
for (const dl of diffLines) {
|
|
1991
|
+
if (dl.startsWith('---') || dl.startsWith('+++')) continue;
|
|
1992
|
+
let match = dl.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
1993
|
+
if (match) {
|
|
1994
|
+
currentHunk = {
|
|
1995
|
+
newStart: parseInt(match[2], 10),
|
|
1996
|
+
lines: []
|
|
1997
|
+
};
|
|
1998
|
+
diffHunks.push(currentHunk);
|
|
1999
|
+
} else if (currentHunk) {
|
|
2000
|
+
currentHunk.lines.push(dl);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
if (currentFileRenderRawDiff || diffHunks.length === 0) {
|
|
2005
|
+
currentFileDiff.replace(/\r\n/g, '\n').split('\n').forEach(line => {
|
|
2006
|
+
mergedLines.push({ type: 'raw', text: line });
|
|
2007
|
+
});
|
|
2008
|
+
} else {
|
|
2009
|
+
let contentLineIdx = 1;
|
|
2010
|
+
for (let hunk of diffHunks) {
|
|
2011
|
+
while (contentLineIdx < hunk.newStart && contentLineIdx <= lines.length) {
|
|
2012
|
+
mergedLines.push({ type: 'normal', text: lines[contentLineIdx - 1], lineNum: contentLineIdx });
|
|
2013
|
+
contentLineIdx++;
|
|
2014
|
+
}
|
|
2015
|
+
for (let dl of hunk.lines) {
|
|
2016
|
+
if (dl.startsWith('-')) {
|
|
2017
|
+
mergedLines.push({ type: 'deleted', text: dl.substring(1) });
|
|
2018
|
+
} else if (dl.startsWith('+')) {
|
|
2019
|
+
mergedLines.push({ type: 'added', text: dl.substring(1), lineNum: contentLineIdx });
|
|
2020
|
+
contentLineIdx++;
|
|
2021
|
+
} else if (dl.startsWith(' ')) {
|
|
2022
|
+
mergedLines.push({ type: 'normal', text: dl.substring(1), lineNum: contentLineIdx });
|
|
2023
|
+
contentLineIdx++;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
while (contentLineIdx <= lines.length) {
|
|
2028
|
+
mergedLines.push({ type: 'normal', text: lines[contentLineIdx - 1], lineNum: contentLineIdx });
|
|
2029
|
+
contentLineIdx++;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
1494
2032
|
} else {
|
|
1495
|
-
|
|
2033
|
+
lines.forEach((line, idx) => {
|
|
2034
|
+
mergedLines.push({ type: 'normal', text: line, lineNum: idx + 1 });
|
|
2035
|
+
});
|
|
1496
2036
|
}
|
|
1497
|
-
|
|
2037
|
+
|
|
2038
|
+
mergedLines.forEach(item => {
|
|
2039
|
+
let color = '#ccc', bg = 'transparent', borderLeft = '2px solid transparent';
|
|
2040
|
+
let prefix = ' ';
|
|
2041
|
+
if (item.type === 'added') { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; prefix = '+'; }
|
|
2042
|
+
else if (item.type === 'deleted') { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; prefix = '-'; }
|
|
2043
|
+
else if (item.type === 'raw') {
|
|
2044
|
+
if (item.text.startsWith('+') && !item.text.startsWith('+++')) { color = '#4ade80'; bg = 'rgba(74, 222, 128, 0.1)'; borderLeft = '2px solid #4ade80'; }
|
|
2045
|
+
else if (item.text.startsWith('-') && !item.text.startsWith('---')) { color = '#f87171'; bg = 'rgba(248, 113, 113, 0.1)'; borderLeft = '2px solid #f87171'; }
|
|
2046
|
+
else if (item.text.startsWith('@@')) { color = '#60a5fa'; bg = 'rgba(96, 165, 250, 0.1)'; }
|
|
2047
|
+
prefix = '';
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const renderedText = item.type === 'raw' ? item.text : `${prefix} ${item.text}`;
|
|
2051
|
+
html += `<div style="color:${color}; background:${bg}; border-left:${borderLeft}; padding: 0 12px 0 8px; display:flex;">`;
|
|
2052
|
+
html += `<div style="color:#555; margin-right:16px; user-select:none; flex-shrink:0; width: 36px; text-align:right;">${item.lineNum || ''}</div>`;
|
|
2053
|
+
html += `<div style="flex:1; min-width:0; ${wrapStyle}">${escapeHtml(renderedText) || ' '}</div>`;
|
|
2054
|
+
html += `</div>`;
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
html += `</div></div></div>`;
|
|
1498
2058
|
content.innerHTML = html;
|
|
1499
2059
|
}
|
|
1500
2060
|
|