diff --git a/.replit b/.replit new file mode 100644 index 0000000..6321067 --- /dev/null +++ b/.replit @@ -0,0 +1,97 @@ +hidden=[".config", ".gitignore", ".github", "node_modules", "pnpm-lock.yaml", "tsconfig.json", "tsconfig.node.json", "vite.config.ts"] + +# onBoot=['echo', '$PATH'] # ⚠ node is not in env, yet +# onBoot=['echo', 'rebooted..'] # Runs on reboot, very limited ENV vars +# compile="npm i" # No runtime ENV vars +# run = ["npm", "run", "dev"] # Use TOML's """ for a multiline bash script +run = """ +echo NodeJS Version: $(node --version) "\n" +pnpm run dev +bash --norc +""" # " + +compile = """ +pnpm i +""" + +entrypoint = ".replit" + +[[ports]] +localPort = 5101 +remotePort = 80 + +[nix] +channel = "stable-22_11" + +[env] +PATH = "/home/runner/$REPL_SLUG/.config/npm/node_global/bin:/home/runner/$REPL_SLUG/node_modules/.bin:./node_modules/.bin:/home/runner/$REPL_SLUG/.config/pnpm" +npm_config_prefix = "/home/runner/$REPL_SLUG/.config/npm/node_global" # Global install support +npm_config_yes="true" # This is a safe space, don't ask stupid questions +PNPM_HOME = "/home/runner/$REPL_SLUG/.config/pnpm" +VITE_HOST = "0.0.0.0" +# NODE_OPTIONS="--max_old_space_size=384" +# EDITOR="replit-git-editor" # Not reliable, use curl replspace instead +#NODE_NO_WARNINGS="1" + +# Helper for Replit's git importer +[gitHubImport] +requiredFiles = ["package.json", "tsconfig.json", "pnpm-lock.yaml"] + +# Disables UPM, which BREAKS with PNPM, NPM v9, PNPM/Turbo/Yarn/Deno/Bun etc +[packager] +language = "no" # nodejs-npm / nodejs-yarn +ignoredPaths = ["."] # disables guessImports + +[languages.typescript] +pattern = "**/{*.ts,*.js,*.tsx,*.jsx}" +syntax = "typescript" + + [languages.typescript.languageServer] + start = [ "typescript-language-server", "--stdio" ] + +# CWD is not supported +# As a workaround, use Node 19 with --import and a helper script that CD's to a directory based on env vars +[debugger] +support = true + + [debugger.interactive] + transport = "localhost:0" + startCommand = [ "dap-node" ] + + [debugger.interactive.initializeMessage] + command = "initialize" + type = "request" + + [debugger.interactive.initializeMessage.arguments] + clientID = "replit" + clientName = "replit.com" + columnsStartAt1 = true + linesStartAt1 = true + locale = "en-us" + pathFormat = "path" + supportsInvalidatedEvent = true + supportsProgressReporting = true + supportsRunInTerminalRequest = true + supportsVariablePaging = true + supportsVariableType = true + + [debugger.interactive.launchMessage] + command = "launch" + type = "request" + + [debugger.interactive.launchMessage.arguments] + runtimeArgs = ["--loader", "ts-node/esm/transpile-only"] + args = [] + console = "externalTerminal" + cwd = "." # Broken + environment = [] # Broken + pauseForSourceMap = false + program = "index.ts" + request = "launch" + sourceMaps = true + stopOnEntry = false + type = "pwa-node" + + [debugger.interactive.launchMessage.arguments.env] + IS_RAY_AWESOME = "yes" + diff --git a/package.json b/package.json index b1eacdc..18b31ed 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,14 @@ "vite-plugin-node-polyfills": "^0.9.0", "vite-plugin-rewrite-all": "^1.0.1" }, + "prettier": { + "printWidth": 100, + "semi": true, + "singleQuote": true, + "bracketSpacing": false, + "trailingComma": "all", + "arrowParens": "avoid" + }, "pnpm": { "overrides": { "dockview-core": "https://pkg.csb.dev/mathuo/dockview/commit/2156a1dd/dockview-core" diff --git a/replit.nix b/replit.nix new file mode 100644 index 0000000..54829c0 --- /dev/null +++ b/replit.nix @@ -0,0 +1,7 @@ +{ pkgs }: { deps = with pkgs; [ + less + bashInteractive + nodejs-18_x + nodePackages.typescript-language-server # Add nodePackages.typescript if not in node_modules + nodePackages.pnpm # Best of YARN 2, but as easy to run as NPM +]; } diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index 15d52d6..2551d6a 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -1,12 +1,16 @@ -import {useRef, useEffect} from 'react'; -import {Tree, UncontrolledTreeEnvironment} from 'react-complex-tree'; +import {useRef, Ref} from 'react'; +import {Tree, UncontrolledTreeEnvironment, TreeEnvironmentRef} from 'react-complex-tree'; import {EventEmitter} from 'react-complex-tree/src/EventEmitter'; import {getDirAsTree} from '../utils/webcontainer'; import {useDarkMode} from '../hooks/useDarkMode'; +import Debug from '../utils/debug'; +import { debounce } from '../utils/debounce'; import type * as RCT from 'react-complex-tree'; import type {FileSystemAPI} from '@webcontainer/api'; +const debug = Debug('FileTree') + interface FileTreeProps { fs: FileSystemAPI, onRenameItem: (path: string, name: string) => void, @@ -22,25 +26,33 @@ const root: RCT.TreeItem = { children: [], }; +export const FileTreeState = { + refresh: new Function(), + treeEnv: null as Ref> +} + export function FileTree(props: FileTreeProps) { const isDark = useDarkMode(); + const UTreeEnvironment = useRef() as Ref> const provider = useRef>(new TreeProvider({root})); - const refresh = async () => { - const data = await getDirAsTree(props.fs, '.', 'root', root, {}); - provider.current.updateItems(data); + const refresh = async (updateMessage?: string) => { + debug('refresh updateMessage', updateMessage); + const data = await getDirAsTree(props.fs, '.', 'root', Object.assign({}, root, {children: []}), {}); + debug('refresh getDirAsTree', data); + provider.current.updateItems(data) }; - - useEffect(() => { - refresh(); - const i = setInterval(refresh, 1000); - return () => clearInterval(i); - }, []); + + Object.assign(FileTreeState, { + refresh: debounce(refresh, 300), + treeEnv: UTreeEnvironment + }) return (
item.data} onPrimaryAction={item => props.onTriggerItem(item.index.toString(), item.data)} onRenameItem={(item, name) => props.onRenameItem(item.index.toString(), name)} - onMissingItems={(itemIds) => console.log('missing', itemIds)} + // onMissingItems={(itemIds) => console.log('missing', itemIds)} onDrop={(item, target) => console.log('drop', item, target)} - onExpandItem={(item) => console.log('expand', item)} - viewState={{}}> + onExpandItem={(item) => { console.log('expand', item); FileTreeState.refresh() }} + viewState={{ + 'filetree': {}, + }} + >
@@ -64,50 +79,35 @@ export function FileTree(props: FileTreeProps) { class TreeProvider implements RCT.TreeDataProvider { private data: RCT.ExplicitDataSource; private onDidChangeTreeDataEmitter = new EventEmitter(); - private setItemName?: (item: RCT.TreeItem, newName: string) => RCT.TreeItem; + // private setItemName?: (item: RCT.TreeItem, newName: string) => RCT.TreeItem; constructor( items: Record>, - setItemName?: (item: RCT.TreeItem, newName: string) => RCT.TreeItem, + // setItemName?: (item: RCT.TreeItem, newName: string) => RCT.TreeItem, ) { + debug('TreeProvider constructor', items); this.data = {items}; - this.setItemName = setItemName; + // this.setItemName = setItemName; } public async updateItems(items: Record>) { - // const changed: Partial>> = diff(this.data.items, items); - // console.log(changed); - + debug('updateItems items', items) this.data = {items}; - this.onChangeItemChildren('root', Object.keys(this.data.items).filter(i => i !== 'root')); - - // update sub children - /*for (const key of Object.keys(changed)) { - const children = this.data.items[key]?.children; - if (key && children) { - this.onChangeItemChildren(key, children); - } - }*/ + this.onDidChangeTreeDataEmitter.emit(['root']); } public async getTreeItem(itemId: RCT.TreeItemIndex): Promise { + debug('getTreeItem', itemId, this.data.items[itemId]); return this.data.items[itemId]; } - public async onChangeItemChildren(itemId: RCT.TreeItemIndex, newChildren: RCT.TreeItemIndex[]): Promise { - this.data.items[itemId].children = newChildren; - this.onDidChangeTreeDataEmitter.emit([itemId]); - } - + public onDidChangeTreeData(listener: (changedItemIds: RCT.TreeItemIndex[]) => void): RCT.Disposable { + debug('onDidChangeTreeData items', this.data.items); const handlerId = this.onDidChangeTreeDataEmitter.on(payload => listener(payload)); return {dispose: () => this.onDidChangeTreeDataEmitter.off(handlerId)}; } - public async onRenameItem(item: RCT.TreeItem, name: string): Promise { - if (this.setItemName) { - this.data.items[item.index] = this.setItemName(item, name); - this.onDidChangeTreeDataEmitter.emit([item.index]); - } - } + // public async onChangeItemChildren(itemId: RCT.TreeItemIndex, newChildren: RCT.TreeItemIndex[]): Promise { + // public async onRenameItem(item: RCT.TreeItem, name: string): Promise { } diff --git a/src/hooks/useShell.ts b/src/hooks/useShell.ts index f1e52d3..22b412c 100644 --- a/src/hooks/useShell.ts +++ b/src/hooks/useShell.ts @@ -4,6 +4,10 @@ import {Terminal} from 'xterm'; import {FitAddon} from 'xterm-addon-fit'; import {startFiles} from '../utils/webcontainer'; import {useDarkMode} from '../hooks/useDarkMode'; +import {FileTreeState} from '../components/FileTree' +import Debug from '../utils/debug'; + +const debug = Debug('useShell'); import type {WebContainerProcess} from '@webcontainer/api'; import type {GridviewPanelApi} from 'dockview'; @@ -35,7 +39,7 @@ export function useShell(): ShellInstance { const start = useCallback(async (root: HTMLElement, panel: GridviewPanelApi, onServerReady?: ServerReadyHandler) => { if (container) return; - console.log('Booting...'); + debug('Booting...'); const shell = await WebContainer.boot({workdirName: 'vslite'}); const terminal = new Terminal({convertEol: true, theme}); const addon = new FitAddon(); @@ -46,13 +50,24 @@ export function useShell(): ShellInstance { let watchReady = false; const watch = await shell.spawn('npx', ['-y', 'chokidar-cli', '.', '-i', '"(**/(node_modules|.git|_tmp_)**)"']); watch.output.pipeTo(new WritableStream({ - write(data) { + async write(data) { + const type: string = data.split(':').at(0) || '' if (watchReady) { - console.log('Change detected: ', data); + debug('Change detected: ', data); } else if (data.includes('Watching "."')) { - console.log('File watcher ready.'); + debug('File watcher ready.'); watchReady = true; } + switch (type) { + case 'change': + break; + case 'add': + case 'unlink': + case 'addDir': + case 'unlinkDir': + default: + FileTreeState.refresh(data); + } } })); // Start shell @@ -62,6 +77,7 @@ export function useShell(): ShellInstance { const input = jsh.input.getWriter(); await init.read(); await input.write(`alias git='npx -y g4c@stable'\n\f`); + await input.write(`alias vslite-clone='git clone github.com/kat-tax/vslite'\n\f`) init.releaseLock(); // Pipe terminal to shell and vice versa terminal.onData(data => {input.write(data)}); @@ -83,7 +99,7 @@ export function useShell(): ShellInstance { shell.on('server-ready', (port, url) => onServerReady && onServerReady(url, port)); setContainer(shell); setTerminal(terminal); - console.log('Done.'); + debug('Done.'); }, []); return {terminal, container, process, start}; diff --git a/src/index.tsx b/src/index.tsx index 5553c03..6f2e895 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,3 +8,7 @@ import {Dock} from './components/Dock'; const el = document.getElementById('root'); el && createRoot(el).render(); + +if (import.meta.env.DEV && !globalThis.localStorage?.debug) { + console.log('To enable debug logging, use', '\`localStorage.debug = "vslite"\`') +} \ No newline at end of file diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 0000000..19e3427 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,9 @@ +// https://gist.github.com/ca0v/73a31f57b397606c9813472f7493a940 +export function debounce(cb: T, wait = 150) { + let h: NodeJS.Timeout; + let callable = (...args: any) => { + clearTimeout(h); + h = setTimeout(() => cb(...args), wait); + }; + return (callable); +} diff --git a/src/utils/debug.ts b/src/utils/debug.ts new file mode 100644 index 0000000..9be65a9 --- /dev/null +++ b/src/utils/debug.ts @@ -0,0 +1,19 @@ +// Isomorphic minimal debug package compatible with npm:debug +// TODO: Vite uses picomatch... we could borrow it +export const NS = 'vslite' +const CONFIG = globalThis.localStorage?.debug ? + globalThis.localStorage?.debug : + globalThis.process?.env.DEBUG || '' +const isEnabled = CONFIG.split(',').find((m: string) => m.startsWith(NS) || m === '*') + +const Debug = (name: string) => { + const prefix = `[${NS}/${name}]` + const debug = (...all: any) => { + if (isEnabled) { + console.debug(prefix, ...all) + } + } + return debug +} + +export default Debug \ No newline at end of file diff --git a/src/utils/webcontainer.ts b/src/utils/webcontainer.ts index 3375650..a1f21e7 100644 --- a/src/utils/webcontainer.ts +++ b/src/utils/webcontainer.ts @@ -1,5 +1,10 @@ import type {FileSystemAPI, FileSystemTree} from '@webcontainer/api'; import type {TreeItem, TreeItemIndex} from 'react-complex-tree'; +import Debug from '../utils/debug'; + +const debug = Debug('webcontainer') +const configRaw = globalThis.localStorage?.vslite_config +const config = configRaw ? JSON.parse(configRaw) : {} export async function getDirAsTree( fs: FileSystemAPI, @@ -8,7 +13,11 @@ export async function getDirAsTree( root: TreeItem, db: Record>, ) { - const dir = await fs.readdir(path, {withFileTypes: true}); + const dirAll = await fs.readdir(path, {withFileTypes: true}); + const dir = config.showHidden ? dirAll : dirAll.filter((item) => + !item.name.startsWith('.') && item.name !== 'node_modules' + ); + debug('getDirAsTree() dir', dir) if (parent === 'root') db.root = root; dir.forEach(item => { const isDir = item.isDirectory(); @@ -24,6 +33,7 @@ export async function getDirAsTree( if (parent) db?.[parent]?.children?.push(itemPath); if (isDir) return getDirAsTree(fs, itemPath, itemPath, root, db); }); + debug('getDirAsTree() db', db) return db; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..5b6b36a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,5 @@ /// + +declare module globalThis { + var process: Record; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c81ef9f..3f7a702 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,9 +6,9 @@ "skipLibCheck": true, /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, + "moduleResolution": "Node", + // "allowImportingTsExtensions": true, + // "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", diff --git a/tsconfig.node.json b/tsconfig.node.json index c0b4e0a..7f9673c 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -4,7 +4,7 @@ "skipLibCheck": true, "target": "ES2022", "module": "ESNext", - "moduleResolution": "bundler", + "moduleResolution": "node", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] diff --git a/vite.config.ts b/vite.config.ts index cc358a2..2dcdaba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,12 +4,14 @@ import pluginRewriteAll from 'vite-plugin-rewrite-all'; import reactSWC from '@vitejs/plugin-react-swc'; import react from '@vitejs/plugin-react'; + + export default defineConfig({ - base: process?.env.VITE_BASE || '/', + base: globalThis.process?.env.VITE_BASE || '/', plugins: [ pluginRewriteAll(), nodePolyfills(), - process?.versions?.webcontainer + globalThis.process?.versions?.webcontainer ? react() : reactSWC(), ], @@ -23,5 +25,6 @@ export default defineConfig({ 'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Opener-Policy': 'same-origin', }, + host: globalThis.process?.env.VITE_HOST || 'localhost' }, });