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.tsxBlame336 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";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude28import { requireRepoAccess } from "../middleware/repo-access";
0074234Claude29import { join } from "path";
30
31const editor = new Hono<AuthEnv>();
32
33editor.use("*", softAuth);
34
35// New file form
04f6b7fClaude36editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude37 const { owner, repo } = c.req.param();
38 const user = c.get("user")!;
39 const refAndPath = c.req.param("ref");
40
41 // Parse ref — use first segment
42 const slashIdx = refAndPath.indexOf("/");
43 const ref = slashIdx === -1 ? refAndPath : refAndPath.slice(0, slashIdx);
44 const dirPath = slashIdx === -1 ? "" : refAndPath.slice(slashIdx + 1);
45
46 return c.html(
47 <Layout title={`New file — ${owner}/${repo}`} user={user}>
48 <RepoHeader owner={owner} repo={repo} />
49 <RepoNav owner={owner} repo={repo} active="code" />
bb0f894Claude50 <Container maxWidth={900}>
0074234Claude51 <h2 style="margin-bottom: 16px">Create new file</h2>
0316dbbClaude52 <Form method="post" action={`/${owner}/${repo}/new/${ref}`}>
0074234Claude53 <input type="hidden" name="dir_path" value={dirPath} />
bb0f894Claude54 <FormGroup label="File path">
55 <Flex align="center" gap={4}>
0074234Claude56 {dirPath && (
bb0f894Claude57 <Text muted size={14}>
0074234Claude58 {dirPath}/
bb0f894Claude59 </Text>
0074234Claude60 )}
bb0f894Claude61 <Input
0074234Claude62 name="filename"
63 required
64 placeholder="filename.ts"
65 style="flex: 1"
66 autocomplete="off"
67 />
bb0f894Claude68 </Flex>
69 </FormGroup>
70 <FormGroup label="Content">
71 <TextArea
0074234Claude72 name="content"
73 rows={20}
74 placeholder="Enter file content..."
bb0f894Claude75 mono
76 style="line-height: 1.5; tab-size: 2"
0074234Claude77 />
bb0f894Claude78 </FormGroup>
79 <FormGroup label="Commit message">
80 <Input
0074234Claude81 name="message"
82 placeholder="Create new file"
83 required
84 />
bb0f894Claude85 </FormGroup>
86 <Button type="submit" variant="primary">
0074234Claude87 Commit new file
bb0f894Claude88 </Button>
89 </Form>
90 </Container>
0074234Claude91 </Layout>
92 );
93});
94
95// Create file via commit
04f6b7fClaude96editor.post("/:owner/:repo/new/:ref", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude97 const { owner, repo } = c.req.param();
98 const user = c.get("user")!;
99 const ref = c.req.param("ref");
100 const body = await c.req.parseBody();
101 const dirPath = String(body.dir_path || "").trim();
102 const filename = String(body.filename || "").trim();
103 const content = String(body.content || "");
104 const message = String(body.message || `Create ${filename}`).trim();
105
106 if (!filename) return c.redirect(`/${owner}/${repo}`);
107
108 const fullPath = dirPath ? `${dirPath}/${filename}` : filename;
109
110 // Use git hash-object + update-index + write-tree + commit-tree
111 const repoDir = getRepoPath(owner, repo);
112
113 const run = async (cmd: string[], cwd: string, stdin?: string) => {
114 const proc = Bun.spawn(cmd, {
115 cwd,
116 stdout: "pipe",
117 stderr: "pipe",
118 stdin: stdin !== undefined ? "pipe" : undefined,
119 });
120 if (stdin !== undefined && proc.stdin) {
121 proc.stdin.write(new TextEncoder().encode(stdin));
122 proc.stdin.end();
123 }
124 const stdout = await new Response(proc.stdout).text();
125 await proc.exited;
126 return stdout.trim();
127 };
128
129 // Hash the new file content
130 const blobSha = await run(
131 ["git", "hash-object", "-w", "--stdin"],
132 repoDir,
133 content
134 );
135
136 // Read current tree
137 const currentTreeSha = await run(
138 ["git", "rev-parse", `${ref}^{tree}`],
139 repoDir
140 );
141
142 // Read current tree and add new entry
143 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
144 const entries = treeContent
145 .split("\n")
146 .filter(Boolean)
147 .map((line) => line + "\n")
148 .join("");
149 const newEntry = `100644 blob ${blobSha}\t${fullPath}\n`;
150
151 const newTreeSha = await run(
152 ["git", "mktree"],
153 repoDir,
154 entries + newEntry
155 );
156
157 // Get parent commit
158 const parentSha = await run(
159 ["git", "rev-parse", ref],
160 repoDir
161 );
162
163 // Create commit
164 const env = {
165 GIT_AUTHOR_NAME: user.displayName || user.username,
166 GIT_AUTHOR_EMAIL: user.email,
167 GIT_COMMITTER_NAME: user.displayName || user.username,
168 GIT_COMMITTER_EMAIL: user.email,
169 };
170
171 const commitProc = Bun.spawn(
172 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
173 {
174 cwd: repoDir,
175 stdout: "pipe",
176 stderr: "pipe",
177 env: { ...process.env, ...env },
178 }
179 );
180 const commitSha = (await new Response(commitProc.stdout).text()).trim();
181 await commitProc.exited;
182
183 // Update branch ref
184 await run(
185 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
186 repoDir
187 );
188
189 return c.redirect(`/${owner}/${repo}/blob/${ref}/${fullPath}`);
190});
191
192// Edit file form
04f6b7fClaude193editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude194 const { owner, repo } = c.req.param();
195 const user = c.get("user")!;
196 const refAndPath = c.req.param("ref");
197
198 // Parse ref/path
199 const slashIdx = refAndPath.indexOf("/");
200 if (slashIdx === -1) return c.text("Not found", 404);
201 const ref = refAndPath.slice(0, slashIdx);
202 const filePath = refAndPath.slice(slashIdx + 1);
203
204 const blob = await getBlob(owner, repo, ref, filePath);
205 if (!blob || blob.isBinary) {
206 return c.html(
207 <Layout title="Cannot edit" user={user}>
bb0f894Claude208 <EmptyState title={blob?.isBinary ? "Cannot edit binary file" : "File not found"} />
0074234Claude209 </Layout>,
210 404
211 );
212 }
213
214 return c.html(
215 <Layout title={`Editing ${filePath} — ${owner}/${repo}`} user={user}>
216 <RepoHeader owner={owner} repo={repo} />
217 <RepoNav owner={owner} repo={repo} active="code" />
218 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
0316dbbClaude219 <Container maxWidth={900}>
220 <Form method="post" action={`/${owner}/${repo}/edit/${ref}/${filePath}`}>
221 <FormGroup label="Content">
222 <TextArea
0074234Claude223 name="content"
224 rows={25}
bb0f894Claude225 value={blob.content}
226 mono
227 style="line-height: 1.5; tab-size: 2; width: 100%"
228 />
229 </FormGroup>
230 <FormGroup label="Commit message">
231 <Input
0074234Claude232 name="message"
233 placeholder={`Update ${filePath.split("/").pop()}`}
234 required
235 />
bb0f894Claude236 </FormGroup>
237 <Flex gap={8}>
238 <Button type="submit" variant="primary">
0074234Claude239 Commit changes
bb0f894Claude240 </Button>
241 <LinkButton href={`/${owner}/${repo}/blob/${ref}/${filePath}`}>
0074234Claude242 Cancel
bb0f894Claude243 </LinkButton>
244 </Flex>
245 </Form>
246 </Container>
0074234Claude247 </Layout>
248 );
249});
250
251// Save edited file
04f6b7fClaude252editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude253 const { owner, repo } = c.req.param();
254 const user = c.get("user")!;
255 const refAndPath = c.req.param("ref");
256
257 const slashIdx = refAndPath.indexOf("/");
258 if (slashIdx === -1) return c.redirect(`/${owner}/${repo}`);
259 const ref = refAndPath.slice(0, slashIdx);
260 const filePath = refAndPath.slice(slashIdx + 1);
261
262 const body = await c.req.parseBody();
263 const content = String(body.content || "");
264 const message = String(
265 body.message || `Update ${filePath.split("/").pop()}`
266 ).trim();
267
268 const repoDir = getRepoPath(owner, repo);
269
270 const run = async (cmd: string[], cwd: string, stdin?: string) => {
271 const proc = Bun.spawn(cmd, {
272 cwd,
273 stdout: "pipe",
274 stderr: "pipe",
275 stdin: stdin !== undefined ? "pipe" : undefined,
276 });
277 if (stdin !== undefined && proc.stdin) {
278 proc.stdin.write(new TextEncoder().encode(stdin));
279 proc.stdin.end();
280 }
281 const stdout = await new Response(proc.stdout).text();
282 await proc.exited;
283 return stdout.trim();
284 };
285
286 // Hash new content
287 const blobSha = await run(
288 ["git", "hash-object", "-w", "--stdin"],
289 repoDir,
290 content
291 );
292
293 // Read current tree, replace the file
294 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
295 const lines = treeContent.split("\n").filter(Boolean);
296 const updated = lines
297 .map((line) => {
298 const parts = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
299 if (parts && parts[4] === filePath) {
300 return `${parts[1]} blob ${blobSha}\t${parts[4]}`;
301 }
302 return line;
303 })
304 .join("\n") + "\n";
305
306 const newTreeSha = await run(["git", "mktree"], repoDir, updated);
307 const parentSha = await run(["git", "rev-parse", ref], repoDir);
308
309 const env = {
310 GIT_AUTHOR_NAME: user.displayName || user.username,
311 GIT_AUTHOR_EMAIL: user.email,
312 GIT_COMMITTER_NAME: user.displayName || user.username,
313 GIT_COMMITTER_EMAIL: user.email,
314 };
315
316 const commitProc = Bun.spawn(
317 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
318 {
319 cwd: repoDir,
320 stdout: "pipe",
321 stderr: "pipe",
322 env: { ...process.env, ...env },
323 }
324 );
325 const commitSha = (await new Response(commitProc.stdout).text()).trim();
326 await commitProc.exited;
327
328 await run(
329 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
330 repoDir
331 );
332
333 return c.redirect(`/${owner}/${repo}/blob/${ref}/${filePath}`);
334});
335
336export default editor;