Open GitHub repos as workspaces via #/github/<spec>
The route imports the repo into its own local workspace on first visit (no
project registry entry; the URL is the identity) and opens the full numbl IDE
with minwebide's source control view: change tracking, Commit & Push, Reload
from GitHub. Local projects get the same view with publish-to-GitHub. Signed-in
tokens are used for reads too, so private repositories work.
3 changed files+138−20
src/githubOpen.tsadded+72−0View file
@@ -0,0 +1,72 @@
1+import { applyThemeToElement, attachGitHubWorkspace, createIndexedDBFileSystem, parseGitHubSpec, type WorkbenchTheme } from 'minwebide';
2+import { openNumblWorkbench, openStartingFile } from './ide';
3+import './landing.css';
4+
5+// The #/github/<spec> route: a GitHub repository as a workspace of its own.
6+// <spec> is anything parseGitHubSpec accepts — owner/repo, owner/repo@ref, or
7+// a URL-encoded github.com URL. The URL is the identity: nothing is added to
8+// the project registry. The first visit imports into a per-repo IndexedDB
9+// database; later visits reopen that local copy, edits included, with the
10+// Source Control view tracking changes against the imported commit (and
11+// offering "Reload from GitHub" to start fresh).
12+
13+/** Handles a #/github/<spec> route. Returns a disposable view (the IDE, or an error screen). */
14+export async function openGitHubRoute(container: HTMLElement, specText: string, theme: WorkbenchTheme): Promise<{ dispose(): void }> {
15+ let fs: Awaited<ReturnType<typeof createIndexedDBFileSystem>> | undefined;
16+ let ide: Awaited<ReturnType<typeof openNumblWorkbench>> | undefined;
17+ try {
18+ const spec = parseGitHubSpec(specText);
19+ const name = `${spec.owner}/${spec.repo}`;
20+ document.title = `${name} — numbl web IDE`;
21+ const dbName = `numbl-web-ide-gh-${spec.owner}-${spec.repo}${spec.ref ? `-${spec.ref}` : ''}${spec.dir ? `-${spec.dir}` : ''}`
22+ .toLowerCase().replace(/[^a-z0-9._-]/g, '-');
23+
24+ fs = await createIndexedDBFileSystem({ dbName });
25+ ide = await openNumblWorkbench(container, fs, name, theme);
26+ ide.workbench.statusBar.removeItem('branding');
27+ ide.workbench.statusBar.setItem('project', 'left', 'Projects', {
28+ icon: 'arrow-left',
29+ title: 'Back to projects',
30+ onClick: () => { location.hash = '#/'; },
31+ });
32+
33+ // imports on first visit (status bar progress + GitHub output channel);
34+ // the README is left to openStartingFile, which prefers numbl entry points
35+ const view = await attachGitHubWorkspace(ide.workbench, fs, spec, { autoOpenReadme: false, appName: 'numbl web IDE' });
36+ await openStartingFile(fs, ide.workbench);
37+
38+ return {
39+ dispose() {
40+ view.dispose();
41+ ide!.dispose();
42+ fs!.dispose();
43+ },
44+ };
45+ } catch (error) {
46+ ide?.dispose();
47+ fs?.dispose();
48+ container.textContent = '';
49+ const message = error instanceof Error ? error.message : String(error);
50+ return renderErrorScreen(container, theme, `Could not open repository: ${message}`);
51+ }
52+}
53+
54+function renderErrorScreen(container: HTMLElement, theme: WorkbenchTheme, text: string): { dispose(): void } {
55+ const root = document.createElement('div');
56+ root.className = 'landing';
57+ applyThemeToElement(theme, root);
58+ const inner = document.createElement('div');
59+ inner.className = 'landing-inner';
60+ const message = document.createElement('p');
61+ message.className = 'landing-subtitle';
62+ message.textContent = text;
63+ inner.appendChild(message);
64+ const back = document.createElement('a');
65+ back.className = 'landing-link';
66+ back.href = '#/';
67+ back.textContent = 'Back to projects';
68+ inner.appendChild(back);
69+ root.appendChild(inner);
70+ container.appendChild(root);
71+ return { dispose: () => root.remove() };
72+}
src/ide.tsmodified+55−20View file
@@ -1,14 +1,19 @@
1-import { createWorkbench, type WorkbenchTheme } from 'minwebide';
1+import { attachGitHubSourceControl, createWorkbench, type Workbench, type WorkbenchTheme, type WorkspaceFileSystem } from 'minwebide';
22 import { createMipSystem } from './numbl/mipSystem';
33 import { createNumblRunner } from './numbl/runner';
44 import { openProjectFileSystem, touchProject, type ProjectInfo } from './projects';
55
6-/** Opens the IDE for a project. Returns a disposable view. */
7-export async function openIde(container: HTMLElement, project: ProjectInfo, theme: WorkbenchTheme): Promise<{ dispose(): void }> {
8- touchProject(project.id);
9- document.title = `${project.name} — numbl web IDE`;
6+export interface NumblWorkbench {
7+ readonly workbench: Workbench;
8+ dispose(): void;
9+}
1010
11- const fs = await openProjectFileSystem(project.id);
11+/**
12+ * Assembles the numbl workbench (mip system + runner) on a file system.
13+ * Shared by project IDEs and GitHub repo IDEs; does not own `fs` — the
14+ * caller disposes it.
15+ */
16+export async function openNumblWorkbench(container: HTMLElement, fs: WorkspaceFileSystem, workspaceName: string, theme: WorkbenchTheme): Promise<NumblWorkbench> {
1217 const mip = await createMipSystem();
1318 // fetch/refresh mip core in the background; runs await it (and say so
1419 // in the output channel if they actually have to wait)
@@ -17,36 +22,66 @@ export async function openIde(container: HTMLElement, project: ProjectInfo, them
1722 const workbench = createWorkbench(container, {
1823 fileSystem: fs,
1924 theme,
20- workspaceName: project.name,
25+ workspaceName,
2126 });
2227
2328 const numbl = createNumblRunner(fs, workbench, mip);
2429 workbench.registerRunner(numbl.runner);
2530
26- // the project indicator: click to go back to the project list
27- workbench.statusBar.setItem('project', 'left', project.name, {
28- icon: 'folder-opened',
29- title: 'Back to projects',
30- onClick: () => { location.hash = '#/'; },
31- });
32- // replace the default branding item with the project indicator
33- workbench.statusBar.removeItem('branding');
31+ return {
32+ workbench,
33+ dispose() {
34+ numbl.dispose();
35+ workbench.dispose();
36+ mip.dispose();
37+ },
38+ };
39+}
3440
35- // open the most useful starting file
41+/** Opens the most useful starting file, if any. */
42+export async function openStartingFile(fs: WorkspaceFileSystem, workbench: Workbench): Promise<void> {
3643 for (const path of ['/waves.m', '/main.m', '/README.md']) {
3744 const uri = fs.root.with({ path });
3845 if (await fs.fileService.exists(uri)) {
3946 await workbench.openFile(uri);
40- break;
47+ return;
4148 }
4249 }
50+}
51+
52+/** Opens the IDE for a project. Returns a disposable view. */
53+export async function openIde(container: HTMLElement, project: ProjectInfo, theme: WorkbenchTheme): Promise<{ dispose(): void }> {
54+ touchProject(project.id);
55+ document.title = `${project.name} — numbl web IDE`;
56+
57+ const fs = await openProjectFileSystem(project.id);
58+ const ide = await openNumblWorkbench(container, fs, project.name, theme);
59+
60+ // the project indicator: click to go back to the project list
61+ ide.workbench.statusBar.setItem('project', 'left', project.name, {
62+ icon: 'folder-opened',
63+ title: 'Back to projects',
64+ onClick: () => { location.hash = '#/'; },
65+ });
66+ // replace the default branding item with the project indicator
67+ ide.workbench.statusBar.removeItem('branding');
68+
69+ // source control: publish this project to a new GitHub repo, or — once
70+ // published — track changes and push
71+ const sourceControl = await attachGitHubSourceControl(ide.workbench, fs, {
72+ appName: 'numbl web IDE',
73+ defaultRepoName: project.name,
74+ // after publishing, the repo's own route is the canonical place to work
75+ onPublished: ({ owner, repo }) => { location.hash = `#/github/${owner}/${repo}`; },
76+ });
77+
78+ await openStartingFile(fs, ide.workbench);
4379
4480 return {
4581 dispose() {
46- numbl.dispose();
47- workbench.dispose();
82+ sourceControl.dispose();
83+ ide.dispose();
4884 fs.dispose();
49- mip.dispose();
5085 },
5186 };
5287 }
src/main.tsmodified+11−0View file
@@ -1,4 +1,5 @@
11 import { loadBuiltinTheme, registerBuiltinLanguages } from 'minwebide';
2+import { openGitHubRoute } from './githubOpen';
23 import { openIde } from './ide';
34 import { renderLanding } from './landing';
45 import { registerMatlabLanguage } from './numbl/language';
@@ -7,6 +8,10 @@ import { getProject } from './projects';
78 // Routes:
89 // #/ project picker (landing page)
910 // #/project/<id> the IDE, opened on that project's file system
11+// #/github/<spec> a GitHub repo as its own workspace (owner/repo[@ref],
12+// or a URL-encoded github.com URL); imports on first
13+// visit, then keeps a local editable copy — the URL
14+// stays on this route and no project is created
1015
1116 async function start(): Promise<void> {
1217 // dev never uses a service worker (isolation comes from server headers) —
@@ -45,6 +50,12 @@ async function start(): Promise<void> {
4550 current = undefined;
4651 app.textContent = '';
4752
53+ const github = location.hash.match(/^#\/github\/(.+)$/);
54+ if (github) {
55+ current = await openGitHubRoute(app, decodeURIComponent(github[1]), theme);
56+ return;
57+ }
58+
4859 const match = location.hash.match(/^#\/project\/([a-z0-9]+)/i);
4960 if (match) {
5061 const project = getProject(match[1]);