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.tsxBlame1740 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',
6fd5915Claude274 caretColor: '#5b6ee8',
3646bfeClaude275 },
276 '.cm-focused': {
277 outline: 'none',
278 },
279 '&.cm-focused': {
6fd5915Claude280 borderColor: 'rgba(91,110,232,0.55)',
281 boxShadow: '0 0 0 3px rgba(91,110,232,0.18)',
3646bfeClaude282 },
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': {
6fd5915Claude290 background: 'rgba(91,110,232,0.08)',
3646bfeClaude291 },
292 '.cm-activeLine': {
6fd5915Claude293 background: 'rgba(91,110,232,0.06)',
3646bfeClaude294 },
295 '.cm-scroller': {
296 overflow: 'auto',
297 minHeight: '280px',
298 maxHeight: '70vh',
299 },
300 '.cm-cursor': {
6fd5915Claude301 borderLeftColor: '#5b6ee8',
3646bfeClaude302 },
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
6f2809fClaude358/**
359 * AI inline suggestion + explain + fix script.
360 *
361 * Ghost-text suggestions: debounced 800ms after typing stops, sends
362 * the content before the cursor to /api/ai/suggest, shows the result
363 * as dimmed italic text after the cursor inside the textarea.
364 * Tab = accept, Escape = dismiss.
365 *
366 * Explain: sends selected text (or full content) to /api/ai/explain
367 * and shows a panel below the editor.
368 *
369 * Fix: sends error from ?error= query param + code to /api/ai/fix and
370 * shows a panel with an Apply button.
371 *
372 * Only wires up if window.__aiEditorEnabled === true (set by the server
373 * when ANTHROPIC_API_KEY is configured).
374 */
375function AI_EDITOR_SCRIPT(args: {
376 lang: string;
377 gateError: string; // JSON-safe error string, may be empty
378}): string {
379 const safe = (v: string) =>
380 JSON.stringify(v)
381 .split("<").join("\\u003C")
382 .split(">").join("\\u003E")
383 .split("&").join("\\u0026");
384
385 const lang = safe(args.lang);
386 const gateError = safe(args.gateError);
387
388 return `
389(function() {
390 try {
391 if (!window.__aiEditorEnabled) return;
392
393 var lang = ${lang};
394 var gateError = ${gateError};
395
396 // ── Find DOM elements ──────────────────────────────────────────────
397 // Works for both new-file (...-new) and edit (...-edit) pages.
398 var ta = document.querySelector('textarea[name="content"]');
399 if (!ta) return;
400
401 var taField = ta.closest('.editor-field');
402 if (!taField) return;
403
404 // Ensure the textarea is wrapped in .editor-wrapper for positioning
405 if (!ta.parentElement || !ta.parentElement.classList.contains('editor-wrapper')) {
406 var wrapper = document.createElement('div');
407 wrapper.className = 'editor-wrapper';
408 wrapper.style.position = 'relative';
409 ta.parentNode.insertBefore(wrapper, ta);
410 wrapper.appendChild(ta);
411 }
412
413 var editorWrapper = ta.parentElement;
414
415 // Loading spinner
416 var spinner = document.createElement('div');
417 spinner.className = 'ai-loading';
418 spinner.innerHTML = '<div class="ai-spinner"></div><span>AI</span>';
419 editorWrapper.appendChild(spinner);
420
421 // Suggestion hint line
422 var hint = document.createElement('div');
423 hint.className = 'ai-suggestion-hint';
424 hint.textContent = 'Tab to accept suggestion · Esc to dismiss';
425 taField.appendChild(hint);
426
427 // Explain panel
428 var explainPanel = document.createElement('div');
429 explainPanel.className = 'ai-explain-panel';
430 explainPanel.innerHTML = '<div class="ai-explain-panel-header"><span class="ai-explain-panel-title">✨ Code Explanation</span><button class="ai-explain-panel-close" type="button" title="Close">✕</button></div><div class="ai-explain-panel-body"></div>';
431 taField.appendChild(explainPanel);
432
433 // Fix panel (only when there's a gate error)
434 var fixPanel = null;
435 var fixCode = '';
436 if (gateError) {
437 fixPanel = document.createElement('div');
438 fixPanel.className = 'ai-fix-panel';
439 fixPanel.innerHTML = '<div class="ai-fix-panel-header"><span class="ai-fix-panel-title">⚡ AI Fix Suggestion</span></div><div class="ai-fix-panel-explanation"></div><button class="ai-fix-panel-apply" type="button">Apply fix</button>';
440 taField.appendChild(fixPanel);
441 }
442
443 // ── State ──────────────────────────────────────────────────────────
444 var currentSuggestion = '';
445 var suggestionPos = 0; // cursor position when suggestion was fetched
446 var debounceTimer = null;
447 var abortController = null;
448
449 // ── Ghost text rendering via textarea mirror technique ─────────────
450 // We render ghost text as a ::after-like element via a positioned div
451 // that mirrors the textarea's scroll position and font metrics.
452 // This is simpler than a full overlay mirror.
453 var ghost = document.createElement('div');
454 ghost.style.cssText = [
455 'position:absolute',
456 'left:0','top:0','right:0','bottom:0',
457 'pointer-events:none',
458 'overflow:hidden',
459 'white-space:pre-wrap',
460 'word-wrap:break-word',
461 'box-sizing:border-box',
462 'padding:12px 14px',
463 'font-family:var(--font-mono)',
464 'font-size:13px',
465 'line-height:1.55',
466 'color:transparent',
467 'z-index:2',
468 ].join(';');
469 editorWrapper.appendChild(ghost);
470
471 function renderGhost() {
472 if (!currentSuggestion) {
473 ghost.innerHTML = '';
474 hint.classList.remove('visible');
475 return;
476 }
477 var before = ta.value.slice(0, suggestionPos);
478 // Escape HTML
479 var esc = function(s) {
480 return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
481 };
482 ghost.innerHTML = esc(before) +
483 '<span style="color:#666;font-style:italic">' + esc(currentSuggestion) + '</span>';
484 hint.classList.add('visible');
485 }
486
487 function clearSuggestion() {
488 currentSuggestion = '';
489 suggestionPos = 0;
490 renderGhost();
491 }
492
493 // ── Sync ghost scroll with textarea ───────────────────────────────
494 ta.addEventListener('scroll', function() {
495 ghost.scrollTop = ta.scrollTop;
496 ghost.scrollLeft = ta.scrollLeft;
497 });
498
499 // ── Fetch suggestion ──────────────────────────────────────────────
500 function fetchSuggestion() {
501 if (abortController) abortController.abort();
502 abortController = new AbortController();
503
504 var cursor = ta.selectionStart;
505 var code = ta.value;
506 var before = code.slice(0, cursor);
507
508 // Skip if the before-cursor content is too short or ends in whitespace-only
509 if (before.trim().length < 3) return;
510
511 spinner.classList.add('visible');
512
513 fetch('/api/ai/suggest', {
514 method: 'POST',
515 headers: { 'content-type': 'application/json' },
516 credentials: 'same-origin',
517 signal: abortController.signal,
518 body: JSON.stringify({ code: before, language: lang, cursor: before.length }),
519 })
520 .then(function(r) { return r.json(); })
521 .then(function(j) {
522 spinner.classList.remove('visible');
523 if (j && typeof j.suggestion === 'string' && j.suggestion.trim()) {
524 currentSuggestion = j.suggestion;
525 suggestionPos = ta.selectionStart;
526 renderGhost();
527 }
528 if (j && j.error && j.error.includes('quota')) {
529 // Show quota message briefly in hint
530 hint.textContent = j.error;
531 hint.classList.add('visible');
532 setTimeout(function() {
533 hint.textContent = 'Tab to accept suggestion · Esc to dismiss';
534 hint.classList.remove('visible');
535 }, 4000);
536 }
537 })
538 .catch(function(e) {
539 spinner.classList.remove('visible');
540 if (e && e.name !== 'AbortError') {
541 console.debug('[ai-editor] suggest error', e);
542 }
543 });
544 }
545
546 // ── Debounce typing ───────────────────────────────────────────────
547 ta.addEventListener('input', function() {
548 clearSuggestion();
549 if (debounceTimer) clearTimeout(debounceTimer);
550 debounceTimer = setTimeout(fetchSuggestion, 800);
551 });
552
553 // ── Key handling: Tab = accept, Escape = dismiss ──────────────────
554 ta.addEventListener('keydown', function(ev) {
555 if (currentSuggestion) {
556 if (ev.key === 'Tab') {
557 ev.preventDefault();
558 // Insert suggestion at cursor
559 var start = ta.selectionStart;
560 var end = ta.selectionEnd;
561 var before = ta.value.slice(0, start);
562 var after = ta.value.slice(end);
563 ta.value = before + currentSuggestion + after;
564 var newPos = start + currentSuggestion.length;
565 ta.selectionStart = ta.selectionEnd = newPos;
566 // Trigger input event so the form stays in sync
567 ta.dispatchEvent(new Event('input', { bubbles: true }));
568 clearSuggestion();
569 return;
570 }
571 if (ev.key === 'Escape') {
572 ev.preventDefault();
573 clearSuggestion();
574 return;
575 }
576 // Any other key: discard suggestion
577 clearSuggestion();
578 }
579 });
580
581 // Dismiss on click (cursor moved)
582 ta.addEventListener('click', clearSuggestion);
583
584 // ── Explain panel ──────────────────────────────────────────────────
585 var explainBody = explainPanel.querySelector('.ai-explain-panel-body');
586 var explainClose = explainPanel.querySelector('.ai-explain-panel-close');
587 if (explainClose) {
588 explainClose.addEventListener('click', function() {
589 explainPanel.classList.remove('visible');
590 });
591 }
592
593 var explainBtn = document.getElementById('ai-explain-btn');
594 if (explainBtn) {
595 explainBtn.addEventListener('click', function(ev) {
596 ev.preventDefault();
597 var selected = ta.value.slice(ta.selectionStart, ta.selectionEnd);
598 var code = selected.trim() || ta.value;
599 if (!code.trim()) return;
600 explainBtn.disabled = true;
601 explainBody.textContent = 'Asking Claude…';
602 explainPanel.classList.add('visible');
603 fetch('/api/ai/explain', {
604 method: 'POST',
605 headers: { 'content-type': 'application/json' },
606 credentials: 'same-origin',
607 body: JSON.stringify({ code: code.slice(0, 4000), language: lang }),
608 })
609 .then(function(r) { return r.json(); })
610 .then(function(j) {
611 explainBtn.disabled = false;
612 if (j && j.explanation) {
613 explainBody.textContent = j.explanation;
614 } else {
615 explainBody.textContent = (j && j.error) || 'No explanation returned.';
616 }
617 })
618 .catch(function() {
619 explainBtn.disabled = false;
620 explainBody.textContent = 'Network error — please try again.';
621 });
622 });
623 }
624
625 // ── Fix panel ─────────────────────────────────────────────────────
626 if (fixPanel && gateError) {
627 var fixBtn = document.getElementById('ai-fix-btn');
628 var fixExplanation = fixPanel.querySelector('.ai-fix-panel-explanation');
629 var fixApply = fixPanel.querySelector('.ai-fix-panel-apply');
630
631 if (fixBtn) {
632 fixBtn.addEventListener('click', function(ev) {
633 ev.preventDefault();
634 fixBtn.disabled = true;
635 fixExplanation.textContent = 'Analyzing error and generating fix…';
636 fixPanel.classList.add('visible');
637 fetch('/api/ai/fix', {
638 method: 'POST',
639 headers: { 'content-type': 'application/json' },
640 credentials: 'same-origin',
641 body: JSON.stringify({ code: ta.value.slice(0, 4000), error: gateError, language: lang }),
642 })
643 .then(function(r) { return r.json(); })
644 .then(function(j) {
645 fixBtn.disabled = false;
646 if (j && j.fix) {
647 fixCode = j.fix;
648 fixExplanation.textContent = j.explanation || 'Fix ready.';
649 fixApply.style.display = 'inline-flex';
650 } else {
651 fixExplanation.textContent = (j && j.error) || 'Could not generate fix.';
652 fixApply.style.display = 'none';
653 }
654 })
655 .catch(function() {
656 fixBtn.disabled = false;
657 fixExplanation.textContent = 'Network error — please try again.';
658 });
659 });
660
661 // Auto-trigger if gate error is present and editor just loaded
662 setTimeout(function() { fixBtn.click(); }, 600);
663 }
664
665 if (fixApply) {
666 fixApply.addEventListener('click', function() {
667 if (!fixCode) return;
668 if (confirm('Apply the AI fix? This will replace the current editor content.')) {
669 ta.value = fixCode;
670 ta.dispatchEvent(new Event('input', { bubbles: true }));
671 fixPanel.classList.remove('visible');
672 }
673 });
674 }
675 }
676
677 } catch(e) {
678 console.debug('[ai-editor] init error', e);
679 }
680})();
681`;
682}
683
e26af57Claude684// ─── Scoped CSS (.editor-*) ─────────────────────────────────────────────────
685// Every selector is prefixed `.editor-*` so this surface can't bleed into
686// the repo header / nav above. Tokens reused from layout (--bg-elevated,
687// --border, --text-strong, --accent, --space-*, --font-*).
688
689const editorStyles = `
eed4684Claude690 .editor-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
e26af57Claude691
692 /* ─── Header strip (sits below RepoHeader + RepoNav) ─── */
693 .editor-head { margin-bottom: var(--space-5); }
694 .editor-eyebrow {
695 display: inline-flex;
696 align-items: center;
697 gap: 8px;
698 text-transform: uppercase;
699 font-family: var(--font-mono);
700 font-size: 11px;
701 letter-spacing: 0.16em;
702 color: var(--text-muted);
703 font-weight: 600;
704 margin-bottom: 10px;
705 }
706 .editor-eyebrow-dot {
707 width: 8px; height: 8px;
708 border-radius: 9999px;
6fd5915Claude709 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
710 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
e26af57Claude711 }
712 .editor-title {
713 font-family: var(--font-display);
714 font-size: clamp(22px, 3vw, 32px);
715 font-weight: 800;
716 letter-spacing: -0.025em;
717 line-height: 1.15;
718 margin: 0 0 6px;
719 color: var(--text-strong);
720 }
721 .editor-title-grad {
6fd5915Claude722 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
e26af57Claude723 -webkit-background-clip: text;
724 background-clip: text;
725 -webkit-text-fill-color: transparent;
726 color: transparent;
727 }
728 .editor-sub {
729 margin: 0;
730 font-size: 14px;
731 color: var(--text-muted);
732 line-height: 1.5;
733 max-width: 700px;
734 }
735
736 /* ─── Toolbar (path breadcrumb + branch pill) ─── */
737 .editor-toolbar {
738 display: flex;
739 align-items: center;
740 justify-content: space-between;
741 gap: var(--space-3);
742 flex-wrap: wrap;
743 margin-bottom: var(--space-3);
744 padding: 10px 14px;
745 background: var(--bg-elevated);
746 border: 1px solid var(--border);
747 border-radius: 12px;
748 }
749 .editor-path {
750 display: inline-flex;
751 align-items: center;
752 gap: 6px;
753 font-family: var(--font-mono);
754 font-size: 13px;
755 color: var(--text);
756 flex-wrap: wrap;
757 min-width: 0;
758 }
759 .editor-path-sep { color: var(--text-faint); }
760 .editor-path-seg {
761 color: var(--text-muted);
762 }
763 .editor-path-name {
764 color: var(--text-strong);
765 font-weight: 600;
766 }
767 .editor-branch-pill {
768 display: inline-flex;
769 align-items: center;
770 gap: 6px;
771 padding: 4px 10px;
772 border-radius: 999px;
773 font-family: var(--font-mono);
774 font-size: 12px;
775 color: #c4b5fd;
6fd5915Claude776 background: rgba(91,110,232,0.12);
777 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
e26af57Claude778 white-space: nowrap;
779 font-weight: 600;
780 }
781 .editor-branch-pill svg { opacity: 0.9; }
782
783 /* ─── Form card ─── */
784 .editor-card {
785 background: var(--bg-elevated);
786 border: 1px solid var(--border);
787 border-radius: 14px;
788 overflow: hidden;
789 position: relative;
790 }
791 .editor-card::before {
792 content: '';
793 position: absolute;
794 top: 0; left: 0; right: 0;
795 height: 2px;
6fd5915Claude796 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
e26af57Claude797 opacity: 0.55;
798 pointer-events: none;
799 }
800 .editor-body { padding: var(--space-4) var(--space-5); }
801
802 .editor-field { display: block; margin-bottom: var(--space-4); }
803 .editor-label {
804 display: block;
805 font-size: 11.5px;
806 color: var(--text-muted);
807 font-weight: 600;
808 text-transform: uppercase;
809 letter-spacing: 0.06em;
810 margin-bottom: 6px;
811 }
812 .editor-filename-row {
813 display: flex;
814 align-items: center;
815 gap: 6px;
816 font-family: var(--font-mono);
817 }
818 .editor-filename-dir {
819 font-size: 13px;
820 color: var(--text-muted);
821 }
822 .editor-input {
823 width: 100%;
824 box-sizing: border-box;
825 padding: 10px 12px;
826 font: inherit;
827 font-size: 14px;
828 color: var(--text);
829 background: rgba(255,255,255,0.03);
830 border: 1px solid var(--border-strong);
831 border-radius: 10px;
832 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
833 }
834 .editor-input:focus {
835 outline: none;
6fd5915Claude836 border-color: rgba(91,110,232,0.55);
e26af57Claude837 background: rgba(255,255,255,0.05);
6fd5915Claude838 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
e26af57Claude839 }
840 .editor-input-mono { font-family: var(--font-mono); }
841
842 .editor-textarea {
843 width: 100%;
844 box-sizing: border-box;
845 padding: 12px 14px;
846 font: inherit;
847 font-family: var(--font-mono);
848 font-size: 13px;
849 line-height: 1.55;
850 tab-size: 2;
851 color: var(--text);
852 background: var(--bg);
853 border: 1px solid var(--border-strong);
854 border-radius: 10px;
855 resize: vertical;
856 min-height: 280px;
857 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
858 }
859 .editor-textarea:focus {
860 outline: none;
6fd5915Claude861 border-color: rgba(91,110,232,0.55);
862 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
e26af57Claude863 }
864
865 /* ─── Action row ─── */
866 .editor-actions {
867 display: flex;
868 align-items: center;
869 gap: 10px;
870 flex-wrap: wrap;
871 padding: var(--space-3) var(--space-5);
872 border-top: 1px solid var(--border);
873 background: rgba(255,255,255,0.012);
874 }
875 .editor-btn {
876 display: inline-flex;
877 align-items: center;
878 justify-content: center;
879 gap: 6px;
880 padding: 9px 16px;
881 border-radius: 10px;
882 font-size: 13px;
883 font-weight: 600;
884 text-decoration: none;
885 border: 1px solid transparent;
886 cursor: pointer;
887 font: inherit;
888 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
889 line-height: 1;
890 white-space: nowrap;
891 }
892 .editor-btn-primary {
6fd5915Claude893 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
e26af57Claude894 color: #fff;
6fd5915Claude895 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.5), inset 0 1px 0 rgba(255,255,255,0.16);
e26af57Claude896 }
897 .editor-btn-primary:hover {
898 transform: translateY(-1px);
6fd5915Claude899 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.6), inset 0 1px 0 rgba(255,255,255,0.20);
e26af57Claude900 text-decoration: none;
901 color: #fff;
902 }
903 .editor-btn-ghost {
904 background: transparent;
905 color: var(--text);
906 border-color: var(--border-strong);
907 }
908 .editor-btn-ghost:hover {
6fd5915Claude909 background: rgba(91,110,232,0.06);
910 border-color: rgba(91,110,232,0.45);
e26af57Claude911 color: var(--text-strong);
912 text-decoration: none;
913 }
914 .editor-btn-ai {
915 background: transparent;
916 color: #c4b5fd;
6fd5915Claude917 border-color: rgba(91,110,232,0.35);
e26af57Claude918 }
919 .editor-btn-ai:hover {
6fd5915Claude920 border-color: rgba(91,110,232,0.65);
921 background: rgba(91,110,232,0.08);
e26af57Claude922 color: #ddd6fe;
923 text-decoration: none;
924 }
925 .editor-btn-ai:disabled {
926 opacity: 0.55;
927 cursor: progress;
928 }
929 .editor-status {
930 color: var(--text-muted);
931 font-size: 12.5px;
932 margin-left: auto;
933 }
3646bfeClaude934
935 /* ─── CodeMirror wrapper ─── */
936 .editor-cm-wrapper {
937 display: none; /* shown by JS after mount */
938 border-radius: 10px;
939 overflow: hidden;
940 }
941 /* Ensure CM fills the field column */
942 .editor-field .cm-editor {
943 width: 100%;
944 box-sizing: border-box;
945 font-family: var(--font-mono);
946 font-size: 13px;
947 line-height: 1.55;
948 border-radius: 10px;
949 min-height: 280px;
950 }
951 /* Override oneDark background to match the app's dark bg variable */
952 .editor-field .cm-editor .cm-scroller {
953 font-family: var(--font-mono);
954 }
6f2809fClaude955
956 /* ─── AI inline suggestion overlay ─── */
957 .editor-wrapper {
958 position: relative;
959 }
960 .ai-suggestion {
961 position: absolute;
962 color: #555;
963 font-style: italic;
964 pointer-events: none;
965 white-space: pre;
966 font-family: var(--font-mono);
967 font-size: 13px;
968 line-height: 1.55;
969 }
970 .ai-loading {
971 position: absolute;
972 right: 10px;
973 top: 10px;
974 font-size: 11px;
975 color: #888;
976 display: none;
977 align-items: center;
978 gap: 5px;
979 pointer-events: none;
980 z-index: 10;
981 }
982 .ai-loading.visible { display: flex; }
983 .ai-spinner {
984 width: 10px;
985 height: 10px;
986 border: 1.5px solid rgba(164,139,255,0.3);
6fd5915Claude987 border-top-color: #5b6ee8;
6f2809fClaude988 border-radius: 50%;
989 animation: ai-spin 0.7s linear infinite;
990 }
991 @keyframes ai-spin {
992 to { transform: rotate(360deg); }
993 }
994
995 /* ─── AI explain panel ─── */
996 .ai-explain-panel {
997 background: #1a1a2e;
998 border: 1px solid #2a2a4a;
999 border-radius: 8px;
1000 padding: 14px 16px;
1001 margin-top: 10px;
1002 display: none;
1003 position: relative;
1004 }
1005 .ai-explain-panel.visible { display: block; }
1006 .ai-explain-panel-header {
1007 display: flex;
1008 align-items: center;
1009 justify-content: space-between;
1010 margin-bottom: 8px;
1011 }
1012 .ai-explain-panel-title {
1013 font-size: 11px;
1014 font-weight: 700;
1015 text-transform: uppercase;
1016 letter-spacing: 0.1em;
6fd5915Claude1017 color: #5b6ee8;
6f2809fClaude1018 }
1019 .ai-explain-panel-close {
1020 background: none;
1021 border: none;
1022 color: var(--text-faint);
1023 cursor: pointer;
1024 font-size: 14px;
1025 line-height: 1;
1026 padding: 2px 5px;
1027 border-radius: 4px;
1028 }
1029 .ai-explain-panel-close:hover { color: var(--text); background: rgba(255,255,255,0.06); }
1030 .ai-explain-panel-body {
1031 font-size: 13px;
1032 color: var(--text-muted);
1033 line-height: 1.6;
1034 white-space: pre-wrap;
1035 word-break: break-word;
1036 }
1037
1038 /* ─── AI fix panel ─── */
1039 .ai-fix-panel {
1040 background: #1a1a2e;
1041 border: 1px solid #2a2a4a;
1042 border-left: 3px solid #f59e0b;
1043 border-radius: 8px;
1044 padding: 14px 16px;
1045 margin-top: 10px;
1046 display: none;
1047 }
1048 .ai-fix-panel.visible { display: block; }
1049 .ai-fix-panel-header {
1050 display: flex;
1051 align-items: center;
1052 justify-content: space-between;
1053 margin-bottom: 8px;
1054 }
1055 .ai-fix-panel-title {
1056 font-size: 11px;
1057 font-weight: 700;
1058 text-transform: uppercase;
1059 letter-spacing: 0.1em;
1060 color: #f59e0b;
1061 }
1062 .ai-fix-panel-explanation {
1063 font-size: 12.5px;
1064 color: var(--text-muted);
1065 margin-bottom: 10px;
1066 line-height: 1.5;
1067 }
1068 .ai-fix-panel-apply {
1069 display: inline-flex;
1070 align-items: center;
1071 gap: 5px;
1072 padding: 6px 12px;
1073 background: rgba(245,158,11,0.12);
1074 border: 1px solid rgba(245,158,11,0.35);
1075 border-radius: 6px;
1076 color: #f59e0b;
1077 font-size: 12px;
1078 font-weight: 600;
1079 cursor: pointer;
1080 font: inherit;
1081 transition: background 100ms ease;
1082 }
1083 .ai-fix-panel-apply:hover { background: rgba(245,158,11,0.22); }
1084
1085 /* ─── AI badge ─── */
1086 .ai-powered-badge {
1087 display: inline-flex;
1088 align-items: center;
1089 gap: 4px;
1090 font-size: 11px;
6fd5915Claude1091 color: #5b6ee8;
1092 background: rgba(91,110,232,0.08);
1093 border: 1px solid rgba(91,110,232,0.20);
6f2809fClaude1094 border-radius: 999px;
1095 padding: 3px 8px;
1096 font-weight: 600;
1097 letter-spacing: 0.03em;
1098 white-space: nowrap;
1099 }
1100
1101 /* ─── AI suggestion hint ─── */
1102 .ai-suggestion-hint {
1103 font-size: 11px;
1104 color: var(--text-faint);
1105 margin-top: 5px;
1106 display: none;
1107 }
1108 .ai-suggestion-hint.visible { display: block; }
e26af57Claude1109`;
1110
1111function IconBranch() {
1112 return (
1113 <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">
1114 <line x1="6" y1="3" x2="6" y2="15" />
1115 <circle cx="18" cy="6" r="3" />
1116 <circle cx="6" cy="18" r="3" />
1117 <path d="M18 9a9 9 0 0 1-9 9" />
1118 </svg>
1119 );
1120}
1121
0074234Claude1122// New file form
04f6b7fClaude1123editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude1124 const { owner, repo } = c.req.param();
1125 const user = c.get("user")!;
1126 const refAndPath = c.req.param("ref");
6f2809fClaude1127 const aiEnabled = isAiAvailable();
0074234Claude1128
1129 // Parse ref — use first segment
1130 const slashIdx = refAndPath.indexOf("/");
1131 const ref = slashIdx === -1 ? refAndPath : refAndPath.slice(0, slashIdx);
1132 const dirPath = slashIdx === -1 ? "" : refAndPath.slice(slashIdx + 1);
1133
1134 return c.html(
1135 <Layout title={`New file — ${owner}/${repo}`} user={user}>
1136 <RepoHeader owner={owner} repo={repo} />
1137 <RepoNav owner={owner} repo={repo} active="code" />
e26af57Claude1138 <div class="editor-wrap">
1139 <header class="editor-head">
1140 <div class="editor-eyebrow">
1141 <span class="editor-eyebrow-dot" aria-hidden="true" />
1142 Editor · New file
1143 </div>
1144 <h1 class="editor-title">
1145 <span class="editor-title-grad">Create a new file.</span>
1146 </h1>
1147 <p class="editor-sub">
1148 Pick a path, paste your content, and write a short commit
1149 message. The commit lands directly on{" "}
1150 <code style="font-size:12.5px">{ref}</code>.
1151 </p>
1152 </header>
1153
1154 <div class="editor-toolbar">
1155 <div class="editor-path" title={dirPath ? `${dirPath}/…` : "Repository root"}>
1156 <span class="editor-path-seg">{owner}/{repo}</span>
1157 {dirPath && (
1158 <>
1159 <span class="editor-path-sep">/</span>
1160 <span class="editor-path-seg">{dirPath}</span>
1161 </>
1162 )}
1163 <span class="editor-path-sep">/</span>
1164 <span class="editor-path-name">new file…</span>
1165 </div>
6f2809fClaude1166 <div style="display:flex;align-items:center;gap:8px">
1167 {aiEnabled && (
1168 <span class="ai-powered-badge" title="Claude-powered inline suggestions">
1169 ✨ AI-powered editor
1170 </span>
1171 )}
1172 <span class="editor-branch-pill" title="Target branch">
1173 <IconBranch />
1174 {ref}
1175 </span>
1176 </div>
e26af57Claude1177 </div>
1178
1179 <form
1180 method="post"
1181 action={`/${owner}/${repo}/new/${ref}`}
1182 class="editor-card"
1183 >
1184 <div class="editor-body">
1185 <input type="hidden" name="dir_path" value={dirPath} />
1186 <div class="editor-field">
1187 <label class="editor-label" for="editor-filename">File path</label>
1188 <div class="editor-filename-row">
1189 {dirPath && (
1190 <span class="editor-filename-dir">{dirPath}/</span>
1191 )}
1192 <input
1193 class="editor-input editor-input-mono"
1194 id="editor-filename"
1195 name="filename"
1196 required
1197 placeholder="filename.ts"
1198 autocomplete="off"
1199 aria-label="File path"
1200 />
1201 </div>
1202 </div>
1203 <div class="editor-field">
1204 <label class="editor-label" for="editor-content-new">Content</label>
3646bfeClaude1205 {/* CodeMirror mounts here; falls back to textarea if JS/CDN unavailable */}
1206 <div id="editor-cm-new" class="editor-cm-wrapper" />
e26af57Claude1207 <textarea
1208 class="editor-textarea"
1209 id="editor-content-new"
1210 name="content"
1211 rows={20}
1212 placeholder="Enter file content…"
1213 spellcheck={false}
3646bfeClaude1214 data-lang="plaintext"
e26af57Claude1215 />
1216 </div>
1217 <div class="editor-field" style="margin-bottom:0">
1218 <label class="editor-label" for="commit-message-input">Commit message</label>
1219 <input
1220 class="editor-input"
1221 id="commit-message-input"
1222 name="message"
1223 placeholder="Create new file"
0074234Claude1224 required
e26af57Claude1225 aria-label="Commit message"
0074234Claude1226 />
e26af57Claude1227 </div>
1228 </div>
1229 <div class="editor-actions">
1230 <button type="submit" class="editor-btn editor-btn-primary">
1231 Commit new file
1232 </button>
6f2809fClaude1233 {aiEnabled && (
1234 <button
1235 type="button"
1236 id="ai-explain-btn"
1237 class="editor-btn editor-btn-ai"
1238 title="Explain selected code (or full file) with Claude"
1239 >
1240 Explain code
1241 </button>
1242 )}
e26af57Claude1243 <a href={`/${owner}/${repo}`} class="editor-btn editor-btn-ghost">
1244 Cancel
1245 </a>
1246 </div>
3646bfeClaude1247 <script
1248 type="module"
1249 dangerouslySetInnerHTML={{
1250 __html: CODEMIRROR_INIT_SCRIPT({
1251 textareaId: "editor-content-new",
1252 wrapperId: "editor-cm-new",
1253 lang: "plaintext",
1254 }),
1255 }}
1256 />
6f2809fClaude1257 {aiEnabled && (
1258 <script
1259 dangerouslySetInnerHTML={{
1260 __html:
1261 `window.__aiEditorEnabled = true;\n` +
1262 AI_EDITOR_SCRIPT({ lang: "plaintext", gateError: "" }),
1263 }}
1264 />
1265 )}
e26af57Claude1266 </form>
1267 </div>
1268 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
0074234Claude1269 </Layout>
1270 );
1271});
1272
1273// Create file via commit
04f6b7fClaude1274editor.post("/:owner/:repo/new/:ref", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude1275 const { owner, repo } = c.req.param();
1276 const user = c.get("user")!;
1277 const ref = c.req.param("ref");
1278 const body = await c.req.parseBody();
1279 const dirPath = String(body.dir_path || "").trim();
1280 const filename = String(body.filename || "").trim();
1281 const content = String(body.content || "");
1282 const message = String(body.message || `Create ${filename}`).trim();
1283
1284 if (!filename) return c.redirect(`/${owner}/${repo}`);
1285
1286 const fullPath = dirPath ? `${dirPath}/${filename}` : filename;
1287
1288 // Use git hash-object + update-index + write-tree + commit-tree
1289 const repoDir = getRepoPath(owner, repo);
1290
1291 const run = async (cmd: string[], cwd: string, stdin?: string) => {
1292 const proc = Bun.spawn(cmd, {
1293 cwd,
1294 stdout: "pipe",
1295 stderr: "pipe",
1296 stdin: stdin !== undefined ? "pipe" : undefined,
1297 });
1298 if (stdin !== undefined && proc.stdin) {
1299 proc.stdin.write(new TextEncoder().encode(stdin));
1300 proc.stdin.end();
1301 }
1302 const stdout = await new Response(proc.stdout).text();
1303 await proc.exited;
1304 return stdout.trim();
1305 };
1306
1307 // Hash the new file content
1308 const blobSha = await run(
1309 ["git", "hash-object", "-w", "--stdin"],
1310 repoDir,
1311 content
1312 );
1313
1314 // Read current tree
1315 const currentTreeSha = await run(
1316 ["git", "rev-parse", `${ref}^{tree}`],
1317 repoDir
1318 );
1319
1320 // Read current tree and add new entry
1321 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
1322 const entries = treeContent
1323 .split("\n")
1324 .filter(Boolean)
1325 .map((line) => line + "\n")
1326 .join("");
1327 const newEntry = `100644 blob ${blobSha}\t${fullPath}\n`;
1328
1329 const newTreeSha = await run(
1330 ["git", "mktree"],
1331 repoDir,
1332 entries + newEntry
1333 );
1334
1335 // Get parent commit
1336 const parentSha = await run(
1337 ["git", "rev-parse", ref],
1338 repoDir
1339 );
1340
1341 // Create commit
1342 const env = {
1343 GIT_AUTHOR_NAME: user.displayName || user.username,
1344 GIT_AUTHOR_EMAIL: user.email,
1345 GIT_COMMITTER_NAME: user.displayName || user.username,
1346 GIT_COMMITTER_EMAIL: user.email,
1347 };
1348
1349 const commitProc = Bun.spawn(
1350 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1351 {
1352 cwd: repoDir,
1353 stdout: "pipe",
1354 stderr: "pipe",
1355 env: { ...process.env, ...env },
1356 }
1357 );
1358 const commitSha = (await new Response(commitProc.stdout).text()).trim();
1359 await commitProc.exited;
1360
1361 // Update branch ref
1362 await run(
1363 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
1364 repoDir
1365 );
1366
1367 return c.redirect(`/${owner}/${repo}/blob/${ref}/${fullPath}`);
1368});
1369
1370// Edit file form
04f6b7fClaude1371editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude1372 const { owner, repo } = c.req.param();
1373 const user = c.get("user")!;
1374 const refAndPath = c.req.param("ref");
6f2809fClaude1375 const aiEnabled = isAiAvailable();
0074234Claude1376
1377 // Parse ref/path
1378 const slashIdx = refAndPath.indexOf("/");
1379 if (slashIdx === -1) return c.text("Not found", 404);
1380 const ref = refAndPath.slice(0, slashIdx);
1381 const filePath = refAndPath.slice(slashIdx + 1);
1382
1383 const blob = await getBlob(owner, repo, ref, filePath);
1384 if (!blob || blob.isBinary) {
1385 return c.html(
1386 <Layout title="Cannot edit" user={user}>
bb0f894Claude1387 <EmptyState title={blob?.isBinary ? "Cannot edit binary file" : "File not found"} />
0074234Claude1388 </Layout>,
1389 404
1390 );
1391 }
1392
e26af57Claude1393 const fileName = filePath.split("/").pop() || filePath;
1394 const dirParts = filePath.split("/").slice(0, -1);
6f2809fClaude1395 const lang = detectEditorLang(filePath);
1396
1397 // GateTest error from query param (e.g. redirect after failed push gate)
1398 const gateError = String(c.req.query("error") ?? "").slice(0, 500);
e26af57Claude1399
0074234Claude1400 return c.html(
1401 <Layout title={`Editing ${filePath} — ${owner}/${repo}`} user={user}>
1402 <RepoHeader owner={owner} repo={repo} />
1403 <RepoNav owner={owner} repo={repo} active="code" />
e26af57Claude1404 <div class="editor-wrap">
1405 <header class="editor-head">
1406 <div class="editor-eyebrow">
1407 <span class="editor-eyebrow-dot" aria-hidden="true" />
1408 Editor · Edit file
1409 </div>
1410 <h1 class="editor-title">
1411 <span class="editor-title-grad">Edit{" "}</span>
1412 <code style="font-family:var(--font-mono);font-size:0.82em;color:var(--text-strong);font-weight:700">{fileName}</code>
1413 </h1>
1414 <p class="editor-sub">
1415 Save your changes as a commit on{" "}
1416 <code style="font-size:12.5px">{ref}</code>. Write a clear
1417 one-line message so reviewers can follow the history.
1418 </p>
1419 </header>
1420
1421 <div class="editor-toolbar">
1422 <div class="editor-path" title={filePath}>
1423 <a
1424 href={`/${owner}/${repo}`}
1425 class="editor-path-seg"
1426 style="color:var(--text-muted);text-decoration:none"
1427 >
1428 {owner}/{repo}
1429 </a>
1430 {dirParts.map((seg, i) => {
1431 const subPath = dirParts.slice(0, i + 1).join("/");
1432 return (
1433 <>
1434 <span class="editor-path-sep">/</span>
1435 <a
1436 href={`/${owner}/${repo}/tree/${ref}/${subPath}`}
1437 class="editor-path-seg"
1438 style="text-decoration:none"
1439 >
1440 {seg}
1441 </a>
1442 </>
1443 );
1444 })}
1445 <span class="editor-path-sep">/</span>
1446 <span class="editor-path-name">{fileName}</span>
1447 </div>
6f2809fClaude1448 <div style="display:flex;align-items:center;gap:8px">
1449 {aiEnabled && (
1450 <span class="ai-powered-badge" title="Claude-powered inline suggestions, explain, and fix">
1451 ✨ AI-powered editor
1452 </span>
1453 )}
1454 <span class="editor-branch-pill" title="Target branch">
1455 <IconBranch />
1456 {ref}
1457 </span>
1458 </div>
e26af57Claude1459 </div>
1460
6f2809fClaude1461 {gateError && (
1462 <div style="margin-bottom:var(--space-3);padding:10px 14px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.35);border-radius:10px;font-size:13px;color:#f59e0b;line-height:1.5">
1463 <strong>GateTest error:</strong>{" "}{gateError}
1464 </div>
1465 )}
1466
e26af57Claude1467 <form
1468 method="post"
1469 action={`/${owner}/${repo}/edit/${ref}/${filePath}`}
1470 class="editor-card"
1471 >
1472 <div class="editor-body">
1473 <div class="editor-field">
1474 <label class="editor-label" for="editor-content-edit">Content</label>
3646bfeClaude1475 {/* CodeMirror mounts here; falls back to textarea if JS/CDN unavailable */}
1476 <div id="editor-cm-edit" class="editor-cm-wrapper" />
e26af57Claude1477 <textarea
1478 class="editor-textarea"
1479 id="editor-content-edit"
1480 name="content"
1481 rows={25}
1482 spellcheck={false}
6f2809fClaude1483 data-lang={lang}
e26af57Claude1484 >{blob.content}</textarea>
1485 </div>
1486 <div class="editor-field" style="margin-bottom:0">
1487 <label class="editor-label" for="commit-message-input">Commit message</label>
1488 <input
1489 class="editor-input"
1490 id="commit-message-input"
1491 name="message"
1492 placeholder={`Update ${fileName}`}
1493 required
1494 aria-label="Commit message"
1495 />
1496 </div>
1497 </div>
1498 <div class="editor-actions">
1499 <button type="submit" class="editor-btn editor-btn-primary">
0074234Claude1500 Commit changes
e26af57Claude1501 </button>
c81b57cClaude1502 <button
1503 type="button"
1504 id="ai-commit-msg-btn"
e26af57Claude1505 class="editor-btn editor-btn-ai"
c81b57cClaude1506 title="Generate a one-line commit message using Claude based on the diff"
1507 >
e26af57Claude1508 {"✨"} Suggest with AI
c81b57cClaude1509 </button>
6f2809fClaude1510 {aiEnabled && (
1511 <button
1512 type="button"
1513 id="ai-explain-btn"
1514 class="editor-btn editor-btn-ai"
1515 title="Explain selected code (or full file) with Claude"
1516 >
1517 Explain code
1518 </button>
1519 )}
1520 {aiEnabled && gateError && (
1521 <button
1522 type="button"
1523 id="ai-fix-btn"
1524 class="editor-btn editor-btn-ai"
1525 title="Ask Claude to fix the GateTest error"
1526 style="border-color:rgba(245,158,11,0.45);color:#f59e0b"
1527 >
1528 ⚡ Fix error
1529 </button>
1530 )}
e26af57Claude1531 <a
1532 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
1533 class="editor-btn editor-btn-ghost"
1534 >
0074234Claude1535 Cancel
e26af57Claude1536 </a>
1537 <span id="ai-commit-msg-status" class="editor-status" />
1538 </div>
c81b57cClaude1539 <script
1540 dangerouslySetInnerHTML={{
1541 __html: AI_COMMIT_MSG_SCRIPT({
1542 endpoint: `/${owner}/${repo}/ai/commit-message`,
1543 ref,
1544 filePath,
1545 }),
1546 }}
1547 />
3646bfeClaude1548 <script
1549 type="module"
1550 dangerouslySetInnerHTML={{
1551 __html: CODEMIRROR_INIT_SCRIPT({
1552 textareaId: "editor-content-edit",
1553 wrapperId: "editor-cm-edit",
6f2809fClaude1554 lang,
3646bfeClaude1555 }),
1556 }}
1557 />
6f2809fClaude1558 {aiEnabled && (
1559 <script
1560 dangerouslySetInnerHTML={{
1561 __html:
1562 `window.__aiEditorEnabled = true;\n` +
1563 AI_EDITOR_SCRIPT({ lang, gateError }),
1564 }}
1565 />
1566 )}
e26af57Claude1567 </form>
1568 </div>
1569 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
0074234Claude1570 </Layout>
1571 );
1572});
1573
c81b57cClaude1574// AI-suggested commit message — JSON endpoint driven by the editor button.
1575// Reads the on-disk blob at (ref, filePath), diffs against the submitted
1576// new content, and asks generateCommitMessage() for a one-liner. Returns
1577// {ok:true, message} on success, {ok:false, error} otherwise. Always 200.
1578editor.post(
1579 "/:owner/:repo/ai/commit-message",
1580 requireAuth,
1581 requireRepoAccess("write"),
1582 async (c) => {
1583 const { owner, repo } = c.req.param();
1584 if (!isAiAvailable()) {
1585 return c.json({
1586 ok: false,
1587 error: "AI is not available — set ANTHROPIC_API_KEY.",
1588 });
1589 }
1590 const body = await c.req.parseBody();
1591 const ref = String(body.ref || "").trim();
1592 const filePath = String(body.filePath || "").trim();
1593 const newContent = String(body.content || "");
1594 if (!ref || !filePath) {
1595 return c.json({ ok: false, error: "ref + filePath required" });
1596 }
1597
1598 let oldContent = "";
1599 try {
1600 const blob = await getBlob(owner, repo, ref, filePath);
1601 oldContent = blob?.content || "";
1602 } catch {
1603 oldContent = "";
1604 }
1605
1606 if (oldContent === newContent) {
1607 return c.json({
1608 ok: false,
1609 error: "No changes to summarise.",
1610 });
1611 }
1612
1613 // Build a minimal unified-diff-ish summary the AI helper can consume.
1614 // generateCommitMessage was written for git diff text; we feed a
1615 // header + truncated old/new sample so it has shape to summarise.
1616 const truncate = (s: string) => (s.length > 4000 ? s.slice(0, 4000) + "\n…(truncated)" : s);
1617 const diff =
1618 `--- a/${filePath}\n+++ b/${filePath}\n` +
1619 "## Old:\n" +
1620 truncate(oldContent) +
1621 "\n\n## New:\n" +
1622 truncate(newContent);
1623
1624 let message = "";
1625 try {
1626 message = await generateCommitMessage(diff);
1627 } catch (err) {
1628 const msg = err instanceof Error ? err.message : "AI request failed.";
1629 return c.json({ ok: false, error: msg });
1630 }
1631 if (!message.trim()) {
1632 return c.json({
1633 ok: false,
1634 error: "AI returned an empty draft.",
1635 });
1636 }
1637 // Cap to one line + 100 chars (commit-message convention).
1638 const oneLine = message.split("\n")[0]!.trim();
1639 const capped = oneLine.length > 100 ? oneLine.slice(0, 97) + "..." : oneLine;
c315551Claude1640 // Emit audit event so L9 ai-hours-saved counters stay accurate.
1641 const user = c.get("user");
1642 if (user) {
1643 void audit({
1644 userId: user.id,
1645 action: AI_AUDIT_ACTIONS.AI_COMMIT_MESSAGE,
1646 targetType: "repository",
1647 targetId: `${owner}/${repo}`,
1648 metadata: { filePath },
1649 }).catch(() => {});
1650 }
c81b57cClaude1651 return c.json({ ok: true, message: capped });
1652 }
1653);
1654
0074234Claude1655// Save edited file
04f6b7fClaude1656editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
0074234Claude1657 const { owner, repo } = c.req.param();
1658 const user = c.get("user")!;
1659 const refAndPath = c.req.param("ref");
1660
1661 const slashIdx = refAndPath.indexOf("/");
1662 if (slashIdx === -1) return c.redirect(`/${owner}/${repo}`);
1663 const ref = refAndPath.slice(0, slashIdx);
1664 const filePath = refAndPath.slice(slashIdx + 1);
1665
1666 const body = await c.req.parseBody();
1667 const content = String(body.content || "");
1668 const message = String(
1669 body.message || `Update ${filePath.split("/").pop()}`
1670 ).trim();
1671
1672 const repoDir = getRepoPath(owner, repo);
1673
1674 const run = async (cmd: string[], cwd: string, stdin?: string) => {
1675 const proc = Bun.spawn(cmd, {
1676 cwd,
1677 stdout: "pipe",
1678 stderr: "pipe",
1679 stdin: stdin !== undefined ? "pipe" : undefined,
1680 });
1681 if (stdin !== undefined && proc.stdin) {
1682 proc.stdin.write(new TextEncoder().encode(stdin));
1683 proc.stdin.end();
1684 }
1685 const stdout = await new Response(proc.stdout).text();
1686 await proc.exited;
1687 return stdout.trim();
1688 };
1689
1690 // Hash new content
1691 const blobSha = await run(
1692 ["git", "hash-object", "-w", "--stdin"],
1693 repoDir,
1694 content
1695 );
1696
1697 // Read current tree, replace the file
1698 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
1699 const lines = treeContent.split("\n").filter(Boolean);
1700 const updated = lines
1701 .map((line) => {
1702 const parts = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
1703 if (parts && parts[4] === filePath) {
1704 return `${parts[1]} blob ${blobSha}\t${parts[4]}`;
1705 }
1706 return line;
1707 })
1708 .join("\n") + "\n";
1709
1710 const newTreeSha = await run(["git", "mktree"], repoDir, updated);
1711 const parentSha = await run(["git", "rev-parse", ref], repoDir);
1712
1713 const env = {
1714 GIT_AUTHOR_NAME: user.displayName || user.username,
1715 GIT_AUTHOR_EMAIL: user.email,
1716 GIT_COMMITTER_NAME: user.displayName || user.username,
1717 GIT_COMMITTER_EMAIL: user.email,
1718 };
1719
1720 const commitProc = Bun.spawn(
1721 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1722 {
1723 cwd: repoDir,
1724 stdout: "pipe",
1725 stderr: "pipe",
1726 env: { ...process.env, ...env },
1727 }
1728 );
1729 const commitSha = (await new Response(commitProc.stdout).text()).trim();
1730 await commitProc.exited;
1731
1732 await run(
1733 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
1734 repoDir
1735 );
1736
1737 return c.redirect(`/${owner}/${repo}/blob/${ref}/${filePath}`);
1738});
1739
1740export default editor;