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