From f9587179a63ab8d123515be4507cd4e788833c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:44:19 +0100 Subject: [PATCH] Read-only files (ProjectFile.readOnly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file with readOnly: true is one to read, not to change, e.g. a course's library that learners use as a black box (the evaluator's parser.py). The editor shows it read-only (domReadOnly, so an iPad's keyboard doesn't open), its tab has a lock, typing into it shows «Schreibgeschützt: Diese Datei kann gelesen, aber nicht geändert werden.» (readOnlyFile, de/en), and updateProjectFile, renameProjectFile and deleteProjectFile leave it as it is. It runs, imports, tests and visualizes like any other file. updateCode now ignores Monaco's flush events (setValue): @monaco-editor/ react shows a read-only file with setValue before its onChange knows the new tab, which wrote parser.py's text into the previously open file. A learner's own edits (typing, paste, undo) are never flushes. check-embed-protocol.mjs checks the helpers against the build. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + README.md | 7 +++++++ scripts/check-embed-protocol.mjs | 20 ++++++++++++++++++++ src/GlPythonWorkspace.tsx | 15 +++++++++++---- src/i18n.ts | 2 ++ src/project.ts | 10 +++++++--- src/style.css | 1 + 7 files changed, 49 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 83a0bc1..5764d09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ Worker messages: - **Visualizer modes:** in `box` mode every call is a `Frame` box at its call site. In `substitution` mode (the default; prop `visualizerMode`, embed `?visualizer=`), a call with `shortForm` renders as `Frame` scope `'inline'`: its return expression with parameters substituted, in parentheses in operator context. - **Layout and scrolling:** nested boxes show their variables below their code (CSS). The scroll effect follows the active box horizontally. It sets `scrollLeft`/`scrollTop` on the visualizer view only; don't use `scrollIntoView`, which also scrolls the LiaScript page around the embed and moves the step buttons. - **Console and Stop:** output, REPL inputs, Tests reports and notes form one transcript (`ConsoleEntry` list after `output`). While Python is busy for more than `STOP_BUTTON_DELAY`, the run button is **Stop**: `stopPython` terminates the worker and calls `startWorker` again, so the REPL namespace is gone after a stop. The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`). +- **One Monaco model for all files:** a tab switch replaces the editor's text. `updateCode` ignores flush events (`setValue`), since `@monaco-editor/react` shows a read-only file (`ProjectFile.readOnly`) with `setValue` before its `onChange` knows the new tab - the text would land in the previous file. - **UI strings** come in via the `t: CoreStrings` prop. `src/i18n.ts` is their single source: `coreStrings` holds German and English, and `resolveUiLocale` picks the language. The embed uses them directly; the glpython IDE imports them and adds only its shell strings. New strings have been added as *optional* `CoreStrings` keys with an English fallback, so older hosts keep compiling; add both languages to `coreStrings`. Python values, reprs and error messages are never translated. - **Two layouts:** with `compact` (the embed) the original tree (`main-split` > `editor-column` + graphics aside), unchanged and styled by `src/embed/style.css`; without it (the IDE) the stacked layout (`workspace-stack`: `stack-top` with the editor panel and, while drawing, the graphics, then `stack-divider` and `stack-console`). Both are assembled from the same JSX pieces (`actionsNode`, `visualizerBarNode`, `editorWrapNode`, `consoleNode`, …) just before the return. The buttons can be portaled into the host's bar (`actionsContainer`). - **Embedded/reduced layouts:** the knobs are `compact`, `capabilities` (`WorkspaceCapabilities`, including `repl`) and `command` (`WorkspaceCommand`, an immutable `{id, type}` used by hosts to trigger run/visualize/test/stop/reset without the workspace's own buttons; `reset` restarts Python and clears the console). Defaults must preserve the full-workspace behavior. diff --git a/README.md b/README.md index 2947880..0058c26 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,13 @@ the embed's Reset does. The run button keeps its width when it becomes the editor. `capabilities={{ closeFileTabs: false }}` drops the ✕ on the file tabs, for a host that shows every file of the project as a tab. +A file with `readOnly: true` (`ProjectFile`) is one to read, not to change, +e.g. a course's library that learners use as a black box: the editor shows it +read-only (on an iPad the keyboard doesn't open), its tab has a lock +(`readOnlyFile`, also the message when typing into it), and +`updateProjectFile`, `renameProjectFile` and `deleteProjectFile` leave it +as it is. It runs, imports and visualizes like any other file. + ### Compact embeds `GlPythonWorkspace` accepts optional `compact` and `capabilities` props. The diff --git a/scripts/check-embed-protocol.mjs b/scripts/check-embed-protocol.mjs index 26993df..48b28d1 100644 --- a/scripts/check-embed-protocol.mjs +++ b/scripts/check-embed-protocol.mjs @@ -5,6 +5,9 @@ import { GLPYTHON_MAX_SOURCE_LENGTH, parseParentMessage, parseParentOrigin, + deleteProjectFile, + renameProjectFile, + updateProjectFile, } from '../dist/index.js' assert.equal(parseParentOrigin('https://course.example'), 'https://course.example') @@ -131,4 +134,21 @@ const readyMessage = { } assert.equal(readyMessage.capabilities.input, false) +// Not the protocol, but the one check against the built library: a +// read-only file (a course's library) is never changed, renamed or deleted by +// the project helpers; an ordinary file still is. +const project = { + id: 'p', name: 'p', mainFile: 'auswerter.py', + files: { + 'parser.py': { path: 'parser.py', label: 'parser', kind: 'python', code: 'def parse(text): ...', readOnly: true }, + 'auswerter.py': { path: 'auswerter.py', label: 'auswerter', kind: 'python', code: 'from parser import parse' }, + }, +} +assert.equal(updateProjectFile(project, 'parser.py', 'kaputt'), project) +assert.equal(renameProjectFile(project, 'parser.py', 'p2.py'), project) +assert.equal(deleteProjectFile(project, 'parser.py'), project) +assert.equal(updateProjectFile(project, 'auswerter.py', 'x = 1').files['auswerter.py'].code, 'x = 1') +assert.equal(updateProjectFile(project, 'auswerter.py', 'x = 1').files['parser.py'].readOnly, true) +assert.deepEqual(Object.keys(renameProjectFile(project, 'auswerter.py', 'main.py').files), ['parser.py', 'main.py']) + console.log('Embed protocol checks passed.') diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index 7ed0515..8a1f6a0 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -2,7 +2,7 @@ import { createPortal } from 'react-dom' import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react' import Editor, { useMonaco } from '@monaco-editor/react' import type { Monaco, OnMount } from '@monaco-editor/react' -import { AlertTriangle, BookOpen, Copy, Square, FileCode2, FlaskConical, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronRight, Ellipsis, Play, RotateCcw, SkipBack, SkipForward, Sparkles, SquareTerminal, Terminal, Workflow, X } from 'lucide-react' +import { AlertTriangle, BookOpen, Copy, Square, FileCode2, FlaskConical, Lock, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronRight, Ellipsis, Play, RotateCcw, SkipBack, SkipForward, Sparkles, SquareTerminal, Terminal, Workflow, X } from 'lucide-react' import { projectImportsGturtle, type Project } from './project' import { GraphicsWindow, type TurtleCommand } from './GraphicsWindow' import { buildAnnotationsUpTo, buildEvaluationChain, buildExecutedLinesUpTo, buildLocalsUpTo, buildSubstitutionsUpTo, collapseDeepCallChain, hasEvaluationChain, hiddenRangesWithin, removedLines, computeLineOffsets, computeOpenFramesUpTo, indexAstNodes, lineNumberAtOffset, locateNodeRenderedRange, nodeRange, renderFrameSource, substitutionModeSteps, type AstNode, type CollapsedCalls, type OpenFrame, type TraceResult, type TraceStep, type VisualizerMode } from './visualizer' @@ -59,6 +59,9 @@ type CoreStrings = { // visualizerCollapsedCalls. consoleSize?: string editorPlaceholder?: string + // A read-only file (ProjectFile.readOnly): the lock's label on its tab, and + // the message when typing into it. Optional, like editorPlaceholder. + readOnlyFile?: string // Accessible name of the REPL's input line, and the note shown after a // "def"/"class" typed into the REPL. Optional like visualizerCollapsedCalls. replInputLabel?: string @@ -1151,7 +1154,11 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { setInputValue('') } - const updateCode = (code: string | undefined) => { + // Only the learner's own edits. A "flush" is Monaco's setValue, which + // @monaco-editor/react uses to show a read-only file - possibly before its + // onChange knows the new tab, so the text would land in the previous file. + const updateCode = (code: string | undefined, event?: { isFlush: boolean }) => { + if (event?.isFlush) return if (coverageMarks?.path === activeFile) setCoverageMarks(null) onFileCodeChange(activeFile, code ?? '') } @@ -2080,8 +2087,8 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const breadcrumbsNode =
{project.name}/{selectedFile.path}
const visualizerBarNode =
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
const visualizerWarningNode = <>{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
} - const editorTabsNode =
{openFiles.map((path) => { const file = project.files[path]; return })}
- const editorWrapNode =
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} onMount={setNormalEditor} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false, placeholder: t.editorPlaceholder ?? 'Type code here' }} />}
+ const editorTabsNode =
{openFiles.map((path) => { const file = project.files[path]; return })}
+ const editorWrapNode =
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} onMount={setNormalEditor} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false, placeholder: t.editorPlaceholder ?? 'Type code here', readOnly: selectedFile.readOnly === true, domReadOnly: selectedFile.readOnly === true, readOnlyMessage: { value: t.readOnlyFile ?? 'Read-only: this file can be read but not changed.' } }} />}
const consoleNode = <>{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ?
: entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}
{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
} // In the stacked layout the graphics show only while the program draws - // for a visualization: if it draws at all, so the panel doesn't appear in diff --git a/src/i18n.ts b/src/i18n.ts index b4584e7..616f2dd 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -69,6 +69,7 @@ export const coreStrings: Record = { stop: 'Stop', stoppedNote: 'Stopped. Python was restarted.', consoleSize: 'Console size', + readOnlyFile: 'Read-only: this file can be read but not changed.', editorPlaceholder: 'Type code here', pyodideRuntime: 'Pyodide runtime', runsFile: (mainFile) => `Runs ${mainFile}`, @@ -146,6 +147,7 @@ export const coreStrings: Record = { stop: 'Stoppen', stoppedNote: 'Gestoppt. Python wurde neu gestartet.', consoleSize: 'Grösse der Konsole', + readOnlyFile: 'Schreibgeschützt: Diese Datei kann gelesen, aber nicht geändert werden.', editorPlaceholder: 'Code hier eingeben', pyodideRuntime: 'Pyodide-Laufzeit', runsFile: (mainFile) => `Führt ${mainFile} aus`, diff --git a/src/project.ts b/src/project.ts index 74acf6d..364d62c 100644 --- a/src/project.ts +++ b/src/project.ts @@ -5,6 +5,10 @@ export type ProjectFile = { label: string code: string kind: ProjectFileKind + // A file to read, not to change - e.g. a course's library that learners + // use as a black box. The editor shows it read-only, and the helpers below + // don't change, rename or delete it. + readOnly?: boolean } export type Project = { @@ -53,7 +57,7 @@ export function projectImportsGturtle(project: Project): boolean { export function updateProjectFile(project: Project, path: string, code: string): Project { const file = project.files[path] - if (!file) return project + if (!file || file.readOnly) return project return { ...project, @@ -73,7 +77,7 @@ export function addProjectFile(project: Project, file: ProjectFile): Project { export function deleteProjectFile(project: Project, path: string): Project { const { [path]: deletedFile, ...remainingFiles } = project.files - if (!deletedFile) return project + if (!deletedFile || deletedFile.readOnly) return project const nextMainFile = project.mainFile === path ? Object.keys(remainingFiles).find((filePath) => remainingFiles[filePath].kind === 'python') ?? Object.keys(remainingFiles)[0] @@ -84,7 +88,7 @@ export function deleteProjectFile(project: Project, path: string): Project { export function renameProjectFile(project: Project, path: string, nextPath: string): Project { const file = project.files[path] - if (!file || project.files[nextPath]) return project + if (!file || file.readOnly || project.files[nextPath]) return project const { [path]: renamedFile, ...remainingFiles } = project.files const nextFile = { ...renamedFile, path: nextPath, label: nextPath.replace(/\.[^/.]+$/, '') } diff --git a/src/style.css b/src/style.css index b52b1da..22a01ac 100644 --- a/src/style.css +++ b/src/style.css @@ -67,6 +67,7 @@ .editor-tab { min-width: 130px; display: flex; align-items: center; gap: 8px; padding: 0 12px; border: 0; border-right: 1px solid var(--glp-border); color: var(--glp-text-muted); background: var(--glp-surface); font: 13px var(--glp-font-code); cursor: pointer; } .editor-tab.active { color: var(--glp-text); background: var(--glp-bg); box-shadow: inset 0 2px 0 var(--glp-accent); } .tab-file-icon { display: inline-flex; color: var(--glp-text-muted); } +.tab-read-only { display: inline-flex; color: var(--glp-text-muted); } .editor-tab.active .tab-file-icon { color: var(--glp-accent); } .tab-close { display: inline-flex; margin-left: auto; padding: 3px; color: var(--glp-text-muted); border-radius: var(--glp-radius); } .tab-close:hover { color: var(--glp-text); background: var(--glp-border); }