Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

editor.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

editor.tsxBlame842 lines · 1 contributor
0074234Claude1/**
2 * Web file editor — create and edit files directly in the browser.
e26af57Claude3 *
4 * 2026 polish: this surface uses a scoped `.editor-*` class system that
5 * mirrors `admin-integrations.tsx` and `collaborators.tsx` — gradient
6 * hairline, mono breadcrumb pill, commit-message input with focus ring,
7 * primary commit + ghost cancel buttons, and an AI "Suggest" button that
8 * sits inline. The git operations themselves are unchanged.
0074234Claude9 */
10
11import { Hono } from "hono";
12import { Layout } from "../views/layout";
e26af57Claude13import { RepoHeader, RepoNav } from "../views/components";
14import { EmptyState } from "../views/ui";
0074234Claude15import {
16 getBlob,
17 getRepoPath,
18} from "../git/repository";
c81b57cClaude19import { generateCommitMessage } from "../lib/ai-generators";
20import { isAiAvailable } from "../lib/ai-client";
0074234Claude21import { softAuth, requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude23import { requireRepoAccess } from "../middleware/repo-access";
0074234Claude24
25const editor = new Hono<AuthEnv>();
26
27editor.use("*", softAuth);
28
c81b57cClaude29/**
30 * Inline JS for the editor's "Suggest with AI" commit-message button.
31 * Picks up the textarea content + form-pinned ref/filePath, POSTs JSON
32 * to the suggest endpoint, fills the message Input on success.
33 *
34 * Built as a string so we don't need a bundler. JSON-escapes against
35 * </script> breakout. Defensive DOM lookups (silent no-op on absence).
36 */
37function AI_COMMIT_MSG_SCRIPT(args: {
38 endpoint: string;
39 ref: string;
40 filePath: string;
41}): string {
42 const safe = (v: string) =>
43 JSON.stringify(v)
44 .split("<").join("\\u003C")
45 .split(">").join("\\u003E")
46 .split("&").join("\\u0026");
47 const url = safe(args.endpoint);
48 const ref = safe(args.ref);
49 const filePath = safe(args.filePath);
50 return (
51 "(function(){try{" +
52 "var btn=document.getElementById('ai-commit-msg-btn');" +
53 "var status=document.getElementById('ai-commit-msg-status');" +
54 "var input=document.getElementById('commit-message-input');" +
55 "var ta=document.querySelector('textarea[name=\"content\"]');" +
56 "if(!btn||!input||!ta)return;" +
57 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
58 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
59 "var fd='ref='+encodeURIComponent(" + ref + ")+'&filePath='+encodeURIComponent(" + filePath + ")+'&content='+encodeURIComponent(ta.value||'');" +
60 "fetch(" + url + ",{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:fd,credentials:'same-origin'})" +
61 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
62 ".then(function(j){btn.disabled=false;" +
63 "if(j&&j.ok&&typeof j.message==='string'){" +
64 "if(input.value&&input.value.trim().length>0){if(!confirm('Replace existing message?')){if(status)status.textContent='Cancelled.';return;}}" +
65 "input.value=j.message;if(status)status.textContent='Filled from AI. Edit before committing.';" +
66 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
67 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
68 "});" +
69 "}catch(e){}})();"
70 );
71}
72
e26af57Claude73// ─── Scoped CSS (.editor-*) ─────────────────────────────────────────────────
74// Every selector is prefixed `.editor-*` so this surface can't bleed into
75// the repo header / nav above. Tokens reused from layout (--bg-elevated,
76// --border, --text-strong, --accent, --space-*, --font-*).
77
78const editorStyles = `
79 .editor-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
80
81 /* ─── Header strip (sits below RepoHeader + RepoNav) ─── */
82 .editor-head { margin-bottom: var(--space-5); }
83 .editor-eyebrow {
84 display: inline-flex;
85 align-items: center;
86 gap: 8px;
87 text-transform: uppercase;
88 font-family: var(--font-mono);
89 font-size: 11px;
90 letter-spacing: 0.16em;
91 color: var(--text-muted);
92 font-weight: 600;
93 margin-bottom: 10px;
94 }
95 .editor-eyebrow-dot {
96 width: 8px; height: 8px;
97 border-radius: 9999px;
98 background: linear-gradient(135deg, #8c6dff, #36c5d6);
99 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
100 }
101 .editor-title {
102 font-family: var(--font-display);
103 font-size: clamp(22px, 3vw, 32px);
104 font-weight: 800;
105 letter-spacing: -0.025em;
106 line-height: 1.15;
107 margin: 0 0 6px;
108 color: var(--text-strong);
109 }
110 .editor-title-grad {
111 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
112 -webkit-background-clip: text;
113 background-clip: text;
114 -webkit-text-fill-color: transparent;
115 color: transparent;
116 }
117 .editor-sub {
118 margin: 0;
119 font-size: 14px;
120 color: var(--text-muted);
121 line-height: 1.5;
122 max-width: 700px;
123 }
124
125 /* ─── Toolbar (path breadcrumb + branch pill) ─── */
126 .editor-toolbar {
127 display: flex;
128 align-items: center;
129 justify-content: space-between;
130 gap: var(--space-3);
131 flex-wrap: wrap;
132 margin-bottom: var(--space-3);
133 padding: 10px 14px;
134 background: var(--bg-elevated);
135 border: 1px solid var(--border);
136 border-radius: 12px;
137 }
138 .editor-path {
139 display: inline-flex;
140 align-items: center;
141 gap: 6px;
142 font-family: var(--font-mono);
143 font-size: 13px;
144 color: var(--text);
145 flex-wrap: wrap;
146 min-width: 0;
147 }
148 .editor-path-sep { color: var(--text-faint); }
149 .editor-path-seg {
150 color: var(--text-muted);
151 }
152 .editor-path-name {
153 color: var(--text-strong);
154 font-weight: 600;
155 }
156 .editor-branch-pill {
157 display: inline-flex;
158 align-items: center;
159 gap: 6px;
160 padding: 4px 10px;
161 border-radius: 999px;
162 font-family: var(--font-mono);
163 font-size: 12px;
164 color: #c4b5fd;
165 background: rgba(140,109,255,0.12);
166 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
167 white-space: nowrap;
168 font-weight: 600;
169 }
170 .editor-branch-pill svg { opacity: 0.9; }
171
172 /* ─── Form card ─── */
173 .editor-card {
174 background: var(--bg-elevated);
175 border: 1px solid var(--border);
176 border-radius: 14px;
177 overflow: hidden;
178 position: relative;
179 }
180 .editor-card::before {
181 content: '';
182 position: absolute;
183 top: 0; left: 0; right: 0;
184 height: 2px;
185 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
186 opacity: 0.55;
187 pointer-events: none;
188 }
189 .editor-body { padding: var(--space-4) var(--space-5); }
190
191 .editor-field { display: block; margin-bottom: var(--space-4); }
192 .editor-label {
193 display: block;
194 font-size: 11.5px;
195 color: var(--text-muted);
196 font-weight: 600;
197 text-transform: uppercase;
198 letter-spacing: 0.06em;
199 margin-bottom: 6px;
200 }
201 .editor-filename-row {
202 display: flex;
203 align-items: center;
204 gap: 6px;
205 font-family: var(--font-mono);
206 }
207 .editor-filename-dir {
208 font-size: 13px;
209 color: var(--text-muted);
210 }
211 .editor-input {
212 width: 100%;
213 box-sizing: border-box;
214 padding: 10px 12px;
215 font: inherit;
216 font-size: 14px;
217 color: var(--text);
218 background: rgba(255,255,255,0.03);
219 border: 1px solid var(--border-strong);
220 border-radius: 10px;
221 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
222 }
223 .editor-input:focus {
224 outline: none;
225 border-color: rgba(140,109,255,0.55);
226 background: rgba(255,255,255,0.05);
227 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
228 }
229 .editor-input-mono { font-family: var(--font-mono); }
230
231 .editor-textarea {
232 width: 100%;
233 box-sizing: border-box;
234 padding: 12px 14px;
235 font: inherit;
236 font-family: var(--font-mono);
237 font-size: 13px;
238 line-height: 1.55;
239 tab-size: 2;
240 color: var(--text);
241 background: var(--bg);
242 border: 1px solid var(--border-strong);
243 border-radius: 10px;
244 resize: vertical;
245 min-height: 280px;
246 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
247 }
248 .editor-textarea:focus {
249 outline: none;
250 border-color: rgba(140,109,255,0.55);
251 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
252 }
253
254 /* ─── Action row ─── */
255 .editor-actions {
256 display: flex;
257 align-items: center;
258 gap: 10px;
259 flex-wrap: wrap;
260 padding: var(--space-3) var(--space-5);
261 border-top: 1px solid var(--border);
262 background: rgba(255,255,255,0.012);
263 }
264 .editor-btn {
265 display: inline-flex;
266 align-items: center;
267 justify-content: center;
268 gap: 6px;
269 padding: 9px 16px;
270 border-radius: 10px;
271 font-size: 13px;
272 font-weight: 600;
273 text-decoration: none;
274 border: 1px solid transparent;
275 cursor: pointer;
276 font: inherit;
277 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
278 line-height: 1;
279 white-space: nowrap;
280 }
281 .editor-btn-primary {
282 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
283 color: #fff;
284 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.5), inset 0 1px 0 rgba(255,255,255,0.16);
285 }
286 .editor-btn-primary:hover {
287 transform: translateY(-1px);
288 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.6), inset 0 1px 0 rgba(255,255,255,0.20);
289 text-decoration: none;
290 color: #fff;
291 }
292 .editor-btn-ghost {
293 background: transparent;
294 color: var(--text);
295 border-color: var(--border-strong);
296 }
297 .editor-btn-ghost:hover {
298 background: rgba(140,109,255,0.06);
299 border-color: rgba(140,109,255,0.45);
300 color: var(--text-strong);
301 text-decoration: none;
302 }
303 .editor-btn-ai {
304 background: transparent;
305 color: #c4b5fd;
306 border-color: rgba(140,109,255,0.35);
307 }
308 .editor-btn-ai:hover {
309 border-color: rgba(140,109,255,0.65);
310 background: rgba(140,109,255,0.08);
311 color: #ddd6fe;
312 text-decoration: none;
313 }
314 .editor-btn-ai:disabled {
315 opacity: 0.55;
316 cursor: progress;
317 }
318 .editor-status {
319 color: var(--text-muted);
320 font-size: 12.5px;
321 margin-left: auto;
322 }
323`;
324
325function IconBranch() {
326 return (
327 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
328 <line x1="6" y1="3" x2="6" y2="15" />
329 <circle cx="18" cy="6" r="3" />
330 <circle cx="6" cy="18" r="3" />
331 <path d="M18 9a9 9 0 0 1-9 9" />
332 </svg>
333 );
334}
335
0074234Claude336// New file form
04f6b7fClaude337editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude338 const { owner, repo } = c.req.param();
339 const user = c.get("user")!;
340 const refAndPath = c.req.param("ref");
341
342 // Parse ref — use first segment
343 const slashIdx = refAndPath.indexOf("/");
344 const ref = slashIdx === -1 ? refAndPath : refAndPath.slice(0, slashIdx);
345 const dirPath = slashIdx === -1 ? "" : refAndPath.slice(slashIdx + 1);
346
347 return c.html(
348 <Layout title={`New file — ${owner}/${repo}`} user={user}>
349 <RepoHeader owner={owner} repo={repo} />
350 <RepoNav owner={owner} repo={repo} active="code" />
e26af57Claude351 <div class="editor-wrap">
352 <header class="editor-head">
353 <div class="editor-eyebrow">
354 <span class="editor-eyebrow-dot" aria-hidden="true" />
355 Editor · New file
356 </div>
357 <h1 class="editor-title">
358 <span class="editor-title-grad">Create a new file.</span>
359 </h1>
360 <p class="editor-sub">
361 Pick a path, paste your content, and write a short commit
362 message. The commit lands directly on{" "}
363 <code style="font-size:12.5px">{ref}</code>.
364 </p>
365 </header>
366
367 <div class="editor-toolbar">
368 <div class="editor-path" title={dirPath ? `${dirPath}/…` : "Repository root"}>
369 <span class="editor-path-seg">{owner}/{repo}</span>
370 {dirPath && (
371 <>
372 <span class="editor-path-sep">/</span>
373 <span class="editor-path-seg">{dirPath}</span>
374 </>
375 )}
376 <span class="editor-path-sep">/</span>
377 <span class="editor-path-name">new file…</span>
378 </div>
379 <span class="editor-branch-pill" title="Target branch">
380 <IconBranch />
381 {ref}
382 </span>
383 </div>
384
385 <form
386 method="post"
387 action={`/${owner}/${repo}/new/${ref}`}
388 class="editor-card"
389 >
390 <div class="editor-body">
391 <input type="hidden" name="dir_path" value={dirPath} />
392 <div class="editor-field">
393 <label class="editor-label" for="editor-filename">File path</label>
394 <div class="editor-filename-row">
395 {dirPath && (
396 <span class="editor-filename-dir">{dirPath}/</span>
397 )}
398 <input
399 class="editor-input editor-input-mono"
400 id="editor-filename"
401 name="filename"
402 required
403 placeholder="filename.ts"
404 autocomplete="off"
405 aria-label="File path"
406 />
407 </div>
408 </div>
409 <div class="editor-field">
410 <label class="editor-label" for="editor-content-new">Content</label>
411 <textarea
412 class="editor-textarea"
413 id="editor-content-new"
414 name="content"
415 rows={20}
416 placeholder="Enter file content…"
417 spellcheck={false}
418 />
419 </div>
420 <div class="editor-field" style="margin-bottom:0">
421 <label class="editor-label" for="commit-message-input">Commit message</label>
422 <input
423 class="editor-input"
424 id="commit-message-input"
425 name="message"
426 placeholder="Create new file"
0074234Claude427 required
e26af57Claude428 aria-label="Commit message"
0074234Claude429 />
e26af57Claude430 </div>
431 </div>
432 <div class="editor-actions">
433 <button type="submit" class="editor-btn editor-btn-primary">
434 Commit new file
435 </button>
436 <a href={`/${owner}/${repo}`} class="editor-btn editor-btn-ghost">
437 Cancel
438 </a>
439 </div>
440 </form>
441 </div>
442 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
0074234Claude443 </Layout>
444 );
445});
446
447// Create file via commit
04f6b7fClaude448editor.post("/:owner/:repo/new/:ref", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude449 const { owner, repo } = c.req.param();
450 const user = c.get("user")!;
451 const ref = c.req.param("ref");
452 const body = await c.req.parseBody();
453 const dirPath = String(body.dir_path || "").trim();
454 const filename = String(body.filename || "").trim();
455 const content = String(body.content || "");
456 const message = String(body.message || `Create ${filename}`).trim();
457
458 if (!filename) return c.redirect(`/${owner}/${repo}`);
459
460 const fullPath = dirPath ? `${dirPath}/${filename}` : filename;
461
462 // Use git hash-object + update-index + write-tree + commit-tree
463 const repoDir = getRepoPath(owner, repo);
464
465 const run = async (cmd: string[], cwd: string, stdin?: string) => {
466 const proc = Bun.spawn(cmd, {
467 cwd,
468 stdout: "pipe",
469 stderr: "pipe",
470 stdin: stdin !== undefined ? "pipe" : undefined,
471 });
472 if (stdin !== undefined && proc.stdin) {
473 proc.stdin.write(new TextEncoder().encode(stdin));
474 proc.stdin.end();
475 }
476 const stdout = await new Response(proc.stdout).text();
477 await proc.exited;
478 return stdout.trim();
479 };
480
481 // Hash the new file content
482 const blobSha = await run(
483 ["git", "hash-object", "-w", "--stdin"],
484 repoDir,
485 content
486 );
487
488 // Read current tree
489 const currentTreeSha = await run(
490 ["git", "rev-parse", `${ref}^{tree}`],
491 repoDir
492 );
493
494 // Read current tree and add new entry
495 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
496 const entries = treeContent
497 .split("\n")
498 .filter(Boolean)
499 .map((line) => line + "\n")
500 .join("");
501 const newEntry = `100644 blob ${blobSha}\t${fullPath}\n`;
502
503 const newTreeSha = await run(
504 ["git", "mktree"],
505 repoDir,
506 entries + newEntry
507 );
508
509 // Get parent commit
510 const parentSha = await run(
511 ["git", "rev-parse", ref],
512 repoDir
513 );
514
515 // Create commit
516 const env = {
517 GIT_AUTHOR_NAME: user.displayName || user.username,
518 GIT_AUTHOR_EMAIL: user.email,
519 GIT_COMMITTER_NAME: user.displayName || user.username,
520 GIT_COMMITTER_EMAIL: user.email,
521 };
522
523 const commitProc = Bun.spawn(
524 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
525 {
526 cwd: repoDir,
527 stdout: "pipe",
528 stderr: "pipe",
529 env: { ...process.env, ...env },
530 }
531 );
532 const commitSha = (await new Response(commitProc.stdout).text()).trim();
533 await commitProc.exited;
534
535 // Update branch ref
536 await run(
537 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
538 repoDir
539 );
540
541 return c.redirect(`/${owner}/${repo}/blob/${ref}/${fullPath}`);
542});
543
544// Edit file form
04f6b7fClaude545editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude546 const { owner, repo } = c.req.param();
547 const user = c.get("user")!;
548 const refAndPath = c.req.param("ref");
549
550 // Parse ref/path
551 const slashIdx = refAndPath.indexOf("/");
552 if (slashIdx === -1) return c.text("Not found", 404);
553 const ref = refAndPath.slice(0, slashIdx);
554 const filePath = refAndPath.slice(slashIdx + 1);
555
556 const blob = await getBlob(owner, repo, ref, filePath);
557 if (!blob || blob.isBinary) {
558 return c.html(
559 <Layout title="Cannot edit" user={user}>
bb0f894Claude560 <EmptyState title={blob?.isBinary ? "Cannot edit binary file" : "File not found"} />
0074234Claude561 </Layout>,
562 404
563 );
564 }
565
e26af57Claude566 const fileName = filePath.split("/").pop() || filePath;
567 const dirParts = filePath.split("/").slice(0, -1);
568
0074234Claude569 return c.html(
570 <Layout title={`Editing ${filePath} — ${owner}/${repo}`} user={user}>
571 <RepoHeader owner={owner} repo={repo} />
572 <RepoNav owner={owner} repo={repo} active="code" />
e26af57Claude573 <div class="editor-wrap">
574 <header class="editor-head">
575 <div class="editor-eyebrow">
576 <span class="editor-eyebrow-dot" aria-hidden="true" />
577 Editor · Edit file
578 </div>
579 <h1 class="editor-title">
580 <span class="editor-title-grad">Edit{" "}</span>
581 <code style="font-family:var(--font-mono);font-size:0.82em;color:var(--text-strong);font-weight:700">{fileName}</code>
582 </h1>
583 <p class="editor-sub">
584 Save your changes as a commit on{" "}
585 <code style="font-size:12.5px">{ref}</code>. Write a clear
586 one-line message so reviewers can follow the history.
587 </p>
588 </header>
589
590 <div class="editor-toolbar">
591 <div class="editor-path" title={filePath}>
592 <a
593 href={`/${owner}/${repo}`}
594 class="editor-path-seg"
595 style="color:var(--text-muted);text-decoration:none"
596 >
597 {owner}/{repo}
598 </a>
599 {dirParts.map((seg, i) => {
600 const subPath = dirParts.slice(0, i + 1).join("/");
601 return (
602 <>
603 <span class="editor-path-sep">/</span>
604 <a
605 href={`/${owner}/${repo}/tree/${ref}/${subPath}`}
606 class="editor-path-seg"
607 style="text-decoration:none"
608 >
609 {seg}
610 </a>
611 </>
612 );
613 })}
614 <span class="editor-path-sep">/</span>
615 <span class="editor-path-name">{fileName}</span>
616 </div>
617 <span class="editor-branch-pill" title="Target branch">
618 <IconBranch />
619 {ref}
620 </span>
621 </div>
622
623 <form
624 method="post"
625 action={`/${owner}/${repo}/edit/${ref}/${filePath}`}
626 class="editor-card"
627 >
628 <div class="editor-body">
629 <div class="editor-field">
630 <label class="editor-label" for="editor-content-edit">Content</label>
631 <textarea
632 class="editor-textarea"
633 id="editor-content-edit"
634 name="content"
635 rows={25}
636 spellcheck={false}
637 >{blob.content}</textarea>
638 </div>
639 <div class="editor-field" style="margin-bottom:0">
640 <label class="editor-label" for="commit-message-input">Commit message</label>
641 <input
642 class="editor-input"
643 id="commit-message-input"
644 name="message"
645 placeholder={`Update ${fileName}`}
646 required
647 aria-label="Commit message"
648 />
649 </div>
650 </div>
651 <div class="editor-actions">
652 <button type="submit" class="editor-btn editor-btn-primary">
0074234Claude653 Commit changes
e26af57Claude654 </button>
c81b57cClaude655 <button
656 type="button"
657 id="ai-commit-msg-btn"
e26af57Claude658 class="editor-btn editor-btn-ai"
c81b57cClaude659 title="Generate a one-line commit message using Claude based on the diff"
660 >
e26af57Claude661 {"✨"} Suggest with AI
c81b57cClaude662 </button>
e26af57Claude663 <a
664 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
665 class="editor-btn editor-btn-ghost"
666 >
0074234Claude667 Cancel
e26af57Claude668 </a>
669 <span id="ai-commit-msg-status" class="editor-status" />
670 </div>
c81b57cClaude671 <script
672 dangerouslySetInnerHTML={{
673 __html: AI_COMMIT_MSG_SCRIPT({
674 endpoint: `/${owner}/${repo}/ai/commit-message`,
675 ref,
676 filePath,
677 }),
678 }}
679 />
e26af57Claude680 </form>
681 </div>
682 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
0074234Claude683 </Layout>
684 );
685});
686
c81b57cClaude687// AI-suggested commit message — JSON endpoint driven by the editor button.
688// Reads the on-disk blob at (ref, filePath), diffs against the submitted
689// new content, and asks generateCommitMessage() for a one-liner. Returns
690// {ok:true, message} on success, {ok:false, error} otherwise. Always 200.
691editor.post(
692 "/:owner/:repo/ai/commit-message",
693 requireAuth,
694 requireRepoAccess("write"),
695 async (c) => {
696 const { owner, repo } = c.req.param();
697 if (!isAiAvailable()) {
698 return c.json({
699 ok: false,
700 error: "AI is not available — set ANTHROPIC_API_KEY.",
701 });
702 }
703 const body = await c.req.parseBody();
704 const ref = String(body.ref || "").trim();
705 const filePath = String(body.filePath || "").trim();
706 const newContent = String(body.content || "");
707 if (!ref || !filePath) {
708 return c.json({ ok: false, error: "ref + filePath required" });
709 }
710
711 let oldContent = "";
712 try {
713 const blob = await getBlob(owner, repo, ref, filePath);
714 oldContent = blob?.content || "";
715 } catch {
716 oldContent = "";
717 }
718
719 if (oldContent === newContent) {
720 return c.json({
721 ok: false,
722 error: "No changes to summarise.",
723 });
724 }
725
726 // Build a minimal unified-diff-ish summary the AI helper can consume.
727 // generateCommitMessage was written for git diff text; we feed a
728 // header + truncated old/new sample so it has shape to summarise.
729 const truncate = (s: string) => (s.length > 4000 ? s.slice(0, 4000) + "\n…(truncated)" : s);
730 const diff =
731 `--- a/${filePath}\n+++ b/${filePath}\n` +
732 "## Old:\n" +
733 truncate(oldContent) +
734 "\n\n## New:\n" +
735 truncate(newContent);
736
737 let message = "";
738 try {
739 message = await generateCommitMessage(diff);
740 } catch (err) {
741 const msg = err instanceof Error ? err.message : "AI request failed.";
742 return c.json({ ok: false, error: msg });
743 }
744 if (!message.trim()) {
745 return c.json({
746 ok: false,
747 error: "AI returned an empty draft.",
748 });
749 }
750 // Cap to one line + 100 chars (commit-message convention).
751 const oneLine = message.split("\n")[0]!.trim();
752 const capped = oneLine.length > 100 ? oneLine.slice(0, 97) + "..." : oneLine;
753 return c.json({ ok: true, message: capped });
754 }
755);
756
0074234Claude757// Save edited file
04f6b7fClaude758editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude759 const { owner, repo } = c.req.param();
760 const user = c.get("user")!;
761 const refAndPath = c.req.param("ref");
762
763 const slashIdx = refAndPath.indexOf("/");
764 if (slashIdx === -1) return c.redirect(`/${owner}/${repo}`);
765 const ref = refAndPath.slice(0, slashIdx);
766 const filePath = refAndPath.slice(slashIdx + 1);
767
768 const body = await c.req.parseBody();
769 const content = String(body.content || "");
770 const message = String(
771 body.message || `Update ${filePath.split("/").pop()}`
772 ).trim();
773
774 const repoDir = getRepoPath(owner, repo);
775
776 const run = async (cmd: string[], cwd: string, stdin?: string) => {
777 const proc = Bun.spawn(cmd, {
778 cwd,
779 stdout: "pipe",
780 stderr: "pipe",
781 stdin: stdin !== undefined ? "pipe" : undefined,
782 });
783 if (stdin !== undefined && proc.stdin) {
784 proc.stdin.write(new TextEncoder().encode(stdin));
785 proc.stdin.end();
786 }
787 const stdout = await new Response(proc.stdout).text();
788 await proc.exited;
789 return stdout.trim();
790 };
791
792 // Hash new content
793 const blobSha = await run(
794 ["git", "hash-object", "-w", "--stdin"],
795 repoDir,
796 content
797 );
798
799 // Read current tree, replace the file
800 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
801 const lines = treeContent.split("\n").filter(Boolean);
802 const updated = lines
803 .map((line) => {
804 const parts = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
805 if (parts && parts[4] === filePath) {
806 return `${parts[1]} blob ${blobSha}\t${parts[4]}`;
807 }
808 return line;
809 })
810 .join("\n") + "\n";
811
812 const newTreeSha = await run(["git", "mktree"], repoDir, updated);
813 const parentSha = await run(["git", "rev-parse", ref], repoDir);
814
815 const env = {
816 GIT_AUTHOR_NAME: user.displayName || user.username,
817 GIT_AUTHOR_EMAIL: user.email,
818 GIT_COMMITTER_NAME: user.displayName || user.username,
819 GIT_COMMITTER_EMAIL: user.email,
820 };
821
822 const commitProc = Bun.spawn(
823 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
824 {
825 cwd: repoDir,
826 stdout: "pipe",
827 stderr: "pipe",
828 env: { ...process.env, ...env },
829 }
830 );
831 const commitSha = (await new Response(commitProc.stdout).text()).trim();
832 await commitProc.exited;
833
834 await run(
835 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
836 repoDir
837 );
838
839 return c.redirect(`/${owner}/${repo}/blob/${ref}/${filePath}`);
840});
841
842export default editor;