Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame477 lines · 2 contributors
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"
2228c49copilot-swe-agent[bot]113 aria-label="File path"
0074234Claude114 />
bb0f894Claude115 </Flex>
116 </FormGroup>
117 <FormGroup label="Content">
118 <TextArea
0074234Claude119 name="content"
120 rows={20}
121 placeholder="Enter file content..."
bb0f894Claude122 mono
123 style="line-height: 1.5; tab-size: 2"
0074234Claude124 />
bb0f894Claude125 </FormGroup>
126 <FormGroup label="Commit message">
127 <Input
0074234Claude128 name="message"
129 placeholder="Create new file"
130 required
2228c49copilot-swe-agent[bot]131 aria-label="Commit message"
0074234Claude132 />
bb0f894Claude133 </FormGroup>
134 <Button type="submit" variant="primary">
0074234Claude135 Commit new file
bb0f894Claude136 </Button>
137 </Form>
138 </Container>
0074234Claude139 </Layout>
140 );
141});
142
143// Create file via commit
04f6b7fClaude144editor.post("/:owner/:repo/new/:ref", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude145 const { owner, repo } = c.req.param();
146 const user = c.get("user")!;
147 const ref = c.req.param("ref");
148 const body = await c.req.parseBody();
149 const dirPath = String(body.dir_path || "").trim();
150 const filename = String(body.filename || "").trim();
151 const content = String(body.content || "");
152 const message = String(body.message || `Create ${filename}`).trim();
153
154 if (!filename) return c.redirect(`/${owner}/${repo}`);
155
156 const fullPath = dirPath ? `${dirPath}/${filename}` : filename;
157
158 // Use git hash-object + update-index + write-tree + commit-tree
159 const repoDir = getRepoPath(owner, repo);
160
161 const run = async (cmd: string[], cwd: string, stdin?: string) => {
162 const proc = Bun.spawn(cmd, {
163 cwd,
164 stdout: "pipe",
165 stderr: "pipe",
166 stdin: stdin !== undefined ? "pipe" : undefined,
167 });
168 if (stdin !== undefined && proc.stdin) {
169 proc.stdin.write(new TextEncoder().encode(stdin));
170 proc.stdin.end();
171 }
172 const stdout = await new Response(proc.stdout).text();
173 await proc.exited;
174 return stdout.trim();
175 };
176
177 // Hash the new file content
178 const blobSha = await run(
179 ["git", "hash-object", "-w", "--stdin"],
180 repoDir,
181 content
182 );
183
184 // Read current tree
185 const currentTreeSha = await run(
186 ["git", "rev-parse", `${ref}^{tree}`],
187 repoDir
188 );
189
190 // Read current tree and add new entry
191 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
192 const entries = treeContent
193 .split("\n")
194 .filter(Boolean)
195 .map((line) => line + "\n")
196 .join("");
197 const newEntry = `100644 blob ${blobSha}\t${fullPath}\n`;
198
199 const newTreeSha = await run(
200 ["git", "mktree"],
201 repoDir,
202 entries + newEntry
203 );
204
205 // Get parent commit
206 const parentSha = await run(
207 ["git", "rev-parse", ref],
208 repoDir
209 );
210
211 // Create commit
212 const env = {
213 GIT_AUTHOR_NAME: user.displayName || user.username,
214 GIT_AUTHOR_EMAIL: user.email,
215 GIT_COMMITTER_NAME: user.displayName || user.username,
216 GIT_COMMITTER_EMAIL: user.email,
217 };
218
219 const commitProc = Bun.spawn(
220 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
221 {
222 cwd: repoDir,
223 stdout: "pipe",
224 stderr: "pipe",
225 env: { ...process.env, ...env },
226 }
227 );
228 const commitSha = (await new Response(commitProc.stdout).text()).trim();
229 await commitProc.exited;
230
231 // Update branch ref
232 await run(
233 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
234 repoDir
235 );
236
237 return c.redirect(`/${owner}/${repo}/blob/${ref}/${fullPath}`);
238});
239
240// Edit file form
04f6b7fClaude241editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude242 const { owner, repo } = c.req.param();
243 const user = c.get("user")!;
244 const refAndPath = c.req.param("ref");
245
246 // Parse ref/path
247 const slashIdx = refAndPath.indexOf("/");
248 if (slashIdx === -1) return c.text("Not found", 404);
249 const ref = refAndPath.slice(0, slashIdx);
250 const filePath = refAndPath.slice(slashIdx + 1);
251
252 const blob = await getBlob(owner, repo, ref, filePath);
253 if (!blob || blob.isBinary) {
254 return c.html(
255 <Layout title="Cannot edit" user={user}>
bb0f894Claude256 <EmptyState title={blob?.isBinary ? "Cannot edit binary file" : "File not found"} />
0074234Claude257 </Layout>,
258 404
259 );
260 }
261
262 return c.html(
263 <Layout title={`Editing ${filePath} — ${owner}/${repo}`} user={user}>
264 <RepoHeader owner={owner} repo={repo} />
265 <RepoNav owner={owner} repo={repo} active="code" />
266 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
0316dbbClaude267 <Container maxWidth={900}>
268 <Form method="post" action={`/${owner}/${repo}/edit/${ref}/${filePath}`}>
269 <FormGroup label="Content">
270 <TextArea
0074234Claude271 name="content"
272 rows={25}
bb0f894Claude273 value={blob.content}
274 mono
275 style="line-height: 1.5; tab-size: 2; width: 100%"
276 />
277 </FormGroup>
278 <FormGroup label="Commit message">
279 <Input
c81b57cClaude280 id="commit-message-input"
0074234Claude281 name="message"
282 placeholder={`Update ${filePath.split("/").pop()}`}
283 required
2228c49copilot-swe-agent[bot]284 aria-label="Commit message"
0074234Claude285 />
bb0f894Claude286 </FormGroup>
c81b57cClaude287 <Flex gap={8} align="center">
bb0f894Claude288 <Button type="submit" variant="primary">
0074234Claude289 Commit changes
bb0f894Claude290 </Button>
c81b57cClaude291 <button
292 type="button"
293 id="ai-commit-msg-btn"
294 class="btn"
295 title="Generate a one-line commit message using Claude based on the diff"
296 >
297 Suggest with AI
298 </button>
299 <span
300 id="ai-commit-msg-status"
301 style="color:var(--text-muted);font-size:13px"
302 />
bb0f894Claude303 <LinkButton href={`/${owner}/${repo}/blob/${ref}/${filePath}`}>
0074234Claude304 Cancel
bb0f894Claude305 </LinkButton>
306 </Flex>
c81b57cClaude307 <script
308 dangerouslySetInnerHTML={{
309 __html: AI_COMMIT_MSG_SCRIPT({
310 endpoint: `/${owner}/${repo}/ai/commit-message`,
311 ref,
312 filePath,
313 }),
314 }}
315 />
bb0f894Claude316 </Form>
317 </Container>
0074234Claude318 </Layout>
319 );
320});
321
c81b57cClaude322// AI-suggested commit message — JSON endpoint driven by the editor button.
323// Reads the on-disk blob at (ref, filePath), diffs against the submitted
324// new content, and asks generateCommitMessage() for a one-liner. Returns
325// {ok:true, message} on success, {ok:false, error} otherwise. Always 200.
326editor.post(
327 "/:owner/:repo/ai/commit-message",
328 requireAuth,
329 requireRepoAccess("write"),
330 async (c) => {
331 const { owner, repo } = c.req.param();
332 if (!isAiAvailable()) {
333 return c.json({
334 ok: false,
335 error: "AI is not available — set ANTHROPIC_API_KEY.",
336 });
337 }
338 const body = await c.req.parseBody();
339 const ref = String(body.ref || "").trim();
340 const filePath = String(body.filePath || "").trim();
341 const newContent = String(body.content || "");
342 if (!ref || !filePath) {
343 return c.json({ ok: false, error: "ref + filePath required" });
344 }
345
346 let oldContent = "";
347 try {
348 const blob = await getBlob(owner, repo, ref, filePath);
349 oldContent = blob?.content || "";
350 } catch {
351 oldContent = "";
352 }
353
354 if (oldContent === newContent) {
355 return c.json({
356 ok: false,
357 error: "No changes to summarise.",
358 });
359 }
360
361 // Build a minimal unified-diff-ish summary the AI helper can consume.
362 // generateCommitMessage was written for git diff text; we feed a
363 // header + truncated old/new sample so it has shape to summarise.
364 const truncate = (s: string) => (s.length > 4000 ? s.slice(0, 4000) + "\n…(truncated)" : s);
365 const diff =
366 `--- a/${filePath}\n+++ b/${filePath}\n` +
367 "## Old:\n" +
368 truncate(oldContent) +
369 "\n\n## New:\n" +
370 truncate(newContent);
371
372 let message = "";
373 try {
374 message = await generateCommitMessage(diff);
375 } catch (err) {
376 const msg = err instanceof Error ? err.message : "AI request failed.";
377 return c.json({ ok: false, error: msg });
378 }
379 if (!message.trim()) {
380 return c.json({
381 ok: false,
382 error: "AI returned an empty draft.",
383 });
384 }
385 // Cap to one line + 100 chars (commit-message convention).
386 const oneLine = message.split("\n")[0]!.trim();
387 const capped = oneLine.length > 100 ? oneLine.slice(0, 97) + "..." : oneLine;
388 return c.json({ ok: true, message: capped });
389 }
390);
391
0074234Claude392// Save edited file
04f6b7fClaude393editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude394 const { owner, repo } = c.req.param();
395 const user = c.get("user")!;
396 const refAndPath = c.req.param("ref");
397
398 const slashIdx = refAndPath.indexOf("/");
399 if (slashIdx === -1) return c.redirect(`/${owner}/${repo}`);
400 const ref = refAndPath.slice(0, slashIdx);
401 const filePath = refAndPath.slice(slashIdx + 1);
402
403 const body = await c.req.parseBody();
404 const content = String(body.content || "");
405 const message = String(
406 body.message || `Update ${filePath.split("/").pop()}`
407 ).trim();
408
409 const repoDir = getRepoPath(owner, repo);
410
411 const run = async (cmd: string[], cwd: string, stdin?: string) => {
412 const proc = Bun.spawn(cmd, {
413 cwd,
414 stdout: "pipe",
415 stderr: "pipe",
416 stdin: stdin !== undefined ? "pipe" : undefined,
417 });
418 if (stdin !== undefined && proc.stdin) {
419 proc.stdin.write(new TextEncoder().encode(stdin));
420 proc.stdin.end();
421 }
422 const stdout = await new Response(proc.stdout).text();
423 await proc.exited;
424 return stdout.trim();
425 };
426
427 // Hash new content
428 const blobSha = await run(
429 ["git", "hash-object", "-w", "--stdin"],
430 repoDir,
431 content
432 );
433
434 // Read current tree, replace the file
435 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
436 const lines = treeContent.split("\n").filter(Boolean);
437 const updated = lines
438 .map((line) => {
439 const parts = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
440 if (parts && parts[4] === filePath) {
441 return `${parts[1]} blob ${blobSha}\t${parts[4]}`;
442 }
443 return line;
444 })
445 .join("\n") + "\n";
446
447 const newTreeSha = await run(["git", "mktree"], repoDir, updated);
448 const parentSha = await run(["git", "rev-parse", ref], repoDir);
449
450 const env = {
451 GIT_AUTHOR_NAME: user.displayName || user.username,
452 GIT_AUTHOR_EMAIL: user.email,
453 GIT_COMMITTER_NAME: user.displayName || user.username,
454 GIT_COMMITTER_EMAIL: user.email,
455 };
456
457 const commitProc = Bun.spawn(
458 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
459 {
460 cwd: repoDir,
461 stdout: "pipe",
462 stderr: "pipe",
463 env: { ...process.env, ...env },
464 }
465 );
466 const commitSha = (await new Response(commitProc.stdout).text()).trim();
467 await commitProc.exited;
468
469 await run(
470 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
471 repoDir
472 );
473
474 return c.redirect(`/${owner}/${repo}/blob/${ref}/${filePath}`);
475});
476
477export default editor;