From c0fa9a83eeac95705a7c358747c727e3b445bc8e 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:48:07 +0100 Subject: [PATCH 1/2] Read-only files: lock in the drawer, kept in share links and ZIPs A file with readOnly: true (glpython-editor v0.1.19, e.g. a course's library in a share link) shows a lock in the file drawer instead of rename and delete. Share links accept the flag only as a boolean; ZIP export lists read-only files in glpython.json ("readOnly"), and ZIP and folder import mark them again. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++++++++-- src/App.tsx | 5 ++++- src/index.css | 2 ++ src/projectShare.ts | 4 +++- src/projectStorage.ts | 25 ++++++++++++++++++------- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d9c3982..1367d41 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,14 @@ The current project is saved automatically in browser local storage. The server, similar to [webtigerpython.ethz.ch](https://webtigerpython.ethz.ch/) -ZIP projects preserve relative paths, the project name, and the configured -Python entry point. This local format is also the shape planned for OneDrive +ZIP projects preserve relative paths, the project name, the configured +Python entry point, and which files are read-only (`readOnly` in +`glpython.json`). + +A **read-only file** (`readOnly: true` on the file, e.g. in a share link from +a course, as the LiaScript macro builds for its libraries) is shown with a +lock: it can be read, run and imported, but not edited, renamed or deleted - +e.g. a parser that learners use as a black box. This local format is also the shape planned for OneDrive folder synchronization. ## Microsoft sign-in diff --git a/src/App.tsx b/src/App.tsx index fb3d4f2..2a34104 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, RotateCcw, Sparkles, Trash2, Upload, X } from 'lucide-react' +import { BookOpen, CircleHelp, Download, EllipsisVertical, FileCode2, Folder, FolderOpen, Link2, Lock, Pencil, Plus, RotateCcw, Sparkles, Trash2, Upload, X } from 'lucide-react' import { GlPythonWorkspace, addProjectFile, deleteProjectFile, initialProject, renameProjectFile, updateProjectFile } from 'glpython-editor' import type { ProjectFileKind, WorkspaceCommand } from 'glpython-editor' import 'glpython-editor/style.css' @@ -241,8 +241,11 @@ function App() { diff --git a/src/index.css b/src/index.css index 1df228c..7c1bec8 100644 --- a/src/index.css +++ b/src/index.css @@ -39,6 +39,8 @@ button { font: inherit; } .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); } +/* In place of rename/delete for a read-only file (e.g. a course's library). */ +.ide-file-read-only { display: inline-grid; place-items: center; padding: 6px; color: var(--glp-text-muted); } .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; } diff --git a/src/projectShare.ts b/src/projectShare.ts index 115466e..05a23aa 100644 --- a/src/projectShare.ts +++ b/src/projectShare.ts @@ -2,7 +2,8 @@ import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from import type { Project, ProjectFile } from 'glpython-editor' // URL query parameter used to carry a whole project (name, main file, and -// every file's path/label/kind/code), the same idea as webTigerPython's +// every file's path/label/kind/code, and readOnly for a file to read only, +// e.g. a course's library), the same idea as webTigerPython's // `?code=` parameter but extended to a full multi-file project instead of a // single source string. const SHARE_PARAM = 'project' @@ -26,6 +27,7 @@ function isProjectFile(value: unknown): value is ProjectFile { if (!value || typeof value !== 'object') return false const file = value as Partial return typeof file.path === 'string' && typeof file.label === 'string' && typeof file.code === 'string' && (file.kind === 'python' || file.kind === 'text') + && (file.readOnly === undefined || typeof file.readOnly === 'boolean') } function isSharedProjectPayload(value: unknown): value is SharedProjectPayload { diff --git a/src/projectStorage.ts b/src/projectStorage.ts index 049d291..df3a6eb 100644 --- a/src/projectStorage.ts +++ b/src/projectStorage.ts @@ -7,6 +7,14 @@ type DirectoryPickerWindow = Window & { showDirectoryPicker?: () => Promise } +type ProjectMetadata = { name?: string; mainFile?: string; readOnly?: string[] } + +// The files glpython.json lists as read-only, marked so. +function withReadOnly(files: Record, metadata: ProjectMetadata): Record { + const readOnly = Array.isArray(metadata.readOnly) ? metadata.readOnly : [] + return Object.fromEntries(Object.entries(files).map(([path, file]) => [path, readOnly.includes(path) ? { ...file, readOnly: true } : file])) +} + export function loadLocalProject(fallback: Project): Project { const savedProject = localStorage.getItem(storageKey) if (!savedProject) return fallback @@ -29,7 +37,10 @@ export function clearLocalProject(): void { export async function exportProjectZip(project: Project): Promise { const zip = new JSZip() for (const file of Object.values(project.files)) zip.file(file.path, file.code) - zip.file('glpython.json', JSON.stringify({ name: project.name, mainFile: project.mainFile }, null, 2)) + // Which files are read-only (e.g. a course's library) travels in the + // metadata, so a ZIP round trip keeps it. + const readOnly = Object.values(project.files).filter((file) => file.readOnly).map((file) => file.path) + zip.file('glpython.json', JSON.stringify({ name: project.name, mainFile: project.mainFile, ...(readOnly.length > 0 ? { readOnly } : {}) }, null, 2)) return zip.generateAsync({ type: 'blob' }) } @@ -37,13 +48,13 @@ export async function importProjectZip(file: File, fallback: Project): Promise

!entry.dir && entry.name !== 'glpython.json' && !entry.name.startsWith('__MACOSX/')) const metadataEntry = zip.file('glpython.json') - const metadata = metadataEntry ? JSON.parse(await metadataEntry.async('text')) as { name?: string; mainFile?: string } : {} + const metadata = metadataEntry ? JSON.parse(await metadataEntry.async('text')) as ProjectMetadata : {} const files = await Promise.all(entries.map(async (entry) => { const path = entry.name.replace(/^\/+/, '') const code = await entry.async('text') return [path, createProjectFile(path, code)] as const })) - const importedFiles = Object.fromEntries(files) + const importedFiles = withReadOnly(Object.fromEntries(files), metadata) const pythonFiles = Object.values(importedFiles).filter((entry) => entry.kind === 'python') const mainFile = metadata.mainFile && importedFiles[metadata.mainFile] ? metadata.mainFile @@ -59,9 +70,9 @@ export async function importProjectFolder(fallback: Project): Promise [file.path, file])) - const pythonFiles = files.filter((file) => file.kind === 'python') const metadata = await readProjectMetadata(directory) + const importedFiles = withReadOnly(Object.fromEntries(files.map((file) => [file.path, file])), metadata) + const pythonFiles = files.filter((file) => file.kind === 'python') const mainFile = metadata.mainFile && importedFiles[metadata.mainFile] ? metadata.mainFile : importedFiles['main.py'] @@ -70,11 +81,11 @@ export async function importProjectFolder(fallback: Project): Promise { +async function readProjectMetadata(directory: FileSystemDirectoryHandle): Promise { try { const metadataHandle = await directory.getFileHandle('glpython.json') const metadataFile = await metadataHandle.getFile() - return JSON.parse(await metadataFile.text()) as { name?: string; mainFile?: string } + return JSON.parse(await metadataFile.text()) as ProjectMetadata } catch { return {} } From 4f218fe2a24dc08df4dfb6da0ed2fbb9d57823ed 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 23:11:03 +0100 Subject: [PATCH 2/2] Bump glpython-editor to v0.1.19 Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4bc6764..1bc8cc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.18", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.19", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", @@ -804,7 +804,7 @@ }, "node_modules/glpython-editor": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#07f11de780ceec5f180bb82ddd9061f86ffad13a", + "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#5bd8a4fe8c333a907da6ae4675bc65ff318c61a4", "license": "MIT", "peerDependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/package.json b/package.json index b68930c..e81b576 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.18", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.19", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0",