diff --git a/package.json b/package.json index 3bdb62e..f4e3239 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", "debug": "^4.3.4", + "file-system-access": "^1.0.4", "monaco-editor": "^0.36.1", "primeflex": "^3.3.1", "primeicons": "^6.0.1", diff --git a/src/components/App.tsx b/src/components/App.tsx index bfbe2b2..d5629c7 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,35 +1,37 @@ // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING. import React, { CSSProperties, useEffect, useState } from 'react'; -import {MultiLayoutComponentId, State, StatePersister} from '../state/app-state' +import { MultiLayoutComponentId, State, StatePersister } from '../state/app-state' import { Model } from '../state/model'; import EditorPanel from './EditorPanel'; import ViewerPanel from './ViewerPanel'; import Footer from './Footer'; -import { ModelContext, FSContext } from './contexts'; +import { ModelContext, FSContext, FileSystemContext } from './contexts'; import PanelSwitcher from './PanelSwitcher'; import { ConfirmDialog } from 'primereact/confirmdialog'; import CustomizerPanel from './CustomizerPanel'; +import { BaseFileSystem, DummyFileSystem, LocalStorage } from '../fs/base-filesystem'; // import "primereact/resources/themes/lara-light-indigo/theme.css"; // import "primereact/resources/primereact.min.css"; // import "primeicons/primeicons.css"; -export function App({initialState, statePersister, fs}: {initialState: State, statePersister: StatePersister, fs: FS}) { +export function App({ initialState, statePersister, fs }: { initialState: State, statePersister: StatePersister, fs: FS }) { const [state, setState] = useState(initialState); - - const model = new Model(fs, state, setState, statePersister); + const [fileSystem, setFileSystem] = useState(new LocalStorage() as BaseFileSystem); + + const model = new Model(fs, fileSystem, state, setState, statePersister); useEffect(() => model.init()); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'F5') { event.preventDefault(); - model.render({isPreview: true, now: true}) + model.render({ isPreview: true, now: true }) } else if (event.key === 'F6') { event.preventDefault(); - model.render({isPreview: false, now: true}) + model.render({ isPreview: false, now: true }) } }; window.addEventListener('keydown', handleKeyDown); @@ -63,7 +65,7 @@ export function App({initialState, statePersister, fs}: {initialState: State, st const itemCount = (layout.editor ? 1 : 0) + (layout.viewer ? 1 : 0) + (layout.customizer ? 1 : 0) return { flex: 1, - maxWidth: Math.floor(100/itemCount) + '%', + maxWidth: Math.floor(100 / itemCount) + '%', display: (state.view.layout as any)[id] ? 'flex' : 'none' } } else { @@ -74,37 +76,40 @@ export function App({initialState, statePersister, fs}: {initialState: State, st } } + return ( -
+
- - - -
+ +
- - - + -
+
-
- -
+
+
); diff --git a/src/components/EditorPanel.tsx b/src/components/EditorPanel.tsx index 62daeba..c131df7 100644 --- a/src/components/EditorPanel.tsx +++ b/src/components/EditorPanel.tsx @@ -10,8 +10,9 @@ import { MenuItem } from 'primereact/menuitem'; import { Menu } from 'primereact/menu'; import { buildUrlForStateParams } from '../state/fragment-state'; import { blankProjectState, defaultSourcePath } from '../state/initial-state'; -import { ModelContext, FSContext } from './contexts'; -import FilePicker, { } from './FilePicker'; +import { ModelContext, FSContext, FileSystemContext } from './contexts'; +import FilePicker, { } from './FilePicker'; +import { DummyFileSystem, LocalFileSystem } from '../fs/base-filesystem'; // const isMonacoSupported = false; const isMonacoSupported = (() => { @@ -25,11 +26,13 @@ if (isMonacoSupported) { loader.init().then(mi => monacoInstance = mi); } -export default function EditorPanel({className, style}: {className?: string, style?: CSSProperties}) { +export default function EditorPanel({ className, style }: { className?: string, style?: CSSProperties }) { const model = useContext(ModelContext); if (!model) throw new Error('No model'); + const fileSystemState = useContext(FileSystemContext); + const menu = useRef(null); const state = model.state; @@ -50,12 +53,12 @@ export default function EditorPanel({className, style}: {className?: string, sty editor.addAction({ id: "openscad-render", label: "Render OpenSCAD", - run: () => model.render({isPreview: false, now: true}) + run: () => model.render({ isPreview: false, now: true }) }); editor.addAction({ id: "openscad-preview", label: "Preview OpenSCAD", - run: () => model.render({isPreview: true, now: true}) + run: () => model.render({ isPreview: true, now: true }) }); setEditor(editor) } @@ -72,7 +75,7 @@ export default function EditorPanel({className, style}: {className?: string, sty
- + { + const name = window.prompt("New file name", "example.scad"); + if (name !== null) { + await fileSystemState?.fileSystem.createFile(name); + await model.openFile(name); + } + }, }, { label: "Copy to new file", @@ -114,6 +124,11 @@ export default function EditorPanel({className, style}: {className?: string, sty { separator: true }, + { + label: 'Use Local File System', + disabled: !('showOpenFilePicker' in self), + command: () => { const fileSystem = new LocalFileSystem(); fileSystem.initialise().finally(() => fileSystemState?.setFileSystem(fileSystem)) }, + }, // https://vscode-docs.readthedocs.io/en/stable/customization/keybindings/ // { // label: 'Undo', @@ -137,49 +152,49 @@ export default function EditorPanel({className, style}: {className?: string, sty // command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardCopyAction', null), // }, // { - // label: 'Cut', - // icon: 'pi pi-eraser', - // // disabled: true, - // command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardCutAction', null), - // }, - // { - // label: 'Paste', - // icon: 'pi pi-images', - // // disabled: true, - // command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardPasteAction', null), - // }, - { - label: 'Select All', - icon: 'pi pi-info-circle', - // disabled: true, - command: () => editor?.trigger(state.params.sourcePath, 'editor.action.selectAll', null), - }, - { - separator: true - }, - { - label: 'Find', - icon: 'pi pi-search', - // disabled: true, - command: () => editor?.trigger(state.params.sourcePath, 'actions.find', null), - }, + // label: 'Cut', + // icon: 'pi pi-eraser', + // // disabled: true, + // command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardCutAction', null), + // }, + // { + // label: 'Paste', + // icon: 'pi pi-images', + // // disabled: true, + // command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardPasteAction', null), + // }, + { + label: 'Select All', + icon: 'pi pi-info-circle', + // disabled: true, + command: () => editor?.trigger(state.params.sourcePath, 'editor.action.selectAll', null), + }, + { + separator: true + }, + { + label: 'Find', + icon: 'pi pi-search', + // disabled: true, + command: () => editor?.trigger(state.params.sourcePath, 'actions.find', null), + }, ] as MenuItem[]} popup ref={menu} />
- +
)} {!isMonacoSupported && ( - model.source = s.target.value ?? ''} + onChange={s => model.source = s.target.value ?? ''} /> )}
@@ -219,7 +234,7 @@ export default function EditorPanel({className, style}: {className?: string, sty
{state.lastCheckerRun?.logText ?? 'No log yet!'}
- + ) } diff --git a/src/components/FilePicker.tsx b/src/components/FilePicker.tsx index 9e0a086..b770202 100644 --- a/src/components/FilePicker.tsx +++ b/src/components/FilePicker.tsx @@ -1,15 +1,15 @@ // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING. -import { CSSProperties, useContext } from 'react'; +import { CSSProperties, useContext, useEffect, useState } from 'react'; import { TreeSelect } from 'primereact/treeselect'; import TreeNode from 'primereact/treenode'; -import { ModelContext, FSContext } from './contexts'; +import { ModelContext, FSContext, FileSystemContext } from './contexts'; // import { isFileWritable } from '../state/model'; import { join } from '../fs/filesystem'; import { defaultSourcePath } from '../state/initial-state'; import { zipArchives } from '../fs/zip-archives'; -const biasedCompare = (a: string, b: string) => +const biasedCompare = (a: string, b: string) => a === 'openscad' ? -1 : b === 'openscad' ? 1 : a.localeCompare(b); function listFilesAsNodes(fs: FS, path: string, accept?: (path: string) => boolean): TreeNode[] { @@ -82,34 +82,46 @@ function listFilesAsNodes(fs: FS, path: string, accept?: (path: string) => boole return nodes; } -export default function FilePicker({className, style}: {className?: string, style?: CSSProperties}) { +export default function FilePicker({ className, style }: { className?: string, style?: CSSProperties }) { const model = useContext(ModelContext); if (!model) throw new Error('No model'); const state = model.state; const fs = useContext(FSContext); - const fsItems = fs && listFilesAsNodes(fs, '/') + const fileSystem = useContext(FileSystemContext); + + const [files, setFiles] = useState([]); + + useEffect(() => { + async function apiCall() { + const apiResponse = await fileSystem?.fileSystem.getFiles(); + console.log(apiResponse); + // @ts-ignore + setFiles(apiResponse); + } + apiCall(); + }, [files]); return ( - { - const key = e.value; - if (typeof key === 'string') { - if (key.startsWith('https://')) { - window.open(key, '_blank') - } else { - model.openFile(key); - } - } - }} - filter - style={style} - options={fsItems} /> + { + const key = e.value; + if (typeof key === 'string') { + if (key.startsWith('https://')) { + window.open(key, '_blank') + } else { + model.openFile(key); + } + } + }} + filter + style={style} + options={files} /> ) } diff --git a/src/components/contexts.ts b/src/components/contexts.ts index b899c39..82c1934 100644 --- a/src/components/contexts.ts +++ b/src/components/contexts.ts @@ -1,7 +1,9 @@ -import React from "react"; +import React, { Dispatch, SetStateAction } from "react"; import { Model } from "../state/model"; +import { FileSystemContextInterface } from "../fs/base-filesystem"; export const FSContext = React.createContext(undefined); +export const FileSystemContext = React.createContext(undefined); export const ModelContext = React.createContext(null); diff --git a/src/fs/base-filesystem.ts b/src/fs/base-filesystem.ts new file mode 100644 index 0000000..fc5dc88 --- /dev/null +++ b/src/fs/base-filesystem.ts @@ -0,0 +1,119 @@ +import { showDirectoryPicker, showOpenFilePicker, support } from 'file-system-access'; +import TreeNode from 'primereact/treenode'; +import { Dispatch, SetStateAction } from 'react'; + +export interface FileSystemContextInterface { + fileSystem: BaseFileSystem + setFileSystem: Dispatch> +} + +export abstract class BaseFileSystem { + abstract initialise(): Promise; + abstract getFiles(): Promise; + abstract readFile(path: string): Promise; + abstract createFile(name: string): Promise; + abstract saveFile(name: string, content: string): Promise; +} + +export class DummyFileSystem implements BaseFileSystem { + async initialise(): Promise { + return null; + } + async getFiles(): Promise { + return []; + } + + async readFile(path: string): Promise { + return "" + } + + async createFile(name: string): Promise { + + } + + async saveFile(name: string, content: string): Promise { + + } + +} + +export class LocalStorage implements BaseFileSystem { + dirHandle!: FileSystemDirectoryHandle; + + constructor() { + + } + async initialise(): Promise { + } + + prefix = "filestore_" + + async getFiles(): Promise { + let result = []; + for (var i = 0, len = localStorage.length; i < len; ++i) { + let key = localStorage.key(i); + if (key !== null && key.startsWith(this.prefix)) { + result.push({ + label: key.substring(this.prefix.length), + key: key.substring(this.prefix.length) + }); + } + } + return result; + } + + async readFile(path: string): Promise { + return localStorage[this.prefix + path] + } + + async createFile(name: string) { + localStorage[this.prefix + name] = "" + } + + async saveFile(name: string, content: string): Promise { + localStorage[this.prefix + name] = content + } +} + +export class LocalFileSystem implements BaseFileSystem { + dirHandle!: FileSystemDirectoryHandle; + + constructor() { + + } + async initialise(): Promise { + this.dirHandle = await showDirectoryPicker(); + } + + supported(): boolean { + return support.adapter.native; + } + + async getFiles(): Promise { + let result = []; + for await (const entry of this.dirHandle.values()) { + console.log(entry.kind, entry.name); + result.push({ + label: entry.name, + key: entry.name + }); + } + return result; + } + + async readFile(path: string): Promise { + return await (await (await this.dirHandle.getFileHandle(path)).getFile()).text() + } + + async createFile(name: string) { + let file = await (await this.dirHandle.getFileHandle(name, { create: true })).createWritable(); + await file.write(""); + await file.close() + } + + async saveFile(name: string, content: string): Promise { + let file = await (await this.dirHandle.getFileHandle(name)).createWritable(); + await file.write(content); + await file.close() + } +} \ No newline at end of file diff --git a/src/state/model.ts b/src/state/model.ts index d132660..78ecc3f 100644 --- a/src/state/model.ts +++ b/src/state/model.ts @@ -4,15 +4,18 @@ import { checkSyntax, render, RenderArgs, RenderOutput } from "../runner/actions import { MultiLayoutComponentId, SingleLayoutComponentId, State, StatePersister } from "./app-state"; import { bubbleUpDeepMutations } from "./deep-mutate"; import { formatBytes, formatMillis } from '../utils' +import { useContext, useState } from "react"; +import { FileSystemContext } from "../components/contexts"; +import { BaseFileSystem, FileSystemContextInterface } from "../fs/base-filesystem"; export class Model { - constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void, + constructor(private fs: FS, private fileSystem: BaseFileSystem, public state: State, private setStateCallback?: (state: State) => void, private statePersister?: StatePersister) { } - + init() { if (!this.state.output && !this.state.lastCheckerRun && !this.state.previewing && !this.state.checkingSyntax && !this.state.rendering && - this.state.params.source.trim() != '') { + this.state.params.source.trim() != '') { this.processSource(); } } @@ -36,8 +39,8 @@ export class Model { } setVar(name: string, value: any) { - this.mutate(s => s.params.vars = {...s.params.vars ?? {}, [name]: value}); - this.render({isPreview: true, now: false}); + this.mutate(s => s.params.vars = { ...s.params.vars ?? {}, [name]: value }); + this.render({ isPreview: true, now: false }); } set logsVisible(value: boolean) { @@ -45,7 +48,7 @@ export class Model { if (this.state.view.layout.mode === 'single') { this.changeSingleVisibility('editor'); } else { - this.changeMultiVisibility('editor', true); + this.changeMultiVisibility('editor', true); } } this.mutate(s => s.view.logs = value); @@ -100,10 +103,16 @@ export class Model { }) } - openFile(path: string) { + async openFile(path: string) { + const fileSystem = this.fileSystem; + if (fileSystem === undefined) { + return; + } + + let fileContent = await fileSystem.readFile(path); // alert(`TODO: open ${path}`); if (this.mutate(s => { - s.params.source = new TextDecoder("utf-8").decode(this.fs.readFileSync(path)); + s.params.source = fileContent; if (s.params.sourcePath != path) { s.params.sourcePath = path; s.lastCheckerRun = undefined; @@ -123,26 +132,29 @@ export class Model { private processSource() { const params = this.state.params; // if (isFileWritable(params.sourcePath)) { - // const absolutePath = params.sourcePath.startsWith('/') ? params.sourcePath : `/${params.sourcePath}`; - this.fs.writeFile(params.sourcePath, params.source); + // const absolutePath = params.sourcePath.startsWith('/') ? params.sourcePath : `/${params.sourcePath}`; + this.fileSystem.saveFile(params.sourcePath, params.source); + //this.fs.writeFile(params.sourcePath, params.source); // } this.checkSyntax(); - this.render({isPreview: true, now: false}); + this.render({ isPreview: true, now: false }); } checkSyntax() { this.mutate(s => s.checkingSyntax = true); - checkSyntax(this.state.params.source, this.state.params.sourcePath)({now: false, callback: (checkerRun, err) => this.mutate(s => { - if (err != null) { - console.error('Error while checking syntax:', err) - } else { - s.lastCheckerRun = checkerRun; - s.parameterSet = checkerRun?.parameterSet; - s.checkingSyntax = false; - } - })}); + checkSyntax(this.state.params.source, this.state.params.sourcePath)({ + now: false, callback: (checkerRun, err) => this.mutate(s => { + if (err != null) { + console.error('Error while checking syntax:', err) + } else { + s.lastCheckerRun = checkerRun; + s.parameterSet = checkerRun?.parameterSet; + s.checkingSyntax = false; + } + }) + }); } - render({isPreview, now}: {isPreview: boolean, now: boolean}) { + render({ isPreview, now }: { isPreview: boolean, now: boolean }) { const setRendering = (s: State, value: boolean) => { if (isPreview) { s.previewing = value; @@ -152,39 +164,41 @@ export class Model { } this.mutate(s => setRendering(s, true)); - const {source, sourcePath, vars, features} = this.state.params; - - render({source, sourcePath, vars, features, extraArgs: [], isPreview})({now, callback: (output, err) => { - this.mutate(s => { - setRendering(s, false); - if (err != null) { - console.error('Error while doing ' + (isPreview ? 'preview' : 'rendering') + ':', err) - s.error = `${err}`; - } else if (output) { - s.error = undefined; - s.lastCheckerRun = { - logText: output.logText, - markers: output.markers, - } - if (s.output?.stlFileURL) { - URL.revokeObjectURL(s.output.stlFileURL); - } - - s.output = { - isPreview: isPreview, - stlFile: output.stlFile, - stlFileURL: URL.createObjectURL(output.stlFile), - elapsedMillis: output.elapsedMillis, - formattedElapsedMillis: formatMillis(output.elapsedMillis), - formattedStlFileSize: formatBytes(output.stlFile.size), - }; - - if (!isPreview) { - const audio = document.getElementById('complete-sound') as HTMLAudioElement; - audio?.play(); + const { source, sourcePath, vars, features } = this.state.params; + + render({ source, sourcePath, vars, features, extraArgs: [], isPreview })({ + now, callback: (output, err) => { + this.mutate(s => { + setRendering(s, false); + if (err != null) { + console.error('Error while doing ' + (isPreview ? 'preview' : 'rendering') + ':', err) + s.error = `${err}`; + } else if (output) { + s.error = undefined; + s.lastCheckerRun = { + logText: output.logText, + markers: output.markers, + } + if (s.output?.stlFileURL) { + URL.revokeObjectURL(s.output.stlFileURL); + } + + s.output = { + isPreview: isPreview, + stlFile: output.stlFile, + stlFileURL: URL.createObjectURL(output.stlFile), + elapsedMillis: output.elapsedMillis, + formattedElapsedMillis: formatMillis(output.elapsedMillis), + formattedStlFileSize: formatBytes(output.stlFile.size), + }; + + if (!isPreview) { + const audio = document.getElementById('complete-sound') as HTMLAudioElement; + audio?.play(); + } } - } - }); - }}) + }); + } + }) } }