From 4cbcc9327c35e76f5073c86a97a204d9284d7049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= Date: Sun, 25 Oct 2026 23:55:46 +0100 Subject: [PATCH 1/2] IDE shell: one bar, files drawer, stacked workspace (P18, phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IDE looks like the embed, only larger. One bar on top: the logo, the workspace's own buttons (▶ Ausführen · Tests · Visualisieren · Zurücksetzen, portaled into the bar), Teilen with «Link kopiert ✓», and a ⋮ menu (open a folder or ZIP, download, language, delete the local project with a confirmation). At the left edge Dateien (a drawer over the editor: project name, files, new, rename, delete) and Python-Referenz. Below, the workspace in its stacked layout, every file a tab. No breadcrumb, no footer, no dev bar; the runtime status shows only while it loads or failed. The menu and the drawer work with the keyboard (focus moves in, arrow keys, Escape returns focus). Shell styles live in index.css; unused shell strings are dropped. Pins glpython-editor to the commit of glpython-editor#30 until it is tagged v0.1.11. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- README.md | 50 +++++++++----- package-lock.json | 5 +- package.json | 2 +- src/App.tsx | 168 +++++++++++++++++++++++++++++++++------------- src/i18n.ts | 42 ++++++------ src/index.css | 45 +++++++++++++ 7 files changed, 229 insertions(+), 88 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4525cac..23d100e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -The glpython IDE: a browser Python IDE for the classroom, deployed to GitHub Pages at the organization's root, https://glpython-edu.github.io/. The repo is `glpython-edu/glpython-edu.github.io` (that name is what makes it the root site); its local clone is `../glpython`. It is only the **shell**: header, file sidebar, language menu, Microsoft sign-in (MSAL redirect flow) and project persistence (local storage, folder/ZIP import, ZIP export, share links). Everything inside the workspace is the `` component from **glpython-editor** (`../glpython-editor`): the Monaco editor, the Pyodide worker, the REPL, the visualizer, the turtle panel. Changes to those belong there, not here. +The glpython IDE: a browser Python IDE for the classroom, deployed to GitHub Pages at the organization's root, https://glpython-edu.github.io/. The repo is `glpython-edu/glpython-edu.github.io` (that name is what makes it the root site); its local clone is `../glpython`. It is only the **shell**: the top bar (with the workspace's buttons portaled in), the files drawer and the ⋮ menu (README «Layout»), Microsoft sign-in (MSAL redirect flow) and project persistence (local storage, folder/ZIP import, ZIP export, share links). Everything inside the workspace is the `` component from **glpython-editor** (`../glpython-editor`): the Monaco editor, the Pyodide worker, the REPL, the visualizer, the turtle panel. Changes to those belong there, not here. Sibling repos: `../glpython-editor` (the component, runtime and LiaScript embed; read its CLAUDE.md) and `../glpython-liascript` (the LiaScript macro). @@ -20,7 +20,8 @@ There are no tests. `.github/workflows/ci.yml` runs `npm ci`, the build (tsc cat ## Layout -- `src/App.tsx` — the shell. It renders `GlPythonWorkspace` and uses glpython-editor's project helpers (`addProjectFile`, `renameProjectFile`, …). +- `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` still has the old shell's rules (`.app-shell`, `.topbar`, `.sidebar`, …), now unused. - `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/auth.ts` — MSAL. Only active with `VITE_ENTRA_CLIENT_ID` in `.env.local` (README «Microsoft sign-in»). diff --git a/README.md b/README.md index ea1e145..3935c3e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A browser-based educational Python IDE with Microsoft 365 and OneDrive integration. -This repository is the **shell application**: the classroom chrome (header, file -sidebar, lesson header, auth, project persistence) that embeds the reusable +This repository is the **shell application**: the classroom chrome (top bar, +files drawer, menu, auth, project persistence) that embeds the reusable editor/visualizer/graphics core from [glpython-editor](https://github.com/glpython-edu/glpython-editor) via the `` component. The split exists so the editor, Python @@ -13,9 +13,26 @@ in other contexts beyond this classroom shell. - **glpython-editor** owns: the Monaco-based code editor, the Pyodide Web Worker and its `input()`/`gturtle` runtime, the step-by-step execution visualizer, and the turtle graphics canvas. -- **glpython** (this repo) owns: the overall page layout, the file - sidebar and lesson metadata, language switching, Microsoft sign-in, and - project persistence (local storage, ZIP/folder import-export, URL sharing). +- **glpython** (this repo) owns: the overall page layout, the files drawer, + language switching, Microsoft sign-in, and project persistence (local + storage, ZIP/folder import-export, URL sharing). + +## Layout + +The IDE looks like the LiaScript embed, only larger: + +- **One bar on top:** the logo, the workspace's own buttons in the embed's + order and labels (**▶ Ausführen · Tests · Visualisieren · Zurücksetzen**; + ■ Stoppen in place of ▶ while Python is busy), and on the right **Teilen** + and **⋮** (open a folder or a ZIP project, download, language, and under + development tools: delete the local project, with a confirmation). +- **Left edge:** **Dateien** (a drawer over the editor: the project name, its + files, new, rename, delete) and **Python-Referenz**, labelled in their + tooltips. Every file is also a tab above the editor. +- **Below:** the workspace in its stacked layout (editor on top, console + below, a draggable divider; graphics beside the editor only while a program + draws; the visualizer in place of the editor). See glpython-editor's README. +- The runtime's status shows only while it loads or failed; no footer. ## Run locally @@ -42,22 +59,23 @@ committing, and run `npm install` again to restore the published dependency. ## Language -The interface language can be changed from the language menu in the header, -which shows the currently active language and lists all available options in -a dropdown. On first load, the language is auto-detected from the browser's +The interface language can be changed in the ⋮ menu, which marks the active +language. On first load, the language is auto-detected from the browser's language settings and then remembered per browser via local storage. ## Project persistence The current project is saved automatically in browser local storage. The -header supports: - -- **Open folder** in browsers with the File System Access API -- **Open ZIP** in all modern browsers with file upload support -- **Download ZIP** with all project files and `glpython.json` metadata -- **Share project**, which serializes the entire project structure (files, - contents, and metadata) into a compressed URL fragment so a project can be - shared or reopened without a server, similar to +⋮ 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 +- **Projekt herunterladen** as a ZIP with all project files and `glpython.json` + metadata +- **Teilen**, which serializes the entire project structure (files, contents, + 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/) ZIP projects preserve relative paths, the project name, and the configured diff --git a/package-lock.json b/package-lock.json index daca9b7..96c5860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.9", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#4c4c86a9aef7bef9c2311134900765e776a40741", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", @@ -803,7 +803,8 @@ }, "node_modules/glpython-editor": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#5af8711fef409e9e476ad645867c88128dbb504d", + "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#4c4c86a9aef7bef9c2311134900765e776a40741", + "integrity": "sha512-/hPCJUVskVEhJ4v6VJMvmStbBkryM90bNVdo+sOQJIgEc2rzN3zjW9YFcoWtrF514+w98U9M24ZBCuJ1p+BlQA==", "peerDependencies": { "@monaco-editor/react": "^4.7.0", "lucide-react": "^1.47.0", diff --git a/package.json b/package.json index d850c2b..edbc02f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.9", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#4c4c86a9aef7bef9c2311134900765e776a40741", "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 e7804e6..707542a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,9 @@ -import { useEffect, useRef, useState } from 'react' -import { BookOpen, ChevronDown, Download, FileCode2, FolderOpen, Languages, Link2, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Pencil, Plus, Sparkles, SquareTerminal, Trash2, Upload } from 'lucide-react' +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { BookOpen, CircleHelp, Download, EllipsisVertical, FileCode2, Folder, FolderOpen, Link2, Pencil, Plus, Sparkles, Trash2, Upload, X } from 'lucide-react' import { GlPythonWorkspace, addProjectFile, deleteProjectFile, initialProject, renameProjectFile, updateProjectFile } from 'glpython-editor' import type { ProjectFileKind } from 'glpython-editor' import 'glpython-editor/style.css' import { initializeAuth } from './auth' -import type { AccountInfo } from '@azure/msal-browser' import { clearLocalProject, exportProjectZip, importProjectFolder, importProjectZip, loadLocalProject, saveLocalProject } from './projectStorage' import { buildShareUrl, consumeSharedProjectFromLocation } from './projectShare' import { detectLocale, localeNames, saveLocale, translations, type Locale } from './i18n' @@ -24,47 +23,82 @@ const startupProject = (() => { return loadLocalProject(initialProject) })() +// Moves keyboard focus through a menu's items with the arrow keys, Home and +// End, as a menu does; Escape is the caller's. +function moveMenuFocus(event: ReactKeyboardEvent, menu: HTMLElement | null) { + const items = [...(menu?.querySelectorAll('[role^="menuitem"]') ?? [])] + if (items.length === 0) return + const index = items.indexOf(document.activeElement as HTMLElement) + const next = ({ ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: items.length - 1 } as Record)[event.key] + if (next === undefined) return + event.preventDefault() + items[(next + items.length) % items.length].focus() +} + +// The IDE is the embed, only larger (P18): one bar on top with the +// workspace's own buttons (▶ Ausführen · Tests · Visualisieren · Zurücksetzen, +// put there by the workspace), Teilen and a ⋮ menu for what is rarely needed; +// at the left edge the files (a drawer) and the Python reference; below, the +// workspace in its stacked layout. function App() { const [locale, setLocale] = useState(() => detectLocale()) const t = translations[locale] const [project, setProject] = useState(() => startupProject) const [activeFile, setActiveFile] = useState(() => startupProject.mainFile) - const [openFiles, setOpenFiles] = useState(() => [startupProject.mainFile]) - const [, setAccount] = useState(null) const [authErrorKey, setAuthErrorKey] = useState<'' | 'init' | 'config' | 'cancelled'>('') - const [isSidebarOpen, setIsSidebarOpen] = useState(true) - // Closed by default; auto-opened by the workspace itself once a project - // importing gturtle is actually run, instead of always showing an empty - // panel for projects that never draw anything. + // The workspace decides itself when to show graphics (only while the + // program draws); these props are for hosts with their own toggle. const [isGraphicsOpen, setIsGraphicsOpen] = useState(false) + const [actionsContainer, setActionsContainer] = useState(null) + const [isDrawerOpen, setIsDrawerOpen] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [shareCopied, setShareCopied] = useState(false) const zipInputRef = useRef(null) - const languageMenuRef = useRef(null) + const drawerRef = useRef(null) + const filesButtonRef = useRef(null) + const menuRef = useRef(null) + const menuButtonRef = useRef(null) useEffect(() => { saveLocalProject(project) }, [project]) useEffect(() => { - initializeAuth().then(setAccount).catch(() => setAuthErrorKey('init')) + initializeAuth().catch(() => setAuthErrorKey('init')) }, []) - const selectLocale = (nextLocale: Locale) => { - setLocale(nextLocale) - saveLocale(nextLocale) - if (languageMenuRef.current) languageMenuRef.current.open = false - } + // The drawer and the menu take keyboard focus when they open; a click + // elsewhere closes the menu. + useEffect(() => { + if (isDrawerOpen) drawerRef.current?.querySelector('button')?.focus() + }, [isDrawerOpen]) + useEffect(() => { + if (!isMenuOpen) return + menuRef.current?.querySelector('[role^="menuitem"]')?.focus() + const closeOnOutsideClick = (event: PointerEvent) => { + if (!menuRef.current?.contains(event.target as Node) && !menuButtonRef.current?.contains(event.target as Node)) setIsMenuOpen(false) + } + document.addEventListener('pointerdown', closeOnOutsideClick) + return () => document.removeEventListener('pointerdown', closeOnOutsideClick) + }, [isMenuOpen]) - const openFile = (path: string) => { - setActiveFile(path) - setOpenFiles((currentFiles) => currentFiles.includes(path) ? currentFiles : [...currentFiles, path]) + const closeDrawer = () => { + setIsDrawerOpen(false) + filesButtonRef.current?.focus() + } + const closeMenu = () => { + setIsMenuOpen(false) + menuButtonRef.current?.focus() + } + // A menu item: closes the menu, then acts. + const fromMenu = (action: () => void) => () => { + setIsMenuOpen(false) + action() } - const closeFile = (path: string) => { - if (openFiles.length === 1) return - const closingIndex = openFiles.indexOf(path) - const remainingFiles = openFiles.filter((openPath) => openPath !== path) - setOpenFiles(remainingFiles) - if (path === activeFile) setActiveFile(remainingFiles[Math.max(0, closingIndex - 1)]) + const selectLocale = (nextLocale: Locale) => { + setLocale(nextLocale) + saveLocale(nextLocale) } const renameProject = () => { @@ -77,29 +111,23 @@ function App() { const requestedPath = window.prompt(t.promptFileName, t.promptDefaultFileName)?.trim() if (!requestedPath || project.files[requestedPath]) return const kind: ProjectFileKind = requestedPath.endsWith('.py') ? 'python' : 'text' - const newFile = { path: requestedPath, label: requestedPath.replace(/\.[^/.]+$/, ''), kind, code: kind === 'python' ? '# Start writing Python here\n' : '' } + const newFile = { path: requestedPath, label: requestedPath.replace(/\.[^/.]+$/, ''), kind, code: '' } setProject((currentProject) => addProjectFile(currentProject, newFile)) - openFile(requestedPath) + setActiveFile(requestedPath) } const renameFile = (path: string) => { const requestedPath = window.prompt(t.promptRenameFile, path)?.trim() if (!requestedPath || requestedPath === path || project.files[requestedPath]) return setProject((currentProject) => renameProjectFile(currentProject, path, requestedPath)) - setOpenFiles((currentFiles) => currentFiles.map((openPath) => openPath === path ? requestedPath : openPath)) if (activeFile === path) setActiveFile(requestedPath) } const deleteFile = (path: string) => { if (Object.keys(project.files).length === 1 || !window.confirm(t.confirmDeleteFile(path))) return const remainingPaths = Object.keys(project.files).filter((filePath) => filePath !== path) - const nextActiveFile = activeFile === path ? remainingPaths[0] : activeFile setProject((currentProject) => deleteProjectFile(currentProject, path)) - setOpenFiles((currentFiles) => { - const remainingOpenFiles = currentFiles.filter((openPath) => openPath !== path) - return remainingOpenFiles.length ? remainingOpenFiles : [nextActiveFile] - }) - if (activeFile === path) setActiveFile(nextActiveFile) + if (activeFile === path) setActiveFile(remainingPaths[0]) } const downloadProject = async () => { @@ -112,10 +140,14 @@ function App() { URL.revokeObjectURL(url) } + // 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) try { await navigator.clipboard.writeText(shareUrl) + setShareCopied(true) + window.setTimeout(() => setShareCopied(false), 3000) } catch { window.prompt(t.shareLinkPromptLabel, shareUrl) } @@ -130,7 +162,6 @@ function App() { } setProject(importedProject) setActiveFile(importedProject.mainFile) - setOpenFiles([importedProject.mainFile]) } catch { // Folder picker not supported or cancelled - importProjectFolder already // falls back to the hidden zip above when appropriate. @@ -141,7 +172,6 @@ function App() { const importedProject = await importProjectZip(file, project) setProject(importedProject) setActiveFile(importedProject.mainFile) - setOpenFiles([importedProject.mainFile]) } const resetLocalProject = () => { @@ -151,29 +181,75 @@ function App() { } const authErrorMessage = authErrorKey === 'init' ? t.authInitError : authErrorKey === 'config' ? t.authConfigError : authErrorKey === 'cancelled' ? t.authCancelledError : '' + const filePaths = Object.keys(project.files) return ( -
-
glpython
{t.openProject}
{ const file = event.target.files?.[0]; if (file) void openZip(file); event.target.value = '' }} />
{localeNames[locale]}
{(Object.keys(localeNames) as Locale[]).map((code) => )}
-
- {isSidebarOpen && } -
+
+
+
glpython
+
+
+ {shareCopied && {t.shareCopied}} + +
+ + {isMenuOpen &&
{ if (event.key === 'Escape') { event.preventDefault(); closeMenu() } else if (event.key === 'Tab') setIsMenuOpen(false); else moveMenuFocus(event, menuRef.current) }}> + + + +
+ + {(Object.keys(localeNames) as Locale[]).map((code) => )} +
+
+ + +
+
} +
+ { const file = event.target.files?.[0]; if (file) void openZip(file); event.target.value = '' }} /> +
+
+
+ + {isDrawerOpen && } +
{authErrorMessage &&
{authErrorMessage}
} undefined} onFileCodeChange={(path, code) => setProject((currentProject) => updateProjectFile(currentProject, path, code))} isGraphicsOpen={isGraphicsOpen} onGraphicsOpenChange={setIsGraphicsOpen} + actionsContainer={actionsContainer} + capabilities={{ breadcrumbs: false, closeFileTabs: false }} t={t} />
-
{t.statusRuntime}{t.footerTagline}
-
{t.devTools}
) } diff --git a/src/i18n.ts b/src/i18n.ts index 56930a1..a889031 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -14,21 +14,23 @@ type ShellStrings = { openProject: string openFolder: string downloadProject: string - shareProject: string shareLinkCopiedOutput: string shareLinkPromptLabel: string + share: string + shareCopied: string + moreActions: string + files: string + closeFiles: string + projectName: string + ideTools: string microsoftConnected: string signInWithMicrosoft: string settings: string notSignedIn: string - collapseSidebar: string - expandSidebar: string - courseCategory: string courseMenu: string renameProject: string promptRenameProject: string lessonProgress: string - projectFiles: string addFile: string renameFile: (path: string) => string deleteFile: (path: string) => string @@ -37,9 +39,7 @@ type ShellStrings = { pythonReference: string unsaved: string save: string - statusRuntime: string autosaveOff: string - footerTagline: string devTools: string clearLocalProject: string language: string @@ -68,21 +68,23 @@ export const translations: Record = { openProject: 'Open project', openFolder: 'Open folder', downloadProject: 'Download project', - shareProject: 'Share project', shareLinkCopiedOutput: 'Share link copied to the clipboard. Anyone who opens it gets a copy of this project.', shareLinkPromptLabel: 'Copy this share link:', + share: 'Share', + shareCopied: 'Link copied ✓', + moreActions: 'More actions', + files: 'Files', + closeFiles: 'Close files', + projectName: 'Project name', + ideTools: 'Tools', microsoftConnected: 'Microsoft connected', signInWithMicrosoft: 'Sign in with Microsoft', settings: 'Settings', notSignedIn: 'Not signed in', - collapseSidebar: 'Collapse files sidebar', - expandSidebar: 'Expand files sidebar', - courseCategory: 'PROJECT NAME', courseMenu: 'Course menu', renameProject: 'Rename project', promptRenameProject: 'Project name', lessonProgress: 'Lesson 01 of 08', - projectFiles: 'PROJECT FILES', addFile: 'Add file', renameFile: (path) => `Rename ${path}`, deleteFile: (path) => `Delete ${path}`, @@ -91,9 +93,7 @@ export const translations: Record = { pythonReference: 'Python reference', unsaved: 'Unsaved', save: 'Save', - statusRuntime: 'Python 3.12 in browser', autosaveOff: 'Autosave is off', - footerTagline: 'glpython preview · built for learning', devTools: 'Development tools', clearLocalProject: 'Clear local project', language: 'Language', @@ -118,21 +118,23 @@ export const translations: Record = { openProject: 'Projekt öffnen', openFolder: 'Ordner öffnen', downloadProject: 'Projekt herunterladen', - shareProject: 'Projekt teilen', shareLinkCopiedOutput: 'Freigabelink in die Zwischenablage kopiert. Wer ihn öffnet, erhält eine Kopie dieses Projekts.', shareLinkPromptLabel: 'Diesen Freigabelink kopieren:', + share: 'Teilen', + shareCopied: 'Link kopiert ✓', + moreActions: 'Weitere Aktionen', + files: 'Dateien', + closeFiles: 'Dateien schliessen', + projectName: 'Projektname', + ideTools: 'Werkzeuge', microsoftConnected: 'Mit Microsoft verbunden', signInWithMicrosoft: 'Mit Microsoft anmelden', settings: 'Einstellungen', notSignedIn: 'Nicht angemeldet', - collapseSidebar: 'Dateileiste einklappen', - expandSidebar: 'Dateileiste ausklappen', - courseCategory: 'PROJEKTNAME', courseMenu: 'Kursmenü', renameProject: 'Projekt umbenennen', promptRenameProject: 'Projektname', lessonProgress: 'Lektion 01 von 08', - projectFiles: 'PROJEKTDATEIEN', addFile: 'Datei hinzufügen', renameFile: (path) => `${path} umbenennen`, deleteFile: (path) => `${path} löschen`, @@ -141,9 +143,7 @@ export const translations: Record = { pythonReference: 'Python-Referenz', unsaved: 'Nicht gespeichert', save: 'Speichern', - statusRuntime: 'Python 3.12 im Browser', autosaveOff: 'Automatisches Speichern ist deaktiviert', - footerTagline: 'glpython-Vorschau · zum Lernen entwickelt', devTools: 'Entwicklerwerkzeuge', clearLocalProject: 'Lokales Projekt löschen', language: 'Sprache', diff --git a/src/index.css b/src/index.css index a91d28a..1df228c 100644 --- a/src/index.css +++ b/src/index.css @@ -3,3 +3,48 @@ * { box-sizing: border-box; } body { margin: 0; min-width: 320px; } button { font: inherit; } + +/* The shell (P18): one bar on top, a rail with the files drawer and the + Python reference at the left edge, and the workspace filling the rest of + the window (its stacked layout: editor, divider, console). */ +.ide { height: 100vh; height: 100dvh; display: flex; flex-direction: column; background: var(--glp-bg); } +.ide :is(button, a, input, [role="separator"]):focus-visible { outline: 2px solid var(--glp-accent); outline-offset: 2px; } +.ide-bar { min-height: 56px; flex-shrink: 0; display: flex; align-items: center; gap: 16px; padding: 8px 16px; border-bottom: 1px solid var(--glp-border); } +.ide-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; } +.ide-brand-mark { width: 28px; height: 28px; display: grid; place-items: center; color: var(--glp-on-accent); background: var(--glp-accent); border-radius: var(--glp-radius); } +.ide-actions { display: flex; } +.ide-bar-end { margin-left: auto; display: flex; align-items: center; gap: 8px; } +.ide-share-status { color: var(--glp-text-muted); font-size: 13px; } +.ide-menu { position: relative; } +.ide-menu-list { position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; min-width: 250px; padding: 6px 0; background: var(--glp-bg); border: 1px solid var(--glp-border); border-radius: var(--glp-radius); box-shadow: 0 8px 20px rgba(0, 0, 0, .12); } +.ide-menu-list button { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px 14px; border: 0; color: var(--glp-text); background: none; text-align: left; font: 14px/1.3 var(--glp-font-ui); cursor: pointer; } +.ide-menu-list button:hover, .ide-menu-list button:focus-visible { background: var(--glp-surface); outline: none; } +.ide-menu-group { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--glp-border); } +.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; } +/* Over the workspace, so nothing below moves when it opens. */ +.ide-drawer { position: absolute; top: 0; bottom: 0; left: 52px; z-index: 15; width: 290px; display: flex; flex-direction: column; gap: 14px; padding: 14px; overflow-y: auto; background: var(--glp-bg); border-right: 1px solid var(--glp-border); box-shadow: 8px 0 20px rgba(0, 0, 0, .08); } +.ide-drawer-head { display: flex; align-items: center; justify-content: space-between; } +.ide-drawer-title { font-weight: 700; } +.ide-project-label { display: block; color: var(--glp-text-muted); font-size: 12px; font-weight: 600; } +.ide-project-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.ide-file-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 2px; } +.ide-file-list li { display: flex; align-items: center; gap: 2px; border-radius: var(--glp-radius); } +.ide-file-list li.active { background: var(--glp-surface); } +.ide-file-open { min-width: 0; flex: 1; display: flex; align-items: center; gap: 8px; padding: 7px 8px; border: 0; color: var(--glp-text); background: none; text-align: left; font: 14px var(--glp-font-code); cursor: pointer; } +.ide-file-open span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ide-file-delete:not(:disabled):hover { color: var(--glp-error); } +.ide-new-file { align-self: flex-start; } +.ide-main { min-width: 0; min-height: 0; flex: 1; display: flex; flex-direction: column; gap: 10px; padding: 14px 16px 16px; } + +/* iPad portrait and narrower: the logo without its name. */ +@media (max-width: 900px) { + .ide-brand > span:last-child { display: none; } + .ide-bar { gap: 10px; padding: 8px 12px; } + .ide-main { padding: 12px; } +} From 6681c9c863167a0390d3851985a6b70663214a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= Date: Mon, 26 Oct 2026 00:07:05 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=C2=ABZur=C3=BCcksetzen=C2=BB=20leaves=20th?= =?UTF-8?q?e=20bar:=20=C2=ABPython=20neu=20starten=C2=BB=20in=20=E2=8B=AE;?= =?UTF-8?q?=20pin=20v0.1.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ▶ Ausführen already starts with a fresh namespace and an empty console, ■ Stoppen ends a hung program, and learners read «Zurücksetzen» as "reset my code". So the bar keeps ▶ · Tests · Visualisieren, and ⋮ starts with «Python neu starten», which mounts the workspace anew (a new Python, an empty console, as the embed's Reset; the code stays). Pins glpython-editor v0.1.11 (glpython-edu/glpython-editor#30). Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +++++++---- package-lock.json | 5 ++--- package.json | 2 +- src/App.tsx | 17 +++++++++++++---- src/i18n.ts | 6 ++++++ 5 files changed, 29 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3935c3e..f82611e 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,13 @@ in other contexts beyond this classroom shell. The IDE looks like the LiaScript embed, only larger: - **One bar on top:** the logo, the workspace's own buttons in the embed's - order and labels (**▶ Ausführen · Tests · Visualisieren · Zurücksetzen**; - ■ Stoppen in place of ▶ while Python is busy), and on the right **Teilen** - and **⋮** (open a folder or a ZIP project, download, language, and under - development tools: delete the local project, with a confirmation). + order and labels (**▶ Ausführen · Tests · Visualisieren**; ■ Stoppen in + place of ▶ while Python is busy), and on the right **Teilen** and **⋮** + (Python neu starten, open a folder or a ZIP project, download, language, and + under development tools: delete the local project, with a confirmation). + There is no Zurücksetzen in the bar: ▶ already starts with a fresh namespace + and an empty console, ■ stops a hung program, and «Python neu starten» (a new + workspace, as the embed's Reset; the code stays) is in ⋮. - **Left edge:** **Dateien** (a drawer over the editor: the project name, its files, new, rename, delete) and **Python-Referenz**, labelled in their tooltips. Every file is also a tab above the editor. diff --git a/package-lock.json b/package-lock.json index 96c5860..3273ece 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#4c4c86a9aef7bef9c2311134900765e776a40741", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.11", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", @@ -803,8 +803,7 @@ }, "node_modules/glpython-editor": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#4c4c86a9aef7bef9c2311134900765e776a40741", - "integrity": "sha512-/hPCJUVskVEhJ4v6VJMvmStbBkryM90bNVdo+sOQJIgEc2rzN3zjW9YFcoWtrF514+w98U9M24ZBCuJ1p+BlQA==", + "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#7f593b183ae3a12f0ef0157976b5db33f00f7b9c", "peerDependencies": { "@monaco-editor/react": "^4.7.0", "lucide-react": "^1.47.0", diff --git a/package.json b/package.json index edbc02f..1358a94 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#4c4c86a9aef7bef9c2311134900765e776a40741", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.11", "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 707542a..dcdac3e 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, EllipsisVertical, FileCode2, Folder, FolderOpen, Link2, Pencil, Plus, Sparkles, Trash2, Upload, X } from 'lucide-react' +import { BookOpen, CircleHelp, Download, EllipsisVertical, FileCode2, Folder, FolderOpen, Link2, Pencil, Plus, RotateCcw, Sparkles, Trash2, Upload, X } from 'lucide-react' import { GlPythonWorkspace, addProjectFile, deleteProjectFile, initialProject, renameProjectFile, updateProjectFile } from 'glpython-editor' import type { ProjectFileKind } from 'glpython-editor' import 'glpython-editor/style.css' @@ -36,8 +36,10 @@ function moveMenuFocus(event: ReactKeyboardEvent, menu: HTMLElement } // The IDE is the embed, only larger (P18): one bar on top with the -// workspace's own buttons (▶ Ausführen · Tests · Visualisieren · Zurücksetzen, -// put there by the workspace), Teilen and a ⋮ menu for what is rarely needed; +// workspace's own buttons (▶ Ausführen · Tests · Visualisieren, put there by +// the workspace), Teilen and a ⋮ menu for what is rarely needed (among it +// «Python neu starten»: ▶ already starts afresh and ■ stops a hung program, +// and learners read «Zurücksetzen» as "reset my code"); // at the left edge the files (a drawer) and the Python reference; below, the // workspace in its stacked layout. function App() { @@ -53,6 +55,9 @@ function App() { const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false) const [shareCopied, setShareCopied] = useState(false) + // «Python neu starten» mounts the workspace anew: a new Python, an empty + // console - as the embed's Reset. The code is the IDE's and stays. + const [workspaceGeneration, setWorkspaceGeneration] = useState(0) const zipInputRef = useRef(null) const drawerRef = useRef(null) const filesButtonRef = useRef(null) @@ -194,9 +199,12 @@ function App() {
{isMenuOpen &&
{ if (event.key === 'Escape') { event.preventDefault(); closeMenu() } else if (event.key === 'Tab') setIsMenuOpen(false); else moveMenuFocus(event, menuRef.current) }}> + +
+
{(Object.keys(localeNames) as Locale[]).map((code) => )} @@ -236,6 +244,7 @@ function App() {
{authErrorMessage &&
{authErrorMessage}
}
diff --git a/src/i18n.ts b/src/i18n.ts index a889031..29a1622 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -23,6 +23,8 @@ type ShellStrings = { closeFiles: string projectName: string ideTools: string + restartPython: string + projectMenu: string microsoftConnected: string signInWithMicrosoft: string settings: string @@ -77,6 +79,8 @@ export const translations: Record = { closeFiles: 'Close files', projectName: 'Project name', ideTools: 'Tools', + restartPython: 'Restart Python', + projectMenu: 'Project', microsoftConnected: 'Microsoft connected', signInWithMicrosoft: 'Sign in with Microsoft', settings: 'Settings', @@ -127,6 +131,8 @@ export const translations: Record = { closeFiles: 'Dateien schliessen', projectName: 'Projektname', ideTools: 'Werkzeuge', + restartPython: 'Python neu starten', + projectMenu: 'Projekt', microsoftConnected: 'Mit Microsoft verbunden', signInWithMicrosoft: 'Mit Microsoft anmelden', settings: 'Einstellungen',