concept-collection / minwebide-demo
Project picker landing page with per-project file systems
- projects stored in the browser: localStorage registry, one IndexedDB database per project - landing page: create (defaults to 'untitled'), sample project template, rename, duplicate (copies all files), delete (drops the database) - hash routing: #/project/<id> addresses the open project; unknown ids fall back to the landing page - status bar project indicator returns to the project list - smoke test covering the full project lifecycle
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 68d072b15860 parent 62f426b Browse files
10 changed files+698−43
README.mdmodified+5−1View file
@@ -6,7 +6,10 @@ system persisted in your browser's IndexedDB.
66
77 **Live site:** https://concept-collection.github.io/minwebide-demo/
88
9-Things to try:
9+The landing page manages **projects**: each project is its own workspace in
10+your browser's IndexedDB (create / rename / duplicate / delete), and the open
11+project is addressed in the URL as `#/project/<id>`. Create a **sample
12+project** to see the showcase content, then try:
1013
1114 - Open files from the Explorer; edit and press **Ctrl+S**; reload — changes persist.
1215 - `data/measurements.csv` opens as a table; `README.md` has a markdown preview
@@ -14,6 +17,7 @@ Things to try:
1417 - Open `scripts/sine-wave.js` and press **▶**: console output goes to the
1518 bottom Output panel, plots render in the secondary side bar.
1619 - Search across files from the activity bar.
20+- The project name in the status bar takes you back to the project list.
1721
1822 ## Development
1923
package-lock.jsonmodified+48−0View file
@@ -11,6 +11,7 @@
1111 "minwebide": "file:../minwebide"
1212 },
1313 "devDependencies": {
14+ "playwright": "^1.61.1",
1415 "typescript": "^5.9.0",
1516 "vite": "^7.0.0"
1617 }
@@ -989,6 +990,53 @@
989990 "url": "https://github.com/sponsors/jonschlinkert"
990991 }
991992 },
993+ "node_modules/playwright": {
994+ "version": "1.61.1",
995+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
996+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
997+ "dev": true,
998+ "license": "Apache-2.0",
999+ "dependencies": {
1000+ "playwright-core": "1.61.1"
1001+ },
1002+ "bin": {
1003+ "playwright": "cli.js"
1004+ },
1005+ "engines": {
1006+ "node": ">=18"
1007+ },
1008+ "optionalDependencies": {
1009+ "fsevents": "2.3.2"
1010+ }
1011+ },
1012+ "node_modules/playwright-core": {
1013+ "version": "1.61.1",
1014+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
1015+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
1016+ "dev": true,
1017+ "license": "Apache-2.0",
1018+ "bin": {
1019+ "playwright-core": "cli.js"
1020+ },
1021+ "engines": {
1022+ "node": ">=18"
1023+ }
1024+ },
1025+ "node_modules/playwright/node_modules/fsevents": {
1026+ "version": "2.3.2",
1027+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
1028+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
1029+ "dev": true,
1030+ "hasInstallScript": true,
1031+ "license": "MIT",
1032+ "optional": true,
1033+ "os": [
1034+ "darwin"
1035+ ],
1036+ "engines": {
1037+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1038+ }
1039+ },
9921040 "node_modules/postcss": {
9931041 "version": "8.5.16",
9941042 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
package.jsonmodified+1−0View file
@@ -13,6 +13,7 @@
1313 "minwebide": "file:../minwebide"
1414 },
1515 "devDependencies": {
16+ "playwright": "^1.61.1",
1617 "typescript": "^5.9.0",
1718 "vite": "^7.0.0"
1819 }
scripts/smoke.mjsadded+108−0View file
@@ -0,0 +1,108 @@
1+// End-to-end smoke test for the project-picker demo:
2+// landing → new project → IDE → edit+save → back → rename/duplicate →
3+// reopen (persistence + URL routing) → delete → sample project.
4+import { chromium } from 'playwright';
5+import { spawn } from 'node:child_process';
6+
7+const root = new URL('..', import.meta.url).pathname;
8+const out = process.argv[2] ?? '.';
9+const previewProc = spawn('npx', ['vite', 'preview', '--port', '4179', '--strictPort'], { stdio: 'ignore', cwd: root });
10+await new Promise((r) => setTimeout(r, 1500));
11+
12+const browser = await chromium.launch({ channel: 'chrome', headless: true });
13+const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
14+const errors = [];
15+page.on('pageerror', (e) => errors.push(e.message));
16+page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
17+const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`);
18+
19+try {
20+ await page.goto('http://localhost:4179/', { waitUntil: 'networkidle' });
21+ await page.waitForTimeout(1200);
22+ check('landing renders', await page.locator('.landing-empty').count() === 1);
23+ await page.screenshot({ path: out + '/p-landing.png' });
24+
25+ // create an empty project → should land in the IDE at #/project/<id>
26+ await page.getByRole('button', { name: 'New project', exact: true }).click();
27+ await page.waitForTimeout(1500);
28+ const url1 = page.url();
29+ check('URL has project route', /#\/project\/[a-z0-9]+/i.test(url1));
30+ check('IDE opened with README', await page.locator('.mw-tab-label', { hasText: 'README.md' }).count() === 1);
31+ check('status bar shows project', await page.locator('.mw-statusbar', { hasText: 'untitled' }).count() === 1);
32+
33+ // make an edit and save
34+ await page.locator('.mw-editor-host').click();
35+ await page.keyboard.press('Control+End');
36+ await page.keyboard.type('\nPROJECT-MARKER-7\n');
37+ await page.keyboard.press('Control+s');
38+ await page.waitForTimeout(600);
39+
40+ // back to landing via the status bar project item
41+ await page.getByTitle('Back to projects').click();
42+ await page.waitForTimeout(800);
43+ check('back on landing', await page.locator('.landing-project').count() === 1);
44+
45+ // rename
46+ page.once('dialog', (d) => d.accept('renamed-project'));
47+ await page.locator('.landing-project').hover();
48+ await page.getByRole('button', { name: 'Rename' }).click();
49+ await page.waitForTimeout(300);
50+ check('rename applied', await page.locator('.landing-project-name', { hasText: 'renamed-project' }).count() === 1);
51+
52+ // duplicate
53+ await page.locator('.landing-project').first().hover();
54+ await page.getByRole('button', { name: 'Duplicate' }).first().click();
55+ await page.waitForTimeout(1200);
56+ check('duplicate created', await page.locator('.landing-project').count() === 2);
57+
58+ // reopen the duplicate: the saved marker must have been copied
59+ await page.locator('.landing-project-name', { hasText: 'renamed-project-copy' }).click();
60+ await page.waitForTimeout(1500);
61+ const text = (await page.locator('.mw-editor-host').innerText()).replace(/ /g, ' ');
62+ check('duplicate carries saved edit', text.includes('PROJECT-MARKER-7'));
63+ await page.screenshot({ path: out + '/p-duplicate.png' });
64+
65+ // deep-link directly to the original by URL
66+ const originalHref = await page.evaluate(() => {
67+ const items = JSON.parse(localStorage.getItem('minwebide-demo.projects'));
68+ return items.find((p) => p.name === 'renamed-project').id;
69+ });
70+ await page.goto(`http://localhost:4179/#/project/${originalHref}`, { waitUntil: 'networkidle' });
71+ await page.waitForTimeout(1500);
72+ check('deep link opens project', await page.locator('.mw-statusbar', { hasText: 'renamed-project' }).count() === 1);
73+
74+ // delete both from the landing page
75+ await page.goto('http://localhost:4179/#/', { waitUntil: 'networkidle' });
76+ await page.waitForTimeout(800);
77+ for (let i = 0; i < 2; i++) {
78+ page.once('dialog', (d) => d.accept());
79+ await page.locator('.landing-project').first().hover();
80+ await page.getByRole('button', { name: 'Delete' }).first().click();
81+ await page.waitForTimeout(500);
82+ }
83+ check('projects deleted', await page.locator('.landing-empty').count() === 1);
84+
85+ // sample project has the showcase content
86+ await page.getByRole('button', { name: 'New sample project' }).click();
87+ await page.waitForTimeout(1800);
88+ check('sample project opens', await page.locator('.mw-tab-label', { hasText: 'README.md' }).count() === 1);
89+ await page.getByText('scripts', { exact: true }).click();
90+ await page.waitForTimeout(400);
91+ check('sample has runnable script', await page.getByText('sine-wave.js', { exact: true }).count() === 1);
92+ await page.screenshot({ path: out + '/p-sample.png' });
93+
94+ // unknown project id falls back to landing
95+ await page.goto('http://localhost:4179/#/project/nope1234', { waitUntil: 'networkidle' });
96+ await page.waitForTimeout(800);
97+ check('unknown id falls back to landing', await page.locator('.landing-header').count() === 1);
98+
99+ if (errors.length) {
100+ console.log('page errors:');
101+ for (const e of errors.slice(0, 10)) console.log(' ' + e);
102+ } else {
103+ console.log('no page errors');
104+ }
105+} finally {
106+ await browser.close();
107+ previewProc.kill();
108+}
src/ide.tsadded+43−0View file
@@ -0,0 +1,43 @@
1+import { createWorkbench, type WorkbenchTheme } from 'minwebide';
2+import { demoCustomEditors } from './customEditors';
3+import { demoRunners } from './runners';
4+import { openProjectFileSystem, touchProject, type ProjectInfo } from './projects';
5+
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} — minwebide demo`;
10+
11+ const fs = await openProjectFileSystem(project.id);
12+
13+ const workbench = createWorkbench(container, {
14+ fileSystem: fs,
15+ theme,
16+ workspaceName: project.name,
17+ customEditors: demoCustomEditors,
18+ });
19+ for (const runner of demoRunners) {
20+ workbench.registerRunner(runner);
21+ }
22+
23+ // the project indicator: click to go back to the project list
24+ workbench.statusBar.setItem('project', 'left', project.name, {
25+ icon: 'folder-opened',
26+ title: 'Back to projects',
27+ onClick: () => { location.hash = '#/'; },
28+ });
29+ // replace the default branding item with the project indicator
30+ workbench.statusBar.removeItem('branding');
31+
32+ const readme = fs.root.with({ path: '/README.md' });
33+ if (await fs.fileService.exists(readme)) {
34+ await workbench.openFile(readme);
35+ }
36+
37+ return {
38+ dispose() {
39+ workbench.dispose();
40+ fs.dispose();
41+ },
42+ };
43+}
src/landing.cssadded+151−0View file
@@ -0,0 +1,151 @@
1+/* Project-picker landing page, themed with the same --vscode-* variables as
2+ * the workbench (in the spirit of VS Code's welcome page). */
3+
4+.landing {
5+ height: 100%;
6+ overflow-y: auto;
7+ background-color: var(--vscode-editor-background);
8+ color: var(--vscode-foreground);
9+ font-family: system-ui, 'Ubuntu', 'Droid Sans', sans-serif;
10+ font-size: 14px;
11+}
12+
13+.landing-inner {
14+ max-width: 720px;
15+ margin: 0 auto;
16+ padding: 48px 32px 64px;
17+}
18+
19+.landing-header h1 {
20+ margin: 0 0 6px;
21+ font-size: 34px;
22+ font-weight: 300;
23+ letter-spacing: 0.5px;
24+}
25+
26+.landing-subtitle {
27+ margin: 0 0 4px;
28+ color: var(--vscode-descriptionForeground);
29+}
30+
31+.landing-link {
32+ color: var(--vscode-textLink-foreground);
33+ text-decoration: none;
34+ font-size: 13px;
35+}
36+
37+.landing-link:hover {
38+ text-decoration: underline;
39+}
40+
41+.landing-section {
42+ margin-top: 36px;
43+}
44+
45+.landing-section h2 {
46+ margin: 0 0 12px;
47+ font-size: 18px;
48+ font-weight: 400;
49+ border-bottom: 1px solid var(--vscode-panel-border);
50+ padding-bottom: 6px;
51+}
52+
53+.landing-start {
54+ display: flex;
55+ gap: 10px;
56+}
57+
58+.landing-button {
59+ padding: 6px 14px;
60+ font-size: 13px;
61+ font-family: inherit;
62+ cursor: pointer;
63+ border-radius: 3px;
64+ border: 1px solid var(--vscode-button-border, transparent);
65+ background-color: var(--vscode-button-secondaryBackground);
66+ color: var(--vscode-button-secondaryForeground);
67+}
68+
69+.landing-button:hover {
70+ background-color: var(--vscode-button-secondaryHoverBackground);
71+}
72+
73+.landing-button.primary {
74+ background-color: var(--vscode-button-background);
75+ color: var(--vscode-button-foreground);
76+}
77+
78+.landing-button.primary:hover {
79+ background-color: var(--vscode-button-hoverBackground);
80+}
81+
82+.landing-button:disabled {
83+ opacity: 0.6;
84+ cursor: default;
85+}
86+
87+.landing-projects {
88+ display: flex;
89+ flex-direction: column;
90+}
91+
92+.landing-empty {
93+ color: var(--vscode-descriptionForeground);
94+ padding: 8px 0;
95+}
96+
97+.landing-project {
98+ display: flex;
99+ align-items: center;
100+ gap: 12px;
101+ padding: 7px 8px;
102+ border-radius: 4px;
103+}
104+
105+.landing-project:hover {
106+ background-color: var(--vscode-list-hoverBackground);
107+}
108+
109+.landing-project-name {
110+ color: var(--vscode-textLink-foreground);
111+ text-decoration: none;
112+ font-size: 14px;
113+ overflow: hidden;
114+ text-overflow: ellipsis;
115+ white-space: nowrap;
116+}
117+
118+.landing-project-name:hover {
119+ text-decoration: underline;
120+}
121+
122+.landing-project-meta {
123+ color: var(--vscode-descriptionForeground);
124+ font-size: 12px;
125+ flex: 1;
126+}
127+
128+.landing-project-actions {
129+ display: flex;
130+ gap: 2px;
131+ visibility: hidden;
132+}
133+
134+.landing-project:hover .landing-project-actions {
135+ visibility: visible;
136+}
137+
138+.landing-action {
139+ background: none;
140+ border: none;
141+ padding: 2px 7px;
142+ font-size: 12px;
143+ font-family: inherit;
144+ cursor: pointer;
145+ border-radius: 3px;
146+ color: var(--vscode-textLink-foreground);
147+}
148+
149+.landing-action:hover {
150+ background-color: var(--vscode-toolbar-hoverBackground);
151+}
src/landing.tsadded+157−0View file
@@ -0,0 +1,157 @@
1+import { applyThemeToElement, type WorkbenchTheme } from 'minwebide';
2+import { createProject, deleteProject, duplicateProject, listProjects, nextUntitledName, openProjectFileSystem, renameProject, type ProjectInfo } from './projects';
3+import { sampleWorkspace } from './sampleWorkspace';
4+import { generateSampleImage } from './sampleImage';
5+import './landing.css';
6+
7+function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
8+ const node = document.createElement(tag);
9+ if (className) {
10+ node.className = className;
11+ }
12+ if (text !== undefined) {
13+ node.textContent = text;
14+ }
15+ return node;
16+}
17+
18+function openProject(id: string): void {
19+ location.hash = `#/project/${id}`;
20+}
21+
22+async function createSampleProject(): Promise<ProjectInfo> {
23+ const project = createProject(nextUntitledName('sample'));
24+ const fs = await openProjectFileSystem(project.id);
25+ try {
26+ await fs.seed({
27+ ...sampleWorkspace,
28+ '/assets/banner.png': await generateSampleImage(),
29+ });
30+ } finally {
31+ fs.dispose();
32+ }
33+ return project;
34+}
35+
36+async function createEmptyProject(): Promise<ProjectInfo> {
37+ const project = createProject(nextUntitledName());
38+ const fs = await openProjectFileSystem(project.id);
39+ try {
40+ await fs.seed({
41+ '/README.md': `# ${project.name}\n\nAn empty minwebide project. Files live in your browser's IndexedDB.\n`,
42+ });
43+ } finally {
44+ fs.dispose();
45+ }
46+ return project;
47+}
48+
49+function formatWhen(timestamp: number): string {
50+ const delta = Date.now() - timestamp;
51+ if (delta < 60_000) {
52+ return 'just now';
53+ }
54+ if (delta < 3_600_000) {
55+ return `${Math.round(delta / 60_000)}m ago`;
56+ }
57+ if (delta < 86_400_000) {
58+ return `${Math.round(delta / 3_600_000)}h ago`;
59+ }
60+ return new Date(timestamp).toLocaleDateString();
61+}
62+
63+/** Renders the project-picker landing page. Returns a disposable view. */
64+export function renderLanding(container: HTMLElement, theme: WorkbenchTheme): { dispose(): void } {
65+ const root = el('div', 'landing');
66+ applyThemeToElement(theme, root);
67+ container.appendChild(root);
68+ document.title = 'minwebide demo';
69+
70+ const inner = el('div', 'landing-inner');
71+ root.appendChild(inner);
72+
73+ const header = el('header', 'landing-header');
74+ header.appendChild(el('h1', undefined, 'minwebide'));
75+ header.appendChild(el('p', 'landing-subtitle', 'A minimalistic web IDE built from VS Code’s own source. Projects are stored in your browser.'));
76+ const ghLink = el('a', 'landing-link', 'github.com/magland/minwebide');
77+ ghLink.href = 'https://github.com/magland/minwebide';
78+ header.appendChild(ghLink);
79+ inner.appendChild(header);
80+
81+ // start section
82+ const start = el('section', 'landing-section');
83+ start.appendChild(el('h2', undefined, 'Start'));
84+ const startButtons = el('div', 'landing-start');
85+ const newButton = el('button', 'landing-button primary', 'New project');
86+ newButton.addEventListener('click', async () => {
87+ newButton.disabled = true;
88+ openProject((await createEmptyProject()).id);
89+ });
90+ const sampleButton = el('button', 'landing-button', 'New sample project');
91+ sampleButton.title = 'Seeded with runnable scripts, a CSV table, markdown preview, and plots';
92+ sampleButton.addEventListener('click', async () => {
93+ sampleButton.disabled = true;
94+ openProject((await createSampleProject()).id);
95+ });
96+ startButtons.append(newButton, sampleButton);
97+ start.appendChild(startButtons);
98+ inner.appendChild(start);
99+
100+ // projects section
101+ const section = el('section', 'landing-section');
102+ section.appendChild(el('h2', undefined, 'Projects'));
103+ const list = el('div', 'landing-projects');
104+ section.appendChild(list);
105+ inner.appendChild(section);
106+
107+ const renderList = () => {
108+ list.textContent = '';
109+ const projects = listProjects();
110+ if (projects.length === 0) {
111+ list.appendChild(el('div', 'landing-empty', 'No projects yet.'));
112+ return;
113+ }
114+ for (const project of projects) {
115+ const row = el('div', 'landing-project');
116+
117+ const name = el('a', 'landing-project-name', project.name);
118+ name.href = `#/project/${project.id}`;
119+ row.appendChild(name);
120+
121+ row.appendChild(el('span', 'landing-project-meta', `opened ${formatWhen(project.lastOpenedAt)}`));
122+
123+ const actions = el('span', 'landing-project-actions');
124+ const action = (label: string, handler: () => void | Promise<void>) => {
125+ const button = el('button', 'landing-action', label);
126+ button.addEventListener('click', () => handler());
127+ actions.appendChild(button);
128+ };
129+ action('Rename', () => {
130+ const name = prompt('Project name', project.name);
131+ if (name !== null) {
132+ renameProject(project.id, name);
133+ renderList();
134+ }
135+ });
136+ action('Duplicate', async () => {
137+ await duplicateProject(project.id);
138+ renderList();
139+ });
140+ action('Delete', async () => {
141+ if (confirm(`Delete project "${project.name}" and all of its files?`)) {
142+ await deleteProject(project.id);
143+ renderList();
144+ }
145+ });
146+ row.appendChild(actions);
147+ list.appendChild(row);
148+ }
149+ };
150+ renderList();
151+
152+ return {
153+ dispose() {
154+ root.remove();
155+ },
156+ };
157+}
src/main.tsmodified+43−42View file
@@ -1,49 +1,50 @@
1-import { createIndexedDBFileSystem, createWorkbench, loadBuiltinTheme, registerBuiltinLanguages } from 'minwebide';
2-import { demoCustomEditors } from './customEditors';
3-import { demoRunners } from './runners';
4-import { sampleWorkspace } from './sampleWorkspace';
5-
6-/** A sample PNG, generated on the fly, to exercise the binary file path. */
7-async function generateSampleImage(): Promise<Uint8Array> {
8- const canvas = document.createElement('canvas');
9- canvas.width = 320;
10- canvas.height = 200;
11- const ctx = canvas.getContext('2d')!;
12- const gradient = ctx.createLinearGradient(0, 0, 320, 200);
13- gradient.addColorStop(0, '#0078d4');
14- gradient.addColorStop(1, '#4ec9b0');
15- ctx.fillStyle = gradient;
16- ctx.fillRect(0, 0, 320, 200);
17- ctx.fillStyle = 'white';
18- ctx.font = 'bold 28px sans-serif';
19- ctx.textAlign = 'center';
20- ctx.fillText('minwebide', 160, 108);
21- const blob = await new Promise<Blob>((resolve, reject) =>
22- canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png'));
23- return new Uint8Array(await blob.arrayBuffer());
24-}
1+import { loadBuiltinTheme, registerBuiltinLanguages } from 'minwebide';
2+import { openIde } from './ide';
3+import { renderLanding } from './landing';
4+import { getProject } from './projects';
5+
6+// Routes:
7+// #/ project picker (landing page)
8+// #/project/<id> the IDE, opened on that project's file system
259
26-async function main(): Promise<void> {
27- const fs = await createIndexedDBFileSystem({ dbName: 'minwebide-demo' });
28- await fs.seed({
29- ...sampleWorkspace,
30- '/assets/banner.png': await generateSampleImage(),
31- });
10+async function start(): Promise<void> {
11+ const app = document.getElementById('app')!;
3212
13+ // one-time global setup: theme + languages are shared by all views
3314 const theme = await loadBuiltinTheme('dark_modern');
3415 await registerBuiltinLanguages(theme);
3516
36- const workbench = createWorkbench(document.getElementById('app')!, {
37- fileSystem: fs,
38- theme,
39- workspaceName: 'demo workspace',
40- customEditors: demoCustomEditors,
41- });
42- for (const runner of demoRunners) {
43- workbench.registerRunner(runner);
44- }
45-
46- await workbench.openFile(fs.root.with({ path: '/README.md' }));
17+ let current: { dispose(): void } | undefined;
18+ let navigating = false;
19+
20+ const route = async () => {
21+ if (navigating) {
22+ return;
23+ }
24+ navigating = true;
25+ try {
26+ current?.dispose();
27+ current = undefined;
28+ app.textContent = '';
29+
30+ const match = location.hash.match(/^#\/project\/([a-z0-9]+)/i);
31+ if (match) {
32+ const project = getProject(match[1]);
33+ if (project) {
34+ current = await openIde(app, project, theme);
35+ return;
36+ }
37+ // unknown project id: fall through to the landing page
38+ history.replaceState(null, '', '#/');
39+ }
40+ current = renderLanding(app, theme);
41+ } finally {
42+ navigating = false;
43+ }
44+ };
45+
46+ window.addEventListener('hashchange', route);
47+ await route();
4748 }
4849
49-main();
50+start();
src/projects.tsadded+123−0View file
@@ -0,0 +1,123 @@
1+import { createIndexedDBFileSystem, type WorkspaceFileSystem } from 'minwebide';
2+
3+// The project registry: a small localStorage index of projects, each backed
4+// by its own IndexedDB database (its own workspace file system).
5+
6+export interface ProjectInfo {
7+ readonly id: string;
8+ name: string;
9+ createdAt: number;
10+ lastOpenedAt: number;
11+}
12+
13+const REGISTRY_KEY = 'minwebide-demo.projects';
14+
15+function readRegistry(): ProjectInfo[] {
16+ try {
17+ const raw = localStorage.getItem(REGISTRY_KEY);
18+ const parsed = raw ? JSON.parse(raw) : [];
19+ return Array.isArray(parsed) ? parsed : [];
20+ } catch {
21+ return [];
22+ }
23+}
24+
25+function writeRegistry(projects: ProjectInfo[]): void {
26+ localStorage.setItem(REGISTRY_KEY, JSON.stringify(projects));
27+}
28+
29+export function projectDbName(id: string): string {
30+ return `minwebide-demo-project-${id}`;
31+}
32+
33+export function listProjects(): ProjectInfo[] {
34+ return readRegistry().sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
35+}
36+
37+export function getProject(id: string): ProjectInfo | undefined {
38+ return readRegistry().find(p => p.id === id);
39+}
40+
41+/** Picks 'untitled', 'untitled-2', ... skipping names already in use. */
42+export function nextUntitledName(base = 'untitled'): string {
43+ const names = new Set(readRegistry().map(p => p.name));
44+ if (!names.has(base)) {
45+ return base;
46+ }
47+ for (let i = 2; ; i++) {
48+ if (!names.has(`${base}-${i}`)) {
49+ return `${base}-${i}`;
50+ }
51+ }
52+}
53+
54+export function createProject(name: string): ProjectInfo {
55+ const project: ProjectInfo = {
56+ id: Math.random().toString(36).slice(2, 10),
57+ name,
58+ createdAt: Date.now(),
59+ lastOpenedAt: Date.now(),
60+ };
61+ writeRegistry([...readRegistry(), project]);
62+ return project;
63+}
64+
65+export function renameProject(id: string, name: string): void {
66+ const projects = readRegistry();
67+ const project = projects.find(p => p.id === id);
68+ if (project && name.trim()) {
69+ project.name = name.trim();
70+ writeRegistry(projects);
71+ }
72+}
73+
74+export function touchProject(id: string): void {
75+ const projects = readRegistry();
76+ const project = projects.find(p => p.id === id);
77+ if (project) {
78+ project.lastOpenedAt = Date.now();
79+ writeRegistry(projects);
80+ }
81+}
82+
83+export async function deleteProject(id: string): Promise<void> {
84+ writeRegistry(readRegistry().filter(p => p.id !== id));
85+ await new Promise<void>((resolve) => {
86+ const request = indexedDB.deleteDatabase(projectDbName(id));
87+ request.onsuccess = request.onerror = request.onblocked = () => resolve();
88+ });
89+}
90+
91+export async function openProjectFileSystem(id: string): Promise<WorkspaceFileSystem> {
92+ return createIndexedDBFileSystem({ dbName: projectDbName(id) });
93+}
94+
95+/** Copies all files of one project into a brand-new project. */
96+export async function duplicateProject(id: string): Promise<ProjectInfo | undefined> {
97+ const source = getProject(id);
98+ if (!source) {
99+ return undefined;
100+ }
101+ const copy = createProject(nextUntitledName(`${source.name}-copy`));
102+ const sourceFs = await openProjectFileSystem(source.id);
103+ const targetFs = await openProjectFileSystem(copy.id);
104+ try {
105+ const copyTree = async (path: string): Promise<void> => {
106+ const stat = await sourceFs.fileService.resolve(sourceFs.root.with({ path }));
107+ for (const child of stat.children ?? []) {
108+ if (child.isDirectory) {
109+ await targetFs.fileService.createFolder(targetFs.root.with({ path: child.resource.path }));
110+ await copyTree(child.resource.path);
111+ } else {
112+ const content = await sourceFs.fileService.readFile(child.resource);
113+ await targetFs.fileService.writeFile(targetFs.root.with({ path: child.resource.path }), content.value);
114+ }
115+ }
116+ };
117+ await copyTree('/');
118+ } finally {
119+ sourceFs.dispose();
120+ targetFs.dispose();
121+ }
122+ return copy;
123+}
src/sampleImage.tsadded+19−0View file
@@ -0,0 +1,19 @@
1+/** A sample PNG, generated on the fly, to exercise the binary file path. */
2+export async function generateSampleImage(): Promise<Uint8Array> {
3+ const canvas = document.createElement('canvas');
4+ canvas.width = 320;
5+ canvas.height = 200;
6+ const ctx = canvas.getContext('2d')!;
7+ const gradient = ctx.createLinearGradient(0, 0, 320, 200);
8+ gradient.addColorStop(0, '#0078d4');
9+ gradient.addColorStop(1, '#4ec9b0');
10+ ctx.fillStyle = gradient;
11+ ctx.fillRect(0, 0, 320, 200);
12+ ctx.fillStyle = 'white';
13+ ctx.font = 'bold 28px sans-serif';
14+ ctx.textAlign = 'center';
15+ ctx.fillText('minwebide', 160, 108);
16+ const blob = await new Promise<Blob>((resolve, reject) =>
17+ canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png'));
18+ return new Uint8Array(await blob.arrayBuffer());
19+}