concept-collection / stan-web-ide
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 Stan IDE with minwebide's source control view: change tracking, Commit & Push, Reload from GitHub. Local projects get the same view with publish-to-GitHub.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit f66b0fbc0877 parent b1dbd02 Browse files
3 changed files+148−29
src/githubOpen.tsadded+72−0View file
@@ -0,0 +1,72 @@
1+import { applyThemeToElement, attachGitHubWorkspace, createIndexedDBFileSystem, parseGitHubSpec, type WorkbenchTheme } from 'minwebide';
2+import { openStanWorkbench, 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 openStanWorkbench>> | undefined;
17+ try {
18+ const spec = parseGitHubSpec(specText);
19+ const name = `${spec.owner}/${spec.repo}`;
20+ document.title = `${name} — stan web IDE`;
21+ const dbName = `stan-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 openStanWorkbench(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 stan entry points
35+ const view = await attachGitHubWorkspace(ide.workbench, fs, spec, { autoOpenReadme: false, appName: 'stan 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+60−24View file
@@ -1,37 +1,34 @@
1-import { createWorkbench, type WorkbenchTheme } from 'minwebide';
1+import { attachGitHubSourceControl, createWorkbench, type Workbench, type WorkbenchTheme, type WorkspaceFileSystem } from 'minwebide';
22 import { createCsvTableProvider } from './csvTable';
33 import { openProjectFileSystem, touchProject, type ProjectInfo } from './projects';
4+import { createResultsViewProvider } from './stan/resultsView';
45 import { createStanRunner } from './stan/runner';
56 import { createSampleEditorProvider } from './stan/sampleEditor';
67 import { showServerDialog } from './stan/serverDialog';
78 import { getServerUrl, onDidChangeServerUrl, probeServer } from './stan/settings';
89
9-/** Opens the IDE for a project. Returns a disposable view. */
10-export async function openIde(container: HTMLElement, project: ProjectInfo, theme: WorkbenchTheme): Promise<{ dispose(): void }> {
11- touchProject(project.id);
12- document.title = `${project.name} — stan web IDE`;
13-
14- const fs = await openProjectFileSystem(project.id);
15- const stan = createStanRunner(fs);
10+export interface StanWorkbench {
11+ readonly workbench: Workbench;
12+ dispose(): void;
13+}
1614
15+/**
16+ * Assembles the Stan workbench (runner, custom editors, compile-server status
17+ * item) on a file system. Shared by project IDEs and GitHub repo IDEs; does
18+ * not own `fs` — the caller disposes it.
19+ */
20+export async function openStanWorkbench(container: HTMLElement, fs: WorkspaceFileSystem, workspaceName: string, theme: WorkbenchTheme): Promise<StanWorkbench> {
1721 const workbench = createWorkbench(container, {
1822 fileSystem: fs,
1923 theme,
20- workspaceName: project.name,
24+ workspaceName,
2125 });
26+ const stan = createStanRunner(fs, (path) => workbench.openFile(fs.root.with({ path })));
2227 workbench.registerRunner(stan.runner);
2328 workbench.registerCustomEditor(createSampleEditorProvider(fs, workbench, { stop: stan.stop }));
29+ workbench.registerCustomEditor(createResultsViewProvider(fs));
2430 workbench.registerCustomEditor(createCsvTableProvider());
2531
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');
34-
3532 // compile-server status: shows connectivity, click to change the URL
3633 let disposed = false;
3734 const refreshServerItem = async () => {
@@ -54,21 +51,60 @@ export async function openIde(container: HTMLElement, project: ProjectInfo, them
5451 void refreshServerItem();
5552 const serverListener = onDidChangeServerUrl(() => void refreshServerItem());
5653
57- // open the most useful starting file
54+ return {
55+ workbench,
56+ dispose() {
57+ disposed = true;
58+ serverListener.dispose();
59+ stan.dispose();
60+ workbench.dispose();
61+ },
62+ };
63+}
64+
65+/** Opens the most useful starting file, if any. */
66+export async function openStartingFile(fs: WorkspaceFileSystem, workbench: Workbench): Promise<void> {
5867 for (const path of ['/fit.sample', '/main.stan', '/README.md']) {
5968 const uri = fs.root.with({ path });
6069 if (await fs.fileService.exists(uri)) {
6170 await workbench.openFile(uri);
62- break;
71+ return;
6372 }
6473 }
74+}
75+
76+/** Opens the IDE for a project. Returns a disposable view. */
77+export async function openIde(container: HTMLElement, project: ProjectInfo, theme: WorkbenchTheme): Promise<{ dispose(): void }> {
78+ touchProject(project.id);
79+ document.title = `${project.name} — stan web IDE`;
80+
81+ const fs = await openProjectFileSystem(project.id);
82+ const ide = await openStanWorkbench(container, fs, project.name, theme);
83+
84+ // the project indicator: click to go back to the project list
85+ ide.workbench.statusBar.setItem('project', 'left', project.name, {
86+ icon: 'folder-opened',
87+ title: 'Back to projects',
88+ onClick: () => { location.hash = '#/'; },
89+ });
90+ // replace the default branding item with the project indicator
91+ ide.workbench.statusBar.removeItem('branding');
92+
93+ // source control: publish this project to a new GitHub repo, or — once
94+ // published — track changes and push
95+ const sourceControl = await attachGitHubSourceControl(ide.workbench, fs, {
96+ appName: 'stan web IDE',
97+ defaultRepoName: project.name,
98+ // after publishing, the repo's own route is the canonical place to work
99+ onPublished: ({ owner, repo }) => { location.hash = `#/github/${owner}/${repo}`; },
100+ });
101+
102+ await openStartingFile(fs, ide.workbench);
65103
66104 return {
67105 dispose() {
68- disposed = true;
69- serverListener.dispose();
70- stan.dispose();
71- workbench.dispose();
106+ sourceControl.dispose();
107+ ide.dispose();
72108 fs.dispose();
73109 },
74110 };
src/main.tsmodified+16−5View 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 { registerStanLanguage } from './stan/language';
@@ -8,13 +9,17 @@ import { getProject } from './projects';
89 // Routes:
910 // #/ project picker (landing page)
1011 // #/project/<id> the IDE, opened on that project's file system
12+// #/github/<spec> a GitHub repo as its own workspace (owner/repo[@ref],
13+// or a URL-encoded github.com URL); imports on first
14+// visit, then keeps a local editable copy — the URL
15+// stays on this route and no project is created
1116
1217 async function start(): Promise<void> {
13- // dev never uses a service worker (isolation comes from server headers) —
14- // unregister any stale coi-serviceworker left over from a production
15- // build or an earlier version, since a controlling stale SW can block
16- // module workers with mismatched COEP headers
17- if (import.meta.env.DEV && 'serviceWorker' in navigator) {
18+ // this app no longer uses a service worker (pure-WASI sampling needs no
19+ // cross-origin isolation) — unregister the coi-serviceworker that earlier
20+ // deployed versions registered, since a stale controlling SW keeps
21+ // rewriting response headers
22+ if ('serviceWorker' in navigator) {
1823 const registrations = await navigator.serviceWorker.getRegistrations();
1924 if (registrations.length > 0) {
2025 await Promise.all(registrations.map(r => r.unregister()));
@@ -49,6 +54,12 @@ async function start(): Promise<void> {
4954 current = undefined;
5055 app.textContent = '';
5156
57+ const github = location.hash.match(/^#\/github\/(.+)$/);
58+ if (github) {
59+ current = await openGitHubRoute(app, decodeURIComponent(github[1]), theme);
60+ return;
61+ }
62+
5263 const match = location.hash.match(/^#\/project\/([a-z0-9]+)/i);
5364 if (match) {
5465 const project = getProject(match[1]);