diff --git a/CLAUDE.md b/CLAUDE.md index 65392bc..3be876d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,8 @@ There are no tests. `.github/workflows/ci.yml` runs `npm ci`, the build (tsc cat - `src/App.tsx` — the shell. It renders `GlPythonWorkspace` (stacked layout; its buttons go into the bar through `actionsContainer`, every file is a tab with `closeFileTabs: false`) and uses glpython-editor's project helpers (`addProjectFile`, `renameProjectFile`, …). - `src/index.css` — the shell's styles (`ide-*` classes), from glpython-editor's `--glp-*` tokens. glpython-editor's `style.css` styles only the workspace (plus `icon-button`, `hidden-input` and `auth-notice`, which the shell uses too). - `src/i18n.ts` — only the shell's own strings (`ShellStrings`, de/en). The workspace's strings come from glpython-editor's `coreStrings`, so don't copy them here. A new workspace string goes into glpython-editor's `src/i18n.ts`. -- `src/projectStorage.ts` (local storage, folder/ZIP) and `src/projectShare.ts` (`?project=` share links, lz-string, versioned payload). +- `src/tabProjects.ts`: per-tab projects (P25). The tab's id is in sessionStorage and `history.state` (never in the address); projects are in localStorage, one entry per id. Nothing is deleted when a tab goes away: a page can't tell a close from iPad Safari unloading it (only `history.state` survives that). Open tabs answer over a BroadcastChannel, which finds duplicated tabs and the projects for «Zuletzt geschlossen». +- `src/projectStorage.ts` (folder/ZIP) and `src/projectShare.ts` (`?project=` share links, lz-string, versioned payload). - `src/auth.ts` — MSAL. Only active with `VITE_ENTRA_CLIENT_ID` in `.env.local` (README «Microsoft sign-in»). - `vite.config.ts` — `optimizeDeps.exclude: ['glpython-editor']` keeps the editor's worker next to its `dist/index.js`. Don't remove it. diff --git a/README.md b/README.md index 1367d41..7998d9c 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,33 @@ language settings and then remembered per browser via local storage. ## Project persistence -The current project is saved automatically in browser local storage. The -⋮ menu and the bar support: +**Each browser tab has its own project** (as in WebTigerPython), saved +automatically in the browser: + +- Opening a share link starts a new tab project; it never replaces the work in + another tab. The address bar then shows only the start page, so there is no + link to copy from it - links come from **Teilen**. +- A reload keeps the tab's project, and so does iPad Safari rebuilding a + background tab. The tab's id is kept in its session storage and its history + entry (`history.state`), never in the address. +- **Closing or reloading** a tab whose project changed since the last + **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 + 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». +- 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. + +The ⋮ menu and the bar support: - **Ordner öffnen** in browsers with the File System Access API - **Projekt öffnen** (a ZIP) in all modern browsers with file upload support diff --git a/package-lock.json b/package-lock.json index 1bc8cc7..98d18f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.19", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.20", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", @@ -804,7 +804,7 @@ }, "node_modules/glpython-editor": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#5bd8a4fe8c333a907da6ae4675bc65ff318c61a4", + "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#925a8ca38c59f77eb9cd54e662e9af7cf810a787", "license": "MIT", "peerDependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/package.json b/package.json index e81b576..b7b4e31 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.19", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.20", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", diff --git a/src/App.tsx b/src/App.tsx index 2a34104..8a9e3a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,27 +1,40 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' -import { BookOpen, CircleHelp, Download, EllipsisVertical, FileCode2, Folder, FolderOpen, Link2, Lock, Pencil, Plus, RotateCcw, Sparkles, Trash2, Upload, X } from 'lucide-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 type { ProjectFileKind, WorkspaceCommand } from 'glpython-editor' +import type { Project, ProjectFileKind, WorkspaceCommand } from 'glpython-editor' import 'glpython-editor/style.css' import { initializeAuth } from './auth' -import { clearLocalProject, exportProjectZip, importProjectFolder, importProjectZip, loadLocalProject, saveLocalProject } from './projectStorage' -import { buildShareUrl, consumeSharedProjectFromLocation } from './projectShare' +import { exportProjectZip, importProjectFolder, importProjectZip } from './projectStorage' +import { buildShareUrl, readSharedProjectFromLocation } from './projectShare' +import { answerTabQueries, claimTabId, newTabEntry, otherLiveTabIds, projectFingerprint, pruneEntries, recentlyClosed, removeEntry, saveEntry, startTab, type TabEntry } from './tabProjects' import { detectLocale, localeNames, saveLocale, translations, type Locale } from './i18n' -// Resolves the project to start the app with: a project shared via the -// `?project=` URL parameter takes priority (and is immediately persisted + -// stripped from the address bar so reloading keeps it without re-decoding), -// otherwise falls back to whatever was last saved locally. Runs once at -// module load, since every useState initializer below needs the same -// result. -const startupProject = (() => { - const sharedProject = consumeSharedProjectFromLocation() - if (sharedProject) { - saveLocalProject(sharedProject) - return sharedProject - } - return loadLocalProject(initialProject) -})() +// 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. Runs +// once at module load, since every useState initializer below needs the +// same result. +const startup = startTab(readSharedProjectFromLocation(), initialProject) +// 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 { + const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) + const minutes = Math.round((time - Date.now()) / 60000) + if (minutes === 0) return justNow + if (Math.abs(minutes) < 60) return format.format(minutes, 'minute') + const hours = Math.round(minutes / 60) + if (Math.abs(hours) < 24) return format.format(hours, 'hour') + 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. +declare const __GLPYTHON_IDE_VERSION__: string +const VERSION_CHECK_MS = 30 * 60 * 1000 // Moves keyboard focus through a menu's items with the arrow keys, Home and // End, as a menu does; Escape is the caller's. @@ -45,8 +58,21 @@ function moveMenuFocus(event: ReactKeyboardEvent, menu: HTMLElement function App() { const [locale, setLocale] = useState(() => detectLocale()) const t = translations[locale] - const [project, setProject] = useState(() => startupProject) - const [activeFile, setActiveFile] = useState(() => startupProject.mainFile) + const [project, setProject] = useState(() => startup.entry.project) + const [activeFile, setActiveFile] = useState(() => startup.entry.project.mainFile) + // This tab's project (tabProjects.ts): its id, and its fingerprint at the + // last «Teilen»/«Herunterladen» - changes since then are unsaved, and the + // browser asks before the tab is closed or reloaded. + const [tabId, setTabId] = useState(startup.entry.id) + const [savedFingerprint, setSavedFingerprint] = useState(startup.entry.savedFingerprint) + const changedAtRef = useRef(startup.entry.changedAt) + const lastFingerprintRef = useRef(projectFingerprint(startup.entry.project)) + const unsavedRef = useRef(false) + // The offer of a closed project in a fresh tab, the «Zuletzt geschlossen» + // list (null while closed), and the notice of a new IDE version. + const [closedOffer, setClosedOffer] = useState([]) + const [closedList, setClosedList] = useState(null) + const [updateAvailable, setUpdateAvailable] = useState(false) const [authErrorKey, setAuthErrorKey] = useState<'' | 'init' | 'config' | 'cancelled'>('') // The workspace decides itself when to show graphics (only while the // program draws); these props are for hosts with their own toggle. @@ -63,10 +89,68 @@ function App() { const filesButtonRef = useRef(null) const menuRef = useRef(null) const menuButtonRef = useRef(null) + const closedDialogRef = useRef(null) + const tabIdRef = useRef(tabId) + tabIdRef.current = tabId + + useEffect(() => { + const fingerprint = projectFingerprint(project) + if (fingerprint !== lastFingerprintRef.current) changedAtRef.current = Date.now() + lastFingerprintRef.current = fingerprint + unsavedRef.current = fingerprint !== savedFingerprint + saveEntry({ id: tabId, project, savedFingerprint, changedAt: changedAtRef.current }) + }, [project, savedFingerprint, tabId]) + + // Other IDE tabs ask which projects are open; a duplicated tab (same id as + // an open one) takes its own copy; a fresh tab offers the last closed + // project; projects older than 14 days go. + useEffect(() => { + const stopAnswering = answerTabQueries(() => tabIdRef.current) + void otherLiveTabIds().then((liveIds) => { + if (liveIds.has(tabIdRef.current)) { + 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)) + pruneEntries(tabIdRef.current) + }) + return stopAnswering + // Once, at the start of the tab. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Closing or reloading the tab with unsaved changes: the browser's own + // question (not on an iPad, whose Safari never asks). + useEffect(() => { + const warn = (event: BeforeUnloadEvent) => { + if (!unsavedRef.current) return + event.preventDefault() + event.returnValue = true + } + window.addEventListener('beforeunload', warn) + return () => window.removeEventListener('beforeunload', warn) + }, []) + // A new IDE version: checked when the tab comes back into view and every + // 30 minutes (not in development, which has no version.json). useEffect(() => { - saveLocalProject(project) - }, [project]) + 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 */ } + } + const onVisible = () => { if (document.visibilityState === 'visible') void check() } + document.addEventListener('visibilitychange', onVisible) + const timer = window.setInterval(() => { void check() }, VERSION_CHECK_MS) + void check() + return () => { + document.removeEventListener('visibilitychange', onVisible) + window.clearInterval(timer) + } + }, []) useEffect(() => { initializeAuth().catch(() => setAuthErrorKey('init')) @@ -149,6 +233,7 @@ function App() { link.download = `${project.name.replace(/[^a-z0-9-_]+/gi, '-').toLowerCase()}.zip` link.click() URL.revokeObjectURL(url) + setSavedFingerprint(projectFingerprint(project)) } // Teilen: the link to a copy of this project, copied with a short @@ -162,7 +247,43 @@ function App() { } catch { window.prompt(t.shareLinkPromptLabel, shareUrl) } + 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. + const switchTabProject = (nextProject: Project, nextSavedFingerprint: string, entry?: TabEntry) => { + if (projectFingerprint(project) === STARTER_FINGERPRINT) removeEntry(tabId) + const next = entry ?? newTabEntry(nextProject, nextSavedFingerprint) + if (entry) claimTabId(entry.id) + changedAtRef.current = next.changedAt + lastFingerprintRef.current = projectFingerprint(next.project) + setTabId(next.id) + setSavedFingerprint(next.savedFingerprint) + setProject(next.project) + setActiveFile(next.project.mainFile) + setClosedOffer([]) + } + + // A closed project: in this tab if it holds only the untouched starter, + // 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) + else window.open(`/?restore=${encodeURIComponent(entry.id)}`, '_blank', 'noopener') } + const showClosedProjects = () => { + void otherLiveTabIds().then((liveIds) => setClosedList(recentlyClosed(tabIdRef.current, liveIds, initialProject))) + } + const removeClosedProject = (entry: TabEntry) => { + removeEntry(entry.id) + setClosedList((list) => list?.filter((other) => other.id !== entry.id) ?? null) + setClosedOffer((list) => list.filter((other) => other.id !== entry.id)) + } + useEffect(() => { + if (closedList) closedDialogRef.current?.querySelector('button')?.focus() + }, [closedList]) const openFolder = async () => { try { @@ -171,8 +292,7 @@ function App() { zipInputRef.current?.click() return } - setProject(importedProject) - setActiveFile(importedProject.mainFile) + switchTabProject(importedProject, projectFingerprint(importedProject)) } catch { // Folder picker not supported or cancelled - importProjectFolder already // falls back to the hidden zip above when appropriate. @@ -181,14 +301,7 @@ function App() { const openZip = async (file: File) => { const importedProject = await importProjectZip(file, project) - setProject(importedProject) - setActiveFile(importedProject.mainFile) - } - - const resetLocalProject = () => { - if (!window.confirm(t.confirmClearProject)) return - clearLocalProject() - window.location.reload() + switchTabProject(importedProject, projectFingerprint(importedProject)) } const authErrorMessage = authErrorKey === 'init' ? t.authInitError : authErrorKey === 'config' ? t.authConfigError : authErrorKey === 'cancelled' ? t.authCancelledError : '' @@ -207,6 +320,8 @@ function App() { {isMenuOpen &&
{ if (event.key === 'Escape') { event.preventDefault(); closeMenu() } else if (event.key === 'Tab') setIsMenuOpen(false); else moveMenuFocus(event, menuRef.current) }}>
+ + @@ -215,10 +330,6 @@ function App() { {(Object.keys(localeNames) as Locale[]).map((code) => )}
-
- - -
} { const file = event.target.files?.[0]; if (file) void openZip(file); event.target.value = '' }} /> @@ -252,6 +363,8 @@ function App() { }
{authErrorMessage &&
{authErrorMessage}
} + {updateAvailable &&
{t.updateAvailable}
} + {closedOffer.length > 0 &&
{t.lastEdited(closedOffer[0].project.mainFile, timeAgo(closedOffer[0].changedAt, locale, t.justNow))}{closedOffer.length > 1 && }
}
+ {closedList &&
setClosedList(null)}> +
event.stopPropagation()} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); setClosedList(null); menuButtonRef.current?.focus() } }}> +
{t.recentlyClosed}
+

{t.recentlyClosedHint}

+ {closedList.length === 0 ?

{t.recentlyClosedEmpty}

:
    + {closedList.map((entry) =>
  • + {entry.project.mainFile} {entry.project.name} · {timeAgo(entry.changedAt, locale, t.justNow)} + + +
  • )} +
} +
+
} ) } diff --git a/src/i18n.ts b/src/i18n.ts index d6b2a21..24a650c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -29,8 +29,18 @@ type ShellStrings = { renameFile: (path: string) => string deleteFile: (path: string) => string pythonReference: string - devTools: string - clearLocalProject: string + newProject: string + recentlyClosed: string + recentlyClosedHint: string + recentlyClosedEmpty: string + openClosed: string + removeClosed: (file: string) => string + showAllClosed: string + lastEdited: (file: string, when: string) => string + justNow: string + dismiss: string + updateAvailable: string + reloadNow: string language: string authInitError: string authConfigError: string @@ -39,7 +49,6 @@ type ShellStrings = { promptDefaultFileName: string promptRenameFile: string confirmDeleteFile: (path: string) => string - confirmClearProject: string } export type Translation = CoreStrings & ShellStrings @@ -66,8 +75,18 @@ export const translations: Record = { renameFile: (path) => `Rename ${path}`, deleteFile: (path) => `Delete ${path}`, pythonReference: 'Python reference', - devTools: 'Development tools', - clearLocalProject: 'Clear local project', + newProject: 'New project', + recentlyClosed: 'Recently closed', + recentlyClosedHint: 'Projects of closed tabs, kept in this browser for 14 days.', + recentlyClosedEmpty: 'No projects closed in the last 14 days.', + openClosed: 'Open', + removeClosed: (file) => `Remove ${file}`, + showAllClosed: 'Show all', + lastEdited: (file, when) => `Last edited: ${file} (${when}).`, + justNow: 'just now', + dismiss: 'Close', + updateAvailable: 'A new version is available.', + reloadNow: 'Reload', language: 'Language', authInitError: 'Microsoft sign-in could not be initialized.', authConfigError: 'Add VITE_ENTRA_CLIENT_ID to .env.local to enable Microsoft sign-in.', @@ -76,7 +95,6 @@ export const translations: Record = { promptDefaultFileName: 'exercise.py', promptRenameFile: 'Rename file', confirmDeleteFile: (path) => `Delete ${path}?`, - confirmClearProject: 'Clear the saved local project and reload the starter?', }, de: { ...coreStrings.de, @@ -99,8 +117,18 @@ export const translations: Record = { renameFile: (path) => `${path} umbenennen`, deleteFile: (path) => `${path} löschen`, pythonReference: 'Python-Referenz', - devTools: 'Entwicklerwerkzeuge', - clearLocalProject: 'Lokales Projekt löschen', + newProject: 'Neues Projekt', + recentlyClosed: 'Zuletzt geschlossen', + recentlyClosedHint: 'Projekte aus geschlossenen Tabs, 14 Tage lang in diesem Browser gespeichert.', + recentlyClosedEmpty: 'Keine geschlossenen Projekte aus den letzten 14 Tagen.', + openClosed: 'Öffnen', + removeClosed: (file) => `${file} entfernen`, + showAllClosed: 'Alle anzeigen', + lastEdited: (file, when) => `Zuletzt bearbeitet: ${file} (${when}).`, + justNow: 'gerade eben', + dismiss: 'Schliessen', + updateAvailable: 'Neue Version verfügbar.', + reloadNow: 'Neu laden', language: 'Sprache', authInitError: 'Die Microsoft-Anmeldung konnte nicht initialisiert werden.', authConfigError: 'Für die Microsoft-Anmeldung VITE_ENTRA_CLIENT_ID in .env.local eintragen.', @@ -109,7 +137,6 @@ export const translations: Record = { promptDefaultFileName: 'uebung.py', promptRenameFile: 'Datei umbenennen', confirmDeleteFile: (path) => `${path} löschen?`, - confirmClearProject: 'Das gespeicherte lokale Projekt löschen und den Startercode neu laden?', }, } diff --git a/src/index.css b/src/index.css index 7c1bec8..796ed57 100644 --- a/src/index.css +++ b/src/index.css @@ -23,7 +23,6 @@ button { font: inherit; } .ide-menu-label { display: block; padding: 6px 14px 2px; color: var(--glp-text-muted); font-size: 12px; font-weight: 600; } .ide-menu-check { width: 15px; text-align: center; } [aria-checked="true"] > .ide-menu-check::before { content: "✓"; } -.ide-menu-danger { color: var(--glp-error) !important; } .ide-body { position: relative; min-height: 0; flex: 1; display: flex; } .ide-rail { width: 52px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 0; border-right: 1px solid var(--glp-border); } .ide-rail .icon-button { width: 38px; height: 38px; } @@ -50,3 +49,20 @@ button { font: inherit; } .ide-bar { gap: 10px; padding: 8px 12px; } .ide-main { padding: 12px; } } + +/* Notices above the workspace: a closed project to open again, a new IDE + version. Information, in the info colours (glpython-editor's tokens). */ +.ide-notice { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 10px; padding: 6px 6px 6px 12px; color: var(--glp-text); background: var(--glp-info-bg); border-left: 3px solid var(--glp-info); border-radius: var(--glp-radius); font-size: 14px; } +.ide-notice > span { flex: 1; min-width: 12em; } +.ide-notice .secondary-button { padding: 5px 10px; font-size: 13px; } +/* «Zuletzt geschlossen». */ +.ide-dialog-backdrop { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 16px; background: rgba(0, 0, 0, .25); } +.ide-dialog { width: min(520px, 100%); max-height: 80vh; overflow-y: auto; padding: 16px; background: var(--glp-bg); border: 1px solid var(--glp-border); border-radius: var(--glp-radius); box-shadow: 0 12px 32px rgba(0, 0, 0, .18); } +.ide-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.ide-dialog-hint { margin: 4px 0 12px; color: var(--glp-text-muted); font-size: 13px; } +.ide-closed-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; } +.ide-closed-list li { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--glp-border); } +.ide-closed-name { min-width: 0; flex: 1; display: flex; flex-direction: column; } +.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; } diff --git a/src/projectShare.ts b/src/projectShare.ts index 05a23aa..2f3b82c 100644 --- a/src/projectShare.ts +++ b/src/projectShare.ts @@ -71,19 +71,11 @@ export function buildShareUrl(project: Project): string { } // Reads a shared project out of the current page URL (if present, in the -// query or, for a long one, the fragment) and strips it from the address bar -// afterwards, so reloading or re-sharing doesn't keep re-importing the same -// snapshot. -export function consumeSharedProjectFromLocation(): Project | null { +// query or, for a long one, the fragment). The tab then holds it as its own +// project and leaves the bare start page in the address (claimTabId), so +// reloading or re-sharing doesn't keep re-importing the same snapshot. +export function readSharedProjectFromLocation(): Project | null { const url = new URL(window.location.href) - const fragment = new URLSearchParams(url.hash.slice(1)) - const encoded = url.searchParams.get(SHARE_PARAM) ?? fragment.get(SHARE_PARAM) - if (!encoded) return null - - const project = decodeProjectFromUrl(encoded) - url.searchParams.delete(SHARE_PARAM) - fragment.delete(SHARE_PARAM) - url.hash = fragment.toString() - window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`) - return project + const encoded = url.searchParams.get(SHARE_PARAM) ?? new URLSearchParams(url.hash.slice(1)).get(SHARE_PARAM) + return encoded ? decodeProjectFromUrl(encoded) : null } diff --git a/src/projectStorage.ts b/src/projectStorage.ts index df3a6eb..70c62b9 100644 --- a/src/projectStorage.ts +++ b/src/projectStorage.ts @@ -1,8 +1,6 @@ import JSZip from 'jszip' import type { Project, ProjectFile, ProjectFileKind } from 'glpython-editor' -const storageKey = 'glpython-project' - type DirectoryPickerWindow = Window & { showDirectoryPicker?: () => Promise } @@ -15,25 +13,6 @@ function withReadOnly(files: Record, metadata: ProjectMetad return Object.fromEntries(Object.entries(files).map(([path, file]) => [path, readOnly.includes(path) ? { ...file, readOnly: true } : file])) } -export function loadLocalProject(fallback: Project): Project { - const savedProject = localStorage.getItem(storageKey) - if (!savedProject) return fallback - - try { - return JSON.parse(savedProject) as Project - } catch { - return fallback - } -} - -export function saveLocalProject(project: Project): void { - localStorage.setItem(storageKey, JSON.stringify(project)) -} - -export function clearLocalProject(): void { - localStorage.removeItem(storageKey) -} - export async function exportProjectZip(project: Project): Promise { const zip = new JSZip() for (const file of Object.values(project.files)) zip.file(file.path, file.code) diff --git a/src/tabProjects.ts b/src/tabProjects.ts new file mode 100644 index 0000000..176c41d --- /dev/null +++ b/src/tabProjects.ts @@ -0,0 +1,192 @@ +import type { Project } from 'glpython-editor' + +// Per-tab projects (P25), as in WebTigerPython: each browser tab has its own +// project. Opening a share link never replaces another tab's work, and +// closing a tab releases its project - no new tab loads it by itself. +// +// - The tab's id is in sessionStorage and in history.state, never in the +// address: the address stays the bare start page, as in WebTigerPython. +// A reload keeps both. When iPad Safari unloads a background tab and +// rebuilds it, only history.state survives (checked with the storage-check +// page, iPadOS 26.6); after quitting Safari, neither does. +// - The projects are in localStorage, one entry per tab id. Nothing is +// deleted when a tab goes away: a page can't tell a close from Safari +// unloading it. A project no open tab holds is listed under «Zuletzt +// geschlossen» for 14 days, and a fresh tab offers the last one it finds. +const SESSION_KEY = 'glpython-tab' +const STATE_KEY = 'glpythonTab' +const ENTRY_PREFIX = 'glpython-tab-project:' +// The one project the IDE kept before per-tab projects; taken over once. +const LEGACY_KEY = 'glpython-project' +const KEEP_MS = 14 * 24 * 60 * 60 * 1000 + +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. + savedFingerprint: string + // The last change to the project. + changedAt: number +} + +// Changes of a project's content (name, files, file to run), as a short hash. +export function projectFingerprint(project: Project): string { + const text = JSON.stringify([project.name, project.mainFile, Object.values(project.files).map((file) => [file.path, file.kind, file.code, file.readOnly === true])]) + let hash = 0x811c9dc5 + for (let index = 0; index < text.length; index++) { + hash ^= text.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) + } + return `${text.length}:${(hash >>> 0).toString(36)}` +} + +function newTabId(): string { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36) +} + +function readJson(storage: Storage, key: string): T | null { + try { + const value = storage.getItem(key) + return value ? JSON.parse(value) as T : null + } catch { + return null + } +} + +export function loadEntry(id: string): TabEntry | null { + const entry = readJson(localStorage, ENTRY_PREFIX + id) + return entry && entry.project && entry.project.files ? entry : null +} + +// Saves an entry; when the storage is full, the oldest projects no tab +// holds make room first. +export function saveEntry(entry: TabEntry, liveIds: Set = new Set()): void { + const value = JSON.stringify(entry) + for (let attempt = 0; attempt < 20; attempt++) { + try { + localStorage.setItem(ENTRY_PREFIX + entry.id, value) + return + } catch { + const oldest = listEntries().filter((other) => other.id !== entry.id && !liveIds.has(other.id)).sort((a, b) => a.changedAt - b.changedAt)[0] + if (!oldest) return + removeEntry(oldest.id) + } + } +} + +export function removeEntry(id: string): void { + try { localStorage.removeItem(ENTRY_PREFIX + id) } catch { /* storage unavailable */ } +} + +export function listEntries(): TabEntry[] { + const entries: TabEntry[] = [] + try { + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index) + if (!key?.startsWith(ENTRY_PREFIX)) continue + const entry = loadEntry(key.slice(ENTRY_PREFIX.length)) + if (entry) entries.push(entry) + } + } catch { /* storage unavailable */ } + return entries +} + +// Projects not changed for 14 days are gone. +export function pruneEntries(keepId: string): void { + const limit = Date.now() - KEEP_MS + for (const entry of listEntries()) if (entry.id !== keepId && entry.changedAt < limit) removeEntry(entry.id) +} + +function readTabId(): string | null { + try { + const fromSession = sessionStorage.getItem(SESSION_KEY) + if (fromSession) return fromSession + } catch { /* storage unavailable */ } + const state = history.state as Record | null + return state && typeof state[STATE_KEY] === 'string' ? state[STATE_KEY] as string : null +} + +// Marks this tab as holding `id`, and leaves the bare start page in the +// address (a share link's `?project=`/`#project=` is gone then). +export function claimTabId(id: string): void { + try { sessionStorage.setItem(SESSION_KEY, id) } catch { /* storage unavailable */ } + const state = history.state && typeof history.state === 'object' ? history.state as Record : {} + history.replaceState({ ...state, [STATE_KEY]: id }, '', window.location.pathname) +} + +// The project this tab starts with: a share link's (always a new tab +// project), 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). +export function startTab(sharedProject: Project | null, starter: Project): { entry: TabEntry; fresh: boolean } { + const restoreId = new URLSearchParams(window.location.search).get('restore') + if (sharedProject) return { entry: newTabEntry(sharedProject, projectFingerprint(sharedProject)), fresh: false } + // «Öffnen» in «Zuletzt geschlossen» opens a closed project in a new tab. + const restored = restoreId ? loadEntry(restoreId) : null + if (restored) { + claimTabId(restored.id) + return { entry: restored, fresh: false } + } + const ownId = readTabId() + const own = ownId ? loadEntry(ownId) : null + if (own) { + claimTabId(own.id) + return { entry: own, fresh: false } + } + const legacy = readJson(localStorage, LEGACY_KEY) + if (legacy && legacy.files) { + // Counts as unsaved: it was never shared from a tab. + const entry = newTabEntry(legacy, '') + try { localStorage.removeItem(LEGACY_KEY) } catch { /* storage unavailable */ } + return { entry, fresh: false } + } + return { entry: newStarterEntry(starter), fresh: true } +} + +// A new tab project, held by this tab: the starter, 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()): TabEntry { + const entry = { id: newTabId(), project, savedFingerprint, changedAt } + saveEntry(entry) + 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). +const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('glpython-tabs') +export function answerTabQueries(currentId: () => string): () => void { + if (!channel) return () => undefined + const answer = (event: MessageEvent) => { if (event.data?.type === 'who') channel.postMessage({ type: 'here', id: currentId() }) } + channel.addEventListener('message', answer) + return () => channel.removeEventListener('message', answer) +} +export function otherLiveTabIds(): Promise> { + if (!channel) return Promise.resolve(new Set()) + return new Promise((resolve) => { + const ids = new Set() + const collect = (event: MessageEvent) => { if (event.data?.type === 'here' && typeof event.data.id === 'string') ids.add(event.data.id) } + channel.addEventListener('message', collect) + channel.postMessage({ type: 'who' }) + window.setTimeout(() => { + channel.removeEventListener('message', collect) + resolve(ids) + }, 300) + }) +} + +// 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) + return listEntries() + .filter((entry) => entry.id !== currentId && !liveIds.has(entry.id) && projectFingerprint(entry.project) !== starterFingerprint) + .sort((a, b) => b.changedAt - a.changedAt) +} diff --git a/vite.config.ts b/vite.config.ts index 7e8eab5..555fa6a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,20 @@ +import { execSync } from 'node:child_process' import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +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). +const version = process.env.GITHUB_SHA ?? (() => { + try { return execSync('git rev-parse HEAD').toString().trim() } catch { return 'dev' } +})() +const versionFile: Plugin = { + name: 'glpython-version-file', + apply: 'build', + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify({ version }) }) + }, +} // Cross-origin isolation headers are required so the Pyodide worker can use // SharedArrayBuffer, which powers the input() dialog bridge. @@ -13,7 +28,10 @@ const crossOriginIsolationHeaders = { // IDE at the organization's root, https://glpython-edu.github.io/, and Vite's // default base "/" applies in production too. export default defineConfig({ - plugins: [react()], + plugins: [react(), versionFile], + define: { + __GLPYTHON_IDE_VERSION__: JSON.stringify(version), + }, // glpython-editor's Pyodide worker is loaded via // `new Worker(new URL('./pyodide-worker.js', import.meta.url))`, resolved // relative to the editor package's own dist/index.js. In dev, Vite's