diff --git a/CLAUDE.md b/CLAUDE.md index a976882..e0b97d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ The one automated check is `test:browser` (`scripts/check-ide-browser.mjs`): `pl - `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` (share links: written as `?p=`, raw deflate (fflate, synchronous so «Teilen» copies within the tap on an iPad) in base64url without padding; `?project=` (lz-string) read forever; versioned payload: v1 every file's code, v2 also libraries by URL, fetched by `src/libraries.ts`; optional `openFile`, the tab to open with (a library by URL once fetched, unless the learner chose a tab: `tabChosenRef`), kept on the `TabEntry`, in «Teilen» links and in the ZIP; the tabs in the link's order (`fileOrder`), which «Teilen» keeps; a link that can't be read opens an empty project with a notice). +- `src/features.ts`: what a tab shows (P34 C): the embed's `features=` vocabulary, the IDE's defaults (`DEFAULT_FEATURES`: everything except `listtree` and `tree`), and `capabilitiesOf` for the workspace. From the payload's `features` or `?features=`, kept on the `TabEntry`, switched in ⋮ «Optionen», written by «Teilen» once the tab has settings. - `src/markdown.ts`: the workspace's `renderMarkdown` (marked + DOMPurify, both pinned; HTML in the Markdown becomes text, links get `target="_blank"` and `rel="noopener noreferrer"`). - `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 0b64cbf..0b108db 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,20 @@ so `https://glpython-edu.github.io/?p=VcxBCoMw…` opens it (`test:browser` checks this). The IDE's own encoder may write other bytes for the same payload; both open. +**What a tab shows.** A link can set which parts the IDE shows, in the +vocabulary of the embed's `features=`: `run` (▶), `tests`, `visualize`, `repl` +(the console's `>>>` input line; the console stays, and `input()` still asks +there), `export` (the visualizer's «→-Kette kopieren»), `coverage` (after +Tests, the lines no example ran), `listtree` (lists as trees) and `tree` (the +visualizer's mode «Baum»). Other names (`ide`, `reset`, unknown ones) are +ignored. It goes into the payload as `"features": ["run", "repl", …]` (in +either encoding), or as `?features=run,repl` on a link without a project. It +is the tab's starting state, not a lock: ⋮ **Optionen** has a switch for each +part; the tab keeps them across a reload, and **Teilen** writes its current +settings (a tab that never had any writes none). Without settings the IDE +shows what it always did: everything except `listtree` and `tree`. Unlike +the embed, that includes `coverage`. + **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 diff --git a/package-lock.json b/package-lock.json index 2bb5005..4c5edcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@azure/msal-browser": "^5.22.0", "dompurify": "3.4.17", "fflate": "0.8.3", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.29", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.30", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", @@ -814,7 +814,7 @@ }, "node_modules/glpython-editor": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#97ad352ae6e7c9ff436b8f8c9bd01e0a766f80bc", + "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#d8c3c95d0470dccdf24858618b9d2d68a0067052", "license": "MIT", "bin": { "glpython-tree": "bin/glpython-tree.mjs" diff --git a/package.json b/package.json index d4aa49d..e85c363 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@azure/msal-browser": "^5.22.0", "dompurify": "3.4.17", "fflate": "0.8.3", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.29", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.30", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", diff --git a/scripts/check-ide-browser.mjs b/scripts/check-ide-browser.mjs index dba541f..dcfc594 100644 --- a/scripts/check-ide-browser.mjs +++ b/scripts/check-ide-browser.mjs @@ -16,7 +16,11 @@ // it; an old link as before; the page's HTML sanitized; // - compact links (P46): «Teilen» writes `?p=` (raw deflate, base64url), // which opens the same project; zlib's test vector, old `?project=` and -// `#project=` links open; a cut `?p=` link shows the notice. +// `#project=` links open; a cut `?p=` link shows the notice; +// - settings through the link (P34 C, `features`): without `repl` no input +// line, but input() asks; ⋮ «Optionen» switches it back, a reload and +// «Teilen» keep the tab's settings; `listtree` and `tree`; ▶ without Tests +// and Tests without ▶; a link without settings as before. // The browser's «leave the page» question and the iPad cases stay manual: // headless Chrome can't confirm them. // @@ -478,6 +482,95 @@ try { await context.close() }) + // P34 C: what a tab shows. The buttons in the top bar, the REPL line. + const settingsPayload = (features, files = [file('main.py', 'name = input("Name: ")\nprint("Hallo", name)\n')]) => ({ v: 1, name: 'Check', mainFile: 'main.py', ...(features ? { features } : {}), files: Object.fromEntries(files.map((entry) => [entry.path, entry])) }) + const barButtons = (page) => page.locator('.ide-actions .editor-actions > button').evaluateAll((buttons) => buttons.map((button) => button.textContent.replace(/\s+/g, ' ').trim().replace(/ ?Stop$/, '').replace(/^Ausführen.*/, 'Ausführen'))) + const replLine = (page) => page.locator('.repl-line:not(.repl-line--program)').count() + const options = async (page) => { + await page.locator('.ide-menu-button').click() + await page.getByRole('menuitem', { name: 'Optionen' }).click() + return page.getByRole('dialog', { name: 'Optionen' }) + } + + await check('settings: without repl no input line, input() still asks; ⋮ Optionen, reload and Teilen', async () => { + const context = await newContext() + const page = await open(context, compactLink(settingsPayload(['run', 'tests', 'visualize', 'ide']))) + assert.deepEqual(await barButtons(page), ['Ausführen', 'Tests', 'Visualisieren']) + assert.equal(await replLine(page), 0, 'no REPL line') + await pythonReady(page) + await page.getByRole('button', { name: 'Ausführen' }).click() + await answer(page, 'Ada', 'Name: ') + await page.waitForFunction(() => document.querySelector('.console')?.textContent.includes('Hallo Ada'), null, { timeout: 30000 }) + assert.equal(await replLine(page), 0, 'still no REPL line') + // ⋮ Optionen: the REPL line back on. + const dialog = await options(page) + assert.deepEqual(await dialog.locator('label').evaluateAll((labels) => labels.map((label) => [label.textContent.trim(), label.querySelector('input').checked])), [['▶ Ausführen', true], ['Tests', true], ['Visualisieren', true], ['Eingabezeile in der Konsole (>>>)', false], ['→-Kette kopieren (im Visualisierer)', false], ['Nach Tests: Zeilen, die kein Beispiel ausführt', false], ['Listen als Baum', false], ['Visualisierer-Modus «Baum»', false]]) + await dialog.getByLabel('Eingabezeile in der Konsole (>>>)').check() + await page.keyboard.press('Escape') + assert.equal(await replLine(page), 1, 'the REPL line is back') + await page.reload() + await page.waitForFunction(() => window.monaco?.editor.getEditors().length > 0, null, { timeout: 60000 }) + assert.equal(await replLine(page), 1, 'after a reload') + await page.getByRole('button', { name: 'Teilen' }).click() + await page.waitForTimeout(500) + assert.deepEqual(payloadOf(await page.evaluate(() => navigator.clipboard.readText())).features, ['run', 'tests', 'visualize', 'repl']) + await context.close() + }) + + await check('settings: listtree and tree - «Baum» in the visualizer, trees in the console', async () => { + const context = await newContext() + const page = await open(context, compactLink(settingsPayload(['run', 'repl', 'visualize', 'listtree', 'tree'], [file('main.py', 'baum = ["+", 2, ["*", 3, 4]]\nx = 2 + 3 * 4\n')]))) + assert.deepEqual(await barButtons(page), ['Ausführen', 'Visualisieren']) + await pythonReady(page) + await page.getByRole('button', { name: 'Ausführen' }).click() + await page.waitForTimeout(1500) + await enterRepl(page, 'baum') + await page.locator('.repl-entry-tree').first().waitFor({ timeout: 30000 }) + await page.locator('.ide-actions').getByRole('button', { name: 'Visualisieren' }).click() + await page.locator('.visualizer-mode').waitFor({ timeout: 60000 }) + assert.deepEqual(await page.locator('.visualizer-mode button').allInnerTexts(), ['Kasten', 'Ersetzung', 'Baum']) + await page.locator('.visualizer-mode button', { hasText: 'Baum' }).click() + await page.getByRole('button', { name: 'Letzter Schritt' }).click() + await page.locator('.locals-row[data-name="baum"] .value-tree').first().waitFor({ timeout: 10000 }) + await context.close() + }) + + await check('settings: ▶ without Tests, Tests without ▶', async () => { + const context = await newContext() + const runOnly = await open(context, compactLink(settingsPayload(['run', 'repl']))) + assert.deepEqual(await barButtons(runOnly), ['Ausführen']) + const testsOnly = await open(context, compactLink(settingsPayload(['tests', 'repl'], [file('main.py', 'def f():\n """\n >>> f()\n 1\n """\n return 1\n')]))) + assert.deepEqual(await barButtons(testsOnly), ['Tests']) + await pythonReady(testsOnly) + await testsOnly.getByRole('button', { name: 'Tests', exact: true }).click() + await testsOnly.locator('.test-summary').waitFor({ timeout: 60000 }) + await context.close() + }) + + await check('settings: a link without features, old and new form, as before', async () => { + const context = await newContext() + for (const url of [link([file('main.py', 'print(1)\n')], 'main.py'), compactLink(settingsPayload(null))]) { + const page = await open(context, url) + assert.deepEqual(await barButtons(page), ['Ausführen', 'Tests', 'Visualisieren']) + assert.equal(await replLine(page), 1) + const dialog = await options(page) + assert.deepEqual(await dialog.locator('input').evaluateAll((inputs) => inputs.map((input) => input.checked)), [true, true, true, true, true, true, false, false], 'the defaults') + await page.keyboard.press('Escape') + await page.getByRole('button', { name: 'Teilen' }).click() + await page.waitForTimeout(500) + assert.equal(payloadOf(await page.evaluate(() => navigator.clipboard.readText())).features, undefined, 'no settings written') + } + await context.close() + }) + + await check('settings: ?features= on a link without a project', async () => { + const context = await newContext() + const page = await open(context, `${base}?features=run,visualize`) + assert.deepEqual(await barButtons(page), ['Ausführen', 'Visualisieren']) + assert.equal(await replLine(page), 0) + await context.close() + }) + await check('an old link: opens on the file that runs, Markdown as text', async () => { const context = await newContext() const page = await open(context, link([file('main.py', 'print(1)\n'), { path: 'notizen.md', label: 'notizen', kind: 'text', code: '# Notizen\n' }], 'main.py', 1, { openFile: 'fehlt.md' })) diff --git a/src/App.tsx b/src/App.tsx index 5c66a0f..802b1bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' -import { BookOpen, CircleHelp, Download, Info, EllipsisVertical, FileCode2, FilePlus2, Folder, FolderOpen, History, Link2, Lock, Pencil, Plus, RotateCcw, Sparkles, Trash2, Upload, X } from 'lucide-react' +import { BookOpen, CircleHelp, Download, Info, EllipsisVertical, SlidersHorizontal, 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' @@ -8,6 +8,7 @@ import { exportProjectZip, importProjectFolder, importProjectZip } from './proje import { buildShareUrl, readSharedLinkFromLocation } from './projectShare' import { fetchLibrary, libraryHost, type LibraryState } from './libraries' import { renderMarkdown } from './markdown' +import { capabilitiesOf, DEFAULT_FEATURES, FEATURE_NAMES, readFeaturesParam, type Feature } from './features' 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' @@ -17,7 +18,7 @@ import { detectLocale, localeNames, saveLocale, translations, type Locale } from // 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(), EMPTY_PROJECT) +const startup = startTab(readSharedLinkFromLocation(), EMPTY_PROJECT, readFeaturesParam()) // 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 @@ -95,6 +96,16 @@ function App() { // The tabs' order: the link's (libraries by URL among the files), new // files after them. const [fileOrder, setFileOrder] = useState(startup.entry.fileOrder) + // Which parts the IDE shows in this tab (features.ts): a link's settings or + // ⋮ «Optionen»'s, else the defaults. Settings once chosen go into «Teilen». + const [features, setFeatures] = useState(startup.entry.features) + const shownFeatures = features ?? DEFAULT_FEATURES + const [isOptionsOpen, setIsOptionsOpen] = useState(false) + const optionsDialogRef = useRef(null) + const toggleFeature = (name: Feature) => setFeatures((current) => { + const now = current ?? DEFAULT_FEATURES + return FEATURE_NAMES.filter((other) => other === name ? !now.includes(name) : now.includes(other)) + }) // 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. @@ -143,8 +154,8 @@ 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, libraries, ...(openFile && (project.files[openFile] || libraries[openFile]) ? { openFile } : {}), ...(fileOrder ? { fileOrder } : {}) }) - }, [project, savedFingerprint, tabId, libraries, openFile, fileOrder]) + saveEntry({ id: tabId, project, savedFingerprint, changedAt: changedAtRef.current, libraries, ...(openFile && (project.files[openFile] || libraries[openFile]) ? { openFile } : {}), ...(fileOrder ? { fileOrder } : {}), ...(features ? { features } : {}) }) + }, [project, savedFingerprint, tabId, libraries, openFile, fileOrder, features]) // 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. @@ -172,7 +183,7 @@ function App() { const stopAnswering = answerTabQueries(() => tabIdRef.current) void otherLiveTabIds().then((liveIds) => { if (liveIds.has(tabIdRef.current)) { - const copy = newTabEntry(project, savedFingerprint, changedAtRef.current, libraries, openFile, fileOrder) + const copy = newTabEntry(project, savedFingerprint, changedAtRef.current, libraries, openFile, fileOrder, features) setTabId(copy.id) } if (startup.fresh) setClosedOffer(recentlyClosed(tabIdRef.current, liveIds).filter((entry) => Date.now() - entry.changedAt < OFFER_CLOSED_MS)) @@ -301,7 +312,7 @@ 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, libraryUrls, openFile, orderedPaths) + const shareUrl = buildShareUrl(project, libraryUrls, openFile, orderedPaths, features) setLongLinkLength(shareUrl.length > LONG_LINK ? shareUrl.length : null) try { await navigator.clipboard.writeText(shareUrl) @@ -329,6 +340,7 @@ function App() { setActiveFile(next.openFile && next.project.files[next.openFile] ? next.openFile : next.project.mainFile) setOpenFile(next.openFile) setFileOrder(next.fileOrder) + setFeatures(next.features) tabChosenRef.current = false setLibraries(next.libraries ?? {}) setClosedOffer([]) @@ -371,6 +383,13 @@ function App() { useEffect(() => { if (isInfoOpen) infoDialogRef.current?.querySelector('button')?.focus() }, [isInfoOpen]) + const closeOptions = () => { + setIsOptionsOpen(false) + menuButtonRef.current?.focus() + } + useEffect(() => { + if (isOptionsOpen) optionsDialogRef.current?.querySelector('input')?.focus() + }, [isOptionsOpen]) const openFolder = async () => { try { @@ -422,6 +441,7 @@ function App() { {(Object.keys(localeNames) as Locale[]).map((code) => )}
+
} @@ -490,7 +510,7 @@ function App() { onGraphicsOpenChange={setIsGraphicsOpen} actionsContainer={actionsContainer} command={workspaceCommand} - capabilities={{ breadcrumbs: false, closeFileTabs: false, resetOutput: false, coverage: true }} + capabilities={{ breadcrumbs: false, closeFileTabs: false, resetOutput: false, ...capabilitiesOf(shownFeatures) }} renderMarkdown={renderMarkdown} t={t} /> @@ -510,6 +530,16 @@ function App() { {infoCheck === 'available' &&
{t.updateAvailable}
} } + {/* ⋮ «Optionen»: which parts this tab shows (a link sets where it starts). */} + {isOptionsOpen &&
+
event.stopPropagation()} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); closeOptions() } }}> +
{t.options}
+

{t.optionsHint}

+
    + {FEATURE_NAMES.map((name) =>
  • )} +
+
+
} {closedList &&
setClosedList(null)}>
event.stopPropagation()} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); setClosedList(null); menuButtonRef.current?.focus() } }}>
{t.recentlyClosed}
diff --git a/src/features.ts b/src/features.ts new file mode 100644 index 0000000..e4a808b --- /dev/null +++ b/src/features.ts @@ -0,0 +1,40 @@ +// Which parts of the IDE a tab shows (P34 C), in the vocabulary of the +// embed's `features=` (the LiaScript macro's): a share link can set them +// (`"features": [...]` in its payload, or `?features=run,repl` without a +// project), ⋮ «Optionen» switches each one, the tab keeps them, and +// «Teilen» writes them. Names the IDE has no use for (`ide`, `reset`) and +// unknown ones are ignored. A tab without settings shows what the IDE always +// did: everything except the trees. +export const FEATURE_NAMES = ['run', 'tests', 'visualize', 'repl', 'export', 'coverage', 'listtree', 'tree'] as const +export type Feature = typeof FEATURE_NAMES[number] + +export const DEFAULT_FEATURES: Feature[] = ['run', 'tests', 'visualize', 'repl', 'export', 'coverage'] + +// The known names of a list (from a link), in the vocabulary's order; null +// for anything that isn't a list of strings. +export function parseFeatures(value: unknown): Feature[] | null { + if (!Array.isArray(value) || !value.every((name) => typeof name === 'string')) return null + return FEATURE_NAMES.filter((name) => value.includes(name)) +} + +// A plain `?features=run,repl` (or `#features=`) on the address. +export function readFeaturesParam(): Feature[] | null { + const url = new URL(window.location.href) + const list = url.searchParams.get('features') ?? new URLSearchParams(url.hash.slice(1)).get('features') + return list === null ? null : parseFeatures(list.split(',').map((name) => name.trim())) +} + +// The workspace's capabilities for these features. +export function capabilitiesOf(features: readonly Feature[]) { + const on = (name: Feature) => features.includes(name) + return { + run: on('run'), + tests: on('tests'), + visualization: on('visualize'), + repl: on('repl'), + export: on('export'), + coverage: on('coverage'), + listTree: on('listtree'), + tree: on('tree'), + } +} diff --git a/src/i18n.ts b/src/i18n.ts index 0999a6b..9656811 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -31,6 +31,10 @@ type ShellStrings = { pythonReference: string newProject: string about: string + // ⋮ «Optionen»: which parts the tab shows, one switch each (features.ts). + options: string + optionsHint: string + featureLabels: Record<'run' | 'tests' | 'visualize' | 'repl' | 'export' | 'coverage' | 'listtree' | 'tree', string> version: string built: string checkForUpdates: string @@ -100,6 +104,18 @@ export const translations: Record = { pythonReference: 'Python reference', newProject: 'New empty project', about: 'About', + options: 'Options', + optionsHint: 'What this tab shows. «Share» passes it on with the project.', + featureLabels: { + run: '▶ Run', + tests: 'Tests', + visualize: 'Visualize', + repl: 'Input line in the console (>>>)', + export: 'Copy → chain (in the visualizer)', + coverage: 'After Tests: lines no example ran', + listtree: 'Lists as trees', + tree: 'Visualizer mode «Tree»', + }, version: 'Version', built: 'Built', checkForUpdates: 'Check for updates', @@ -164,6 +180,18 @@ export const translations: Record = { pythonReference: 'Python-Referenz', newProject: 'Neues leeres Projekt', about: 'Info', + options: 'Optionen', + optionsHint: 'Was dieser Tab zeigt. «Teilen» gibt es mit dem Projekt weiter.', + featureLabels: { + run: '▶ Ausführen', + tests: 'Tests', + visualize: 'Visualisieren', + repl: 'Eingabezeile in der Konsole (>>>)', + export: '→-Kette kopieren (im Visualisierer)', + coverage: 'Nach Tests: Zeilen, die kein Beispiel ausführt', + listtree: 'Listen als Baum', + tree: 'Visualisierer-Modus «Baum»', + }, version: 'Version', built: 'Erstellt', checkForUpdates: 'Nach Updates suchen', diff --git a/src/index.css b/src/index.css index afa7eca..82e121f 100644 --- a/src/index.css +++ b/src/index.css @@ -76,6 +76,10 @@ button { font: inherit; } .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-options-list { margin: 8px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 2px; } +.ide-options-list label { display: flex; align-items: center; gap: 10px; min-height: 36px; padding: 2px 4px; border-radius: var(--glp-radius); cursor: pointer; } +.ide-options-list label:hover { background: var(--glp-surface); } +.ide-options-list input { width: 18px; height: 18px; margin: 0; accent-color: var(--glp-accent); } .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; } diff --git a/src/projectShare.ts b/src/projectShare.ts index 4ea0a1a..34453d1 100644 --- a/src/projectShare.ts +++ b/src/projectShare.ts @@ -1,5 +1,6 @@ import { deflateSync, inflateSync, strFromU8, strToU8 } from 'fflate' import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string' +import { parseFeatures, type Feature } from './features' import type { Project, ProjectFile } from 'glpython-editor' // URL query parameter used to carry a whole project (name, main file, and @@ -19,6 +20,10 @@ import type { Project, ProjectFile } from 'glpython-editor' // an IDE from before reads the link as it did. A file with its code can be // `readOnly: true` too. // +// `features` (P34 C, optional, any version): which parts the IDE shows when +// the project opens, in the embed's `features=` vocabulary (see +// features.ts); without it, everything as before. +// // Two encodings of the same JSON payload (P46): // - `?p=` (written since P46): the payload's UTF-8 bytes compressed with raw // deflate (RFC 1951, no zlib or gzip header; fflate's deflateSync, level 9), @@ -49,6 +54,7 @@ type SharedProjectPayload = { name: string mainFile: string openFile?: string + features?: Feature[] files: Record } @@ -58,7 +64,7 @@ type SharedProjectPayload = { export type SharedLink = | { status: 'none' } | { status: 'broken' } - | { status: 'ok'; project: Project; libraries: Record; openFile?: string; order: string[] } + | { status: 'ok'; project: Project; libraries: Record; openFile?: string; order: string[]; features?: Feature[] } function isProjectFile(value: unknown): value is ProjectFile { if (!value || typeof value !== 'object') return false @@ -84,19 +90,20 @@ function isSharedProjectPayload(value: unknown): value is SharedProjectPayload { // link as URLs, not as their code. // `openFile`: the tab to open with (only if it is one of the project's); // `order`: the order of the tabs (files not in it follow). -function payloadOf(project: Project, libraries: Record, openFile: string | undefined, order: string[]): SharedProjectPayload { +// `features`: the tab's settings, if it has any. +function payloadOf(project: Project, libraries: Record, openFile: string | undefined, order: string[], features?: Feature[]): SharedProjectPayload { const files: Record = {} const paths = [...new Set([...order, ...Object.keys(project.files), ...Object.keys(libraries)])] for (const path of paths) { if (libraries[path]) files[path] = { path, url: libraries[path], readOnly: true } else if (project.files[path]) files[path] = project.files[path] } - return { v: Object.keys(libraries).length > 0 ? 2 : 1, name: project.name, mainFile: project.mainFile, ...(openFile && openFile !== project.mainFile && files[openFile] ? { openFile } : {}), files } + return { v: Object.keys(libraries).length > 0 ? 2 : 1, name: project.name, mainFile: project.mainFile, ...(openFile && openFile !== project.mainFile && files[openFile] ? { openFile } : {}), ...(features ? { features } : {}), files } } // The old encoding (`?project=`), for the checks and as a reference. -export function encodeProjectForUrl(project: Project, libraries: Record = {}, openFile?: string, order: string[] = []): string { - return compressToEncodedURIComponent(JSON.stringify(payloadOf(project, libraries, openFile, order))) +export function encodeProjectForUrl(project: Project, libraries: Record = {}, openFile?: string, order: string[] = [], features?: Feature[]): string { + return compressToEncodedURIComponent(JSON.stringify(payloadOf(project, libraries, openFile, order, features))) } function toBase64Url(bytes: Uint8Array): string { @@ -112,8 +119,8 @@ function fromBase64Url(text: string): Uint8Array | null { } // The compact encoding (`?p=`), see above. -export function encodeProjectCompact(project: Project, libraries: Record = {}, openFile?: string, order: string[] = []): string { - return toBase64Url(deflateSync(strToU8(JSON.stringify(payloadOf(project, libraries, openFile, order))), { level: 9 })) +export function encodeProjectCompact(project: Project, libraries: Record = {}, openFile?: string, order: string[] = [], features?: Feature[]): string { + return toBase64Url(deflateSync(strToU8(JSON.stringify(payloadOf(project, libraries, openFile, order, features))), { level: 9 })) } function inflatePayload(encoded: string): string | null { @@ -137,6 +144,7 @@ export function decodeProjectFromUrl(encoded: string, compact = false): SharedLi else libraries[path] = file.url } const openFile = typeof payload.openFile === 'string' && payload.files[payload.openFile] ? payload.openFile : undefined + const features = parseFeatures(payload.features) return { status: 'ok', project: { id: `shared-${Date.now()}`, name: payload.name.trim() || 'Shared project', mainFile: payload.mainFile, files }, @@ -144,15 +152,16 @@ export function decodeProjectFromUrl(encoded: string, compact = false): SharedLi ...(openFile ? { openFile } : {}), // The tabs in the link's order (libraries by URL among the files). order: Object.keys(payload.files), + ...(features ? { features } : {}), } } catch { return { status: 'broken' } } } -export function buildShareUrl(project: Project, libraries: Record = {}, openFile?: string, order: string[] = []): string { +export function buildShareUrl(project: Project, libraries: Record = {}, openFile?: string, order: string[] = [], features?: Feature[]): string { const url = new URL(import.meta.env.BASE_URL, window.location.origin) - const encoded = encodeProjectCompact(project, libraries, openFile, order) + const encoded = encodeProjectCompact(project, libraries, openFile, order, features) url.searchParams.set(COMPACT_PARAM, encoded) if (url.pathname.length + url.search.length <= MAX_PATH_AND_QUERY) return url.toString() url.search = '' diff --git a/src/tabProjects.ts b/src/tabProjects.ts index f7d621f..2951e0a 100644 --- a/src/tabProjects.ts +++ b/src/tabProjects.ts @@ -1,6 +1,7 @@ import { initialProject, type Project } from 'glpython-editor' import type { LibraryState } from './libraries' import type { SharedLink } from './projectShare' +import type { Feature } from './features' // 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 @@ -39,6 +40,9 @@ export type TabEntry = { openFile?: string // The tabs' order, as in the link (libraries by URL among the files). fileOrder?: string[] + // Which parts the IDE shows in this tab (features.ts), from a link or + // ⋮ «Optionen»; without it, the IDE's defaults. + features?: Feature[] } // A fresh tab's project: one empty file to run. Decided by content, not by @@ -146,7 +150,19 @@ export function claimTabId(id: string): void { // 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 } { +// `features`: a plain `?features=` on the address, for this tab (a link's +// own `features` win). +export function startTab(link: SharedLink, empty: Project, features: Feature[] | null = null): { entry: TabEntry; fresh: boolean; broken: boolean } { + const started = startTabEntry(link, empty) + const chosen = (link.status === 'ok' ? link.features : undefined) ?? features ?? undefined + if (chosen) { + started.entry = { ...started.entry, features: chosen } + saveEntry(started.entry) + } + return started +} + +function startTabEntry(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') { @@ -178,8 +194,8 @@ export function startTab(link: SharedLink, empty: Project): { entry: TabEntry; f // 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 = {}, openFile?: string, fileOrder?: string[]): TabEntry { - const entry: TabEntry = { id: newTabId(), project, savedFingerprint, changedAt, libraries, ...(openFile ? { openFile } : {}), ...(fileOrder ? { fileOrder } : {}) } +export function newTabEntry(project: Project, savedFingerprint: string, changedAt = Date.now(), libraries: Record = {}, openFile?: string, fileOrder?: string[], features?: Feature[]): TabEntry { + const entry: TabEntry = { id: newTabId(), project, savedFingerprint, changedAt, libraries, ...(openFile ? { openFile } : {}), ...(fileOrder ? { fileOrder } : {}), ...(features ? { features } : {}) } saveEntry(entry) claimTabId(entry.id) return entry