Existing accounts (including admin) seeded before new demos shipped had no easy way to pull in the latest copies — the registration-time seeder is intentionally non-destructive. The new badge action fetches src/static/bundled-demos/manifest.json, confirms the overwrite, and re-copies each canonical demo into code/. Open tabs of those files are refreshed in place so the user sees the new content immediately. src/static/bundled-demos/ ships the six canonical files plus the manifest so this works in local mode and on a static-only host. The Dockerfile now mirrors workspace/code/<demo>.py into bundled-demos/ during the image build, keeping the two locations in sync. Co-authored-by: Cursor <cursoragent@cursor.com>
3167 lines
102 KiB
JavaScript
3167 lines
102 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=4";
|
|
import { createZip, readZip } from "/static/zip-utils.js?v=2";
|
|
|
|
class TextEditor {
|
|
constructor() {
|
|
this.sessionStorageKey = 'python-editor.editor.session.v1';
|
|
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.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.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');
|
|
if (!badge) return;
|
|
if (this.localMode) {
|
|
badge.innerHTML = '';
|
|
const label = document.createElement('span');
|
|
label.className = 'workspace-badge-label';
|
|
label.textContent = 'Local · IndexedDB';
|
|
badge.appendChild(label);
|
|
|
|
if (supportsFolderPicker()) {
|
|
const pick = document.createElement('button');
|
|
pick.type = 'button';
|
|
pick.className = 'workspace-badge-action';
|
|
pick.textContent = 'Folder…';
|
|
pick.title = 'Save files to a folder on this device';
|
|
pick.addEventListener('click', () => this.pickLocalFolder());
|
|
badge.appendChild(pick);
|
|
} else {
|
|
const why = document.createElement('span');
|
|
why.className = 'workspace-badge-note';
|
|
why.textContent = '(no folder picker)';
|
|
why.title =
|
|
'window.showDirectoryPicker is not exposed in this browser context.\n' +
|
|
'• Firefox / Safari: not implemented (use 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.';
|
|
badge.appendChild(why);
|
|
}
|
|
|
|
const exportBtn = document.createElement('button');
|
|
exportBtn.type = 'button';
|
|
exportBtn.className = 'workspace-badge-action';
|
|
exportBtn.textContent = 'Export';
|
|
exportBtn.title = 'Download every workspace file as a .zip';
|
|
exportBtn.addEventListener('click', () => this.exportWorkspaceZip());
|
|
badge.appendChild(exportBtn);
|
|
|
|
const importBtn = document.createElement('button');
|
|
importBtn.type = 'button';
|
|
importBtn.className = 'workspace-badge-action';
|
|
importBtn.textContent = 'Import';
|
|
importBtn.title = 'Upload a .zip — its files land in code/ (overwrites on conflict)';
|
|
importBtn.addEventListener('click', () => this.importWorkspaceZip());
|
|
badge.appendChild(importBtn);
|
|
|
|
const resetBtn = document.createElement('button');
|
|
resetBtn.type = 'button';
|
|
resetBtn.className = 'workspace-badge-action';
|
|
resetBtn.textContent = 'Reset demos';
|
|
resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)';
|
|
resetBtn.addEventListener('click', () => this.resetDemoFiles());
|
|
badge.appendChild(resetBtn);
|
|
|
|
const exit = document.createElement('button');
|
|
exit.type = 'button';
|
|
exit.className = 'workspace-badge-exit';
|
|
exit.textContent = 'Exit';
|
|
exit.title = 'Leave local mode and return to the home page';
|
|
exit.addEventListener('click', () => {
|
|
if (!confirm('Leave local mode? Your files stay in this browser; you can come back later.')) return;
|
|
exitLocalMode();
|
|
window.location.href = '/';
|
|
});
|
|
badge.appendChild(exit);
|
|
badge.classList.remove('hidden');
|
|
|
|
try {
|
|
const info = await this.localWorkspace.describeStorage();
|
|
if (info) {
|
|
if (info.mode === 'filesystem') {
|
|
label.textContent = `Local · ${info.label}`;
|
|
const swap = document.createElement('button');
|
|
swap.type = 'button';
|
|
swap.className = 'workspace-badge-action';
|
|
swap.textContent = 'IndexedDB';
|
|
swap.title = 'Switch storage back to in-browser IndexedDB';
|
|
swap.addEventListener('click', () => this.useIndexedDbStorage());
|
|
badge.insertBefore(swap, exit);
|
|
} else if (info.pendingReconnect) {
|
|
label.textContent = `Local · IndexedDB (folder “${info.pendingFolderName}” needs reconnect)`;
|
|
const reconnect = document.createElement('button');
|
|
reconnect.type = 'button';
|
|
reconnect.className = 'workspace-badge-action';
|
|
reconnect.textContent = 'Reconnect';
|
|
reconnect.title = 'Re-grant read/write access to the previously picked folder';
|
|
reconnect.addEventListener('click', () => this.reconnectLocalFolder());
|
|
badge.insertBefore(reconnect, exit);
|
|
} else {
|
|
label.textContent = 'Local · IndexedDB';
|
|
}
|
|
}
|
|
} catch (_err) {
|
|
// Best-effort label refresh.
|
|
}
|
|
return;
|
|
}
|
|
badge.innerHTML = '';
|
|
const label = document.createElement('span');
|
|
label.className = 'workspace-badge-label';
|
|
label.textContent = this.workspaceUserId
|
|
? `Workspace: user ${this.workspaceUserId}`
|
|
: 'Server workspace';
|
|
badge.appendChild(label);
|
|
|
|
const exportBtn = document.createElement('button');
|
|
exportBtn.type = 'button';
|
|
exportBtn.className = 'workspace-badge-action';
|
|
exportBtn.textContent = 'Export';
|
|
exportBtn.title = 'Download every workspace file as a .zip';
|
|
exportBtn.addEventListener('click', () => this.exportWorkspaceZip());
|
|
badge.appendChild(exportBtn);
|
|
|
|
const importBtn = document.createElement('button');
|
|
importBtn.type = 'button';
|
|
importBtn.className = 'workspace-badge-action';
|
|
importBtn.textContent = 'Import';
|
|
importBtn.title = 'Upload a .zip — its files land in code/ (overwrites on conflict)';
|
|
importBtn.addEventListener('click', () => this.importWorkspaceZip());
|
|
badge.appendChild(importBtn);
|
|
|
|
const resetBtn = document.createElement('button');
|
|
resetBtn.type = 'button';
|
|
resetBtn.className = 'workspace-badge-action';
|
|
resetBtn.textContent = 'Reset demos';
|
|
resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)';
|
|
resetBtn.addEventListener('click', () => this.resetDemoFiles());
|
|
badge.appendChild(resetBtn);
|
|
|
|
badge.classList.remove('hidden');
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (!confirm(
|
|
`Reset ${names.length} demo file${names.length === 1 ? '' : 's'}?\n\n` +
|
|
names.map((n) => ` • code/${n}`).join('\n') +
|
|
'\n\nAny edits you made to these files will be overwritten. Other ' +
|
|
'files (main.py, your own scripts) are not touched.'
|
|
)) return;
|
|
|
|
let written = 0;
|
|
let failed = 0;
|
|
for (const name of names) {
|
|
try {
|
|
const r = await fetch(`/static/bundled-demos/${encodeURIComponent(name)}`, {
|
|
cache: 'no-store',
|
|
});
|
|
if (!r.ok) {
|
|
failed += 1;
|
|
continue;
|
|
}
|
|
const content = await r.text();
|
|
const w = await this.apiFetch(`/api/file/code/${encodeURIComponent(name)}`, {
|
|
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 = `code/${name}`;
|
|
if (this.findTab(path)) {
|
|
try {
|
|
const fr = await this.apiFetch(`/api/file/${encodeURIComponent(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']);
|
|
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']);
|
|
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'];
|
|
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/${encodeURIComponent(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.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.`
|
|
)) return;
|
|
|
|
let written = 0;
|
|
let failed = 0;
|
|
for (const entry of safeEntries) {
|
|
try {
|
|
const r = await this.apiFetch(`/api/file/${encodeURIComponent(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']);
|
|
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']);
|
|
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();
|
|
}
|
|
|
|
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() {
|
|
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=8');
|
|
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.isPythonRunning) {
|
|
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;
|
|
}
|
|
await this.callPyWorker('init', {
|
|
adcSab: this.getAdcSab(),
|
|
serialSab: this.getSerialSab(),
|
|
pinOutSab: this.getPinOutSab(),
|
|
pinInSab: this.getPinInSab(),
|
|
});
|
|
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;
|
|
});
|
|
}
|
|
|
|
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) {
|
|
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')
|
|
: [];
|
|
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;
|
|
await this.loadDirectory(path, { suppressError: true });
|
|
}
|
|
this.renderFileTree();
|
|
}
|
|
|
|
getVisibleTopLevelNames() {
|
|
/* lib is shared read-only for everyone; browsing is allowed, saves are blocked in API/UI. */
|
|
return new Set(['code', 'lib']);
|
|
}
|
|
|
|
getDefaultEditableRoot() {
|
|
return 'code';
|
|
}
|
|
|
|
async loadInitialDirectoryState() {
|
|
await this.loadDirectory('');
|
|
await this.ensureFolderExists('code');
|
|
this.selectedPath = 'code';
|
|
this.selectedIsDirectory = true;
|
|
this.expandedDirs.add('code');
|
|
await this.loadDirectory('code', { 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/${encodeURIComponent(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();
|
|
}
|
|
return parts.join('/');
|
|
}
|
|
|
|
resetDirectoryState() {
|
|
this.directoryCache.clear();
|
|
this.expandedDirs = new Set(['']);
|
|
}
|
|
|
|
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')) {
|
|
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')) {
|
|
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;
|
|
document.addEventListener('click', (event) => {
|
|
if (!menu.open) return;
|
|
if (!menu.contains(event.target)) {
|
|
menu.open = false;
|
|
}
|
|
});
|
|
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.isPythonRunning) {
|
|
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.resetDirectoryState();
|
|
this.loadDirectory(this.getTreeRootPath());
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
|
|
this.setupHeaderMenu();
|
|
|
|
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 ? '*' : '';
|
|
const displayPath = tab.path.startsWith('code/') ? tab.path.slice('code/'.length) : tab.path;
|
|
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) {
|
|
return typeof path === 'string' && path.startsWith('lib/');
|
|
}
|
|
|
|
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 = {}) {
|
|
const { suppressError = 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 || []);
|
|
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 (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) {
|
|
await this.openFile(path);
|
|
this.renderFileTree();
|
|
if (this.isMobileViewport()) {
|
|
this.setMobileSidebarOpen(false);
|
|
}
|
|
} else {
|
|
this.renderFileTree();
|
|
}
|
|
});
|
|
});
|
|
|
|
fileTreeElement.querySelectorAll('.file-item').forEach((item) => {
|
|
item.setAttribute('draggable', 'true');
|
|
item.addEventListener('dragstart', () => {
|
|
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 = 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. */
|
|
const displayName = depth === 0 && !basePath && file.is_directory && file.name === 'code'
|
|
? '/'
|
|
: file.name;
|
|
|
|
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;
|
|
}
|
|
|
|
const response = await this.apiFetch(`/api/file/${encodeURIComponent(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('Files in lib are read-only');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const content = this.editor.state.doc.toString();
|
|
const response = await this.apiFetch(`/api/file/${encodeURIComponent(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 (!confirm(`Are you sure you want to delete "${this.currentFilePath}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const deletedPath = this.currentFilePath;
|
|
const response = await this.apiFetch(`/api/file/${encodeURIComponent(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');
|
|
}
|
|
|
|
this.directoryCache.clear();
|
|
this.loadDirectory("");
|
|
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 canRun = runMainSelected || hasPythonFile;
|
|
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);
|
|
}
|
|
.stop-fab {
|
|
position: fixed;
|
|
top: max(20px, env(safe-area-inset-top));
|
|
right: max(20px, env(safe-area-inset-right));
|
|
width: clamp(120px, 18vmin, 200px);
|
|
height: clamp(120px, 18vmin, 200px);
|
|
border-radius: 999px;
|
|
border: 3px solid rgba(255, 255, 255, 0.35);
|
|
background: #c53030;
|
|
color: white;
|
|
font-size: clamp(2.5rem, 6vmin, 4rem);
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55);
|
|
z-index: 10;
|
|
}
|
|
.stop-fab:hover {
|
|
background: #9b2c2c;
|
|
}
|
|
.stop-fab:active {
|
|
transform: scale(0.96);
|
|
}
|
|
body.led-panel-popup-desktop .stop-fab {
|
|
display: none;
|
|
}
|
|
</style>
|
|
<div id="grid" class="grid"></div>
|
|
<button id="stop-fab" class="stop-fab" type="button" aria-label="Stop" title="Stop">\u25A0</button>
|
|
`;
|
|
if (!this.isMobileViewport()) {
|
|
win.document.body.classList.add('led-panel-popup-desktop');
|
|
}
|
|
win.document.getElementById('stop-fab')?.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';
|
|
button.addEventListener('click', () => {
|
|
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));
|
|
});
|
|
|
|
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) return;
|
|
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;
|
|
}
|
|
|
|
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 (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;
|
|
}
|
|
|
|
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')) {
|
|
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();
|
|
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;
|
|
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;
|
|
}
|
|
const targetType = this.selectedIsDirectory ? "folder" : "file";
|
|
if (!confirm(`Delete ${targetType} "${this.selectedPath}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const endpoint = this.selectedIsDirectory
|
|
? `/api/folder/${encodeURIComponent(this.selectedPath)}`
|
|
: `/api/file/${encodeURIComponent(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);
|
|
}
|
|
|
|
this.selectedPath = null;
|
|
this.selectedIsDirectory = false;
|
|
this.resetDirectoryState();
|
|
this.loadDirectory(this.getTreeRootPath());
|
|
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;
|
|
}
|
|
return this.selectedIsDirectory ? this.selectedPath : this.getParentDirectory(this.selectedPath);
|
|
}
|
|
|
|
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 targetRoot = this.getDefaultEditableRoot();
|
|
const targetPath = `${targetRoot}/${cleanName}`;
|
|
const response = await this.apiFetch(`/api/file/${encodeURIComponent(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.resetDirectoryState();
|
|
this.loadDirectory(this.getTreeRootPath());
|
|
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 (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.directoryCache.clear();
|
|
this.expandedDirs.add('');
|
|
this.renderTabs();
|
|
this.loadDirectory(this.getTreeRootPath());
|
|
this.showSuccess('Path moved successfully');
|
|
} catch (error) {
|
|
this.showError(`Failed to move path: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async createNewFolder() {
|
|
const folderPath = prompt('Enter folder path (e.g., src/components)');
|
|
if (!folderPath) return;
|
|
let cleanPath = this.normalizeRelativePathInput(folderPath);
|
|
if (!cleanPath) {
|
|
this.showError('Please enter a folder path');
|
|
return;
|
|
}
|
|
const targetRoot = this.getDefaultEditableRoot();
|
|
const targetPath = `${targetRoot}/${cleanPath}`;
|
|
|
|
try {
|
|
const response = await this.apiFetch(`/api/folder/new/${encodeURIComponent(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.resetDirectoryState();
|
|
this.loadDirectory(this.getTreeRootPath());
|
|
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();
|
|
}); |