Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ There are no tests. `.github/workflows/ci.yml` runs `npm ci`, the build (tsc cat
- `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/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/projectStorage.ts` (folder/ZIP) and `src/projectShare.ts` (`?project=` share links, lz-string, versioned payload: v1 every file's code, v2 also libraries by URL, fetched by `src/libraries.ts`; a link that can't be read opens an empty project with a notice).
- `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.

Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,28 @@ The ⋮ menu and the bar support:
and metadata) into a compressed link (`?project=`, or `#project=` beyond
GitHub Pages' URL limit) so a project can be shared or reopened without a
server, similar to
[webtigerpython.ethz.ch](https://webtigerpython.ethz.ch/)
[webtigerpython.ethz.ch](https://webtigerpython.ethz.ch/). A link longer
than 2,083 characters (OneNote cuts links there) is still copied, with a
note to download the project for a hand-in and a **Herunterladen** button.

**Libraries by URL.** A share link (version 2) can name a library by its URL
instead of carrying its code, e.g. a course's `parser.py`:
`{"path": "parser.py", "url": "https://…/parser.py", "readOnly": true}`. The
IDE fetches it once when the link opens (https only, or http on localhost; at
most 1 MB; without cookies; the server must send CORS headers, as GitHub
Pages and raw.githubusercontent.com do) and keeps the text with the tab's
project, so a reload or working offline uses that copy. The file drawer lists
it under **Bibliotheken** with its host and a lock. If it can't be loaded
(e.g. HTTP 404, no network, no CORS), the drawer and a notice say so
(«Die Bibliothek parser.py konnte nicht geladen werden (HTTP 404).») with
**Erneut versuchen**; importing it then raises Python's own
`ModuleNotFoundError`, as in the LiaScript macro. **Teilen** names it by URL
again; the ZIP holds its code and lists its URL in `glpython.json`
(`"libraries"`). Links of version 1, with every file's code, open as before.

**A link that can't be read** (cut off when copied, or damaged) opens an empty
project with «Dieser Link ist unvollständig oder beschädigt. Möglicherweise
wurde er beim Kopieren gekürzt.» - never another project.

ZIP projects preserve relative paths, the project name, the configured
Python entry point, and which files are read-only (`readOnly` in
Expand Down
80 changes: 65 additions & 15 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ 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, readSharedProjectFromLocation } from './projectShare'
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 { 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. Runs
// once at module load, since every useState initializer below needs the
// same result.
const startup = startTab(readSharedProjectFromLocation(), initialProject)
// (`?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
// 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)
// 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
Expand Down Expand Up @@ -73,6 +78,12 @@ function App() {
const [closedOffer, setClosedOffer] = useState<TabEntry[]>([])
const [closedList, setClosedList] = useState<TabEntry[] | null>(null)
const [updateAvailable, setUpdateAvailable] = useState(false)
// 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<Record<string, LibraryState>>(() => startup.entry.libraries ?? {})
const [brokenLink, setBrokenLink] = useState(startup.broken)
const [longLinkLength, setLongLinkLength] = useState<number | null>(null)
const fetchingRef = useRef(new Set<string>())
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.
Expand All @@ -98,8 +109,26 @@ function App() {
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])
saveEntry({ id: tabId, project, savedFingerprint, changedAt: changedAtRef.current, libraries })
}, [project, savedFingerprint, tabId, libraries])

// Libraries still to fetch: once, when the link opened (and again after
// «Erneut versuchen»); fetched, a library is a read-only file of the project.
useEffect(() => {
for (const [path, library] of Object.entries(libraries)) {
if (library.status !== 'loading' || fetchingRef.current.has(path)) continue
fetchingRef.current.add(path)
const forTab = tabIdRef.current
void fetchLibrary(library.url, t).then((result) => {
fetchingRef.current.delete(path)
if (tabIdRef.current !== forTab) return
if ('code' in result) setProject((current) => ({ ...current, files: { ...current.files, [path]: { path, label: path.replace(/\.[^/.]+$/, ''), kind: /\.py$/i.test(path) ? 'python' : 'text', code: result.code, readOnly: true } } }))
setLibraries((current) => current[path]?.url !== library.url ? current : { ...current, [path]: 'code' in result ? { url: library.url, status: 'loaded' } : { url: library.url, status: 'failed', failure: result.failure } })
})
}
}, [libraries, t])
const retryLibrary = (path: string) => setLibraries((current) => current[path] ? { ...current, [path]: { url: current[path].url, status: 'loading' } } : current)
const libraryUrls = Object.fromEntries(Object.entries(libraries).map(([path, library]) => [path, library.url]))

// 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
Expand Down Expand Up @@ -226,7 +255,7 @@ function App() {
}

const downloadProject = async () => {
const blob = await exportProjectZip(project)
const blob = await exportProjectZip(project, libraryUrls)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
Expand All @@ -239,7 +268,8 @@ function App() {
// Teilen: the link to a copy of this project, copied with a short
// confirmation beside the button, or shown to copy by hand.
const shareProject = async () => {
const shareUrl = buildShareUrl(project)
const shareUrl = buildShareUrl(project, libraryUrls)
setLongLinkLength(shareUrl.length > LONG_LINK ? shareUrl.length : null)
try {
await navigator.clipboard.writeText(shareUrl)
setShareCopied(true)
Expand All @@ -253,17 +283,20 @@ function App() {
// 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) => {
const switchTabProject = (nextProject: Project, nextSavedFingerprint: string, entry?: TabEntry, nextLibraries: Record<string, string> = {}) => {
if (projectFingerprint(project) === STARTER_FINGERPRINT) removeEntry(tabId)
const next = entry ?? newTabEntry(nextProject, nextSavedFingerprint)
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
lastFingerprintRef.current = projectFingerprint(next.project)
setTabId(next.id)
setSavedFingerprint(next.savedFingerprint)
setProject(next.project)
setActiveFile(next.project.mainFile)
setLibraries(next.libraries ?? {})
setClosedOffer([])
setBrokenLink(false)
setLongLinkLength(null)
}

// A closed project: in this tab if it holds only the untouched starter,
Expand Down Expand Up @@ -292,7 +325,7 @@ function App() {
zipInputRef.current?.click()
return
}
switchTabProject(importedProject, projectFingerprint(importedProject))
switchTabProject(importedProject.project, projectFingerprint(importedProject.project), undefined, importedProject.libraries)
} catch {
// Folder picker not supported or cancelled - importProjectFolder already
// falls back to the hidden zip <input> above when appropriate.
Expand All @@ -301,11 +334,13 @@ function App() {

const openZip = async (file: File) => {
const importedProject = await importProjectZip(file, project)
switchTabProject(importedProject, projectFingerprint(importedProject))
switchTabProject(importedProject.project, projectFingerprint(importedProject.project), undefined, importedProject.libraries)
}

const authErrorMessage = authErrorKey === 'init' ? t.authInitError : authErrorKey === 'config' ? t.authConfigError : authErrorKey === 'cancelled' ? t.authCancelledError : ''
const filePaths = Object.keys(project.files)
// The learner's own files; libraries are listed apart in the drawer.
const ownPaths = filePaths.filter((path) => !libraries[path])

return (
<div className="ide">
Expand Down Expand Up @@ -350,19 +385,34 @@ function App() {
<div className="ide-project-row"><strong>{project.name}</strong><button type="button" className="icon-button small" aria-label={t.renameProject} title={t.renameProject} onClick={renameProject}><Pencil size={15} /></button></div>
</div>
<ul className="ide-file-list">
{filePaths.map((path) => { const file = project.files[path]; return <li key={path} className={path === activeFile ? 'active' : undefined}>
{ownPaths.map((path) => { const file = project.files[path]; return <li key={path} className={path === activeFile ? 'active' : undefined}>
<button type="button" className="ide-file-open" aria-current={path === activeFile ? 'true' : undefined} onClick={() => setActiveFile(path)}>{file.kind === 'python' ? <FileCode2 size={15} /> : <BookOpen size={15} />}<span>{path}</span></button>
{/* A read-only file (e.g. a course's library) can't be renamed or deleted either. */}
{file.readOnly ? <span className="ide-file-read-only" role="img" aria-label={t.readOnlyFile} title={t.readOnlyFile}><Lock size={14} /></span> : <>
<button type="button" className="icon-button small" aria-label={t.renameFile(path)} title={t.renameFile(path)} onClick={() => renameFile(path)}><Pencil size={14} /></button>
<button type="button" className="icon-button small ide-file-delete" aria-label={t.deleteFile(path)} title={t.deleteFile(path)} disabled={filePaths.length === 1} onClick={() => deleteFile(path)}><Trash2 size={14} /></button>
<button type="button" className="icon-button small ide-file-delete" aria-label={t.deleteFile(path)} title={t.deleteFile(path)} disabled={ownPaths.length === 1} onClick={() => deleteFile(path)}><Trash2 size={14} /></button>
</>}
</li> })}
</ul>
<button type="button" className="secondary-button ide-new-file" onClick={addFile}><Plus size={15} /> {t.addFile}</button>
{/* Libraries by URL (libraries.ts): read-only, with where they come from. */}
{Object.keys(libraries).length > 0 && <div className="ide-libraries">
<span className="ide-project-label">{t.libraries}</span>
<ul className="ide-file-list">
{Object.entries(libraries).map(([path, library]) => <li key={path} className={path === activeFile ? 'active' : undefined}>
<button type="button" className="ide-file-open" aria-current={path === activeFile ? 'true' : undefined} disabled={library.status !== 'loaded'} onClick={() => setActiveFile(path)}><FileCode2 size={15} /><span className="ide-library-name"><span>{path}</span><small className={library.status === 'failed' ? 'ide-library-failed' : undefined}>{library.status === 'loaded' ? t.librarySource(libraryHost(library.url)) : library.status === 'loading' ? t.libraryLoading : t.libraryFailed(path, library.failure ?? '')}</small></span></button>
{library.status === 'failed'
? <button type="button" className="icon-button small" aria-label={t.retryLibrary(path)} title={t.retryLibrary(path)} onClick={() => retryLibrary(path)}><RotateCcw size={14} /></button>
: <span className="ide-file-read-only" role="img" aria-label={t.readOnlyFile} title={t.readOnlyFile}><Lock size={14} /></span>}
</li>)}
</ul>
</div>}
</aside>}
<main className="ide-main">
{authErrorMessage && <div className="auth-notice" role="status">{authErrorMessage}</div>}
{brokenLink && <div className="ide-notice ide-notice--warning" role="alert"><span>{t.brokenLink}</span><button type="button" className="icon-button small" aria-label={t.dismiss} title={t.dismiss} onClick={() => setBrokenLink(false)}><X size={15} /></button></div>}
{Object.entries(libraries).filter(([, library]) => library.status === 'failed').map(([path, library]) => <div key={path} className="ide-notice ide-notice--warning" role="status"><span>{t.libraryFailed(path, library.failure ?? '')}</span><button type="button" className="secondary-button" onClick={() => retryLibrary(path)}><RotateCcw size={14} /> {t.retry}</button></div>)}
{longLinkLength !== null && <div className="ide-notice" role="status"><span>{t.longLink(longLinkLength)}</span><button type="button" className="secondary-button" onClick={() => { void downloadProject() }}><Download size={14} /> {t.downloadForHandIn}</button><button type="button" className="icon-button small" aria-label={t.dismiss} title={t.dismiss} onClick={() => setLongLinkLength(null)}><X size={15} /></button></div>}
{updateAvailable && <div className="ide-notice" role="status"><span>{t.updateAvailable}</span><button type="button" className="secondary-button" onClick={() => window.location.reload()}><RotateCcw size={14} /> {t.reloadNow}</button></div>}
{closedOffer.length > 0 && <div className="ide-notice" role="status"><span>{t.lastEdited(closedOffer[0].project.mainFile, timeAgo(closedOffer[0].changedAt, locale, t.justNow))}</span><button type="button" className="secondary-button" onClick={() => openClosedProject(closedOffer[0])}>{t.openClosed}</button>{closedOffer.length > 1 && <button type="button" className="secondary-button" onClick={showClosedProjects}>{t.showAllClosed}</button>}<button type="button" className="icon-button small" aria-label={t.dismiss} title={t.dismiss} onClick={() => setClosedOffer([])}><X size={15} /></button></div>}
<GlPythonWorkspace
Expand Down
42 changes: 42 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ type ShellStrings = {
showAllClosed: string
lastEdited: (file: string, when: string) => string
justNow: string
emptyProjectName: string
brokenLink: string
longLink: (length: number) => string
downloadForHandIn: string
libraries: string
librarySource: (host: string) => string
libraryLoading: string
libraryFailed: (name: string, reason: string) => string
networkError: string
timeout: string
libraryTooLarge: string
libraryNotHttps: string
retry: string
retryLibrary: (name: string) => string
dismiss: string
updateAvailable: string
reloadNow: string
Expand Down Expand Up @@ -84,6 +98,20 @@ export const translations: Record<Locale, Translation> = {
showAllClosed: 'Show all',
lastEdited: (file, when) => `Last edited: ${file} (${when}).`,
justNow: 'just now',
emptyProjectName: 'Empty project',
brokenLink: 'This link is incomplete or damaged. It may have been shortened when it was copied.',
longLink: (length) => `This link is very long (${length} characters). Some programs, e.g. OneNote, shorten such links. For a hand-in, download the project instead.`,
downloadForHandIn: 'Download',
libraries: 'Libraries',
librarySource: (host) => `from ${host}`,
libraryLoading: 'loading …',
libraryFailed: (name, reason) => `Could not load library ${name} (${reason}).`,
networkError: 'network error',
timeout: 'timeout',
libraryTooLarge: 'larger than 1 MB',
libraryNotHttps: 'not an https address',
retry: 'Try again',
retryLibrary: (name) => `Load ${name} again`,
dismiss: 'Close',
updateAvailable: 'A new version is available.',
reloadNow: 'Reload',
Expand Down Expand Up @@ -126,6 +154,20 @@ export const translations: Record<Locale, Translation> = {
showAllClosed: 'Alle anzeigen',
lastEdited: (file, when) => `Zuletzt bearbeitet: ${file} (${when}).`,
justNow: 'gerade eben',
emptyProjectName: 'Leeres Projekt',
brokenLink: 'Dieser Link ist unvollständig oder beschädigt. Möglicherweise wurde er beim Kopieren gekürzt.',
longLink: (length) => `Dieser Link ist sehr lang (${length} Zeichen). Manche Programme, z. B. OneNote, kürzen solche Links. Für eine Abgabe das Projekt besser herunterladen.`,
downloadForHandIn: 'Herunterladen',
libraries: 'Bibliotheken',
librarySource: (host) => `von ${host}`,
libraryLoading: 'wird geladen …',
libraryFailed: (name, reason) => `Die Bibliothek ${name} konnte nicht geladen werden (${reason}).`,
networkError: 'Netzwerkfehler',
timeout: 'Zeitüberschreitung',
libraryTooLarge: 'grösser als 1 MB',
libraryNotHttps: 'keine https-Adresse',
retry: 'Erneut versuchen',
retryLibrary: (name) => `${name} erneut laden`,
dismiss: 'Schliessen',
updateAvailable: 'Neue Version verfügbar.',
reloadNow: 'Neu laden',
Expand Down
Loading
Loading