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..3681128 --- /dev/null +++ b/scripts/check-ide-browser.mjs @@ -0,0 +1,253 @@ +// 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; 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() + }) + + 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..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' @@ -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,15 @@ 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. 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 === '') || projectFingerprint(project) === FORMER_STARTER +} + // 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. @@ -48,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) } @@ -125,10 +136,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 +166,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 +178,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 +203,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