// 8bitworkshop IDE user interface import * as localforage from "localforage"; import { CodeProject, createNewPersistentStore, LocalForageFilesystem, OverlayFilesystem, ProjectFilesystem, WebPresetsFileSystem } from "./project"; import { WorkerResult, WorkerError, FileData } from "../common/workertypes"; import { ProjectWindows } from "./windows"; import { Platform, Preset, DebugSymbols, DebugEvalCondition, isDebuggable, EmuState } from "../common/baseplatform"; import { PLATFORMS, EmuHalt } from "../common/emu"; import { Toolbar } from "./toolbar"; import { getFilenameForPath, getFilenamePrefix, highlightDifferences, byteArrayToString, compressLZG, stringToByteArray, byteArrayToUTF8, isProbablyBinary, getWithBinary, getBasePlatform, getRootBasePlatform, hex, loadScript, decodeQueryString, parseBool, getCookie } from "../common/util"; import { StateRecorderImpl } from "../common/recorder"; import { getRepos, parseGithubURL } from "./services"; import Split = require('split.js'); import { importPlatform } from "../platform/_index"; import { DisassemblerView, ListingView, PC_LINE_LOOKAHEAD , SourceEditor } from "./views/editors"; import { AddressHeatMapView, BinaryFileView, MemoryMapView, MemoryView, ProbeLogView, ProbeSymbolView, RasterStackMapView, ScanlineIOView, VRAMMemoryView } from "./views/debugviews"; import { AssetEditorView } from "./views/asseteditor"; import { isMobileDevice } from "./views/baseviews"; import { CallStackView, DebugBrowserView } from "./views/treeviews"; import DOMPurify = require("dompurify"); import { alertError, alertInfo, fatalError, setWaitDialog, setWaitProgress } from "./dialogs"; import { _importProjectFromGithub, _loginToGithub, _logoutOfGithub, _publishProjectToGithub, _pullProjectFromGithub, _pushProjectToGithub, _removeRepository, importProjectFromGithub } from "./sync"; import { gaEvent, gaPageView } from "./analytics"; import { _downloadAllFilesZipFile, _downloadCassetteFile, _downloadProjectZipFile, _downloadROMImage, _downloadSourceFile, _downloadSymFile, _getCassetteFunction, _recordVideo, _shareEmbedLink } from "./shareexport"; // external libs (TODO) declare var Tour; declare var $ : JQueryStatic; // use browser jquery // query string interface UIQueryString { platform? : string; options?: string; repo? : string; file? : string; electron? : string; importURL? : string; githubURL? : string; localfs? : string; newfile? : string; embed? : string; ignore? : string; force? : string; highlight? : string; file0_name? : string; file0_data? : string; file0_type? : string; tool?: string; } /// EXPORTED GLOBALS (TODO: remove) export var qs = decodeQueryString(window.location.search||'?') as UIQueryString; export var platform_id : string; // platform ID string (platform) export var store_id : string; // store ID string (repo || platform) export var repo_id : string; // repository ID (repo) export var platform : Platform; // emulator object export var current_project : CodeProject; // current CodeProject object export var projectWindows : ProjectWindows; // window manager export var lastDebugState : EmuState; // last debug state (object) // private globals var compparams; // received build params from worker var platform_name : string; // platform name (after setPlatformUI) var toolbar = $("#controls_top"); var uitoolbar : Toolbar; var stateRecorder : StateRecorderImpl; var userPaused : boolean; // did user explicitly pause? var current_output : any; // current ROM (or other object) var current_preset : Preset; // current preset object (if selected) var store : LocalForage; // persistent store const isElectron = parseBool(qs.electron); const isEmbed = parseBool(qs.embed); type DebugCommandType = null | 'toline' | 'step' | 'stepout' | 'stepover' | 'tovsync' | 'stepback' | 'restart'; var lastDebugInfo; // last debug info (CPU text) var debugCategory; // current debug category var debugTickPaused = false; var recorderActive = false; var lastViewClicked : string = null; var lastDebugCommand : DebugCommandType = null; var errorWasRuntime = false; var lastBreakExpr = "c.PC == 0x6000"; export function getPlatformStore() { return store; } export function getCurrentProject() { return current_project; } export function getCurrentOutput() { return current_output; } export function getWorkerParams() { return compparams; } // TODO: codemirror multiplex support? // TODO: move to views.ts? const TOOL_TO_SOURCE_STYLE = { 'dasm': '6502', 'acme': '6502', 'cc65': 'text/x-csrc', 'ca65': '6502', 'nesasm': '6502', 'z80asm': 'z80', 'sdasz80': 'z80', 'sdcc': 'text/x-csrc', 'verilator': 'verilog', 'jsasm': 'z80', 'zmac': 'z80', 'bataribasic': 'bataribasic', 'markdown': 'markdown', 'js': 'javascript', 'xasm6809': 'z80', 'cmoc': 'text/x-csrc', 'yasm': 'gas', 'smlrc': 'text/x-csrc', 'inform6': 'inform6', 'fastbasic': 'fastbasic', 'basic': 'basic', 'silice': 'verilog', 'wiz': 'text/x-wiz', 'vasmarm': 'vasm', 'armips': 'vasm', 'ecs': 'ecs', 'remote:llvm-mos': 'text/x-csrc', 'cc7800': 'text/x-csrc', 'armtcc': 'text/x-csrc', } // TODO: move into tool class const TOOL_TO_HELPURL = { 'dasm': 'https://raw.githubusercontent.com/sehugg/dasm/master/doc/dasm.txt', 'cc65': 'https://cc65.github.io/doc/cc65.html', 'ca65': 'https://cc65.github.io/doc/ca65.html', 'sdcc': 'http://sdcc.sourceforge.net/doc/sdccman.pdf', 'verilator': 'https://www.veripool.org/ftp/verilator_doc.pdf', 'fastbasic': 'https://github.com/dmsc/fastbasic/blob/master/manual.md', 'bataribasic': "help/bataribasic/manual.html", 'wiz': "https://github.com/wiz-lang/wiz/blob/master/readme.md#wiz", 'silice': "https://github.com/sylefeb/Silice", 'zmac': "https://raw.githubusercontent.com/sehugg/zmac/master/doc.txt", 'cmoc': "http://perso.b2b2c.ca/~sarrazip/dev/cmoc.html", 'remote:llvm-mos': 'https://llvm-mos.org/wiki/Welcome', 'acme': 'https://raw.githubusercontent.com/sehugg/acme/main/docs/QuickRef.txt', } function newWorker() : Worker { // TODO: return new Worker("https://8bitworkshop.com.s3-website-us-east-1.amazonaws.com/dev/gen/worker/bundle.js"); return new Worker("./gen/worker/bundle.js"); } const hasLocalStorage : boolean = function() { try { const key = "__some_random_key_you_are_not_going_to_use__"; localStorage.setItem(key, key); var has = localStorage.getItem(key) == key; localStorage.removeItem(key); return has; } catch (e) { return false; } }(); // wrapper for localstorage class UserPrefs { setLastPreset(id:string) { if (hasLocalStorage && !isEmbed) { if (repo_id && platform_id && !isElectron) localStorage.setItem("__lastrepo_" + platform_id, repo_id); else localStorage.removeItem("__lastrepo_" + platform_id); localStorage.setItem("__lastplatform", platform_id); localStorage.setItem("__lastid_" + store_id, id); } } unsetLastPreset() { if (hasLocalStorage && !isEmbed) { delete qs.file; localStorage.removeItem("__lastid_"+store_id); } } getLastPreset() { return hasLocalStorage && !isEmbed && localStorage.getItem("__lastid_"+store_id); } getLastPlatformID() { return hasLocalStorage && !isEmbed && localStorage.getItem("__lastplatform"); } getLastRepoID(platform: string) { return hasLocalStorage && !isEmbed && platform && localStorage.getItem("__lastrepo_" + platform); } shouldCompleteTour() { return hasLocalStorage && !isEmbed && !localStorage.getItem("8bitworkshop.hello"); } completedTour() { if (hasLocalStorage && !isEmbed) localStorage.setItem("8bitworkshop.hello", "true"); } } var userPrefs = new UserPrefs(); // https://developers.google.com/web/updates/2016/06/persistent-storage function requestPersistPermission(interactive: boolean, failureonly: boolean) { if (navigator.storage && navigator.storage.persist) { navigator.storage.persist().then(persistent=>{ console.log("requestPersistPermission =", persistent); if (persistent) { interactive && !failureonly && alertInfo("Your browser says it will persist your local file edits, but you may want to back up your work anyway."); } else { interactive && alertError("Your browser refused to expand the peristent storage quota. Your edits may not be preserved after closing the page."); } }); } else { interactive && alertError("Your browser may not persist edits after closing the page. Try a different browser."); } } function getCurrentPresetTitle() : string { if (!current_preset) return current_project.mainPath || "ROM"; else return current_preset.title || current_preset.name || current_project.mainPath || "ROM"; } async function newFilesystem() { var basefs : ProjectFilesystem = new WebPresetsFileSystem(platform_id); if (isElectron) { console.log('using electron with local filesystem', alternateLocalFilesystem); return new OverlayFilesystem(basefs, alternateLocalFilesystem); } else if (qs.localfs != null) { return new OverlayFilesystem(basefs, await getLocalFilesystem(qs.localfs)); } else { return new OverlayFilesystem(basefs, new LocalForageFilesystem(store)); } } async function initProject() { var filesystem = await newFilesystem(); current_project = new CodeProject(newWorker(), platform_id, platform, filesystem); current_project.remoteTool = qs.tool || null; projectWindows = new ProjectWindows($("#workspace")[0] as HTMLElement, current_project); current_project.callbackBuildResult = (result:WorkerResult) => { setCompileOutput(result); }; current_project.callbackBuildStatus = (busy:boolean) => { setBusyStatus(busy); }; } function setBusyStatus(busy: boolean) { if (busy) { toolbar.addClass("is-busy"); } else { toolbar.removeClass("is-busy"); } $('#compile_spinner').css('visibility', busy ? 'visible' : 'hidden'); } function newDropdownListItem(id, text) { var li = document.createElement("li"); var a = document.createElement("a"); a.setAttribute("class", "dropdown-item"); a.setAttribute("href", "#"); a.setAttribute("data-wndid", id); if (id == projectWindows.getActiveID()) $(a).addClass("dropdown-item-checked"); a.appendChild(document.createTextNode(text)); li.appendChild(a); return {li, a}; } function refreshWindowList() { var ul = $("#windowMenuList").empty(); var separate = false; function addWindowItem(id, name, createfn) { if (separate) { ul.append(document.createElement("hr")); separate = false; } let {li,a} = newDropdownListItem(id, name); ul.append(li); if (createfn) { var onopen = (id, wnd) => { ul.find('a').removeClass("dropdown-item-checked"); $(a).addClass("dropdown-item-checked"); }; projectWindows.setCreateFunc(id, createfn); projectWindows.setShowFunc(id, onopen); $(a).click( (e) => { projectWindows.createOrShow(id); lastViewClicked = id; }); } } function loadEditor(path:string) { var tool = platform.getToolForFilename(path); // hack because .h files can be DASM or CC65 if (tool == 'dasm' && path.endsWith(".h") && getCurrentMainFilename().endsWith(".c")) { tool = 'cc65'; } var mode = tool && TOOL_TO_SOURCE_STYLE[tool]; return new SourceEditor(path, mode); } function addEditorItem(id:string) { addWindowItem(id, getFilenameForPath(id), () => { var data = current_project.getFile(id); if (typeof data === 'string') return loadEditor(id); else if (data instanceof Uint8Array) return new BinaryFileView(id, data as Uint8Array); }); } // add main file editor addEditorItem(current_project.mainPath); // add other source files current_project.iterateFiles( (id, text) => { if (text && id != current_project.mainPath) { addEditorItem(id); } }); // add listings separate = true; var listings = current_project.getListings(); if (listings) { for (var lstfn in listings) { var lst = listings[lstfn]; // add listing if source/assembly file exists and has text if ((lst.assemblyfile && lst.assemblyfile.text) || (lst.sourcefile && lst.sourcefile.text) || lst.text) { addWindowItem(lstfn, getFilenameForPath(lstfn), (path) => { return new ListingView(path); }); } } } // add other tools separate = true; if (platform.disassemble && platform.saveState) { addWindowItem("#disasm", "Disassembly", () => { return new DisassemblerView(); }); } if (platform.readAddress) { addWindowItem("#memory", "Memory Browser", () => { return new MemoryView(); }); } if (current_project.segments && current_project.segments.length) { addWindowItem("#memmap", "Memory Map", () => { return new MemoryMapView(); }); } if (platform.readVRAMAddress) { addWindowItem("#memvram", "VRAM Browser", () => { return new VRAMMemoryView(); }); } if (platform.startProbing) { addWindowItem("#memheatmap", "Memory Probe", () => { return new AddressHeatMapView(); }); // TODO: only if raster addWindowItem("#crtheatmap", "CRT Probe", () => { //return new RasterPCHeatMapView(); return new RasterStackMapView(); }); addWindowItem("#probelog", "Probe Log", () => { return new ProbeLogView(); }); addWindowItem("#scanlineio", "Scanline I/O", () => { return new ScanlineIOView(); }); addWindowItem("#symbolprobe", "Symbol Profiler", () => { return new ProbeSymbolView(); }); addWindowItem("#callstack", "Call Stack", () => { return new CallStackView(); }); /* addWindowItem("#framecalls", "Frame Profiler", () => { return new FrameCallsView(); }); */ } if (platform.getDebugTree) { addWindowItem("#debugview", "Debug Tree", () => { return new DebugBrowserView(); }); } addWindowItem('#asseteditor', 'Asset Editor', () => { return new AssetEditorView(); }); } function highlightLines(path:string, hispec:string) { if (hispec) { var toks = qs.highlight.split(','); var start = parseInt(toks[0]) - 1; var end = parseInt(toks[1]) - 1; var editor = projectWindows.createOrShow(path) as SourceEditor; editor.highlightLines(start, end); } } function loadMainWindow(preset_id:string) { // we need this to build create functions for the editor refreshWindowList(); // show main file projectWindows.createOrShow(preset_id); // build project current_project.setMainFile(preset_id); // highlighting? highlightLines(preset_id, qs.highlight); } async function loadProject(preset_id:string) { // set current file ID // TODO: this is done twice (mainPath and mainpath!) current_project.mainPath = preset_id; userPrefs.setLastPreset(preset_id); // load files from storage or web URLs var result = await current_project.loadFiles([preset_id]); if (result && result.length) { // file found; continue loadMainWindow(preset_id); } else { var skel = await getSkeletonFile(preset_id); current_project.filedata[preset_id] = skel || "\n"; loadMainWindow(preset_id); // don't alert if we selected "new file" if (!qs.newfile) { alertInfo("Could not find file \"" + preset_id + "\". Loading default file."); } else { requestPersistPermission(true, true); } delete qs.newfile; replaceURLState(); } } function reloadProject(id:string) { // leave repository == '/' if (id == '/') { qs = {repo:'/'}; } else if (id.indexOf('://') >= 0) { var urlparse = parseGithubURL(id); if (urlparse) { qs = {repo:urlparse.repopath}; } } else { qs.platform = platform_id; qs.file = id; } gotoNewLocation(); } async function getSkeletonFile(fileid:string) : Promise { var ext = platform.getToolForFilename(fileid); try { return await $.get( "presets/"+getBasePlatform(platform_id)+"/skeleton."+ext, 'text'); } catch(e) { alertError("Could not load skeleton for " + platform_id + "/" + ext + "; using blank file"); } } function checkEnteredFilename(fn : string) : boolean { if (fn.indexOf(" ") >= 0) { alertError("No spaces in filenames, please."); return false; } return true; } function _createNewFile(e) { // TODO: support spaces bootbox.prompt({ title:"Enter the name of your new main source file.", placeholder:"newfile" + platform.getDefaultExtension(), callback:(filename) => { if (filename && filename.trim().length > 0) { if (!checkEnteredFilename(filename)) return; if (filename.indexOf(".") < 0) { filename += platform.getDefaultExtension(); } var path = filename; gaEvent('workspace', 'file', 'new'); qs.newfile = '1'; reloadProject(path); } } } as any); return true; } function _uploadNewFile(e) { const uploadFileElem = $(``); const file = uploadFileElem[0] as HTMLInputElement; uploadFileElem.change((e) => { handleFileUpload(file.files) }); uploadFileElem.click(); } // called from index.html function handleFileUpload(files: FileList) { console.log(files); var index = 0; function uploadNextFile() { var f = files[index++]; if (!f) { console.log("Done uploading", index); if (index > 2) { alertInfo("Files uploaded."); setTimeout(updateSelector, 1000); // TODO: wait for files to upload } else { qs.file = files[0].name; bootbox.confirm({ message: "Open '" + DOMPurify.sanitize(qs.file) + "' as main project file?", buttons: { confirm: { label: "Open As New Project" }, cancel: { label: "Include/Link With Project Later" }, }, callback: (result) => { if (result) gotoNewLocation(); else setTimeout(updateSelector, 1000); // TODO: wait for files to upload } }); } gaEvent('workspace', 'file', 'upload'); } else { var path = f.name; var reader = new FileReader(); reader.onload = function(e) { var arrbuf = (e.target).result as ArrayBuffer; var data : FileData = new Uint8Array(arrbuf); // convert to UTF8, unless it's a binary file if (isProbablyBinary(path, data)) { //gotoMainFile = false; } else { data = byteArrayToUTF8(data).replace('\r\n','\n'); // convert CRLF to LF } // store in local forage projectWindows.updateFile(path, data); console.log("Uploaded " + path + " " + data.length + " bytes"); uploadNextFile(); } reader.readAsArrayBuffer(f); // read as binary } } if (files) uploadNextFile(); } async function _openLocalDirectory(e) { var pickerfn = window['showDirectoryPicker']; if (!pickerfn) { alertError(`This browser can't open local files on your computer, yet. Try Chrome.`); } var dirHandle = await pickerfn(); var repoid = dirHandle.name; var storekey = '__localfs__' + repoid; var fsdata = { handle: dirHandle, } var lstore = localforage.createInstance({ name: storekey, version: 2.0 }); await lstore.setItem(storekey, fsdata); qs = {localfs: repoid}; gotoNewLocation(true); } async function promptUser(message: string) : Promise { return new Promise( (resolve, reject) => { bootbox.prompt(DOMPurify.sanitize(message), (result) => { resolve(result); }); }); } async function getLocalFilesystem(repoid: string) : Promise { const options = {mode:'readwrite'}; var storekey = '__localfs__' + repoid; var lstore = localforage.createInstance({ name: storekey, version: 2.0 }); var fsdata : any = await lstore.getItem(storekey); var dirHandle = fsdata.handle as any; console.log(fsdata, dirHandle); var granted = await dirHandle.queryPermission(options); console.log(granted); if (granted !== 'granted') { await promptUser(`Request permissions to access filesystem?`); granted = await dirHandle.requestPermission(options); } if (granted !== 'granted') { alertError(`Could not get permission to access filesystem.`); return; } return { getFileData: async (path) => { console.log('getFileData', path); let fileHandle = await dirHandle.getFileHandle(path, { create: false }); console.log('getFileData', fileHandle); let file = await fileHandle.getFile(); console.log('getFileData', file); let contents = await (isProbablyBinary(path) ? file.binary() : file.text()); console.log(fileHandle, file, contents); return contents; }, setFileData: async (path, data) => { //let vh = await dirHandle.getFileHandle(path, { create: true }); } } } export function getCurrentMainFilename() : string { return getFilenameForPath(current_project.mainPath); } export function getCurrentEditorFilename() : string { return getFilenameForPath(projectWindows.getActiveID()); } function _revertFile(e) { var wnd = projectWindows.getActive(); if (wnd && wnd.setText) { var fn = projectWindows.getActiveID(); $.get( "presets/"+getBasePlatform(platform_id)+"/"+fn, (text) => { bootbox.confirm("Reset '" + DOMPurify.sanitize(fn) + "' to default?", (ok) => { if (ok) { wnd.setText(text); } }); }, 'text') .fail(() => { if (repo_id) alertError("Can only revert built-in examples. If you want to revert all files, You can pull from the repository."); else alertError("Can only revert built-in examples."); }); } else { alertError("Cannot revert the active window. Please choose a text file."); } } function _deleteFile(e) { var wnd = projectWindows.getActive(); if (wnd && wnd.getPath) { var fn = projectWindows.getActiveID(); bootbox.confirm("Delete '" + DOMPurify.sanitize(fn) + "'?", (ok) => { if (ok) { store.removeItem(fn).then( () => { // if we delete what is selected if (qs.file == fn) { userPrefs.unsetLastPreset(); gotoNewLocation(); } else { updateSelector(); alertInfo("Deleted " + fn); } }); } }); } else { alertError("Cannot delete the active window."); } } function _renameFile(e) { var wnd = projectWindows.getActive(); if (wnd && wnd.getPath && current_project.getFile(wnd.getPath())) { var fn = projectWindows.getActiveID(); bootbox.prompt({ title: "Rename '" + DOMPurify.sanitize(fn) + "' to?", value: fn, callback: (newfn) => { var data = current_project.getFile(wnd.getPath()); if (newfn && newfn != fn && data) { if (!checkEnteredFilename(newfn)) return; store.removeItem(fn).then( () => { return store.setItem(newfn, data); }).then( () => { updateSelector(); alert("Renamed " + fn + " to " + newfn); // need alert() so it pauses if (fn == current_project.mainPath) { reloadProject(newfn); } }); } } }); } else { alertError("Cannot rename the active window."); } } function populateExamples(sel) { let files = {}; let optgroup; const PRESETS = platform.getPresets ? platform.getPresets() : []; for (var i=0; i").attr('label','Examples: ' + preset.category).appendTo(sel); } else if (!optgroup) { optgroup = $("").attr('label','Examples').appendTo(sel); } optgroup.append($("").attr('label','Repositories').appendTo(sel); for (let repopath in repos) { var repo = repos[repopath]; if (repo.platform_id && getBasePlatform(repo.platform_id) == getBasePlatform(platform_id)) { optgroup.append($("").attr('label',category).appendTo(sel); let name = key.substring(prefix.length); optgroup.append($("