diff --git a/website/src/repl/FilesTab.jsx b/website/src/repl/FilesTab.jsx
new file mode 100644
index 00000000..ed8d1215
--- /dev/null
+++ b/website/src/repl/FilesTab.jsx
@@ -0,0 +1,187 @@
+import { Fragment, useEffect } from 'react';
+import { getAudioContext, loadBuffer, processSampleMap } from '@strudel.cycles/webaudio';
+import React, { useMemo, useState } from 'react';
+
+const TAURI = window.__TAURI__;
+const { BaseDirectory, readDir, readBinaryFile, writeTextFile, readTextFile } = TAURI?.fs || {};
+
+async function loadFiles() {
+ return readDir('', { dir: BaseDirectory.Audio, recursive: true });
+}
+
+const walkFileTree = (node, fn) => {
+ if (!Array.isArray(node?.children)) {
+ return;
+ }
+ for (const entry of node.children) {
+ entry.subpath = (node.subpath || []).concat([node.name]);
+ fn(entry, node);
+ if (entry.children) {
+ walkFileTree(entry, fn);
+ }
+ }
+};
+
+const isAudioFile = (filename) => ['wav', 'mp3'].includes(filename.split('.').slice(-1)[0]);
+function uint8ArrayToDataURL(uint8Array) {
+ const blob = new Blob([uint8Array], { type: 'audio/*' });
+ const dataURL = URL.createObjectURL(blob);
+ return dataURL;
+}
+
+const loadCache = {}; // caches local urls to data urls
+async function resolveFileURL(url) {
+ if (loadCache[url]) {
+ return loadCache[url];
+ }
+ loadCache[url] = (async () => {
+ const contents = await readBinaryFile(url, {
+ dir: BaseDirectory.Audio,
+ });
+ return uint8ArrayToDataURL(contents);
+ })();
+ return loadCache[url];
+}
+
+export function FilesTab() {
+ const [path, setPath] = useState([]);
+ useEffect(() => {
+ let init = false;
+ loadFiles().then((_tree) => setPath([{ name: 'files', children: _tree, path: BaseDirectory.Audio }]));
+ return () => {
+ init = true;
+ };
+ }, []);
+ const current = useMemo(() => path[path.length - 1], [path]);
+ const strudelJson = useMemo(() => current?.children?.find((e) => e.name === 'strudel.json'), [current]);
+ const subpath = useMemo(
+ () =>
+ path
+ .slice(1)
+ .map((p) => p.name)
+ .join('/'),
+ [path],
+ );
+ const folders = useMemo(() => current?.children.filter((e) => !!e.children), [current]);
+
+ const files = useMemo(
+ () => current?.children.filter((e) => !e.children && isAudioFile(e.name) /* || e.name === 'strudel.json' */),
+ [current],
+ );
+ const audioFiles = useMemo(() => {
+ let files = [];
+ walkFileTree(current, (entry) => {
+ if (isAudioFile(entry.name)) {
+ files.push(entry);
+ }
+ });
+ return files;
+ }, [current]);
+ const select = (e) => setPath((p) => p.concat([e]));
+
+ return (
+
+
+
+ {path?.map((p, i) => {
+ if (i < path.length - 1) {
+ return (
+
+ setPath((p) => p.slice(0, i + 1))}>
+ {p.name}
+ {' '}
+ /{' '}
+
+ );
+ } else {
+ return (
+
+ {p.name}{' '}
+
+ );
+ }
+ })}
+
+
+
+ {folders?.map((e, i) => (
+
select(e)}>
+ {e.name}
+
+ ))}
+ {files?.map((e, i) => (
+
{
+ const url = await resolveFileURL(`${subpath}/${e.name}`);
+ const ac = getAudioContext();
+ const bufferSource = ac.createBufferSource();
+ bufferSource.buffer = await loadBuffer(url, ac);
+ bufferSource.connect(ac.destination);
+ bufferSource.start(ac.currentTime);
+ }}
+ >
+ {e.name}
+
+ ))}
+
+
+
+
+ {strudelJson && (
+ <>
+
+ {/* */}
+ >
+ )}
+
+
+ );
+}
diff --git a/website/src/repl/Footer.jsx b/website/src/repl/Footer.jsx
index 417fb8ed..ed0efaef 100644
--- a/website/src/repl/Footer.jsx
+++ b/website/src/repl/Footer.jsx
@@ -9,6 +9,9 @@ import { themes } from './themes.mjs';
import { useSettings, settingsMap, setActiveFooter, defaultSettings } from '../settings.mjs';
import { getAudioContext, soundMap } from '@strudel.cycles/webaudio';
import { useStore } from '@nanostores/react';
+import { FilesTab } from './FilesTab';
+
+const TAURI = window.__TAURI__;
export function Footer({ context }) {
const footerContent = useRef();
@@ -77,6 +80,7 @@ export function Footer({ context }) {
+ {TAURI && }
{activeFooter !== '' && (