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.tsxBlame331 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";
8import {
9 getBlob,
10 getDefaultBranch,
11 getRepoPath,
12 repoExists,
13} from "../git/repository";
14import { softAuth, requireAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16import { join } from "path";
17
18const editor = new Hono<AuthEnv>();
19
20editor.use("*", softAuth);
21
22// New file form
23editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, async (c) => {
24 const { owner, repo } = c.req.param();
25 const user = c.get("user")!;
26 const refAndPath = c.req.param("ref");
27
28 // Parse ref — use first segment
29 const slashIdx = refAndPath.indexOf("/");
30 const ref = slashIdx === -1 ? refAndPath : refAndPath.slice(0, slashIdx);
31 const dirPath = slashIdx === -1 ? "" : refAndPath.slice(slashIdx + 1);
32
33 return c.html(
34 <Layout title={`New file — ${owner}/${repo}`} user={user}>
35 <RepoHeader owner={owner} repo={repo} />
36 <RepoNav owner={owner} repo={repo} active="code" />
37 <div style="max-width: 900px">
38 <h2 style="margin-bottom: 16px">Create new file</h2>
9837657Claude39 <form method="post" action={`/${owner}/${repo}/new/${ref}`}>
0074234Claude40 <input type="hidden" name="dir_path" value={dirPath} />
41 <div class="form-group">
42 <label>File path</label>
43 <div style="display: flex; align-items: center; gap: 4px">
44 {dirPath && (
45 <span style="color: var(--text-muted); font-size: 14px">
46 {dirPath}/
47 </span>
48 )}
49 <input
50 type="text"
51 name="filename"
52 required
53 placeholder="filename.ts"
54 style="flex: 1"
55 autocomplete="off"
56 />
57 </div>
58 </div>
59 <div class="form-group">
60 <label>Content</label>
61 <textarea
62 name="content"
63 rows={20}
64 style="font-family: var(--font-mono); font-size: 13px; line-height: 1.5; tab-size: 2"
65 placeholder="Enter file content..."
66 />
67 </div>
68 <div class="form-group">
69 <label>Commit message</label>
70 <input
71 type="text"
72 name="message"
73 placeholder="Create new file"
74 required
75 />
76 </div>
77 <button type="submit" class="btn btn-primary">
78 Commit new file
79 </button>
80 </form>
81 </div>
82 </Layout>
83 );
84});
85
86// Create file via commit
87editor.post("/:owner/:repo/new/:ref", requireAuth, async (c) => {
88 const { owner, repo } = c.req.param();
89 const user = c.get("user")!;
90 const ref = c.req.param("ref");
91 const body = await c.req.parseBody();
92 const dirPath = String(body.dir_path || "").trim();
93 const filename = String(body.filename || "").trim();
94 const content = String(body.content || "");
95 const message = String(body.message || `Create ${filename}`).trim();
96
97 if (!filename) return c.redirect(`/${owner}/${repo}`);
98
99 const fullPath = dirPath ? `${dirPath}/${filename}` : filename;
100
101 // Use git hash-object + update-index + write-tree + commit-tree
102 const repoDir = getRepoPath(owner, repo);
103
104 const run = async (cmd: string[], cwd: string, stdin?: string) => {
105 const proc = Bun.spawn(cmd, {
106 cwd,
107 stdout: "pipe",
108 stderr: "pipe",
109 stdin: stdin !== undefined ? "pipe" : undefined,
110 });
111 if (stdin !== undefined && proc.stdin) {
112 proc.stdin.write(new TextEncoder().encode(stdin));
113 proc.stdin.end();
114 }
115 const stdout = await new Response(proc.stdout).text();
116 await proc.exited;
117 return stdout.trim();
118 };
119
120 // Hash the new file content
121 const blobSha = await run(
122 ["git", "hash-object", "-w", "--stdin"],
123 repoDir,
124 content
125 );
126
127 // Read current tree
128 const currentTreeSha = await run(
129 ["git", "rev-parse", `${ref}^{tree}`],
130 repoDir
131 );
132
133 // Read current tree and add new entry
134 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
135 const entries = treeContent
136 .split("\n")
137 .filter(Boolean)
138 .map((line) => line + "\n")
139 .join("");
140 const newEntry = `100644 blob ${blobSha}\t${fullPath}\n`;
141
142 const newTreeSha = await run(
143 ["git", "mktree"],
144 repoDir,
145 entries + newEntry
146 );
147
148 // Get parent commit
149 const parentSha = await run(
150 ["git", "rev-parse", ref],
151 repoDir
152 );
153
154 // Create commit
155 const env = {
156 GIT_AUTHOR_NAME: user.displayName || user.username,
157 GIT_AUTHOR_EMAIL: user.email,
158 GIT_COMMITTER_NAME: user.displayName || user.username,
159 GIT_COMMITTER_EMAIL: user.email,
160 };
161
162 const commitProc = Bun.spawn(
163 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
164 {
165 cwd: repoDir,
166 stdout: "pipe",
167 stderr: "pipe",
168 env: { ...process.env, ...env },
169 }
170 );
171 const commitSha = (await new Response(commitProc.stdout).text()).trim();
172 await commitProc.exited;
173
174 // Update branch ref
175 await run(
176 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
177 repoDir
178 );
179
180 return c.redirect(`/${owner}/${repo}/blob/${ref}/${fullPath}`);
181});
182
183// Edit file form
184editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, async (c) => {
185 const { owner, repo } = c.req.param();
186 const user = c.get("user")!;
187 const refAndPath = c.req.param("ref");
188
189 // Parse ref/path
190 const slashIdx = refAndPath.indexOf("/");
191 if (slashIdx === -1) return c.text("Not found", 404);
192 const ref = refAndPath.slice(0, slashIdx);
193 const filePath = refAndPath.slice(slashIdx + 1);
194
195 const blob = await getBlob(owner, repo, ref, filePath);
196 if (!blob || blob.isBinary) {
197 return c.html(
198 <Layout title="Cannot edit" user={user}>
199 <div class="empty-state">
200 <h2>{blob?.isBinary ? "Cannot edit binary file" : "File not found"}</h2>
201 </div>
202 </Layout>,
203 404
204 );
205 }
206
207 return c.html(
208 <Layout title={`Editing ${filePath} — ${owner}/${repo}`} user={user}>
209 <RepoHeader owner={owner} repo={repo} />
210 <RepoNav owner={owner} repo={repo} active="code" />
211 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
212 <div style="max-width: 900px">
9837657Claude213 <form method="post" action={`/${owner}/${repo}/edit/${ref}/${filePath}`}>
0074234Claude214 <div class="form-group">
215 <textarea
216 name="content"
217 rows={25}
218 style="font-family: var(--font-mono); font-size: 13px; line-height: 1.5; tab-size: 2; width: 100%"
219 >
220 {blob.content}
221 </textarea>
222 </div>
223 <div class="form-group">
224 <label>Commit message</label>
225 <input
226 type="text"
227 name="message"
228 placeholder={`Update ${filePath.split("/").pop()}`}
229 required
230 />
231 </div>
232 <div style="display: flex; gap: 8px">
233 <button type="submit" class="btn btn-primary">
234 Commit changes
235 </button>
236 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} class="btn">
237 Cancel
238 </a>
239 </div>
240 </form>
241 </div>
242 </Layout>
243 );
244});
245
246// Save edited file
247editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, async (c) => {
248 const { owner, repo } = c.req.param();
249 const user = c.get("user")!;
250 const refAndPath = c.req.param("ref");
251
252 const slashIdx = refAndPath.indexOf("/");
253 if (slashIdx === -1) return c.redirect(`/${owner}/${repo}`);
254 const ref = refAndPath.slice(0, slashIdx);
255 const filePath = refAndPath.slice(slashIdx + 1);
256
257 const body = await c.req.parseBody();
258 const content = String(body.content || "");
259 const message = String(
260 body.message || `Update ${filePath.split("/").pop()}`
261 ).trim();
262
263 const repoDir = getRepoPath(owner, repo);
264
265 const run = async (cmd: string[], cwd: string, stdin?: string) => {
266 const proc = Bun.spawn(cmd, {
267 cwd,
268 stdout: "pipe",
269 stderr: "pipe",
270 stdin: stdin !== undefined ? "pipe" : undefined,
271 });
272 if (stdin !== undefined && proc.stdin) {
273 proc.stdin.write(new TextEncoder().encode(stdin));
274 proc.stdin.end();
275 }
276 const stdout = await new Response(proc.stdout).text();
277 await proc.exited;
278 return stdout.trim();
279 };
280
281 // Hash new content
282 const blobSha = await run(
283 ["git", "hash-object", "-w", "--stdin"],
284 repoDir,
285 content
286 );
287
288 // Read current tree, replace the file
289 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
290 const lines = treeContent.split("\n").filter(Boolean);
291 const updated = lines
292 .map((line) => {
293 const parts = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
294 if (parts && parts[4] === filePath) {
295 return `${parts[1]} blob ${blobSha}\t${parts[4]}`;
296 }
297 return line;
298 })
299 .join("\n") + "\n";
300
301 const newTreeSha = await run(["git", "mktree"], repoDir, updated);
302 const parentSha = await run(["git", "rev-parse", ref], repoDir);
303
304 const env = {
305 GIT_AUTHOR_NAME: user.displayName || user.username,
306 GIT_AUTHOR_EMAIL: user.email,
307 GIT_COMMITTER_NAME: user.displayName || user.username,
308 GIT_COMMITTER_EMAIL: user.email,
309 };
310
311 const commitProc = Bun.spawn(
312 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
313 {
314 cwd: repoDir,
315 stdout: "pipe",
316 stderr: "pipe",
317 env: { ...process.env, ...env },
318 }
319 );
320 const commitSha = (await new Response(commitProc.stdout).text()).trim();
321 await commitProc.exited;
322
323 await run(
324 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
325 repoDir
326 );
327
328 return c.redirect(`/${owner}/${repo}/blob/${ref}/${filePath}`);
329});
330
331export default editor;