Commit3646bfeunknown_key
feat(editor+dev-env): CodeMirror 6 editor + dev environment open to all users
feat(editor+dev-env): CodeMirror 6 editor + dev environment open to all users editor.tsx - Replace plain textarea with CodeMirror 6 loaded from esm.sh CDN - Server-side language detection from file extension (TS/JS/Py/MD/JSON/CSS/HTML/Shell/SQL/XML/YAML/Go/Rust/Java/Ruby + more) - Hidden textarea stays in sync on every keystroke so form POST is unchanged - Matches existing dark theme via oneDark from CDN - indentWithTab + defaultKeymap wired dev-env.tsx + dev-env.test.ts - Remove isSiteAdmin gate — dev environment now open to all authenticated users - Access control via resolveRepoAccess: read access required to view, write required to start/stop - Private repos return 404 to unauthenticated viewers (don't reveal existence) - isPrivate field threaded through resolveRepoForUser helper web.tsx - "Dev environment" CTA added to the AI quick-actions strip on every repo page https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
4 files changed+474−123646bfe37cfd69083dfd8bcf655a978c79caf7b8
4 changed files+474−12
Modifiedsrc/__tests__/dev-env.test.ts+94−5View fileUnifiedSplit
@@ -1,12 +1,16 @@
11/**
2 * Tests for src/lib/dev-env.ts — cloud dev environments (migration 0072).
2 * Tests for src/lib/dev-env.ts and src/routes/dev-env.tsx — cloud dev
3 * environments (migration 0072).
34 *
4 * Two layers:
5 * Three layers:
56 *
6 * 1. Pure helpers — URL building, status label mapping, machine-size
7 * 1. Route smoke tests — module loads without error; unauthenticated
8 * visitors on a public repo are redirected to login.
9 *
10 * 2. Pure helpers — URL building, status label mapping, machine-size
711 * validation. No DB, no network. Always run.
812 *
9 * 2. DB-backed pipeline — gated on HAS_DB so the suite stays green on
13 * 3. DB-backed pipeline — gated on HAS_DB so the suite stays green on
1014 * machines without Postgres. Covers:
1115 * - startDevEnv reads committed dev.yml when present
1216 * - startDevEnv generates a default when no file is committed
@@ -17,6 +21,7 @@
1721
1822import { afterEach, describe, expect, it } from "bun:test";
1923import { eq } from "drizzle-orm";
24import app from "../app";
2025import {
2126 buildDevEnvUrl,
2227 DEFAULT_IDLE_MINUTES,
@@ -38,7 +43,91 @@ import { devEnvs, repositories, users } from "../db/schema";
3843const HAS_DB = Boolean(process.env.DATABASE_URL);
3944
4045// ---------------------------------------------------------------------------
41// 1. Pure helpers
46// 1. Route smoke tests
47// ---------------------------------------------------------------------------
48
49describe("dev-env — route module", () => {
50 it("loads without error and exports a Hono app", async () => {
51 // Dynamic import to verify the module resolves cleanly at runtime.
52 const mod = await import("../routes/dev-env");
53 expect(mod.default).toBeDefined();
54 // Hono instances expose a `fetch` method.
55 expect(typeof mod.default.fetch).toBe("function");
56 });
57});
58
59describe("dev-env — unauthenticated redirect", () => {
60 it.skipIf(!HAS_DB)(
61 "GET /:owner/:repo/dev redirects unauthenticated visitor on a public repo to /login",
62 async () => {
63 // Seed a minimal public repo so resolveRepoForUser resolves.
64 const { db: database } = await import("../db");
65 const { repositories: repos, users: usersTable } = await import(
66 "../db/schema"
67 );
68 const username =
69 "devenvsmoke_" + Math.random().toString(36).slice(2, 10);
70 const [user] = await database
71 .insert(usersTable)
72 .values({
73 username,
74 email: `${username}@test.local`,
75 passwordHash: "$2b$10$" + "x".repeat(53),
76 })
77 .returning();
78 const repoName = "pub-" + Math.random().toString(36).slice(2, 8);
79 await database
80 .insert(repos)
81 .values({
82 name: repoName,
83 ownerId: user!.id,
84 diskPath: `/tmp/devenvsmoke-${repoName}`,
85 isPrivate: false,
86 devEnvsEnabled: true,
87 })
88 .returning();
89
90 try {
91 // No session cookie → softAuth sets user=null → redirect to /login.
92 const res = await app.request(
93 `/${username}/${repoName}/dev`,
94 { redirect: "manual" }
95 );
96 expect(res.status).toBe(302);
97 const location = res.headers.get("location") ?? "";
98 expect(location).toContain("/login");
99 } finally {
100 // Clean up seeded rows.
101 try {
102 const { eq: eqFn } = await import("drizzle-orm");
103 await database
104 .delete(repos)
105 .where(eqFn(repos.ownerId, user!.id));
106 await database
107 .delete(usersTable)
108 .where(eqFn(usersTable.id, user!.id));
109 } catch {
110 /* best effort */
111 }
112 }
113 }
114 );
115
116 it("GET /:owner/:repo/dev returns 404 for non-existent repo (no DB or absent repo)", async () => {
117 // Without a real repo row the route cannot resolve the repo and returns 404.
118 // This test always runs (no DB required) because resolveRepoForUser
119 // returns null when the DB is unreachable or the repo doesn't exist.
120 const res = await app.request(
121 "/no-such-owner-xyzzy/no-such-repo-xyzzy/dev",
122 { redirect: "manual" }
123 );
124 // 404 (repo not found) or 302 (redirect if somehow auth redirects first).
125 expect([404, 302].includes(res.status)).toBe(true);
126 });
127});
128
129// ---------------------------------------------------------------------------
130// 2. Pure helpers
42131// ---------------------------------------------------------------------------
43132
44133describe("dev-env — buildDevEnvUrl", () => {
Modifiedsrc/routes/dev-env.tsx+46−7View fileUnifiedSplit
@@ -22,6 +22,10 @@ import { db } from "../db";
2222import { repositories, users } from "../db/schema";
2323import { softAuth, requireAuth } from "../middleware/auth";
2424import type { AuthEnv } from "../middleware/auth";
25import {
26 resolveRepoAccess,
27 satisfiesAccess,
28} from "../middleware/repo-access";
2529import { Layout } from "../views/layout";
2630import {
2731 buildDevEnvUrl,
@@ -310,6 +314,7 @@ async function resolveRepoForUser(
310314 repoId: string;
311315 repoName: string;
312316 devEnabled: boolean;
317 isPrivate: boolean;
313318} | null> {
314319 try {
315320 const [owner] = await db
@@ -335,6 +340,7 @@ async function resolveRepoForUser(
335340 repoId: repo.id,
336341 repoName: repo.name,
337342 devEnabled: !!(repo as { devEnvsEnabled?: boolean }).devEnvsEnabled,
343 isPrivate: !!(repo as { isPrivate?: boolean }).isPrivate,
338344 };
339345 } catch {
340346 return null;
@@ -408,6 +414,26 @@ devEnvRoutes.get("/:owner/:repo/dev", softAuth, async (c) => {
408414 const resolved = await resolveRepoForUser(ownerName, repoName);
409415 if (!resolved) return c.notFound();
410416
417 // Gate: resolve the viewer's access level.
418 const access = await resolveRepoAccess({
419 repoId: resolved.repoId,
420 userId: user?.id ?? null,
421 isPublic: !resolved.isPrivate,
422 });
423
424 // Private repo with no access → 404 (don't leak the repo exists).
425 if (!satisfiesAccess(access, "read")) {
426 return c.notFound();
427 }
428
429 // Unauthenticated visitor on a public repo: redirect to login so they can
430 // get their own dev env session.
431 if (!user) {
432 return c.redirect(
433 `/login?next=${encodeURIComponent(`/${resolved.ownerName}/${resolved.repoName}/dev`)}`
434 );
435 }
436
411437 // Render a "disabled" notice if the repo hasn't opted in. Owners get a
412438 // direct link to flip the toggle.
413439 if (!resolved.devEnabled) {
@@ -462,13 +488,6 @@ devEnvRoutes.get("/:owner/:repo/dev", softAuth, async (c) => {
462488 );
463489 }
464490
465 // Login required to provision/use an env.
466 if (!user) {
467 return c.redirect(
468 `/login?next=${encodeURIComponent(`/${resolved.ownerName}/${resolved.repoName}/dev`)}`
469 );
470 }
471
472491 const env = await getDevEnvForOwner(resolved.repoId, user.id);
473492
474493 // Record activity if there's a live row — every page hit counts.
@@ -692,6 +711,16 @@ devEnvRoutes.post(
692711 const resolved = await resolveRepoForUser(ownerName, repoName);
693712 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
694713
714 // Require at least write access to start a dev env.
715 const access = await resolveRepoAccess({
716 repoId: resolved.repoId,
717 userId: user.id,
718 isPublic: !resolved.isPrivate,
719 });
720 if (!satisfiesAccess(access, "write")) {
721 return c.json({ ok: false, error: "Insufficient access" }, 403);
722 }
723
695724 // Parse machine_size from either form body or JSON body, leniently.
696725 let machineSize: string | undefined;
697726 try {
@@ -764,6 +793,16 @@ devEnvRoutes.post(
764793 const resolved = await resolveRepoForUser(ownerName, repoName);
765794 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
766795
796 // Require at least write access to stop a dev env.
797 const access = await resolveRepoAccess({
798 repoId: resolved.repoId,
799 userId: user.id,
800 isPublic: !resolved.isPrivate,
801 });
802 if (!satisfiesAccess(access, "write")) {
803 return c.json({ ok: false, error: "Insufficient access" }, 403);
804 }
805
767806 const env = await getDevEnvForOwner(resolved.repoId, user.id);
768807 if (!env) {
769808 const wantsJson =
Modifiedsrc/routes/editor.tsx+330−0View fileUnifiedSplit
@@ -6,6 +6,11 @@
66 * hairline, mono breadcrumb pill, commit-message input with focus ring,
77 * primary commit + ghost cancel buttons, and an AI "Suggest" button that
88 * sits inline. The git operations themselves are unchanged.
9 *
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.
914 */
1015
1116import { Hono } from "hono";
@@ -70,6 +75,284 @@ function AI_COMMIT_MSG_SCRIPT(args: {
7075 );
7176}
7277
78/**
79 * Detect a language identifier from a file path/name for CodeMirror.
80 * Returns a string like "typescript", "python", "markdown", etc.
81 * Used server-side to embed a data-lang attribute.
82 */
83function detectEditorLang(filePath: string): string {
84 const lower = filePath.toLowerCase();
85 const ext = lower.split(".").pop() || "";
86 switch (ext) {
87 case "ts":
88 case "tsx":
89 return "typescript";
90 case "js":
91 case "jsx":
92 case "mjs":
93 case "cjs":
94 return "javascript";
95 case "py":
96 return "python";
97 case "md":
98 case "mdx":
99 return "markdown";
100 case "json":
101 case "jsonc":
102 return "json";
103 case "css":
104 case "scss":
105 case "sass":
106 case "less":
107 return "css";
108 case "html":
109 case "htm":
110 return "html";
111 case "sh":
112 case "bash":
113 case "zsh":
114 return "shell";
115 case "sql":
116 return "sql";
117 case "xml":
118 return "xml";
119 case "yaml":
120 case "yml":
121 return "yaml";
122 case "rs":
123 return "rust";
124 case "go":
125 return "go";
126 case "java":
127 return "java";
128 case "rb":
129 return "ruby";
130 case "php":
131 return "php";
132 case "c":
133 case "h":
134 return "c";
135 case "cpp":
136 case "cc":
137 case "cxx":
138 case "hh":
139 case "hpp":
140 return "cpp";
141 default:
142 return "plaintext";
143 }
144}
145
146/**
147 * CodeMirror 6 initialization script — loaded from esm.sh CDN.
148 * Mounts a CodeMirror editor in place of the hidden textarea.
149 * Syncs on every change so the form POST continues to work.
150 * The textareaId identifies the hidden textarea to sync to.
151 * The lang attribute controls language detection + dynamic import.
152 */
153function CODEMIRROR_INIT_SCRIPT(args: {
154 textareaId: string;
155 wrapperId: string;
156 lang: string;
157}): string {
158 const safe = (v: string) =>
159 JSON.stringify(v)
160 .split("<").join("\\u003C")
161 .split(">").join("\\u003E")
162 .split("&").join("\\u0026");
163 const textareaId = safe(args.textareaId);
164 const wrapperId = safe(args.wrapperId);
165 const lang = safe(args.lang);
166
167 return `
168(async function() {
169 try {
170 var ta = document.getElementById(${textareaId});
171 var wrapper = document.getElementById(${wrapperId});
172 if (!ta || !wrapper) return;
173
174 // Load CodeMirror 6 core from esm.sh
175 var [cmView, cmState, cmCommands, cmLanguage, cmTheme] = await Promise.all([
176 import('https://esm.sh/@codemirror/view@6'),
177 import('https://esm.sh/@codemirror/state@6'),
178 import('https://esm.sh/@codemirror/commands@6'),
179 import('https://esm.sh/@codemirror/language@6'),
180 import('https://esm.sh/@codemirror/theme-one-dark@6')
181 ]);
182
183 var { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightSpecialChars } = cmView;
184 var { EditorState, Compartment } = cmState;
185 var { defaultKeymap, indentWithTab, historyKeymap, history } = cmCommands;
186 var { indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter } = cmLanguage;
187 var { oneDark } = cmTheme;
188
189 // Dynamically load language support
190 var langExtension = [];
191 try {
192 var langStr = ${lang};
193 if (langStr === 'typescript' || langStr === 'javascript') {
194 var jsLang = await import('https://esm.sh/@codemirror/lang-javascript@6');
195 langExtension = [jsLang.javascript({ typescript: langStr === 'typescript', jsx: true })];
196 } else if (langStr === 'python') {
197 var pyLang = await import('https://esm.sh/@codemirror/lang-python@6');
198 langExtension = [pyLang.python()];
199 } else if (langStr === 'markdown') {
200 var mdLang = await import('https://esm.sh/@codemirror/lang-markdown@6');
201 langExtension = [mdLang.markdown()];
202 } else if (langStr === 'json') {
203 var jsonLang = await import('https://esm.sh/@codemirror/lang-json@6');
204 langExtension = [jsonLang.json()];
205 } else if (langStr === 'css') {
206 var cssLang = await import('https://esm.sh/@codemirror/lang-css@6');
207 langExtension = [cssLang.css()];
208 } else if (langStr === 'html') {
209 var htmlLang = await import('https://esm.sh/@codemirror/lang-html@6');
210 langExtension = [htmlLang.html()];
211 } else if (langStr === 'shell') {
212 var { StreamLanguage } = cmLanguage;
213 var shellLang = await import('https://esm.sh/@codemirror/legacy-modes@6/src/shell');
214 langExtension = [StreamLanguage.define(shellLang.shell)];
215 } else if (langStr === 'sql') {
216 var sqlLang = await import('https://esm.sh/@codemirror/lang-sql@6');
217 langExtension = [sqlLang.sql()];
218 } else if (langStr === 'rust') {
219 var rsLang = await import('https://esm.sh/@codemirror/lang-rust@6');
220 langExtension = [rsLang.rust()];
221 } else if (langStr === 'cpp' || langStr === 'c') {
222 var cppLang = await import('https://esm.sh/@codemirror/lang-cpp@6');
223 langExtension = [cppLang.cpp()];
224 } else if (langStr === 'java') {
225 var javaLang = await import('https://esm.sh/@codemirror/lang-java@6');
226 langExtension = [javaLang.java()];
227 } else if (langStr === 'xml') {
228 var xmlLang = await import('https://esm.sh/@codemirror/lang-xml@6');
229 langExtension = [xmlLang.xml()];
230 } else if (langStr === 'yaml') {
231 var { StreamLanguage } = cmLanguage;
232 var yamlLang = await import('https://esm.sh/@codemirror/legacy-modes@6/src/yaml');
233 langExtension = [StreamLanguage.define(yamlLang.yaml)];
234 } else if (langStr === 'go') {
235 var { StreamLanguage } = cmLanguage;
236 var goLang = await import('https://esm.sh/@codemirror/legacy-modes@6/src/go');
237 langExtension = [StreamLanguage.define(goLang.go)];
238 } else if (langStr === 'ruby') {
239 var { StreamLanguage } = cmLanguage;
240 var rubyLang = await import('https://esm.sh/@codemirror/legacy-modes@6/src/ruby');
241 langExtension = [StreamLanguage.define(rubyLang.ruby)];
242 } else if (langStr === 'php') {
243 var phpLang = await import('https://esm.sh/@codemirror/lang-php@6');
244 langExtension = [phpLang.php()];
245 }
246 } catch(e) {
247 // language load failure is non-fatal — continue without highlighting
248 langExtension = [];
249 }
250
251 var initialContent = ta.value;
252
253 var updateListener = EditorView.updateListener.of(function(update) {
254 if (update.docChanged) {
255 ta.value = update.state.doc.toString();
256 }
257 });
258
259 var editorTheme = EditorView.theme({
260 '&': {
261 background: 'var(--bg)',
262 color: 'var(--text)',
263 border: '1px solid var(--border-strong)',
264 borderRadius: '10px',
265 minHeight: '280px',
266 fontSize: '13px',
267 lineHeight: '1.55',
268 fontFamily: 'var(--font-mono)',
269 },
270 '.cm-content': {
271 padding: '12px 14px',
272 caretColor: '#a48bff',
273 },
274 '.cm-focused': {
275 outline: 'none',
276 },
277 '&.cm-focused': {
278 borderColor: 'rgba(140,109,255,0.55)',
279 boxShadow: '0 0 0 3px rgba(140,109,255,0.18)',
280 },
281 '.cm-gutters': {
282 background: 'rgba(255,255,255,0.02)',
283 borderRight: '1px solid var(--border)',
284 color: 'var(--text-faint)',
285 paddingRight: '4px',
286 },
287 '.cm-activeLineGutter': {
288 background: 'rgba(140,109,255,0.08)',
289 },
290 '.cm-activeLine': {
291 background: 'rgba(140,109,255,0.06)',
292 },
293 '.cm-scroller': {
294 overflow: 'auto',
295 minHeight: '280px',
296 maxHeight: '70vh',
297 },
298 '.cm-cursor': {
299 borderLeftColor: '#a48bff',
300 },
301 });
302
303 var state = EditorState.create({
304 doc: initialContent,
305 extensions: [
306 history(),
307 lineNumbers(),
308 highlightActiveLine(),
309 highlightActiveLineGutter(),
310 drawSelection(),
311 dropCursor(),
312 rectangularSelection(),
313 crosshairCursor(),
314 highlightSpecialChars(),
315 indentOnInput(),
316 syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
317 bracketMatching(),
318 foldGutter(),
319 keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
320 oneDark,
321 editorTheme,
322 updateListener,
323 ...langExtension,
324 EditorView.lineWrapping,
325 ],
326 });
327
328 var view = new EditorView({
329 state: state,
330 parent: wrapper,
331 });
332
333 // Hide the textarea now that CM is mounted
334 ta.style.display = 'none';
335 wrapper.style.display = 'block';
336
337 // Sync on form submit (belt-and-suspenders)
338 var form = ta.closest('form');
339 if (form) {
340 form.addEventListener('submit', function() {
341 ta.value = view.state.doc.toString();
342 });
343 }
344
345 } catch(e) {
346 // If CodeMirror fails to load for any reason, fall back to the textarea
347 var ta2 = document.getElementById(${textareaId});
348 if (ta2) ta2.style.display = '';
349 var w2 = document.getElementById(${wrapperId});
350 if (w2) w2.style.display = 'none';
351 }
352})();
353`;
354}
355
73356// ─── Scoped CSS (.editor-*) ─────────────────────────────────────────────────
74357// Every selector is prefixed `.editor-*` so this surface can't bleed into
75358// the repo header / nav above. Tokens reused from layout (--bg-elevated,
@@ -320,6 +603,27 @@ const editorStyles = `
320603 font-size: 12.5px;
321604 margin-left: auto;
322605 }
606
607 /* ─── CodeMirror wrapper ─── */
608 .editor-cm-wrapper {
609 display: none; /* shown by JS after mount */
610 border-radius: 10px;
611 overflow: hidden;
612 }
613 /* Ensure CM fills the field column */
614 .editor-field .cm-editor {
615 width: 100%;
616 box-sizing: border-box;
617 font-family: var(--font-mono);
618 font-size: 13px;
619 line-height: 1.55;
620 border-radius: 10px;
621 min-height: 280px;
622 }
623 /* Override oneDark background to match the app's dark bg variable */
624 .editor-field .cm-editor .cm-scroller {
625 font-family: var(--font-mono);
626 }
323627`;
324628
325629function IconBranch() {
@@ -408,6 +712,8 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"
408712 </div>
409713 <div class="editor-field">
410714 <label class="editor-label" for="editor-content-new">Content</label>
715 {/* CodeMirror mounts here; falls back to textarea if JS/CDN unavailable */}
716 <div id="editor-cm-new" class="editor-cm-wrapper" />
411717 <textarea
412718 class="editor-textarea"
413719 id="editor-content-new"
@@ -415,6 +721,7 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"
415721 rows={20}
416722 placeholder="Enter file content…"
417723 spellcheck={false}
724 data-lang="plaintext"
418725 />
419726 </div>
420727 <div class="editor-field" style="margin-bottom:0">
@@ -437,6 +744,16 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"
437744 Cancel
438745 </a>
439746 </div>
747 <script
748 type="module"
749 dangerouslySetInnerHTML={{
750 __html: CODEMIRROR_INIT_SCRIPT({
751 textareaId: "editor-content-new",
752 wrapperId: "editor-cm-new",
753 lang: "plaintext",
754 }),
755 }}
756 />
440757 </form>
441758 </div>
442759 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
@@ -628,12 +945,15 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write
628945 <div class="editor-body">
629946 <div class="editor-field">
630947 <label class="editor-label" for="editor-content-edit">Content</label>
948 {/* CodeMirror mounts here; falls back to textarea if JS/CDN unavailable */}
949 <div id="editor-cm-edit" class="editor-cm-wrapper" />
631950 <textarea
632951 class="editor-textarea"
633952 id="editor-content-edit"
634953 name="content"
635954 rows={25}
636955 spellcheck={false}
956 data-lang={detectEditorLang(filePath)}
637957 >{blob.content}</textarea>
638958 </div>
639959 <div class="editor-field" style="margin-bottom:0">
@@ -677,6 +997,16 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write
677997 }),
678998 }}
679999 />
1000 <script
1001 type="module"
1002 dangerouslySetInnerHTML={{
1003 __html: CODEMIRROR_INIT_SCRIPT({
1004 textareaId: "editor-content-edit",
1005 wrapperId: "editor-cm-edit",
1006 lang: detectEditorLang(filePath),
1007 }),
1008 }}
1009 />
6801010 </form>
6811011 </div>
6821012 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
Modifiedsrc/routes/web.tsx+4−0View fileUnifiedSplit
@@ -3019,6 +3019,10 @@ web.get("/:owner/:repo", async (c) => {
30193019 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
30203020 AI release notes
30213021 </a>
3022 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
3023 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
3024 Dev environment
3025 </a>
30223026 </nav>
30233027 <div class="repo-home-grid">
30243028 <div class="repo-home-main">
30253029