From 43c1c76675dd624af959a09dcbe837fb39733042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:32:09 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=E2=8B=AE=20=C2=ABInfo=C2=BB=20with=20the?= =?UTF-8?q?=20version;=20a=20new=20tab=20starts=20empty;=20browser=20check?= =?UTF-8?q?=20for=20the=20IDE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⋮ «Info» shows the version - the short commit and the build date, as the build writes them to version.json (now with "built") - and «Nach Updates suchen» runs the same check as the «Neue Version verfügbar» notice: «Diese Version ist aktuell.» or the reload. - A fresh tab starts with one empty main.py instead of the editor's English starter project (main.py, greetings.py, notes.md); the editor's placeholder says what to do. The same empty project as for a link that can't be read and ⋮ «Neues leeres Projekt» (was «Neues Projekt»). An untouched empty project is recognised by content (one empty file to run), not by name, so it counts as saved and never appears among «Zuletzt geschlossen». - scripts/check-ide-browser.mjs (npm run test:browser, in CI): headless Chrome against vite preview of dist/, with Pyodide from its CDN and its own fixtures. Tab switches with a read-only file plus typing, undo and redo (glpython-editor #41), per-tab projects, share links (library by URL served by the check, 404, cut at 2,083 characters, long link), ▶ and Tests on two files, ⋮ «Info». Without the editor's #41 fix it fails. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 + CLAUDE.md | 3 +- README.md | 15 ++- package-lock.json | 14 ++ package.json | 4 +- scripts/check-ide-browser.mjs | 240 ++++++++++++++++++++++++++++++++++ src/App.tsx | 98 ++++++++++---- src/i18n.ts | 25 +++- src/index.css | 9 ++ src/tabProjects.ts | 35 ++--- vite.config.ts | 10 +- 11 files changed, 406 insertions(+), 52 deletions(-) create mode 100644 scripts/check-ide-browser.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0734bdd..d358339 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,8 @@ jobs: # tsc -b && vite build - run: npm run build - run: npm run lint + # The IDE in Chrome (on the runner) with Pyodide from its CDN, against + # the build above: tab switches with a read-only file, per-tab projects, + # share links (libraries by URL, 404, cut and long links), ▶ and Tests, + # ⋮ «Info». The «leave the page» question and the iPad stay manual. + - run: npm run test:browser diff --git a/CLAUDE.md b/CLAUDE.md index ff67b1e..6bb19ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,9 +14,10 @@ Sibling repos: `../glpython-editor` (the component, runtime and LiaScript embed; npm run dev # vite with COOP/COEP headers (input() needs SharedArrayBuffer) npm run build # tsc -b && vite build (base /) npm run lint # oxlint; 7 known warnings, all in the vendored public/coi-serviceworker.js +npm run test:browser # after a build: scripts/check-ide-browser.mjs, the IDE in headless Chrome ``` -There are no tests. `.github/workflows/ci.yml` runs `npm ci`, the build (tsc catches type changes in a new editor tag) and lint on every pull request. Pushes to `main` deploy to GitHub Pages (`deploy-pages.yml`). To check a change in the browser, use `npm run dev`; `npm run build && npm run preview` serves the production layout. The embed is a separate project site under the same origin, https://glpython-edu.github.io/glpython-editor/. The IDE's `coi-serviceworker.js` registers for scope `/`, which includes it, so after a change to that worker check the embed too: its demo page opened directly, and a LiaScript course. +The one automated check is `test:browser` (`scripts/check-ide-browser.mjs`): `playwright-core` drives the installed Chrome (GitHub's runner has one) against `vite preview` of `dist/`, with Pyodide from its CDN and its own fixtures (it serves a library by URL itself). It covers tab switches with a read-only file, per-tab projects, share links (library by URL, 404, cut and long links), ▶ and Tests, and ⋮ «Info»; add regression cases there. The browser's «leave the page» question and the iPad stay manual. `.github/workflows/ci.yml` runs `npm ci`, the build (tsc catches type changes in a new editor tag), lint and `test:browser` on every pull request. Pushes to `main` deploy to GitHub Pages (`deploy-pages.yml`). To check a change in the browser, use `npm run dev`; `npm run build && npm run preview` serves the production layout. The embed is a separate project site under the same origin, https://glpython-edu.github.io/glpython-editor/. The IDE's `coi-serviceworker.js` registers for scope `/`, which includes it, so after a change to that worker check the embed too: its demo page opened directly, and a LiaScript course. ## Layout diff --git a/README.md b/README.md index 19d6004..1179254 100644 --- a/README.md +++ b/README.md @@ -81,18 +81,27 @@ automatically in the browser: **Teilen** or **Projekt herunterladen** brings the browser's own question (desktop browsers; iPad Safari never asks). - A closed tab's project is kept for 14 days: ⋮ **Zuletzt geschlossen** lists - them («Öffnen» in this tab if it holds only the untouched starter, else in a + them («Öffnen» in this tab if it holds only an untouched empty project, else in a new tab; «Entfernen»). A new tab offers the last one changed within a day right away («Zuletzt bearbeitet: auswerter.py (vor 2 Stunden).» · «Öffnen»), since after quitting Safari or restarting the iPad a tab comes back empty. -- ⋮ **Neues Projekt** gives the tab the starter; opening a folder or a ZIP - gives it that project. The one before goes to «Zuletzt geschlossen». +- A new tab starts with **one empty `main.py`**; the editor's placeholder + («Code hier eingeben») says what to do. Learners arrive by links, and the + IDE needs one file to run. An untouched empty project counts as saved and + never appears under «Zuletzt geschlossen». +- ⋮ **Neues leeres Projekt** gives the tab that empty project; opening a + folder or a ZIP gives it that project. The one before goes to «Zuletzt + geschlossen». - The one project the IDE kept before per-tab projects becomes the first tab's project, once. - A tab that stays open notices a newer IDE version (`version.json`, written by the build, checked when the tab comes back into view and every 30 minutes): «Neue Version verfügbar.» · «Neu laden». The code survives the reload; Python's session doesn't. +- ⋮ **Info** shows the version - the short commit and the build date, as in + `version.json` - and **Nach Updates suchen** runs the same check: «Diese + Version ist aktuell.», or the notice with «Neu laden». When a learner reports + a problem, it tells whether their browser still runs an old version. The ⋮ menu and the bar support: diff --git a/package-lock.json b/package-lock.json index 98d18f0..3029031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", "oxlint": "^1.81.0", + "playwright-core": "^1.64.1", "typescript": "~6.0.2", "vite": "^8.3.0" } @@ -1255,6 +1256,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/package.json b/package.json index b7b4e31..9ce8900 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev": "vite", "build": "tsc -b && vite build && cp LICENSE THIRD_PARTY_NOTICES.md dist/", "lint": "oxlint", - "preview": "vite preview" + "preview": "vite preview", + "test:browser": "node scripts/check-ide-browser.mjs" }, "dependencies": { "@azure/msal-browser": "^5.22.0", @@ -25,6 +26,7 @@ "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", "oxlint": "^1.81.0", + "playwright-core": "^1.64.1", "typescript": "~6.0.2", "vite": "^8.3.0" } diff --git a/scripts/check-ide-browser.mjs b/scripts/check-ide-browser.mjs new file mode 100644 index 0000000..a50486c --- /dev/null +++ b/scripts/check-ide-browser.mjs @@ -0,0 +1,240 @@ +// Browser check of the IDE: headless Chrome against the production build +// (`vite preview` of dist/), with Pyodide from its CDN. Covers what broke or +// is easy to break by hand: +// - the tab switch with a read-only file (glpython-editor #41: opening it +// copied its text into the other file), with typing, undo and redo; +// - per-tab projects: two links in two tabs, reload, the bare address, a +// closed tab's project offered again; +// - share links: a library by URL (served here), a library that gives 404, +// a link cut at 2,083 characters, the note for a long link; +// - ▶ and «Tests» on a project of two files; ⋮ «Info» and its update check. +// The browser's «leave the page» question and the iPad cases stay manual: +// headless Chrome can't confirm them. +// +// Needs Google Chrome (GitHub's ubuntu-latest has it). +// npm run build && npm run test:browser +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' +import http from 'node:http' +import lz from 'lz-string' +import { chromium } from 'playwright-core' + +const port = 4196 +const base = `http://localhost:${port}/` +const libraryPort = 4197 +const libraryBase = `http://localhost:${libraryPort}/` +const server = spawn('npx', ['vite', 'preview', '--port', String(port), '--strictPort'], { stdio: 'ignore' }) + +// The libraries a link names by URL: bib.py, with CORS as GitHub Pages sends +// it; anything else is a 404 (with CORS too, so the IDE sees the status). +const library = 'def gross(text):\n return text.upper()\n' +const libraryServer = http.createServer((request, response) => { + const headers = { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'text/plain; charset=utf-8' } + if (new URL(request.url, libraryBase).pathname === '/bib.py') { + response.writeHead(200, headers) + response.end(library) + } else { + response.writeHead(404, headers) + response.end('not found') + } +}).listen(libraryPort) +const stop = () => { server.kill(); libraryServer.close() } +process.on('exit', stop) + +const file = (path, code, extra = {}) => ({ path, label: path, kind: 'python', code, ...extra }) +const link = (files, mainFile, version = 1) => { + const url = new URL(base) + url.searchParams.set('project', lz.compressToEncodedURIComponent(JSON.stringify({ v: version, name: 'Check', mainFile, files: Object.fromEntries(files.map((entry) => [entry.path, entry])) }))) + return url.toString() +} +const helfer = 'def doppelt(x):\n return 2 * x\n' +const main = 'from helfer import doppelt\n\n\ndef viermal(x):\n """\n >>> viermal(2)\n 8\n """\n return doppelt(doppelt(x))\n\n\nprint(viermal(21))\n' +const twoFiles = link([file('helfer.py', helfer), file('main.py', main)], 'main.py') +const withReadOnly = link([file('bib.py', library, { readOnly: true }), file('main.py', 'from bib import gross\nprint(gross("hallo"))\n')], 'main.py') +const byUrl = (url) => link([{ path: 'bib.py', url, readOnly: true }, file('main.py', 'from bib import gross\nprint(gross("hallo"))\n')], 'main.py', 2) +// A file that compresses badly, for a link longer than 2,083 characters. +let seed = 7 +const noise = Array.from({ length: 220 }, (_, index) => { seed = (seed * 1103515245 + 12345) % 2147483648; return `w${index} = ${seed}` }).join('\n') +const long = link([file('main.py', `${noise}\nprint(len([w0, w1]))\n`)], 'main.py') + +let failed = 0 +const check = async (label, body) => { + try { + await body() + console.log(`${label}: ok`) + } catch (error) { + failed += 1 + console.error(`${label}: ${error.message}`) + } +} + +const browser = await chromium.launch({ channel: 'chrome', headless: true }) +const newContext = async () => { + const context = await browser.newContext({ viewport: { width: 1200, height: 800 }, locale: 'de-CH', permissions: ['clipboard-read', 'clipboard-write'] }) + // The «leave the page» question (unsaved changes) and the copy-by-hand + // prompt: accepted. + context.on('page', (page) => page.on('dialog', (dialog) => { dialog.accept().catch(() => {}) })) + return context +} +const open = async (context, url) => { + const page = await context.newPage() + await page.goto(url) + await page.waitForFunction(() => window.monaco?.editor.getEditors().length > 0, null, { timeout: 60000 }) + await page.waitForTimeout(800) + return page +} +const pythonReady = (page) => page.locator('.runtime-badge').waitFor({ state: 'detached', timeout: 120000 }) +const editorText = (page) => page.evaluate(() => window.monaco.editor.getEditors()[0].getValue()) +const tabs = (page) => page.locator('.editor-tab').allInnerTexts() +const openTab = async (page, name) => { await page.locator('.editor-tab', { hasText: name }).click(); await page.waitForTimeout(250) } +const typeAtStart = async (page, text) => { + await page.locator('.monaco-editor .view-lines').click({ position: { x: 200, y: 10 } }) + await page.keyboard.press('ControlOrMeta+Home') + await page.keyboard.type(text) + await page.waitForTimeout(300) +} +// The tab's project as saved (tabProjects.ts). +const stored = (page) => page.evaluate(() => JSON.parse(localStorage.getItem(`glpython-tab-project:${sessionStorage.getItem('glpython-tab')}`)).project) +const notices = (page) => page.locator('.ide-notice').allInnerTexts() +const consoleText = (page) => page.locator('.console').innerText() + +try { + for (let attempt = 0; ; attempt++) { + try { await fetch(base); break } catch { if (attempt > 100) throw new Error('vite preview did not start') } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + + await check('fresh tab: one empty main.py', async () => { + const context = await newContext() + const page = await open(context, base) + assert.deepEqual(await tabs(page), ['main.py']) + assert.equal(await editorText(page), '') + await context.close() + }) + + await check('#41: tab switches, typing, undo, redo keep each file its own', async () => { + const context = await newContext() + const page = await open(context, withReadOnly) + for (const name of ['bib.py', 'main.py', 'bib.py', 'main.py', 'bib.py', 'main.py']) await openTab(page, name) + await typeAtStart(page, '# eigene Zeile\n') + await page.keyboard.press('ControlOrMeta+z') + await page.keyboard.press('ControlOrMeta+Shift+z') + await page.waitForTimeout(300) + await openTab(page, 'bib.py') + await typeAtStart(page, 'kaputt') + for (const name of ['main.py', 'bib.py', 'main.py']) await openTab(page, name) + const project = await stored(page) + assert.equal(project.files['bib.py'].code, library, 'the read-only file is unchanged') + assert.equal(project.files['bib.py'].readOnly, true) + assert.ok(project.files['main.py'].code.startsWith('# eigene Zeile\nfrom bib import gross'), `main.py: ${JSON.stringify(project.files['main.py'].code.slice(0, 40))}`) + assert.ok(await page.locator('.editor-tab', { hasText: 'bib.py' }).locator('.tab-read-only').count() === 1, 'lock on the read-only tab') + await context.close() + }) + + await check('per tab: two links, reload, bare address, closed tab offered again', async () => { + const context = await newContext() + const first = await open(context, twoFiles) + assert.equal(first.url(), base, 'the address shows only the start page') + await typeAtStart(first, '# erster Tab\n') + const second = await open(context, twoFiles) + assert.ok(!(await editorText(second)).includes('# erster Tab'), 'the second tab has the link\'s code') + assert.ok((await editorText(first)).includes('# erster Tab'), 'the first tab keeps its work') + await first.reload() + await first.waitForFunction(() => window.monaco?.editor.getEditors().length > 0, null, { timeout: 60000 }) + await first.waitForTimeout(500) + assert.ok((await editorText(first)).includes('# erster Tab'), 'reload keeps the code') + await first.close() + const third = await open(context, base) + await third.waitForTimeout(800) + assert.match((await notices(third)).join(' | '), /Zuletzt bearbeitet: main\.py/) + await third.locator('.ide-notice').getByRole('button', { name: 'Öffnen' }).click() + await third.waitForTimeout(400) + assert.ok((await editorText(third)).includes('# erster Tab'), 'reopened in place') + await context.close() + }) + + await check('links: a library by URL is read-only and runs', async () => { + const context = await newContext() + const page = await open(context, byUrl(`${libraryBase}bib.py`)) + await page.waitForFunction(() => [...document.querySelectorAll('.editor-tab')].some((tab) => tab.textContent.includes('bib.py')), null, { timeout: 15000 }) + assert.equal(await page.locator('.editor-tab', { hasText: 'bib.py' }).locator('.tab-read-only').count(), 1) + await page.getByRole('button', { name: 'Dateien' }).first().click() + assert.match(await page.locator('.ide-libraries').innerText(), /bib\.py\s+von localhost:4197/) + await page.keyboard.press('Escape') + await pythonReady(page) + await page.getByRole('button', { name: 'Ausführen' }).click() + await page.waitForFunction(() => document.querySelector('.console')?.textContent.includes('HALLO'), null, { timeout: 60000 }) + await context.close() + }) + + await check('links: a library that gives 404', async () => { + const context = await newContext() + const page = await open(context, byUrl(`${libraryBase}fehlt.py`)) + await page.waitForFunction(() => document.querySelector('.ide-notice')?.textContent.includes('HTTP 404'), null, { timeout: 15000 }) + assert.match((await notices(page)).join(' | '), /Die Bibliothek bib\.py konnte nicht geladen werden \(HTTP 404\)\./) + await pythonReady(page) + await page.getByRole('button', { name: 'Ausführen' }).click() + await page.waitForFunction(() => document.querySelector('.console')?.textContent.includes('ModuleNotFoundError'), null, { timeout: 60000 }) + assert.match(await consoleText(page), /ModuleNotFoundError: No module named 'bib'/) + await context.close() + }) + + await check('links: a link cut at 2,083 characters', async () => { + assert.ok(long.length > 2083, `the long link has ${long.length} characters`) + const context = await newContext() + // Another project in this browser first: it must not show instead. + const other = await open(context, twoFiles) + await typeAtStart(other, '# anderes Projekt\n') + const page = await open(context, long.slice(0, 2083)) + assert.match((await notices(page)).join(' | '), /Dieser Link ist unvollständig oder beschädigt\./) + assert.deepEqual(await tabs(page), ['main.py']) + assert.equal(await editorText(page), '') + await context.close() + }) + + await check('links: «Teilen» on a long link shows the note', async () => { + const context = await newContext() + const page = await open(context, long) + await page.getByRole('button', { name: 'Teilen' }).click() + await page.waitForTimeout(500) + assert.match((await notices(page)).join(' | '), /Dieser Link ist sehr lang \(\d+ Zeichen\)\. Manche Programme, z\. B\. OneNote, kürzen solche Links\./) + assert.equal(await page.locator('.ide-notice').getByRole('button', { name: 'Herunterladen' }).count(), 1) + await context.close() + }) + + await check('▶ and «Tests» on two files', async () => { + const context = await newContext() + const page = await open(context, twoFiles) + await pythonReady(page) + await page.getByRole('button', { name: 'Ausführen' }).click() + await page.waitForFunction(() => document.querySelector('.console')?.textContent.includes('84'), null, { timeout: 60000 }) + await page.getByRole('button', { name: 'Tests', exact: true }).click() + await page.locator('.test-summary').waitFor({ timeout: 60000 }) + assert.equal(await page.locator('.test-summary').innerText(), '1 von 1 Beispielen bestanden') + await context.close() + }) + + await check('⋮ «Info»: the version from version.json, and the update check', async () => { + const { version } = JSON.parse(readFileSync(new URL('../dist/version.json', import.meta.url), 'utf8')) + const context = await newContext() + const page = await open(context, base) + await page.locator('.ide-menu-button').click() + await page.getByRole('menuitem', { name: 'Info' }).click() + const dialog = page.getByRole('dialog') + assert.match(await dialog.innerText(), new RegExp(`Version\\s+${version.slice(0, 7)}`)) + await dialog.getByRole('button', { name: 'Nach Updates suchen' }).click() + await page.waitForFunction(() => document.querySelector('.ide-info-check [role=status]')?.textContent === 'Diese Version ist aktuell.', null, { timeout: 10000 }) + // A newer deploy: the same check offers the reload. + await page.route('**/version.json*', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: 'neuer', built: new Date().toISOString() }) })) + await dialog.getByRole('button', { name: 'Nach Updates suchen' }).click() + await dialog.getByText('Neue Version verfügbar.').waitFor({ timeout: 10000 }) + assert.equal(await dialog.getByRole('button', { name: 'Neu laden' }).count(), 1) + await context.close() + }) +} finally { + await browser.close() + stop() +} +if (failed > 0) process.exit(1) +console.log('IDE browser checks passed.') diff --git a/src/App.tsx b/src/App.tsx index 0f1d8bb..b104dcb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,28 +1,28 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' -import { BookOpen, CircleHelp, Download, EllipsisVertical, FileCode2, FilePlus2, Folder, FolderOpen, History, Link2, Lock, Pencil, Plus, RotateCcw, Sparkles, Trash2, Upload, X } from 'lucide-react' -import { GlPythonWorkspace, addProjectFile, deleteProjectFile, initialProject, renameProjectFile, updateProjectFile } from 'glpython-editor' +import { BookOpen, CircleHelp, Download, Info, EllipsisVertical, FileCode2, FilePlus2, Folder, FolderOpen, History, Link2, Lock, Pencil, Plus, RotateCcw, Sparkles, Trash2, Upload, X } from 'lucide-react' +import { GlPythonWorkspace, addProjectFile, deleteProjectFile, renameProjectFile, updateProjectFile } from 'glpython-editor' import type { Project, ProjectFileKind, WorkspaceCommand } from 'glpython-editor' import 'glpython-editor/style.css' import { initializeAuth } from './auth' import { exportProjectZip, importProjectFolder, importProjectZip } from './projectStorage' import { buildShareUrl, readSharedLinkFromLocation } from './projectShare' import { fetchLibrary, libraryHost, type LibraryState } from './libraries' -import { answerTabQueries, claimTabId, newTabEntry, otherLiveTabIds, projectFingerprint, pruneEntries, recentlyClosed, removeEntry, saveEntry, startTab, type TabEntry } from './tabProjects' +import { answerTabQueries, claimTabId, isEmptyProject, newTabEntry, otherLiveTabIds, projectFingerprint, pruneEntries, recentlyClosed, removeEntry, saveEntry, startTab, type TabEntry } from './tabProjects' import { detectLocale, localeNames, saveLocale, translations, type Locale } from './i18n' // This tab's project to start with (see tabProjects.ts): a share link's -// (`?project=`), else the one this tab held before, else the starter; for a -// link that can't be read, an empty project and a notice. Runs once at +// (`?project=`), else the one this tab held before, else one empty main.py - +// learners arrive by links, and the editor's placeholder says what to do; for +// a link that can't be read, that empty project and a notice. Runs once at // module load, since every useState initializer below needs the same result. const EMPTY_PROJECT: Project = { id: 'empty', name: translations[detectLocale()].emptyProjectName, mainFile: 'main.py', files: { 'main.py': { path: 'main.py', label: 'main', kind: 'python', code: '' } } } -const startup = startTab(readSharedLinkFromLocation(), initialProject, EMPTY_PROJECT) +const startup = startTab(readSharedLinkFromLocation(), EMPTY_PROJECT) // OneNote cuts links at 2,083 characters (Internet Explorer's old limit, // which other programs kept too): «Teilen» copies longer ones with a note. const LONG_LINK = 2083 // A fresh tab offers the last project closed within a day: after Safari or // the iPad restarted, a tab comes back empty. const OFFER_CLOSED_MS = 24 * 60 * 60 * 1000 -const STARTER_FINGERPRINT = projectFingerprint(initialProject) // «vor 2 Stunden», «2 hours ago»; under a minute «gerade eben». function timeAgo(time: number, locale: string, justNow: string): string { @@ -35,11 +35,23 @@ function timeAgo(time: number, locale: string, justNow: string): string { return format.format(Math.round(hours / 24), 'day') } -// The IDE's version (the commit it was built from) and the one deployed now -// (version.json, see vite.config.ts): a tab that stayed open - on an iPad -// often for days - offers to load the new one. +// The IDE's version (the commit it was built from, and when) and the one +// deployed now (version.json, see vite.config.ts): a tab that stayed open - +// on an iPad often for days - offers to load the new one; ⋮ «Info» shows it. declare const __GLPYTHON_IDE_VERSION__: string +declare const __GLPYTHON_IDE_BUILT__: string const VERSION_CHECK_MS = 30 * 60 * 1000 +async function checkForNewVersion(): Promise<'current' | 'available' | 'failed'> { + if (import.meta.env.DEV) return 'current' + try { + const response = await fetch(`/version.json?t=${Date.now()}`, { cache: 'no-store' }) + const { version } = await response.json() as { version?: string } + if (!version) return 'failed' + return version === __GLPYTHON_IDE_VERSION__ ? 'current' : 'available' + } catch { + return 'failed' + } +} // Moves keyboard focus through a menu's items with the arrow keys, Home and // End, as a menu does; Escape is the caller's. @@ -78,6 +90,10 @@ function App() { const [closedOffer, setClosedOffer] = useState([]) const [closedList, setClosedList] = useState(null) const [updateAvailable, setUpdateAvailable] = useState(false) + // ⋮ «Info»: open, and the result of its «Nach Updates suchen». + const [isInfoOpen, setIsInfoOpen] = useState(false) + const [infoCheck, setInfoCheck] = useState<'idle' | 'checking' | 'current' | 'available' | 'failed'>('idle') + const infoDialogRef = useRef(null) // The project's libraries by URL (libraries.ts); a link that couldn't be // read; the length of a long link just copied. const [libraries, setLibraries] = useState>(() => startup.entry.libraries ?? {}) @@ -140,7 +156,7 @@ function App() { const copy = newTabEntry(project, savedFingerprint, changedAtRef.current) setTabId(copy.id) } - if (startup.fresh) setClosedOffer(recentlyClosed(tabIdRef.current, liveIds, initialProject).filter((entry) => Date.now() - entry.changedAt < OFFER_CLOSED_MS)) + if (startup.fresh) setClosedOffer(recentlyClosed(tabIdRef.current, liveIds).filter((entry) => Date.now() - entry.changedAt < OFFER_CLOSED_MS)) pruneEntries(tabIdRef.current) }) return stopAnswering @@ -164,13 +180,8 @@ function App() { // 30 minutes (not in development, which has no version.json). useEffect(() => { if (import.meta.env.DEV) return - const check = async () => { - try { - const response = await fetch(`/version.json?t=${Date.now()}`, { cache: 'no-store' }) - const { version } = await response.json() as { version?: string } - if (version && version !== __GLPYTHON_IDE_VERSION__) setUpdateAvailable(true) - } catch { /* offline: try again later */ } - } + // Offline: tried again later. + const check = async () => { if (await checkForNewVersion() === 'available') setUpdateAvailable(true) } const onVisible = () => { if (document.visibilityState === 'visible') void check() } document.addEventListener('visibilitychange', onVisible) const timer = window.setInterval(() => { void check() }, VERSION_CHECK_MS) @@ -280,11 +291,12 @@ function App() { setSavedFingerprint(projectFingerprint(project)) } - // Another project in this tab (opened, imported, or «Neues Projekt»): a new - // tab project, so the one before stays among «Zuletzt geschlossen» - unless - // it is the untouched starter, which there is nothing to come back to. + // Another project in this tab (opened, imported, or «Neues leeres + // Projekt»): a new tab project, so the one before stays among «Zuletzt + // geschlossen» - unless it is an untouched empty one, which there is nothing + // to come back to. const switchTabProject = (nextProject: Project, nextSavedFingerprint: string, entry?: TabEntry, nextLibraries: Record = {}) => { - if (projectFingerprint(project) === STARTER_FINGERPRINT) removeEntry(tabId) + if (isEmptyProject(project)) removeEntry(tabId) const next = entry ?? newTabEntry(nextProject, nextSavedFingerprint, Date.now(), Object.fromEntries(Object.entries(nextLibraries).map(([path, url]) => [path, { url, status: 'loaded' as const }]))) if (entry) claimTabId(entry.id) changedAtRef.current = next.changedAt @@ -299,15 +311,15 @@ function App() { setLongLinkLength(null) } - // A closed project: in this tab if it holds only the untouched starter, + // A closed project: in this tab if it holds only an untouched empty project, // else in a new browser tab (`?restore=`, see startTab). const openClosedProject = (entry: TabEntry) => { setClosedList(null) - if (projectFingerprint(project) === STARTER_FINGERPRINT) switchTabProject(entry.project, entry.savedFingerprint, entry) + if (isEmptyProject(project)) switchTabProject(entry.project, entry.savedFingerprint, entry) else window.open(`/?restore=${encodeURIComponent(entry.id)}`, '_blank', 'noopener') } const showClosedProjects = () => { - void otherLiveTabIds().then((liveIds) => setClosedList(recentlyClosed(tabIdRef.current, liveIds, initialProject))) + void otherLiveTabIds().then((liveIds) => setClosedList(recentlyClosed(tabIdRef.current, liveIds))) } const removeClosedProject = (entry: TabEntry) => { removeEntry(entry.id) @@ -318,6 +330,23 @@ function App() { if (closedList) closedDialogRef.current?.querySelector('button')?.focus() }, [closedList]) + // ⋮ «Info»: the version (when a learner reports a problem: «Was steht unter + // ⋮ → Info?») and the same check as the «Neue Version verfügbar» notice. + const versionDate = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeStyle: 'short' }).format(new Date(__GLPYTHON_IDE_BUILT__)) + const checkForUpdates = async () => { + setInfoCheck('checking') + const result = await checkForNewVersion() + setInfoCheck(result) + if (result === 'available') setUpdateAvailable(true) + } + const closeInfo = () => { + setIsInfoOpen(false) + menuButtonRef.current?.focus() + } + useEffect(() => { + if (isInfoOpen) infoDialogRef.current?.querySelector('button')?.focus() + }, [isInfoOpen]) + const openFolder = async () => { try { const importedProject = await importProjectFolder(project) @@ -355,7 +384,7 @@ function App() { {isMenuOpen &&
{ if (event.key === 'Escape') { event.preventDefault(); closeMenu() } else if (event.key === 'Tab') setIsMenuOpen(false); else moveMenuFocus(event, menuRef.current) }}>
- + @@ -365,6 +394,9 @@ function App() { {(Object.keys(localeNames) as Locale[]).map((code) => )}
+
+ +
} { const file = event.target.files?.[0]; if (file) void openZip(file); event.target.value = '' }} /> @@ -431,6 +463,20 @@ function App() { /> + {isInfoOpen &&
+
event.stopPropagation()} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); closeInfo() } }}> +
glpython
+
+
{t.version}
{__GLPYTHON_IDE_VERSION__.slice(0, 7)}
+
{t.built}
{versionDate}
+
+
+ + {infoCheck === 'checking' ? t.checkingForUpdates : infoCheck === 'current' ? t.upToDate : infoCheck === 'failed' ? t.updateCheckFailed : ''} +
+ {infoCheck === 'available' &&
{t.updateAvailable}
} +
+
} {closedList &&
setClosedList(null)}>
event.stopPropagation()} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); setClosedList(null); menuButtonRef.current?.focus() } }}>
{t.recentlyClosed}
diff --git a/src/i18n.ts b/src/i18n.ts index 4b14e80..61243fb 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -30,6 +30,13 @@ type ShellStrings = { deleteFile: (path: string) => string pythonReference: string newProject: string + about: string + version: string + built: string + checkForUpdates: string + checkingForUpdates: string + upToDate: string + updateCheckFailed: string recentlyClosed: string recentlyClosedHint: string recentlyClosedEmpty: string @@ -89,7 +96,14 @@ export const translations: Record = { renameFile: (path) => `Rename ${path}`, deleteFile: (path) => `Delete ${path}`, pythonReference: 'Python reference', - newProject: 'New project', + newProject: 'New empty project', + about: 'About', + version: 'Version', + built: 'Built', + checkForUpdates: 'Check for updates', + checkingForUpdates: 'Checking …', + upToDate: 'This version is up to date.', + updateCheckFailed: 'Could not check for a new version (no connection?).', recentlyClosed: 'Recently closed', recentlyClosedHint: 'Projects of closed tabs, kept in this browser for 14 days.', recentlyClosedEmpty: 'No projects closed in the last 14 days.', @@ -145,7 +159,14 @@ export const translations: Record = { renameFile: (path) => `${path} umbenennen`, deleteFile: (path) => `${path} löschen`, pythonReference: 'Python-Referenz', - newProject: 'Neues Projekt', + newProject: 'Neues leeres Projekt', + about: 'Info', + version: 'Version', + built: 'Erstellt', + checkForUpdates: 'Nach Updates suchen', + checkingForUpdates: 'Wird geprüft …', + upToDate: 'Diese Version ist aktuell.', + updateCheckFailed: 'Die Prüfung auf eine neue Version ist fehlgeschlagen (keine Verbindung?).', recentlyClosed: 'Zuletzt geschlossen', recentlyClosedHint: 'Projekte aus geschlossenen Tabs, 14 Tage lang in diesem Browser gespeichert.', recentlyClosedEmpty: 'Keine geschlossenen Projekte aus den letzten 14 Tagen.', diff --git a/src/index.css b/src/index.css index 5078469..afa7eca 100644 --- a/src/index.css +++ b/src/index.css @@ -74,3 +74,12 @@ button { font: inherit; } .ide-closed-name strong { font-family: var(--glp-font-code); font-weight: 600; } .ide-closed-name span { color: var(--glp-text-muted); font-size: 13px; } .ide-closed-list .secondary-button { padding: 5px 10px; font-size: 13px; } +/* ⋮ «Info»: version and build date, and «Nach Updates suchen». */ +.ide-info { width: min(380px, 100%); } +.ide-info-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 16px; margin: 12px 0; } +.ide-info-list dt { color: var(--glp-text-muted); } +.ide-info-list dd { margin: 0; } +.ide-info-list code { font: 600 14px var(--glp-font-code); } +.ide-info-check { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 14px; } +.ide-info-check .secondary-button { padding: 5px 10px; font-size: 13px; } +.ide-info .ide-notice { margin: 0; } diff --git a/src/tabProjects.ts b/src/tabProjects.ts index e8f6d78..1b97b72 100644 --- a/src/tabProjects.ts +++ b/src/tabProjects.ts @@ -26,8 +26,8 @@ export type TabEntry = { id: string project: Project // The project's fingerprint at the last «Teilen» or «Herunterladen» (or - // when it was opened from a link, or as the starter): changes since then - // are unsaved. + // when it was opened from a link, or as an empty project): changes since + // then are unsaved. savedFingerprint: string // The last change to the project. changedAt: number @@ -35,6 +35,14 @@ export type TabEntry = { libraries?: Record } +// A fresh tab's project: one empty file to run. Decided by content, not by +// name (which follows the language at creation), so an untouched one is +// never offered or listed as closed. +export function isEmptyProject(project: Project): boolean { + const files = Object.values(project.files) + return files.length === 1 && files[0].path === project.mainFile && files[0].code === '' +} + // Changes of a project's content (name, the learner's files, file to run), as // a short hash. Read-only files (a course's libraries) aren't the learner's // work: a library arriving after its fetch is no change. @@ -125,10 +133,11 @@ export function claimTabId(id: string): void { // The project this tab starts with: a share link's (always a new tab // project, its libraries still to fetch), else the one this tab held before // (reload, or Safari rebuilding the tab), else - in a tab without one - the -// IDE's former single project (once) or the starter. `fresh` is true for the -// starter: only then is another tab's project offered (see recentlyClosed). -// A link that can't be read gives `empty` and `broken`: never another project. -export function startTab(link: SharedLink, starter: Project, empty: Project): { entry: TabEntry; fresh: boolean; broken: boolean } { +// IDE's former single project (once) or `empty` (one empty main.py). `fresh` +// is true for the empty one: only then is another tab's project offered (see +// recentlyClosed). A link that can't be read gives `empty` and `broken`: +// never another project. +export function startTab(link: SharedLink, empty: Project): { entry: TabEntry; fresh: boolean; broken: boolean } { const restoreId = new URLSearchParams(window.location.search).get('restore') if (link.status === 'broken') return { entry: newTabEntry(empty, projectFingerprint(empty)), fresh: false, broken: true } if (link.status === 'ok') { @@ -154,10 +163,10 @@ export function startTab(link: SharedLink, starter: Project, empty: Project): { try { localStorage.removeItem(LEGACY_KEY) } catch { /* storage unavailable */ } return { entry, fresh: false, broken: false } } - return { entry: newStarterEntry(starter), fresh: true, broken: false } + return { entry: newTabEntry(empty, projectFingerprint(empty)), fresh: true, broken: false } } -// A new tab project, held by this tab: the starter, an imported project, or +// A new tab project, held by this tab: an empty one, an imported project, or // a copy (a duplicated tab gets its own). The one this tab held before stays // as it is, among «Zuletzt geschlossen». export function newTabEntry(project: Project, savedFingerprint: string, changedAt = Date.now(), libraries: Record = {}): TabEntry { @@ -166,9 +175,6 @@ export function newTabEntry(project: Project, savedFingerprint: string, changedA claimTabId(entry.id) return entry } -function newStarterEntry(starter: Project): TabEntry { - return newTabEntry(starter, projectFingerprint(starter)) -} // The ids the other open tabs hold, asked over a BroadcastChannel: each // open IDE tab answers with its own (see answerTabQueries). @@ -194,10 +200,9 @@ export function otherLiveTabIds(): Promise> { } // The projects no open tab holds, most recently changed first - without -// untouched starters, which there is nothing to come back to. -export function recentlyClosed(currentId: string, liveIds: Set, starter: Project): TabEntry[] { - const starterFingerprint = projectFingerprint(starter) +// untouched empty ones, which there is nothing to come back to. +export function recentlyClosed(currentId: string, liveIds: Set): TabEntry[] { return listEntries() - .filter((entry) => entry.id !== currentId && !liveIds.has(entry.id) && projectFingerprint(entry.project) !== starterFingerprint) + .filter((entry) => entry.id !== currentId && !liveIds.has(entry.id) && !isEmptyProject(entry.project)) .sort((a, b) => b.changedAt - a.changedAt) } diff --git a/vite.config.ts b/vite.config.ts index 555fa6a..f249655 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,17 +2,18 @@ import { execSync } from 'node:child_process' import react from '@vitejs/plugin-react' import { defineConfig, type Plugin } from 'vite' -// The commit the IDE is built from (GitHub's in the deploy), compiled in and -// written to dist/version.json: an open tab compares the two and offers to -// load a newer version (see App.tsx). +// The commit the IDE is built from (GitHub's in the deploy) and when, +// compiled in and written to dist/version.json: an open tab compares the two +// and offers to load a newer version, and ⋮ «Info» shows them (App.tsx). const version = process.env.GITHUB_SHA ?? (() => { try { return execSync('git rev-parse HEAD').toString().trim() } catch { return 'dev' } })() +const built = new Date().toISOString() const versionFile: Plugin = { name: 'glpython-version-file', apply: 'build', generateBundle() { - this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify({ version }) }) + this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify({ version, built }) }) }, } @@ -31,6 +32,7 @@ export default defineConfig({ plugins: [react(), versionFile], define: { __GLPYTHON_IDE_VERSION__: JSON.stringify(version), + __GLPYTHON_IDE_BUILT__: JSON.stringify(built), }, // glpython-editor's Pyodide worker is loaded via // `new Worker(new URL('./pyodide-worker.js', import.meta.url))`, resolved From fcf6461985584f36d10c62a5df6db8ce7a42f260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:34:44 +0100 Subject: [PATCH 2/2] An untouched former starter project counts as empty too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tabs from before this change hold glpython-editor's starter project. Left untouched, it would otherwise appear among «Zuletzt geschlossen» for 14 days and be offered in a fresh tab. The browser check covers it. Co-Authored-By: Claude Opus 5.5 --- scripts/check-ide-browser.mjs | 15 ++++++++++++++- src/tabProjects.ts | 9 ++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/check-ide-browser.mjs b/scripts/check-ide-browser.mjs index a50486c..3681128 100644 --- a/scripts/check-ide-browser.mjs +++ b/scripts/check-ide-browser.mjs @@ -105,11 +105,24 @@ try { await new Promise((resolve) => setTimeout(resolve, 200)) } - await check('fresh tab: one empty main.py', async () => { + await check('fresh tab: one empty main.py; the former starter isn\'t offered', async () => { const context = await newContext() + // A tab from before: glpython-editor's untouched starter project. + await context.addInitScript(() => { + if (localStorage.getItem('glpython-tab-project:alt')) return + const code = '# main.py is the project entry point\nfrom gturtle import *\nfrom greetings import welcome\n\nprint(welcome("Python learner"))\n\nmakeTurtle()\nfor _ in range(4):\n forward(100)\n right(90)' + const project = { id: 'first-steps', name: 'First steps', mainFile: 'main.py', files: { + 'main.py': { path: 'main.py', label: 'Main program', kind: 'python', code }, + 'greetings.py': { path: 'greetings.py', label: 'Greetings helper', kind: 'python', code: 'def welcome(name):\n return f"Hello, {name}!"' }, + 'notes.md': { path: 'notes.md', label: 'Lesson notes', kind: 'text', code: '# Variables and output\n\nUse print() to show a value.\nStore reusable values in variables.' }, + } } + localStorage.setItem('glpython-tab-project:alt', JSON.stringify({ id: 'alt', project, savedFingerprint: '', changedAt: Date.now() })) + }) const page = await open(context, base) assert.deepEqual(await tabs(page), ['main.py']) assert.equal(await editorText(page), '') + await page.waitForTimeout(500) + assert.deepEqual(await notices(page), [], 'no offer of the former starter') await context.close() }) diff --git a/src/tabProjects.ts b/src/tabProjects.ts index 1b97b72..955907d 100644 --- a/src/tabProjects.ts +++ b/src/tabProjects.ts @@ -1,4 +1,4 @@ -import type { Project } from 'glpython-editor' +import { initialProject, type Project } from 'glpython-editor' import type { LibraryState } from './libraries' import type { SharedLink } from './projectShare' @@ -37,10 +37,11 @@ export type TabEntry = { // A fresh tab's project: one empty file to run. Decided by content, not by // name (which follows the language at creation), so an untouched one is -// never offered or listed as closed. +// never offered or listed as closed. Tabs from before November 2026 started +// with glpython-editor's starter project; untouched, it counts the same. export function isEmptyProject(project: Project): boolean { const files = Object.values(project.files) - return files.length === 1 && files[0].path === project.mainFile && files[0].code === '' + return (files.length === 1 && files[0].path === project.mainFile && files[0].code === '') || projectFingerprint(project) === FORMER_STARTER } // Changes of a project's content (name, the learner's files, file to run), as @@ -56,6 +57,8 @@ export function projectFingerprint(project: Project): string { return `${text.length}:${(hash >>> 0).toString(36)}` } +const FORMER_STARTER = projectFingerprint(initialProject) + function newTabId(): string { return Math.random().toString(36).slice(2, 10) + Date.now().toString(36) }