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 @@
+
+
+
+
+
-
-
- {#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 @@
-
+
+
+ {#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}
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}