From e02a94893b5338bb6288a640a22a6b818a60721a Mon Sep 17 00:00:00 2001 From: Jacob Pake Date: Sun, 23 Aug 2026 15:34:40 +0100 Subject: [PATCH] playground: add file system abstraction and separate concerns, support multiple files / tabs --- src/lib/assets/examples/other.py | 1 + src/lib/playground/App.svelte | 90 +++++++ src/lib/playground/StatusBar.svelte | 35 --- .../playground/{ => components}/Editor.svelte | 41 +-- .../playground/components/StatusBar.svelte | 31 +++ src/lib/playground/components/Tabs.svelte | 117 +++++++++ .../{ => components}/Terminal.svelte | 0 .../{ => components}/TerminalDragger.svelte | 0 .../{ => components}/Toolbar.svelte | 6 +- .../{ => statusbar}/StatusItem.svelte | 15 +- .../components/{ => toolbar}/Kbd.svelte | 0 .../{ => toolbar}/ToolbarButton.svelte | 0 .../{ => toolbar}/ToolbarItem.svelte | 0 .../{ => toolbar}/ToolbarLink.svelte | 0 src/lib/playground/lib/app.svelte.ts | 186 ++++++++++++++ src/lib/playground/lib/file.svelte.ts | 202 +++++++++++++++ src/lib/playground/lib/purepy.ts | 43 ++-- src/lib/playground/lib/share.ts | 23 +- src/lib/playground/lib/share/codec.ts | 1 + src/lib/playground/lib/state.ts | 12 - src/lib/utils/non-empty.ts | 6 + src/lib/utils/once.ts | 12 + src/routes/playground/+page.svelte | 235 +++--------------- 23 files changed, 739 insertions(+), 317 deletions(-) create mode 100644 src/lib/assets/examples/other.py create mode 100644 src/lib/playground/App.svelte delete mode 100644 src/lib/playground/StatusBar.svelte rename src/lib/playground/{ => components}/Editor.svelte (64%) create mode 100644 src/lib/playground/components/StatusBar.svelte create mode 100644 src/lib/playground/components/Tabs.svelte rename src/lib/playground/{ => components}/Terminal.svelte (100%) rename src/lib/playground/{ => components}/TerminalDragger.svelte (100%) rename src/lib/playground/{ => components}/Toolbar.svelte (78%) rename src/lib/playground/components/{ => statusbar}/StatusItem.svelte (76%) rename src/lib/playground/components/{ => toolbar}/Kbd.svelte (100%) rename src/lib/playground/components/{ => toolbar}/ToolbarButton.svelte (100%) rename src/lib/playground/components/{ => toolbar}/ToolbarItem.svelte (100%) rename src/lib/playground/components/{ => toolbar}/ToolbarLink.svelte (100%) create mode 100644 src/lib/playground/lib/app.svelte.ts create mode 100644 src/lib/playground/lib/file.svelte.ts delete mode 100644 src/lib/playground/lib/state.ts create mode 100644 src/lib/utils/non-empty.ts create mode 100644 src/lib/utils/once.ts diff --git a/src/lib/assets/examples/other.py b/src/lib/assets/examples/other.py new file mode 100644 index 0000000..548fbdf --- /dev/null +++ b/src/lib/assets/examples/other.py @@ -0,0 +1 @@ +print("hello world!!") diff --git a/src/lib/playground/App.svelte b/src/lib/playground/App.svelte new file mode 100644 index 0000000..9122a13 --- /dev/null +++ b/src/lib/playground/App.svelte @@ -0,0 +1,90 @@ + + + + +
+
+ + +
+ +
+ +
+ +
+ (terminalHeight = height)} /> + + +
+
diff --git a/src/lib/playground/StatusBar.svelte b/src/lib/playground/StatusBar.svelte deleted file mode 100644 index 624f01c..0000000 --- a/src/lib/playground/StatusBar.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -
-
-

- {#if status.has_unsaved_changes} - Unsaved changes - {:else} - Up to date - {/if} -

-
- -
- - -
-
diff --git a/src/lib/playground/Editor.svelte b/src/lib/playground/components/Editor.svelte similarity index 64% rename from src/lib/playground/Editor.svelte rename to src/lib/playground/components/Editor.svelte index f74364c..db542b3 100644 --- a/src/lib/playground/Editor.svelte +++ b/src/lib/playground/components/Editor.svelte @@ -1,13 +1,9 @@ -
+
diff --git a/src/lib/playground/components/StatusBar.svelte b/src/lib/playground/components/StatusBar.svelte new file mode 100644 index 0000000..0588cda --- /dev/null +++ b/src/lib/playground/components/StatusBar.svelte @@ -0,0 +1,31 @@ + + +
+
+

+ {#if dirty} + Unsaved changes + {:else} + Up to date + {/if} +

+
+ +
+ + +
+
diff --git a/src/lib/playground/components/Tabs.svelte b/src/lib/playground/components/Tabs.svelte new file mode 100644 index 0000000..16d16bf --- /dev/null +++ b/src/lib/playground/components/Tabs.svelte @@ -0,0 +1,117 @@ + + +
+ {#each app.fs.files as file, index (file.path)} + {#if app.fs.active_fd === index} +
+
+
+ {#if file.dirty} +
+ {/if} +
+ + {file.path} +
+ +
+ {#if app.fs.files.length > 1} + + {/if} +
+
+ {:else} +
{}} + role="button" + onclick={() => app.fs.open(index)} + class="px-1.5 gap-2 flex justify-center items-center text-sm cursor-pointer rounded-t-md bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-300 border border-t-transparent border-x-transparent border-b-zinc-500" + > +
+
+ {#if file.dirty} +
+ {/if} +
+ + {file.path} +
+ +
+
+ {/if} + {/each} + +
+ {#if new_file_prompt} +
+
{}}> + + +
+
+ {:else} + + {/if} +
+
diff --git a/src/lib/playground/Terminal.svelte b/src/lib/playground/components/Terminal.svelte similarity index 100% rename from src/lib/playground/Terminal.svelte rename to src/lib/playground/components/Terminal.svelte diff --git a/src/lib/playground/TerminalDragger.svelte b/src/lib/playground/components/TerminalDragger.svelte similarity index 100% rename from src/lib/playground/TerminalDragger.svelte rename to src/lib/playground/components/TerminalDragger.svelte diff --git a/src/lib/playground/Toolbar.svelte b/src/lib/playground/components/Toolbar.svelte similarity index 78% rename from src/lib/playground/Toolbar.svelte rename to src/lib/playground/components/Toolbar.svelte index fba65dc..bd16a67 100644 --- a/src/lib/playground/Toolbar.svelte +++ b/src/lib/playground/components/Toolbar.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/playground/components/StatusItem.svelte b/src/lib/playground/components/statusbar/StatusItem.svelte similarity index 76% rename from src/lib/playground/components/StatusItem.svelte rename to src/lib/playground/components/statusbar/StatusItem.svelte index 448d677..aa28ed5 100644 --- a/src/lib/playground/components/StatusItem.svelte +++ b/src/lib/playground/components/statusbar/StatusItem.svelte @@ -1,16 +1,17 @@
- {#if success === true} + {#if result?.success === true} - {:else if success === false} + {:else if result?.success === false} - {#if success !== null} + {#if result !== null}

{type} - {time}ms + {result.time}ms

{/if}
diff --git a/src/lib/playground/components/Kbd.svelte b/src/lib/playground/components/toolbar/Kbd.svelte similarity index 100% rename from src/lib/playground/components/Kbd.svelte rename to src/lib/playground/components/toolbar/Kbd.svelte diff --git a/src/lib/playground/components/ToolbarButton.svelte b/src/lib/playground/components/toolbar/ToolbarButton.svelte similarity index 100% rename from src/lib/playground/components/ToolbarButton.svelte rename to src/lib/playground/components/toolbar/ToolbarButton.svelte diff --git a/src/lib/playground/components/ToolbarItem.svelte b/src/lib/playground/components/toolbar/ToolbarItem.svelte similarity index 100% rename from src/lib/playground/components/ToolbarItem.svelte rename to src/lib/playground/components/toolbar/ToolbarItem.svelte diff --git a/src/lib/playground/components/ToolbarLink.svelte b/src/lib/playground/components/toolbar/ToolbarLink.svelte similarity index 100% rename from src/lib/playground/components/ToolbarLink.svelte rename to src/lib/playground/components/toolbar/ToolbarLink.svelte diff --git a/src/lib/playground/lib/app.svelte.ts b/src/lib/playground/lib/app.svelte.ts new file mode 100644 index 0000000..c403c41 --- /dev/null +++ b/src/lib/playground/lib/app.svelte.ts @@ -0,0 +1,186 @@ +import main_py from "$lib/assets/examples/example.py?raw"; +import other_py from "$lib/assets/examples/other.py?raw"; +import { type NonEmpty } from "$lib/utils/non-empty"; +import { once } from "$lib/utils/once"; +import { type StaticFile, FileSystem } from "./file.svelte"; +import type { PurePy } from "./purepy"; +import type { SharableState } from "./share/codec"; +import { Stdout } from "./stdout.svelte"; + +type Editor = { + set_doc: (str: string) => void; +}; + +export type Result = { + success: boolean; + time: number; +}; + +export class State { + #fs: FileSystem; + #purepy: PurePy | null = null; + #stdout: Stdout; + + #busy: boolean; + + check_result: Result | null; + eval_result: Result | null; + + constructor(files: NonEmpty, active_index = 0) { + this.#fs = new FileSystem(files, active_index); + + this.#stdout = new Stdout(); + + this.#busy = $state(true); + + this.check_result = $state(null); + this.eval_result = $state(null); + } + + static default = () => { + return new State([ + { path: "main.py", data: main_py }, + { path: "other.py", data: other_py }, + ]); + }; + + static from = (state: SharableState) => { + return new State( + state.files, + Math.min(state.active ?? 0, state.files.length), + ); + }; + + get busy() { + return this.#busy; + } + + get stdout() { + return this.#stdout; + } + + // its not clear to me if these lose reactivity by returning like this + // that was certainly the case for fs.buffer + get fs() { + return { + files: this.#fs.files, + dirty: this.#fs.dirty, + new: this.#fs.new, + open: this.#fs.open, + active_fd: this.#fs.active_fd, + remove: this.#fs.remove, + save_all: this.#fs.save_all, + }; + } + + get buffer() { + return this.#fs.buffer; + } + + set buffer(data: string) { + this.#fs.buffer = data; + } + + register_editor = once((editor: Editor) => { + editor.set_doc(this.#fs.buffer); + this.#fs.add_hook("open", (file) => editor.set_doc(file.buffer)); + }); + + register_purepy = once((purepy: PurePy) => { + this.#purepy = purepy; + this.#fs.for_each(purepy.write_file); + this.#fs.add_hook("write", purepy.write_file); + this.#fs.add_hook("delete", purepy.delete_file); + this.#busy = false; + }); + + #invalidate = () => { + this.check_result = null; + this.eval_result = null; + }; + + #check = (purepy: PurePy) => { + const start = Date.now(); + const { success, error } = purepy.parse_and_check( + this.#fs.file(this.#fs.active_fd), + ); + const time = Date.now() - start; + if (!success) { + this.#stdout?.write_err(error.msg); + } + this.check_result = { + success, + time, + }; + return success; + }; + + // just a helper for common parts of `save_and_check` and + // `save_and_run` + #statefully = (fn: (purepy: PurePy) => void) => { + if (this.busy || this.#purepy === null) { + // never? + return; + } + this.#busy = true; + this.#fs.save(this.#fs.active_fd); + this.#invalidate(); + try { + fn(this.#purepy); + } catch (error) { + console.error(error); + } finally { + this.#busy = false; + } + }; + + save_and_check = () => + this.#statefully((purepy) => { + const success = this.#check(purepy); + if (success) { + this.#stdout?.write("Check ok!"); + } else { + this.#stdout?.write_err("Check failed!"); + } + }); + + save_and_run = () => + this.#statefully((purepy) => { + const check_success = this.#check(purepy); + if (!check_success) { + this.#stdout?.write_err("Check failed, running anyway..."); + } + + this.#stdout?.write("--- stdout ---"); + + const start = Date.now(); + const { success, output } = purepy.evaluate( + this.#fs.file(this.#fs.active_fd), + ); + const time = Date.now() - start; + this.eval_result = { + success, + time, + }; + + this.#stdout?.write("--- result ---"); + + // if the program ends in a value expression, we get that + // value here (otherwise undefined) + const result = output === undefined ? "" : output; + // TODO: figure out the possible, sensible output types and handle them properly, + // in the meantime at least avoid [object Object] + this.#stdout?.write( + typeof result === "object" ? JSON.stringify(result) : `${result}`, + ); + + this.#stdout?.write("---"); + }); + + serialise = () => { + return { + files: this.#fs.serialise(), + active: this.#fs.active_fd, + } as const; + }; +} diff --git a/src/lib/playground/lib/file.svelte.ts b/src/lib/playground/lib/file.svelte.ts new file mode 100644 index 0000000..309d0f2 --- /dev/null +++ b/src/lib/playground/lib/file.svelte.ts @@ -0,0 +1,202 @@ +import { nonempty_map, type NonEmpty } from "$lib/utils/non-empty"; + +export type StaticFile = { + readonly path: string; + readonly data: string; +}; + +export type ReadonlyFile = { + readonly path: string; + readonly data: string; + readonly buffer: string; + readonly dirty: boolean; +}; + +export class File { + #data: string; + #buffer: string; + + #dirty: boolean; + + readonly info: ReadonlyFile; + + constructor(path: string, data: string) { + this.#data = $state(data); + this.#buffer = $state(data); + this.#dirty = $state(false); + + this.info = $derived({ + path, + data: this.#data, + buffer: this.#buffer, + dirty: this.#dirty, + }); + } + + get buffer() { + return this.#buffer; + } + + set buffer(data: string) { + this.#buffer = data; + this.#dirty = this.#buffer !== this.#data; + } + + save = () => { + this.#data = this.#buffer; + this.#dirty = false; + }; + + serialise = () => { + return { + path: this.info.path, + data: this.info.data, + } as const; + }; +} + +export type FileHook = (file: ReadonlyFile) => void; +export type FileEvent = "open" | "write" | "delete"; + +export class FileSystem { + #files: NonEmpty; + #hooks: Record; + + #active_fd: number; + #active_file: File; + + constructor(files: NonEmpty, active_index: number) { + this.#files = $state(nonempty_map(files, (f) => new File(f.path, f.data))); + this.#hooks = { open: [], write: [], delete: [] }; + this.#active_fd = $state(active_index); + this.#active_file = $derived(this.#file(this.#active_fd)); + } + + #file = (fd: number) => { + const file = this.#files[fd]; + if (file === undefined) { + throw new Error("Index out of bounds"); + } + return file; + }; + + file = (fd: number) => { + const file = this.#file(fd); + return file.info; + }; + + get active_fd() { + return this.#active_fd; + } + + get files() { + return nonempty_map(this.#files, (f) => f.info); + } + + get dirty() { + return this.files.some((file) => file.dirty); + } + + get buffer() { + return this.#active_file.buffer; + } + + set buffer(data: string) { + this.#active_file.buffer = data; + } + + add_hook = (event: FileEvent, hook: FileHook) => { + this.#hooks[event].push(hook); + }; + + run_hooks = (event: FileEvent, file: ReadonlyFile) => { + this.#hooks[event].forEach((hook) => hook(file)); + }; + + for_each = (fn: (file: ReadonlyFile, index: number) => void) => { + this.files.forEach((file, index) => fn(file, index)); + }; + + count = () => { + return this.#files.length; + }; + + last_index = () => { + return this.count() - 1; + }; + + // Path methods + + exists = (path: string) => { + return this.#files.some((file) => file.info.path === path); + }; + + new = (path: string): ReadonlyFile => { + if (this.exists(path)) { + throw new Error("File already exists at this path"); + } + const file = new File(path, ""); + this.#files.push(file); + this.run_hooks("write", file.info); + return this.open(this.last_index()); + }; + + // Index methods + + open = (fd: number): ReadonlyFile => { + const file = this.#file(fd); + this.#active_fd = fd; + this.run_hooks("open", file.info); + return file.info; + }; + + save = (fd: number): ReadonlyFile => { + const file = this.#file(fd); + if (file.info.dirty) { + file.save(); + this.run_hooks("write", file.info); + } + return file.info; + }; + + remove = (fd: number): ReadonlyFile => { + if (this.files.length === 1) { + throw new Error("Attempted to delete only file."); + } + + const is_active = fd === this.#active_fd; + const is_last = fd === this.last_index(); + + const [file] = this.#files.splice(fd, 1); + + if (file === undefined) { + throw new Error("Index out of bounds"); + } + + this.run_hooks("delete", file.info); + + // if this is the active file, and happens to be the last, + // we need to set the active_file to the new last + if (is_active) { + this.open(is_last ? fd - 1 : fd); + } + + return file.info; + }; + + // for all files + + save_all = () => { + this.for_each((_, index) => this.save(index)); + }; + + // Operate on the open file + + save_active = () => { + this.save(this.#active_fd); + }; + + serialise = () => { + return nonempty_map(this.#files, (f) => f.serialise()); + }; +} diff --git a/src/lib/playground/lib/purepy.ts b/src/lib/playground/lib/purepy.ts index 7cd2514..d819d0a 100644 --- a/src/lib/playground/lib/purepy.ts +++ b/src/lib/playground/lib/purepy.ts @@ -7,6 +7,7 @@ import reasons_py from "$lib/assets/reasons.py?raw"; // loading experience on slower connections import { type PyodideAPI } from "pyodide"; import type { Stdout } from "./stdout.svelte"; +import type { ReadonlyFile } from "./file.svelte"; const PurePyError = z.object({ msg: z.string(), @@ -32,6 +33,7 @@ export class PurePy { constructor(pyodide: PyodideAPI) { this.pyodide = pyodide; + pyodide.setDebug(true); pyodide.FS.writeFile("parse.py", parse_py); pyodide.FS.writeFile("check_module.py", check_module_py); pyodide.FS.writeFile("reasons.py", reasons_py); @@ -49,9 +51,12 @@ export class PurePy { this.pyodide.setStderr({ batched: stdout.write_err }); }; - write_file = (path: string, src: string) => { - this.pyodide.FS.writeFile(path, src); - return path; + write_file = (file: ReadonlyFile) => { + this.pyodide.FS.writeFile(file.path, file.data); + }; + + delete_file = (file: ReadonlyFile) => { + this.pyodide.FS.unlink(file.path); }; run = (src: string) => { @@ -67,13 +72,14 @@ export class PurePy { } }; - parse = (path: string) => + parse = (file: ReadonlyFile) => capture_err(() => - this.run(` + this.run( + ` import parse def fn(): try: - parse.check_file("${path}") + parse.check_file("${file.path}") except SyntaxError as err: return { "msg": "Syntax error", @@ -81,27 +87,28 @@ export class PurePy { "col": err.offset } fn() - `), + `, + ), ); - check = (path: string) => + check = (file: ReadonlyFile) => capture_err(() => - this.run(` + this.run( + ` import ast import check_module - check_module.check_file("${path}") - `), + check_module.check_file("${file.path}") + `, + ), ); - parse_and_check = (src: string) => { - const path = this.write_file("whatever.purepy", src); - - const parse_result = this.parse(path); + parse_and_check = (file: ReadonlyFile) => { + const parse_result = this.parse(file); if (!parse_result.success) { return parse_result; } - const check_result = this.check(path); + const check_result = this.check(file); if (!check_result.success) { return check_result; } @@ -109,8 +116,8 @@ export class PurePy { return { success: true, error: null } as const; }; - evaluate = (src: string) => { - const result = this.run(src); + evaluate = (file: ReadonlyFile) => { + const result = this.run(file.data); return { success: true, output: result } as const; }; } diff --git a/src/lib/playground/lib/share.ts b/src/lib/playground/lib/share.ts index c7d4f46..690624f 100644 --- a/src/lib/playground/lib/share.ts +++ b/src/lib/playground/lib/share.ts @@ -1,25 +1,17 @@ // these are mostly convenience functions and subject to change import { - decode_state, encode_state, get_encoded_state, set_encoded_state, SharableState, - type EncodedState, } from "./share/codec"; -export const url_with_params_from_src = async (url: URL, src: string) => { - // since we don't actually support multiple files yet - const state: SharableState = { - files: [ - { - path: "main.py", - data: src, - }, - ], - }; - +export const url_with_params_from_state = async ( + url: URL, + state: SharableState, +) => { + url = new URL(url); const encoded = await encode_state(state); const new_url = set_encoded_state(url, encoded); return new_url; @@ -35,8 +27,3 @@ export const params_from_url = (url: URL) => { return null; } }; - -export const src_from_params = async (params: EncodedState) => { - const state = await decode_state(params.version, params.payload); - return state.files[0].data; -}; diff --git a/src/lib/playground/lib/share/codec.ts b/src/lib/playground/lib/share/codec.ts index 4431893..4b938e4 100644 --- a/src/lib/playground/lib/share/codec.ts +++ b/src/lib/playground/lib/share/codec.ts @@ -51,6 +51,7 @@ const SharableFile = z.object({ export type SharableState = z.infer; export const SharableState = z.object({ files: z.tuple([SharableFile], SharableFile), + active: z.optional(z.int().check(z.nonnegative())), }); export const set_encoded_state = ( diff --git a/src/lib/playground/lib/state.ts b/src/lib/playground/lib/state.ts deleted file mode 100644 index 55bf43d..0000000 --- a/src/lib/playground/lib/state.ts +++ /dev/null @@ -1,12 +0,0 @@ -// TODO: something more sensible -export type Status = { - has_unsaved_changes: boolean; - check_success: boolean | null; - eval_success: boolean | null; - is_running_check: boolean; - is_running_evaluate: boolean; - is_pyodide_ready: boolean; - is_codemirror_ready: boolean; - check_time: number; - eval_time: number; -}; diff --git a/src/lib/utils/non-empty.ts b/src/lib/utils/non-empty.ts new file mode 100644 index 0000000..c9aac8f --- /dev/null +++ b/src/lib/utils/non-empty.ts @@ -0,0 +1,6 @@ +// N.B. our definition of nonempty is that the first +// element is not undefined, which is distinct from arr.length === 0 +export type NonEmpty = [A, ...A[]]; + +export const nonempty_map = (arr: NonEmpty, fn: (e: A) => B) => + arr.map((e) => fn(e)) as NonEmpty; diff --git a/src/lib/utils/once.ts b/src/lib/utils/once.ts new file mode 100644 index 0000000..094b8e0 --- /dev/null +++ b/src/lib/utils/once.ts @@ -0,0 +1,12 @@ +const once_map = new Set(); + +export const once = (fn: (...args: A) => R) => { + const symbol = Symbol(); + return (...args: A) => { + if (once_map.has(symbol)) { + return; + } + once_map.add(symbol); + fn(...args); + }; +}; diff --git a/src/routes/playground/+page.svelte b/src/routes/playground/+page.svelte index e723e56..8bbd13f 100644 --- a/src/routes/playground/+page.svelte +++ b/src/routes/playground/+page.svelte @@ -1,219 +1,46 @@ - - -
-
- -
- -
- -
+
-
- (terminalHeight = height)} /> - - -
+
+ {#await app_promise then app} + + {/await}