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.tsxBlame1172 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.
3646bfeClaude9 *
10 * CodeMirror 6 enhancement: The plain textarea is replaced with a
11 * CodeMirror 6 editor loaded from CDN (esm.sh). The textarea is kept
12 * hidden and synced on every change so the existing form POST works
13 * without server changes. Language is auto-detected from the file extension.
0074234Claude14 */
15
16import { Hono } from "hono";
17import { Layout } from "../views/layout";
e26af57Claude18import { RepoHeader, RepoNav } from "../views/components";
19import { EmptyState } from "../views/ui";
0074234Claude20import {
21 getBlob,
22 getRepoPath,
23} from "../git/repository";
c81b57cClaude24import { generateCommitMessage } from "../lib/ai-generators";
25import { isAiAvailable } from "../lib/ai-client";
0074234Claude26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude28import { requireRepoAccess } from "../middleware/repo-access";
0074234Claude29
30const editor = new Hono<AuthEnv>();
31
32editor.use("*", softAuth);
33
c81b57cClaude34/**
35 * Inline JS for the editor's "Suggest with AI" commit-message button.
36 * Picks up the textarea content + form-pinned ref/filePath, POSTs JSON
37 * to the suggest endpoint, fills the message Input on success.
38 *
39 * Built as a string so we don't need a bundler. JSON-escapes against
40 * </script> breakout. Defensive DOM lookups (silent no-op on absence).
41 */
42function AI_COMMIT_MSG_SCRIPT(args: {
43 endpoint: string;
44 ref: string;
45 filePath: string;
46}): string {
47 const safe = (v: string) =>
48 JSON.stringify(v)
49 .split("<").join("\\u003C")
50 .split(">").join("\\u003E")
51 .split("&").join("\\u0026");
52 const url = safe(args.endpoint);
53 const ref = safe(args.ref);
54 const filePath = safe(args.filePath);
55 return (
56 "(function(){try{" +
57 "var btn=document.getElementById('ai-commit-msg-btn');" +
58 "var status=document.getElementById('ai-commit-msg-status');" +
59 "var input=document.getElementById('commit-message-input');" +
60 "var ta=document.querySelector('textarea[name=\"content\"]');" +
61 "if(!btn||!input||!ta)return;" +
62 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
63 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
64 "var fd='ref='+encodeURIComponent(" + ref + ")+'&filePath='+encodeURIComponent(" + filePath + ")+'&content='+encodeURIComponent(ta.value||'');" +
65 "fetch(" + url + ",{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:fd,credentials:'same-origin'})" +
66 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
67 ".then(function(j){btn.disabled=false;" +
68 "if(j&&j.ok&&typeof j.message==='string'){" +
69 "if(input.value&&input.value.trim().length>0){if(!confirm('Replace existing message?')){if(status)status.textContent='Cancelled.';return;}}" +
70 "input.value=j.message;if(status)status.textContent='Filled from AI. Edit before committing.';" +
71 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
72 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
73 "});" +
74 "}catch(e){}})();"
75 );
76}
77
3646bfeClaude78/**
79 * Detect a language identifier from a file path/name for CodeMirror.
80 * Returns a string like "typescript", "python", "markdown", etc.
81 * Used server-side to embed a data-lang attribute.
82 */
83function detectEditorLang(filePath: string): string {
84 const lower = filePath.toLowerCase();
85 const ext = lower.split(".").pop() || "";
86 switch (ext) {
87 case "ts":
88 case "tsx":
89 return "typescript";
90 case "js":
91 case "jsx":
92 case "mjs":
93 case "cjs":
94 return "javascript";
95 case "py":
96 return "python";
97 case "md":
98 case "mdx":
99 return "markdown";
100 case "json":
101 case "jsonc":
102 return "json";
103 case "css":
104 case "scss":
105 case "sass":
106 case "less":
107 return "css";
108 case "html":
109 case "htm":
110 return "html";
111 case "sh":
112 case "bash":
113 case "zsh":
114 return "shell";
115 case "sql":
116 return "sql";
117 case "xml":
118 return "xml";
119 case "yaml":
120 case "yml":
121 return "yaml";
122 case "rs":
123 return "rust";
124 case "go":
125 return "go";
126 case "java":
127 return "java";
128 case "rb":
129 return "ruby";
130 case "php":
131 return "php";
132 case "c":
133 case "h":
134 return "c";
135 case "cpp":
136 case "cc":
137 case "cxx":
138 case "hh":
139 case "hpp":
140 return "cpp";
141 default:
142 return "plaintext";
143 }
144}
145
146/**
147 * CodeMirror 6 initialization script — loaded from esm.sh CDN.
148 * Mounts a CodeMirror editor in place of the hidden textarea.
149 * Syncs on every change so the form POST continues to work.
150 * The textareaId identifies the hidden textarea to sync to.
151 * The lang attribute controls language detection + dynamic import.
152 */
153function CODEMIRROR_INIT_SCRIPT(args: {
154 textareaId: string;
155 wrapperId: string;
156 lang: string;
157}): string {
158 const safe = (v: string) =>
159 JSON.stringify(v)
160 .split("<").join("\\u003C")
161 .split(">").join("\\u003E")
162 .split("&").join("\\u0026");
163 const textareaId = safe(args.textareaId);
164 const wrapperId = safe(args.wrapperId);
165 const lang = safe(args.lang);
166
167 return `
168(async function() {
169 try {
170 var ta = document.getElementById(${textareaId});
171 var wrapper = document.getElementById(${wrapperId});
172 if (!ta || !wrapper) return;
173
174 // Load CodeMirror 6 core from esm.sh
175 var [cmView, cmState, cmCommands, cmLanguage, cmTheme] = await Promise.all([
176 import('https://esm.sh/@codemirror/view@6'),
177 import('https://esm.sh/@codemirror/state@6'),
178 import('https://esm.sh/@codemirror/commands@6'),
179 import('https://esm.sh/@codemirror/language@6'),
180 import('https://esm.sh/@codemirror/theme-one-dark@6')
181 ]);
182
183 var { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightSpecialChars } = cmView;
184 var { EditorState, Compartment } = cmState;
185 var { defaultKeymap, indentWithTab, historyKeymap, history } = cmCommands;
186 var { indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter } = cmLanguage;
187 var { oneDark } = cmTheme;
188
189 // Dynamically load language support
190 var langExtension = [];
191 try {
192 var langStr = ${lang};
193 if (langStr === 'typescript' || langStr === 'javascript') {
194 var jsLang = await import('https://esm.sh/@codemirror/lang-javascript@6');
195 langExtension = [jsLang.javascript({ typescript: langStr === 'typescript', jsx: true })];
196 } else if (langStr === 'python') {
197 var pyLang = await import('https://esm.sh/@codemirror/lang-python@6');
198 langExtension = [pyLang.python()];
199 } else if (langStr === 'markdown') {
200 var mdLang = await import('https://esm.sh/@codemirror/lang-markdown@6');
201 langExtension = [mdLang.markdown()];
202 } else if (langStr === 'json') {
203 var jsonLang = await import('https://esm.sh/@codemirror/lang-json@6');
204 langExtension = [jsonLang.json()];
205 } else if (langStr === 'css') {
206 var cssLang = await import('https://esm.sh/@codemirror/lang-css@6');
207 langExtension = [cssLang.css()];
208 } else if (langStr === 'html') {
209 var htmlLang = await import('https://esm.sh/@codemirror/lang-html@6');
210 langExtension = [htmlLang.html()];
211 } else if (langStr === 'shell') {
212 var { StreamLanguage } = cmLanguage;
213 var shellLang = await import('https://esm.sh/@codemirror/legacy-modes@6/src/shell');
214 langExtension = [StreamLanguage.define(shellLang.shell)];
215 } else if (langStr === 'sql') {
216 var sqlLang = await import('https://esm.sh/@codemirror/lang-sql@6');
217 langExtension = [sqlLang.sql()];
218 } else if (langStr === 'rust') {
219 var rsLang = await import('https://esm.sh/@codemirror/lang-rust@6');
220 langExtension = [rsLang.rust()];
221 } else if (langStr === 'cpp' || langStr === 'c') {
222 var cppLang = await import('https://esm.sh/@codemirror/lang-cpp@6');
223 langExtension = [cppLang.cpp()];
224 } else if (langStr === 'java') {
225 var javaLang = await import('https://esm.sh/@codemirror/lang-java@6');
226 langExtension = [javaLang.java()];
227 } else if (langStr === 'xml') {
228 var xmlLang = await import('https://esm.sh/@codemirror/lang-xml@6');
229 langExtension = [xmlLang.xml()];
230 } else if (langStr === 'yaml') {
231 var { StreamLanguage } = cmLanguage;
232 var yamlLang = await import('https://esm.sh/@codemirror/legacy-modes@6/src/yaml');
233 langExtension = [StreamLanguage.define(yamlLang.yaml)];
234 } else if (langStr === 'go') {
235 var { StreamLanguage } = cmLanguage;
236 var goLang = await import('https://esm.sh/@codemirror/legacy-modes@6/src/go');
237 langExtension = [StreamLanguage.define(goLang.go)];
238 } else if (langStr === 'ruby') {
239 var { StreamLanguage } = cmLanguage;
240 var rubyLang = await import('https://esm.sh/@codemirror/legacy-modes@6/src/ruby');
241 langExtension = [StreamLanguage.define(rubyLang.ruby)];
242 } else if (langStr === 'php') {
243 var phpLang = await import('https://esm.sh/@codemirror/lang-php@6');
244 langExtension = [phpLang.php()];
245 }
246 } catch(e) {
247 // language load failure is non-fatal — continue without highlighting
248 langExtension = [];
249 }
250
251 var initialContent = ta.value;
252
253 var updateListener = EditorView.updateListener.of(function(update) {
254 if (update.docChanged) {
255 ta.value = update.state.doc.toString();
256 }
257 });
258
259 var editorTheme = EditorView.theme({
260 '&': {
261 background: 'var(--bg)',
262 color: 'var(--text)',
263 border: '1px solid var(--border-strong)',
264 borderRadius: '10px',
265 minHeight: '280px',
266 fontSize: '13px',
267 lineHeight: '1.55',
268 fontFamily: 'var(--font-mono)',
269 },
270 '.cm-content': {
271 padding: '12px 14px',
272 caretColor: '#a48bff',
273 },
274 '.cm-focused': {
275 outline: 'none',
276 },
277 '&.cm-focused': {
278 borderColor: 'rgba(140,109,255,0.55)',
279 boxShadow: '0 0 0 3px rgba(140,109,255,0.18)',
280 },
281 '.cm-gutters': {
282 background: 'rgba(255,255,255,0.02)',
283 borderRight: '1px solid var(--border)',
284 color: 'var(--text-faint)',
285 paddingRight: '4px',
286 },
287 '.cm-activeLineGutter': {
288 background: 'rgba(140,109,255,0.08)',
289 },
290 '.cm-activeLine': {
291 background: 'rgba(140,109,255,0.06)',
292 },
293 '.cm-scroller': {
294 overflow: 'auto',
295 minHeight: '280px',
296 maxHeight: '70vh',
297 },
298 '.cm-cursor': {
299 borderLeftColor: '#a48bff',
300 },
301 });
302
303 var state = EditorState.create({
304 doc: initialContent,
305 extensions: [
306 history(),
307 lineNumbers(),
308 highlightActiveLine(),
309 highlightActiveLineGutter(),
310 drawSelection(),
311 dropCursor(),
312 rectangularSelection(),
313 crosshairCursor(),
314 highlightSpecialChars(),
315 indentOnInput(),
316 syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
317 bracketMatching(),
318 foldGutter(),
319 keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
320 oneDark,
321 editorTheme,
322 updateListener,
323 ...langExtension,
324 EditorView.lineWrapping,
325 ],
326 });
327
328 var view = new EditorView({
329 state: state,
330 parent: wrapper,
331 });
332
333 // Hide the textarea now that CM is mounted
334 ta.style.display = 'none';
335 wrapper.style.display = 'block';
336
337 // Sync on form submit (belt-and-suspenders)
338 var form = ta.closest('form');
339 if (form) {
340 form.addEventListener('submit', function() {
341 ta.value = view.state.doc.toString();
342 });
343 }
344
345 } catch(e) {
346 // If CodeMirror fails to load for any reason, fall back to the textarea
347 var ta2 = document.getElementById(${textareaId});
348 if (ta2) ta2.style.display = '';
349 var w2 = document.getElementById(${wrapperId});
350 if (w2) w2.style.display = 'none';
351 }
352})();
353`;
354}
355
e26af57Claude356// ─── Scoped CSS (.editor-*) ─────────────────────────────────────────────────
357// Every selector is prefixed `.editor-*` so this surface can't bleed into
358// the repo header / nav above. Tokens reused from layout (--bg-elevated,
359// --border, --text-strong, --accent, --space-*, --font-*).
360
361const editorStyles = `
362 .editor-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
363
364 /* ─── Header strip (sits below RepoHeader + RepoNav) ─── */
365 .editor-head { margin-bottom: var(--space-5); }
366 .editor-eyebrow {
367 display: inline-flex;
368 align-items: center;
369 gap: 8px;
370 text-transform: uppercase;
371 font-family: var(--font-mono);
372 font-size: 11px;
373 letter-spacing: 0.16em;
374 color: var(--text-muted);
375 font-weight: 600;
376 margin-bottom: 10px;
377 }
378 .editor-eyebrow-dot {
379 width: 8px; height: 8px;
380 border-radius: 9999px;
381 background: linear-gradient(135deg, #8c6dff, #36c5d6);
382 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
383 }
384 .editor-title {
385 font-family: var(--font-display);
386 font-size: clamp(22px, 3vw, 32px);
387 font-weight: 800;
388 letter-spacing: -0.025em;
389 line-height: 1.15;
390 margin: 0 0 6px;
391 color: var(--text-strong);
392 }
393 .editor-title-grad {
394 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
395 -webkit-background-clip: text;
396 background-clip: text;
397 -webkit-text-fill-color: transparent;
398 color: transparent;
399 }
400 .editor-sub {
401 margin: 0;
402 font-size: 14px;
403 color: var(--text-muted);
404 line-height: 1.5;
405 max-width: 700px;
406 }
407
408 /* ─── Toolbar (path breadcrumb + branch pill) ─── */
409 .editor-toolbar {
410 display: flex;
411 align-items: center;
412 justify-content: space-between;
413 gap: var(--space-3);
414 flex-wrap: wrap;
415 margin-bottom: var(--space-3);
416 padding: 10px 14px;
417 background: var(--bg-elevated);
418 border: 1px solid var(--border);
419 border-radius: 12px;
420 }
421 .editor-path {
422 display: inline-flex;
423 align-items: center;
424 gap: 6px;
425 font-family: var(--font-mono);
426 font-size: 13px;
427 color: var(--text);
428 flex-wrap: wrap;
429 min-width: 0;
430 }
431 .editor-path-sep { color: var(--text-faint); }
432 .editor-path-seg {
433 color: var(--text-muted);
434 }
435 .editor-path-name {
436 color: var(--text-strong);
437 font-weight: 600;
438 }
439 .editor-branch-pill {
440 display: inline-flex;
441 align-items: center;
442 gap: 6px;
443 padding: 4px 10px;
444 border-radius: 999px;
445 font-family: var(--font-mono);
446 font-size: 12px;
447 color: #c4b5fd;
448 background: rgba(140,109,255,0.12);
449 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
450 white-space: nowrap;
451 font-weight: 600;
452 }
453 .editor-branch-pill svg { opacity: 0.9; }
454
455 /* ─── Form card ─── */
456 .editor-card {
457 background: var(--bg-elevated);
458 border: 1px solid var(--border);
459 border-radius: 14px;
460 overflow: hidden;
461 position: relative;
462 }
463 .editor-card::before {
464 content: '';
465 position: absolute;
466 top: 0; left: 0; right: 0;
467 height: 2px;
468 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
469 opacity: 0.55;
470 pointer-events: none;
471 }
472 .editor-body { padding: var(--space-4) var(--space-5); }
473
474 .editor-field { display: block; margin-bottom: var(--space-4); }
475 .editor-label {
476 display: block;
477 font-size: 11.5px;
478 color: var(--text-muted);
479 font-weight: 600;
480 text-transform: uppercase;
481 letter-spacing: 0.06em;
482 margin-bottom: 6px;
483 }
484 .editor-filename-row {
485 display: flex;
486 align-items: center;
487 gap: 6px;
488 font-family: var(--font-mono);
489 }
490 .editor-filename-dir {
491 font-size: 13px;
492 color: var(--text-muted);
493 }
494 .editor-input {
495 width: 100%;
496 box-sizing: border-box;
497 padding: 10px 12px;
498 font: inherit;
499 font-size: 14px;
500 color: var(--text);
501 background: rgba(255,255,255,0.03);
502 border: 1px solid var(--border-strong);
503 border-radius: 10px;
504 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
505 }
506 .editor-input:focus {
507 outline: none;
508 border-color: rgba(140,109,255,0.55);
509 background: rgba(255,255,255,0.05);
510 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
511 }
512 .editor-input-mono { font-family: var(--font-mono); }
513
514 .editor-textarea {
515 width: 100%;
516 box-sizing: border-box;
517 padding: 12px 14px;
518 font: inherit;
519 font-family: var(--font-mono);
520 font-size: 13px;
521 line-height: 1.55;
522 tab-size: 2;
523 color: var(--text);
524 background: var(--bg);
525 border: 1px solid var(--border-strong);
526 border-radius: 10px;
527 resize: vertical;
528 min-height: 280px;
529 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
530 }
531 .editor-textarea:focus {
532 outline: none;
533 border-color: rgba(140,109,255,0.55);
534 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
535 }
536
537 /* ─── Action row ─── */
538 .editor-actions {
539 display: flex;
540 align-items: center;
541 gap: 10px;
542 flex-wrap: wrap;
543 padding: var(--space-3) var(--space-5);
544 border-top: 1px solid var(--border);
545 background: rgba(255,255,255,0.012);
546 }
547 .editor-btn {
548 display: inline-flex;
549 align-items: center;
550 justify-content: center;
551 gap: 6px;
552 padding: 9px 16px;
553 border-radius: 10px;
554 font-size: 13px;
555 font-weight: 600;
556 text-decoration: none;
557 border: 1px solid transparent;
558 cursor: pointer;
559 font: inherit;
560 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
561 line-height: 1;
562 white-space: nowrap;
563 }
564 .editor-btn-primary {
565 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
566 color: #fff;
567 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.5), inset 0 1px 0 rgba(255,255,255,0.16);
568 }
569 .editor-btn-primary:hover {
570 transform: translateY(-1px);
571 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.6), inset 0 1px 0 rgba(255,255,255,0.20);
572 text-decoration: none;
573 color: #fff;
574 }
575 .editor-btn-ghost {
576 background: transparent;
577 color: var(--text);
578 border-color: var(--border-strong);
579 }
580 .editor-btn-ghost:hover {
581 background: rgba(140,109,255,0.06);
582 border-color: rgba(140,109,255,0.45);
583 color: var(--text-strong);
584 text-decoration: none;
585 }
586 .editor-btn-ai {
587 background: transparent;
588 color: #c4b5fd;
589 border-color: rgba(140,109,255,0.35);
590 }
591 .editor-btn-ai:hover {
592 border-color: rgba(140,109,255,0.65);
593 background: rgba(140,109,255,0.08);
594 color: #ddd6fe;
595 text-decoration: none;
596 }
597 .editor-btn-ai:disabled {
598 opacity: 0.55;
599 cursor: progress;
600 }
601 .editor-status {
602 color: var(--text-muted);
603 font-size: 12.5px;
604 margin-left: auto;
605 }
3646bfeClaude606
607 /* ─── CodeMirror wrapper ─── */
608 .editor-cm-wrapper {
609 display: none; /* shown by JS after mount */
610 border-radius: 10px;
611 overflow: hidden;
612 }
613 /* Ensure CM fills the field column */
614 .editor-field .cm-editor {
615 width: 100%;
616 box-sizing: border-box;
617 font-family: var(--font-mono);
618 font-size: 13px;
619 line-height: 1.55;
620 border-radius: 10px;
621 min-height: 280px;
622 }
623 /* Override oneDark background to match the app's dark bg variable */
624 .editor-field .cm-editor .cm-scroller {
625 font-family: var(--font-mono);
626 }
e26af57Claude627`;
628
629function IconBranch() {
630 return (
631 <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">
632 <line x1="6" y1="3" x2="6" y2="15" />
633 <circle cx="18" cy="6" r="3" />
634 <circle cx="6" cy="18" r="3" />
635 <path d="M18 9a9 9 0 0 1-9 9" />
636 </svg>
637 );
638}
639
0074234Claude640// New file form
04f6b7fClaude641editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude642 const { owner, repo } = c.req.param();
643 const user = c.get("user")!;
644 const refAndPath = c.req.param("ref");
645
646 // Parse ref — use first segment
647 const slashIdx = refAndPath.indexOf("/");
648 const ref = slashIdx === -1 ? refAndPath : refAndPath.slice(0, slashIdx);
649 const dirPath = slashIdx === -1 ? "" : refAndPath.slice(slashIdx + 1);
650
651 return c.html(
652 <Layout title={`New file — ${owner}/${repo}`} user={user}>
653 <RepoHeader owner={owner} repo={repo} />
654 <RepoNav owner={owner} repo={repo} active="code" />
e26af57Claude655 <div class="editor-wrap">
656 <header class="editor-head">
657 <div class="editor-eyebrow">
658 <span class="editor-eyebrow-dot" aria-hidden="true" />
659 Editor · New file
660 </div>
661 <h1 class="editor-title">
662 <span class="editor-title-grad">Create a new file.</span>
663 </h1>
664 <p class="editor-sub">
665 Pick a path, paste your content, and write a short commit
666 message. The commit lands directly on{" "}
667 <code style="font-size:12.5px">{ref}</code>.
668 </p>
669 </header>
670
671 <div class="editor-toolbar">
672 <div class="editor-path" title={dirPath ? `${dirPath}/…` : "Repository root"}>
673 <span class="editor-path-seg">{owner}/{repo}</span>
674 {dirPath && (
675 <>
676 <span class="editor-path-sep">/</span>
677 <span class="editor-path-seg">{dirPath}</span>
678 </>
679 )}
680 <span class="editor-path-sep">/</span>
681 <span class="editor-path-name">new file…</span>
682 </div>
683 <span class="editor-branch-pill" title="Target branch">
684 <IconBranch />
685 {ref}
686 </span>
687 </div>
688
689 <form
690 method="post"
691 action={`/${owner}/${repo}/new/${ref}`}
692 class="editor-card"
693 >
694 <div class="editor-body">
695 <input type="hidden" name="dir_path" value={dirPath} />
696 <div class="editor-field">
697 <label class="editor-label" for="editor-filename">File path</label>
698 <div class="editor-filename-row">
699 {dirPath && (
700 <span class="editor-filename-dir">{dirPath}/</span>
701 )}
702 <input
703 class="editor-input editor-input-mono"
704 id="editor-filename"
705 name="filename"
706 required
707 placeholder="filename.ts"
708 autocomplete="off"
709 aria-label="File path"
710 />
711 </div>
712 </div>
713 <div class="editor-field">
714 <label class="editor-label" for="editor-content-new">Content</label>
3646bfeClaude715 {/* CodeMirror mounts here; falls back to textarea if JS/CDN unavailable */}
716 <div id="editor-cm-new" class="editor-cm-wrapper" />
e26af57Claude717 <textarea
718 class="editor-textarea"
719 id="editor-content-new"
720 name="content"
721 rows={20}
722 placeholder="Enter file content…"
723 spellcheck={false}
3646bfeClaude724 data-lang="plaintext"
e26af57Claude725 />
726 </div>
727 <div class="editor-field" style="margin-bottom:0">
728 <label class="editor-label" for="commit-message-input">Commit message</label>
729 <input
730 class="editor-input"
731 id="commit-message-input"
732 name="message"
733 placeholder="Create new file"
0074234Claude734 required
e26af57Claude735 aria-label="Commit message"
0074234Claude736 />
e26af57Claude737 </div>
738 </div>
739 <div class="editor-actions">
740 <button type="submit" class="editor-btn editor-btn-primary">
741 Commit new file
742 </button>
743 <a href={`/${owner}/${repo}`} class="editor-btn editor-btn-ghost">
744 Cancel
745 </a>
746 </div>
3646bfeClaude747 <script
748 type="module"
749 dangerouslySetInnerHTML={{
750 __html: CODEMIRROR_INIT_SCRIPT({
751 textareaId: "editor-content-new",
752 wrapperId: "editor-cm-new",
753 lang: "plaintext",
754 }),
755 }}
756 />
e26af57Claude757 </form>
758 </div>
759 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
0074234Claude760 </Layout>
761 );
762});
763
764// Create file via commit
04f6b7fClaude765editor.post("/:owner/:repo/new/:ref", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude766 const { owner, repo } = c.req.param();
767 const user = c.get("user")!;
768 const ref = c.req.param("ref");
769 const body = await c.req.parseBody();
770 const dirPath = String(body.dir_path || "").trim();
771 const filename = String(body.filename || "").trim();
772 const content = String(body.content || "");
773 const message = String(body.message || `Create ${filename}`).trim();
774
775 if (!filename) return c.redirect(`/${owner}/${repo}`);
776
777 const fullPath = dirPath ? `${dirPath}/${filename}` : filename;
778
779 // Use git hash-object + update-index + write-tree + commit-tree
780 const repoDir = getRepoPath(owner, repo);
781
782 const run = async (cmd: string[], cwd: string, stdin?: string) => {
783 const proc = Bun.spawn(cmd, {
784 cwd,
785 stdout: "pipe",
786 stderr: "pipe",
787 stdin: stdin !== undefined ? "pipe" : undefined,
788 });
789 if (stdin !== undefined && proc.stdin) {
790 proc.stdin.write(new TextEncoder().encode(stdin));
791 proc.stdin.end();
792 }
793 const stdout = await new Response(proc.stdout).text();
794 await proc.exited;
795 return stdout.trim();
796 };
797
798 // Hash the new file content
799 const blobSha = await run(
800 ["git", "hash-object", "-w", "--stdin"],
801 repoDir,
802 content
803 );
804
805 // Read current tree
806 const currentTreeSha = await run(
807 ["git", "rev-parse", `${ref}^{tree}`],
808 repoDir
809 );
810
811 // Read current tree and add new entry
812 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
813 const entries = treeContent
814 .split("\n")
815 .filter(Boolean)
816 .map((line) => line + "\n")
817 .join("");
818 const newEntry = `100644 blob ${blobSha}\t${fullPath}\n`;
819
820 const newTreeSha = await run(
821 ["git", "mktree"],
822 repoDir,
823 entries + newEntry
824 );
825
826 // Get parent commit
827 const parentSha = await run(
828 ["git", "rev-parse", ref],
829 repoDir
830 );
831
832 // Create commit
833 const env = {
834 GIT_AUTHOR_NAME: user.displayName || user.username,
835 GIT_AUTHOR_EMAIL: user.email,
836 GIT_COMMITTER_NAME: user.displayName || user.username,
837 GIT_COMMITTER_EMAIL: user.email,
838 };
839
840 const commitProc = Bun.spawn(
841 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
842 {
843 cwd: repoDir,
844 stdout: "pipe",
845 stderr: "pipe",
846 env: { ...process.env, ...env },
847 }
848 );
849 const commitSha = (await new Response(commitProc.stdout).text()).trim();
850 await commitProc.exited;
851
852 // Update branch ref
853 await run(
854 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
855 repoDir
856 );
857
858 return c.redirect(`/${owner}/${repo}/blob/${ref}/${fullPath}`);
859});
860
861// Edit file form
04f6b7fClaude862editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude863 const { owner, repo } = c.req.param();
864 const user = c.get("user")!;
865 const refAndPath = c.req.param("ref");
866
867 // Parse ref/path
868 const slashIdx = refAndPath.indexOf("/");
869 if (slashIdx === -1) return c.text("Not found", 404);
870 const ref = refAndPath.slice(0, slashIdx);
871 const filePath = refAndPath.slice(slashIdx + 1);
872
873 const blob = await getBlob(owner, repo, ref, filePath);
874 if (!blob || blob.isBinary) {
875 return c.html(
876 <Layout title="Cannot edit" user={user}>
bb0f894Claude877 <EmptyState title={blob?.isBinary ? "Cannot edit binary file" : "File not found"} />
0074234Claude878 </Layout>,
879 404
880 );
881 }
882
e26af57Claude883 const fileName = filePath.split("/").pop() || filePath;
884 const dirParts = filePath.split("/").slice(0, -1);
885
0074234Claude886 return c.html(
887 <Layout title={`Editing ${filePath} — ${owner}/${repo}`} user={user}>
888 <RepoHeader owner={owner} repo={repo} />
889 <RepoNav owner={owner} repo={repo} active="code" />
e26af57Claude890 <div class="editor-wrap">
891 <header class="editor-head">
892 <div class="editor-eyebrow">
893 <span class="editor-eyebrow-dot" aria-hidden="true" />
894 Editor · Edit file
895 </div>
896 <h1 class="editor-title">
897 <span class="editor-title-grad">Edit{" "}</span>
898 <code style="font-family:var(--font-mono);font-size:0.82em;color:var(--text-strong);font-weight:700">{fileName}</code>
899 </h1>
900 <p class="editor-sub">
901 Save your changes as a commit on{" "}
902 <code style="font-size:12.5px">{ref}</code>. Write a clear
903 one-line message so reviewers can follow the history.
904 </p>
905 </header>
906
907 <div class="editor-toolbar">
908 <div class="editor-path" title={filePath}>
909 <a
910 href={`/${owner}/${repo}`}
911 class="editor-path-seg"
912 style="color:var(--text-muted);text-decoration:none"
913 >
914 {owner}/{repo}
915 </a>
916 {dirParts.map((seg, i) => {
917 const subPath = dirParts.slice(0, i + 1).join("/");
918 return (
919 <>
920 <span class="editor-path-sep">/</span>
921 <a
922 href={`/${owner}/${repo}/tree/${ref}/${subPath}`}
923 class="editor-path-seg"
924 style="text-decoration:none"
925 >
926 {seg}
927 </a>
928 </>
929 );
930 })}
931 <span class="editor-path-sep">/</span>
932 <span class="editor-path-name">{fileName}</span>
933 </div>
934 <span class="editor-branch-pill" title="Target branch">
935 <IconBranch />
936 {ref}
937 </span>
938 </div>
939
940 <form
941 method="post"
942 action={`/${owner}/${repo}/edit/${ref}/${filePath}`}
943 class="editor-card"
944 >
945 <div class="editor-body">
946 <div class="editor-field">
947 <label class="editor-label" for="editor-content-edit">Content</label>
3646bfeClaude948 {/* CodeMirror mounts here; falls back to textarea if JS/CDN unavailable */}
949 <div id="editor-cm-edit" class="editor-cm-wrapper" />
e26af57Claude950 <textarea
951 class="editor-textarea"
952 id="editor-content-edit"
953 name="content"
954 rows={25}
955 spellcheck={false}
3646bfeClaude956 data-lang={detectEditorLang(filePath)}
e26af57Claude957 >{blob.content}</textarea>
958 </div>
959 <div class="editor-field" style="margin-bottom:0">
960 <label class="editor-label" for="commit-message-input">Commit message</label>
961 <input
962 class="editor-input"
963 id="commit-message-input"
964 name="message"
965 placeholder={`Update ${fileName}`}
966 required
967 aria-label="Commit message"
968 />
969 </div>
970 </div>
971 <div class="editor-actions">
972 <button type="submit" class="editor-btn editor-btn-primary">
0074234Claude973 Commit changes
e26af57Claude974 </button>
c81b57cClaude975 <button
976 type="button"
977 id="ai-commit-msg-btn"
e26af57Claude978 class="editor-btn editor-btn-ai"
c81b57cClaude979 title="Generate a one-line commit message using Claude based on the diff"
980 >
e26af57Claude981 {"✨"} Suggest with AI
c81b57cClaude982 </button>
e26af57Claude983 <a
984 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
985 class="editor-btn editor-btn-ghost"
986 >
0074234Claude987 Cancel
e26af57Claude988 </a>
989 <span id="ai-commit-msg-status" class="editor-status" />
990 </div>
c81b57cClaude991 <script
992 dangerouslySetInnerHTML={{
993 __html: AI_COMMIT_MSG_SCRIPT({
994 endpoint: `/${owner}/${repo}/ai/commit-message`,
995 ref,
996 filePath,
997 }),
998 }}
999 />
3646bfeClaude1000 <script
1001 type="module"
1002 dangerouslySetInnerHTML={{
1003 __html: CODEMIRROR_INIT_SCRIPT({
1004 textareaId: "editor-content-edit",
1005 wrapperId: "editor-cm-edit",
1006 lang: detectEditorLang(filePath),
1007 }),
1008 }}
1009 />
e26af57Claude1010 </form>
1011 </div>
1012 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
0074234Claude1013 </Layout>
1014 );
1015});
1016
c81b57cClaude1017// AI-suggested commit message — JSON endpoint driven by the editor button.
1018// Reads the on-disk blob at (ref, filePath), diffs against the submitted
1019// new content, and asks generateCommitMessage() for a one-liner. Returns
1020// {ok:true, message} on success, {ok:false, error} otherwise. Always 200.
1021editor.post(
1022 "/:owner/:repo/ai/commit-message",
1023 requireAuth,
1024 requireRepoAccess("write"),
1025 async (c) => {
1026 const { owner, repo } = c.req.param();
1027 if (!isAiAvailable()) {
1028 return c.json({
1029 ok: false,
1030 error: "AI is not available — set ANTHROPIC_API_KEY.",
1031 });
1032 }
1033 const body = await c.req.parseBody();
1034 const ref = String(body.ref || "").trim();
1035 const filePath = String(body.filePath || "").trim();
1036 const newContent = String(body.content || "");
1037 if (!ref || !filePath) {
1038 return c.json({ ok: false, error: "ref + filePath required" });
1039 }
1040
1041 let oldContent = "";
1042 try {
1043 const blob = await getBlob(owner, repo, ref, filePath);
1044 oldContent = blob?.content || "";
1045 } catch {
1046 oldContent = "";
1047 }
1048
1049 if (oldContent === newContent) {
1050 return c.json({
1051 ok: false,
1052 error: "No changes to summarise.",
1053 });
1054 }
1055
1056 // Build a minimal unified-diff-ish summary the AI helper can consume.
1057 // generateCommitMessage was written for git diff text; we feed a
1058 // header + truncated old/new sample so it has shape to summarise.
1059 const truncate = (s: string) => (s.length > 4000 ? s.slice(0, 4000) + "\n…(truncated)" : s);
1060 const diff =
1061 `--- a/${filePath}\n+++ b/${filePath}\n` +
1062 "## Old:\n" +
1063 truncate(oldContent) +
1064 "\n\n## New:\n" +
1065 truncate(newContent);
1066
1067 let message = "";
1068 try {
1069 message = await generateCommitMessage(diff);
1070 } catch (err) {
1071 const msg = err instanceof Error ? err.message : "AI request failed.";
1072 return c.json({ ok: false, error: msg });
1073 }
1074 if (!message.trim()) {
1075 return c.json({
1076 ok: false,
1077 error: "AI returned an empty draft.",
1078 });
1079 }
1080 // Cap to one line + 100 chars (commit-message convention).
1081 const oneLine = message.split("\n")[0]!.trim();
1082 const capped = oneLine.length > 100 ? oneLine.slice(0, 97) + "..." : oneLine;
1083 return c.json({ ok: true, message: capped });
1084 }
1085);
1086
0074234Claude1087// Save edited file
04f6b7fClaude1088editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude1089 const { owner, repo } = c.req.param();
1090 const user = c.get("user")!;
1091 const refAndPath = c.req.param("ref");
1092
1093 const slashIdx = refAndPath.indexOf("/");
1094 if (slashIdx === -1) return c.redirect(`/${owner}/${repo}`);
1095 const ref = refAndPath.slice(0, slashIdx);
1096 const filePath = refAndPath.slice(slashIdx + 1);
1097
1098 const body = await c.req.parseBody();
1099 const content = String(body.content || "");
1100 const message = String(
1101 body.message || `Update ${filePath.split("/").pop()}`
1102 ).trim();
1103
1104 const repoDir = getRepoPath(owner, repo);
1105
1106 const run = async (cmd: string[], cwd: string, stdin?: string) => {
1107 const proc = Bun.spawn(cmd, {
1108 cwd,
1109 stdout: "pipe",
1110 stderr: "pipe",
1111 stdin: stdin !== undefined ? "pipe" : undefined,
1112 });
1113 if (stdin !== undefined && proc.stdin) {
1114 proc.stdin.write(new TextEncoder().encode(stdin));
1115 proc.stdin.end();
1116 }
1117 const stdout = await new Response(proc.stdout).text();
1118 await proc.exited;
1119 return stdout.trim();
1120 };
1121
1122 // Hash new content
1123 const blobSha = await run(
1124 ["git", "hash-object", "-w", "--stdin"],
1125 repoDir,
1126 content
1127 );
1128
1129 // Read current tree, replace the file
1130 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
1131 const lines = treeContent.split("\n").filter(Boolean);
1132 const updated = lines
1133 .map((line) => {
1134 const parts = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
1135 if (parts && parts[4] === filePath) {
1136 return `${parts[1]} blob ${blobSha}\t${parts[4]}`;
1137 }
1138 return line;
1139 })
1140 .join("\n") + "\n";
1141
1142 const newTreeSha = await run(["git", "mktree"], repoDir, updated);
1143 const parentSha = await run(["git", "rev-parse", ref], repoDir);
1144
1145 const env = {
1146 GIT_AUTHOR_NAME: user.displayName || user.username,
1147 GIT_AUTHOR_EMAIL: user.email,
1148 GIT_COMMITTER_NAME: user.displayName || user.username,
1149 GIT_COMMITTER_EMAIL: user.email,
1150 };
1151
1152 const commitProc = Bun.spawn(
1153 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1154 {
1155 cwd: repoDir,
1156 stdout: "pipe",
1157 stderr: "pipe",
1158 env: { ...process.env, ...env },
1159 }
1160 );
1161 const commitSha = (await new Response(commitProc.stdout).text()).trim();
1162 await commitProc.exited;
1163
1164 await run(
1165 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
1166 repoDir
1167 );
1168
1169 return c.redirect(`/${owner}/${repo}/blob/${ref}/${filePath}`);
1170});
1171
1172export default editor;