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.tsxBlame474 lines · 1 contributor
0074234Claude1/**
2 * Web file editor — create and edit files directly in the browser.
3 */
4
5import { Hono } from "hono";
6import { Layout } from "../views/layout";
7import { RepoHeader, RepoNav, Breadcrumb } from "../views/components";
bb0f894Claude8import {
9 Container,
10 Flex,
11 Form,
12 FormGroup,
13 Input,
14 TextArea,
15 Button,
16 LinkButton,
17 EmptyState,
18 Text,
19} from "../views/ui";
0074234Claude20import {
21 getBlob,
22 getDefaultBranch,
23 getRepoPath,
24 repoExists,
25} from "../git/repository";
c81b57cClaude26import { generateCommitMessage } from "../lib/ai-generators";
27import { isAiAvailable } from "../lib/ai-client";
0074234Claude28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude30import { requireRepoAccess } from "../middleware/repo-access";
0074234Claude31import { join } from "path";
32
33const editor = new Hono<AuthEnv>();
34
35editor.use("*", softAuth);
36
c81b57cClaude37/**
38 * Inline JS for the editor's "Suggest with AI" commit-message button.
39 * Picks up the textarea content + form-pinned ref/filePath, POSTs JSON
40 * to the suggest endpoint, fills the message Input on success.
41 *
42 * Built as a string so we don't need a bundler. JSON-escapes against
43 * </script> breakout. Defensive DOM lookups (silent no-op on absence).
44 */
45function AI_COMMIT_MSG_SCRIPT(args: {
46 endpoint: string;
47 ref: string;
48 filePath: string;
49}): string {
50 const safe = (v: string) =>
51 JSON.stringify(v)
52 .split("<").join("\\u003C")
53 .split(">").join("\\u003E")
54 .split("&").join("\\u0026");
55 const url = safe(args.endpoint);
56 const ref = safe(args.ref);
57 const filePath = safe(args.filePath);
58 return (
59 "(function(){try{" +
60 "var btn=document.getElementById('ai-commit-msg-btn');" +
61 "var status=document.getElementById('ai-commit-msg-status');" +
62 "var input=document.getElementById('commit-message-input');" +
63 "var ta=document.querySelector('textarea[name=\"content\"]');" +
64 "if(!btn||!input||!ta)return;" +
65 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
66 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
67 "var fd='ref='+encodeURIComponent(" + ref + ")+'&filePath='+encodeURIComponent(" + filePath + ")+'&content='+encodeURIComponent(ta.value||'');" +
68 "fetch(" + url + ",{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:fd,credentials:'same-origin'})" +
69 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
70 ".then(function(j){btn.disabled=false;" +
71 "if(j&&j.ok&&typeof j.message==='string'){" +
72 "if(input.value&&input.value.trim().length>0){if(!confirm('Replace existing message?')){if(status)status.textContent='Cancelled.';return;}}" +
73 "input.value=j.message;if(status)status.textContent='Filled from AI. Edit before committing.';" +
74 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
75 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
76 "});" +
77 "}catch(e){}})();"
78 );
79}
80
0074234Claude81// New file form
04f6b7fClaude82editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude83 const { owner, repo } = c.req.param();
84 const user = c.get("user")!;
85 const refAndPath = c.req.param("ref");
86
87 // Parse ref — use first segment
88 const slashIdx = refAndPath.indexOf("/");
89 const ref = slashIdx === -1 ? refAndPath : refAndPath.slice(0, slashIdx);
90 const dirPath = slashIdx === -1 ? "" : refAndPath.slice(slashIdx + 1);
91
92 return c.html(
93 <Layout title={`New file — ${owner}/${repo}`} user={user}>
94 <RepoHeader owner={owner} repo={repo} />
95 <RepoNav owner={owner} repo={repo} active="code" />
bb0f894Claude96 <Container maxWidth={900}>
0074234Claude97 <h2 style="margin-bottom: 16px">Create new file</h2>
0316dbbClaude98 <Form method="post" action={`/${owner}/${repo}/new/${ref}`}>
0074234Claude99 <input type="hidden" name="dir_path" value={dirPath} />
bb0f894Claude100 <FormGroup label="File path">
101 <Flex align="center" gap={4}>
0074234Claude102 {dirPath && (
bb0f894Claude103 <Text muted size={14}>
0074234Claude104 {dirPath}/
bb0f894Claude105 </Text>
0074234Claude106 )}
bb0f894Claude107 <Input
0074234Claude108 name="filename"
109 required
110 placeholder="filename.ts"
111 style="flex: 1"
112 autocomplete="off"
113 />
bb0f894Claude114 </Flex>
115 </FormGroup>
116 <FormGroup label="Content">
117 <TextArea
0074234Claude118 name="content"
119 rows={20}
120 placeholder="Enter file content..."
bb0f894Claude121 mono
122 style="line-height: 1.5; tab-size: 2"
0074234Claude123 />
bb0f894Claude124 </FormGroup>
125 <FormGroup label="Commit message">
126 <Input
0074234Claude127 name="message"
128 placeholder="Create new file"
129 required
130 />
bb0f894Claude131 </FormGroup>
132 <Button type="submit" variant="primary">
0074234Claude133 Commit new file
bb0f894Claude134 </Button>
135 </Form>
136 </Container>
0074234Claude137 </Layout>
138 );
139});
140
141// Create file via commit
04f6b7fClaude142editor.post("/:owner/:repo/new/:ref", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude143 const { owner, repo } = c.req.param();
144 const user = c.get("user")!;
145 const ref = c.req.param("ref");
146 const body = await c.req.parseBody();
147 const dirPath = String(body.dir_path || "").trim();
148 const filename = String(body.filename || "").trim();
149 const content = String(body.content || "");
150 const message = String(body.message || `Create ${filename}`).trim();
151
152 if (!filename) return c.redirect(`/${owner}/${repo}`);
153
154 const fullPath = dirPath ? `${dirPath}/${filename}` : filename;
155
156 // Use git hash-object + update-index + write-tree + commit-tree
157 const repoDir = getRepoPath(owner, repo);
158
159 const run = async (cmd: string[], cwd: string, stdin?: string) => {
160 const proc = Bun.spawn(cmd, {
161 cwd,
162 stdout: "pipe",
163 stderr: "pipe",
164 stdin: stdin !== undefined ? "pipe" : undefined,
165 });
166 if (stdin !== undefined && proc.stdin) {
167 proc.stdin.write(new TextEncoder().encode(stdin));
168 proc.stdin.end();
169 }
170 const stdout = await new Response(proc.stdout).text();
171 await proc.exited;
172 return stdout.trim();
173 };
174
175 // Hash the new file content
176 const blobSha = await run(
177 ["git", "hash-object", "-w", "--stdin"],
178 repoDir,
179 content
180 );
181
182 // Read current tree
183 const currentTreeSha = await run(
184 ["git", "rev-parse", `${ref}^{tree}`],
185 repoDir
186 );
187
188 // Read current tree and add new entry
189 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
190 const entries = treeContent
191 .split("\n")
192 .filter(Boolean)
193 .map((line) => line + "\n")
194 .join("");
195 const newEntry = `100644 blob ${blobSha}\t${fullPath}\n`;
196
197 const newTreeSha = await run(
198 ["git", "mktree"],
199 repoDir,
200 entries + newEntry
201 );
202
203 // Get parent commit
204 const parentSha = await run(
205 ["git", "rev-parse", ref],
206 repoDir
207 );
208
209 // Create commit
210 const env = {
211 GIT_AUTHOR_NAME: user.displayName || user.username,
212 GIT_AUTHOR_EMAIL: user.email,
213 GIT_COMMITTER_NAME: user.displayName || user.username,
214 GIT_COMMITTER_EMAIL: user.email,
215 };
216
217 const commitProc = Bun.spawn(
218 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
219 {
220 cwd: repoDir,
221 stdout: "pipe",
222 stderr: "pipe",
223 env: { ...process.env, ...env },
224 }
225 );
226 const commitSha = (await new Response(commitProc.stdout).text()).trim();
227 await commitProc.exited;
228
229 // Update branch ref
230 await run(
231 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
232 repoDir
233 );
234
235 return c.redirect(`/${owner}/${repo}/blob/${ref}/${fullPath}`);
236});
237
238// Edit file form
04f6b7fClaude239editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude240 const { owner, repo } = c.req.param();
241 const user = c.get("user")!;
242 const refAndPath = c.req.param("ref");
243
244 // Parse ref/path
245 const slashIdx = refAndPath.indexOf("/");
246 if (slashIdx === -1) return c.text("Not found", 404);
247 const ref = refAndPath.slice(0, slashIdx);
248 const filePath = refAndPath.slice(slashIdx + 1);
249
250 const blob = await getBlob(owner, repo, ref, filePath);
251 if (!blob || blob.isBinary) {
252 return c.html(
253 <Layout title="Cannot edit" user={user}>
bb0f894Claude254 <EmptyState title={blob?.isBinary ? "Cannot edit binary file" : "File not found"} />
0074234Claude255 </Layout>,
256 404
257 );
258 }
259
260 return c.html(
261 <Layout title={`Editing ${filePath} — ${owner}/${repo}`} user={user}>
262 <RepoHeader owner={owner} repo={repo} />
263 <RepoNav owner={owner} repo={repo} active="code" />
264 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
0316dbbClaude265 <Container maxWidth={900}>
266 <Form method="post" action={`/${owner}/${repo}/edit/${ref}/${filePath}`}>
267 <FormGroup label="Content">
268 <TextArea
0074234Claude269 name="content"
270 rows={25}
bb0f894Claude271 value={blob.content}
272 mono
273 style="line-height: 1.5; tab-size: 2; width: 100%"
274 />
275 </FormGroup>
276 <FormGroup label="Commit message">
277 <Input
c81b57cClaude278 id="commit-message-input"
0074234Claude279 name="message"
280 placeholder={`Update ${filePath.split("/").pop()}`}
281 required
282 />
bb0f894Claude283 </FormGroup>
c81b57cClaude284 <Flex gap={8} align="center">
bb0f894Claude285 <Button type="submit" variant="primary">
0074234Claude286 Commit changes
bb0f894Claude287 </Button>
c81b57cClaude288 <button
289 type="button"
290 id="ai-commit-msg-btn"
291 class="btn"
292 title="Generate a one-line commit message using Claude based on the diff"
293 >
294 Suggest with AI
295 </button>
296 <span
297 id="ai-commit-msg-status"
298 style="color:var(--text-muted);font-size:13px"
299 />
bb0f894Claude300 <LinkButton href={`/${owner}/${repo}/blob/${ref}/${filePath}`}>
0074234Claude301 Cancel
bb0f894Claude302 </LinkButton>
303 </Flex>
c81b57cClaude304 <script
305 dangerouslySetInnerHTML={{
306 __html: AI_COMMIT_MSG_SCRIPT({
307 endpoint: `/${owner}/${repo}/ai/commit-message`,
308 ref,
309 filePath,
310 }),
311 }}
312 />
bb0f894Claude313 </Form>
314 </Container>
0074234Claude315 </Layout>
316 );
317});
318
c81b57cClaude319// AI-suggested commit message — JSON endpoint driven by the editor button.
320// Reads the on-disk blob at (ref, filePath), diffs against the submitted
321// new content, and asks generateCommitMessage() for a one-liner. Returns
322// {ok:true, message} on success, {ok:false, error} otherwise. Always 200.
323editor.post(
324 "/:owner/:repo/ai/commit-message",
325 requireAuth,
326 requireRepoAccess("write"),
327 async (c) => {
328 const { owner, repo } = c.req.param();
329 if (!isAiAvailable()) {
330 return c.json({
331 ok: false,
332 error: "AI is not available — set ANTHROPIC_API_KEY.",
333 });
334 }
335 const body = await c.req.parseBody();
336 const ref = String(body.ref || "").trim();
337 const filePath = String(body.filePath || "").trim();
338 const newContent = String(body.content || "");
339 if (!ref || !filePath) {
340 return c.json({ ok: false, error: "ref + filePath required" });
341 }
342
343 let oldContent = "";
344 try {
345 const blob = await getBlob(owner, repo, ref, filePath);
346 oldContent = blob?.content || "";
347 } catch {
348 oldContent = "";
349 }
350
351 if (oldContent === newContent) {
352 return c.json({
353 ok: false,
354 error: "No changes to summarise.",
355 });
356 }
357
358 // Build a minimal unified-diff-ish summary the AI helper can consume.
359 // generateCommitMessage was written for git diff text; we feed a
360 // header + truncated old/new sample so it has shape to summarise.
361 const truncate = (s: string) => (s.length > 4000 ? s.slice(0, 4000) + "\n…(truncated)" : s);
362 const diff =
363 `--- a/${filePath}\n+++ b/${filePath}\n` +
364 "## Old:\n" +
365 truncate(oldContent) +
366 "\n\n## New:\n" +
367 truncate(newContent);
368
369 let message = "";
370 try {
371 message = await generateCommitMessage(diff);
372 } catch (err) {
373 const msg = err instanceof Error ? err.message : "AI request failed.";
374 return c.json({ ok: false, error: msg });
375 }
376 if (!message.trim()) {
377 return c.json({
378 ok: false,
379 error: "AI returned an empty draft.",
380 });
381 }
382 // Cap to one line + 100 chars (commit-message convention).
383 const oneLine = message.split("\n")[0]!.trim();
384 const capped = oneLine.length > 100 ? oneLine.slice(0, 97) + "..." : oneLine;
385 return c.json({ ok: true, message: capped });
386 }
387);
388
0074234Claude389// Save edited file
04f6b7fClaude390editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude391 const { owner, repo } = c.req.param();
392 const user = c.get("user")!;
393 const refAndPath = c.req.param("ref");
394
395 const slashIdx = refAndPath.indexOf("/");
396 if (slashIdx === -1) return c.redirect(`/${owner}/${repo}`);
397 const ref = refAndPath.slice(0, slashIdx);
398 const filePath = refAndPath.slice(slashIdx + 1);
399
400 const body = await c.req.parseBody();
401 const content = String(body.content || "");
402 const message = String(
403 body.message || `Update ${filePath.split("/").pop()}`
404 ).trim();
405
406 const repoDir = getRepoPath(owner, repo);
407
408 const run = async (cmd: string[], cwd: string, stdin?: string) => {
409 const proc = Bun.spawn(cmd, {
410 cwd,
411 stdout: "pipe",
412 stderr: "pipe",
413 stdin: stdin !== undefined ? "pipe" : undefined,
414 });
415 if (stdin !== undefined && proc.stdin) {
416 proc.stdin.write(new TextEncoder().encode(stdin));
417 proc.stdin.end();
418 }
419 const stdout = await new Response(proc.stdout).text();
420 await proc.exited;
421 return stdout.trim();
422 };
423
424 // Hash new content
425 const blobSha = await run(
426 ["git", "hash-object", "-w", "--stdin"],
427 repoDir,
428 content
429 );
430
431 // Read current tree, replace the file
432 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
433 const lines = treeContent.split("\n").filter(Boolean);
434 const updated = lines
435 .map((line) => {
436 const parts = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
437 if (parts && parts[4] === filePath) {
438 return `${parts[1]} blob ${blobSha}\t${parts[4]}`;
439 }
440 return line;
441 })
442 .join("\n") + "\n";
443
444 const newTreeSha = await run(["git", "mktree"], repoDir, updated);
445 const parentSha = await run(["git", "rev-parse", ref], repoDir);
446
447 const env = {
448 GIT_AUTHOR_NAME: user.displayName || user.username,
449 GIT_AUTHOR_EMAIL: user.email,
450 GIT_COMMITTER_NAME: user.displayName || user.username,
451 GIT_COMMITTER_EMAIL: user.email,
452 };
453
454 const commitProc = Bun.spawn(
455 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
456 {
457 cwd: repoDir,
458 stdout: "pipe",
459 stderr: "pipe",
460 env: { ...process.env, ...env },
461 }
462 );
463 const commitSha = (await new Response(commitProc.stdout).text()).trim();
464 await commitProc.exited;
465
466 await run(
467 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
468 repoDir
469 );
470
471 return c.redirect(`/${owner}/${repo}/blob/${ref}/${filePath}`);
472});
473
474export default editor;