Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -241,8 +241,11 @@ function App() {
<ul className="ide-file-list">
{filePaths.map((path) => { const file = project.files[path]; return <li key={path} className={path === activeFile ? 'active' : undefined}>
<button type="button" className="ide-file-open" aria-current={path === activeFile ? 'true' : undefined} onClick={() => setActiveFile(path)}>{file.kind === 'python' ? <FileCode2 size={15} /> : <BookOpen size={15} />}<span>{path}</span></button>
{/* A read-only file (e.g. a course's library) can't be renamed or deleted either. */}
{file.readOnly ? <span className="ide-file-read-only" role="img" aria-label={t.readOnlyFile} title={t.readOnlyFile}><Lock size={14} /></span> : <>
<button type="button" className="icon-button small" aria-label={t.renameFile(path)} title={t.renameFile(path)} onClick={() => renameFile(path)}><Pencil size={14} /></button>
<button type="button" className="icon-button small ide-file-delete" aria-label={t.deleteFile(path)} title={t.deleteFile(path)} disabled={filePaths.length === 1} onClick={() => deleteFile(path)}><Trash2 size={14} /></button>
</>}
</li> })}
</ul>
<button type="button" className="secondary-button ide-new-file" onClick={addFile}><Plus size={15} /> {t.addFile}</button>
Expand Down
2 changes: 2 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
4 changes: 3 additions & 1 deletion src/projectShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -26,6 +27,7 @@ function isProjectFile(value: unknown): value is ProjectFile {
if (!value || typeof value !== 'object') return false
const file = value as Partial<ProjectFile>
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 {
Expand Down
25 changes: 18 additions & 7 deletions src/projectStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ type DirectoryPickerWindow = Window & {
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>
}

type ProjectMetadata = { name?: string; mainFile?: string; readOnly?: string[] }

// The files glpython.json lists as read-only, marked so.
function withReadOnly(files: Record<string, ProjectFile>, metadata: ProjectMetadata): Record<string, ProjectFile> {
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
Expand All @@ -29,21 +37,24 @@ export function clearLocalProject(): void {
export async function exportProjectZip(project: Project): Promise<Blob> {
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' })
}

export async function importProjectZip(file: File, fallback: Project): Promise<Project> {
const zip = await JSZip.loadAsync(file)
const entries = Object.values(zip.files).filter((entry) => !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
Expand All @@ -59,9 +70,9 @@ export async function importProjectFolder(fallback: Project): Promise<Project |
const directory = await pickerWindow.showDirectoryPicker()
const files: ProjectFile[] = []
await readDirectory(directory, '', files)
const importedFiles = Object.fromEntries(files.map((file) => [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']
Expand All @@ -70,11 +81,11 @@ export async function importProjectFolder(fallback: Project): Promise<Project |
return { ...fallback, name: metadata.name || directory.name, mainFile, files: importedFiles }
}

async function readProjectMetadata(directory: FileSystemDirectoryHandle): Promise<{ name?: string; mainFile?: string }> {
async function readProjectMetadata(directory: FileSystemDirectoryHandle): Promise<ProjectMetadata> {
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 {}
}
Expand Down
Loading