Expose micropip installs from the menu, right-click actions on the file tree, local folder/file open shortcuts, and browse demos/ alongside code/. Co-authored-by: Cursor <cursoragent@cursor.com>
4503 lines
148 KiB
JavaScript
4503 lines
148 KiB
JavaScript
import { EditorView, basicSetup } from "https://esm.sh/codemirror";
|
|
import { Compartment } from "https://esm.sh/@codemirror/state";
|
|
import { python } from "https://esm.sh/@codemirror/lang-python";
|
|
import { LocalWorkspaceClient, isLocalModeEnabled, exitLocalMode, supportsFolderPicker } from "/static/local-workspace.js?v=7";
|
|
import { createZip, readZip } from "/static/zip-utils.js?v=2";
|
|
|
|
/** Legacy browser-local paths (older sessions); new mounts use *-local at workspace root. */
|
|
const BROWSER_LOCAL_LEGACY_ROOT = '__local__';
|
|
const BROWSER_LOCAL_LEGACY_PREFIX = '__local__/';
|
|
const BROWSER_LOCAL_SEG_CHANGE = '__change_folder__';
|
|
const BROWSER_LOCAL_LEGACY_VIRTUAL = /(__change_folder__|__choose_folder__|__open_file__|__menu_hint__|__info_unsupported__)$/;
|
|
const BROWSER_LOCAL_FOLDER_IDB_NAME = 'python-editor.browser-local-folder';
|
|
const BROWSER_LOCAL_FOLDER_IDB_VER = 1;
|
|
const BROWSER_LOCAL_FOLDER_IDB_STORE = 'folder';
|
|
|
|
class TextEditor {
|
|
constructor() {
|
|
this.sessionStorageKey = 'python-editor.editor.session.v1';
|
|
/** PEP 508 specs installed via micropip; persisted in localStorage (see getPersistedMicropipPackages). */
|
|
this.micropipPackagesStorageKey = 'python-editor.micropip.packages.v1';
|
|
/** When true, worker stdout/stderr is appended to #packages-install-log during micropip installs. */
|
|
this.micropipInstallCaptureIo = false;
|
|
/** Briefly true after a worker run so late batched stdout still reaches the console. */
|
|
this.workerConsoleIoGrace = false;
|
|
this._workerConsoleIoGraceTimer = null;
|
|
this.pyWorker = null;
|
|
this.pyWorkerMsgId = 0;
|
|
this.pyWorkerHandlers = new Map();
|
|
this.pyodideInited = false;
|
|
this.workerWarmupPromise = null;
|
|
this.pyRunGeneration = 0;
|
|
this.editor = null;
|
|
this.currentFilePath = null;
|
|
this.isModified = false;
|
|
this.selectedPath = null;
|
|
this.ignoreNextChange = false;
|
|
this.expandedDirs = new Set([""]);
|
|
this.directoryCache = new Map();
|
|
this.openTabs = [];
|
|
this.activeTabPath = null;
|
|
this.currentFileReadOnly = false;
|
|
this.selectedIsDirectory = false;
|
|
this.isPythonRunning = false;
|
|
this.consolePendingText = '';
|
|
this.consoleFlushTimer = null;
|
|
this.completionItems = [];
|
|
this.completionIndex = 0;
|
|
this.completionOpen = false;
|
|
this.completionRequestId = 0;
|
|
this.diagnosticsRequestId = 0;
|
|
this.diagnosticsTimer = null;
|
|
this.workspaceSourcesCache = null;
|
|
this.workspaceSourcesCacheAt = 0;
|
|
this.draggedItemPath = null;
|
|
this.draggedItemIsDirectory = false;
|
|
this.dragHoverExpandTimer = null;
|
|
this.dragHoverTargetPath = null;
|
|
this.savedSession = null;
|
|
this.languageCompartment = new Compartment();
|
|
this.autoCompleteTimer = null;
|
|
this.ledSimWindow = null;
|
|
this.ledPanelDismissed = false;
|
|
this.lastLedFrame = null;
|
|
this.ledPanelWindow = null;
|
|
this.workspaceUserId = null;
|
|
this.isSuperuser = false;
|
|
this.adcPinCount = 64;
|
|
this.adcSab = null;
|
|
this.adcView = null;
|
|
this.adcSliders = new Map();
|
|
this.adcDefaultValue = 32768;
|
|
this.serialCapacity = 4096;
|
|
this.serialSab = null;
|
|
this.serialIndices = null;
|
|
this.serialData = null;
|
|
this.serialPanelOpen = false;
|
|
this.serialDecoder = null;
|
|
this.pinCount = 64;
|
|
this.pinOutSab = null;
|
|
this.pinOutView = null;
|
|
this.pinInSab = null;
|
|
this.pinInView = null;
|
|
this.pinRows = new Map();
|
|
this.pinRafId = null;
|
|
/* Mirrors the latest UI state per pin so the toggle works even when
|
|
SharedArrayBuffer is unavailable (mobile over LAN IP, no COOP/COEP
|
|
cross-origin isolation, etc.). The actual delivery to Python then
|
|
goes through `worker.postMessage({type:'pinIn'})`. */
|
|
this._pinInLocal = new Array(64).fill(0);
|
|
/* Pins claimed by another simulator (NeoPixel data line, etc.) — they
|
|
skip the Pins panel entirely so the row doesn't flicker on every
|
|
neopixel.write(). Cleared between runs. */
|
|
this._claimedPins = new Set();
|
|
this.localMode = isLocalModeEnabled();
|
|
this.localWorkspace = this.localMode ? new LocalWorkspaceClient() : null;
|
|
this._browserLocalRootHandle = null;
|
|
/** Top-level directory name for the picked folder (e.g. `Photos-local`). */
|
|
this._browserLocalFolderMount = null;
|
|
this._browserLocalHandles = new Map();
|
|
this._onFileTreeContextMenuDismiss = null;
|
|
|
|
this.init();
|
|
}
|
|
|
|
async getWorkspacePythonSources() {
|
|
const now = Date.now();
|
|
if (this.workspaceSourcesCache && (now - this.workspaceSourcesCacheAt) < 1200) {
|
|
return { ...this.workspaceSourcesCache };
|
|
}
|
|
const response = await this.apiFetch('/api/workspace/py-sources');
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load workspace sources');
|
|
}
|
|
const payload = await response.json().catch(() => ({}));
|
|
const files = payload && typeof payload.files === 'object' ? payload.files : {};
|
|
this.workspaceSourcesCache = files;
|
|
this.workspaceSourcesCacheAt = now;
|
|
return { ...files };
|
|
}
|
|
|
|
init() {
|
|
try {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const fromQuery = params.get('api_key');
|
|
const workspaceUserId = params.get('workspace_user_id');
|
|
if (fromQuery) {
|
|
sessionStorage.setItem('python-editor.api_key', fromQuery);
|
|
}
|
|
if (workspaceUserId && /^\d+$/.test(workspaceUserId)) {
|
|
this.workspaceUserId = workspaceUserId;
|
|
}
|
|
} catch (_error) {
|
|
// Ignore query / storage failures.
|
|
}
|
|
this.loadSessionState();
|
|
this.setupEditor();
|
|
this.setupEventListeners();
|
|
this.setupDevAutoReload();
|
|
this.updateRunButtonState();
|
|
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
|
this.updateWorkspaceBanner();
|
|
this.prewarmPyWorker();
|
|
this.fetchViewerRole().finally(() =>
|
|
this.loadInitialDirectoryState()
|
|
.then(() => this.restoreBrowserLocalFolderFromPersistence())
|
|
.then(() => this.restoreSessionTabs())
|
|
.then(() => this.ensureDefaultMainOpen()),
|
|
);
|
|
}
|
|
|
|
async fetchViewerRole() {
|
|
if (this.localMode) {
|
|
this.isSuperuser = false;
|
|
return;
|
|
}
|
|
try {
|
|
const me = await fetch('/api/auth/me', { credentials: 'include' });
|
|
if (!me.ok) {
|
|
this.isSuperuser = false;
|
|
return;
|
|
}
|
|
const data = await me.json().catch(() => ({}));
|
|
this.isSuperuser = Boolean(data && data.user && data.user.is_superuser);
|
|
} catch (_error) {
|
|
this.isSuperuser = false;
|
|
}
|
|
}
|
|
|
|
async updateWorkspaceBanner() {
|
|
const badge = document.getElementById('workspace-badge');
|
|
const menuActions = document.getElementById('workspace-menu-actions');
|
|
const menuLabel = document.getElementById('workspace-menu-label');
|
|
if (!badge) return;
|
|
|
|
// Helper: a button styled as a menu item.
|
|
const makeMenuButton = (text, title, onClick, opts = {}) => {
|
|
const b = document.createElement('button');
|
|
b.type = 'button';
|
|
b.className = 'menu-item menu-action' + (opts.danger ? ' menu-action-danger' : '');
|
|
b.setAttribute('role', 'menuitem');
|
|
b.textContent = text;
|
|
if (title) b.title = title;
|
|
b.addEventListener('click', (event) => {
|
|
// Close the dropdown after a click — feels more like a normal menu.
|
|
const details = document.getElementById('header-menu');
|
|
if (details) details.open = false;
|
|
onClick(event);
|
|
});
|
|
return b;
|
|
};
|
|
|
|
// Helper: a passive note line inside the menu (e.g. for "no folder picker").
|
|
const makeMenuNote = (text, title) => {
|
|
const n = document.createElement('div');
|
|
n.className = 'menu-note';
|
|
n.textContent = text;
|
|
if (title) n.title = title;
|
|
return n;
|
|
};
|
|
|
|
badge.innerHTML = '';
|
|
if (menuActions) menuActions.innerHTML = '';
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'workspace-badge-label';
|
|
badge.appendChild(label);
|
|
badge.classList.remove('hidden');
|
|
|
|
if (this.localMode) {
|
|
label.textContent = 'Local · IndexedDB';
|
|
if (menuLabel) menuLabel.textContent = 'Workspace · Local';
|
|
|
|
if (menuActions) {
|
|
if (supportsFolderPicker()) {
|
|
menuActions.appendChild(
|
|
makeMenuButton('📁 Save to folder…', 'Save files to a folder on this device', () =>
|
|
this.pickLocalFolder(),
|
|
),
|
|
);
|
|
} else {
|
|
menuActions.appendChild(
|
|
makeMenuNote(
|
|
'(folder picker unavailable)',
|
|
'window.showDirectoryPicker is not exposed in this browser context.\n' +
|
|
'• Firefox / Safari: not implemented (use a Chromium-based browser).\n' +
|
|
'• Brave: enable brave://flags/#file-system-access-api or relax Shields for this site.\n' +
|
|
'• HTTPS-only requirement: must be served from localhost or https://.\n' +
|
|
'Files keep saving to IndexedDB; use Export to download a ZIP.',
|
|
),
|
|
);
|
|
}
|
|
|
|
menuActions.appendChild(
|
|
makeMenuButton('⬇️ Export workspace…', 'Download every workspace file as a .zip', () =>
|
|
this.exportWorkspaceZip(),
|
|
),
|
|
);
|
|
menuActions.appendChild(
|
|
makeMenuButton('⬆️ Import .zip…', 'Upload a .zip — its files land in code/ (overwrites on conflict)', () =>
|
|
this.importWorkspaceZip(),
|
|
),
|
|
);
|
|
menuActions.appendChild(
|
|
makeMenuButton(
|
|
'↻ Reset demos',
|
|
'Re-copy bundled demos into demos/ (overwrites your edits to those files)',
|
|
() => this.resetDemoFiles(),
|
|
),
|
|
);
|
|
menuActions.appendChild(
|
|
makeMenuButton(
|
|
'🚪 Exit local mode',
|
|
'Leave local mode and return to the home page',
|
|
() => {
|
|
if (!confirm('Leave local mode? Your files stay in this browser; you can come back later.')) return;
|
|
exitLocalMode();
|
|
window.location.href = '/';
|
|
},
|
|
{ danger: true },
|
|
),
|
|
);
|
|
}
|
|
|
|
try {
|
|
const info = await this.localWorkspace.describeStorage();
|
|
if (info) {
|
|
if (info.mode === 'filesystem') {
|
|
label.textContent = `Local · ${info.label}`;
|
|
if (menuActions) {
|
|
const swap = makeMenuButton(
|
|
'↺ Switch to IndexedDB',
|
|
'Switch storage back to in-browser IndexedDB',
|
|
() => this.useIndexedDbStorage(),
|
|
);
|
|
// Insert near the top, just under the picker button (or first child).
|
|
menuActions.insertBefore(swap, menuActions.firstChild);
|
|
}
|
|
} else if (info.pendingReconnect) {
|
|
label.textContent = `Local · folder “${info.pendingFolderName}” (reconnect)`;
|
|
if (menuActions) {
|
|
const reconnect = makeMenuButton(
|
|
`🔌 Reconnect “${info.pendingFolderName}”`,
|
|
'Re-grant read/write access to the previously picked folder',
|
|
() => this.reconnectLocalFolder(),
|
|
);
|
|
menuActions.insertBefore(reconnect, menuActions.firstChild);
|
|
}
|
|
} else {
|
|
label.textContent = 'Local · IndexedDB';
|
|
}
|
|
}
|
|
} catch (_err) {
|
|
// Best-effort label refresh.
|
|
}
|
|
return;
|
|
}
|
|
|
|
label.textContent = this.workspaceUserId
|
|
? `Workspace: user ${this.workspaceUserId}`
|
|
: 'Server workspace';
|
|
if (menuLabel) menuLabel.textContent = 'Workspace';
|
|
|
|
if (menuActions) {
|
|
menuActions.appendChild(
|
|
makeMenuButton('⬇️ Export workspace…', 'Download every workspace file as a .zip', () =>
|
|
this.exportWorkspaceZip(),
|
|
),
|
|
);
|
|
menuActions.appendChild(
|
|
makeMenuButton('⬆️ Import .zip…', 'Upload a .zip — its files land in code/ (overwrites on conflict)', () =>
|
|
this.importWorkspaceZip(),
|
|
),
|
|
);
|
|
menuActions.appendChild(
|
|
makeMenuButton(
|
|
'↻ Reset demos',
|
|
'Re-copy bundled demos into demos/ (overwrites your edits to those files)',
|
|
() => this.resetDemoFiles(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
async resetDemoFiles() {
|
|
let manifest;
|
|
try {
|
|
const r = await fetch('/static/bundled-demos/manifest.json', { cache: 'no-store' });
|
|
if (!r.ok) throw new Error(`manifest fetch ${r.status}`);
|
|
manifest = await r.json();
|
|
} catch (err) {
|
|
this.showError(
|
|
`Could not load demo manifest: ${err && err.message ? err.message : err}`
|
|
);
|
|
return;
|
|
}
|
|
const names = Array.isArray(manifest && manifest.files) ? manifest.files.slice() : [];
|
|
if (!names.length) {
|
|
this.showError('No demos in bundle');
|
|
return;
|
|
}
|
|
const demoBasename = (n) => (n.startsWith('demo/') ? n.slice(5) : n);
|
|
const editorDemoPath = (base) => `demos/${base}`;
|
|
if (!confirm(
|
|
`Reset ${names.length} demo file${names.length === 1 ? '' : 's'}?\n\n` +
|
|
names.map((n) => ` • ${editorDemoPath(demoBasename(n))}`).join('\n') +
|
|
'\n\nAny edits you made to these files will be overwritten. ' +
|
|
'main.py, code/, and lib/ are not touched.'
|
|
)) return;
|
|
|
|
let written = 0;
|
|
let failed = 0;
|
|
for (const name of names) {
|
|
try {
|
|
const urlPath = name.split('/').map(encodeURIComponent).join('/');
|
|
const r = await fetch(`/static/bundled-demos/${urlPath}`, {
|
|
cache: 'no-store',
|
|
});
|
|
if (!r.ok) {
|
|
failed += 1;
|
|
continue;
|
|
}
|
|
const content = await r.text();
|
|
const codeName = demoBasename(name);
|
|
const filePath = editorDemoPath(codeName);
|
|
const w = await this.apiFetch(`/api/file/${this.encodeApiFilePath(filePath)}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
if (w && w.ok) {
|
|
written += 1;
|
|
} else {
|
|
failed += 1;
|
|
}
|
|
} catch (_err) {
|
|
failed += 1;
|
|
}
|
|
}
|
|
this.workspaceSourcesCache = null;
|
|
this.directoryCache.clear();
|
|
/* If a stale demo is currently open in a tab, refresh the editor
|
|
contents from disk so the user sees the new version. */
|
|
for (const name of names) {
|
|
const path = editorDemoPath(demoBasename(name));
|
|
if (this.findTab(path)) {
|
|
try {
|
|
const fr = await this.apiFetch(`/api/file/${this.encodeApiFilePath(path)}`);
|
|
if (fr && fr.ok) {
|
|
const fd = await fr.json();
|
|
const tab = this.findTab(path);
|
|
if (tab) {
|
|
tab.content = typeof fd.content === 'string' ? fd.content : '';
|
|
tab.savedContent = tab.content;
|
|
if (this.activeTabPath === path) {
|
|
this.ignoreNextChange = true;
|
|
this.editor.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: this.editor.state.doc.length,
|
|
insert: tab.content,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (_err) {
|
|
// Skip refresh failure; user can re-open manually.
|
|
}
|
|
}
|
|
}
|
|
await this.loadInitialDirectoryState();
|
|
if (failed) {
|
|
this.showError(`Reset ${written} demo${written === 1 ? '' : 's'} (${failed} failed)`);
|
|
} else {
|
|
this.showSuccess(`Reset ${written} demo${written === 1 ? '' : 's'}`);
|
|
}
|
|
}
|
|
|
|
async pickLocalFolder() {
|
|
if (!this.localWorkspace) return;
|
|
try {
|
|
await this.localWorkspace.pickDirectory();
|
|
} catch (err) {
|
|
const msg = err && err.message ? err.message : String(err);
|
|
if (!/aborted|cancel/i.test(msg)) {
|
|
this.showError(`Could not switch to folder: ${msg}`);
|
|
}
|
|
return;
|
|
}
|
|
this.workspaceSourcesCache = null;
|
|
this.directoryCache.clear();
|
|
this.expandedDirs = new Set(['', 'code', 'demos']);
|
|
await this.loadInitialDirectoryState();
|
|
await this.updateWorkspaceBanner();
|
|
this.showSuccess('Now saving to the picked folder');
|
|
}
|
|
|
|
async useIndexedDbStorage() {
|
|
if (!this.localWorkspace) return;
|
|
if (!confirm('Switch back to in-browser storage? Your folder files stay on disk untouched.')) return;
|
|
try {
|
|
await this.localWorkspace.useIndexedDb();
|
|
} catch (err) {
|
|
this.showError(`Could not switch storage: ${err && err.message ? err.message : err}`);
|
|
return;
|
|
}
|
|
this.workspaceSourcesCache = null;
|
|
this.directoryCache.clear();
|
|
this.expandedDirs = new Set(['', 'code', 'demos']);
|
|
await this.loadInitialDirectoryState();
|
|
await this.updateWorkspaceBanner();
|
|
this.showSuccess('Storage: IndexedDB');
|
|
}
|
|
|
|
/**
|
|
* Recursively read every file under `code/` via apiFetch — works for both
|
|
* local-mode (LocalWorkspaceClient dispatches it) and server-mode (FastAPI
|
|
* serves it). `lib/` is excluded since it ships with the app and is
|
|
* read-only anyway.
|
|
*/
|
|
async _walkUserFiles() {
|
|
const out = {};
|
|
const queue = ['code', 'demos'];
|
|
while (queue.length) {
|
|
const path = queue.shift();
|
|
let resp;
|
|
try {
|
|
resp = await this.apiFetch(`/api/files?path=${encodeURIComponent(path)}`);
|
|
} catch (_err) {
|
|
continue;
|
|
}
|
|
if (!resp || !resp.ok) continue;
|
|
let data;
|
|
try {
|
|
data = await resp.json();
|
|
} catch (_err) {
|
|
continue;
|
|
}
|
|
for (const entry of data.files || []) {
|
|
const child = path ? `${path}/${entry.name}` : entry.name;
|
|
if (entry.is_directory) {
|
|
queue.push(child);
|
|
} else {
|
|
try {
|
|
const fr = await this.apiFetch(`/api/file/${this.encodeApiFilePath(child)}`);
|
|
if (fr && fr.ok) {
|
|
const fd = await fr.json();
|
|
out[child] = typeof fd.content === 'string' ? fd.content : '';
|
|
}
|
|
} catch (_err) {
|
|
// Skip unreadable files.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async exportWorkspaceZip() {
|
|
let files;
|
|
try {
|
|
files = await this._walkUserFiles();
|
|
} catch (err) {
|
|
this.showError(`Could not export: ${err && err.message ? err.message : err}`);
|
|
return;
|
|
}
|
|
const entries = Object.entries(files).map(([name, content]) => ({ name, content }));
|
|
if (!entries.length) {
|
|
entries.push({
|
|
name: 'README.txt',
|
|
content: 'Your LED Editor workspace was empty when this archive was created.\n',
|
|
});
|
|
}
|
|
const blob = createZip(entries);
|
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
const filename = `led-editor-workspace-${stamp}.zip`;
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
|
let bytes = 0;
|
|
for (const e of entries) bytes += e.content.length;
|
|
const sizeKb = (bytes / 1024).toFixed(1);
|
|
const fileCount = Object.keys(files).length;
|
|
this.showSuccess(
|
|
`Exported ${fileCount} file${fileCount === 1 ? '' : 's'} (${sizeKb} KB)`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Open a hidden file input, read the chosen `.zip`, and write each entry
|
|
* back into the workspace through apiFetch. Same path-routing as export:
|
|
* local-mode keeps it in-browser, server-mode uploads it.
|
|
*
|
|
* Conflict policy = overwrite. The user gets a confirm prompt that lists
|
|
* the file count first, and a final summary toast.
|
|
*/
|
|
async importWorkspaceZip() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.zip,application/zip';
|
|
input.style.display = 'none';
|
|
document.body.appendChild(input);
|
|
const file = await new Promise((resolve) => {
|
|
input.addEventListener('change', () => resolve(input.files && input.files[0]), { once: true });
|
|
input.click();
|
|
});
|
|
document.body.removeChild(input);
|
|
if (!file) return;
|
|
|
|
let entries;
|
|
try {
|
|
const buf = await file.arrayBuffer();
|
|
entries = await readZip(buf);
|
|
} catch (err) {
|
|
this.showError(`Could not read ZIP: ${err && err.message ? err.message : err}`);
|
|
return;
|
|
}
|
|
|
|
/* Sanitize paths and force every entry under `code/`. Anything that
|
|
points at `lib/`, an absolute path, or escapes via `..` gets remapped
|
|
so the import can never silently overwrite library stubs or wander
|
|
outside the workspace. */
|
|
const safeEntries = [];
|
|
for (const entry of entries) {
|
|
let raw = String(entry.name || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
const parts = raw.split('/').filter((seg) => seg && seg !== '.' && seg !== '..');
|
|
if (!parts.length) continue;
|
|
if (parts[0] === 'lib') continue;
|
|
if (parts[0] !== 'code' && parts[0] !== 'demos') parts.unshift('code');
|
|
const path = parts.join('/');
|
|
safeEntries.push({ path, content: entry.content });
|
|
}
|
|
if (!safeEntries.length) {
|
|
this.showError('ZIP contained no importable files');
|
|
return;
|
|
}
|
|
const fileCount = safeEntries.length;
|
|
if (!confirm(
|
|
`Import ${fileCount} file${fileCount === 1 ? '' : 's'} from "${file.name}"?\n\n` +
|
|
`Existing files with the same path will be overwritten. ` +
|
|
`Files in lib/ are skipped. Paths default to code/ unless they start with demos/.`
|
|
)) return;
|
|
|
|
let written = 0;
|
|
let failed = 0;
|
|
for (const entry of safeEntries) {
|
|
try {
|
|
const r = await this.apiFetch(`/api/file/${this.encodeApiFilePath(entry.path)}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: entry.content }),
|
|
});
|
|
if (r && r.ok) {
|
|
written += 1;
|
|
} else {
|
|
failed += 1;
|
|
}
|
|
} catch (_err) {
|
|
failed += 1;
|
|
}
|
|
}
|
|
this.workspaceSourcesCache = null;
|
|
this.directoryCache.clear();
|
|
this.expandedDirs = new Set(['', 'code', 'demos']);
|
|
await this.loadInitialDirectoryState();
|
|
if (failed) {
|
|
this.showError(`Imported ${written} file${written === 1 ? '' : 's'} (${failed} failed)`);
|
|
} else {
|
|
this.showSuccess(`Imported ${written} file${written === 1 ? '' : 's'}`);
|
|
}
|
|
}
|
|
|
|
async reconnectLocalFolder() {
|
|
if (!this.localWorkspace) return;
|
|
let ok = false;
|
|
try {
|
|
ok = await this.localWorkspace.reconnectSavedFolder();
|
|
} catch (err) {
|
|
this.showError(`Could not reconnect folder: ${err && err.message ? err.message : err}`);
|
|
return;
|
|
}
|
|
if (!ok) {
|
|
this.showError('Permission denied. Pick the folder again to continue.');
|
|
return;
|
|
}
|
|
this.workspaceSourcesCache = null;
|
|
this.directoryCache.clear();
|
|
this.expandedDirs = new Set(['', 'code', 'demos']);
|
|
await this.loadInitialDirectoryState();
|
|
await this.updateWorkspaceBanner();
|
|
this.showSuccess('Folder reconnected');
|
|
}
|
|
|
|
setupDevAutoReload() {
|
|
const isLocalhost =
|
|
window.location.hostname === 'localhost' ||
|
|
window.location.hostname === '127.0.0.1';
|
|
if (!isLocalhost) return;
|
|
|
|
let lastToken = null;
|
|
const poll = async () => {
|
|
try {
|
|
const resp = await fetch(`/static/.reload-token?ts=${Date.now()}`, { cache: 'no-store' });
|
|
if (!resp.ok) {
|
|
return;
|
|
}
|
|
const token = (await resp.text()).trim();
|
|
if (lastToken === null) {
|
|
lastToken = token;
|
|
return;
|
|
}
|
|
if (token && token !== lastToken) {
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
} catch (_err) {
|
|
// Dev hook file may not exist yet.
|
|
}
|
|
};
|
|
|
|
setInterval(poll, 1000);
|
|
poll();
|
|
}
|
|
|
|
/** Build `/api/file/...` or `/api/folder/...` paths: encode each segment so `/` is not `%2F` (many servers reject that). */
|
|
encodeApiFilePath(relPath) {
|
|
const s = String(relPath || '').replace(/^\/+/, '');
|
|
if (!s) return '';
|
|
return s.split('/').map(encodeURIComponent).join('/');
|
|
}
|
|
|
|
async apiFetch(url, init = {}) {
|
|
if (this.localWorkspace && typeof url === 'string') {
|
|
const local = await this.localWorkspace.dispatch(url, init);
|
|
if (local) return local;
|
|
}
|
|
const next = { ...init };
|
|
const headers = new Headers(init.headers || {});
|
|
const key = sessionStorage.getItem('python-editor.api_key');
|
|
if (key) {
|
|
headers.set('Authorization', `Bearer ${key}`);
|
|
}
|
|
next.headers = headers;
|
|
next.credentials = 'include';
|
|
let finalUrl = url;
|
|
if (this.workspaceUserId && typeof url === 'string' && url.startsWith('/api/')) {
|
|
try {
|
|
const parsed = new URL(url, window.location.origin);
|
|
if (!parsed.searchParams.has('workspace_user_id')) {
|
|
parsed.searchParams.set('workspace_user_id', this.workspaceUserId);
|
|
}
|
|
finalUrl = parsed.pathname + parsed.search;
|
|
} catch (_error) {
|
|
// ignore URL parse failure and use original
|
|
}
|
|
}
|
|
return fetch(finalUrl, next);
|
|
}
|
|
|
|
disposePyWorker() {
|
|
if (this._workerConsoleIoGraceTimer) {
|
|
clearTimeout(this._workerConsoleIoGraceTimer);
|
|
this._workerConsoleIoGraceTimer = null;
|
|
}
|
|
this.workerConsoleIoGrace = false;
|
|
for (const [, pending] of this.pyWorkerHandlers) {
|
|
pending.reject(new Error('Worker terminated'));
|
|
}
|
|
this.pyWorkerHandlers.clear();
|
|
if (this.pyWorker) {
|
|
this.pyWorker.terminate();
|
|
this.pyWorker = null;
|
|
}
|
|
this.pyodideInited = false;
|
|
}
|
|
|
|
ensurePyWorker() {
|
|
if (!this.pyWorker) {
|
|
const worker = new Worker('/static/pyodide-worker.js?v=24');
|
|
this.pyWorker = worker;
|
|
worker.onmessage = (event) => this.handlePyWorkerMessage(event);
|
|
}
|
|
return this.pyWorker;
|
|
}
|
|
|
|
handlePyWorkerMessage(event) {
|
|
const data = event.data;
|
|
if (!data || typeof data !== 'object') {
|
|
return;
|
|
}
|
|
if (data.type === 'io') {
|
|
if (this.micropipInstallCaptureIo) {
|
|
const logEl = document.getElementById('packages-install-log');
|
|
const chunk = data.text || '';
|
|
if (logEl && chunk) {
|
|
const next = logEl.textContent + chunk;
|
|
logEl.textContent =
|
|
next.length > 64000 ? `…(truncated)\n${next.slice(next.length - 63000)}` : next;
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
}
|
|
if (
|
|
!this.micropipInstallCaptureIo &&
|
|
(this.isPythonRunning || this.workerConsoleIoGrace)
|
|
) {
|
|
const prefix = data.stream === 'stderr' ? '[stderr] ' : '';
|
|
this.appendConsoleOutput([`${prefix}${data.text || ''}`]);
|
|
}
|
|
return;
|
|
}
|
|
const { id } = data;
|
|
if (id == null || !this.pyWorkerHandlers.has(id)) {
|
|
return;
|
|
}
|
|
const pending = this.pyWorkerHandlers.get(id);
|
|
this.pyWorkerHandlers.delete(id);
|
|
if (data.ok) {
|
|
pending.resolve(data);
|
|
} else {
|
|
pending.reject(new Error(data.error || 'Pyodide worker error'));
|
|
}
|
|
}
|
|
|
|
callPyWorker(type, payload) {
|
|
return new Promise((resolve, reject) => {
|
|
const worker = this.ensurePyWorker();
|
|
const id = ++this.pyWorkerMsgId;
|
|
this.pyWorkerHandlers.set(id, { resolve, reject });
|
|
worker.postMessage({ id, type, payload });
|
|
});
|
|
}
|
|
|
|
async ensurePyodideReady() {
|
|
if (this.pyodideInited) {
|
|
return;
|
|
}
|
|
const initData = await this.callPyWorker('init', {
|
|
adcSab: this.getAdcSab(),
|
|
serialSab: this.getSerialSab(),
|
|
pinOutSab: this.getPinOutSab(),
|
|
pinInSab: this.getPinInSab(),
|
|
persistedMicropipPackages: this.getPersistedMicropipPackages(),
|
|
});
|
|
if (initData && initData.micropipRestoreError) {
|
|
this.showError(
|
|
`Could not restore saved PyPI packages (editor still works): ${initData.micropipRestoreError}`,
|
|
);
|
|
}
|
|
this.pyodideInited = true;
|
|
}
|
|
|
|
getAdcSab() {
|
|
if (this.adcSab) {
|
|
return this.adcSab;
|
|
}
|
|
if (typeof SharedArrayBuffer === 'undefined') {
|
|
return null;
|
|
}
|
|
try {
|
|
this.adcSab = new SharedArrayBuffer(this.adcPinCount * 4);
|
|
this.adcView = new Int32Array(this.adcSab);
|
|
} catch (_err) {
|
|
this.adcSab = null;
|
|
this.adcView = null;
|
|
}
|
|
return this.adcSab;
|
|
}
|
|
|
|
getSerialSab() {
|
|
if (this.serialSab) {
|
|
return this.serialSab;
|
|
}
|
|
if (typeof SharedArrayBuffer === 'undefined') {
|
|
return null;
|
|
}
|
|
try {
|
|
this.serialSab = new SharedArrayBuffer(8 + this.serialCapacity);
|
|
this.serialIndices = new Int32Array(this.serialSab, 0, 2);
|
|
this.serialData = new Uint8Array(this.serialSab, 8, this.serialCapacity);
|
|
} catch (_err) {
|
|
this.serialSab = null;
|
|
this.serialIndices = null;
|
|
this.serialData = null;
|
|
}
|
|
return this.serialSab;
|
|
}
|
|
|
|
getPinOutSab() {
|
|
if (this.pinOutSab) return this.pinOutSab;
|
|
if (typeof SharedArrayBuffer === 'undefined') return null;
|
|
try {
|
|
this.pinOutSab = new SharedArrayBuffer(this.pinCount * 4);
|
|
this.pinOutView = new Int32Array(this.pinOutSab);
|
|
} catch (_err) {
|
|
this.pinOutSab = null;
|
|
this.pinOutView = null;
|
|
}
|
|
return this.pinOutSab;
|
|
}
|
|
|
|
getPinInSab() {
|
|
if (this.pinInSab) return this.pinInSab;
|
|
if (typeof SharedArrayBuffer === 'undefined') return null;
|
|
try {
|
|
this.pinInSab = new SharedArrayBuffer(this.pinCount * 4);
|
|
this.pinInView = new Int32Array(this.pinInSab);
|
|
} catch (_err) {
|
|
this.pinInSab = null;
|
|
this.pinInView = null;
|
|
}
|
|
return this.pinInSab;
|
|
}
|
|
|
|
pushSerialBytes(bytes) {
|
|
if (!this.serialIndices || !this.serialData) return 0;
|
|
const cap = this.serialCapacity;
|
|
let written = 0;
|
|
for (const byte of bytes) {
|
|
const w = Atomics.load(this.serialIndices, 1);
|
|
const r = Atomics.load(this.serialIndices, 0);
|
|
const next = (w + 1) % cap;
|
|
if (next === r) {
|
|
break;
|
|
}
|
|
this.serialData[w] = byte & 0xff;
|
|
Atomics.store(this.serialIndices, 1, next);
|
|
written += 1;
|
|
}
|
|
return written;
|
|
}
|
|
|
|
prewarmPyWorker() {
|
|
if (this.pyodideInited || this.workerWarmupPromise) {
|
|
return;
|
|
}
|
|
this.workerWarmupPromise = this.ensurePyodideReady()
|
|
.catch(() => {
|
|
// Ignore warm-up failures; next foreground run will retry.
|
|
})
|
|
.finally(() => {
|
|
this.workerWarmupPromise = null;
|
|
});
|
|
}
|
|
|
|
getPersistedMicropipPackages() {
|
|
try {
|
|
const raw = localStorage.getItem(this.micropipPackagesStorageKey);
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? this.normalizePackageSpecList(parsed) : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
normalizePackageSpecList(items) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const item of items) {
|
|
const t = typeof item === 'string' ? item.trim() : '';
|
|
if (!t) {
|
|
continue;
|
|
}
|
|
const key = t.toLowerCase();
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
out.push(t);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Split user input on commas or newlines (one spec per segment). */
|
|
parsePackageSpecsInput(text) {
|
|
const raw = String(text || '')
|
|
.split(/[,\n]+/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
return this.normalizePackageSpecList(raw);
|
|
}
|
|
|
|
savePersistedMicropipPackages(specs) {
|
|
try {
|
|
const list = this.normalizePackageSpecList(specs);
|
|
localStorage.setItem(this.micropipPackagesStorageKey, JSON.stringify(list));
|
|
} catch (_error) {
|
|
// Ignore storage failures (private mode, quota).
|
|
}
|
|
}
|
|
|
|
pypiProjectNameFromInput(text) {
|
|
const first = String(text || '')
|
|
.split(/[,\n]+/)[0]
|
|
.trim();
|
|
if (!first) {
|
|
return '';
|
|
}
|
|
const base = first.split(/[\s\[<>=!]/)[0];
|
|
return (base || first).trim();
|
|
}
|
|
|
|
escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
renderPackagesSavedList() {
|
|
const ul = document.getElementById('packages-saved-list');
|
|
if (!ul) {
|
|
return;
|
|
}
|
|
const specs = this.getPersistedMicropipPackages();
|
|
if (!specs.length) {
|
|
ul.innerHTML = '<li class="packages-saved-empty">None yet</li>';
|
|
return;
|
|
}
|
|
ul.innerHTML = specs
|
|
.map(
|
|
(s) =>
|
|
`<li class="packages-saved-item"><span>${this.escapeHtml(s)}</span>` +
|
|
`<button type="button" class="packages-remove-btn" data-spec="${encodeURIComponent(s)}">Remove</button></li>`,
|
|
)
|
|
.join('');
|
|
}
|
|
|
|
showPackagesModal() {
|
|
const modal = document.getElementById('packages-modal');
|
|
const status = document.getElementById('packages-modal-status');
|
|
const info = document.getElementById('packages-pypi-info');
|
|
if (!modal) {
|
|
return;
|
|
}
|
|
if (status) {
|
|
status.textContent = '';
|
|
}
|
|
if (info) {
|
|
info.textContent = '';
|
|
}
|
|
const installLog = document.getElementById('packages-install-log');
|
|
if (installLog) {
|
|
installLog.textContent = '';
|
|
installLog.classList.add('hidden');
|
|
}
|
|
this.renderPackagesSavedList();
|
|
modal.style.display = 'block';
|
|
const input = document.getElementById('packages-install-input');
|
|
if (input) {
|
|
input.focus();
|
|
}
|
|
}
|
|
|
|
hidePackagesModal() {
|
|
const modal = document.getElementById('packages-modal');
|
|
if (modal) {
|
|
modal.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async removePersistedPackage(encodedSpec) {
|
|
let spec = encodedSpec;
|
|
try {
|
|
spec = decodeURIComponent(encodedSpec);
|
|
} catch (_e) {
|
|
// use raw
|
|
}
|
|
const next = this.getPersistedMicropipPackages().filter((s) => s !== spec);
|
|
this.savePersistedMicropipPackages(next);
|
|
this.renderPackagesSavedList();
|
|
this.disposePyWorker();
|
|
this.pyodideInited = false;
|
|
this.prewarmPyWorker();
|
|
this.showSuccess('Removed from saved list. Pyodide reloads in the background.');
|
|
}
|
|
|
|
async lookupPackageOnPyPI() {
|
|
const input = document.getElementById('packages-install-input');
|
|
const infoEl = document.getElementById('packages-pypi-info');
|
|
const name = this.pypiProjectNameFromInput((input && input.value) || '');
|
|
if (!name) {
|
|
this.showError('Enter a package name to look up');
|
|
return;
|
|
}
|
|
if (infoEl) {
|
|
infoEl.textContent = 'Loading…';
|
|
}
|
|
try {
|
|
const r = await fetch(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`);
|
|
if (!r.ok) {
|
|
throw new Error('No such project on PyPI');
|
|
}
|
|
const j = await r.json();
|
|
const ver = j.info && j.info.version;
|
|
const sum = j.info && j.info.summary;
|
|
const bits = [name, ver ? `(latest: ${ver})` : '', sum ? `— ${sum}` : ''].filter(Boolean);
|
|
if (infoEl) {
|
|
infoEl.textContent = bits.join(' ');
|
|
}
|
|
} catch (err) {
|
|
if (infoEl) {
|
|
infoEl.textContent = '';
|
|
}
|
|
const msg = err && err.message ? err.message : String(err);
|
|
this.showError(`PyPI lookup failed: ${msg}`);
|
|
}
|
|
}
|
|
|
|
async installPackagesFromModal() {
|
|
const input = document.getElementById('packages-install-input');
|
|
const status = document.getElementById('packages-modal-status');
|
|
const btn = document.getElementById('packages-install-btn');
|
|
const text = (input && input.value) || '';
|
|
const newSpecs = this.parsePackageSpecsInput(text);
|
|
if (!newSpecs.length) {
|
|
this.showError('Enter at least one package name or version spec.');
|
|
return;
|
|
}
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
}
|
|
const logEl = document.getElementById('packages-install-log');
|
|
if (logEl) {
|
|
logEl.textContent = '';
|
|
logEl.classList.remove('hidden');
|
|
}
|
|
if (status) {
|
|
status.textContent = 'Installing (may download from PyPI)…';
|
|
}
|
|
const t0 = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
|
this.micropipInstallCaptureIo = true;
|
|
try {
|
|
await this.ensurePyodideReady();
|
|
await this.callPyWorker('micropipInstall', { specs: newSpecs });
|
|
const merged = this.normalizePackageSpecList([...this.getPersistedMicropipPackages(), ...newSpecs]);
|
|
this.savePersistedMicropipPackages(merged);
|
|
if (input) {
|
|
input.value = '';
|
|
}
|
|
const elapsedSec =
|
|
((typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) - t0) /
|
|
1000;
|
|
const elapsed = elapsedSec < 10 ? elapsedSec.toFixed(2) : elapsedSec.toFixed(1);
|
|
if (status) {
|
|
const hasLog = logEl && logEl.textContent.trim();
|
|
status.textContent = hasLog
|
|
? `Installed: ${newSpecs.join(', ')} (${elapsed}s). Installer output below.`
|
|
: `Installed: ${newSpecs.join(', ')} (${elapsed}s). No installer console output (often normal for tiny pure-Python wheels).`;
|
|
}
|
|
this.renderPackagesSavedList();
|
|
this.showSuccess('Packages installed');
|
|
} catch (err) {
|
|
const msg = err && err.message ? err.message : String(err);
|
|
const elapsedSec =
|
|
((typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) - t0) /
|
|
1000;
|
|
const elapsed = elapsedSec < 10 ? elapsedSec.toFixed(2) : elapsedSec.toFixed(1);
|
|
if (status) {
|
|
status.textContent = `Install failed after ${elapsed}s.`;
|
|
}
|
|
this.showError(`Install failed: ${msg}`);
|
|
} finally {
|
|
this.micropipInstallCaptureIo = false;
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
loadSessionState() {
|
|
try {
|
|
const raw = localStorage.getItem(this.sessionStorageKey);
|
|
if (!raw) return;
|
|
const session = JSON.parse(raw);
|
|
this.savedSession = session;
|
|
} catch (_error) {
|
|
this.savedSession = null;
|
|
}
|
|
}
|
|
|
|
saveSessionState() {
|
|
try {
|
|
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
|
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
|
|
const sidebar = document.getElementById('sidebar');
|
|
const consoleContainer = document.getElementById('console-container');
|
|
const session = {
|
|
openTabPaths: this.openTabs.map((tab) => tab.path),
|
|
activeTabPath: this.activeTabPath,
|
|
runMainChecked: Boolean(runMainCheckbox && runMainCheckbox.checked),
|
|
panel16x16Checked: Boolean(panelModeCheckbox && panelModeCheckbox.checked),
|
|
expandedDirs: Array.from(this.expandedDirs || []),
|
|
selectedPath: this.selectedPath || '',
|
|
selectedIsDirectory: Boolean(this.selectedIsDirectory),
|
|
sidebarCollapsed: Boolean(sidebar && sidebar.classList.contains('is-collapsed')),
|
|
consoleCollapsed: Boolean(consoleContainer && consoleContainer.classList.contains('is-collapsed')),
|
|
};
|
|
localStorage.setItem(this.sessionStorageKey, JSON.stringify(session));
|
|
} catch (_error) {
|
|
// Ignore storage failures.
|
|
}
|
|
}
|
|
|
|
applyPersistedPanelState() {
|
|
const session = this.savedSession;
|
|
if (!session) return;
|
|
if (session.consoleCollapsed) {
|
|
this.setConsoleCollapsed(true);
|
|
}
|
|
if (session.sidebarCollapsed && !this.isMobileViewport()) {
|
|
this.setDesktopSidebarCollapsed(true);
|
|
}
|
|
}
|
|
|
|
async restoreSessionTabs() {
|
|
const session = this.savedSession;
|
|
this.savedSession = null;
|
|
if (!session) {
|
|
return;
|
|
}
|
|
await this.restoreExplorerState(session);
|
|
if (!Array.isArray(session.openTabPaths) || session.openTabPaths.length === 0) {
|
|
return;
|
|
}
|
|
for (const path of session.openTabPaths) {
|
|
if (this.isBrowserLocalPath(path) && !this.isBrowserLocalFilePath(path)) {
|
|
continue;
|
|
}
|
|
if (this.isBrowserLocalFilePath(path) && !this._browserLocalHandles.has(path)) {
|
|
continue;
|
|
}
|
|
await this.openFile(path);
|
|
}
|
|
if (session.activeTabPath && this.findTab(session.activeTabPath)) {
|
|
this.switchToTab(session.activeTabPath);
|
|
}
|
|
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
|
if (runMainCheckbox && typeof session.runMainChecked === 'boolean') {
|
|
runMainCheckbox.checked = session.runMainChecked;
|
|
}
|
|
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
|
|
if (panelModeCheckbox && typeof session.panel16x16Checked === 'boolean') {
|
|
panelModeCheckbox.checked = session.panel16x16Checked;
|
|
}
|
|
this.saveSessionState();
|
|
}
|
|
|
|
async ensureDefaultMainOpen() {
|
|
if (this.openTabs.length > 0) {
|
|
return;
|
|
}
|
|
await this.openFile('code/main.py');
|
|
this.selectedPath = 'code/main.py';
|
|
this.selectedIsDirectory = false;
|
|
this.renderFileTree();
|
|
}
|
|
|
|
async restoreExplorerState(session) {
|
|
const expanded = Array.isArray(session.expandedDirs)
|
|
? session.expandedDirs.filter(
|
|
(path) =>
|
|
typeof path === 'string' &&
|
|
path !== BROWSER_LOCAL_LEGACY_ROOT &&
|
|
!path.startsWith(BROWSER_LOCAL_LEGACY_PREFIX),
|
|
)
|
|
: [];
|
|
this.expandedDirs = new Set(expanded.length ? expanded : ['']);
|
|
if (!this.expandedDirs.has('')) {
|
|
this.expandedDirs.add('');
|
|
}
|
|
this.selectedPath = typeof session.selectedPath === 'string' ? session.selectedPath : '';
|
|
this.selectedIsDirectory = Boolean(session.selectedIsDirectory);
|
|
|
|
for (const path of [...this.expandedDirs]) {
|
|
if (!path || this.directoryCache.has(path)) continue;
|
|
const ok = await this.loadDirectory(path, { suppressError: true });
|
|
if (!ok && this.isBrowserLocalPath(path)) {
|
|
this.expandedDirs.delete(path);
|
|
}
|
|
}
|
|
this.renderFileTree();
|
|
}
|
|
|
|
getVisibleTopLevelNames() {
|
|
/* lib is shared read-only for everyone; browsing is allowed, saves are blocked in API/UI. */
|
|
return new Set(['code', 'demos', 'lib']);
|
|
}
|
|
|
|
getDefaultEditableRoot() {
|
|
return 'code';
|
|
}
|
|
|
|
async loadInitialDirectoryState() {
|
|
await this.loadDirectory('');
|
|
await this.ensureFolderExists('code');
|
|
await this.ensureFolderExists('demos');
|
|
this.selectedPath = 'code';
|
|
this.selectedIsDirectory = true;
|
|
this.expandedDirs.add('code');
|
|
this.expandedDirs.add('demos');
|
|
await this.loadDirectory('code', { suppressError: true });
|
|
await this.loadDirectory('demos', { suppressError: true });
|
|
await this.loadDirectory('lib', { suppressError: true });
|
|
this.renderFileTree();
|
|
}
|
|
|
|
async ensureFolderExists(folderPath) {
|
|
try {
|
|
const existing = await this.apiFetch(`/api/files?path=${encodeURIComponent(folderPath)}`);
|
|
if (existing.ok) {
|
|
return true;
|
|
}
|
|
if (existing.status && existing.status !== 404) {
|
|
return false;
|
|
}
|
|
const response = await this.apiFetch(`/api/folder/new/${this.encodeApiFilePath(folderPath)}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: folderPath })
|
|
});
|
|
return response.ok;
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
getTreeRootPath() {
|
|
return '';
|
|
}
|
|
|
|
normalizeRelativePathInput(pathValue) {
|
|
let normalized = (pathValue || '').trim().replace(/^\/+/, '');
|
|
const parts = normalized.split('/').filter(Boolean);
|
|
while (parts.length >= 2 && parts[0] === 'code' && parts[1] === 'code') {
|
|
parts.shift();
|
|
}
|
|
while (parts.length >= 2 && parts[0] === 'demos' && parts[1] === 'demos') {
|
|
parts.shift();
|
|
}
|
|
return parts.join('/');
|
|
}
|
|
|
|
resetDirectoryState() {
|
|
this.directoryCache.clear();
|
|
this.expandedDirs = new Set(['']);
|
|
}
|
|
|
|
ensureExpandedAncestorsForPath(relativePath, { isDirectory = false } = {}) {
|
|
if (!relativePath || typeof relativePath !== 'string') return;
|
|
this.expandedDirs.add('');
|
|
const parts = relativePath.split('/').filter(Boolean);
|
|
if (!parts.length) return;
|
|
const depth = isDirectory ? parts.length : Math.max(0, parts.length - 1);
|
|
let acc = '';
|
|
for (let i = 0; i < depth; i++) {
|
|
acc = acc ? `${acc}/${parts[i]}` : parts[i];
|
|
this.expandedDirs.add(acc);
|
|
}
|
|
}
|
|
|
|
pruneExpandedDirsAtAndUnder(prefix) {
|
|
if (!prefix || typeof prefix !== 'string') return;
|
|
const norm = prefix.replace(/\/+$/g, '');
|
|
for (const p of [...this.expandedDirs]) {
|
|
if (!p) continue;
|
|
if (p === norm || p.startsWith(`${norm}/`)) {
|
|
this.expandedDirs.delete(p);
|
|
}
|
|
}
|
|
this.expandedDirs.add('');
|
|
}
|
|
|
|
pathDepthForSort(p) {
|
|
if (!p) return 0;
|
|
return p.split('/').length;
|
|
}
|
|
|
|
async refreshFileTreePreservingExpansion(options = {}) {
|
|
const { pruneDeletedPrefix = null } = options;
|
|
if (pruneDeletedPrefix) {
|
|
this.pruneExpandedDirsAtAndUnder(pruneDeletedPrefix);
|
|
}
|
|
this.directoryCache.clear();
|
|
const sorted = Array.from(this.expandedDirs).sort((a, b) => {
|
|
const da = this.pathDepthForSort(a);
|
|
const db = this.pathDepthForSort(b);
|
|
if (da !== db) return da - db;
|
|
if (a < b) return -1;
|
|
if (a > b) return 1;
|
|
return 0;
|
|
});
|
|
for (const p of sorted) {
|
|
await this.loadDirectory(p, { suppressError: true, suppressRender: true });
|
|
}
|
|
this.renderFileTree();
|
|
}
|
|
|
|
browserLocalChangePath() {
|
|
return this._browserLocalFolderMount
|
|
? `${this._browserLocalFolderMount}/${BROWSER_LOCAL_SEG_CHANGE}`
|
|
: null;
|
|
}
|
|
|
|
isBrowserLocalVirtualPath(path) {
|
|
if (!path || typeof path !== 'string') return false;
|
|
const ch = this.browserLocalChangePath();
|
|
if (ch && path === ch) return true;
|
|
if (BROWSER_LOCAL_LEGACY_VIRTUAL.test(path)) return true;
|
|
return false;
|
|
}
|
|
|
|
isBrowserLocalPath(path) {
|
|
if (!path || typeof path !== 'string') return false;
|
|
if (path === BROWSER_LOCAL_LEGACY_ROOT || path.startsWith(BROWSER_LOCAL_LEGACY_PREFIX)) return true;
|
|
if (this._browserLocalFolderMount) {
|
|
if (path === this._browserLocalFolderMount) return true;
|
|
if (path.startsWith(`${this._browserLocalFolderMount}/`)) return true;
|
|
}
|
|
if (!path.includes('/') && path.endsWith('-local')) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
isBrowserLocalFilePath(path) {
|
|
if (!path || this.isBrowserLocalVirtualPath(path)) return false;
|
|
const h = this._browserLocalHandles.get(path);
|
|
return Boolean(h && h.kind === 'file');
|
|
}
|
|
|
|
slugifyLocalMountBase(raw) {
|
|
let s = String(raw || '').trim() || 'folder';
|
|
s = s.replace(/[/\\:*?"<>|]+/g, '-').replace(/\s+/g, '-').replace(/-+/g, '-');
|
|
s = s.replace(/^-|-$/g, '') || 'folder';
|
|
return s.slice(0, 96);
|
|
}
|
|
|
|
rootTopLevelNameTaken(name) {
|
|
const rootFiles = this.directoryCache.get('') || [];
|
|
return rootFiles.some((f) => f && f.name === name);
|
|
}
|
|
|
|
allocateBrowserLocalFolderMount(rawName) {
|
|
const base = this.slugifyLocalMountBase(rawName);
|
|
let candidate = `${base}-local`;
|
|
let n = 2;
|
|
while (
|
|
candidate === 'code' ||
|
|
candidate === 'demos' ||
|
|
candidate === 'lib' ||
|
|
this.rootTopLevelNameTaken(candidate) ||
|
|
this._browserLocalHandles.has(candidate)
|
|
) {
|
|
candidate = `${base}-${n}-local`;
|
|
n += 1;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
allocateBrowserLocalFileMount(baseName) {
|
|
const base = this.slugifyLocalMountBase(baseName);
|
|
let candidate = `${base}-local`;
|
|
let n = 2;
|
|
const dot = baseName.lastIndexOf('.');
|
|
const stem = dot === -1 ? baseName : baseName.slice(0, dot);
|
|
const ext = dot === -1 ? '' : baseName.slice(dot);
|
|
while (
|
|
this._browserLocalHandles.has(candidate) ||
|
|
this.findTab(candidate) ||
|
|
candidate === this._browserLocalFolderMount ||
|
|
this.rootTopLevelNameTaken(candidate)
|
|
) {
|
|
candidate = `${this.slugifyLocalMountBase(`${stem} (${n})${ext}`)}-local`;
|
|
n += 1;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
formatBrowserLocalError(err) {
|
|
const msg = err && err.message ? err.message : String(err);
|
|
if (/aborted|cancel/i.test(msg)) return '';
|
|
if (/permission|not allowed|denied/i.test(msg)) {
|
|
return 'Permission was denied for local folder access. Choose the folder again if you need access.';
|
|
}
|
|
return `Local folder: ${msg}`;
|
|
}
|
|
|
|
showBrowserLocalUnsupportedHelp() {
|
|
this.showError(
|
|
'Opening local folders needs the File System Access API (Chromium: Chrome, Edge, Brave, Opera). ' +
|
|
'Firefox and Safari are not supported. The page must be served over HTTPS or from localhost. ' +
|
|
'In Brave, you may need to enable the File System Access API flag or relax Shields for this site.',
|
|
);
|
|
}
|
|
|
|
async browserLocalFolderIdbOpen() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(BROWSER_LOCAL_FOLDER_IDB_NAME, BROWSER_LOCAL_FOLDER_IDB_VER);
|
|
req.onupgradeneeded = () => {
|
|
const db = req.result;
|
|
if (!db.objectStoreNames.contains(BROWSER_LOCAL_FOLDER_IDB_STORE)) {
|
|
db.createObjectStore(BROWSER_LOCAL_FOLDER_IDB_STORE);
|
|
}
|
|
};
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async saveBrowserLocalFolderPersistence() {
|
|
if (!this._browserLocalFolderMount || !this._browserLocalRootHandle) return;
|
|
try {
|
|
const db = await this.browserLocalFolderIdbOpen();
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(BROWSER_LOCAL_FOLDER_IDB_STORE, 'readwrite');
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
tx.objectStore(BROWSER_LOCAL_FOLDER_IDB_STORE).put(
|
|
{ mount: this._browserLocalFolderMount, handle: this._browserLocalRootHandle },
|
|
'saved',
|
|
);
|
|
});
|
|
db.close();
|
|
} catch (_e) {
|
|
// Ignore IDB failures (private mode, quota, etc.).
|
|
}
|
|
}
|
|
|
|
async clearBrowserLocalFolderPersistence() {
|
|
try {
|
|
const db = await this.browserLocalFolderIdbOpen();
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(BROWSER_LOCAL_FOLDER_IDB_STORE, 'readwrite');
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
tx.objectStore(BROWSER_LOCAL_FOLDER_IDB_STORE).delete('saved');
|
|
});
|
|
db.close();
|
|
} catch (_e) {
|
|
// Ignore.
|
|
}
|
|
}
|
|
|
|
async loadBrowserLocalFolderPersistenceRecord() {
|
|
try {
|
|
const db = await this.browserLocalFolderIdbOpen();
|
|
const rec = await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(BROWSER_LOCAL_FOLDER_IDB_STORE, 'readonly');
|
|
const r = tx.objectStore(BROWSER_LOCAL_FOLDER_IDB_STORE).get('saved');
|
|
r.onsuccess = () => resolve(r.result || null);
|
|
r.onerror = () => reject(r.error);
|
|
});
|
|
db.close();
|
|
return rec;
|
|
} catch (_e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async restoreBrowserLocalFolderFromPersistence() {
|
|
if (!supportsFolderPicker() || typeof indexedDB === 'undefined') return;
|
|
const rec = await this.loadBrowserLocalFolderPersistenceRecord();
|
|
if (!rec || !rec.mount || !rec.handle || rec.handle.kind !== 'directory') {
|
|
await this.clearBrowserLocalFolderPersistence();
|
|
return;
|
|
}
|
|
if (this.rootTopLevelNameTaken(rec.mount)) {
|
|
await this.clearBrowserLocalFolderPersistence();
|
|
return;
|
|
}
|
|
let perm = 'granted';
|
|
try {
|
|
perm = rec.handle.queryPermission ? await rec.handle.queryPermission({ mode: 'read' }) : 'granted';
|
|
} catch (_e) {
|
|
perm = 'prompt';
|
|
}
|
|
if (perm === 'denied') {
|
|
await this.clearBrowserLocalFolderPersistence();
|
|
return;
|
|
}
|
|
if (perm === 'prompt') {
|
|
try {
|
|
perm = rec.handle.requestPermission ? await rec.handle.requestPermission({ mode: 'read' }) : 'denied';
|
|
} catch (_e) {
|
|
perm = 'denied';
|
|
}
|
|
}
|
|
if (perm !== 'granted') {
|
|
return;
|
|
}
|
|
this._browserLocalFolderMount = rec.mount;
|
|
this._browserLocalRootHandle = rec.handle;
|
|
this._browserLocalHandles.clear();
|
|
this._browserLocalHandles.set(rec.mount, rec.handle);
|
|
this.expandedDirs.add(rec.mount);
|
|
await this.loadBrowserLocalDirectory(rec.mount, { suppressError: true });
|
|
}
|
|
|
|
async pickBrowserLocalRootDirectory() {
|
|
if (!supportsFolderPicker()) {
|
|
this.showBrowserLocalUnsupportedHelp();
|
|
return;
|
|
}
|
|
try {
|
|
const handle = await window.showDirectoryPicker({
|
|
id: 'python-editor-local-browse',
|
|
mode: 'read',
|
|
});
|
|
const prevMount = this._browserLocalFolderMount;
|
|
for (const key of [...this.directoryCache.keys()]) {
|
|
if (
|
|
key.startsWith(BROWSER_LOCAL_LEGACY_PREFIX) ||
|
|
key === BROWSER_LOCAL_LEGACY_ROOT ||
|
|
(prevMount && (key === prevMount || key.startsWith(`${prevMount}/`))) ||
|
|
(!key.includes('/') && key.endsWith('-local'))
|
|
) {
|
|
this.directoryCache.delete(key);
|
|
}
|
|
}
|
|
this._browserLocalHandles.clear();
|
|
const mount = this.allocateBrowserLocalFolderMount(handle.name);
|
|
this._browserLocalFolderMount = mount;
|
|
this._browserLocalRootHandle = handle;
|
|
this._browserLocalHandles.set(mount, handle);
|
|
this.expandedDirs.add(mount);
|
|
await this.loadBrowserLocalDirectory(mount, { suppressError: true });
|
|
await this.saveBrowserLocalFolderPersistence();
|
|
this.showSuccess(`Opened local folder as “${mount}” (read-only in the editor).`);
|
|
} catch (err) {
|
|
const msg = this.formatBrowserLocalError(err);
|
|
if (msg) this.showError(msg);
|
|
}
|
|
}
|
|
|
|
async changeBrowserLocalFolder() {
|
|
if (!supportsFolderPicker()) {
|
|
this.showBrowserLocalUnsupportedHelp();
|
|
return;
|
|
}
|
|
this.openTabs
|
|
.filter((tab) => this.isBrowserLocalFilePath(tab.path))
|
|
.forEach((tab) => this.closeTabWithoutPrompt(tab.path));
|
|
await this.clearBrowserLocalFolderPersistence();
|
|
const prevMount = this._browserLocalFolderMount;
|
|
this._browserLocalRootHandle = null;
|
|
this._browserLocalFolderMount = null;
|
|
this._browserLocalHandles.clear();
|
|
for (const key of [...this.directoryCache.keys()]) {
|
|
if (
|
|
key.startsWith(BROWSER_LOCAL_LEGACY_PREFIX) ||
|
|
key === BROWSER_LOCAL_LEGACY_ROOT ||
|
|
(prevMount && (key === prevMount || key.startsWith(`${prevMount}/`))) ||
|
|
(!key.includes('/') && key.endsWith('-local'))
|
|
) {
|
|
this.directoryCache.delete(key);
|
|
}
|
|
}
|
|
if (prevMount) {
|
|
this.expandedDirs.delete(prevMount);
|
|
}
|
|
await this.pickBrowserLocalRootDirectory();
|
|
}
|
|
|
|
async removeBrowserLocalFolderConnection() {
|
|
if (!this._browserLocalFolderMount) {
|
|
return;
|
|
}
|
|
const label = this._browserLocalFolderMount;
|
|
if (
|
|
!confirm(
|
|
`Remove “${label}” from the file browser? Open tabs from that folder will close. Nothing is deleted on disk.`,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
await this.clearBrowserLocalFolderPersistence();
|
|
const prevMount = this._browserLocalFolderMount;
|
|
this.openTabs
|
|
.filter((tab) => tab.path === prevMount || tab.path.startsWith(`${prevMount}/`))
|
|
.forEach((tab) => this.closeTabWithoutPrompt(tab.path));
|
|
this._browserLocalRootHandle = null;
|
|
this._browserLocalFolderMount = null;
|
|
for (const k of [...this._browserLocalHandles.keys()]) {
|
|
if (k === prevMount || k.startsWith(`${prevMount}/`)) {
|
|
this._browserLocalHandles.delete(k);
|
|
}
|
|
}
|
|
for (const key of [...this.directoryCache.keys()]) {
|
|
if (key.startsWith(BROWSER_LOCAL_LEGACY_PREFIX) || key === BROWSER_LOCAL_LEGACY_ROOT) {
|
|
this.directoryCache.delete(key);
|
|
} else if (prevMount && (key === prevMount || key.startsWith(`${prevMount}/`))) {
|
|
this.directoryCache.delete(key);
|
|
}
|
|
}
|
|
this.expandedDirs.delete(prevMount);
|
|
this.selectedPath = null;
|
|
this.selectedIsDirectory = false;
|
|
this.renderFileTree();
|
|
this.showSuccess('Removed local folder from the editor.');
|
|
}
|
|
|
|
async removeBrowserLocalRootFileEntry(filePath) {
|
|
if (!filePath || filePath.includes('/') || !filePath.endsWith('-local')) {
|
|
return;
|
|
}
|
|
if (!this.isBrowserLocalFilePath(filePath)) {
|
|
return;
|
|
}
|
|
if (!confirm(`Remove “${filePath}” from the editor? The file on disk is not deleted.`)) {
|
|
return;
|
|
}
|
|
this._browserLocalHandles.delete(filePath);
|
|
if (this.findTab(filePath)) {
|
|
this.closeTabWithoutPrompt(filePath);
|
|
}
|
|
this.renderFileTree();
|
|
this.showSuccess('Removed local file from the editor.');
|
|
}
|
|
|
|
async closeLocalBrowserFileUnderFolder(filePath) {
|
|
if (!this.isBrowserLocalFilePath(filePath)) {
|
|
return;
|
|
}
|
|
const mount = this._browserLocalFolderMount;
|
|
if (!mount || !filePath.startsWith(`${mount}/`)) {
|
|
return;
|
|
}
|
|
if (!confirm(`Close “${filePath}” in the editor? The file on disk is not deleted.`)) {
|
|
return;
|
|
}
|
|
this._browserLocalHandles.delete(filePath);
|
|
if (this.findTab(filePath)) {
|
|
this.closeTabWithoutPrompt(filePath);
|
|
}
|
|
const parent = this.getParentDirectory(filePath);
|
|
if (parent) {
|
|
this.directoryCache.delete(parent);
|
|
await this.loadBrowserLocalDirectory(parent, { suppressError: true });
|
|
} else {
|
|
this.renderFileTree();
|
|
}
|
|
this.showSuccess('Removed from editor.');
|
|
}
|
|
|
|
hideFileTreeContextMenu() {
|
|
const menu = document.getElementById('file-tree-context-menu');
|
|
if (menu) {
|
|
menu.hidden = true;
|
|
menu.setAttribute('aria-hidden', 'true');
|
|
menu.innerHTML = '';
|
|
menu.style.left = '';
|
|
menu.style.top = '';
|
|
}
|
|
if (this._onFileTreeContextMenuDismiss) {
|
|
document.removeEventListener('pointerdown', this._onFileTreeContextMenuDismiss, true);
|
|
document.removeEventListener('keydown', this._onFileTreeContextMenuDismiss, true);
|
|
this._onFileTreeContextMenuDismiss = null;
|
|
}
|
|
}
|
|
|
|
setupFileTreeContextMenu() {
|
|
const tree = document.getElementById('file-tree');
|
|
if (!tree || tree.dataset.contextMenuBound === '1') {
|
|
return;
|
|
}
|
|
tree.dataset.contextMenuBound = '1';
|
|
tree.addEventListener('contextmenu', (e) => {
|
|
if (!tree.contains(e.target)) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
this.openFileTreeContextMenu(e);
|
|
});
|
|
}
|
|
|
|
openFileTreeContextMenu(e) {
|
|
const tree = document.getElementById('file-tree');
|
|
const menu = document.getElementById('file-tree-context-menu');
|
|
if (!tree || !menu) {
|
|
return;
|
|
}
|
|
this.hideFileTreeContextMenu();
|
|
|
|
const item = e.target.closest('.file-item');
|
|
let path = '';
|
|
let isDirectory = false;
|
|
if (item) {
|
|
path = item.dataset.path || '';
|
|
isDirectory = item.dataset.isDirectory === 'true';
|
|
this.selectedPath = path;
|
|
this.selectedIsDirectory = isDirectory;
|
|
} else {
|
|
this.selectedPath = 'code';
|
|
this.selectedIsDirectory = true;
|
|
}
|
|
|
|
const ch = this.browserLocalChangePath();
|
|
const isEmpty = !item;
|
|
const isLib = Boolean(path && (path === 'lib' || path.startsWith('lib/')));
|
|
const isProjectWritable = Boolean(
|
|
path &&
|
|
(path === 'code' ||
|
|
path.startsWith('code/') ||
|
|
path === 'demos' ||
|
|
path.startsWith('demos/')),
|
|
);
|
|
const isLocalChange = Boolean(ch && path === ch);
|
|
const isLocalMount = Boolean(this._browserLocalFolderMount && path === this._browserLocalFolderMount);
|
|
const isUnderLocal = Boolean(
|
|
this._browserLocalFolderMount &&
|
|
path &&
|
|
path.startsWith(`${this._browserLocalFolderMount}/`) &&
|
|
path !== ch,
|
|
);
|
|
const isLocalFile = this.isBrowserLocalFilePath(path);
|
|
const isRootLocalFile =
|
|
Boolean(path && !path.includes('/') && path.endsWith('-local')) && this.isBrowserLocalFilePath(path);
|
|
|
|
const frag = document.createDocumentFragment();
|
|
const addBtn = (label, onClick, opts = {}) => {
|
|
const b = document.createElement('button');
|
|
b.type = 'button';
|
|
b.setAttribute('role', 'menuitem');
|
|
b.textContent = label;
|
|
if (opts.danger) {
|
|
b.className = 'file-tree-cm-danger';
|
|
}
|
|
if (opts.disabled) {
|
|
b.disabled = true;
|
|
} else {
|
|
b.addEventListener('click', () => {
|
|
this.hideFileTreeContextMenu();
|
|
onClick();
|
|
});
|
|
}
|
|
frag.appendChild(b);
|
|
};
|
|
const addSep = () => {
|
|
const hr = document.createElement('hr');
|
|
hr.className = 'file-tree-cm-sep';
|
|
frag.appendChild(hr);
|
|
};
|
|
const addNote = (text) => {
|
|
const n = document.createElement('div');
|
|
n.className = 'file-tree-cm-note';
|
|
n.textContent = text;
|
|
frag.appendChild(n);
|
|
};
|
|
const refreshTree = () => {
|
|
this.refreshFileTreePreservingExpansion();
|
|
};
|
|
|
|
if (isLib) {
|
|
addNote('Shared library is read-only.');
|
|
addBtn('Refresh file tree', refreshTree);
|
|
} else if (isLocalChange || isLocalMount) {
|
|
if (typeof window.showOpenFilePicker === 'function') {
|
|
addBtn('Open local file…', async () => {
|
|
await this.pickBrowserLocalSingleFile();
|
|
this.renderFileTree();
|
|
});
|
|
}
|
|
addBtn('Change local folder…', async () => {
|
|
await this.changeBrowserLocalFolder();
|
|
});
|
|
addBtn('Remove local folder from editor…', () => this.removeBrowserLocalFolderConnection(), {
|
|
danger: true,
|
|
});
|
|
} else if (isUnderLocal) {
|
|
if (isLocalFile) {
|
|
addBtn('Remove from editor…', () => this.closeLocalBrowserFileUnderFolder(path), {
|
|
danger: true,
|
|
});
|
|
} else {
|
|
addNote('Local folders are read-only here.');
|
|
addBtn('Refresh this folder', async () => {
|
|
this.directoryCache.delete(path);
|
|
await this.loadBrowserLocalDirectory(path, { suppressError: true });
|
|
});
|
|
}
|
|
} else if (isRootLocalFile) {
|
|
addBtn('Remove local file from editor…', () => this.removeBrowserLocalRootFileEntry(path), {
|
|
danger: true,
|
|
});
|
|
} else if (isEmpty) {
|
|
addBtn('New file…', () => this.showNewFileModal());
|
|
addBtn('New folder…', () => this.createNewFolder());
|
|
addSep();
|
|
if (supportsFolderPicker()) {
|
|
addBtn('Open local folder…', async () => {
|
|
await this.pickBrowserLocalRootDirectory();
|
|
this.renderFileTree();
|
|
});
|
|
}
|
|
if (typeof window.showOpenFilePicker === 'function') {
|
|
addBtn('Open local file…', async () => {
|
|
await this.pickBrowserLocalSingleFile();
|
|
this.renderFileTree();
|
|
});
|
|
}
|
|
addSep();
|
|
addBtn('Refresh file tree', refreshTree);
|
|
} else if (isProjectWritable) {
|
|
addBtn('New file…', () => this.showNewFileModal());
|
|
addBtn('New folder…', () => this.createNewFolder(this.getNewFolderPromptDefault()));
|
|
addSep();
|
|
const canDel =
|
|
!((path === 'code' || path === 'demos') && isDirectory) && !this.isBrowserLocalVirtualPath(path);
|
|
addBtn('Delete…', () => this.deleteSelected(), { danger: true, disabled: !canDel });
|
|
addSep();
|
|
if (supportsFolderPicker() && !this._browserLocalFolderMount) {
|
|
addBtn('Open local folder…', async () => {
|
|
await this.pickBrowserLocalRootDirectory();
|
|
this.renderFileTree();
|
|
});
|
|
}
|
|
if (typeof window.showOpenFilePicker === 'function') {
|
|
addBtn('Open local file…', async () => {
|
|
await this.pickBrowserLocalSingleFile();
|
|
this.renderFileTree();
|
|
});
|
|
}
|
|
addBtn('Refresh file tree', refreshTree);
|
|
} else {
|
|
addBtn('Refresh file tree', refreshTree);
|
|
}
|
|
|
|
menu.appendChild(frag);
|
|
menu.hidden = false;
|
|
menu.removeAttribute('aria-hidden');
|
|
let x = e.clientX;
|
|
let y = e.clientY;
|
|
const pad = 6;
|
|
requestAnimationFrame(() => {
|
|
const rr = menu.getBoundingClientRect();
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
if (x + rr.width + pad > vw) {
|
|
x = Math.max(pad, vw - rr.width - pad);
|
|
}
|
|
if (y + rr.height + pad > vh) {
|
|
y = Math.max(pad, vh - rr.height - pad);
|
|
}
|
|
menu.style.left = `${x}px`;
|
|
menu.style.top = `${y}px`;
|
|
});
|
|
|
|
this._onFileTreeContextMenuDismiss = (ev) => {
|
|
const m = document.getElementById('file-tree-context-menu');
|
|
if (!m || m.hidden) {
|
|
return;
|
|
}
|
|
if (ev.type === 'keydown') {
|
|
if (ev.key === 'Escape') {
|
|
this.hideFileTreeContextMenu();
|
|
}
|
|
return;
|
|
}
|
|
if (ev.type === 'pointerdown' && ev.target instanceof Node && m.contains(ev.target)) {
|
|
return;
|
|
}
|
|
if (ev.type === 'pointerdown') {
|
|
this.hideFileTreeContextMenu();
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
document.addEventListener('pointerdown', this._onFileTreeContextMenuDismiss, true);
|
|
document.addEventListener('keydown', this._onFileTreeContextMenuDismiss, true);
|
|
}, 10);
|
|
}
|
|
|
|
async pickBrowserLocalSingleFile() {
|
|
if (typeof window.showOpenFilePicker !== 'function') {
|
|
this.showError('Single-file picker is not available in this browser.');
|
|
return;
|
|
}
|
|
try {
|
|
const picked = await window.showOpenFilePicker({ multiple: false });
|
|
const handle = picked && picked[0];
|
|
if (!handle || handle.kind !== 'file') return;
|
|
const baseName = handle.name || 'file';
|
|
const path = this.allocateBrowserLocalFileMount(baseName);
|
|
this._browserLocalHandles.set(path, handle);
|
|
await this.openFile(path);
|
|
if (this.isMobileViewport()) {
|
|
this.setMobileSidebarOpen(false);
|
|
}
|
|
} catch (err) {
|
|
const msg = this.formatBrowserLocalError(err);
|
|
if (msg) this.showError(msg);
|
|
}
|
|
}
|
|
|
|
async listBrowserLocalDirectory(dirPath) {
|
|
const handle = this._browserLocalHandles.get(dirPath);
|
|
if (!handle || handle.kind !== 'directory') {
|
|
return [];
|
|
}
|
|
const out = [];
|
|
for await (const [name, h] of handle.entries()) {
|
|
const fullPath = `${dirPath}/${name}`;
|
|
this._browserLocalHandles.set(fullPath, h);
|
|
if (h.kind === 'directory') {
|
|
out.push({ name, is_directory: true, size: null });
|
|
} else {
|
|
let size = null;
|
|
try {
|
|
const f = await h.getFile();
|
|
size = typeof f.size === 'number' ? f.size : null;
|
|
} catch (_e) {
|
|
size = null;
|
|
}
|
|
out.push({ name, is_directory: false, size });
|
|
}
|
|
}
|
|
out.sort((a, b) => {
|
|
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
async loadBrowserLocalDirectory(path, options = {}) {
|
|
const { suppressError = false, suppressRender = false } = options;
|
|
try {
|
|
if (path === this._browserLocalFolderMount) {
|
|
if (!this._browserLocalRootHandle || !this._browserLocalFolderMount) {
|
|
return false;
|
|
}
|
|
const ch = this.browserLocalChangePath();
|
|
const files = [
|
|
{ name: '↻ Change folder…', is_directory: false, virtualPath: ch },
|
|
...(await this.listBrowserLocalDirectory(path)),
|
|
];
|
|
this.directoryCache.set(path, files);
|
|
if (!suppressRender) this.renderFileTree();
|
|
return true;
|
|
}
|
|
const inner = await this.listBrowserLocalDirectory(path);
|
|
this.directoryCache.set(path, inner);
|
|
if (!suppressRender) this.renderFileTree();
|
|
return true;
|
|
} catch (err) {
|
|
if (!suppressError) {
|
|
console.error('Local folder listing failed:', err);
|
|
const msg = this.formatBrowserLocalError(err);
|
|
if (msg) this.showError(msg);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
setupEditor() {
|
|
this.editor = new EditorView({
|
|
doc: '',
|
|
extensions: [basicSetup, this.languageCompartment.of([])],
|
|
parent: document.getElementById('editor')
|
|
});
|
|
|
|
this.editor.dom.addEventListener('keydown', (event) => {
|
|
if (this.currentFileReadOnly) {
|
|
const blocksEditKey = event.key.length === 1 || ['Backspace', 'Delete', 'Enter', 'Tab'].includes(event.key);
|
|
if (blocksEditKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (this.completionOpen) {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
this.moveCompletionSelection(1);
|
|
return;
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
this.moveCompletionSelection(-1);
|
|
return;
|
|
}
|
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
event.preventDefault();
|
|
this.applySelectedCompletion();
|
|
return;
|
|
}
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
this.hideCompletionDropdown();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
|
event.preventDefault();
|
|
this.saveFile();
|
|
return;
|
|
}
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.code === 'Space') {
|
|
event.preventDefault();
|
|
this.showCompletionDropdown();
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Tab') {
|
|
event.preventDefault();
|
|
const selection = this.editor.state.selection.main;
|
|
const from = Math.min(selection.from, selection.to);
|
|
const to = Math.max(selection.from, selection.to);
|
|
const indentText = ' ';
|
|
this.editor.dispatch({
|
|
changes: { from, to, insert: indentText },
|
|
selection: { anchor: from + indentText.length }
|
|
});
|
|
}
|
|
});
|
|
|
|
this.editor.dispatch = ((dispatch) => (transaction) => {
|
|
dispatch.call(this.editor, transaction);
|
|
if (!this.ignoreNextChange && transaction.docChanged) {
|
|
this.hideCompletionDropdown();
|
|
this.updateActiveTabContent();
|
|
this.markAsModified();
|
|
this.scheduleAutoCompletion();
|
|
this.scheduleDiagnostics();
|
|
}
|
|
})(this.editor.dispatch);
|
|
}
|
|
|
|
setLspStatus(text, title = '') {
|
|
const status = document.getElementById('lsp-status');
|
|
if (!status) return;
|
|
status.textContent = text;
|
|
status.title = title || text;
|
|
}
|
|
|
|
scheduleDiagnostics() {
|
|
if (this.diagnosticsTimer) {
|
|
clearTimeout(this.diagnosticsTimer);
|
|
}
|
|
this.diagnosticsTimer = setTimeout(() => {
|
|
this.runDiagnostics();
|
|
}, 220);
|
|
}
|
|
|
|
async runDiagnostics() {
|
|
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
|
|
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
|
return;
|
|
}
|
|
const requestId = ++this.diagnosticsRequestId;
|
|
this.setLspStatus('LSP: checking...', 'Running Jedi syntax diagnostics');
|
|
try {
|
|
await this.ensurePyodideReady();
|
|
const extraFiles = await this.getWorkspacePythonSources();
|
|
for (const tab of this.openTabs) {
|
|
if (
|
|
tab.path &&
|
|
tab.path.toLowerCase().endsWith('.py') &&
|
|
!this.isBrowserLocalPath(tab.path)
|
|
) {
|
|
extraFiles[tab.path] = tab.content;
|
|
}
|
|
}
|
|
const data = await this.callPyWorker('diagnostics', {
|
|
path: this.currentFilePath,
|
|
content: this.editor.state.doc.toString(),
|
|
extra_files: extraFiles
|
|
});
|
|
if (requestId !== this.diagnosticsRequestId) return;
|
|
const diagnostics = Array.isArray(data.diagnostics) ? data.diagnostics : [];
|
|
if (diagnostics.length === 0) {
|
|
this.setLspStatus('LSP: OK', 'No syntax errors');
|
|
} else {
|
|
const first = diagnostics[0];
|
|
const msg = `${first.message || 'Syntax error'} (line ${first.line ?? '?'})`;
|
|
this.setLspStatus(`LSP: ${diagnostics.length} issue(s)`, msg);
|
|
}
|
|
} catch (_error) {
|
|
this.setLspStatus('LSP: unavailable', 'Diagnostics failed');
|
|
}
|
|
}
|
|
|
|
scheduleAutoCompletion() {
|
|
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
|
|
return;
|
|
}
|
|
const cursor = this.editor.state.selection.main.head;
|
|
const doc = this.editor.state.doc.toString();
|
|
const prev = cursor > 0 ? doc[cursor - 1] : '';
|
|
if (!(prev === '.' || /[A-Za-z0-9_]/.test(prev))) {
|
|
return;
|
|
}
|
|
if (this.autoCompleteTimer) {
|
|
clearTimeout(this.autoCompleteTimer);
|
|
}
|
|
this.autoCompleteTimer = setTimeout(() => {
|
|
this.showCompletionDropdown();
|
|
}, 140);
|
|
}
|
|
|
|
setLanguageForPath(path) {
|
|
const ext = (path || '').toLowerCase();
|
|
const language = ext.endsWith('.py') ? python() : [];
|
|
this.editor.dispatch({
|
|
effects: this.languageCompartment.reconfigure(language),
|
|
});
|
|
}
|
|
|
|
getCursorLineAndColumn() {
|
|
const cursor = this.editor.state.selection.main.head;
|
|
const lineInfo = this.editor.state.doc.lineAt(cursor);
|
|
return {
|
|
line: lineInfo.number,
|
|
column: cursor - lineInfo.from
|
|
};
|
|
}
|
|
|
|
getCurrentPrefix(cursor) {
|
|
const content = this.editor.state.doc.toString();
|
|
let start = cursor;
|
|
while (start > 0 && /[A-Za-z0-9_]/.test(content[start - 1])) {
|
|
start -= 1;
|
|
}
|
|
return {
|
|
prefixStart: start,
|
|
prefix: content.slice(start, cursor)
|
|
};
|
|
}
|
|
|
|
async fetchCompletions() {
|
|
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
|
|
return [];
|
|
}
|
|
|
|
const { line, column } = this.getCursorLineAndColumn();
|
|
const requestId = ++this.completionRequestId;
|
|
|
|
try {
|
|
await this.ensurePyodideReady();
|
|
const extraFiles = await this.getWorkspacePythonSources();
|
|
for (const tab of this.openTabs) {
|
|
if (
|
|
tab.path &&
|
|
tab.path.toLowerCase().endsWith('.py') &&
|
|
!this.isBrowserLocalPath(tab.path)
|
|
) {
|
|
extraFiles[tab.path] = tab.content;
|
|
}
|
|
}
|
|
const data = await this.callPyWorker('complete', {
|
|
path: this.currentFilePath,
|
|
content: this.editor.state.doc.toString(),
|
|
line,
|
|
column,
|
|
max_results: 20,
|
|
extra_files: extraFiles
|
|
});
|
|
if (requestId !== this.completionRequestId) {
|
|
return [];
|
|
}
|
|
return Array.isArray(data.completions) ? data.completions : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
renderCompletionDropdown() {
|
|
const dropdown = document.getElementById('completion-dropdown');
|
|
if (!dropdown) return;
|
|
|
|
dropdown.innerHTML = this.completionItems.map((item, index) => {
|
|
const activeClass = index === this.completionIndex ? 'active' : '';
|
|
const itemType = item.type || 'value';
|
|
return `
|
|
<div class="completion-item ${activeClass}" data-index="${index}">
|
|
<span class="completion-name">${item.name}</span>
|
|
<span class="completion-type">${itemType}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
dropdown.querySelectorAll('.completion-item').forEach((node) => {
|
|
node.addEventListener('mousedown', (event) => {
|
|
event.preventDefault();
|
|
this.completionIndex = Number(node.dataset.index || 0);
|
|
this.applySelectedCompletion();
|
|
});
|
|
});
|
|
}
|
|
|
|
positionCompletionDropdown() {
|
|
const dropdown = document.getElementById('completion-dropdown');
|
|
if (!dropdown) return;
|
|
const cursor = this.editor.state.selection.main.head;
|
|
const coords = this.editor.coordsAtPos(cursor);
|
|
const editorRect = this.editor.dom.getBoundingClientRect();
|
|
if (!coords || !editorRect) return;
|
|
dropdown.style.top = `${coords.bottom - editorRect.top + 4}px`;
|
|
dropdown.style.left = `${coords.left - editorRect.left}px`;
|
|
}
|
|
|
|
moveCompletionSelection(direction) {
|
|
if (!this.completionOpen || this.completionItems.length === 0) return;
|
|
const total = this.completionItems.length;
|
|
this.completionIndex = (this.completionIndex + direction + total) % total;
|
|
this.renderCompletionDropdown();
|
|
}
|
|
|
|
hideCompletionDropdown() {
|
|
const dropdown = document.getElementById('completion-dropdown');
|
|
if (dropdown) {
|
|
dropdown.style.display = 'none';
|
|
dropdown.innerHTML = '';
|
|
}
|
|
this.completionOpen = false;
|
|
this.completionItems = [];
|
|
this.completionIndex = 0;
|
|
}
|
|
|
|
async showCompletionDropdown() {
|
|
const dropdown = document.getElementById('completion-dropdown');
|
|
if (!dropdown) return;
|
|
|
|
const completions = await this.fetchCompletions();
|
|
if (completions.length === 0) {
|
|
this.hideCompletionDropdown();
|
|
return;
|
|
}
|
|
|
|
const cursor = this.editor.state.selection.main.head;
|
|
const { prefix } = this.getCurrentPrefix(cursor);
|
|
const ranked = completions
|
|
.filter((item) => item && typeof item.name === 'string')
|
|
.sort((a, b) => {
|
|
const aStarts = prefix && a.name.startsWith(prefix) ? 1 : 0;
|
|
const bStarts = prefix && b.name.startsWith(prefix) ? 1 : 0;
|
|
return bStarts - aStarts;
|
|
});
|
|
|
|
if (ranked.length === 0) {
|
|
this.hideCompletionDropdown();
|
|
return;
|
|
}
|
|
|
|
this.completionItems = ranked;
|
|
this.completionIndex = 0;
|
|
this.completionOpen = true;
|
|
this.renderCompletionDropdown();
|
|
this.positionCompletionDropdown();
|
|
dropdown.style.display = 'block';
|
|
}
|
|
|
|
applySelectedCompletion() {
|
|
if (!this.completionOpen || this.completionItems.length === 0) return;
|
|
const selected = this.completionItems[this.completionIndex];
|
|
if (!selected || typeof selected.name !== 'string') return;
|
|
|
|
const cursor = this.editor.state.selection.main.head;
|
|
const { prefixStart } = this.getCurrentPrefix(cursor);
|
|
const isCallable = selected.type === 'function' || selected.type === 'method';
|
|
const insertText = isCallable ? `${selected.name}()` : selected.name;
|
|
const cursorOffset = isCallable ? selected.name.length + 1 : insertText.length;
|
|
this.editor.dispatch({
|
|
changes: {
|
|
from: prefixStart,
|
|
to: cursor,
|
|
insert: insertText
|
|
},
|
|
selection: { anchor: prefixStart + cursorOffset }
|
|
});
|
|
this.hideCompletionDropdown();
|
|
}
|
|
|
|
isMobileViewport() {
|
|
return typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
|
}
|
|
|
|
setMobileSidebarOpen(open) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const backdrop = document.getElementById('sidebar-backdrop');
|
|
const toggle = document.getElementById('sidebar-toggle');
|
|
if (!sidebar || !backdrop || !toggle) return;
|
|
sidebar.classList.toggle('is-open', open);
|
|
backdrop.classList.toggle('is-open', open);
|
|
backdrop.hidden = !open;
|
|
toggle.setAttribute('aria-expanded', String(open));
|
|
}
|
|
|
|
setDesktopSidebarCollapsed(collapsed) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const toggle = document.getElementById('sidebar-toggle');
|
|
if (!sidebar || !toggle) return;
|
|
sidebar.classList.toggle('is-collapsed', collapsed);
|
|
toggle.setAttribute('aria-expanded', String(!collapsed));
|
|
this.saveSessionState();
|
|
}
|
|
|
|
setupSidebarToggle() {
|
|
const toggle = document.getElementById('sidebar-toggle');
|
|
const backdrop = document.getElementById('sidebar-backdrop');
|
|
if (!toggle) return;
|
|
/* Initial state: mobile drawer starts closed; desktop sidebar starts open. */
|
|
if (this.isMobileViewport()) {
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
} else {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const collapsed = Boolean(sidebar && sidebar.classList.contains('is-collapsed'));
|
|
toggle.setAttribute('aria-expanded', String(!collapsed));
|
|
}
|
|
toggle.addEventListener('click', () => {
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (!sidebar) return;
|
|
if (this.isMobileViewport()) {
|
|
const isOpen = sidebar.classList.contains('is-open');
|
|
this.setMobileSidebarOpen(!isOpen);
|
|
} else {
|
|
const collapsed = sidebar.classList.contains('is-collapsed');
|
|
this.setDesktopSidebarCollapsed(!collapsed);
|
|
}
|
|
});
|
|
if (backdrop) {
|
|
backdrop.addEventListener('click', () => this.setMobileSidebarOpen(false));
|
|
}
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') {
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (sidebar?.classList.contains('is-open')) {
|
|
this.setMobileSidebarOpen(false);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
setConsoleCollapsed(collapsed) {
|
|
const container = document.getElementById('console-container');
|
|
const button = document.getElementById('console-toggle');
|
|
if (!container || !button) return;
|
|
container.classList.toggle('is-collapsed', collapsed);
|
|
button.setAttribute('aria-expanded', String(!collapsed));
|
|
this.saveSessionState();
|
|
}
|
|
|
|
setupConsoleToggle() {
|
|
const button = document.getElementById('console-toggle');
|
|
if (!button) return;
|
|
button.addEventListener('click', () => {
|
|
const container = document.getElementById('console-container');
|
|
const collapsed = container?.classList.contains('is-collapsed');
|
|
this.setConsoleCollapsed(!collapsed);
|
|
});
|
|
}
|
|
|
|
setupHeaderMenu() {
|
|
const menu = document.getElementById('header-menu');
|
|
if (!menu) return;
|
|
/* Close the dropdown when the user taps anywhere outside it. We listen
|
|
* on both `pointerdown` (fires reliably for touch) and `click` (mouse
|
|
* users + keyboard activations) so the menu can't end up floating over
|
|
* the Pin / ADC / Serial panels on mobile. */
|
|
const closeIfOutside = (event) => {
|
|
if (!menu.open) return;
|
|
const target = event.target;
|
|
if (target instanceof Node && menu.contains(target)) return;
|
|
menu.open = false;
|
|
};
|
|
document.addEventListener('pointerdown', closeIfOutside);
|
|
document.addEventListener('click', closeIfOutside);
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape' && menu.open) {
|
|
menu.open = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
setupEventListeners() {
|
|
this.setupSidebarToggle();
|
|
this.setupConsoleToggle();
|
|
this.applyPersistedPanelState();
|
|
const persistSession = () => {
|
|
this.saveSessionState();
|
|
};
|
|
window.addEventListener('beforeunload', persistSession);
|
|
window.addEventListener('pagehide', persistSession);
|
|
|
|
/* Stop button inside the 16x16 popup posts back so mobile users (where the popup
|
|
takes over the screen) can halt without returning to the editor. */
|
|
window.addEventListener('message', (event) => {
|
|
const data = event && event.data;
|
|
if (data && data.type === 'neopixel-popup-stop') {
|
|
this.stopPython();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('keydown', (event) => {
|
|
if (!this.completionOpen) return;
|
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.applySelectedCompletion();
|
|
}
|
|
}, true);
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (this.completionOpen) {
|
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
event.preventDefault();
|
|
this.applySelectedCompletion();
|
|
return;
|
|
}
|
|
}
|
|
if (event.key === 'F5') {
|
|
event.preventDefault();
|
|
this.runPython();
|
|
return;
|
|
}
|
|
const isMod = event.ctrlKey || event.metaKey;
|
|
if (!isMod) return;
|
|
const key = event.key.toLowerCase();
|
|
if (event.code === 'Space') {
|
|
event.preventDefault();
|
|
this.showCompletionDropdown();
|
|
return;
|
|
}
|
|
if (key === 's') {
|
|
event.preventDefault();
|
|
this.saveFile();
|
|
return;
|
|
}
|
|
if (key === 'z') {
|
|
event.preventDefault();
|
|
this.performUndo();
|
|
return;
|
|
}
|
|
if (key === 'y') {
|
|
event.preventDefault();
|
|
this.performRedo();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mousedown', (event) => {
|
|
if (!this.completionOpen) return;
|
|
const dropdown = document.getElementById('completion-dropdown');
|
|
if (!dropdown) return;
|
|
if (!dropdown.contains(event.target)) {
|
|
this.hideCompletionDropdown();
|
|
}
|
|
});
|
|
|
|
document.getElementById('refresh-btn').addEventListener('click', () => {
|
|
this.refreshFileTreePreservingExpansion();
|
|
});
|
|
|
|
document.getElementById('new-file-btn').addEventListener('click', () => {
|
|
this.showNewFileModal();
|
|
});
|
|
|
|
document.getElementById('new-folder-btn').addEventListener('click', () => {
|
|
this.createNewFolder();
|
|
});
|
|
|
|
document.getElementById('delete-selected-btn').addEventListener('click', () => {
|
|
this.deleteSelected();
|
|
});
|
|
|
|
document.getElementById('run-btn').addEventListener('click', () => {
|
|
this.runPython();
|
|
});
|
|
|
|
document.getElementById('stop-btn').addEventListener('click', () => {
|
|
this.stopPython();
|
|
});
|
|
|
|
const ledRunBtn = document.getElementById('led-run-btn');
|
|
if (ledRunBtn) {
|
|
ledRunBtn.addEventListener('click', () => {
|
|
this.runPython();
|
|
});
|
|
}
|
|
const ledStopBtn = document.getElementById('led-stop-btn');
|
|
if (ledStopBtn) {
|
|
ledStopBtn.addEventListener('click', () => {
|
|
this.stopPython();
|
|
});
|
|
}
|
|
const ledCloseBtn = document.getElementById('led-close-btn');
|
|
if (ledCloseBtn) {
|
|
ledCloseBtn.addEventListener('click', () => {
|
|
this.ledPanelDismissed = true;
|
|
const panel = document.getElementById('led-sim-panel');
|
|
if (panel) panel.classList.add('hidden');
|
|
});
|
|
}
|
|
|
|
document.getElementById('create-file-btn').addEventListener('click', () => {
|
|
this.createNewFile();
|
|
});
|
|
|
|
document.getElementById('cancel-create-btn').addEventListener('click', () => {
|
|
this.hideNewFileModal();
|
|
});
|
|
|
|
document.getElementById('new-filename').addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
this.createNewFile();
|
|
}
|
|
});
|
|
|
|
document.getElementById('new-file-modal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'new-file-modal') {
|
|
this.hideNewFileModal();
|
|
}
|
|
});
|
|
|
|
const packagesMenuBtn = document.getElementById('packages-menu-btn');
|
|
if (packagesMenuBtn) {
|
|
packagesMenuBtn.addEventListener('click', () => {
|
|
const headerMenu = document.getElementById('header-menu');
|
|
if (headerMenu) {
|
|
headerMenu.open = false;
|
|
}
|
|
this.showPackagesModal();
|
|
});
|
|
}
|
|
const menuOpenLocalFolderBtn = document.getElementById('menu-open-local-folder-btn');
|
|
if (menuOpenLocalFolderBtn) {
|
|
menuOpenLocalFolderBtn.addEventListener('click', async (e) => {
|
|
/* Keep the user activation for showDirectoryPicker: do not close <details> first. */
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
await this.pickBrowserLocalRootDirectory();
|
|
} finally {
|
|
const headerMenu = document.getElementById('header-menu');
|
|
if (headerMenu) {
|
|
headerMenu.open = false;
|
|
}
|
|
this.renderFileTree();
|
|
}
|
|
});
|
|
}
|
|
const menuOpenLocalFileBtn = document.getElementById('menu-open-local-file-btn');
|
|
if (menuOpenLocalFileBtn) {
|
|
menuOpenLocalFileBtn.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
await this.pickBrowserLocalSingleFile();
|
|
} finally {
|
|
const headerMenu = document.getElementById('header-menu');
|
|
if (headerMenu) {
|
|
headerMenu.open = false;
|
|
}
|
|
this.renderFileTree();
|
|
}
|
|
});
|
|
}
|
|
const packagesCloseBtn = document.getElementById('packages-close-btn');
|
|
if (packagesCloseBtn) {
|
|
packagesCloseBtn.addEventListener('click', () => {
|
|
this.hidePackagesModal();
|
|
});
|
|
}
|
|
const packagesInstallBtn = document.getElementById('packages-install-btn');
|
|
if (packagesInstallBtn) {
|
|
packagesInstallBtn.addEventListener('click', () => {
|
|
this.installPackagesFromModal();
|
|
});
|
|
}
|
|
const packagesLookupBtn = document.getElementById('packages-lookup-btn');
|
|
if (packagesLookupBtn) {
|
|
packagesLookupBtn.addEventListener('click', () => {
|
|
this.lookupPackageOnPyPI();
|
|
});
|
|
}
|
|
const packagesInstallInput = document.getElementById('packages-install-input');
|
|
if (packagesInstallInput) {
|
|
packagesInstallInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
this.installPackagesFromModal();
|
|
}
|
|
});
|
|
}
|
|
const packagesModal = document.getElementById('packages-modal');
|
|
if (packagesModal) {
|
|
packagesModal.addEventListener('click', (e) => {
|
|
if (e.target.id === 'packages-modal') {
|
|
this.hidePackagesModal();
|
|
}
|
|
});
|
|
}
|
|
const packagesSavedList = document.getElementById('packages-saved-list');
|
|
if (packagesSavedList) {
|
|
packagesSavedList.addEventListener('click', (e) => {
|
|
const t = e.target.closest('.packages-remove-btn');
|
|
if (!t || !packagesSavedList.contains(t)) {
|
|
return;
|
|
}
|
|
const specEnc = t.getAttribute('data-spec');
|
|
if (specEnc) {
|
|
this.removePersistedPackage(specEnc);
|
|
}
|
|
});
|
|
}
|
|
|
|
this.setupHeaderMenu();
|
|
this.setupFileTreeContextMenu();
|
|
|
|
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
|
if (runMainCheckbox) {
|
|
runMainCheckbox.addEventListener('change', () => {
|
|
this.saveSessionState();
|
|
this.updateRunButtonState();
|
|
});
|
|
}
|
|
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
|
|
if (panelModeCheckbox) {
|
|
panelModeCheckbox.addEventListener('change', () => {
|
|
this.saveSessionState();
|
|
if (!panelModeCheckbox.checked && this.ledPanelWindow && !this.ledPanelWindow.closed) {
|
|
this.ledPanelWindow.close();
|
|
this.ledPanelWindow = null;
|
|
}
|
|
if (this.lastLedFrame) {
|
|
this.renderLedSimulation(this.lastLedFrame);
|
|
}
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
performUndo() {
|
|
document.execCommand('undo');
|
|
}
|
|
|
|
performRedo() {
|
|
document.execCommand('redo');
|
|
}
|
|
|
|
renderTabs() {
|
|
const tabsContainer = document.getElementById('tabs');
|
|
if (this.openTabs.length === 0) {
|
|
tabsContainer.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
tabsContainer.innerHTML = this.openTabs.map((tab) => {
|
|
const activeClass = tab.path === this.activeTabPath ? 'active' : '';
|
|
const modifiedMark = tab.isModified ? '*' : '';
|
|
let displayPath = tab.path;
|
|
if (tab.path.startsWith('code/')) {
|
|
displayPath = tab.path.slice('code/'.length);
|
|
} else if (tab.path.startsWith('demos/')) {
|
|
displayPath = tab.path;
|
|
} else if (this._browserLocalFolderMount && tab.path.startsWith(`${this._browserLocalFolderMount}/`)) {
|
|
displayPath = tab.path.slice(this._browserLocalFolderMount.length + 1);
|
|
} else if (tab.path.startsWith(BROWSER_LOCAL_LEGACY_PREFIX)) {
|
|
displayPath = tab.path.slice(BROWSER_LOCAL_LEGACY_PREFIX.length);
|
|
}
|
|
return `
|
|
<div class="tab ${activeClass}" data-path="${tab.path}">
|
|
<span class="tab-title">${displayPath}${modifiedMark}</span>
|
|
<button class="tab-close" data-path="${tab.path}" title="Close tab">x</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
tabsContainer.querySelectorAll('.tab').forEach((tabElement) => {
|
|
tabElement.addEventListener('click', (event) => {
|
|
if (event.target.classList.contains('tab-close')) {
|
|
return;
|
|
}
|
|
this.switchToTab(tabElement.dataset.path);
|
|
});
|
|
});
|
|
|
|
tabsContainer.querySelectorAll('.tab-close').forEach((closeButton) => {
|
|
closeButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
this.closeTab(closeButton.dataset.path);
|
|
});
|
|
});
|
|
this.saveSessionState();
|
|
}
|
|
|
|
findTab(path) {
|
|
return this.openTabs.find((tab) => tab.path === path);
|
|
}
|
|
|
|
isReadOnlyPath(path) {
|
|
if (typeof path !== 'string') return false;
|
|
if (path.startsWith('lib/')) return true;
|
|
return this.isBrowserLocalFilePath(path);
|
|
}
|
|
|
|
setEditorReadOnly(isReadOnly) {
|
|
this.currentFileReadOnly = Boolean(isReadOnly);
|
|
if (!this.editor || !this.editor.contentDOM) return;
|
|
this.editor.contentDOM.setAttribute('contenteditable', this.currentFileReadOnly ? 'false' : 'true');
|
|
}
|
|
|
|
updateActiveTabContent() {
|
|
if (!this.activeTabPath) return;
|
|
const tab = this.findTab(this.activeTabPath);
|
|
if (!tab) return;
|
|
tab.content = this.editor.state.doc.toString();
|
|
tab.isModified = true;
|
|
this.renderTabs();
|
|
}
|
|
|
|
switchToTab(path) {
|
|
this.hideCompletionDropdown();
|
|
const tab = this.findTab(path);
|
|
if (!tab) return;
|
|
this.activeTabPath = path;
|
|
this.currentFilePath = path;
|
|
this.ignoreNextChange = true;
|
|
this.editor.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: this.editor.state.doc.length,
|
|
insert: tab.content
|
|
}
|
|
});
|
|
this.ignoreNextChange = false;
|
|
this.setLanguageForPath(path);
|
|
const currentFileEl = document.getElementById('current-file');
|
|
if (currentFileEl) currentFileEl.textContent = path;
|
|
this.setEditorReadOnly(this.isReadOnlyPath(path));
|
|
this.updateRunButtonState();
|
|
this.scheduleDiagnostics();
|
|
if (tab.isModified) {
|
|
this.markAsModified();
|
|
} else {
|
|
this.markAsSaved();
|
|
}
|
|
this.renderTabs();
|
|
this.selectedPath = path;
|
|
this.selectedIsDirectory = false;
|
|
this.renderFileTree();
|
|
}
|
|
|
|
closeTab(path) {
|
|
this.hideCompletionDropdown();
|
|
const index = this.openTabs.findIndex((tab) => tab.path === path);
|
|
if (index === -1) return;
|
|
|
|
const tab = this.openTabs[index];
|
|
if (tab.isModified && !confirm(`Close "${path}" with unsaved changes?`)) {
|
|
return;
|
|
}
|
|
|
|
this.openTabs.splice(index, 1);
|
|
|
|
if (this.activeTabPath === path) {
|
|
const nextTab = this.openTabs[index] || this.openTabs[index - 1];
|
|
if (nextTab) {
|
|
this.switchToTab(nextTab.path);
|
|
} else {
|
|
this.activeTabPath = null;
|
|
this.currentFilePath = null;
|
|
this.setEditorReadOnly(false);
|
|
this.setLanguageForPath('');
|
|
this.ignoreNextChange = true;
|
|
this.editor.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: this.editor.state.doc.length,
|
|
insert: ''
|
|
}
|
|
});
|
|
this.ignoreNextChange = false;
|
|
const currentFileEl = document.getElementById('current-file');
|
|
if (currentFileEl) currentFileEl.textContent = 'No file selected';
|
|
this.updateRunButtonState();
|
|
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
|
this.markAsSaved();
|
|
this.selectedPath = null;
|
|
this.selectedIsDirectory = false;
|
|
this.renderFileTree();
|
|
}
|
|
}
|
|
|
|
this.renderTabs();
|
|
this.saveSessionState();
|
|
}
|
|
|
|
async loadDirectory(path = "", options = {}) {
|
|
if (this.isBrowserLocalPath(path)) {
|
|
return this.loadBrowserLocalDirectory(path, options);
|
|
}
|
|
const { suppressError = false, suppressRender = false } = options;
|
|
try {
|
|
const response = await this.apiFetch(`/api/files?path=${encodeURIComponent(path)}`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load directory: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
this.directoryCache.set(path, data.files || []);
|
|
if (!suppressRender) this.renderFileTree();
|
|
return true;
|
|
} catch (error) {
|
|
if (!suppressError) {
|
|
console.error('Error loading file tree:', error);
|
|
this.showError('Failed to load files');
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
renderFileTree() {
|
|
const fileTreeElement = document.getElementById('file-tree');
|
|
const treeRootPath = this.getTreeRootPath();
|
|
let rootFiles = this.directoryCache.get(treeRootPath) || [];
|
|
if (!treeRootPath) {
|
|
const visibleNames = this.getVisibleTopLevelNames();
|
|
if (visibleNames) {
|
|
rootFiles = rootFiles.filter((file) => visibleNames.has(file.name));
|
|
}
|
|
if (this._browserLocalFolderMount && this._browserLocalRootHandle) {
|
|
rootFiles = [...rootFiles, { name: this._browserLocalFolderMount, is_directory: true }];
|
|
}
|
|
}
|
|
|
|
if (rootFiles.length === 0) {
|
|
fileTreeElement.innerHTML = `<div class="loading">No files found</div>`;
|
|
} else {
|
|
const initialDepth = treeRootPath ? 1 : 0;
|
|
fileTreeElement.innerHTML = this.renderDirectoryHtml(treeRootPath, initialDepth, rootFiles);
|
|
}
|
|
|
|
fileTreeElement.querySelectorAll('.file-item').forEach((item) => {
|
|
item.addEventListener('click', async () => {
|
|
const path = item.dataset.path;
|
|
const isDirectory = item.dataset.isDirectory === 'true';
|
|
this.selectedPath = path;
|
|
this.selectedIsDirectory = isDirectory;
|
|
|
|
if (isDirectory && path) {
|
|
await this.toggleDirectory(path);
|
|
} else if (!isDirectory) {
|
|
const changePath = this.browserLocalChangePath();
|
|
if (changePath && path === changePath) {
|
|
await this.changeBrowserLocalFolder();
|
|
this.renderFileTree();
|
|
return;
|
|
}
|
|
await this.openFile(path);
|
|
this.renderFileTree();
|
|
if (this.isMobileViewport()) {
|
|
this.setMobileSidebarOpen(false);
|
|
}
|
|
} else {
|
|
this.renderFileTree();
|
|
}
|
|
});
|
|
});
|
|
|
|
fileTreeElement.querySelectorAll('.file-item').forEach((item) => {
|
|
const p = item.dataset.path || '';
|
|
const noDrag = item.dataset.noDrag === 'true' || this.isBrowserLocalPath(p);
|
|
item.setAttribute('draggable', noDrag ? 'false' : 'true');
|
|
item.addEventListener('dragstart', () => {
|
|
if (noDrag) return;
|
|
this.draggedItemPath = item.dataset.path || null;
|
|
this.draggedItemIsDirectory = item.dataset.isDirectory === 'true';
|
|
item.classList.add('dragging-file');
|
|
});
|
|
item.addEventListener('dragend', () => {
|
|
this.clearDragHoverExpand();
|
|
this.draggedItemPath = null;
|
|
this.draggedItemIsDirectory = false;
|
|
item.classList.remove('dragging-file');
|
|
fileTreeElement.querySelectorAll('.file-item.drag-target').forEach((node) => {
|
|
node.classList.remove('drag-target');
|
|
});
|
|
});
|
|
});
|
|
|
|
fileTreeElement.querySelectorAll('.file-item[data-is-directory="true"]').forEach((item) => {
|
|
item.addEventListener('dragover', (event) => {
|
|
if (!this.draggedItemPath) return;
|
|
event.preventDefault();
|
|
item.classList.add('drag-target');
|
|
const targetPath = item.dataset.path || "";
|
|
this.scheduleDragHoverExpand(targetPath);
|
|
});
|
|
item.addEventListener('dragleave', () => {
|
|
item.classList.remove('drag-target');
|
|
this.clearDragHoverExpand();
|
|
});
|
|
item.addEventListener('drop', async (event) => {
|
|
event.preventDefault();
|
|
item.classList.remove('drag-target');
|
|
this.clearDragHoverExpand();
|
|
const destination = item.dataset.path || "";
|
|
await this.movePathToFolder(this.draggedItemPath, this.draggedItemIsDirectory, destination);
|
|
});
|
|
});
|
|
|
|
fileTreeElement.addEventListener('dragover', (event) => {
|
|
if (!this.draggedItemPath) return;
|
|
const targetItem = event.target.closest('.file-item');
|
|
if (!targetItem) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
fileTreeElement.addEventListener('drop', async (event) => {
|
|
const targetItem = event.target.closest('.file-item');
|
|
if (targetItem) return;
|
|
if (!this.draggedItemPath) return;
|
|
event.preventDefault();
|
|
this.clearDragHoverExpand();
|
|
const dropRoot = treeRootPath || this.getDefaultEditableRoot();
|
|
await this.movePathToFolder(this.draggedItemPath, this.draggedItemIsDirectory, dropRoot);
|
|
});
|
|
}
|
|
|
|
clearDragHoverExpand() {
|
|
if (this.dragHoverExpandTimer) {
|
|
clearTimeout(this.dragHoverExpandTimer);
|
|
this.dragHoverExpandTimer = null;
|
|
}
|
|
this.dragHoverTargetPath = null;
|
|
}
|
|
|
|
scheduleDragHoverExpand(targetPath) {
|
|
if (!targetPath || this.expandedDirs.has(targetPath)) {
|
|
this.clearDragHoverExpand();
|
|
return;
|
|
}
|
|
if (this.dragHoverTargetPath === targetPath && this.dragHoverExpandTimer) {
|
|
return;
|
|
}
|
|
this.clearDragHoverExpand();
|
|
this.dragHoverTargetPath = targetPath;
|
|
this.dragHoverExpandTimer = setTimeout(async () => {
|
|
if (!this.draggedItemPath || this.dragHoverTargetPath !== targetPath) return;
|
|
this.expandedDirs.add(targetPath);
|
|
if (!this.directoryCache.has(targetPath)) {
|
|
await this.loadDirectory(targetPath);
|
|
} else {
|
|
this.renderFileTree();
|
|
}
|
|
this.clearDragHoverExpand();
|
|
}, 500);
|
|
}
|
|
|
|
renderDirectoryHtml(basePath, depth, overrideFiles = null) {
|
|
const files = overrideFiles || this.directoryCache.get(basePath) || [];
|
|
let html = "";
|
|
|
|
for (const file of files) {
|
|
const fullPath = file.virtualPath || (basePath ? `${basePath}/${file.name}` : file.name);
|
|
const isDir = file.is_directory;
|
|
const selectedClass = this.selectedPath === fullPath ? "selected" : "";
|
|
const indent = depth * 16;
|
|
/* The top-level "code" folder is the user's editable root; show it as "/" while keeping the underlying path "code" for the API. */
|
|
let displayName = file.name;
|
|
if (depth === 0 && !basePath && file.is_directory && file.name === 'code') {
|
|
displayName = '/';
|
|
}
|
|
|
|
if (isDir) {
|
|
const expanded = this.expandedDirs.has(fullPath);
|
|
html += `
|
|
<div class="file-item directory ${selectedClass}" data-path="${fullPath}" data-is-directory="true" style="padding-left: ${8 + indent}px">
|
|
<span class="file-icon">${expanded ? "📂" : "📁"}</span>
|
|
<span class="file-name">${displayName}</span>
|
|
</div>
|
|
`;
|
|
if (expanded) {
|
|
html += this.renderDirectoryHtml(fullPath, depth + 1);
|
|
}
|
|
} else {
|
|
const size = file.size ? ` (${this.formatFileSize(file.size)})` : '';
|
|
html += `
|
|
<div class="file-item ${selectedClass}" data-path="${fullPath}" data-is-directory="false" style="padding-left: ${8 + indent}px">
|
|
<span class="file-icon">${this.getFileIcon(file.name)}</span>
|
|
<span class="file-name">${displayName}</span>
|
|
<span class="file-size">${size}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
async toggleDirectory(path) {
|
|
if (this.expandedDirs.has(path)) {
|
|
this.expandedDirs.delete(path);
|
|
this.renderFileTree();
|
|
return;
|
|
}
|
|
|
|
this.expandedDirs.add(path);
|
|
if (!this.directoryCache.has(path)) {
|
|
await this.loadDirectory(path);
|
|
return;
|
|
}
|
|
this.renderFileTree();
|
|
}
|
|
|
|
getFileIcon(filename) {
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
const icons = {
|
|
'txt': '📄',
|
|
'md': '📝',
|
|
'py': '🐍',
|
|
'js': '📜',
|
|
'html': '🌐',
|
|
'css': '🎨',
|
|
'json': '📋',
|
|
'yaml': '⚙️',
|
|
'yml': '⚙️',
|
|
'xml': '📄',
|
|
'csv': '📊'
|
|
};
|
|
return icons[ext] || '📄';
|
|
}
|
|
|
|
formatFileSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
async openFile(filePath) {
|
|
this.hideCompletionDropdown();
|
|
try {
|
|
const existingTab = this.findTab(filePath);
|
|
if (existingTab) {
|
|
this.switchToTab(filePath);
|
|
return;
|
|
}
|
|
|
|
if (this.isBrowserLocalFilePath(filePath)) {
|
|
const handle = this._browserLocalHandles.get(filePath);
|
|
if (!handle || handle.kind !== 'file') {
|
|
this.showError(
|
|
'That local file is no longer available. Use ⋮ → Open local folder… again, or re-open the file.',
|
|
);
|
|
return;
|
|
}
|
|
const file = await handle.getFile();
|
|
const maxBytes = 4 * 1024 * 1024;
|
|
if (typeof file.size === 'number' && file.size > maxBytes) {
|
|
this.showError('That file is larger than 4MB; open a smaller file in the browser editor.');
|
|
return;
|
|
}
|
|
let content = '';
|
|
try {
|
|
content = await file.text();
|
|
} catch (readErr) {
|
|
const hint = this.formatBrowserLocalError(readErr);
|
|
this.showError(hint || 'Could not read that file (it may be binary or no longer permitted).');
|
|
return;
|
|
}
|
|
this.openTabs.push({
|
|
path: filePath,
|
|
content,
|
|
isModified: false,
|
|
});
|
|
this.activeTabPath = filePath;
|
|
this.currentFilePath = filePath;
|
|
this.ignoreNextChange = true;
|
|
this.editor.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: this.editor.state.doc.length,
|
|
insert: content,
|
|
},
|
|
});
|
|
this.ignoreNextChange = false;
|
|
this.setLanguageForPath(filePath);
|
|
this.markAsSaved();
|
|
this.setEditorReadOnly(this.isReadOnlyPath(filePath));
|
|
const currentFileEl = document.getElementById('current-file');
|
|
if (currentFileEl) currentFileEl.textContent = filePath;
|
|
this.updateRunButtonState();
|
|
this.scheduleDiagnostics();
|
|
this.renderTabs();
|
|
this.saveSessionState();
|
|
return;
|
|
}
|
|
|
|
const response = await this.apiFetch(`/api/file/${this.encodeApiFilePath(filePath)}`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to open file: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
|
|
this.openTabs.push({
|
|
path: filePath,
|
|
content: data.content,
|
|
isModified: false
|
|
});
|
|
this.activeTabPath = filePath;
|
|
this.currentFilePath = filePath;
|
|
this.ignoreNextChange = true;
|
|
this.editor.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: this.editor.state.doc.length,
|
|
insert: data.content
|
|
}
|
|
});
|
|
this.ignoreNextChange = false;
|
|
this.setLanguageForPath(filePath);
|
|
this.markAsSaved();
|
|
this.setEditorReadOnly(this.isReadOnlyPath(filePath));
|
|
|
|
const currentFileEl = document.getElementById('current-file');
|
|
if (currentFileEl) currentFileEl.textContent = filePath;
|
|
this.updateRunButtonState();
|
|
this.scheduleDiagnostics();
|
|
this.renderTabs();
|
|
this.saveSessionState();
|
|
} catch (error) {
|
|
console.error('Error opening file:', error);
|
|
this.showError('Failed to open file');
|
|
}
|
|
}
|
|
|
|
async saveFile() {
|
|
if (!this.currentFilePath) return;
|
|
if (this.isReadOnlyPath(this.currentFilePath)) {
|
|
this.showError(
|
|
this.isBrowserLocalFilePath(this.currentFilePath)
|
|
? 'Local folder and picked files are read-only in this editor.'
|
|
: 'Files in lib are read-only',
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const content = this.editor.state.doc.toString();
|
|
const response = await this.apiFetch(`/api/file/${this.encodeApiFilePath(this.currentFilePath)}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ content })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to save file: ${response.statusText}`);
|
|
}
|
|
|
|
const tab = this.findTab(this.currentFilePath);
|
|
if (tab) {
|
|
tab.content = content;
|
|
tab.isModified = false;
|
|
}
|
|
this.markAsSaved();
|
|
this.renderTabs();
|
|
this.showSuccess('File saved successfully');
|
|
} catch (error) {
|
|
console.error('Error saving file:', error);
|
|
this.showError('Failed to save file');
|
|
}
|
|
}
|
|
|
|
async deleteFile() {
|
|
if (!this.currentFilePath) return;
|
|
if (this.isBrowserLocalPath(this.currentFilePath)) {
|
|
this.showError('Deleting local disk files from the tree is not supported. Use your file manager.');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Are you sure you want to delete "${this.currentFilePath}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const deletedPath = this.currentFilePath;
|
|
const response = await this.apiFetch(`/api/file/${this.encodeApiFilePath(this.currentFilePath)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to delete file: ${response.statusText}`);
|
|
}
|
|
|
|
this.ignoreNextChange = true;
|
|
this.editor.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: this.editor.state.doc.length,
|
|
insert: ''
|
|
}
|
|
});
|
|
this.ignoreNextChange = false;
|
|
this.openTabs = this.openTabs.filter((tab) => tab.path !== deletedPath);
|
|
|
|
if (this.openTabs.length > 0) {
|
|
this.switchToTab(this.openTabs[this.openTabs.length - 1].path);
|
|
} else {
|
|
this.currentFilePath = null;
|
|
this.activeTabPath = null;
|
|
this.markAsSaved();
|
|
const currentFileEl = document.getElementById('current-file');
|
|
if (currentFileEl) currentFileEl.textContent = 'No file selected';
|
|
this.updateRunButtonState();
|
|
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
|
}
|
|
|
|
await this.refreshFileTreePreservingExpansion();
|
|
this.renderTabs();
|
|
this.showSuccess('File deleted successfully');
|
|
} catch (error) {
|
|
console.error('Error deleting file:', error);
|
|
this.showError('Failed to delete file');
|
|
}
|
|
}
|
|
|
|
closeTabWithoutPrompt(path) {
|
|
this.hideCompletionDropdown();
|
|
this.openTabs = this.openTabs.filter((tab) => tab.path !== path);
|
|
if (this.activeTabPath === path) {
|
|
const nextTab = this.openTabs[this.openTabs.length - 1];
|
|
if (nextTab) {
|
|
this.switchToTab(nextTab.path);
|
|
} else {
|
|
this.activeTabPath = null;
|
|
this.currentFilePath = null;
|
|
this.setLanguageForPath('');
|
|
this.ignoreNextChange = true;
|
|
this.editor.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: this.editor.state.doc.length,
|
|
insert: ''
|
|
}
|
|
});
|
|
this.ignoreNextChange = false;
|
|
this.markAsSaved();
|
|
const currentFileEl = document.getElementById('current-file');
|
|
if (currentFileEl) currentFileEl.textContent = 'No file selected';
|
|
this.updateRunButtonState();
|
|
}
|
|
}
|
|
this.renderTabs();
|
|
}
|
|
|
|
updateRunButtonState() {
|
|
const runButton = document.getElementById('run-btn');
|
|
const stopButton = document.getElementById('stop-btn');
|
|
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
|
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
|
|
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
|
|
const runnablePython =
|
|
hasPythonFile && !this.isBrowserLocalPath(this.currentFilePath);
|
|
const canRun = runMainSelected || runnablePython;
|
|
runButton.disabled = !canRun;
|
|
stopButton.disabled = !this.isPythonRunning;
|
|
this.updateLedWindowControls();
|
|
}
|
|
|
|
clearConsole() {
|
|
const consoleOutput = document.getElementById('console-output');
|
|
consoleOutput.textContent = '';
|
|
this.consolePendingText = '';
|
|
if (this.consoleFlushTimer) {
|
|
clearTimeout(this.consoleFlushTimer);
|
|
this.consoleFlushTimer = null;
|
|
}
|
|
}
|
|
|
|
maybePrepareLedWindow(files) {
|
|
const importsNeoPixel = Object.values(files || {}).some((content) =>
|
|
typeof content === 'string' &&
|
|
/\bimport\s+neopixel\b|\bfrom\s+neopixel\s+import\b/.test(content)
|
|
);
|
|
if (!importsNeoPixel) return;
|
|
this.ledPanelDismissed = false;
|
|
this.ensureLedWindow();
|
|
}
|
|
|
|
ensureLedWindow() {
|
|
const panel = document.getElementById('led-sim-panel');
|
|
if (!panel) return null;
|
|
if (!this.ledPanelDismissed) {
|
|
panel.classList.remove('hidden');
|
|
}
|
|
this.updateLedWindowControls();
|
|
return panel;
|
|
}
|
|
|
|
ensureLedPanelWindow() {
|
|
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
|
|
return this.ledPanelWindow;
|
|
}
|
|
const win = window.open('', 'neopixel-16x16', 'width=760,height=760');
|
|
if (!win) return null;
|
|
win.document.title = 'NeoPixel 16x16 Panel';
|
|
win.document.documentElement.style.height = '100%';
|
|
win.document.body.innerHTML = `
|
|
<style>
|
|
html, body {
|
|
margin: 0;
|
|
height: 100%;
|
|
width: 100%;
|
|
background: #111827;
|
|
color: #e5e7eb;
|
|
font-family: system-ui, sans-serif;
|
|
overflow: hidden;
|
|
}
|
|
body {
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(16, 1fr);
|
|
grid-template-rows: repeat(16, 1fr);
|
|
gap: clamp(2px, 0.5vmin, 8px);
|
|
padding: clamp(4px, 1vmin, 14px);
|
|
box-sizing: border-box;
|
|
width: 100vmin;
|
|
height: 100vmin;
|
|
}
|
|
.led {
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 50%;
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.45);
|
|
}
|
|
/* Match main editor #stop-btn.icon-btn (see styles.css .editor-actions) */
|
|
#led-popup-stop-btn.icon-btn {
|
|
position: fixed;
|
|
top: max(12px, env(safe-area-inset-top));
|
|
right: max(12px, env(safe-area-inset-right));
|
|
z-index: 10;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
padding: 0.5rem 0.75rem;
|
|
min-width: 40px;
|
|
border: 1px solid #e2e8f0;
|
|
background-color: white;
|
|
color: #c53030;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-family: system-ui, sans-serif;
|
|
transition: all 0.2s;
|
|
}
|
|
#led-popup-stop-btn.icon-btn:hover:not(:disabled) {
|
|
background-color: #f7fafc;
|
|
border-color: #cbd5e0;
|
|
}
|
|
#led-popup-stop-btn.icon-btn:active:not(:disabled) {
|
|
background-color: #edf2f7;
|
|
}
|
|
#led-popup-stop-btn.icon-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
</style>
|
|
<div id="grid" class="grid"></div>
|
|
<button id="led-popup-stop-btn" class="icon-btn" type="button" disabled aria-label="Stop" title="Stop">\u25A0</button>
|
|
`;
|
|
this.updateLedWindowControls();
|
|
win.document.getElementById('led-popup-stop-btn')?.addEventListener('click', () => {
|
|
try {
|
|
if (win.opener && !win.opener.closed) {
|
|
win.opener.postMessage({ type: 'neopixel-popup-stop' }, '*');
|
|
}
|
|
} catch (_e) {
|
|
// ignore - parent will handle stop on next interaction
|
|
}
|
|
});
|
|
this.ledPanelWindow = win;
|
|
return win;
|
|
}
|
|
|
|
closeLedPanelWindow() {
|
|
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
|
|
this.ledPanelWindow.close();
|
|
}
|
|
this.ledPanelWindow = null;
|
|
}
|
|
|
|
hideInlineLedPanel() {
|
|
const panel = document.getElementById('led-sim-panel');
|
|
if (panel) panel.classList.add('hidden');
|
|
const grid = document.getElementById('led-grid');
|
|
if (grid) grid.innerHTML = '';
|
|
this.lastLedFrame = null;
|
|
}
|
|
|
|
closeLedSimulator() {
|
|
this.closeLedPanelWindow();
|
|
this.hideInlineLedPanel();
|
|
}
|
|
|
|
ensureAdcSlider(pin) {
|
|
if (!Number.isFinite(pin) || pin < 0 || pin >= this.adcPinCount) {
|
|
return;
|
|
}
|
|
const panel = document.getElementById('adc-panel');
|
|
const list = document.getElementById('adc-sliders');
|
|
if (!panel || !list) return;
|
|
panel.classList.remove('hidden');
|
|
if (this.adcSliders.has(pin)) {
|
|
return;
|
|
}
|
|
this.getAdcSab();
|
|
if (this.adcView) {
|
|
try {
|
|
Atomics.store(this.adcView, pin, this.adcDefaultValue);
|
|
} catch (_err) {
|
|
this.adcView[pin] = this.adcDefaultValue;
|
|
}
|
|
}
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'adc-row';
|
|
row.dataset.pin = String(pin);
|
|
|
|
const label = document.createElement('label');
|
|
label.className = 'adc-row-label';
|
|
const labelId = `adc-slider-${pin}`;
|
|
label.setAttribute('for', labelId);
|
|
label.textContent = `ADC pin ${pin}`;
|
|
|
|
const slider = document.createElement('input');
|
|
slider.type = 'range';
|
|
slider.id = labelId;
|
|
slider.className = 'adc-slider';
|
|
slider.min = '0';
|
|
slider.max = '65535';
|
|
slider.step = '1';
|
|
slider.value = String(this.adcDefaultValue);
|
|
|
|
const readout = document.createElement('span');
|
|
readout.className = 'adc-readout';
|
|
const updateReadout = (raw) => {
|
|
const u16 = Number(raw) | 0;
|
|
const volts = (u16 / 65535) * 3.3;
|
|
readout.textContent = `${u16} (${volts.toFixed(2)} V)`;
|
|
};
|
|
updateReadout(this.adcDefaultValue);
|
|
|
|
slider.addEventListener('input', () => {
|
|
const value = Number(slider.value) | 0;
|
|
if (this.adcView) {
|
|
try {
|
|
Atomics.store(this.adcView, pin, value);
|
|
} catch (_err) {
|
|
this.adcView[pin] = value;
|
|
}
|
|
}
|
|
/* SAB-less fallback: deliver the new slider value directly to the
|
|
Pyodide worker, which keeps its own Int32Array when isolation is
|
|
off. Same idea as the Pin IN button. */
|
|
try {
|
|
if (this.pyWorker) {
|
|
this.pyWorker.postMessage({ type: 'adcSet', payload: { pin, value } });
|
|
}
|
|
} catch (_err) {
|
|
// ignore
|
|
}
|
|
updateReadout(value);
|
|
});
|
|
|
|
row.appendChild(label);
|
|
row.appendChild(slider);
|
|
row.appendChild(readout);
|
|
list.appendChild(row);
|
|
this.adcSliders.set(pin, { row, slider, readout });
|
|
}
|
|
|
|
closeAdcPanel() {
|
|
const panel = document.getElementById('adc-panel');
|
|
const list = document.getElementById('adc-sliders');
|
|
if (panel) panel.classList.add('hidden');
|
|
if (list) list.innerHTML = '';
|
|
this.adcSliders.clear();
|
|
if (this.adcView) {
|
|
for (let i = 0; i < this.adcPinCount; i += 1) {
|
|
try {
|
|
Atomics.store(this.adcView, i, 0);
|
|
} catch (_err) {
|
|
this.adcView[i] = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
openSerialMonitor(meta) {
|
|
const panel = document.getElementById('serial-panel');
|
|
if (!panel) return;
|
|
panel.classList.remove('hidden');
|
|
const metaEl = document.getElementById('serial-meta');
|
|
if (metaEl && meta && Number.isFinite(meta.baudrate)) {
|
|
metaEl.textContent = `UART ${meta.id ?? 0} @ ${meta.baudrate} baud`;
|
|
}
|
|
if (!this.serialPanelOpen) {
|
|
this.serialPanelOpen = true;
|
|
this.bindSerialPanel();
|
|
}
|
|
}
|
|
|
|
closeSerialPanel() {
|
|
const panel = document.getElementById('serial-panel');
|
|
if (panel) panel.classList.add('hidden');
|
|
const out = document.getElementById('serial-output');
|
|
if (out) out.textContent = '';
|
|
if (this.serialIndices) {
|
|
try {
|
|
Atomics.store(this.serialIndices, 0, 0);
|
|
Atomics.store(this.serialIndices, 1, 0);
|
|
} catch (_err) {
|
|
this.serialIndices[0] = 0;
|
|
this.serialIndices[1] = 0;
|
|
}
|
|
}
|
|
this.serialDecoder = null;
|
|
}
|
|
|
|
bindSerialPanel() {
|
|
const form = document.getElementById('serial-form');
|
|
const input = document.getElementById('serial-input');
|
|
const clearBtn = document.getElementById('serial-clear');
|
|
if (form && input && !form.dataset.bound) {
|
|
form.dataset.bound = '1';
|
|
form.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
if (!this.isPythonRunning) return;
|
|
const newlineCheckbox = document.getElementById('serial-newline-checkbox');
|
|
let text = input.value;
|
|
if (newlineCheckbox && newlineCheckbox.checked) text += '\n';
|
|
if (!text) return;
|
|
const bytes = new TextEncoder().encode(text);
|
|
this.pushSerialBytes(bytes);
|
|
this.appendSerialMonitorText(text, 'tx');
|
|
input.value = '';
|
|
input.focus();
|
|
});
|
|
}
|
|
if (clearBtn && !clearBtn.dataset.bound) {
|
|
clearBtn.dataset.bound = '1';
|
|
clearBtn.addEventListener('click', () => {
|
|
const out = document.getElementById('serial-output');
|
|
if (out) out.textContent = '';
|
|
});
|
|
}
|
|
}
|
|
|
|
appendSerialMonitorText(text, direction) {
|
|
const out = document.getElementById('serial-output');
|
|
if (!out) return;
|
|
const span = document.createElement('span');
|
|
span.className = direction === 'tx' ? 'serial-tx' : 'serial-rx';
|
|
span.textContent = text;
|
|
out.appendChild(span);
|
|
const maxChars = 50000;
|
|
if (out.textContent.length > maxChars) {
|
|
out.textContent = out.textContent.slice(-maxChars);
|
|
}
|
|
out.scrollTop = out.scrollHeight;
|
|
}
|
|
|
|
appendSerialOutputBase64(b64) {
|
|
if (typeof b64 !== 'string' || !b64) return;
|
|
let bytes;
|
|
try {
|
|
const bin = atob(b64);
|
|
bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i += 1) {
|
|
bytes[i] = bin.charCodeAt(i);
|
|
}
|
|
} catch (_err) {
|
|
return;
|
|
}
|
|
if (!this.serialDecoder) {
|
|
this.serialDecoder = new TextDecoder('utf-8', { fatal: false });
|
|
}
|
|
const text = this.serialDecoder.decode(bytes, { stream: true });
|
|
if (text) this.appendSerialMonitorText(text, 'rx');
|
|
}
|
|
|
|
ensurePinRow(pin, mode, extra) {
|
|
if (!Number.isFinite(pin) || pin < 0 || pin >= this.pinCount) return;
|
|
if (this._claimedPins && this._claimedPins.has(pin)) return;
|
|
const panel = document.getElementById('pin-panel');
|
|
const list = document.getElementById('pin-rows');
|
|
if (!panel || !list) return;
|
|
panel.classList.remove('hidden');
|
|
this.getPinOutSab();
|
|
this.getPinInSab();
|
|
|
|
let entry = this.pinRows.get(pin);
|
|
if (!entry) {
|
|
const row = document.createElement('div');
|
|
row.className = 'pin-row';
|
|
row.dataset.pin = String(pin);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'pin-row-label';
|
|
|
|
const indicator = document.createElement('span');
|
|
indicator.className = 'pin-led';
|
|
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'pin-toggle';
|
|
button.textContent = '0';
|
|
const togglePin = (event) => {
|
|
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
|
const cur = this.pinInView ? (this.pinInView[pin] | 0) : (this._pinInLocal[pin] || 0);
|
|
const next = cur ? 0 : 1;
|
|
this._pinInLocal[pin] = next;
|
|
if (this.pinInView) {
|
|
try {
|
|
Atomics.store(this.pinInView, pin, next);
|
|
} catch (_err) {
|
|
try {
|
|
this.pinInView[pin] = next;
|
|
} catch (_e2) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
/* postMessage the change too — when SAB is unavailable (e.g. mobile
|
|
hitting a LAN IP over plain HTTP) the worker keeps its own local
|
|
Int32Array and only learns about clicks through messages. */
|
|
try {
|
|
if (this.pyWorker) {
|
|
this.pyWorker.postMessage({ type: 'pinIn', payload: { pin, value: next } });
|
|
}
|
|
} catch (_err) {
|
|
// ignore
|
|
}
|
|
button.textContent = next ? '1' : '0';
|
|
button.classList.toggle('on', Boolean(next));
|
|
};
|
|
/* On iOS Safari, `click` on a small button inside a scrollable parent
|
|
* sometimes fails to fire (the tap gets reclassified as a scroll
|
|
* gesture). `pointerup` fires reliably for finger taps; we guard with
|
|
* a 300ms suppression flag so we don't double-toggle on browsers that
|
|
* deliver both events. */
|
|
let lastTriggerAt = 0;
|
|
const trigger = (event) => {
|
|
const now = (typeof performance !== 'undefined' && performance.now)
|
|
? performance.now()
|
|
: Date.now();
|
|
if (now - lastTriggerAt < 300) return;
|
|
lastTriggerAt = now;
|
|
togglePin(event);
|
|
};
|
|
button.addEventListener('pointerup', (event) => {
|
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
trigger(event);
|
|
});
|
|
button.addEventListener('click', trigger);
|
|
|
|
const bar = document.createElement('div');
|
|
bar.className = 'pin-pwm-bar';
|
|
const fill = document.createElement('div');
|
|
fill.className = 'pin-pwm-fill';
|
|
bar.appendChild(fill);
|
|
|
|
const detail = document.createElement('span');
|
|
detail.className = 'pin-row-detail';
|
|
|
|
row.appendChild(label);
|
|
row.appendChild(indicator);
|
|
row.appendChild(button);
|
|
row.appendChild(bar);
|
|
row.appendChild(detail);
|
|
list.appendChild(row);
|
|
|
|
entry = { row, label, indicator, button, bar, fill, detail, mode: 0 };
|
|
this.pinRows.set(pin, entry);
|
|
}
|
|
|
|
entry.mode = mode;
|
|
entry.label.textContent = `Pin ${pin}`;
|
|
const isOut = mode === 1;
|
|
const isIn = mode === 2;
|
|
const isPwm = mode === 4;
|
|
entry.indicator.style.display = isOut ? '' : 'none';
|
|
entry.button.style.display = isIn ? '' : 'none';
|
|
entry.bar.style.display = isPwm ? '' : 'none';
|
|
if (isPwm && extra && Number.isFinite(extra.freq)) {
|
|
entry.detail.textContent = `PWM ${extra.freq} Hz`;
|
|
} else if (isOut) {
|
|
entry.detail.textContent = 'OUT';
|
|
} else if (isIn) {
|
|
entry.detail.textContent = 'IN';
|
|
} else {
|
|
entry.detail.textContent = '';
|
|
}
|
|
|
|
this.startPinRafLoop();
|
|
}
|
|
|
|
startPinRafLoop() {
|
|
if (this.pinRafId != null) return;
|
|
const tick = () => {
|
|
this.pinRafId = null;
|
|
if (!this.pinRows.size) return;
|
|
this.refreshPinRows();
|
|
if (this.pinRows.size) {
|
|
this.pinRafId = requestAnimationFrame(tick);
|
|
}
|
|
};
|
|
this.pinRafId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
refreshPinRows() {
|
|
const view = this.pinOutView;
|
|
if (!view) return;
|
|
for (const [pin, entry] of this.pinRows) {
|
|
if (entry.mode === 1) {
|
|
const packed = view[pin] | 0;
|
|
const value = packed & 0x00ffffff;
|
|
entry.indicator.classList.toggle('on', value !== 0);
|
|
} else if (entry.mode === 4) {
|
|
const packed = view[pin] | 0;
|
|
const duty = packed & 0x00ffffff;
|
|
const pct = Math.max(0, Math.min(100, (duty / 65535) * 100));
|
|
entry.fill.style.width = `${pct.toFixed(1)}%`;
|
|
entry.detail.textContent = `${(pct).toFixed(0)}% duty`;
|
|
}
|
|
}
|
|
}
|
|
|
|
closePinPanel() {
|
|
const panel = document.getElementById('pin-panel');
|
|
const list = document.getElementById('pin-rows');
|
|
if (panel) panel.classList.add('hidden');
|
|
if (list) list.innerHTML = '';
|
|
this.pinRows.clear();
|
|
if (this._claimedPins) this._claimedPins.clear();
|
|
if (this.pinRafId != null) {
|
|
cancelAnimationFrame(this.pinRafId);
|
|
this.pinRafId = null;
|
|
}
|
|
if (this.pinOutView) {
|
|
for (let i = 0; i < this.pinCount; i += 1) {
|
|
try {
|
|
Atomics.store(this.pinOutView, i, 0);
|
|
} catch (_err) {
|
|
this.pinOutView[i] = 0;
|
|
}
|
|
}
|
|
}
|
|
if (this.pinInView) {
|
|
for (let i = 0; i < this.pinCount; i += 1) {
|
|
try {
|
|
Atomics.store(this.pinInView, i, 0);
|
|
} catch (_err) {
|
|
this.pinInView[i] = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updateLedWindowControls() {
|
|
const runBtn = document.getElementById('led-run-btn');
|
|
const stopBtn = document.getElementById('led-stop-btn');
|
|
if (runBtn && stopBtn) {
|
|
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
|
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
|
|
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
|
|
runBtn.disabled = !(runMainSelected || hasPythonFile);
|
|
stopBtn.disabled = !this.isPythonRunning;
|
|
}
|
|
const popup = this.ledPanelWindow;
|
|
if (popup && !popup.closed) {
|
|
const popupStop = popup.document.getElementById('led-popup-stop-btn');
|
|
if (popupStop) {
|
|
popupStop.disabled = !this.isPythonRunning;
|
|
}
|
|
}
|
|
}
|
|
|
|
renderLedSimulation(frame) {
|
|
this.lastLedFrame = frame;
|
|
this.ledPanelDismissed = false;
|
|
if (!frame) return;
|
|
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
|
|
const panelMode = Boolean(panelModeCheckbox && panelModeCheckbox.checked);
|
|
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
|
|
if (panelMode) {
|
|
const panelWindow = this.ensureLedPanelWindow();
|
|
if (!panelWindow || panelWindow.closed) return;
|
|
const grid = panelWindow.document.getElementById('grid');
|
|
if (!grid) return;
|
|
grid.innerHTML = '';
|
|
const panelSize = 16 * 16;
|
|
for (let panelIndex = 0; panelIndex < panelSize; panelIndex += 1) {
|
|
const row = Math.floor(panelIndex / 16);
|
|
const col = panelIndex % 16;
|
|
const ledIndex = row % 2 === 0 ? row * 16 + (15 - col) : row * 16 + col;
|
|
const px = pixels[ledIndex] || [0, 0, 0];
|
|
const r = Number(px?.[0] ?? 0);
|
|
const g = Number(px?.[1] ?? 0);
|
|
const b = Number(px?.[2] ?? 0);
|
|
const div = panelWindow.document.createElement('div');
|
|
div.className = 'led';
|
|
div.title = `panel(${row},${col}) -> #${ledIndex} (${r}, ${g}, ${b})`;
|
|
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
|
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
|
|
grid.appendChild(div);
|
|
}
|
|
} else {
|
|
const panel = this.ensureLedWindow();
|
|
if (!panel) return;
|
|
const meta = document.getElementById('led-meta');
|
|
const grid = document.getElementById('led-grid');
|
|
if (!grid) return;
|
|
if (meta) {
|
|
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3}`;
|
|
}
|
|
grid.innerHTML = '';
|
|
grid.classList.remove('panel-mode');
|
|
pixels.forEach((px, i) => {
|
|
const r = Number(px?.[0] ?? 0);
|
|
const g = Number(px?.[1] ?? 0);
|
|
const b = Number(px?.[2] ?? 0);
|
|
const div = document.createElement('div');
|
|
div.className = 'led';
|
|
div.title = `#${i} (${r}, ${g}, ${b})`;
|
|
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
|
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
|
|
grid.appendChild(div);
|
|
});
|
|
}
|
|
}
|
|
|
|
processSimulationLines(lines) {
|
|
for (const line of lines || []) {
|
|
if (typeof line !== 'string') continue;
|
|
const npxIdx = line.indexOf('[neopixel-json]');
|
|
if (npxIdx !== -1) {
|
|
const jsonPart = line.slice(npxIdx + '[neopixel-json]'.length).trim();
|
|
if (jsonPart) {
|
|
try {
|
|
const payload = JSON.parse(jsonPart);
|
|
if (payload && payload.type === 'neopixel') {
|
|
this.renderLedSimulation(payload);
|
|
}
|
|
} catch (_err) {
|
|
// Ignore malformed simulation payloads.
|
|
}
|
|
}
|
|
}
|
|
const adcIdx = line.indexOf('[adc-register]');
|
|
if (adcIdx !== -1) {
|
|
const jsonPart = line.slice(adcIdx + '[adc-register]'.length).trim();
|
|
if (jsonPart) {
|
|
try {
|
|
const payload = JSON.parse(jsonPart);
|
|
if (payload && Number.isFinite(payload.pin)) {
|
|
this.ensureAdcSlider(Number(payload.pin));
|
|
}
|
|
} catch (_err) {
|
|
// Ignore malformed ADC registrations.
|
|
}
|
|
}
|
|
}
|
|
const serialRegIdx = line.indexOf('[serial-register]');
|
|
if (serialRegIdx !== -1) {
|
|
const jsonPart = line.slice(serialRegIdx + '[serial-register]'.length).trim();
|
|
let payload = null;
|
|
try {
|
|
payload = jsonPart ? JSON.parse(jsonPart) : {};
|
|
} catch (_err) {
|
|
payload = {};
|
|
}
|
|
this.openSerialMonitor(payload || {});
|
|
}
|
|
const serialOutIdx = line.indexOf('[serial-out]');
|
|
if (serialOutIdx !== -1) {
|
|
const b64 = line.slice(serialOutIdx + '[serial-out]'.length).trim();
|
|
if (b64) this.appendSerialOutputBase64(b64);
|
|
}
|
|
const pinClaimIdx = line.indexOf('[pin-claim]');
|
|
if (pinClaimIdx !== -1) {
|
|
const jsonPart = line.slice(pinClaimIdx + '[pin-claim]'.length).trim();
|
|
if (jsonPart) {
|
|
try {
|
|
const payload = JSON.parse(jsonPart);
|
|
this.claimPin(payload);
|
|
} catch (_err) {
|
|
// Ignore malformed pin claims.
|
|
}
|
|
}
|
|
}
|
|
const pinRegIdx = line.indexOf('[pin-register]');
|
|
if (pinRegIdx !== -1) {
|
|
const jsonPart = line.slice(pinRegIdx + '[pin-register]'.length).trim();
|
|
if (jsonPart) {
|
|
try {
|
|
const payload = JSON.parse(jsonPart);
|
|
if (payload && Number.isFinite(payload.pin) && Number.isFinite(payload.mode)) {
|
|
this.ensurePinRow(Number(payload.pin), Number(payload.mode), payload);
|
|
}
|
|
} catch (_err) {
|
|
// Ignore malformed pin registrations.
|
|
}
|
|
}
|
|
}
|
|
/* SAB-less fallback: machine.py prints `[pin-out]<json>` whenever the
|
|
worker has no SharedArrayBuffer for pin output. The main thread
|
|
can't read the worker's local array, so we drive indicators/PWM
|
|
bars off these console lines instead. */
|
|
const pinOutIdx = line.indexOf('[pin-out]');
|
|
if (pinOutIdx !== -1) {
|
|
const jsonPart = line.slice(pinOutIdx + '[pin-out]'.length).trim();
|
|
if (jsonPart) {
|
|
try {
|
|
const payload = JSON.parse(jsonPart);
|
|
this.applyPinOutSnapshot(payload);
|
|
} catch (_err) {
|
|
// Ignore malformed pin-out lines.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
claimPin(payload) {
|
|
if (!payload || !Number.isFinite(payload.pin)) return;
|
|
const pin = Number(payload.pin);
|
|
if (!this._claimedPins) this._claimedPins = new Set();
|
|
this._claimedPins.add(pin);
|
|
/* If a Pin(...) constructor already published a row for this pin
|
|
(NeoPixel internally uses Pin(...)), drop it now. The remaining rows
|
|
collapse and the panel hides itself when empty. */
|
|
const entry = this.pinRows.get(pin);
|
|
if (entry) {
|
|
entry.row.remove();
|
|
this.pinRows.delete(pin);
|
|
}
|
|
if (!this.pinRows.size) {
|
|
const panel = document.getElementById('pin-panel');
|
|
if (panel) panel.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
applyPinOutSnapshot(payload) {
|
|
if (!payload || !Number.isFinite(payload.pin)) return;
|
|
const pin = Number(payload.pin);
|
|
const mode = Number(payload.mode) | 0;
|
|
const entry = this.pinRows.get(pin);
|
|
if (!entry) return;
|
|
if (mode === 1) {
|
|
const on = Boolean(payload.value);
|
|
entry.indicator.classList.toggle('on', on);
|
|
} else if (mode === 4) {
|
|
const u16 = Math.max(0, Math.min(65535, Number(payload.value) | 0));
|
|
entry.fill.style.width = `${(u16 / 65535) * 100}%`;
|
|
if (Number.isFinite(payload.freq)) {
|
|
entry.detail.textContent = `${u16} / 65535 @ ${payload.freq} Hz`;
|
|
} else {
|
|
entry.detail.textContent = `${u16} / 65535`;
|
|
}
|
|
}
|
|
}
|
|
|
|
appendConsoleOutput(lines) {
|
|
if (!Array.isArray(lines) || lines.length === 0) return;
|
|
this.processSimulationLines(lines);
|
|
const visibleLines = lines.filter((line) => {
|
|
if (typeof line !== 'string') return false;
|
|
return (
|
|
!line.includes('[neopixel-json]') &&
|
|
!line.includes('[adc-register]') &&
|
|
!line.includes('[serial-register]') &&
|
|
!line.includes('[serial-out]') &&
|
|
!line.includes('[pin-register]') &&
|
|
!line.includes('[pin-out]') &&
|
|
!line.includes('[pin-claim]')
|
|
);
|
|
});
|
|
if (visibleLines.length === 0) return;
|
|
this.consolePendingText += visibleLines.join('');
|
|
if (this.consoleFlushTimer) return;
|
|
this.consoleFlushTimer = setTimeout(() => {
|
|
const consoleOutput = document.getElementById('console-output');
|
|
if (this.consolePendingText) {
|
|
consoleOutput.textContent += this.consolePendingText;
|
|
this.consolePendingText = '';
|
|
}
|
|
const maxChars = 200000;
|
|
if (consoleOutput.textContent.length > maxChars) {
|
|
consoleOutput.textContent = consoleOutput.textContent.slice(-maxChars);
|
|
}
|
|
consoleOutput.scrollTop = consoleOutput.scrollHeight;
|
|
this.consoleFlushTimer = null;
|
|
}, 50);
|
|
}
|
|
|
|
async runPython() {
|
|
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
|
const selectedRunFile = runMainCheckbox && runMainCheckbox.checked ? 'code/main.py' : this.currentFilePath;
|
|
if (this.isBrowserLocalPath(selectedRunFile)) {
|
|
this.showError(
|
|
'The workspace runner cannot execute files from a local folder or *-local file tab. Open a file under code/ or demos/, or enable “Run main.py”.',
|
|
);
|
|
return;
|
|
}
|
|
if (selectedRunFile && selectedRunFile !== this.currentFilePath && this.findTab(selectedRunFile)) {
|
|
this.switchToTab(selectedRunFile);
|
|
}
|
|
if (!selectedRunFile || !selectedRunFile.toLowerCase().endsWith('.py')) {
|
|
this.showError('Select a Python (.py) file first');
|
|
return;
|
|
}
|
|
|
|
let workerRunIssued = false;
|
|
try {
|
|
if (this.isPythonRunning) {
|
|
await this.stopPython();
|
|
}
|
|
if (this.isModified) {
|
|
await this.saveFile();
|
|
}
|
|
const bundleResp = await this.apiFetch('/api/workspace/py-sources');
|
|
if (!bundleResp.ok) {
|
|
const errorBody = await bundleResp.json().catch(() => ({}));
|
|
throw new Error(errorBody.detail || bundleResp.statusText);
|
|
}
|
|
const bundle = await bundleResp.json();
|
|
const files = { ...(bundle.files || {}) };
|
|
for (const tab of this.openTabs) {
|
|
if (
|
|
tab.path &&
|
|
tab.path.toLowerCase().endsWith('.py') &&
|
|
!this.isBrowserLocalPath(tab.path)
|
|
) {
|
|
files[tab.path] = tab.content;
|
|
}
|
|
}
|
|
this.maybePrepareLedWindow(files);
|
|
this.clearConsole();
|
|
const args = [];
|
|
this.appendConsoleOutput([`$ pyodide ${selectedRunFile}\n`]);
|
|
this.isPythonRunning = true;
|
|
this.updateRunButtonState();
|
|
const generation = ++this.pyRunGeneration;
|
|
await this.ensurePyodideReady();
|
|
workerRunIssued = true;
|
|
await this.callPyWorker('run', {
|
|
files,
|
|
mainPath: selectedRunFile,
|
|
args
|
|
});
|
|
if (generation === this.pyRunGeneration) {
|
|
this.appendConsoleOutput(['\n[Finished]\n']);
|
|
this.closeLedSimulator();
|
|
this.closeAdcPanel();
|
|
this.closeSerialPanel();
|
|
this.closePinPanel();
|
|
}
|
|
} catch (error) {
|
|
this.appendConsoleOutput([`\n${error.message}\n`]);
|
|
} finally {
|
|
this.isPythonRunning = false;
|
|
if (workerRunIssued) {
|
|
this.workerConsoleIoGrace = true;
|
|
if (this._workerConsoleIoGraceTimer) {
|
|
clearTimeout(this._workerConsoleIoGraceTimer);
|
|
}
|
|
this._workerConsoleIoGraceTimer = setTimeout(() => {
|
|
this.workerConsoleIoGrace = false;
|
|
this._workerConsoleIoGraceTimer = null;
|
|
}, 800);
|
|
}
|
|
this.updateRunButtonState();
|
|
}
|
|
}
|
|
|
|
async stopPython() {
|
|
if (!this.isPythonRunning) {
|
|
return;
|
|
}
|
|
this.pyRunGeneration += 1;
|
|
this.disposePyWorker();
|
|
this.isPythonRunning = false;
|
|
this.closeLedSimulator();
|
|
this.closeAdcPanel();
|
|
this.closeSerialPanel();
|
|
this.closePinPanel();
|
|
this.appendConsoleOutput(['\n[Execution stopped — Pyodide worker was reset]\n']);
|
|
this.updateRunButtonState();
|
|
this.prewarmPyWorker();
|
|
}
|
|
|
|
async deleteSelected() {
|
|
if (!this.selectedPath) {
|
|
this.showError('Select a file or folder first');
|
|
return;
|
|
}
|
|
if (this.selectedPath === 'code' && this.selectedIsDirectory) {
|
|
this.showError('The workspace root folder “code” cannot be deleted.');
|
|
return;
|
|
}
|
|
if (this.selectedPath === 'demos' && this.selectedIsDirectory) {
|
|
this.showError('The demos folder cannot be deleted.');
|
|
return;
|
|
}
|
|
if (this.isBrowserLocalPath(this.selectedPath)) {
|
|
this.showError('Local folder files are read-only. Delete or move them in your file manager instead.');
|
|
return;
|
|
}
|
|
const targetType = this.selectedIsDirectory ? "folder" : "file";
|
|
if (!confirm(`Delete ${targetType} "${this.selectedPath}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const endpoint = this.selectedIsDirectory
|
|
? `/api/folder/${this.encodeApiFilePath(this.selectedPath)}`
|
|
: `/api/file/${this.encodeApiFilePath(this.selectedPath)}`;
|
|
const response = await this.apiFetch(endpoint, { method: 'DELETE' });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to delete ${targetType}: ${response.statusText}`);
|
|
}
|
|
|
|
if (this.selectedIsDirectory) {
|
|
const prefix = `${this.selectedPath}/`;
|
|
const pathsToClose = this.openTabs
|
|
.map((tab) => tab.path)
|
|
.filter((path) => path === this.selectedPath || path.startsWith(prefix));
|
|
pathsToClose.forEach((path) => this.closeTabWithoutPrompt(path));
|
|
} else {
|
|
this.closeTabWithoutPrompt(this.selectedPath);
|
|
}
|
|
|
|
const deletedPath = this.selectedPath;
|
|
const wasDirectory = this.selectedIsDirectory;
|
|
this.selectedPath = null;
|
|
this.selectedIsDirectory = false;
|
|
if (wasDirectory) {
|
|
await this.refreshFileTreePreservingExpansion({ pruneDeletedPrefix: deletedPath });
|
|
} else {
|
|
await this.refreshFileTreePreservingExpansion();
|
|
}
|
|
this.showSuccess(`${targetType} deleted successfully`);
|
|
} catch (error) {
|
|
console.error(`Error deleting ${targetType}:`, error);
|
|
this.showError(`Failed to delete ${targetType}`);
|
|
}
|
|
}
|
|
|
|
getParentDirectory(filePath) {
|
|
if (!filePath || !filePath.includes('/')) return "";
|
|
return filePath.slice(0, filePath.lastIndexOf('/'));
|
|
}
|
|
|
|
getNewFileBasePath() {
|
|
const fallbackRoot = this.getDefaultEditableRoot();
|
|
if (!this.selectedPath) {
|
|
return fallbackRoot;
|
|
}
|
|
if (this.isBrowserLocalPath(this.selectedPath)) {
|
|
return fallbackRoot;
|
|
}
|
|
if (this.selectedPath === 'lib' || this.selectedPath.startsWith('lib/')) {
|
|
return fallbackRoot;
|
|
}
|
|
return this.selectedIsDirectory ? this.selectedPath : this.getParentDirectory(this.selectedPath);
|
|
}
|
|
|
|
getNewFolderPromptDefault() {
|
|
const base = this.getNewFileBasePath();
|
|
if (!base || base === 'code') {
|
|
return 'src/';
|
|
}
|
|
if (base.startsWith('code/')) {
|
|
return `${base.slice('code/'.length)}/`;
|
|
}
|
|
if (base === 'demos') {
|
|
return '';
|
|
}
|
|
if (base.startsWith('demos/')) {
|
|
return `${base.slice('demos/'.length)}/`;
|
|
}
|
|
return 'src/';
|
|
}
|
|
|
|
showNewFileModal() {
|
|
const basePath = this.getNewFileBasePath();
|
|
const input = document.getElementById('new-filename');
|
|
input.value = basePath ? `${basePath}/` : '';
|
|
document.getElementById('new-file-modal').style.display = 'block';
|
|
input.focus();
|
|
input.setSelectionRange(input.value.length, input.value.length);
|
|
}
|
|
|
|
hideNewFileModal() {
|
|
document.getElementById('new-file-modal').style.display = 'none';
|
|
document.getElementById('new-filename').value = '';
|
|
}
|
|
|
|
async createNewFile() {
|
|
const filename = document.getElementById('new-filename').value.trim();
|
|
|
|
if (!filename) {
|
|
this.showError('Please enter a filename');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let cleanName = this.normalizeRelativePathInput(filename);
|
|
if (cleanName.startsWith('new/')) {
|
|
cleanName = cleanName.slice(4);
|
|
}
|
|
if (!cleanName) {
|
|
this.showError('Please enter a filename');
|
|
return;
|
|
}
|
|
const basePath = this.getNewFileBasePath() || this.getDefaultEditableRoot();
|
|
let targetPath;
|
|
if (
|
|
cleanName.startsWith('code/') ||
|
|
cleanName.startsWith('demos/') ||
|
|
cleanName.startsWith('lib/')
|
|
) {
|
|
targetPath = cleanName;
|
|
} else {
|
|
targetPath = `${basePath}/${cleanName}`.replace(/\/+/g, '/');
|
|
}
|
|
const response = await this.apiFetch(`/api/file/${this.encodeApiFilePath(targetPath)}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ filename: targetPath, content: '' })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to create file: ${response.statusText}`);
|
|
}
|
|
|
|
this.hideNewFileModal();
|
|
this.ensureExpandedAncestorsForPath(targetPath, { isDirectory: false });
|
|
await this.refreshFileTreePreservingExpansion();
|
|
this.showSuccess('File created successfully');
|
|
} catch (error) {
|
|
console.error('Error creating file:', error);
|
|
this.showError('Failed to create file');
|
|
}
|
|
}
|
|
|
|
remapPath(path, oldPrefix, newPrefix) {
|
|
if (!path) return path;
|
|
if (path === oldPrefix) return newPrefix;
|
|
if (path.startsWith(`${oldPrefix}/`)) {
|
|
return `${newPrefix}${path.slice(oldPrefix.length)}`;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
async movePathToFolder(sourcePath, sourceIsDirectory, destinationFolder) {
|
|
if (!sourcePath) {
|
|
return;
|
|
}
|
|
if (this.isBrowserLocalPath(sourcePath) || this.isBrowserLocalPath(destinationFolder)) {
|
|
return;
|
|
}
|
|
if (sourcePath === destinationFolder) {
|
|
return;
|
|
}
|
|
if (sourceIsDirectory && destinationFolder && destinationFolder.startsWith(`${sourcePath}/`)) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await this.apiFetch('/api/file-move', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
source_path: sourcePath,
|
|
destination_folder: destinationFolder
|
|
})
|
|
});
|
|
if (!response.ok) {
|
|
const errorBody = await response.json().catch(() => ({}));
|
|
throw new Error(errorBody.detail || response.statusText);
|
|
}
|
|
|
|
const body = await response.json();
|
|
const newPath = body.new_path;
|
|
if (typeof newPath === 'string' && newPath) {
|
|
this.openTabs.forEach((tab) => {
|
|
tab.path = this.remapPath(tab.path, sourcePath, newPath);
|
|
});
|
|
this.currentFilePath = this.remapPath(this.currentFilePath, sourcePath, newPath);
|
|
this.activeTabPath = this.remapPath(this.activeTabPath, sourcePath, newPath);
|
|
if (this.currentFilePath) {
|
|
const currentFileEl = document.getElementById('current-file');
|
|
if (currentFileEl) currentFileEl.textContent = this.currentFilePath;
|
|
}
|
|
this.selectedPath = this.remapPath(this.selectedPath, sourcePath, newPath);
|
|
this.expandedDirs = new Set(
|
|
Array.from(this.expandedDirs).map((path) => this.remapPath(path, sourcePath, newPath))
|
|
);
|
|
if (destinationFolder) {
|
|
this.expandedDirs.add(destinationFolder);
|
|
}
|
|
}
|
|
|
|
this.renderTabs();
|
|
await this.refreshFileTreePreservingExpansion();
|
|
this.showSuccess('Path moved successfully');
|
|
} catch (error) {
|
|
this.showError(`Failed to move path: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async createNewFolder(overrideSuggestedPath) {
|
|
const suggestion =
|
|
typeof overrideSuggestedPath === 'string' && overrideSuggestedPath.length
|
|
? overrideSuggestedPath
|
|
: this.getNewFolderPromptDefault();
|
|
const folderPath = prompt('Enter folder path (under code/ or demos/)', suggestion);
|
|
if (!folderPath) return;
|
|
let cleanPath = this.normalizeRelativePathInput(folderPath);
|
|
if (!cleanPath) {
|
|
this.showError('Please enter a folder path');
|
|
return;
|
|
}
|
|
const basePath = this.getNewFileBasePath() || this.getDefaultEditableRoot();
|
|
let targetPath;
|
|
if (cleanPath.startsWith('code/') || cleanPath.startsWith('demos/') || cleanPath.startsWith('lib/')) {
|
|
targetPath = cleanPath;
|
|
} else {
|
|
targetPath = `${basePath}/${cleanPath}`.replace(/\/+/g, '/');
|
|
}
|
|
|
|
try {
|
|
const response = await this.apiFetch(`/api/folder/new/${this.encodeApiFilePath(targetPath)}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ path: targetPath })
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to create folder: ${response.statusText}`);
|
|
}
|
|
this.ensureExpandedAncestorsForPath(targetPath, { isDirectory: true });
|
|
await this.refreshFileTreePreservingExpansion();
|
|
this.showSuccess('Folder created successfully');
|
|
} catch (error) {
|
|
console.error('Error creating folder:', error);
|
|
this.showError('Failed to create folder');
|
|
}
|
|
}
|
|
|
|
markAsModified() {
|
|
this.isModified = true;
|
|
const saveStatus = document.getElementById('save-status');
|
|
saveStatus.textContent = 'Unsaved changes';
|
|
saveStatus.className = 'save-status unsaved';
|
|
}
|
|
|
|
markAsSaved() {
|
|
this.isModified = false;
|
|
const saveStatus = document.getElementById('save-status');
|
|
saveStatus.textContent = 'Saved';
|
|
saveStatus.className = 'save-status saved';
|
|
}
|
|
|
|
showError(message) {
|
|
alert(`Error: ${message}`);
|
|
}
|
|
|
|
showSuccess(message) {
|
|
console.log(`Success: ${message}`);
|
|
}
|
|
}
|
|
|
|
async function bootEditorApp() {
|
|
/* Decide between local and server mode. Order of precedence:
|
|
1. `?local=1` in the URL → local mode (always wins, even when signed in,
|
|
so a logged-in user can still test the local-mode workflow).
|
|
2. Signed-in session → server mode (clear any stale local-mode
|
|
flag from a previous "Use locally" visit).
|
|
3. No session, local flag in localStorage → local mode.
|
|
4. Auth required, no session → redirect to /login.
|
|
5. Auth disabled → server mode.
|
|
*/
|
|
const params = new URLSearchParams(window.location.search);
|
|
const explicitLocal = params.get('local') === '1';
|
|
|
|
if (explicitLocal) {
|
|
new TextEditor();
|
|
return;
|
|
}
|
|
|
|
let authEnabled = false;
|
|
let signedIn = false;
|
|
try {
|
|
const st = await fetch('/api/auth/status');
|
|
if (st.ok) {
|
|
const status = await st.json();
|
|
authEnabled = Boolean(status.auth_enabled);
|
|
}
|
|
} catch (_e) {
|
|
// No backend reachable — fall through to local-flag handling.
|
|
}
|
|
|
|
if (authEnabled) {
|
|
try {
|
|
const me = await fetch('/api/auth/me', { credentials: 'include' });
|
|
signedIn = me.ok;
|
|
} catch (_e) {
|
|
signedIn = false;
|
|
}
|
|
}
|
|
|
|
if (signedIn) {
|
|
/* Signed-in users always get the server workspace. Wipe the stale
|
|
local-mode flag so refreshing this tab doesn't bounce back to
|
|
IndexedDB without warning. */
|
|
try {
|
|
window.localStorage.removeItem('python-editor.local-mode');
|
|
} catch (_e) {
|
|
// ignore
|
|
}
|
|
new TextEditor();
|
|
return;
|
|
}
|
|
|
|
if (isLocalModeEnabled()) {
|
|
new TextEditor();
|
|
return;
|
|
}
|
|
|
|
if (authEnabled) {
|
|
const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
|
window.location.replace(`/login?next=${next}`);
|
|
return;
|
|
}
|
|
|
|
new TextEditor();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
bootEditorApp();
|
|
}); |