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 =
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error}> : visualStdout || t.noOutput} : <>{output}{consoleEntries.map((entry) => entry.kind === 'tests' ? {entry.text}
:)}>}visualizeReplEntry(entry.id) : undefined} busy={isBusy} />