diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..8f390c35a --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use_flake \ No newline at end of file diff --git a/.gitignore b/.gitignore index df6cc8aa3..3c22a3472 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ /compilerTypes node_modules/* -!node_modules/vue-template-compiler # local env files .env @@ -23,4 +22,8 @@ pnpm-debug.log* *.njsproj *.sln *.sw? -todos.md \ No newline at end of file +todos.md + +public/changelog.html + +.direnv \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json index 3ffcd03fd..38f534037 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -4,5 +4,5 @@ "tabWidth": 4, "semi": false, "singleQuote": true, - "printWidth": 80 -} + "printWidth": 140 +} \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d506ab55b..863e341bc 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["esbenp.prettier-vscode", "octref.vetur"] + "recommendations": ["esbenp.prettier-vscode", "Vue.volar"] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 20b1188f8..490848d0d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,6 +7,13 @@ "problemMatcher": [], "label": "npm: dev", "detail": "vue-cli-service serve" + }, + { + "type": "npm", + "script": "dev:native", + "problemMatcher": [], + "label": "npm: dev:native", + "detail": "cargo tauri dev" } ] -} \ No newline at end of file +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..eb9d74da4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,25 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1759733170, + "narHash": "sha256-TXnlsVb5Z8HXZ6mZoeOAIwxmvGHp1g4Dw89eLvIwKVI=", + "rev": "8913c168d1c56dc49a7718685968f38752171c3b", + "revCount": 873256, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.873256%2Brev-8913c168d1c56dc49a7718685968f38752171c3b/0199bd36-8ae7-7817-b019-8688eb4f61ff/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..b8ecc24dc --- /dev/null +++ b/flake.nix @@ -0,0 +1,47 @@ +{ + description = "A Nix-flake-based Node.js development environment"; + + inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1"; + + outputs = + inputs: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forEachSupportedSystem = + f: + inputs.nixpkgs.lib.genAttrs supportedSystems ( + system: + f { + pkgs = import inputs.nixpkgs { + inherit system; + overlays = [ inputs.self.overlays.default ]; + }; + } + ); + in + { + overlays.default = final: prev: rec { + nodejs = prev.nodejs; + yarn = (prev.yarn.override { inherit nodejs; }); + }; + + devShells = forEachSupportedSystem ( + { pkgs }: + { + default = pkgs.mkShell { + packages = with pkgs; [ + node2nix + nodejs + nodePackages.pnpm + yarn + ]; + }; + } + ); + }; +} \ No newline at end of file diff --git a/index.html b/index.html index 35275fbc3..457fc39ad 100644 --- a/index.html +++ b/index.html @@ -2,19 +2,10 @@ - - + + - + @@ -23,103 +14,45 @@ - + - - - <% if(isNightly) { %> - - - - - - - <% } else { %> - - + + + + + + + + - - - - <% } %> + + + - - + + + + diff --git a/src/components/Actions/Action.ts b/src/components/Actions/Action.ts deleted file mode 100644 index 6d0083a0a..000000000 --- a/src/components/Actions/Action.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ActionManager } from './ActionManager' -import { KeyBinding } from './KeyBinding' -import { v4 as uuid } from 'uuid' -import { IActionConfig, SimpleAction } from './SimpleAction' - -export class Action extends SimpleAction { - public readonly type = 'action' - public readonly id: string - protected _keyBinding: KeyBinding | undefined - - constructor( - protected actionManager: ActionManager, - protected config: IActionConfig - ) { - super(config) - this.id = config.id ?? uuid() - - if (config.keyBinding) { - const keyBindings = Array.isArray(config.keyBinding) - ? config.keyBinding - : [config.keyBinding] - - keyBindings.forEach((keyBinding) => - this.addKeyBinding( - KeyBinding.fromStrKeyCode( - actionManager.keyBindingManager, - keyBinding, - true, - config.prevent - ) - ) - ) - } - } - - get keyBinding() { - return this._keyBinding - } - - addKeyBinding(keyBinding: KeyBinding) { - this._keyBinding = keyBinding - keyBinding.on(() => this.trigger()) - return this - } - disposeKeyBinding() { - if (this._keyBinding) this._keyBinding.dispose() - } - - dispose() { - this.actionManager.disposeAction(this.id) - this.disposeKeyBinding() - } -} diff --git a/src/components/Actions/ActionManager.ts b/src/components/Actions/ActionManager.ts deleted file mode 100644 index 62e4e9b27..000000000 --- a/src/components/Actions/ActionManager.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Action } from './Action' -import { IActionConfig } from './SimpleAction' -import { del, set, shallowReactive } from 'vue' -import type { KeyBindingManager } from './KeyBindingManager' -import { v4 as uuid } from 'uuid' -import { ISubmenuConfig } from '../ContextMenu/showContextMenu' - -export class ActionManager { - type = 'submenu' - public state: Record< - string, - | Action - | { type: 'divider' } - | { - type: 'submenu' - icon: string - name: string - submenu: Submenu - } - > = shallowReactive({}) - - constructor(public readonly _keyBindingManager?: KeyBindingManager) {} - - get keyBindingManager() { - if (!this._keyBindingManager) - throw new Error( - `No keyBindingManager was defined for this actionManager` - ) - - return this._keyBindingManager - } - - create(actionConfig: IActionConfig) { - const action = new Action(this, actionConfig) - set(this.state, action.id, action) - return action - } - getAction(actionId: string) { - return this.state[actionId] - } - getAllActions() { - return Object.values(this.state).filter( - (action) => action.type === 'action' - ) - } - getAllElements() { - return Object.values(this.state) - } - - /** - * This is used by some classes that use an action manager as an abstraction to render action lists. - * e.g. showContextMenu(...) - */ - addDivider() { - this.state[uuid()] = { type: 'divider' } - } - addSubMenu(submenuConfig: ISubmenuConfig) { - const submenu = new Submenu() - - submenuConfig.actions.forEach((action) => { - if (action === null) return - - if (action.type === 'divider') { - submenu.addDivider() - } else { - submenu.create(action) - } - }) - this.state[uuid()] = { - type: 'submenu', - icon: submenuConfig.icon, - name: submenuConfig.name, - submenu, - } - } - disposeAction(actionId: string) { - del(this.state, actionId) - } - dispose() { - Object.values(this.state).forEach((action) => - action.type === 'action' ? action.dispose() : null - ) - } - - async trigger(actionId: string) { - if (!this.state[actionId] || this.state[actionId].type !== 'action') - throw new Error( - `Failed to trigger "${actionId}": Action does not exist.` - ) - - // This must be an action because of the check above - await (this.state[actionId]).trigger() - } -} - -export class Submenu extends ActionManager { - type = 'submenu' -} diff --git a/src/components/Actions/ActionViewer.vue b/src/components/Actions/ActionViewer.vue deleted file mode 100644 index 92432529c..000000000 --- a/src/components/Actions/ActionViewer.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/src/components/Actions/Actions.ts b/src/components/Actions/Actions.ts deleted file mode 100644 index 6c0a45a68..000000000 --- a/src/components/Actions/Actions.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { TextTab } from '../Editors/Text/TextTab' -import { TreeTab } from '../Editors/TreeEditor/Tab' -import { clearAllNotifications } from '../Notifications/create' -import { FileTab } from '../TabSystem/FileTab' -import { fullScreenAction } from '../TabSystem/TabContextMenu/Fullscreen' -import { ViewCompilerOutput } from '../UIElements/DirectoryViewer/ContextMenu/Actions/ViewCompilerOutput' -import { App } from '/@/App' -import { platformRedoBinding } from '/@/utils/constants' -import { platform } from '/@/utils/os' - -export function setupActions(app: App) { - addViewActions(app) - addToolActions(app) - addEditActions(app) -} - -function addEditActions(app: App) { - app.actionManager.create({ - icon: 'mdi-undo', - name: 'actions.undo.name', - description: 'actions.undo.description', - keyBinding: 'Ctrl + Z', - prevent: (el) => el.tagName === 'INPUT' || el.tagName === 'TEXTAREA', - onTrigger: () => { - const currentTab = app.tabSystem?.selectedTab - if (currentTab instanceof TreeTab) currentTab.treeEditor.undo() - else if (currentTab instanceof TextTab) - currentTab.editorInstance.trigger('toolbar', 'undo', null) - else document.execCommand('undo') - }, - }) - - app.actionManager.create({ - icon: 'mdi-redo', - name: 'actions.redo.name', - description: 'actions.redo.description', - keyBinding: platformRedoBinding, - prevent: (el) => el.tagName === 'INPUT' || el.tagName === 'TEXTAREA', - onTrigger: () => { - const currentTab = app.tabSystem?.selectedTab - if (currentTab instanceof TreeTab) currentTab.treeEditor.redo() - else if (currentTab instanceof TextTab) - currentTab.editorInstance.trigger('toolbar', 'redo', null) - else document.execCommand('redo') - }, - }) - - const blockActions = new Set(['INPUT', 'TEXTAREA']) - - app.actionManager.create({ - icon: 'mdi-content-copy', - name: 'actions.copy.name', - description: 'actions.copy.description', - keyBinding: 'Ctrl + C', - prevent: (element) => { - return blockActions.has(element.tagName) - }, - onTrigger: () => app.tabSystem?.selectedTab?.copy(), - }) - - app.actionManager.create({ - icon: 'mdi-content-cut', - name: 'actions.cut.name', - description: 'actions.cut.description', - keyBinding: 'Ctrl + X', - prevent: (element) => { - return blockActions.has(element.tagName) - }, - onTrigger: () => app.tabSystem?.selectedTab?.cut(), - }) - - app.actionManager.create({ - icon: 'mdi-content-paste', - name: 'actions.paste.name', - description: 'actions.paste.description', - keyBinding: 'Ctrl + V', - prevent: (element) => { - return blockActions.has(element.tagName) - }, - onTrigger: () => app.tabSystem?.selectedTab?.paste(), - }) -} - -function addToolActions(app: App) { - app.actionManager.create({ - icon: 'mdi-folder-refresh-outline', - name: 'general.reloadBridge.name', - description: 'general.reloadBridge.description', - keyBinding: 'Ctrl + R', - onTrigger: () => { - location.reload() - }, - }) - - app.actionManager.create({ - id: 'bridge.action.refreshProject', - icon: 'mdi-folder-refresh-outline', - name: 'packExplorer.refresh.name', - description: 'packExplorer.refresh.description', - keyBinding: - platform() === 'win32' ? 'Ctrl + Alt + R' : 'Ctrl + Meta + R', - onTrigger: async () => { - if (app.isNoProjectSelected) return - await app.projectManager.projectReady.fired - await app.project.refresh() - }, - }) - - app.actionManager.create({ - icon: 'mdi-reload', - name: 'actions.reloadAutoCompletions.name', - description: 'actions.reloadAutoCompletions.description', - keyBinding: 'Ctrl + Shift + R', - onTrigger: async () => { - if (app.isNoProjectSelected) return - await app.projectManager.projectReady.fired - app.project.jsonDefaults.reload() - }, - }) - - app.actionManager.create({ - icon: 'mdi-puzzle-outline', - name: 'actions.reloadExtensions.name', - description: 'actions.reloadExtensions.description', - onTrigger: async () => { - // Global extensions - app.extensionLoader.disposeAll() - app.extensionLoader.loadExtensions() - if (app.isNoProjectSelected) return - await app.projectManager.projectReady.fired - // Local extensions - app.project.extensionLoader.disposeAll() - app.project.extensionLoader.loadExtensions() - }, - }) - - app.actionManager.create({ - icon: 'mdi-cancel', - name: 'actions.clearAllNotifications.name', - description: 'actions.clearAllNotifications.description', - onTrigger: () => clearAllNotifications(), - }) -} - -function addViewActions(app: App) { - app.actionManager.create({ - icon: 'mdi-folder-outline', - name: 'toolbar.view.togglePackExplorer.name', - description: 'toolbar.view.togglePackExplorer.description', - keyBinding: 'Ctrl + Shift + E', - onTrigger: () => { - App.sidebar.elements.packExplorer.click() - }, - }) - - app.actionManager.create({ - icon: 'mdi-file-search-outline', - name: 'toolbar.view.openFileSearch.name', - description: 'toolbar.view.openFileSearch.description', - keyBinding: 'Ctrl + Shift + F', - onTrigger: () => { - App.sidebar.elements.fileSearch.click() - }, - }) - - const fullscreenAction = fullScreenAction() - if (fullscreenAction) app.actionManager.create(fullscreenAction) - - app.actionManager.create({ - icon: 'mdi-chevron-right', - name: 'toolbar.view.nextTab.name', - description: 'toolbar.view.nextTab.description', - keyBinding: platform() === 'darwin' ? 'Meta + Tab' : 'Ctrl + Tab', - onTrigger: () => { - if (app.tabSystem?.hasRecentTab()) { - app.tabSystem?.selectRecentTab() - } else { - app.tabSystem?.selectNextTab() - } - }, - }) - - app.actionManager.create({ - icon: 'mdi-chevron-left', - name: 'toolbar.view.previousTab.name', - description: 'toolbar.view.previousTab.description', - keyBinding: - platform() === 'darwin' - ? 'Meta + Shift + Tab' - : 'Ctrl + Shift + Tab', - onTrigger: () => { - app.tabSystem?.selectPreviousTab() - }, - }) - - app.actionManager.create({ - icon: 'mdi-arrow-u-left-bottom', - name: 'toolbar.view.cursorUndo.name', - description: 'toolbar.view.cursorUndo.description', - keyBinding: 'ctrl + mouseBack', - onTrigger: async () => { - const tabSystem = app.project.tabSystem - if (!tabSystem) return - // Await monacoEditor being created - await tabSystem.fired - tabSystem?.monacoEditor?.trigger('keybinding', 'cursorUndo', null) - }, - }) - - app.actionManager.create({ - icon: 'mdi-arrow-u-right-top', - name: 'toolbar.view.cursorRedo.name', - description: 'toolbar.view.cursorRedo.description', - keyBinding: 'mouseForward', - onTrigger: async () => { - const tabSystem = app.project.tabSystem - if (!tabSystem) return - // Await monacoEditor being created - await tabSystem.fired - tabSystem?.monacoEditor?.trigger('keybinding', 'cursorRedo', null) - }, - }) - - app.actionManager.create(ViewCompilerOutput(undefined, true)) - - app.actionManager.create({ - icon: 'mdi-puzzle-outline', - name: 'actions.viewExtensionsFolder.name', - description: 'actions.viewExtensionsFolder.description', - onTrigger: async () => { - const extensionsFolder = await app.fileSystem.getDirectoryHandle( - '~local/extensions' - ) - app.viewFolders.addDirectoryHandle({ - directoryHandle: extensionsFolder, - }) - }, - }) - - app.actionManager.create({ - icon: 'mdi-pencil-outline', - name: 'actions.toggleReadOnly.name', - description: 'actions.toggleReadOnly.description', - onTrigger: () => { - const currentTab = app.tabSystem?.selectedTab - if ( - !(currentTab instanceof FileTab) || - currentTab.readOnlyMode === 'forced' - ) - return - currentTab.setReadOnly( - currentTab.readOnlyMode === 'manual' ? 'off' : 'manual' - ) - }, - }) -} diff --git a/src/components/Actions/KeyBinding.ts b/src/components/Actions/KeyBinding.ts deleted file mode 100644 index 7cfe3d993..000000000 --- a/src/components/Actions/KeyBinding.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { KeyBindingManager } from './KeyBindingManager' -import { fromStrKeyCode, toStrKeyCode } from './Utils' - -export interface IKeyBindingConfig { - key: string - shiftKey?: boolean - ctrlKey?: boolean - altKey?: boolean - metaKey?: boolean - prevent?: (element: HTMLElement) => boolean -} - -export class KeyBinding extends EventDispatcher { - constructor( - protected keyBindingManager: KeyBindingManager, - protected config: IKeyBindingConfig - ) { - super() - } - - static fromStrKeyCode( - keyBindingManager: KeyBindingManager, - keyCode: string, - forceWindowsCtrl = false, - prevent: IKeyBindingConfig['prevent'] - ) { - return keyBindingManager.create({ - ...fromStrKeyCode(keyCode, forceWindowsCtrl), - prevent, - }) - } - toStrKeyCode() { - return toStrKeyCode(this.config) - } - - async trigger() { - this.dispatch() - } - prevent(element: HTMLElement) { - if (typeof this.config.prevent === 'function') - return this.config.prevent(element) - return false - } - - dispose() { - this.keyBindingManager.disposeKeyBinding(this.toStrKeyCode()) - } -} diff --git a/src/components/Actions/KeyBindingManager.ts b/src/components/Actions/KeyBindingManager.ts deleted file mode 100644 index b4b6a8578..000000000 --- a/src/components/Actions/KeyBindingManager.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { platform } from '/@/utils/os' -import { IKeyBindingConfig, KeyBinding } from './KeyBinding' -import { toStrKeyCode } from './Utils' -import { del, set, shallowReactive } from 'vue' - -const IGNORE_KEYS = ['Control', 'Alt', 'Meta'] - -interface IKeyEvent { - key: string - altKey: boolean - ctrlKey: boolean - shiftKey: boolean - metaKey: boolean - target: EventTarget | null - preventDefault: () => void - stopImmediatePropagation: () => void -} -export class KeyBindingManager { - protected state: Record = shallowReactive({}) - protected lastTimeStamp = 0 - - protected onKeydown = (event: IKeyEvent) => { - const { key, ctrlKey, altKey, metaKey, shiftKey } = event - if (IGNORE_KEYS.includes(key)) return - - const keyCode = toStrKeyCode({ - key, - ctrlKey: platform() === 'darwin' ? metaKey : ctrlKey, - altKey, - metaKey: platform() === 'darwin' ? ctrlKey : metaKey, - shiftKey, - }) - - const keyBinding = this.state[keyCode] - - if (keyBinding && this.lastTimeStamp + 100 < Date.now()) { - if (keyBinding.prevent(event.target as HTMLElement)) return - - this.lastTimeStamp = Date.now() - event.preventDefault() - event.stopImmediatePropagation() - keyBinding.trigger() - } - } - protected onMouseDown = (event: MouseEvent) => { - let buttonName = null - switch (event.button) { - case 0: - buttonName = 'Left' - break - case 1: - buttonName = 'Middle' - break - case 2: - buttonName = 'Right' - break - case 3: - buttonName = 'Back' - break - case 4: - buttonName = 'Forward' - break - default: - console.error(`Unknown mouse button: ${event.button}`) - } - if (!buttonName) return - - this.onKeydown({ - key: `mouse${buttonName}`, - ctrlKey: event.ctrlKey, - altKey: event.altKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - target: event.target, - preventDefault: () => event.preventDefault(), - stopImmediatePropagation: () => event.stopImmediatePropagation(), - }) - } - - constructor(protected element: HTMLDivElement | Document = document) { - // @ts-ignore TypeScript isn't smart enough to understand that the type "KeyboardEvent" is correct - element.addEventListener('keydown', this.onKeydown) - - // @ts-ignore TypeScript isn't smart enough to understand that the type "MouseEvent" is correct - element.addEventListener('mousedown', this.onMouseDown) - } - - create(keyBindingConfig: IKeyBindingConfig) { - const keyBinding = new KeyBinding(this, keyBindingConfig) - const keyCode = keyBinding.toStrKeyCode() - - if (this.state[keyCode]) - throw new Error( - `KeyBinding with keyCode "${keyCode}" already exists!` - ) - - set(this.state, keyCode, keyBinding) - return keyBinding - } - disposeKeyBinding(keyCode: string) { - del(this.state, keyCode) - } - - dispose() { - // @ts-ignore TypeScript isn't smart enough to understand that the type "KeyboardEvent" is correct - this.element.removeEventListener('keydown', this.onKeydown) - } -} diff --git a/src/components/Actions/SimpleAction.ts b/src/components/Actions/SimpleAction.ts deleted file mode 100644 index af3e9e169..000000000 --- a/src/components/Actions/SimpleAction.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { IKeyBindingConfig } from './KeyBinding' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { v4 as uuid } from 'uuid' - -export interface IActionConfig { - type?: 'action' - id?: string - icon?: string - name?: string - color?: string - description?: string - isDisabled?: (() => boolean) | boolean - keyBinding?: string | string[] - prevent?: IKeyBindingConfig['prevent'] - onTrigger: (action: SimpleAction) => Promise | unknown -} - -export class SimpleAction extends EventDispatcher { - public readonly type = 'action' - id: string - protected addPadding = false - - constructor(protected config: IActionConfig) { - super() - this.id = config.id ?? uuid() - } - - //#region GETTERS - get name() { - return this.config.name - } - get icon() { - return this.config.icon - } - get description() { - return this.config.description - } - get color() { - return this.config.color - } - get isDisabled() { - if (typeof this.config.isDisabled === 'function') - return this.config.isDisabled?.() ?? false - return this.config.isDisabled ?? false - } - //#endregion - - getConfig() { - return this.config - } - - async trigger() { - if (this.isDisabled) return - this.dispatch() - return await this.config.onTrigger(this) - } - - withPadding() { - const action = new SimpleAction(this.config) - action.addPadding = true - return action - } -} diff --git a/src/components/Actions/Utils.ts b/src/components/Actions/Utils.ts deleted file mode 100644 index bff692300..000000000 --- a/src/components/Actions/Utils.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { platform } from '/@/utils/os' -import { IKeyBindingConfig } from './KeyBinding' - -export function fromStrKeyCode(keyCode: string, forceWindowsCtrl = false) { - const parts = keyCode.toLowerCase().split(' + ') - const keyBinding: IKeyBindingConfig = { key: '' } - - parts.forEach((p) => { - if ( - p === '⌘' || - ((platform() !== 'darwin' || forceWindowsCtrl) && p === 'ctrl') - ) { - keyBinding.ctrlKey = true - } else if (p === '⌥' || p === 'alt') { - keyBinding.altKey = true - } else if (p === '⇧' || p === 'shift') { - keyBinding.shiftKey = true - } else if (p === '⌃' || p === 'meta') { - keyBinding.metaKey = true - } else { - keyBinding.key = p - } - }) - - return keyBinding -} - -export function toStrKeyCode({ - key, - ctrlKey, - altKey, - shiftKey, - metaKey, -}: IKeyBindingConfig) { - const p = platform() - - let code = key.toUpperCase() - if (shiftKey) { - if (p === 'darwin') code = '⇧ + ' + code - else code = 'Shift + ' + code - } - if (altKey) { - if (p === 'darwin') code = '⌥ + ' + code - else code = 'Alt + ' + code - } - if (metaKey) { - if (p === 'darwin') code = '⌃ + ' + code - else code = 'Meta + ' + code - } - if (ctrlKey) { - if (p === 'darwin') code = '⌘ + ' + code - else code = 'Ctrl + ' + code - } - - return code -} diff --git a/src/components/App/Icon/Blockbench.vue b/src/components/App/Icon/Blockbench.vue deleted file mode 100644 index a7b21eb83..000000000 --- a/src/components/App/Icon/Blockbench.vue +++ /dev/null @@ -1,70 +0,0 @@ - - diff --git a/src/components/App/Icon/IconMap.ts b/src/components/App/Icon/IconMap.ts deleted file mode 100644 index c9cf33f05..000000000 --- a/src/components/App/Icon/IconMap.ts +++ /dev/null @@ -1,5 +0,0 @@ -import BlockbenchIcon from './Blockbench.vue' - -export const iconMap = { - blockbench: BlockbenchIcon, -} diff --git a/src/components/App/Install.ts b/src/components/App/Install.ts deleted file mode 100644 index ff4721c09..000000000 --- a/src/components/App/Install.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Signal } from '../Common/Event/Signal' -import { Notification } from '../Notifications/Notification' - -export class InstallApp extends Notification { - public readonly isInstallable = new Signal() - public readonly isInstalled = new Signal() - protected installEvent!: any - - constructor() { - super({ - message: 'sidebar.notifications.installApp.message', - color: 'primary', - icon: 'mdi-download', - textColor: 'white', - isVisible: false, - }) - - window.addEventListener('beforeinstallprompt', (event: any) => - this.onInstallPrompt(event) - ) - window.addEventListener('appinstalled', () => this.dispose()) - this.addClickHandler(() => this.prompt()) - } - - onInstallPrompt(event: any) { - event.preventDefault() - this.installEvent = event - this.show() - this.isInstallable.dispatch() - } - - prompt() { - if (this.installEvent) { - this.installEvent.prompt() - - this.installEvent.userChoice.then((choice: any) => { - if (choice.outcome === 'accepted') { - this.dispose() - this.isInstalled.dispatch() - } - }) - } else { - this.dispose() - } - } -} diff --git a/src/components/App/Mobile.ts b/src/components/App/Mobile.ts deleted file mode 100644 index 92cd8a751..000000000 --- a/src/components/App/Mobile.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ref, watch } from 'vue' -import { Framework } from 'vuetify' -import { EventDispatcher } from '../Common/Event/EventDispatcher' -import { App } from '/@/App' - -export class Mobile { - public readonly change = new EventDispatcher() - public readonly is = ref(this.isCurrentDevice()) - - constructor(protected vuetify: Framework) { - watch(vuetify.breakpoint, () => { - this.is.value = this.isCurrentDevice() - this.change.dispatch(vuetify.breakpoint.mobile) - }) - - App.getApp().then(() => { - setTimeout( - () => this.change.dispatch(vuetify.breakpoint.mobile), - 10 - ) - }) - } - - isCurrentDevice() { - return this.vuetify?.breakpoint?.mobile - } -} diff --git a/src/components/App/Tauri/TauriUpdater.ts b/src/components/App/Tauri/TauriUpdater.ts deleted file mode 100644 index 50ce3b002..000000000 --- a/src/components/App/Tauri/TauriUpdater.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { createNotification } from '../../Notifications/create' -import { checkUpdate, installUpdate } from '@tauri-apps/api/updater' -import { relaunch } from '@tauri-apps/api/process' -import { App } from '/@/App' -import { openUpdateWindow } from '../../Windows/Update/UpdateWindow' - -async function installTauriUpdate() { - const app = await App.getApp() - - // Task to indicate background progress - const task = app.taskManager.create({ - icon: 'mdi-update', - name: 'sidebar.notifications.installingUpdate.name', - description: 'sidebar.notifications.installingUpdate.description', - - indeterminate: true, - }) - - // Install the update - await installUpdate() - // Dispose task - task.complete() - - // Create a notification to indicate that the app needs to be restarted - createNotification({ - icon: 'mdi-update', - color: 'primary', - message: 'sidebar.notifications.restartToApplyUpdate.message', - textColor: 'white', - onClick: async () => { - // ...and finally relaunch the app - await relaunch() - }, - }) -} - -checkUpdate() - .then(async (update) => { - if (!update.shouldUpdate) return - - const notification = createNotification({ - icon: 'mdi-update', - color: 'primary', - message: 'sidebar.notifications.updateAvailable.message', - textColor: 'white', - onClick: async () => { - // Dispose the notification - notification.dispose() - - // Install the update - await installTauriUpdate() - }, - }) - - openUpdateWindow({ - // Version should always be defined because we're checking update.shouldUpdate before - version: update.manifest?.version ?? '2.x', - onClick: () => { - // Dispose the notification - notification.dispose() - - // Install the update - installTauriUpdate() - }, - }) - }) - .catch((err: any) => { - console.error(`[TauriUpdater] ${err}`) - - return null - }) diff --git a/src/components/App/Vue.ts b/src/components/App/Vue.ts deleted file mode 100644 index 6f01f5aa2..000000000 --- a/src/components/App/Vue.ts +++ /dev/null @@ -1,17 +0,0 @@ -import Vue from 'vue' -import Vuetify from 'vuetify' -import { LocaleManager } from '../Locales/Manager' -import { vuetify } from './Vuetify' -import AppComponent from '/@/App.vue' - -Vue.use(Vuetify) -Vue.config.productionTip = false - -export const vue = new Vue({ - vuetify, - render: (h) => h(AppComponent), -}) - -LocaleManager.setDefaultLanguage().then(() => { - vue.$mount('#app') -}) diff --git a/src/components/App/Vuetify.ts b/src/components/App/Vuetify.ts deleted file mode 100644 index eb0cfbc4f..000000000 --- a/src/components/App/Vuetify.ts +++ /dev/null @@ -1,23 +0,0 @@ -import Vuetify from 'vuetify' -import { iconMap } from './Icon/IconMap' - -export const vuetify = new Vuetify({ - breakpoint: { - mobileBreakpoint: 'xs', - }, - icons: { - iconfont: 'mdi', - values: Object.fromEntries( - Object.entries(iconMap).map(([name, icon]) => [ - name, - { component: icon }, - ]) - ), - }, - theme: { - options: { - customProperties: true, - variations: false, - }, - }, -}) diff --git a/src/components/BedrockWorlds/BlockLibrary/BlockLibrary.ts b/src/components/BedrockWorlds/BlockLibrary/BlockLibrary.ts deleted file mode 100644 index 4c7ed6cb0..000000000 --- a/src/components/BedrockWorlds/BlockLibrary/BlockLibrary.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { AnyDirectoryHandle } from '../../FileSystem/Types' -import { FileSystem } from '../../FileSystem/FileSystem' -import { DataLoader } from '../../Data/DataLoader' -import { loadImage } from './loadImage' -import { toBlob } from '/@/utils/canvasToBlob' - -export type TDirection = - | 'up' - | 'down' - | 'north' - | 'west' - | 'east' - | 'south' - | 'side' - | 'all' - -interface IBlockLibEntry { - faces: { - [key in TDirection]?: { - uvOffset?: [number, number] - texturePath: string - overlayColor?: [number, number, number] - } - } -} - -export class BlockLibrary { - protected fileSystem: FileSystem - protected dataLoader: DataLoader - protected _missingTexture?: ImageBitmap - protected _tileMap?: HTMLCanvasElement - - get missingTexture() { - if (!this._missingTexture) - throw new Error( - `Trying to access missingTexture before BlockLibrary was setup` - ) - return this._missingTexture - } - get tileMap() { - if (!this._tileMap) - throw new Error('Trying to access tileMap before it was created') - return this._tileMap - } - - protected library = new Map() - - constructor(baseDirectory: AnyDirectoryHandle) { - this.fileSystem = new FileSystem(baseDirectory) - this.dataLoader = new DataLoader() - } - - async setup() { - if (!this.dataLoader.hasFired) await this.dataLoader.loadData() - - const file = await this.dataLoader.readFile( - 'data/packages/minecraftBedrock/vanilla/missing_tile.png' - ) - - this._missingTexture = await createImageBitmap( - file.isVirtual ? await file.toBlobFile() : file - ) - - const blocksJson = Object.assign( - await this.dataLoader.readJSON( - 'data/packages/minecraftBedrock/vanilla/blocks.json' - ), - await this.fileSystem.readJSON('RP/blocks.json') - ) - const terrainTexture = Object.assign( - ( - await this.dataLoader.readJSON( - 'data/packages/minecraftBedrock/vanilla/terrain_texture.json' - ) - ).texture_data, - (await this.fileSystem.readJSON('RP/textures/terrain_texture.json')) - .texture_data - ) - console.log(terrainTexture) - - for (let id in blocksJson) { - const { textures } = blocksJson[id] - - // Normalize identifiers to make sure they include a namespace - if (id.indexOf(':') === -1) id = `minecraft:${id}` - - if (typeof textures === 'string') - this.library.set(id, { - faces: { - all: { - texturePath: `RP/${this.chooseTexture( - terrainTexture[textures] - )}`, - }, - }, - }) - else if (typeof textures === 'object') { - const entry: IBlockLibEntry = { faces: {} } - - for (let direction in textures) { - const textureLookup = textures[direction] - - entry.faces[direction as TDirection] = { - texturePath: `RP/${this.chooseTexture( - terrainTexture[textureLookup] - )}`, - } - } - - this.library.set(id, entry) - } - } - - console.log(this.library) - - return await this.createTileMap() - } - - protected chooseTexture(textureData: { - textures: - | string - | ( - | string - | { - path?: string - variations?: { path: string; weight: number }[] - } - )[] - | { path?: string; variations?: { path: string; weight: number }[] } - }) { - let { textures } = textureData - - if (Array.isArray(textures)) textures = textures[0] - - if (typeof textures === 'string') return textures - else if (typeof textures === 'object') - return textures.variations?.[0]?.path ?? textures.path - } - - protected async createTileMap() { - const canvas = document.createElement('canvas') - const rowLength = Math.ceil(Math.sqrt(this.library.size * 8)) - canvas.width = rowLength * 16 - canvas.height = canvas.width - const context = canvas.getContext('2d') - if (!context) throw new Error(`Failed to initialize canvas 2d context`) - - context.imageSmoothingEnabled = false - - let currentUVOffset = 0 - for (const blockData of this.library.values()) { - for (const [dir, { texturePath, overlayColor }] of Object.entries( - blockData.faces - )) { - const x = currentUVOffset % rowLength - const y = Math.floor(currentUVOffset / rowLength) - const image = - (await loadImage(this.fileSystem, texturePath)) ?? - this.missingTexture - context.drawImage(image, x * 16, y * 16, 16, 16) - - blockData.faces[dir as TDirection] = { - ...blockData.faces[dir as TDirection], - uvOffset: [x, y], - texturePath: - blockData.faces[dir as TDirection]?.texturePath!, - } - currentUVOffset++ - } - } - - await this.fileSystem.writeFile( - '.bridge/bedrockWorld/uvMap.png', - await toBlob(canvas) - ) - - this._tileMap = canvas - - return canvas - } - - getTileMapSize() { - return [this.tileMap.width, this.tileMap.height] - } - getVoxelUv(identifier: string, faces: TDirection[]) { - const entry = this.library.get(identifier) - if (!entry) return [0, 0] - - for (let face of faces) { - if (entry.faces[face] !== undefined) - return entry.faces[face]!.uvOffset! - } - - if (entry.faces.all) return entry.faces.all.uvOffset! - - return [0, 0] - } -} diff --git a/src/components/BedrockWorlds/BlockLibrary/loadImage.ts b/src/components/BedrockWorlds/BlockLibrary/loadImage.ts deleted file mode 100644 index ccbb2aa8c..000000000 --- a/src/components/BedrockWorlds/BlockLibrary/loadImage.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { findFileExtension } from '/@/components/FileSystem/FindFile' -import { FileSystem } from '/@/components/FileSystem/FileSystem' - -export async function loadImage(fileSystem: FileSystem, filePath: string) { - // TODO: Support .tga files - const realPath = await findFileExtension(fileSystem, filePath, [ - '.png', - '.jpg', - '.jpeg', - ]) - - if (!realPath) return null - - const file = await fileSystem.readFile(realPath) - - return await createImageBitmap( - file.isVirtual ? await file.toBlobFile() : file - ) -} diff --git a/src/components/BedrockWorlds/LevelDB/Comparators/Bytewise.ts b/src/components/BedrockWorlds/LevelDB/Comparators/Bytewise.ts deleted file mode 100644 index facb865b4..000000000 --- a/src/components/BedrockWorlds/LevelDB/Comparators/Bytewise.ts +++ /dev/null @@ -1,34 +0,0 @@ -export class BytewiseComparator { - constructor() {} - - public compare(a: Uint8Array, b: Uint8Array): number { - if (a.length === b.length) { - return this.compareFixedLength(a, b) - } else { - const minLength = Math.min(a.length, b.length) - const res = this.compareFixedLength( - a.slice(0, minLength), - b.slice(0, minLength) - ) - - if (res !== 0) return res - - return a.length - b.length > 0 ? 1 : -1 - } - } - - /** - * Assumption: a and b are of equal length - */ - protected compareFixedLength(a: Uint8Array, b: Uint8Array) { - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { - const res = a[i] - b[i] - - return res > 0 ? 1 : -1 - } - } - - return 0 - } -} diff --git a/src/components/BedrockWorlds/LevelDB/FileMetaData.ts b/src/components/BedrockWorlds/LevelDB/FileMetaData.ts deleted file mode 100644 index e62532541..000000000 --- a/src/components/BedrockWorlds/LevelDB/FileMetaData.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Table } from './Table/Table' - -export interface IFileMetaData { - fileNumber: number - fileSize: number - smallestKey: Uint8Array - largestKey: Uint8Array -} -export class FileMetaData { - public fileNumber: number - public fileSize: number - public smallestKey: Uint8Array - public largestKey: Uint8Array - public table?: Table - - constructor({ - fileNumber, - fileSize, - smallestKey, - largestKey, - }: IFileMetaData) { - this.fileNumber = fileNumber - this.fileSize = fileSize - this.smallestKey = smallestKey - this.largestKey = largestKey - } -} diff --git a/src/components/BedrockWorlds/LevelDB/Key/AsUsableKey.ts b/src/components/BedrockWorlds/LevelDB/Key/AsUsableKey.ts deleted file mode 100644 index 2cc789b73..000000000 --- a/src/components/BedrockWorlds/LevelDB/Key/AsUsableKey.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function asUsableKey(key: Uint8Array) { - return key.slice(0, key.length - 8) -} diff --git a/src/components/BedrockWorlds/LevelDB/Key/GetKeyType.ts b/src/components/BedrockWorlds/LevelDB/Key/GetKeyType.ts deleted file mode 100644 index 9a86e7993..000000000 --- a/src/components/BedrockWorlds/LevelDB/Key/GetKeyType.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function getKeyType(key: Uint8Array) { - return key.slice(key.length - 8, key.length - 7)[0] -} diff --git a/src/components/BedrockWorlds/LevelDB/LevelDB.ts b/src/components/BedrockWorlds/LevelDB/LevelDB.ts deleted file mode 100644 index 98c0aac6f..000000000 --- a/src/components/BedrockWorlds/LevelDB/LevelDB.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { Manifest } from './Manifest' -import { LogReader } from './LogReader' -import { MemoryCache } from './MemoryCache' -import { ERequestState } from './RequestStatus' - -export class LevelDB { - protected _manifest?: Manifest - protected _memoryCache?: MemoryCache - - constructor(protected dbDirectory: AnyDirectoryHandle) {} - - get manifest() { - if (!this._manifest) - throw new Error(`DB manifest not defined yet; did you open the DB?`) - return this._manifest - } - get memoryCache() { - if (!this._memoryCache) - throw new Error( - `DB memory cache not defined yet; did you open the DB?` - ) - return this._memoryCache - } - - async open() { - this._manifest = new Manifest(this.dbDirectory) - console.log(this.manifest) - await this.manifest.load() - - const logReader = new LogReader() - await logReader.readLogFile( - await this.dbDirectory.getFileHandle( - `${this.manifest.version.logNumber - ?.toString() - .padStart(6, '0')}.log` - ) - ) - this._memoryCache = new MemoryCache() - this.memoryCache.load(logReader) - } - - get(key: Uint8Array) { - let res = this.memoryCache.get(key) - if (res.state === ERequestState.Success) return res.value! - else if (res.state === ERequestState.Deleted) return null - - res = this.manifest.get(key) - if (res.value === undefined || res.value.length === 0) return null - - return res.value - } - keys() { - return [...this.memoryCache.keys(), ...this.manifest.keys()] - } -} diff --git a/src/components/BedrockWorlds/LevelDB/LogReader.ts b/src/components/BedrockWorlds/LevelDB/LogReader.ts deleted file mode 100644 index b2dd86fe2..000000000 --- a/src/components/BedrockWorlds/LevelDB/LogReader.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { AnyFileHandle } from '../../FileSystem/Types' -import { ELogRecordType, Record, UndefinedRecord } from './Record' - -export enum EOperationType { - Delete = 0, - PUT = 1, -} - -export class LogReader { - protected blockSize = 32768 // Size of a single block (32 * 1024 Bytes) - protected headerSize = 4 + 2 + 1 - protected position: number = 0 - protected lastData: Uint8Array | null = null - protected _logFileData?: Uint8Array - - constructor() {} - - get logFileData() { - if (!this._logFileData) - throw new Error( - 'Trying to access logFileData before file was loaded' - ) - return this._logFileData - } - - async readLogFile(fileHandle: AnyFileHandle) { - const file = await fileHandle.getFile() - this._logFileData = new Uint8Array(await file.arrayBuffer()) - - this.position = 0 - } - - readData(logFileData: Uint8Array = this.logFileData) { - let lastRecord = new UndefinedRecord() - - while (this.position < logFileData.length) { - while (true) { - let record = this.readNextRecord(logFileData) - - if (record.type === ELogRecordType.InvalidRecord) { - break - } else if (record.type === ELogRecordType.First) { - lastRecord = record - continue - } else if (record.type === ELogRecordType.Zero) { - // Ignore other record types - continue - } - - if ( - lastRecord.type !== ELogRecordType.Undefined && - (record.type === ELogRecordType.Middle || - record.type == ELogRecordType.Last) - ) { - lastRecord.length! += record.length! - var lastData = lastRecord.data! - lastRecord.data = new Uint8Array( - lastData.length + record.data!.length - ) - lastRecord.data.set(lastData) - lastRecord.data.set(record.data!, lastData.length) - - if (record.type == ELogRecordType.Middle) { - continue - } - - record = lastRecord - record.type = ELogRecordType.Full - } - - if (record.type !== ELogRecordType.Full) { - console.warn(`Read unhandled record of type ${record.type}`) - continue - } - - return record.data! - } - } - - return null - } - - protected readNextRecord(logFileData: Uint8Array) { - // Blocks may be padded if size left is less than the header - const sizeLeft = this.blockSize - (this.position % this.blockSize) - // if (sizeLeft < 7) stream.Seek(sizeLeft, SeekOrigin.Current); - - // Header is checksum (4 bytes), length (2 bytes), type (1 byte). - const header = this.consumeBytes(logFileData, this.headerSize) - if (header.length !== this.headerSize) - return new Record({ type: ELogRecordType.InvalidRecord }) - - const expectedCrc = - header[0] + (header[1] << 8) + (header[2] << 16) + (header[3] << 24) - const length = header[4] + (header[5] << 8) - const type = header[6] - - if (length > logFileData.length) - throw new Error('Not enough data in stream to read') - - const data = this.consumeBytes(logFileData, length) - - // TODO: Calculate CRC - - const record = new Record({ - checksum: expectedCrc, - length, - type, - data, - }) - - // TODO: Check CRC - - return record - } - - protected consumeBytes(logFileData: Uint8Array, byteCount: number) { - const consumed = logFileData.slice( - this.position, - this.position + byteCount - ) - this.position += byteCount - return consumed - } -} diff --git a/src/components/BedrockWorlds/LevelDB/Manifest.ts b/src/components/BedrockWorlds/LevelDB/Manifest.ts deleted file mode 100644 index 9a7472b5f..000000000 --- a/src/components/BedrockWorlds/LevelDB/Manifest.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { AnyDirectoryHandle, AnyFileHandle } from '../../FileSystem/Types' -import { BytewiseComparator } from './Comparators/Bytewise' -import { FileMetaData } from './FileMetaData' -import { asUsableKey } from './Key/AsUsableKey' -import { LogReader } from './LogReader' -import { ERequestState, RequestStatus } from './RequestStatus' -import { Table } from './Table/Table' -import { Uint8ArrayReader } from './Uint8ArrayUtils/Reader' -import { Version } from './Version' - -enum ELogTagType { - Comparator = 1, - LogNumber = 2, - NextFileNumber = 3, - LastSequence = 4, - CompactPointer = 5, - DeletedFile = 6, - NewFile = 7, - PrevLogNumber = 9, -} - -const defaultLdbOperator = 'leveldb.BytewiseComparator' - -export class Manifest { - protected logReader = new LogReader() - protected _version?: Version - protected comparator = new BytewiseComparator() - - get version() { - if (!this._version) - throw new Error( - 'Trying to access version before loading the manifest was done' - ) - - return this._version - } - - constructor(protected dbDirectory: AnyDirectoryHandle) {} - - async load() { - const currentManifest = await this.dbDirectory - .getFileHandle('CURRENT') - .then((fileHandle) => fileHandle.getFile()) - .then((file) => file.text()) - const manifestData = await this.dbDirectory - .getFileHandle(currentManifest.trim()) - .then((fileHandle) => fileHandle.getFile()) - .then((file) => file.arrayBuffer()) - .then((data) => new Uint8Array(data)) - - this._version = this.readVersionEdit(manifestData) - - // For each file in every level, create a Table that represents the backing file - for (const files of this.version.levels.values()) { - for (const file of files) { - file.table = await this.createTable(file.fileNumber) - } - } - - if (defaultLdbOperator !== this.version.comparator) - throw new Error( - `Unsupported comparator: "${this.version.comparator}"` - ) - - console.log(this._version) - } - - get(key: Uint8Array) { - for (const level of this.version.levels.values()) { - for (const file of level) { - const smallestKey = asUsableKey(file.smallestKey) - const largestKey = asUsableKey(file.largestKey) - - if ( - this.comparator.compare(smallestKey, key) < 0 && - this.comparator.compare(largestKey, key) > 0 - ) { - const req = file.table!.get(key) - - if ( - req.state === ERequestState.Success || - req.state === ERequestState.Deleted - ) - return req - } - } - } - - return RequestStatus.createNotFound() - } - keys() { - const keys: Uint8Array[] = [] - - this.forEachFile((file) => { - keys.push(...file.table!.keys()) - }) - - return keys - } - - protected forEachFile(cb: (file: FileMetaData) => void) { - for (const level of this.version.levels.values()) { - for (const file of level) { - cb(file) - } - } - } - - protected readVersionEdit(manifestData: Uint8Array) { - const version = new Version() - - while (true) { - const data = this.logReader.readData(manifestData) - - if (data === null) break - - const reader = new Uint8ArrayReader(data) - - while (reader.hasUnreadBytes) { - const logTag = reader.readVarLong() - - switch (logTag) { - case ELogTagType.Comparator: - version.comparator = reader.readLengthPrefixedString() - break - case ELogTagType.LogNumber: - version.logNumber = reader.readVarLong() - break - case ELogTagType.NextFileNumber: - version.nextFileNumber = reader.readVarLong() - break - case ELogTagType.LastSequence: - version.lastSequence = reader.readVarLong() - break - case ELogTagType.CompactPointer: { - version.compactPointers.set( - reader.readVarLong(), - reader.readLengthPrefixedBytes() - ) - break - } - case ELogTagType.DeletedFile: { - const level = reader.readVarLong() - const fileNumber = reader.readVarLong() - - if (!version.deletedFiles.has(level)) - version.deletedFiles.set(level, new Set()) - - version.deletedFiles.get(level)!.add(fileNumber) - break - } - case ELogTagType.NewFile: { - const level = reader.readVarLong() - const fileNumber = reader.readVarLong() - const fileSize = reader.readVarLong() - const smallestKey = reader.readLengthPrefixedBytes() - const largestKey = reader.readLengthPrefixedBytes() - - const fileMetaData = new FileMetaData({ - fileNumber, - fileSize, - smallestKey, - largestKey, - }) - - if (!version.levels.has(level)) - version.levels.set(level, []) - - version.levels.get(level)!.push(fileMetaData) - break - } - case ELogTagType.PrevLogNumber: - version.previousLogNumber = reader.readVarLong() - break - default: - throw new Error('Unknown log tag: ' + logTag) - } - } - } - - // Cleanup deleted files - const deletedFiles = new Set() - for (const deletedFile of version.deletedFiles.values()) { - for (const fileNumber of deletedFile) { - deletedFiles.add(fileNumber) - } - } - - for (const [levelKey, fileMetaData] of version.levels.entries()) { - version.levels.set( - levelKey, - fileMetaData.filter( - (fileMetaData) => !deletedFiles.has(fileMetaData.fileNumber) - ) - ) - } - - if (!version.comparator) version.comparator = defaultLdbOperator - - return version - } - - protected async createTable(fileNumber: number) { - const fileName = `${fileNumber.toString().padStart(6, '0')}.ldb` - - let fileHandle: AnyFileHandle - try { - fileHandle = await this.dbDirectory.getFileHandle(fileName) - } catch { - throw new Error(`File ${fileName} not found`) - } - - const table = new Table(fileHandle) - await table.load() - - return table - } -} diff --git a/src/components/BedrockWorlds/LevelDB/MemoryCache.ts b/src/components/BedrockWorlds/LevelDB/MemoryCache.ts deleted file mode 100644 index b701c6145..000000000 --- a/src/components/BedrockWorlds/LevelDB/MemoryCache.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { BytewiseComparator } from './Comparators/Bytewise' -import { EOperationType, LogReader } from './LogReader' -import { ERequestState, RequestStatus } from './RequestStatus' -import { Uint8ArrayReader } from './Uint8ArrayUtils/Reader' - -interface ILogEntry { - sequenceNumber: bigint - state: ERequestState - data?: Uint8Array -} - -export class MemoryCache { - protected comparator = new BytewiseComparator() - protected cache: Map = new Map() - protected size = 0 - - load(logReader: LogReader) { - this.cache = new Map() - - let data: Uint8Array | null - while (true) { - data = logReader.readData() - if (data === null) break - - const logEntries = this.decodeData(new Uint8ArrayReader(data)) - for (const [key, value] of logEntries) { - this.cache.set(key, value) - this.size += key.length + (value.data?.length ?? 0) - } - } - } - - protected decodeData(data: Uint8ArrayReader) { - const sequenceNumber = data.readUint64() - const totalOperations = data.readUint32() - - const res: [Uint8Array, ILogEntry][] = [] - - for (let i = 0; i < totalOperations; i++) { - const operation = data.readByte() - const key = data.readLengthPrefixedBytes() - - if (operation === EOperationType.PUT) { - const value = data.readLengthPrefixedBytes() - - res.push([ - key, - { - sequenceNumber, - state: ERequestState.Success, - data: value, - }, - ]) - } else if (operation === EOperationType.Delete) { - res.push([ - key, - { - sequenceNumber, - state: ERequestState.Deleted, - }, - ]) - } else { - throw new Error('Unknown operation type') - } - } - return res - } - - get(key: Uint8Array) { - const entry = this.cache.get(key) - if (entry === undefined) { - return RequestStatus.createNotFound() - } - return new RequestStatus(entry.data) - } - keys() { - return this.cache.keys() - } -} diff --git a/src/components/BedrockWorlds/LevelDB/Record.ts b/src/components/BedrockWorlds/LevelDB/Record.ts deleted file mode 100644 index ae4d7ae91..000000000 --- a/src/components/BedrockWorlds/LevelDB/Record.ts +++ /dev/null @@ -1,41 +0,0 @@ -export enum ELogRecordType { - // Zero is reserved for preallocated files - Zero = 0, - - Full = 1, - - // Data split across multiple records - First = 2, - Middle = 3, - Last = 4, - - // Util - InvalidRecord = Last + 1, - Undefined = Last + 1, -} - -interface IRecord { - type: ELogRecordType - data?: Uint8Array - length?: number - checksum?: number -} -export class Record { - public type: ELogRecordType - public checksum?: number - public length?: number - public data?: Uint8Array - - constructor({ type, checksum, data, length }: IRecord) { - this.type = type - this.checksum = checksum - this.data = data - this.length = length - } -} - -export class UndefinedRecord extends Record { - constructor() { - super({ type: ELogRecordType.Undefined }) - } -} diff --git a/src/components/BedrockWorlds/LevelDB/RequestStatus.ts b/src/components/BedrockWorlds/LevelDB/RequestStatus.ts deleted file mode 100644 index 0e3971362..000000000 --- a/src/components/BedrockWorlds/LevelDB/RequestStatus.ts +++ /dev/null @@ -1,17 +0,0 @@ -export enum ERequestState { - Success, - Deleted, - NotFound, - Undefined, -} - -export class RequestStatus { - constructor(public value?: T, public state = ERequestState.Success) {} - - static createNotFound() { - return new RequestStatus(undefined, ERequestState.NotFound) - } - static createDeleted() { - return new RequestStatus(undefined, ERequestState.Deleted) - } -} diff --git a/src/components/BedrockWorlds/LevelDB/Table/BlockHandle.ts b/src/components/BedrockWorlds/LevelDB/Table/BlockHandle.ts deleted file mode 100644 index ffe95471d..000000000 --- a/src/components/BedrockWorlds/LevelDB/Table/BlockHandle.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ESeekType, Uint8ArrayReader } from '../Uint8ArrayUtils/Reader' -import { unzlibSync, inflateSync } from 'fflate' - -enum ECompressionTypes { - Uncompressed = 0, - Snappy = 1, - Zlib = 2, -} - -export class BlockHandle { - constructor(protected offset: number, protected length: number) {} - - static readBlockHandle(reader: Uint8ArrayReader) { - const offset = reader.readVarLong() - const length = reader.readVarLong() - - return new BlockHandle(offset, length) - } - - getOffset() { - return this.offset - } - getLength() { - return this.length - } - - encode() { - // TODO - } - - readBlock( - reader: Uint8ArrayReader, - length = this.length, - verifyChecksum = false - ) { - /** - * A block looks like this: - * - * block := - * block_data: uint8[] - * type: uint8 - * checksum: uint32 - */ - - reader.seek(this.offset, ESeekType.Start) - let data = reader.read(length) - - const compressionType = reader.readByte() - const checksum = reader.read(4) - - // TODO: Verify checksum - - switch (compressionType) { - case ECompressionTypes.Snappy: - throw new Error('Snappy compression not implemented') - // Do nothing - case ECompressionTypes.Uncompressed: - break - default: { - if (compressionType === ECompressionTypes.Zlib) { - if (data[0] !== 0x78) { - throw new Error('Invalid zlib header') - } - - data = data.slice(2) - } - - data = inflateSync(data) - } - } - - return data - } -} diff --git a/src/components/BedrockWorlds/LevelDB/Table/BlockSeeker.ts b/src/components/BedrockWorlds/LevelDB/Table/BlockSeeker.ts deleted file mode 100644 index 1316aa1f7..000000000 --- a/src/components/BedrockWorlds/LevelDB/Table/BlockSeeker.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { BytewiseComparator } from '../Comparators/Bytewise' -import { asUsableKey } from '../Key/AsUsableKey' -import { ESeekType, Uint8ArrayReader } from '../Uint8ArrayUtils/Reader' -import { BlockHandle } from './BlockHandle' - -export class BlockSeeker { - protected comparator = new BytewiseComparator() - protected reader: Uint8ArrayReader - protected restartCount = 0 // Amount of restart points - protected restartOffset = 0 // Current restart point - protected currentKey: Uint8Array | null = null - protected currentValue: BlockHandle | null = null - - constructor(protected blockData: Uint8Array) { - this.reader = new Uint8ArrayReader(blockData) - this.reader.seek(-4, ESeekType.End) - this.restartCount = this.reader.readUint32() - this.reader.seek(-((1 + this.restartCount) * 4), ESeekType.End) - this.restartOffset = this.reader.getPosition() - - this.reader.seek(0, ESeekType.Start) - } - - getCurrentKey() { - return this.currentKey - } - getCurrentValue() { - this.reader.seek(this.currentValue!.getOffset(), ESeekType.Start) - return this.reader.read(this.currentValue!.getLength()) - } - - binarySearchKey(key: Uint8Array) { - if (this.restartCount === 0) return false - - let low = 0 - let high = this.restartCount - 1 - - while (low < high) { - const mid = (low + high + 1) >> 1 - this.seekToRestart(mid) - - if ( - this.comparator.compare(asUsableKey(this.currentKey!), key) < 0 - ) { - low = mid - } else { - high = mid - 1 - } - } - - this.seekToRestart(low) - - while (this.hasNext()) { - const usableKey = asUsableKey(this.currentKey!) - - if (this.comparator.compare(usableKey, key) >= 0) { - return true - } - this.next() - } - - for (let i = low - 1; i >= 0; i--) { - this.seekToRestart(i) - - while (this.hasNext()) { - const usableKey = asUsableKey(this.currentKey!) - - if (this.comparator.compare(usableKey, key) >= 0) { - return true - } - this.next() - } - } - - return false - } - keys() { - let keys: Uint8Array[] = [] - this.seekToRestart(0) - - while (this.hasNext()) { - keys.push(asUsableKey(this.currentKey!)) - this.next() - } - - return keys - } - - hasNext() { - return this.currentKey !== null && this.currentKey.length !== 0 - } - next() { - if (!this.hasNext()) return false - - return this.parseCurrentIndex() - } - - protected getRestartOffset(index: number) { - if (index < 0) throw new Error('Index must be greater than 0') - if (index >= this.restartCount) - throw new Error('Index must be less than restart count') - - const reader = new Uint8ArrayReader(this.blockData) - reader.seek(this.restartOffset + index * 4, ESeekType.Start) - return reader.readUint32() - } - protected seekToRestart(index: number) { - const offset = this.getRestartOffset(index) - this.reader.unsafelySetPosition(offset) - this.currentKey = null - this.parseCurrentIndex() - } - - protected parseCurrentIndex() { - if (this.reader.getPosition() >= this.reader.getLength()) { - this.currentKey = null - return false - } - - /** - * Read key-val pair - * - * index := - * shared = varint32; - * non_shared = varint32; - * value_length = varint32; - * key_delta = char[non_shared]; - */ - const shared = this.reader.readVarLong() - if (this.currentKey === null && shared !== 0) - throw new Error(`Shared bytes without a currentKey`) - - const nonShared = this.reader.readVarLong() - const valueLength = this.reader.readVarLong() - const keyDelta = this.reader.read(nonShared) - - const combinedKey = new Uint8Array(shared + nonShared) - if (shared !== 0) combinedKey.set(this.currentKey!.slice(0, shared), 0) - combinedKey.set(keyDelta.slice(0, nonShared), shared) - this.currentKey = combinedKey - - this.currentValue = new BlockHandle( - this.reader.getPosition(), - valueLength - ) - this.reader.seek(valueLength, ESeekType.Current) - return true - } -} diff --git a/src/components/BedrockWorlds/LevelDB/Table/Footer.ts b/src/components/BedrockWorlds/LevelDB/Table/Footer.ts deleted file mode 100644 index d292f5ff3..000000000 --- a/src/components/BedrockWorlds/LevelDB/Table/Footer.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { equals } from '../Uint8ArrayUtils/Equals' -import { ESeekType, Uint8ArrayReader } from '../Uint8ArrayUtils/Reader' -import { BlockHandle } from './BlockHandle' - -export class TableFooter { - protected static magicNumber: Uint8Array = new Uint8Array([ - 0x57, - 0xfb, - 0x80, - 0x8b, - 0x24, - 0x75, - 0x47, - 0xdb, - ]) - /** - * Table footer contains padding to always reach 48 bytes - * - * 20 = max length of block handle - * 8 = magic byte sequence length - * - * 48 = (20 * 2) + 8 - */ - protected static footerLength = 48 - - constructor( - public metaIndexBlockHandle: BlockHandle, - public dataIndexBlockHandle: BlockHandle - ) {} - - static read(reader: Uint8ArrayReader) { - reader.seek(-this.footerLength, ESeekType.End) - const footerReader = new Uint8ArrayReader( - reader.read(this.footerLength) - ) - - const metaIndexBlockHandle = BlockHandle.readBlockHandle(footerReader) - const dataIndexBlockHandle = BlockHandle.readBlockHandle(footerReader) - - footerReader.seek(-this.magicNumber.length, ESeekType.End) - const magicNumber = footerReader.read(this.magicNumber.length) - - if (!equals(magicNumber, this.magicNumber)) { - throw new Error('Invalid magic number') - } - - return new TableFooter(metaIndexBlockHandle, dataIndexBlockHandle) - } -} diff --git a/src/components/BedrockWorlds/LevelDB/Table/Table.ts b/src/components/BedrockWorlds/LevelDB/Table/Table.ts deleted file mode 100644 index ccd47e1de..000000000 --- a/src/components/BedrockWorlds/LevelDB/Table/Table.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { AnyFileHandle } from '../../../FileSystem/Types' -import { BytewiseComparator } from '../Comparators/Bytewise' -import { asUsableKey } from '../Key/AsUsableKey' -import { getKeyType } from '../Key/GetKeyType' -import { RequestStatus } from '../RequestStatus' -import { Uint8ArrayReader } from '../Uint8ArrayUtils/Reader' -import { BlockHandle } from './BlockHandle' -import { BlockSeeker } from './BlockSeeker' -import { TableFooter } from './Footer' - -enum EKeyType { - Deleted = 0, - Exists = 1, -} - -export class Table { - protected blockIndex: Uint8Array | null = null - protected metaIndex: Uint8Array | null = null - protected cache = new Map() - protected reader!: Uint8ArrayReader - comparator = new BytewiseComparator() - - constructor(protected fileHandle: AnyFileHandle) {} - - async load() { - const fileData = await this.fileHandle - .getFile() - .then((file) => file.arrayBuffer()) - .then((buffer) => new Uint8Array(buffer)) - this.reader = new Uint8ArrayReader(fileData) - - if (this.blockIndex === null || this.metaIndex === null) { - const footer = TableFooter.read(this.reader) - - this.metaIndex = footer.metaIndexBlockHandle.readBlock(this.reader) - this.blockIndex = footer.dataIndexBlockHandle.readBlock(this.reader) - } - } - - get(key: Uint8Array) { - const blockHandle = this.findBlockHandleInBlockIndex(key) - if (blockHandle === null) { - console.warn(`Expected to find key within this table`) - return RequestStatus.createNotFound() - } - - const block = this.getBlock(blockHandle) - - return this.searchKeyInBlockData(key, block) - } - keys() { - if (this.blockIndex === null) throw new Error('Block index not loaded') - - const blockHelper = new BlockSeeker(this.blockIndex) - return blockHelper.keys() - } - - protected getBlock(blockHandle: BlockHandle) { - if (this.cache.has(blockHandle)) { - return this.cache.get(blockHandle)! - } - - const block = blockHandle.readBlock(this.reader) - if (this.cache.size > 50) { - const keys = this.cache.keys() - for (let i = 0; i < 25; i++) this.cache.delete(keys.next().value) - } - - this.cache.set(blockHandle, block) - - return block - } - - protected findBlockHandleInBlockIndex(key: Uint8Array) { - if (this.blockIndex === null) throw new Error('Block index not loaded') - - const searchHelper = new BlockSeeker(this.blockIndex) - if (searchHelper.binarySearchKey(key)) { - const foundKey = searchHelper.getCurrentKey() - if (foundKey === null) throw new Error('Key not found') - - const usableKey = asUsableKey(foundKey) - if (this.comparator.compare(usableKey, key) < 0) return null - - const val = searchHelper.getCurrentValue() - if (val === null) return null - - return BlockHandle.readBlockHandle(new Uint8ArrayReader(val)) - } - - return null - } - protected searchKeyInBlockData(key: Uint8Array, blockData: Uint8Array) { - const searchHelper = new BlockSeeker(blockData) - - if (searchHelper.binarySearchKey(key)) { - const foundKey = searchHelper.getCurrentKey() - if (foundKey === null) throw new Error('Key not found') - - const keyType = getKeyType(foundKey) - const usableKey = asUsableKey(foundKey) - - if (this.comparator.compare(usableKey, key) === 0) { - switch (keyType) { - case EKeyType.Deleted: { - // console.log('DELETED') - return RequestStatus.createDeleted() - } - case EKeyType.Exists: { - // console.log('EXISTS') - return new RequestStatus(searchHelper.getCurrentValue()) - } - default: { - console.warn(`Unknown key type ${keyType}`) - } - } - } - } - - // console.log('NOT FOUND') - return RequestStatus.createNotFound() - } -} diff --git a/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/Equals.ts b/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/Equals.ts deleted file mode 100644 index 158b9f3a0..000000000 --- a/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/Equals.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Given two Uint8Arrays, returns true if they are equal, false otherwise. - */ -export function equals(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) { - return false - } - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { - return false - } - } - return true -} diff --git a/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/Reader.ts b/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/Reader.ts deleted file mode 100644 index d0a1aa5c5..000000000 --- a/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/Reader.ts +++ /dev/null @@ -1,128 +0,0 @@ -const textDecoder = new TextDecoder('utf-8') - -export enum ESeekType { - Start = 0, - Current = 1, - End = 2, -} - -export class Uint8ArrayReader { - constructor(protected data: Uint8Array, protected position = 0) { - this.data = data - } - - get hasUnreadBytes() { - return this.position < this.data.length - } - - clone() { - return new Uint8ArrayReader(this.data, this.position) - } - getPosition() { - return this.position - } - getLength() { - return this.data.length - } - - seek(offset: number, where: ESeekType = ESeekType.Start) { - if (offset > this.data.length) throw new Error('Offset out of bounds') - - let newPosition = this.position - - switch (where) { - case ESeekType.Start: - newPosition = offset - break - case ESeekType.Current: - newPosition += offset - break - case ESeekType.End: - newPosition = this.data.length + offset - break - } - - if (newPosition < 0) throw new Error('Offset out of bounds') - - this.position = newPosition - - return this.position - } - unsafelySetPosition(position: number) { - this.position = position - } - - readByte() { - const byte = this.data[this.position] - this.position++ - return byte - } - readInt32() { - const a = this.readByte() - const b = this.readByte() - const c = this.readByte() - const d = this.readByte() - return a | (b << 8) | (c << 16) | (d << 24) - } - readUint32() { - return this.readInt32() >>> 0 - } - readInt64() { - const low = BigInt(this.readInt32()) - const high = BigInt(this.readInt32()) - return low | (high << 32n) - } - readUint64() { - return BigInt.asUintN(64, this.readInt64()) - } - decodeZigZagInt32() { - const i = this.readInt32() - return (i >>> 1) ^ -(i & 1) - } - - readVarLong() { - let result = 0 - - for (let shift = 0; shift < 63; shift += 7) { - const b = this.readByte() - result |= (b & 0x7f) << shift - - if ((b & 0x80) === 0) { - return result - } - } - - throw new Error('Invalid varlong') - } - - read(length: number) { - if (length > this.data.length - this.position) { - console.log(length, this.data, this.position) - throw new Error('Not enough data') - } - - const bytes = this.data.slice(this.position, this.position + length) - this.position += length - return bytes - } - - readWithOffset(offset: number, length: number) { - const availableBytes = this.data.length - this.position - if (availableBytes <= 0) return new Uint8Array(0) - - const bytes = new Uint8Array(offset + length) - - bytes.set(this.read(length), offset) - - return bytes - } - - readLengthPrefixedBytes() { - const length = this.readVarLong() - return this.read(length) - } - readLengthPrefixedString() { - const bytes = this.readLengthPrefixedBytes() - return textDecoder.decode(bytes) - } -} diff --git a/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/ToUint8Array.ts b/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/ToUint8Array.ts deleted file mode 100644 index 6d8102e83..000000000 --- a/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/ToUint8Array.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Convert the signed integer n to an Uint8Array representing a little-endian, 32 bit signed integer - * @param n - * @returns Uint8Array[4] - */ -export function toUint8Array(n: number) { - const buffer = new ArrayBuffer(4) - const view = new DataView(buffer) - view.setInt32(0, n, true) - return new Uint8Array(buffer) -} diff --git a/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/Unpack.ts b/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/Unpack.ts deleted file mode 100644 index b15e35f89..000000000 --- a/src/components/BedrockWorlds/LevelDB/Uint8ArrayUtils/Unpack.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Given an Uint8Array, return an array with only 0 and 1 values. - */ -export function unpackBits(bytes: Uint8Array): Uint8Array { - const result = new Uint8Array(8 * bytes.length) - - for (let i = 0; i < bytes.length; i++) { - const byte = bytes[i] - for (let j = 0; j < 8; j++) { - result[(i + 1) * 8 - (j + 1)] = (byte >> j) & 1 - } - } - - return result -} - -/** - * Given an array of 0 and 1 values, return an Uint8Array where bitsPerNumber bits are packed into each byte. - */ -export function packBits(bits: Uint8Array, bitsPerNumber: number) { - const bytes = new Uint8Array(Math.ceil(bits.length / bitsPerNumber)) - - for (let i = 0; i < bytes.length; i++) { - let byte = 0 - for (let j = 0; j < bitsPerNumber; j++) { - byte |= bits[i * bitsPerNumber + j] << j - } - bytes[i] = byte - } - - return bytes -} - -export function unpackStruct(bytes: Uint8Array) { - return bytes.length < 4 ? 0 : new DataView(bytes.buffer).getUint32(0, true) -} diff --git a/src/components/BedrockWorlds/LevelDB/Version.ts b/src/components/BedrockWorlds/LevelDB/Version.ts deleted file mode 100644 index 5d7534dc9..000000000 --- a/src/components/BedrockWorlds/LevelDB/Version.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { FileMetaData } from './FileMetaData' - -export class Version { - public comparator?: string - public logNumber?: number - public previousLogNumber?: number - public nextFileNumber?: number - public lastSequence?: number - - public deletedFiles = new Map>() - public levels = new Map() - public compactPointers = new Map() - - getFiles(level: number): FileMetaData[] { - return this.levels.get(level) ?? [] - } - addFile(level: number, file: FileMetaData) { - if (!this.levels.has(level)) { - this.levels.set(level, []) - } - this.levels.get(level)!.push(file) - } - removeFile(level: number, fileNumber: number) { - const files = this.getFiles(level) - - const index = files.findIndex((f) => f.fileNumber === fileNumber) - if (index === -1) { - throw new Error(`File ${fileNumber} not found in level ${level}`) - } - - files.splice(index, 1) - - if (!this.deletedFiles.has(level)) { - this.deletedFiles.set(level, new Set()) - } - this.deletedFiles.get(level)!.add(fileNumber) - } - - getCompactPointer(level: number) { - return this.compactPointers.get(level) ?? null - } - setCompactPointer(level: number, pointer: Uint8Array) { - this.compactPointers.set(level, pointer) - } - removeCompactPointer(level: number) { - this.compactPointers.delete(level) - } -} diff --git a/src/components/BedrockWorlds/Render/Neighbours.ts b/src/components/BedrockWorlds/Render/Neighbours.ts deleted file mode 100644 index fb8c59804..000000000 --- a/src/components/BedrockWorlds/Render/Neighbours.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Array to store all neighbours of a voxel, useful for iterating - */ -export const VoxelNeighbours = [ - [0, 0, 0], // Self - [-1, 0, 0], // Left - [1, 0, 0], // Right - [0, -1, 0], // Down - [0, 1, 0], // Up - [0, 0, -1], // Back - [0, 0, 1], // Front -] diff --git a/src/components/BedrockWorlds/Render/Tab.ts b/src/components/BedrockWorlds/Render/Tab.ts deleted file mode 100644 index 11debb1e3..000000000 --- a/src/components/BedrockWorlds/Render/Tab.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { World } from '../WorldFormat/World' -import { ThreePreviewTab } from '/@/components/Editors/ThreePreview/ThreePreviewTab' - -export class WorldTab extends ThreePreviewTab { - protected world?: World - onChange() {} - reload() {} - - async onActivate() { - const project = this.parent.project - - this.world = new World( - await project.fileSystem.getDirectoryHandle( - 'PATH TO WORLD DB FOLDER' - ), - project.fileSystem.baseDirectory, - this.scene - ) - - await this.world.loadWorld() - this.requestRendering() - - console.log(this.world.blockLibrary, this) - } - - async render() { - super.render() - - await this.world?.updateCurrentMeshes( - this.camera.position.x, - this.camera.position.y, - this.camera.position.z - ) - } - - get name() { - return 'World' - } - get iconColor() { - return '#ffb400' - } - get icon() { - return 'mdi-earth' - } -} diff --git a/src/components/BedrockWorlds/Render/VoxelFaces.ts b/src/components/BedrockWorlds/Render/VoxelFaces.ts deleted file mode 100644 index 630eee4cb..000000000 --- a/src/components/BedrockWorlds/Render/VoxelFaces.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Stores data for the different voxel faces, needs to be updated once we support loading Minecraft's blocks - */ -export const VoxelFaces = [ - // Left - { - faces: ['east', 'side', 'all'], - dir: [-1, 0, 0], - corners: [ - { pos: [0, 1, 0], uv: [0, 1] }, - { pos: [0, 0, 0], uv: [0, 0] }, - { pos: [0, 1, 1], uv: [1, 1] }, - { pos: [0, 0, 1], uv: [1, 0] }, - ], - }, - - // Right - { - faces: ['west', 'side', 'all'], - dir: [1, 0, 0], - corners: [ - { pos: [1, 1, 1], uv: [0, 1] }, - { pos: [1, 0, 1], uv: [0, 0] }, - { pos: [1, 1, 0], uv: [1, 1] }, - { pos: [1, 0, 0], uv: [1, 0] }, - ], - }, - - // Bottom - { - faces: ['down', 'all'], - dir: [0, -1, 0], - corners: [ - { pos: [1, 0, 1], uv: [1, 0] }, - { pos: [0, 0, 1], uv: [0, 0] }, - { pos: [1, 0, 0], uv: [1, 1] }, - { pos: [0, 0, 0], uv: [0, 1] }, - ], - }, - - // Top - { - faces: ['up', 'all'], - dir: [0, 1, 0], - corners: [ - { pos: [0, 1, 1], uv: [1, 1] }, - { pos: [1, 1, 1], uv: [0, 1] }, - { pos: [0, 1, 0], uv: [1, 0] }, - { pos: [1, 1, 0], uv: [0, 0] }, - ], - }, - - //Back - { - faces: ['south', 'side', 'all'], - dir: [0, 0, -1], - corners: [ - { pos: [1, 0, 0], uv: [0, 0] }, - { pos: [0, 0, 0], uv: [1, 0] }, - { pos: [1, 1, 0], uv: [0, 1] }, - { pos: [0, 1, 0], uv: [1, 1] }, - ], - }, - - //Front - { - faces: ['north', 'side', 'all'], - dir: [0, 0, 1], - corners: [ - { pos: [0, 0, 1], uv: [0, 0] }, - { pos: [1, 0, 1], uv: [1, 0] }, - { pos: [0, 1, 1], uv: [0, 1] }, - { pos: [1, 1, 1], uv: [1, 1] }, - ], - }, -] as const diff --git a/src/components/BedrockWorlds/Render/World/SubChunk.ts b/src/components/BedrockWorlds/Render/World/SubChunk.ts deleted file mode 100644 index 79c9b8a6f..000000000 --- a/src/components/BedrockWorlds/Render/World/SubChunk.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { TDirection } from '../../BlockLibrary/BlockLibrary' -import { SubChunk } from '../../WorldFormat/Chunk' -import { World } from '../../WorldFormat/World' -import { VoxelFaces } from '../VoxelFaces' -const chunkSize = 16 - -export class RenderSubChunk { - constructor(protected world: World, protected subChunkInstance: SubChunk) {} - - /** - * TODO: - * This function is currently the big bottleneck of rendering worlds, needs optimizations - */ - getGeometryData() { - if (this.subChunkInstance.blockLayers.length === 0) return - const tileSize = 16 - const [ - tileTextureWidth, - tileTextureHeight, - ] = this.world.blockLibrary.getTileMapSize() - - const positions: number[] = [] - const normals: number[] = [] - const uvs: number[] = [] - const indices: number[] = [] - const chunkX = this.subChunkInstance.parent.getX() - const chunkY = this.subChunkInstance.y - const chunkZ = this.subChunkInstance.parent.getZ() - - const startX = chunkX * chunkSize - const startY = chunkY * chunkSize - const startZ = chunkZ * chunkSize - - for (let y = 0; y < 16; y++) { - const correctedY = chunkY < 0 ? chunkSize - 1 - y : y - - for (let z = 0; z < 16; z++) { - const correctedZ = chunkZ < 0 ? chunkSize - 1 - z : z - - for (let x = 0; x < 16; x++) { - const correctedX = chunkX < 0 ? chunkSize - 1 - x : x - - const block = this.subChunkInstance - .getLayer(0) - .getBlockAt(correctedX, correctedY, correctedZ) - - // The current voxel is not air, we may need to render faces for it - if (block.name !== 'minecraft:air') { - // console.warn( - // chunkX, - // chunkY, - // chunkZ, - // correctedX, - // correctedY, - // correctedZ - // ) - // console.log( - // 'MAIN:', - // block.name, - // startX + correctedX, - // startY + correctedY, - // startZ + correctedZ - // ) - - // Do we need faces for the current voxel? - for (const { dir, corners, faces } of VoxelFaces) { - const neighbour = this.world.getBlockAt( - 0, - startX + correctedX + dir[0], - startY + correctedY + dir[1], - startZ + correctedZ + dir[2] - ) - // console.log( - // faces[0] + ':', - // neighbour.name, - // startX + correctedX + dir[0], - // startY + correctedY + dir[1], - // startZ + correctedZ + dir[2] - // ) - - // This voxel has a transparent voxel as a neighbour in the current direction -> add face - if ( - neighbour.name === 'minecraft:air' - // BlockLibrary.isTransparent( - // neighbour, - // (faces as unknown) as TDirection[] - // ) || - // BlockLibrary.isSlab(voxel) || - // BlockLibrary.isFence(voxel) || - // BlockLibrary.isStairs(voxel) - ) { - const ndx = positions.length / 3 - for (let { - pos: [oX, oY, oZ], - uv: [uvX, uvY], - } of corners) { - const [ - voxelUVX, - voxelUVY, - ] = this.world.blockLibrary.getVoxelUv( - block.name, - (faces as unknown) as TDirection[] - ) - // if (BlockLibrary.isSlab(voxel)) { - // oY /= 2 - // uvY /= 2 - // } else if (BlockLibrary.isStairs(voxel)) { - // oY /= 2 - // oX /= 2 - // uvY /= 2 - // uvX /= 2 - // } else if (BlockLibrary.isFence(voxel)) { - // ;(oX as number) = - // oX === 0 ? 6 / 16 : 10 / 16 - // ;(oZ as number) = - // oZ === 0 ? 6 / 16 : 10 / 16 - // ;(uvX as number) = - // uvX === 0 ? 6 / 16 : 10 / 16 - // if ( - // ((faces as unknown) as TDirection[]).includes( - // 'up' - // ) || - // ((faces as unknown) as TDirection[]).includes( - // 'down' - // ) - // ) - // (uvY as number) = - // uvY === 0 ? 6 / 16 : 10 / 16 - // } - positions.push( - oX + correctedX, - oY + correctedY, - oZ + correctedZ - ) - normals.push(...dir) - uvs.push( - ((voxelUVX + uvX) * tileSize) / - tileTextureWidth, - 1 - - ((voxelUVY + 1 - uvY) * tileSize) / - tileTextureHeight - ) - } - indices.push( - ndx, - ndx + 1, - ndx + 2, - ndx + 2, - ndx + 1, - ndx + 3 - ) - } - } - } - } - } - } - - return { - positions, - normals, - uvs, - indices, - } - } -} diff --git a/src/components/BedrockWorlds/WorldFormat/Block.ts b/src/components/BedrockWorlds/WorldFormat/Block.ts deleted file mode 100644 index 40fa5b7b7..000000000 --- a/src/components/BedrockWorlds/WorldFormat/Block.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface IBlock { - version: 17879555 - name: string - value?: number - states?: IBlockStates -} -export interface IBlockStates { - [key: string]: unknown -} - -export class Block implements IBlock { - public readonly version = 17879555 - public readonly name: string - public readonly states: IBlockStates - - constructor(identifier: string, states?: IBlockStates) { - this.name = identifier - this.states = states ?? {} - } -} diff --git a/src/components/BedrockWorlds/WorldFormat/Chunk.ts b/src/components/BedrockWorlds/WorldFormat/Chunk.ts deleted file mode 100644 index 4b517732f..000000000 --- a/src/components/BedrockWorlds/WorldFormat/Chunk.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { Uint8ArrayReader } from '../LevelDB/Uint8ArrayUtils/Reader' -import { EDimension } from './EDimension' -import { EKeyTypeTag } from './EKeyTypeTags' -import { simplify } from 'prismarine-nbt' -import { readAllNbt, readNbt } from './readNbt' -import { unpackStruct } from '../LevelDB/Uint8ArrayUtils/Unpack' -import { Block, IBlock } from './Block' -import type { World } from './World' - -export class Chunk { - protected subChunks: SubChunk[] = [] - - constructor( - protected world: World, - public readonly x: Uint8Array, - public readonly z: Uint8Array, - public readonly dimension = new Uint8Array([0, 0, 0, 0]) - ) { - this.loadSubChunks() - // const entityData = this.loadNbtData(EKeyTypeTag.Entity) - // if (entityData) console.log(entityData) - // const blockEntity = this.loadNbtData(EKeyTypeTag.BlockEntity) - // if (blockEntity) console.log(blockEntity) - // const pendingTicks = this.loadNbtData(EKeyTypeTag.PendingTicks) - // if (pendingTicks) console.log(pendingTicks) - // const BlockExtraData = this.loadNbtData(EKeyTypeTag.BlockExtraData) - // if (BlockExtraData) console.log(BlockExtraData) - } - - getDimension() { - return new Uint8ArrayReader(this.dimension).readInt32() - } - getX() { - return new Uint8ArrayReader(this.x).readInt32() - } - getZ() { - return new Uint8ArrayReader(this.z).readInt32() - } - - getLevelDb() { - return this.world.levelDb - } - - getSubChunk(n: number) { - return this.subChunks[n] - } - - loadSubChunks() { - for (let i = 0; i < 16; i++) { - this.subChunks.push(new SubChunk(this, i)) - } - } - - getChunkKey(tagType: EKeyTypeTag) { - const dimension = this.getDimension() - const key = new Uint8Array( - 9 + // x + z coordinate + 1 byte for key type tag - (dimension === EDimension.Overworld ? 0 : 4) // dimension - ) - key.set(this.x, 0) - key.set(this.z, 4) - let offset = 0 - if (dimension !== EDimension.Overworld) { - key.set(this.dimension, 8) - offset = 4 - } - - key[8 + offset] = tagType - - return key - } - - protected loadData(tagType: EKeyTypeTag) { - if (tagType === EKeyTypeTag.SubChunkPrefix) - throw new Error( - 'SubChunkPrefix data should be loaded by sub chunks' - ) - - return this.getLevelDb().get(this.getChunkKey(tagType)) - } - - protected loadNbtData(tagType: EKeyTypeTag) { - const data = this.loadData(tagType) - if (!data) return null - - let offset = 0 - const length = data.length - - const loadedNbt = [] - while (offset < length) { - const { data: d, size } = readNbt(data, offset) - loadedNbt.push(simplify(d)) - offset += size - } - - return loadedNbt - } -} - -const totalBlockSpaces = 4096 // 16 * 16 * 16 - -export class SubChunk { - public blockLayers: BlockLayer[] = [] - - constructor(public readonly parent: Chunk, public readonly y: number) { - let blockData = this.loadData() - - if (blockData) { - // This var stores how many blocks can be stored in a single block space (e.g. waterlogged blocks) - let blocksPerBlockSpace = 1 - - // First byte in blockData is sub chunk version - if (blockData[0] === 1) { - blockData = blockData.slice(1) - } else if (blockData[0] === 8) { - blocksPerBlockSpace = blockData[1] - blockData = blockData.slice(2) - } else if (blockData[0] === 9) { - blocksPerBlockSpace = blockData[1] - // Extra byte stores sub chunk y coordinate - blockData = blockData.slice(3) - } else { - throw new Error( - `Unknown sub chunk format version: ${blockData[0]}` - ) - } - - for ( - let blockSpaceIndex = 0; - blockSpaceIndex < blocksPerBlockSpace; - blockSpaceIndex++ - ) { - const { blocks, palette, data } = this.loadBlockPalette( - blockData - ) - blockData = data - this.blockLayers.push(new BlockLayer(blocks, palette)) - } - } else { - this.blockLayers.push(new EmptyBlockLayer()) - } - } - - getLayer(index: number) { - if (index < 0 || index >= this.blockLayers.length) { - throw new Error(`Invalid layer index: ${index}`) - } - - return this.blockLayers[index] - } - - protected loadBlockPalette(data: Uint8Array) { - const bitsPerBlock = data[0] >>> 1 - data = data.slice(1) - - if (bitsPerBlock === 0) { - const { data: nbtData, size } = readNbt(data, 0) - - return { - blocks: new Uint16Array(totalBlockSpaces), - palette: simplify(nbtData), - data: data.slice(0, size), - } - } else { - // One word consists of 4 bytes - const blocksPerWord = Math.floor(32 / bitsPerBlock) - const wordCount = Math.ceil(totalBlockSpaces / blocksPerWord) - const padding = - bitsPerBlock === 3 || bitsPerBlock === 5 || bitsPerBlock === 6 - ? 2 - : 0 - - let rawBlocks = new Uint8Array(wordCount * 4) - rawBlocks.set(data.slice(0, wordCount * 4)) - data = data.slice(wordCount * 4) - - const processedBlocks = new Uint16Array(totalBlockSpaces) - let position = 0 - for (let wordIndex = 0; wordIndex < wordCount; wordIndex++) { - const rawWord = rawBlocks.slice( - wordIndex * 4, - (wordIndex + 1) * 4 - ) - const uint32Word = new DataView(rawWord.buffer).getUint32( - 0, - true - ) - - for ( - let blockIndex = 0; - blockIndex < blocksPerWord; - blockIndex++ - ) { - const blockId = - (uint32Word >> - ((position % blocksPerWord) * bitsPerBlock)) & - ((1 << bitsPerBlock) - 1) - processedBlocks[position] = blockId - position++ - } - } - - const paletteLength = unpackStruct(data.slice(0, 4)) - data = data.slice(4) - - const { data: nbtData, size } = readAllNbt(data, paletteLength) - - // console.log(size, data.slice(0, size)) - return { - blocks: processedBlocks, - palette: nbtData.map((d) => simplify(d)), - data: data.slice(size), - } - } - } - - protected loadData() { - return this.parent.getLevelDb().get(this.getSubChunkPrefixKey()) - } - - protected getSubChunkPrefixKey() { - return new Uint8Array([ - ...this.parent.getChunkKey(EKeyTypeTag.SubChunkPrefix), - this.y, - ]) - } -} - -export class BlockLayer { - constructor(protected blocks: Uint16Array, protected pallete: any[]) { - // console.log(pallete) - } - - getBlockAt(x: number, y: number, z: number): IBlock { - if (x < 0 || x >= 16 || y < 0 || y >= 16 || z < 0 || z >= 16) { - throw new Error(`Invalid block coordinates: ${x}, ${y}, ${z}`) - } - - const numericId = this.blocks[y + z * 16 + x * 256] - const block = this.pallete[numericId] - - return block ?? new Block('minecraft:info_update') - } -} - -export class EmptyBlockLayer extends BlockLayer { - constructor() { - super(new Uint16Array(totalBlockSpaces), []) - } - - getBlockAt(x: number, y: number, z: number): IBlock { - return new Block('minecraft:air') - } -} diff --git a/src/components/BedrockWorlds/WorldFormat/EDimension.ts b/src/components/BedrockWorlds/WorldFormat/EDimension.ts deleted file mode 100644 index 495326d4e..000000000 --- a/src/components/BedrockWorlds/WorldFormat/EDimension.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum EDimension { - Overworld = 0, - Nether = 1, - TheEnd = 2, -} diff --git a/src/components/BedrockWorlds/WorldFormat/EKeyTypeTags.ts b/src/components/BedrockWorlds/WorldFormat/EKeyTypeTags.ts deleted file mode 100644 index 06949a177..000000000 --- a/src/components/BedrockWorlds/WorldFormat/EKeyTypeTags.ts +++ /dev/null @@ -1,18 +0,0 @@ -export enum EKeyTypeTag { - ChunkVersion = 44, - Data2D = 45, - Data2DLegacy = 46, - SubChunkPrefix = 47, - LegacyTerrain = 48, - BlockEntity = 49, - Entity = 50, - PendingTicks = 51, - BlockExtraData = 52, - BiomeState = 53, - FinalizedState = 54, - BorderBlocks = 56, - HardCodedSpawnAreas = 57, - RandomTicks = 58, - Checksums = 59, - OldChunkVersion = 118, -} diff --git a/src/components/BedrockWorlds/WorldFormat/World.ts b/src/components/BedrockWorlds/WorldFormat/World.ts deleted file mode 100644 index c3b75ff38..000000000 --- a/src/components/BedrockWorlds/WorldFormat/World.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { LevelDB } from '../LevelDB/LevelDB' -import { EKeyTypeTag } from './EKeyTypeTags' -import { Chunk } from './Chunk' -import { toUint8Array } from '../LevelDB/Uint8ArrayUtils/ToUint8Array' -import { Block } from './Block' -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { BlockLibrary } from '../BlockLibrary/BlockLibrary' -import { RenderSubChunk } from '../Render/World/SubChunk' -import { - BufferAttribute, - BufferGeometry, - CanvasTexture, - FrontSide, - Mesh, - MeshLambertMaterial, - NearestFilter, - Scene, - Vector3, - MathUtils, -} from 'three' -import { markRaw } from 'vue' - -export class World { - protected chunks = new Map() - public readonly blockLibrary: BlockLibrary - public readonly levelDb: LevelDB - - constructor( - protected worldHandle: AnyDirectoryHandle, - protected projectHandle: AnyDirectoryHandle, - protected scene: Scene - ) { - this.levelDb = markRaw(new LevelDB(this.worldHandle)) - this.blockLibrary = markRaw(new BlockLibrary(this.projectHandle)) - } - - async loadWorld() { - await Promise.all([this.blockLibrary.setup(), this.levelDb.open()]) - - const texture = new CanvasTexture(this.blockLibrary.tileMap) - texture.magFilter = NearestFilter - texture.minFilter = NearestFilter - this.material = new MeshLambertMaterial({ - map: texture, - side: FrontSide, - alphaTest: 0.1, - transparent: true, - }) - - const keys = this.levelDb.keys() - - for (const key of keys) { - const decoded = this.decodeChunkKey(key) - if (!decoded) continue - const { x, z, dimension } = decoded - - const position = new Uint8Array([ - ...x, - ...z, - ...(dimension ? dimension : []), - ]).join(',') - - if (!this.chunks.has(position)) - this.chunks.set(position, new Chunk(this, x, z, dimension)) - } - } - - getSubChunkAt(x: number, y: number, z: number) { - const chunkX = toUint8Array(Math.floor(x / 16)) - const chunkZ = toUint8Array(Math.floor(z / 16)) - - const chunk = this.chunks.get( - new Uint8Array([...chunkX, ...chunkZ]).join(',') - ) - - return chunk?.getSubChunk(Math.floor(y / 16)) - } - - getBlockAt(layer: number, x: number, y: number, z: number) { - // console.log(layer, x, y, z, this.getSubChunkAt(x, y, z)) - return ( - this.getSubChunkAt(x, y, z) - ?.getLayer(layer) - .getBlockAt( - Math.abs(x < 0 ? (16 + (x % 16)) % 16 : x % 16), - Math.abs(y < 0 ? (16 + (y % 16)) % 16 : y % 16), - Math.abs(z < 0 ? (16 + (z % 16)) % 16 : z % 16) - ) ?? new Block('minecraft:air') - ) - } - - decodeChunkKey(key: Uint8Array) { - if (![9, 10, 13, 14].includes(key.length)) return null - - /** - * Chunk key format - * 1) little endian int32 (chunk x coordinate) - * 2) little endian int32 (chunk z coordinate) - * 3) optional little endian int32 (dimension) - * 4) key type byte (see EKeyTypeTag) - * 5) one byte for SubChunkPrefix data when type is EKeyTypeTag.SubChunkPrefix - */ - - const x = key.slice(0, 4) - const z = key.slice(4, 8) - - // Optional dimension is defined if key is long enough - const dimension = key.length >= 13 ? key.slice(8, 12) : undefined - - const keyTypeByte = key[key.length - 2] - const subChunkPrefixByte = - keyTypeByte === EKeyTypeTag.SubChunkPrefix && - (key.length === 10 || key.length === 14) - ? key[key.length - 1] - : null - - return { - x: x, - z: z, - dimension: dimension, - keyType: keyTypeByte, - subChunkPrefix: subChunkPrefixByte, - } - } - - // TODO: Move all following methods to its own class dedicated to rendering a world - protected loadedChunks = new Set() - protected builtChunks = new Map() - protected builtChunkMeshes = new Map() - protected renderDistance = 16 - protected material!: MeshLambertMaterial - - async updateCurrentMeshes(currX: number, currY: number, currZ: number) { - Array.from(this.loadedChunks).forEach((currID) => { - let mesh = this.builtChunkMeshes.get(currID) - if (mesh !== undefined) this.scene.remove(mesh) - this.loadedChunks.delete(currID) - }) - - const max = (this.renderDistance * 16) / 2 - const start = -max - - for (let oX = start; oX <= max; oX += 16) - for (let oZ = start; oZ <= max; oZ += 16) - for (let oY = start; oY <= max; oY += 16) { - const chunkID = this.getChunkId( - Math.floor((currX + oX) / 16), - Math.floor((currY + oY) / 16), - Math.floor((currZ + oZ) / 16) - ) - - if ( - !this.loadedChunks.has(chunkID) && - new Vector3(oX, oY, oZ).distanceTo( - new Vector3(currX, currY, currZ) - ) < - max * 16 - ) { - let mesh = this.builtChunkMeshes.get(chunkID) - if (mesh === undefined) { - this.updateChunkGeometry( - currX + oX, - currY + oY, - currZ + oZ - ) - } else { - this.scene.add(mesh) - this.loadedChunks.add(chunkID) - } - } - } - } - - updateChunkGeometry(x: number, y: number, z: number) { - const chunkX = Math.floor(x / 16) - const chunkY = Math.floor(y / 16) - const chunkZ = Math.floor(z / 16) - const chunkId = this.getChunkId(chunkX, chunkY, chunkZ) - - //Building chunks is expensive, skip it whenever possible - if (this.builtChunks.has(chunkId)) return - - let mesh = this.builtChunkMeshes.get(chunkId) - let geometry = mesh?.geometry ?? new BufferGeometry() - - const subChunk = this.getSubChunkAt(x, y, z) - if (!subChunk) return - - const renderSubChunk = new RenderSubChunk(this, subChunk) - const data = renderSubChunk.getGeometryData() - if (data === undefined) { - if (mesh) { - this.scene.remove(mesh) - this.loadedChunks.delete(chunkId) - this.builtChunkMeshes.delete(chunkId) - } - return - } - - const { positions, normals, uvs, indices } = data - - const positionNumComponents = 3 - geometry.setAttribute( - 'position', - new BufferAttribute( - new Float32Array(positions), - positionNumComponents - ) - ) - const normalNumComponents = 3 - geometry.setAttribute( - 'normal', - new BufferAttribute(new Float32Array(normals), normalNumComponents) - ) - const uvNumComponents = 2 - geometry.setAttribute( - 'uv', - new BufferAttribute(new Float32Array(uvs), uvNumComponents) - ) - geometry.setIndex(indices) - geometry.computeBoundingSphere() - - if (mesh === undefined) { - mesh = new Mesh(geometry, this.material) - mesh.name = chunkId - this.builtChunkMeshes.set(chunkId, mesh) - this.loadedChunks.add(chunkId) - mesh.position.set(chunkX * 16, chunkY * 16, chunkZ * 16) - this.scene.add(mesh) - } - } - - getChunkId(x: number, y: number, z: number) { - return `${x},${y},${z}` - } -} diff --git a/src/components/BedrockWorlds/WorldFormat/readNbt.ts b/src/components/BedrockWorlds/WorldFormat/readNbt.ts deleted file mode 100644 index b736bddb2..000000000 --- a/src/components/BedrockWorlds/WorldFormat/readNbt.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { protoLE } from 'prismarine-nbt' -import { Buffer } from 'buffer' - -export function readNbt(nbtData: Uint8Array, offset = 0) { - const { data, metadata } = protoLE.parsePacketBuffer( - 'nbt', - Buffer.from(nbtData), - offset - ) - - return { - data, - size: metadata.size, - } -} - -export function readAllNbt(nbtData: Uint8Array, count = 1) { - const resData = [] - - let lastOffset = 0 - for (let i = 0; i < count; i++) { - const { data, size } = readNbt(nbtData, lastOffset) - resData.push(data) - lastOffset += size - } - - return { - data: resData, - size: lastOffset, - } -} diff --git a/src/components/BottomPanel/BottomPanel.css b/src/components/BottomPanel/BottomPanel.css deleted file mode 100644 index 23df6630a..000000000 --- a/src/components/BottomPanel/BottomPanel.css +++ /dev/null @@ -1,50 +0,0 @@ -.bottom-panel-content { - height: 100%; -} - -/* Tab bar */ -.bottom-panel-tab-bar { - width: 100%; - overflow-x: auto; - overflow-y: visible; - padding: 4px 0; -} -.bottom-panel-tab-bar::-webkit-scrollbar { - display: none; -} - -/* Tabs */ -.bottom-panel-tab { - display: flex; - align-items: center; - font-size: 12px; - font-weight: 500; - letter-spacing: 1.25px; - - cursor: pointer; - padding: 1px 8px; - border-radius: 8px; - background: var(--v-sidebarSelection-base); - text-transform: uppercase; - - transition: transform 0.1s ease-in-out; -} -.bottom-panel-tab i { - font-size: 18px !important; -} -.bottom-panel-tab-active { - cursor: default; - z-index: 1; - background: var(--v-primary-base); - transform: scale(1.1); -} - -/* Panel content */ -.bottom-panel-content-container { - /* Full height - tab bar height - tab bar padding - vertical padding */ - height: calc(100% - 39px - 8px); - overflow-y: auto; -} -.bottom-panel-content-container-full-height { - height: 100%; -} diff --git a/src/components/BottomPanel/BottomPanel.tsx b/src/components/BottomPanel/BottomPanel.tsx deleted file mode 100644 index 264a5ee9c..000000000 --- a/src/components/BottomPanel/BottomPanel.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { computed, ref } from 'vue' -import { JSX } from 'solid-js/types' -import './BottomPanel.css' -import { LogPanel } from '../Compiler/LogPanel/Panel' -import { App } from '/@/App' - -interface ITab { - name: string - icon: string - component: () => JSX.Element -} - -export class BottomPanel { - public readonly isVisible = ref(false) - public readonly height = ref(400) - public readonly tabs = ref([]) - public readonly activeTab = ref(null) - public readonly currentHeight = computed(() => - this.isVisible.value ? this.height.value : 0 - ) - - constructor() { - this.setupTerminal() - - this.addTab({ - icon: 'mdi-bug', - name: 'bottomPanel.problems.name', - component: () => ( - <> -
- We are still working on displaying problems with your - project here... -
- - ), - }) - - setTimeout(() => { - App.getApp().then((app) => { - this.addTab({ - icon: 'mdi-cogs', - name: 'bottomPanel.compiler.name', - component: () => - LogPanel({ - compilerWindow: app.windows.compilerWindow, - }), - }) - }) - }) - } - - async setupTerminal() { - if (!import.meta.env.VITE_IS_TAURI_APP) return - const { Terminal } = await import('./Terminal/Terminal') - const { TerminalInput } = await import('./Terminal/Input') - const { TerminalOutput } = await import('./Terminal/Output') - - const terminal = new Terminal() - - this.addTab( - { - icon: 'mdi-console-line', - name: 'bottomPanel.terminal.name', - component: () => ( - <> - - - - ), - }, - true - ) - } - - selectTab(tab: ITab) { - this.activeTab.value = tab - } - addTab(tab: ITab, asFirst = false) { - if (asFirst) this.tabs.value.unshift(tab) - else this.tabs.value.push(tab) - - if (this.activeTab.value === null || asFirst) { - this.activeTab.value = tab - } - } -} diff --git a/src/components/BottomPanel/BottomPanel.vue b/src/components/BottomPanel/BottomPanel.vue deleted file mode 100644 index daef53c77..000000000 --- a/src/components/BottomPanel/BottomPanel.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - diff --git a/src/components/BottomPanel/PanelContent.tsx b/src/components/BottomPanel/PanelContent.tsx deleted file mode 100644 index 5ae27e1ed..000000000 --- a/src/components/BottomPanel/PanelContent.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Component, Show } from 'solid-js' -import { Dynamic } from 'solid-js/web' -import { toSignal } from '../Solid/toSignal' -import { toVue } from '../Solid/toVue' -import { TabBar } from './TabBar' -import { App } from '/@/App' - -export const PanelContent: Component = (props) => { - const [activeTab] = toSignal(App.bottomPanel.activeTab) - const [tabs] = toSignal(App.bottomPanel.tabs) - - return ( -
- 1}> - - - -
- -
-
- ) -} - -export const VuePanelContent = toVue(PanelContent) diff --git a/src/components/BottomPanel/TabBar.tsx b/src/components/BottomPanel/TabBar.tsx deleted file mode 100644 index 0f914443d..000000000 --- a/src/components/BottomPanel/TabBar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Component, For } from 'solid-js' -import { useTranslations } from '../Composables/useTranslations' -import { useRipple } from '../Solid/Directives/Ripple/Ripple' -import { SolidIcon } from '../Solid/Icon/SolidIcon' -import { SolidIconButton } from '../Solid/Inputs/IconButton/IconButton' -import { SolidSpacer } from '../Solid/SolidSpacer' -import { toSignal } from '../Solid/toSignal' -import { App } from '/@/App' - -export const TabBar: Component = (props) => { - const ripple = useRipple() - const { t } = useTranslations() - const [tabs] = toSignal(App.bottomPanel.tabs) - const [activeTab] = toSignal(App.bottomPanel.activeTab) - const [_, setIsVisible] = toSignal(App.bottomPanel.isVisible) - - return ( -
-
- - {(tab, i) => ( -
0, - }} - onClick={() => App.bottomPanel.selectTab(tab)} - > - - {t(tab.name)} -
- )} -
-
- - - - setIsVisible(false)} - /> -
- ) -} diff --git a/src/components/BottomPanel/Terminal/Input.tsx b/src/components/BottomPanel/Terminal/Input.tsx deleted file mode 100644 index 0c9b2193e..000000000 --- a/src/components/BottomPanel/Terminal/Input.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Component, createSignal } from 'solid-js' -import { useTranslations } from '../../Composables/useTranslations' -import { SolidIconButton } from '../../Solid/Inputs/IconButton/IconButton' -import { TextField } from '../../Solid/Inputs/TextField/TextField' -import { toSignal } from '../../Solid/toSignal' -import type { Terminal } from './Terminal' - -export const TerminalInput: Component<{ - terminal: Terminal -}> = (props) => { - const { t } = useTranslations() - const [input, setInput] = createSignal('') - const [hasRunningTask] = toSignal(props.terminal.hasRunningTask) - const [output, setOutput] = toSignal(props.terminal.output) - - const onEnter = () => { - props.terminal.executeCommand(input()) - setInput('') - } - - return ( -
- - - props.terminal.killCommand()} - /> - - setOutput([])} - /> -
- ) -} diff --git a/src/components/BottomPanel/Terminal/Output.tsx b/src/components/BottomPanel/Terminal/Output.tsx deleted file mode 100644 index f6b13eb67..000000000 --- a/src/components/BottomPanel/Terminal/Output.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Component, For, Show } from 'solid-js' -import { SolidIcon } from '../../Solid/Icon/SolidIcon' -import { toSignal } from '../../Solid/toSignal' -import type { Terminal } from './Terminal' - -export const TerminalOutput: Component<{ - terminal: Terminal -}> = (props) => { - const [output] = toSignal(props.terminal.output) - const [cwd] = toSignal(props.terminal.cwd) - - const prettyCwd = () => { - const tmp = cwd() - .replace(props.terminal.baseCwd, '') - .replace(/\\/g, '/') - if (tmp === '') return 'bridge' - return `bridge${tmp}` - } - - return ( - <> - {/* Show current cwd */} - - - - - - {prettyCwd()} - - - {/* Render terminal output */} -
- - {({ kind, time, currentCwdName, msg }, i) => ( -
- [{time}] - - - {currentCwdName} >  - - {msg} - -
- )} -
-
- - ) -} diff --git a/src/components/BottomPanel/Terminal/Terminal.css b/src/components/BottomPanel/Terminal/Terminal.css deleted file mode 100644 index b9e13da8d..000000000 --- a/src/components/BottomPanel/Terminal/Terminal.css +++ /dev/null @@ -1,11 +0,0 @@ -.terminal-line { - line-break: normal; -} - -.terminal-output-container { - /* full height - terminal input bar height - cwd display height - a few pixels to prevent double scrollbar */ - height: calc(100% - 58px - 33px - 2px); - overflow-y: auto; - display: flex; - flex-direction: column-reverse; -} diff --git a/src/components/BottomPanel/Terminal/Terminal.ts b/src/components/BottomPanel/Terminal/Terminal.ts deleted file mode 100644 index b795181c2..000000000 --- a/src/components/BottomPanel/Terminal/Terminal.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { markRaw, ref } from 'vue' -import { invoke } from '@tauri-apps/api/tauri' -import { App } from '/@/App' -import { Signal } from '../../Common/Event/Signal' -import { isAbsolute, join, sep } from '@tauri-apps/api/path' -import { exists } from '@tauri-apps/api/fs' -import { listen, Event } from '@tauri-apps/api/event' -import './Terminal.css' -import { getBridgeFolderPath } from '/@/utils/getBridgeFolderPath' - -type TMessageKind = 'stdout' | 'stderr' | 'stdin' - -interface IMessage { - // Format HH:MM:SS - time: string - kind: TMessageKind - currentCwdName: string - msg: string -} - -interface IMessagePayload { - message: string -} - -export class Terminal { - output = ref([]) - hasRunningTask = ref(false) - baseCwd = '' - cwd = ref('') - setupDone = markRaw(new Signal()) - - constructor() { - setTimeout(() => this.setup()) - - listen('onStdoutMessage', ({ payload }: Event) => { - this.addToOutput(payload.message, 'stdout') - }) - listen('onStderrMessage', ({ payload }: Event) => { - this.addToOutput(payload.message, 'stderr') - }) - listen('onCommandDone', () => { - this.hasRunningTask.value = false - }) - } - - async setup() { - const app = await App.getApp() - - this.baseCwd = await getBridgeFolderPath() - - if (this.cwd.value === '') this.cwd.value = this.baseCwd - - this.setupDone.dispatch() - } - - addToOutput(msg: string, kind: TMessageKind) { - this.output.value.unshift({ - time: new Date().toLocaleTimeString(), - kind, - currentCwdName: this.cwd.value.split(sep).pop()!, - // Replace ANSI escape codes - msg: msg.replace(/\x1b\[[0-9;]*m/g, ''), - }) - } - - protected async handleCdCommand(command: string) { - const path = command.substring(3) - const prevCwd = this.cwd.value - - if (await isAbsolute(path)) { - this.cwd.value = path - } else { - this.cwd.value = await join(this.cwd.value, path) - } - - // Confirm that the path exists - if (!(await exists(this.cwd.value))) { - this.cwd.value = prevCwd - this.addToOutput(`cd: no such directory`, 'stderr') - } - - // Ensure that cwd doesn't leave the baseCwd - if (!this.cwd.value.startsWith(this.baseCwd)) { - this.cwd.value = prevCwd - this.addToOutput(`cd: Permission denied`, 'stderr') - } - } - - async executeCommand(command: string) { - this.addToOutput(command, 'stdin') - - if (command.startsWith('cd ')) { - await this.handleCdCommand(command) - return - } - - this.hasRunningTask.value = true - - await this.setupDone.fired - - await invoke('execute_command', { - cwd: this.cwd.value, - command, - }) - } - - async killCommand() { - await invoke('kill_command') - } -} diff --git a/src/components/CommandBar/AddFiles.ts b/src/components/CommandBar/AddFiles.ts deleted file mode 100644 index c4be245fe..000000000 --- a/src/components/CommandBar/AddFiles.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SimpleAction } from '../Actions/SimpleAction' -import { AnyDirectoryHandle, AnyFileHandle } from '../FileSystem/Types' -import { addCommandBarAction } from './State' -import { App } from '/@/App' -import { IDisposable } from '/@/types/disposable' -import { loadAllFiles } from '/@/utils/file/loadAllFiles' - -export async function addFilesToCommandBar( - directoryHandle: AnyDirectoryHandle, - color?: string -) { - const files = await loadAllFiles(directoryHandle) - let disposables: IDisposable[] = [] - - for (const file of files) { - const action = new SimpleAction({ - icon: 'mdi-file-outline', - color, - name: `[${file.path}]`, - description: 'actions.openFile.name', - onTrigger: async () => { - const app = await App.getApp() - app.project.openFile(file.handle) - }, - }) - - disposables.push(addCommandBarAction(action)) - } - - return { - dispose: () => { - disposables.forEach((disposable) => disposable.dispose()) - disposables = [] - }, - } -} diff --git a/src/components/CommandBar/CommandBar.vue b/src/components/CommandBar/CommandBar.vue deleted file mode 100644 index fc43e2fbd..000000000 --- a/src/components/CommandBar/CommandBar.vue +++ /dev/null @@ -1,157 +0,0 @@ - - - - - diff --git a/src/components/CommandBar/State.ts b/src/components/CommandBar/State.ts deleted file mode 100644 index 61a504ee9..000000000 --- a/src/components/CommandBar/State.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { reactive } from 'vue' -import { SimpleAction } from '../Actions/SimpleAction' - -export const CommandBarState = reactive({ - isWindowOpen: false, - shouldRender: false, // Property is automatically updated - closeDelay: null, -}) -const CommandBarActions = new Set() -export function addCommandBarAction(action: SimpleAction) { - CommandBarActions.add(action) - - return { - dispose: () => { - CommandBarActions.delete(action) - }, - } -} -export function getCommandBarActions() { - return Array.from(CommandBarActions) -} diff --git a/src/components/CommandBar/Window.vue b/src/components/CommandBar/Window.vue deleted file mode 100644 index 87bd29269..000000000 --- a/src/components/CommandBar/Window.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - diff --git a/src/components/Common/Action.vue b/src/components/Common/Action.vue new file mode 100644 index 000000000..b7a4edaa5 --- /dev/null +++ b/src/components/Common/Action.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/components/Common/ActionContextMenuItem.vue b/src/components/Common/ActionContextMenuItem.vue new file mode 100644 index 000000000..af2c96cf1 --- /dev/null +++ b/src/components/Common/ActionContextMenuItem.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/Common/Button.vue b/src/components/Common/Button.vue new file mode 100644 index 000000000..5e1f24293 --- /dev/null +++ b/src/components/Common/Button.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/Common/ContextMenu.vue b/src/components/Common/ContextMenu.vue new file mode 100644 index 000000000..6a7c2b35e --- /dev/null +++ b/src/components/Common/ContextMenu.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/src/components/Common/ContextMenuDivider.vue b/src/components/Common/ContextMenuDivider.vue new file mode 100644 index 000000000..7c2306b31 --- /dev/null +++ b/src/components/Common/ContextMenuDivider.vue @@ -0,0 +1,5 @@ + + + diff --git a/src/components/Common/ContextMenuItem.vue b/src/components/Common/ContextMenuItem.vue new file mode 100644 index 000000000..c7ccb4a8f --- /dev/null +++ b/src/components/Common/ContextMenuItem.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/Common/Dropdown.vue b/src/components/Common/Dropdown.vue new file mode 100644 index 000000000..177429feb --- /dev/null +++ b/src/components/Common/Dropdown.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/Common/Error.vue b/src/components/Common/Error.vue new file mode 100644 index 000000000..2d127e1cf --- /dev/null +++ b/src/components/Common/Error.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/Common/Event/EventDispatcher.ts b/src/components/Common/Event/EventDispatcher.ts deleted file mode 100644 index 6809e598f..000000000 --- a/src/components/Common/Event/EventDispatcher.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { IDisposable } from '/@/types/disposable' - -export class EventDispatcher { - protected listeners = new Set<(data: T) => void>() - - constructor() {} - - get hasListeners() { - return this.listeners.size > 0 - } - dispatch(data: T) { - this.listeners.forEach((listener) => listener(data)) - } - - on(listener: (data: T) => void, getDisposable?: true): IDisposable - on(listener: (data: T) => void, getDisposable: false): undefined - on( - listener: (data: T) => void, - getDisposable?: boolean - ): IDisposable | undefined - on(listener: (data: T) => void, getDisposable: boolean = true) { - this.listeners.add(listener) - - if (getDisposable) - return { - dispose: () => { - this.off(listener) - }, - } - } - - off(listener: (data: T) => void) { - this.listeners.delete(listener) - } - - once(listener: (data: T) => void, getDisposable = false) { - const callback = (data: T) => { - listener(data) - this.off(callback) - } - return this.on(callback, getDisposable) - } - - disposeListeners() { - this.listeners = new Set() - } -} diff --git a/src/components/Common/Event/EventSystem.ts b/src/components/Common/Event/EventSystem.ts deleted file mode 100644 index b1e849e05..000000000 --- a/src/components/Common/Event/EventSystem.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Trigger and react to events - */ - -import { EventDispatcher } from './EventDispatcher' - -export class EventSystem { - protected events = new Map>() - public readonly any = new EventDispatcher<[string, T]>() - - constructor( - events: string[] | readonly string[] = [], - protected autoCleanEmptyDispatchers = false - ) { - events.forEach((event) => this.create(event)) - } - - create(name: string) { - const dispatcher = this.events.get(name) - if (dispatcher !== undefined) - throw new Error( - `Dispatcher for event "${name}" is already defined.` - ) - - this.events.set(name, new EventDispatcher()) - return { - dispose: () => { - this.events.delete(name) - }, - } - } - hasEvent(name: string) { - return this.events.has(name) - } - - protected getDispatcher(name: string) { - const dispatcher = this.events.get(name) - if (dispatcher === undefined) - throw new Error(`No dispatcher defined for event "${name}".`) - - return dispatcher - } - - dispatch(name: string, data: T) { - this.any.dispatch([name, data]) - return this.getDispatcher(name).dispatch(data) - } - on(name: string, listener: (data: T) => void) { - const dispatcher = this.getDispatcher(name) - const disposable = dispatcher.on(listener) - - return { - dispose: () => { - disposable.dispose() - if (this.autoCleanEmptyDispatchers && !dispatcher.hasListeners) - this.events.delete(name) - }, - } - } - off(name: string, listener: (data: T) => void) { - const dispatcher = this.getDispatcher(name) - dispatcher.off(listener) - if (this.autoCleanEmptyDispatchers && !dispatcher.hasListeners) - this.events.delete(name) - } - once(name: string, listener: (data: T) => void) { - const dispatcher = this.getDispatcher(name) - - dispatcher.once((data: T) => { - listener(data) - if (this.autoCleanEmptyDispatchers && !dispatcher.hasListeners) - this.events.delete(name) - }) - } -} diff --git a/src/components/Common/Event/Signal.ts b/src/components/Common/Event/Signal.ts deleted file mode 100644 index c7bf0a33f..000000000 --- a/src/components/Common/Event/Signal.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { EventDispatcher } from './EventDispatcher' -import { IDisposable } from '/@/types/disposable' - -export class Signal extends EventDispatcher { - protected firedTimes = 0 - protected data: T | undefined - - constructor(protected needsToFireAmount = 1) { - super() - } - - get fired() { - return new Promise((resolve) => this.once(resolve, false)) - } - get hasFired() { - return this.firedTimes >= this.needsToFireAmount - } - - setFiredTimes(firedTimes: number) { - this.firedTimes = firedTimes - } - - resetSignal() { - this.data = undefined - this.firedTimes = 0 - } - - dispatch(data: T) { - if (this.firedTimes < this.needsToFireAmount) this.firedTimes++ - this.data = data - - if (this.hasFired) return super.dispatch(data) - } - - on(listener: (data: T) => void, getDisposable?: true): IDisposable - on(listener: (data: T) => void, getDisposable: false): undefined - on( - listener: (data: T) => void, - getDisposable?: boolean - ): IDisposable | undefined - on(listener: (data: T) => void, getDisposable = true) { - if (this.hasFired) listener(this.data!) - - return super.on(listener, getDisposable) - } -} diff --git a/src/components/Common/Expandable.vue b/src/components/Common/Expandable.vue new file mode 100644 index 000000000..1a4f7531c --- /dev/null +++ b/src/components/Common/Expandable.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/components/Common/FileSystemDrop.vue b/src/components/Common/FileSystemDrop.vue new file mode 100644 index 000000000..84dbf9251 --- /dev/null +++ b/src/components/Common/FileSystemDrop.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/Common/FreeContextMenu.ts b/src/components/Common/FreeContextMenu.ts new file mode 100644 index 000000000..ca9ba378c --- /dev/null +++ b/src/components/Common/FreeContextMenu.ts @@ -0,0 +1,3 @@ +import { ref, Ref } from 'vue' + +export const openContextMenuId: Ref = ref('none') diff --git a/src/components/Common/FreeContextMenu.vue b/src/components/Common/FreeContextMenu.vue new file mode 100644 index 000000000..0cadcb450 --- /dev/null +++ b/src/components/Common/FreeContextMenu.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/components/Common/GlobalMutex.ts b/src/components/Common/GlobalMutex.ts deleted file mode 100644 index 208529610..000000000 --- a/src/components/Common/GlobalMutex.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Mutex } from './Mutex' - -/** - * A global mutex manages multiple different, keyed mutexes. - */ -export class GlobalMutex { - protected mutexMap = new Map() - - /** - * Lock the mutex with the given key. Creates the mutex if it does not exist. - * - * @param key Mutex to lock - */ - async lock(key: string) { - let mutex = this.mutexMap.get(key) - if (!mutex) { - mutex = new Mutex() - this.mutexMap.set(key, mutex) - } - - await mutex.lock() - } - - /** - * Unlock the mutex with the given key. - * - * @throws If the mutex does not exist. - * @param key - */ - unlock(key: string) { - const mutex = this.mutexMap.get(key) - if (!mutex) { - throw new Error('Trying to unlock a mutex that does not exist') - } - - // Store whether mutex still has listeners - const hasListeners = mutex.hasListeners() - - mutex.unlock() - - // Clean up map if no more listeners - if (!hasListeners) { - this.mutexMap.delete(key) - } - } -} diff --git a/src/components/Common/Icon.vue b/src/components/Common/Icon.vue new file mode 100644 index 000000000..805c85043 --- /dev/null +++ b/src/components/Common/Icon.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/components/Common/IconButton.vue b/src/components/Common/IconButton.vue new file mode 100644 index 000000000..47480b081 --- /dev/null +++ b/src/components/Common/IconButton.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/Common/Info.vue b/src/components/Common/Info.vue new file mode 100644 index 000000000..11163fafe --- /dev/null +++ b/src/components/Common/Info.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/Common/InformativeToggle.vue b/src/components/Common/InformativeToggle.vue new file mode 100644 index 000000000..fe6105042 --- /dev/null +++ b/src/components/Common/InformativeToggle.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/components/Common/LabeledAutocompleteInput.vue b/src/components/Common/LabeledAutocompleteInput.vue new file mode 100644 index 000000000..b4b877ffb --- /dev/null +++ b/src/components/Common/LabeledAutocompleteInput.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/src/components/Common/LabeledDropdown.vue b/src/components/Common/LabeledDropdown.vue new file mode 100644 index 000000000..47a0aa964 --- /dev/null +++ b/src/components/Common/LabeledDropdown.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/components/Common/LabeledInput.vue b/src/components/Common/LabeledInput.vue new file mode 100644 index 000000000..3efb79f5f --- /dev/null +++ b/src/components/Common/LabeledInput.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/Common/LabeledTextInput.vue b/src/components/Common/LabeledTextInput.vue new file mode 100644 index 000000000..8227be73e --- /dev/null +++ b/src/components/Common/LabeledTextInput.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/components/Common/Legacy/LegacyDropdown.vue b/src/components/Common/Legacy/LegacyDropdown.vue new file mode 100644 index 000000000..fa86b5ae6 --- /dev/null +++ b/src/components/Common/Legacy/LegacyDropdown.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/components/Common/Logo.vue b/src/components/Common/Logo.vue new file mode 100644 index 000000000..e904f5d92 --- /dev/null +++ b/src/components/Common/Logo.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/Common/Mutex.ts b/src/components/Common/Mutex.ts deleted file mode 100644 index b457a7d8d..000000000 --- a/src/components/Common/Mutex.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * An instance of the Mutex class ensures that calls to specific APIs happen sequentially instead of in parallel. - */ -export class Mutex { - protected listeners: (() => void)[] = [] - protected isLocked = false - - constructor() {} - - /** - * Lock the mutex. If it is already locked, the function will wait until it is unlocked. - * - * @returns When the mutex is unlocked - */ - lock() { - return new Promise(async (resolve, reject) => { - if (this.isLocked) { - this.listeners.push(() => { - this.isLocked = true - resolve() - }) - } else { - this.isLocked = true - resolve() - } - }) - } - - /** - * Unlock the mutex. - * - * @throws If the mutex is not locked. - */ - unlock() { - if (!this.isLocked) { - throw new Error('Trying to unlock a mutex that is not locked') - } - - this.isLocked = false - - if (this.listeners.length > 0) { - const listener = this.listeners.shift()! - - listener() - } - } - - hasListeners() { - return this.listeners.length > 0 - } -} diff --git a/src/components/Common/PersistentQueue.ts b/src/components/Common/PersistentQueue.ts deleted file mode 100644 index a6d7409ec..000000000 --- a/src/components/Common/PersistentQueue.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { App } from '/@/App' -import { Signal } from './Event/Signal' -import { Queue } from './Queue' -import { set, markRaw } from 'vue' -import { dirname } from '/@/utils/path' - -export class PersistentQueue extends Signal> { - protected queue!: Queue - protected app: App - - constructor( - app: App, - protected maxSize: number, - protected savePath: string, - callSetup = true - ) { - super() - set(this, 'queue', new Queue(maxSize)) - this.app = markRaw(app) - - if (callSetup) this.setup() - } - - async setup() { - await this.app.fileSystem.fired - - let data = [] - try { - data = await this.app.fileSystem.readJSON(this.savePath) - } catch {} - - this.queue.fromArray(data) - this.dispatch(this.queue) - } - - protected isEquals(e1: T, e2: T) { - return e1 === e2 - } - - keep(cb: (e: T) => boolean) { - const queue = new Queue(this.maxSize) - - this.elements - .filter((e) => cb(e)) - .forEach((e) => queue.add(e, this.isEquals.bind(this))) - - this.queue = queue - } - - async add(e: T) { - await this.fired - - this.queue.add(e, this.isEquals.bind(this)) - - await this.saveQueue() - } - async remove(e: T) { - await this.fired - - this.queue.remove(e, this.isEquals.bind(this)) - - await this.saveQueue() - } - clear() { - this.queue.clear() - return this.saveQueue() - } - protected async saveQueue() { - await this.app.fileSystem.mkdir(dirname(this.savePath), { - recursive: true, - }) - await this.app.fileSystem.writeFile(this.savePath, this.queue.toJSON()) - } - - get elements() { - return this.queue.elements - } -} diff --git a/src/components/Common/Progress.ts b/src/components/Common/Progress.ts deleted file mode 100644 index 6110246ec..000000000 --- a/src/components/Common/Progress.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' - -export class Progress extends EventDispatcher<[number, number]> { - constructor( - protected current: number, - protected total: number, - protected prevTotal: number - ) { - super() - } - - addToCurrent(value?: number) { - this.current += value ?? 1 - this.dispatch([this.getCurrent(), this.getTotal()]) - } - addToTotal(value?: number) { - this.total += value ?? 1 - this.dispatch([this.getCurrent(), this.getTotal()]) - } - - getTotal() { - return this.total > this.prevTotal ? this.total : this.prevTotal - } - getCurrent() { - return this.current - } - - get isDone() { - return this.getCurrent() === this.getTotal() - } - - setTotal(val: number) { - this.total = val - } -} diff --git a/src/components/Common/Progress.vue b/src/components/Common/Progress.vue new file mode 100644 index 000000000..1207b5733 --- /dev/null +++ b/src/components/Common/Progress.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/components/Common/Queue.ts b/src/components/Common/Queue.ts deleted file mode 100644 index 9dd2d7583..000000000 --- a/src/components/Common/Queue.ts +++ /dev/null @@ -1,61 +0,0 @@ -export class Queue { - protected array: T[] = [] - constructor( - protected maxSize: number = Infinity, - iterable?: Iterable | null | undefined - ) { - for (const e of iterable ?? []) this.add(e) - } - - add( - element: T, - isEquals: (e1: T, e2: T) => boolean = this.isEquals.bind(this) - ) { - const index = this.array.findIndex((e) => isEquals(e, element)) - if (index > -1) { - this.array.splice(index, 1) - this.array.unshift(element) - return this - } - - if (this.array.length >= this.maxSize) this.array.pop() - this.array.unshift(element) - - return this - } - remove( - element: T, - isEquals: (e1: T, e2: T) => boolean = this.isEquals.bind(this) - ) { - const index = this.array.findIndex((e) => isEquals(e, element)) - if (index > -1) this.array.splice(index, 1) - - return this - } - clear() { - this.array = [] - } - protected isEquals(e1: T, e2: T) { - return e1 === e2 - } - - toJSON() { - return JSON.stringify(this.array) - } - fromArray(arr: any) { - this.array = arr - } - [Symbol.iterator]() { - return this.array.values() - } - - get size() { - return this.maxSize - } - get elementCount() { - return this.array.length - } - get elements() { - return [...this.array] - } -} diff --git a/src/components/Common/SubMenu.vue b/src/components/Common/SubMenu.vue new file mode 100644 index 000000000..8f25e3ba6 --- /dev/null +++ b/src/components/Common/SubMenu.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/src/components/Common/Switch.vue b/src/components/Common/Switch.vue new file mode 100644 index 000000000..65dd575d6 --- /dev/null +++ b/src/components/Common/Switch.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/Common/TextButton.vue b/src/components/Common/TextButton.vue new file mode 100644 index 000000000..5cdada355 --- /dev/null +++ b/src/components/Common/TextButton.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/components/Common/Warning.vue b/src/components/Common/Warning.vue new file mode 100644 index 000000000..4f5a7c5cc --- /dev/null +++ b/src/components/Common/Warning.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/Common/WindowResize.ts b/src/components/Common/WindowResize.ts deleted file mode 100644 index de20893ff..000000000 --- a/src/components/Common/WindowResize.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { debounce } from 'lodash-es' -import { reactive } from 'vue' -import { App } from '/@/App' - -export class WindowResize extends EventDispatcher<[number, number]> { - public readonly state = reactive({ - currentHeight: window.innerHeight, - currentWidth: window.innerWidth, - }) - - constructor() { - super() - - window.addEventListener( - 'resize', - debounce(() => this.dispatch(), 50, { trailing: true }) - ) - - this.on(([newWidth, newHeight]) => { - this.state.currentWidth = newWidth - this.state.currentHeight = newHeight - }) - - App.getApp().then((app) => - app.projectManager.projectReady.fired.then(() => this.dispatch()) - ) - } - - dispatch() { - super.dispatch([window.innerWidth, window.innerHeight]) - } -} diff --git a/src/components/Compiler/Actions/RecompileChanges.ts b/src/components/Compiler/Actions/RecompileChanges.ts deleted file mode 100644 index 44676e1f3..000000000 --- a/src/components/Compiler/Actions/RecompileChanges.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { App } from '/@/App' -import { SimpleAction } from '../../Actions/SimpleAction' - -export const recompileChangesConfig = { - icon: 'mdi-cog-outline', - name: 'actions.recompileChanges.name', - description: 'actions.recompileChanges.description', - onTrigger: async () => { - const app = await App.getApp() - const project = app.project - - project.packIndexer.deactivate() - await project.packIndexer.activate(true) - - const [changedFiles, deletedFiles] = await project.packIndexer.fired - - await project.compilerService.start(changedFiles, deletedFiles) - }, -} - -export const recompileChangesAction = new SimpleAction(recompileChangesConfig) diff --git a/src/components/Compiler/Actions/RestartWatchMode.ts b/src/components/Compiler/Actions/RestartWatchMode.ts deleted file mode 100644 index 02c5f54ae..000000000 --- a/src/components/Compiler/Actions/RestartWatchMode.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { App } from '/@/App' -import { SimpleAction } from '../../Actions/SimpleAction' - -export const restartWatchModeConfig = (includeDescription = true) => ({ - icon: 'mdi-restart-alert', - name: 'packExplorer.restartWatchMode.name', - description: includeDescription - ? 'packExplorer.restartWatchMode.description' - : undefined, - onTrigger: () => { - new ConfirmationWindow({ - description: 'packExplorer.restartWatchMode.confirmDescription', - height: 168, - onConfirm: async () => { - const app = await App.getApp() - - await Promise.all([ - app.project.fileSystem.unlink('.bridge/.lightningCache'), - app.project.fileSystem.unlink('.bridge/.compilerFiles'), - ]) - await app.project.fileSystem.writeFile( - '.bridge/.restartWatchMode', - '' - ) - - app.actionManager.trigger('bridge.action.refreshProject') - }, - }) - }, -}) - -export const restartWatchModeAction = new SimpleAction(restartWatchModeConfig()) diff --git a/src/components/Compiler/Compiler.ts b/src/components/Compiler/Compiler.ts deleted file mode 100644 index 57e5eb799..000000000 --- a/src/components/Compiler/Compiler.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { DashService } from './Worker/Service' -import CompilerWorker from './Worker/Service?worker' -import { wrap } from 'comlink' -import { setupWorker } from '/@/utils/worker/setup' - -const worker = new CompilerWorker() -export const DashCompiler = wrap(worker) - -setupWorker(worker) diff --git a/src/components/Compiler/LogPanel/Panel.tsx b/src/components/Compiler/LogPanel/Panel.tsx deleted file mode 100644 index 6f7624899..000000000 --- a/src/components/Compiler/LogPanel/Panel.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Component, For, Show } from 'solid-js' -import { useTranslations } from '../../Composables/useTranslations' -import { SolidIcon } from '../../Solid/Icon/SolidIcon' -import { toSignal } from '../../Solid/toSignal' -import { CompilerWindow } from '../Window/Window' -import { ILogData } from '../Worker/Console' - -export const LogPanel: Component<{ - compilerWindow: CompilerWindow -}> = (props) => { - const { t } = useTranslations() - const [log] = toSignal<[string, ILogData][]>( - props.compilerWindow.getCategories().logs.data - ) - - const icon = (type: string | undefined) => { - if (type === 'info') return 'mdi-information-outline' - if (type === 'warning') return 'mdi-alert-outline' - if (type === 'error') return 'mdi-alert-circle-outline' - - return null - } - - return ( - <> - -
- {t('bottomPanel.compiler.noLogs')} -
-
- - - {([logEntry, { time, type }]) => ( -
- - [{time}] - - - - - - - - {logEntry} - -
- )} -
- - ) -} diff --git a/src/components/Compiler/Sidebar/create.ts b/src/components/Compiler/Sidebar/create.ts deleted file mode 100644 index b3fcce498..000000000 --- a/src/components/Compiler/Sidebar/create.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { proxy } from 'comlink' -import { Project } from '/@/components/Projects/Project/Project' -import { createSidebar } from '../../Sidebar/SidebarElement' -import { App } from '/@/App' - -const saveState = new Map() -interface ISidebarState { - projectName: string - lastReadCount: number - currentCount: number -} - -export function createCompilerSidebar() { - let state: ISidebarState = { - projectName: '', - lastReadCount: 0, - currentCount: 0, - } - let selectedCategory: string | undefined = undefined - let isWindowOpen = false - - const removeListeners = async (project: Project) => { - await project.compilerReady.fired - project.compilerService.removeConsoleListeners() - } - const listenForLogChanges = async ( - project: Project, - resetListeners = false - ) => { - if (resetListeners) await removeListeners(project) - - await project.compilerReady.fired - project.compilerService.onConsoleUpdate( - proxy(async () => { - const allLogs = await project.compilerService.getCompilerLogs() - const logs = allLogs.filter( - ([_, { type }]) => type === 'error' || type === 'warning' - ) - - const app = await App.getApp() - app.windows.compilerWindow.getCategories().logs.data.value = - allLogs - - state.currentCount = logs.length - - // User currently still has logs tab selected and therefore sees the new logs - if (isWindowOpen && selectedCategory === 'logs') - state.lastReadCount = state.currentCount - updateBadge() - }) - ) - } - const updateBadge = () => { - sidebar.attachBadge({ - count: state.currentCount - state.lastReadCount, - color: 'error', - }) - } - - const sidebar = createSidebar({ - id: 'compiler', - displayName: 'sidebar.compiler.name', - icon: 'mdi-cogs', - disabled: () => App.instance.isNoProjectSelected, - /** - * The compiler window is doing more harm than good on mobile (confusion with app settings) so - * we are now disabling it by default. - * Additionally, manual production builds are also pretty much useless as they are internal to bridge. and can only be - * accessed over the "Open Project Folder" button within the project explorer context menu - */ - defaultVisibility: !App.instance.mobile.isCurrentDevice(), - onClick: async () => { - const app = await App.getApp() - const compilerWindow = app.windows.compilerWindow - - const disposable = compilerWindow.activeCategoryChanged.on( - (selected) => { - selectedCategory = selected - - // User switched to logs tab and therefore saw all previously unread logs - if (selected === 'logs') { - state.lastReadCount = state.currentCount - updateBadge() - } - } - ) - - /** - * We manually clear the signal here because that normally happens inside of the window.open() method - * which is triggered after the listener registration in this case - */ - compilerWindow.resetSignal() - compilerWindow.once(async () => { - disposable.dispose() - isWindowOpen = false - listenForLogChanges( - await App.getApp().then((app) => app.project) - ) - }) - - isWindowOpen = true - await compilerWindow.open() - - // User opened window and logs tab is still selected - if (selectedCategory === 'logs') { - state.lastReadCount = state.currentCount - updateBadge() - } - }, - }) - - App.eventSystem.on('projectChanged', async (project: Project) => { - saveState.set(state.projectName, state) - - state = saveState.get(project.name) ?? { - projectName: project.name, - lastReadCount: 0, - currentCount: 0, - } - updateBadge() - - await listenForLogChanges(project, true) - }) -} diff --git a/src/components/Compiler/Window/BuildProfiles.vue b/src/components/Compiler/Window/BuildProfiles.vue deleted file mode 100644 index c88a2b2cf..000000000 --- a/src/components/Compiler/Window/BuildProfiles.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/src/components/Compiler/Window/Content.vue b/src/components/Compiler/Window/Content.vue deleted file mode 100644 index a2d3bb0e7..000000000 --- a/src/components/Compiler/Window/Content.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/src/components/Compiler/Window/Logs.vue b/src/components/Compiler/Window/Logs.vue deleted file mode 100644 index dcd22a2f7..000000000 --- a/src/components/Compiler/Window/Logs.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - diff --git a/src/components/Compiler/Window/OutputFolders.vue b/src/components/Compiler/Window/OutputFolders.vue deleted file mode 100644 index a1d088936..000000000 --- a/src/components/Compiler/Window/OutputFolders.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/src/components/Compiler/Window/WatchMode.vue b/src/components/Compiler/Window/WatchMode.vue deleted file mode 100644 index 329396438..000000000 --- a/src/components/Compiler/Window/WatchMode.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - diff --git a/src/components/Compiler/Window/WatchMode/SettingSheet.vue b/src/components/Compiler/Window/WatchMode/SettingSheet.vue deleted file mode 100644 index f2a841948..000000000 --- a/src/components/Compiler/Window/WatchMode/SettingSheet.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - - - diff --git a/src/components/Compiler/Window/Window.ts b/src/components/Compiler/Window/Window.ts deleted file mode 100644 index 1f907cc30..000000000 --- a/src/components/Compiler/Window/Window.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { Sidebar, SidebarItem } from '/@/components/Windows/Layout/Sidebar' -import Content from './Content.vue' -import BuildProfiles from './BuildProfiles.vue' -import Logs from './Logs.vue' -import OutputFolders from './OutputFolders.vue' -import WatchMode from './WatchMode.vue' -import { IActionConfig, SimpleAction } from '/@/components/Actions/SimpleAction' -import { App } from '/@/App' -import { markRaw, ref } from 'vue' -import json5 from 'json5' -import { proxy } from 'comlink' -import { InfoPanel, IPanelOptions } from '/@/components/InfoPanel/InfoPanel' -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { restartWatchModeAction } from '../Actions/RestartWatchMode' -import { SettingsWindow } from '../../Windows/Settings/SettingsWindow' -import { LocaleManager } from '../../Locales/Manager' -import { NewBaseWindow } from '../../Windows/NewBaseWindow' - -export class CompilerWindow extends NewBaseWindow { - protected sidebar = new Sidebar([], false) - protected categories = markRaw< - Record - >({ - watchMode: { - component: WatchMode, - data: ref({ - shouldSaveSettings: false, - }), - }, - buildProfiles: { - component: BuildProfiles, - data: ref(null), - }, - outputFolders: { - component: OutputFolders, - data: ref(null), - }, - logs: { - component: Logs, - data: ref(null), - }, - }) - public readonly activeCategoryChanged = markRaw( - new EventDispatcher() - ) - protected lastUsedBuildProfile: SimpleAction | null = null - protected runLastProfileAction = new SimpleAction({ - name: 'sidebar.compiler.actions.runLastProfile', - icon: 'mdi-play', - color: 'accent', - onTrigger: () => { - if (!this.lastUsedBuildProfile) - throw new Error( - `Invalid state: Triggered runLastProfileAction without a last used build profile` - ) - this.lastUsedBuildProfile.trigger() - }, - }) - - constructor() { - super(Content, false, true) - this.defineWindow() - - const reloadAction = new SimpleAction({ - icon: 'mdi-refresh', - name: 'general.reload', - color: 'accent', - onTrigger: () => { - this.reload() - }, - }) - this.state.actions.push(reloadAction) - - const clearConsoleAction = new SimpleAction({ - icon: 'mdi-close-circle-outline', - name: 'general.clear', - color: 'accent', - onTrigger: async () => { - const app = await App.getApp() - app.project.compilerService.clearCompilerLogs() - this.categories.logs.data.value = [] - }, - }) - this.sidebar.on((selected) => { - this.activeCategoryChanged.dispatch(selected) - - if (selected === 'logs') - this.state.actions.splice( - this.state.actions.indexOf(reloadAction), - 0, - clearConsoleAction - ) - else - this.state.actions = this.state.actions.filter( - (a) => a !== clearConsoleAction - ) - }) - // Close this window whenever the watch mode is restarted - restartWatchModeAction.on(() => this.close()) - - App.getApp().then(() => { - this.sidebar.addElement( - new SidebarItem({ - id: 'watchMode', - text: LocaleManager.translate( - 'sidebar.compiler.categories.watchMode.name' - ), - color: 'primary', - icon: 'mdi-eye-outline', - }) - ) - this.sidebar.addElement( - new SidebarItem({ - id: 'buildProfiles', - text: LocaleManager.translate( - 'sidebar.compiler.categories.profiles' - ), - color: 'primary', - icon: 'mdi-motion-play-outline', - }) - ) - this.sidebar.addElement( - new SidebarItem({ - id: 'outputFolders', - text: LocaleManager.translate( - 'sidebar.compiler.categories.outputFolders' - ), - color: 'primary', - icon: 'mdi-folder-open-outline', - }) - ) - this.sidebar.addElement( - new SidebarItem({ - id: 'logs', - text: LocaleManager.translate( - 'sidebar.compiler.categories.logs.name' - ), - color: 'primary', - icon: 'mdi-format-list-text', - }) - ) - this.sidebar.setDefaultSelected() - }) - } - - getCategories() { - return this.categories - } - - async reload() { - const app = await App.getApp() - - this.categories.buildProfiles.data.value = await this.loadProfiles() - this.categories.logs.data.value = - await app.project.compilerService.getCompilerLogs() - this.categories.outputFolders.data.value = - await this.loadOutputFolders() - } - async open() { - const app = await App.getApp() - - await this.reload() - await app.project.compilerService.onConsoleUpdate( - proxy(async () => { - this.categories.logs.data.value = - await app.project.compilerService.getCompilerLogs() - }) - ) - - if (this.lastUsedBuildProfile) - this.state.actions.unshift(this.runLastProfileAction) - - super.open() - } - async close() { - const app = await App.getApp() - await app.project.compilerService.removeConsoleListeners() - - this.state.actions = this.state.actions.filter( - (a) => a !== this.runLastProfileAction - ) - - super.close() - - if (this.categories.watchMode.data.value.shouldSaveSettings) { - this.categories.watchMode.data.value.shouldSaveSettings = false - await SettingsWindow.saveSettings() - } - } - - async loadProfiles() { - const app = await App.getApp() - const project = app.project - - const configDir = await project.fileSystem.getDirectoryHandle( - `.bridge/compiler`, - { create: true } - ) - - const actions: IActionConfig[] = [ - { - icon: 'mdi-cog', - name: 'sidebar.compiler.default.name', - description: 'sidebar.compiler.default.description', - onTrigger: async (action) => { - this.close() - this.lastUsedBuildProfile = action - - const service = await project.createDashService( - 'production' - ) - await service.build() - }, - }, - ] - - for await (const entry of configDir.values()) { - if ( - entry.kind !== 'file' || - entry.name === '.DS_Store' || - entry.name === 'default.json' // Default compiler config already gets triggerd with the default action above (outside of the loop) - ) - continue - const file = await entry.getFile() - - let config - try { - config = json5.parse(await file.text()) - } catch { - continue - } - - actions.push({ - icon: config.icon, - name: config.name, - description: config.description, - onTrigger: async (action) => { - this.close() - this.lastUsedBuildProfile = action - - const service = await project.createDashService( - 'production', - `${project.projectPath}/.bridge/compiler/${entry.name}` - ) - await service.build() - }, - }) - } - - return actions.map((action) => new SimpleAction(action)) - } - - async loadOutputFolders() { - const app = await App.getApp() - - const comMojang = app.comMojang - const { hasComMojang, didDenyPermission } = comMojang.status - let panelConfig: IPanelOptions - - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value - ) { - panelConfig = { - text: 'comMojang.status.notAvailable', - type: 'error', - isDismissible: false, - } - } else if (!hasComMojang && didDenyPermission) { - panelConfig = { - text: 'comMojang.status.deniedPermission', - type: 'warning', - isDismissible: false, - } - } else if (hasComMojang && !didDenyPermission) { - panelConfig = { - text: 'comMojang.status.sucess', - type: 'success', - isDismissible: false, - } - } else if (!hasComMojang) { - panelConfig = { - text: import.meta.env.VITE_IS_TAURI_APP - ? 'comMojang.status.notSetupTauri' - : 'comMojang.status.notSetup', - type: 'error', - isDismissible: false, - } - } else { - throw new Error(`Invalid com.mojang status`) - } - - return new InfoPanel(panelConfig) - } -} diff --git a/src/components/Compiler/Worker/Console.ts b/src/components/Compiler/Worker/Console.ts deleted file mode 100644 index e5792ddc4..000000000 --- a/src/components/Compiler/Worker/Console.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Console } from 'dash-compiler' - -export interface ILogData { - // Format: HH:MM:SS - time: string - type?: 'info' | 'error' | 'warning' -} - -export class ForeignConsole extends Console { - protected logs: [string, ILogData][] = [] - protected changeListeners: (() => void)[] = [] - - getLogs() { - return this.logs - } - - protected basicLog( - message: any, - { type }: { type?: 'info' | 'error' | 'warning' } = {} - ) { - switch (type) { - case 'warning': - console.warn(message) - break - case 'error': - console.error(message) - break - case 'info': - console.info(message) - break - default: - console.log(message) - break - } - if (message instanceof Error) message = message.message - - this.logs.unshift([ - typeof message === 'string' ? message : JSON.stringify(message), - { time: new Date().toLocaleTimeString(), type }, - ]) - this.logsChanged() - } - - addChangeListener(cb: () => void) { - this.changeListeners.push(cb) - } - removeChangeListeners() { - this.changeListeners = [] - } - logsChanged() { - this.changeListeners.forEach((cb) => cb()) - } - - clear() { - this.logs = [] - this.logsChanged() - } - log(...args: any[]) { - args.forEach((arg) => this.basicLog(arg)) - } - info(...args: any[]) { - args.forEach((arg) => this.basicLog(arg, { type: 'info' })) - } - warn(...args: any[]) { - args.forEach((arg) => this.basicLog(arg, { type: 'warning' })) - } - error(...args: any[]) { - args.forEach((arg) => this.basicLog(arg, { type: 'error' })) - } -} diff --git a/src/components/Compiler/Worker/FileSystem.ts b/src/components/Compiler/Worker/FileSystem.ts deleted file mode 100644 index 0d5b8b1d8..000000000 --- a/src/components/Compiler/Worker/FileSystem.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { FileSystem } from 'dash-compiler' -import { IDirEntry } from 'dash-compiler/dist/FileSystem/FileSystem' -import { AnyDirectoryHandle } from '../../FileSystem/Types' -import { FileSystem as BridgeFileSystem } from '/@/components/FileSystem/FileSystem' - -export class DashFileSystem extends FileSystem { - protected internalFs: BridgeFileSystem - - constructor(baseDirectory: AnyDirectoryHandle) { - super() - this.internalFs = new BridgeFileSystem(baseDirectory) - } - - get internal() { - return this.internalFs - } - - readJson(path: string) { - return this.internalFs.readJSON(path) - } - async writeJson(path: string, content: any, beautify?: boolean) { - await this.internalFs.writeJSON(path, content, beautify) - } - async readFile(path: string): Promise { - const file = await this.internalFs.readFile(path) - return file.isVirtual ? await file.toBlobFile() : file - } - async writeFile(path: string, content: string | Uint8Array) { - await this.internalFs.writeFile(path, content) - } - // async copyFile(from: string, to: string, outputFs = this) { - // const [fromHandle, toHandle] = await Promise.all([ - // this.internalFs.getFileHandle(from), - // outputFs.internalFs.getFileHandle(to, true), - // ]) - - // const [writable, fromFile] = await Promise.all([ - // toHandle.createWritable({ keepExistingData: true }), - // fromHandle.getFile(), - // ]) - - // await writable.write(fromFile) - // await writable.close() - // } - - mkdir(path: string) { - return this.internalFs.mkdir(path) - } - unlink(path: string) { - return this.internalFs.unlink(path) - } - async allFiles(path: string) { - return (await this.internalFs.readFilesFromDir(path)).map( - (file) => file.path - ) - } - readdir(path: string): Promise { - return this.internalFs.readdir(path, { withFileTypes: true }) - } - async lastModified(filePath: string) { - return 0 - } -} diff --git a/src/components/Compiler/Worker/Plugins/CustomCommands/generateSchemas.ts b/src/components/Compiler/Worker/Plugins/CustomCommands/generateSchemas.ts deleted file mode 100644 index c5d989b77..000000000 --- a/src/components/Compiler/Worker/Plugins/CustomCommands/generateSchemas.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Command, DefaultConsole } from 'dash-compiler' -import { App } from '/@/App' -import { JsRuntime } from '/@/components/Extensions/Scripts/JsRuntime' -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { iterateDir, iterateDirParallel } from '/@/utils/iterateDir' - -// TODO: Rewrite this to properly cache evaluated scripts until they are changed by the user -// See how it's done for custom components already! -export async function generateCommandSchemas() { - const app = await App.getApp() - const project = app.project - const jsRuntime = new JsRuntime() - await ( - await project.compilerService.completedStartUp - ).fired - - const v1CompatMode = project.config.get().bridge?.v1CompatMode ?? false - const fromFilePath = project.config.resolvePackPath( - 'behaviorPack', - 'commands' - ) - - let baseDir: AnyDirectoryHandle - try { - baseDir = await app.fileSystem.getDirectoryHandle(fromFilePath) - } catch { - return [] - } - - const schemas: any[] = [] - - await iterateDirParallel( - baseDir, - async (fileHandle, filePath) => { - let fileContent = await fileHandle - .getFile() - .then(async (file) => new Uint8Array(await file.arrayBuffer())) - - // Only transform file if it's a TypeScript file - // TODO: Change back to always go through compiler pipeline once we cache the evaluated schemas - // This is too slow at the moment - if (filePath.endsWith('.ts')) { - fileContent = ( - await project.compilerService.compileFile( - filePath, - fileContent - ) - )[1] - } - - const file = new File([fileContent], fileHandle.name) - const command = new Command( - new DefaultConsole(), - await file.text(), - 'development', - v1CompatMode - ) - - await command.load(jsRuntime, filePath, 'client').catch((err) => { - console.error(`Failed to load command "${filePath}": ${err}`) - }) - - schemas.push(...command.getSchema()) - }, - undefined, - fromFilePath - ) - - return schemas -} diff --git a/src/components/Compiler/Worker/Plugins/CustomComponent/ComponentSchemas.ts b/src/components/Compiler/Worker/Plugins/CustomComponent/ComponentSchemas.ts deleted file mode 100644 index fd49f394e..000000000 --- a/src/components/Compiler/Worker/Plugins/CustomComponent/ComponentSchemas.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { Remote } from 'comlink' -import { Component, DefaultConsole } from 'dash-compiler' -import { DashService } from '../../Service' -import { App } from '/@/App' -import { JsRuntime } from '/@/components/Extensions/Scripts/JsRuntime' -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { VirtualFile } from '/@/components/FileSystem/Virtual/File' -import { Project } from '/@/components/Projects/Project/Project' -import { IDisposable } from '/@/types/disposable' -import { iterateDir, iterateDirParallel } from '/@/utils/iterateDir' - -export const supportsCustomComponents = ['block', 'item', 'entity'] -export type TComponentFileType = typeof supportsCustomComponents[number] -export class ComponentSchemas { - protected schemas: Record> = { - block: {}, - item: {}, - entity: {}, - } - protected schemaLookup = new Map() - protected disposables?: IDisposable[] - protected dash?: Remote - - constructor(protected project: Project) {} - - get(fileType: TComponentFileType) { - return this.schemas[fileType] - } - - async activate() { - this.dash = await this.project.createDashService('development') - - this.disposables = [ - App.eventSystem.on( - 'fileSave', - async ([filePath, file]: [string, File]) => { - const app = await App.getApp() - const project = app.project - - const componentPath = project.config.resolvePackPath( - 'behaviorPack', - 'components' - ) - - const v1CompatMode = - project.config.get().bridge?.v1CompatMode ?? false - - if (!filePath.startsWith(componentPath)) return - const jsRuntime = new JsRuntime() - - const fileType = filePath - .replace(componentPath + '/', '') - .split('/')[0] as TComponentFileType - const isSupportedFileType = - supportsCustomComponents.includes(fileType) - - if (!isSupportedFileType && v1CompatMode) { - // This is not a supported file type but v1CompatMode is enabled, - // meaning that we emulate v1's behavior where components could be placed anywhere - await Promise.all( - supportsCustomComponents.map((fileType) => - this.evalComponentSchema( - jsRuntime, - fileType, - filePath, - file, - true - ) - ) - ) - } else if (isSupportedFileType) { - // No v1CompatMode but a valid file type which supports custom components to work with - await this.evalComponentSchema( - jsRuntime, - fileType, - filePath, - file, - v1CompatMode - ) - } - } - ), - App.eventSystem.on('fileUnlinked', (filePath: string) => { - const [fileType, componentName] = - this.schemaLookup.get(filePath) ?? [] - if (!fileType || !componentName) return - - this.schemas[fileType][componentName] = undefined - this.schemaLookup.delete(filePath) - }), - ] - - const jsRuntime = new JsRuntime() - - await Promise.all( - supportsCustomComponents.map((fileType) => - this.generateComponentSchemas(jsRuntime, fileType) - ) - ) - } - - dispose() { - this.disposables?.forEach((disposable) => disposable.dispose()) - this.disposables = undefined - } - - protected async generateComponentSchemas( - jsRuntime: JsRuntime, - fileType: TComponentFileType - ) { - const v1CompatMode = - this.project.config.get().bridge?.v1CompatMode ?? false - - const fromFilePath = v1CompatMode - ? this.project.config.resolvePackPath('behaviorPack', 'components') - : this.project.config.resolvePackPath( - 'behaviorPack', - `components/${fileType}` - ) - - let baseDir: AnyDirectoryHandle - try { - baseDir = await this.project.app.fileSystem.getDirectoryHandle( - fromFilePath - ) - } catch { - return {} - } - - // Reset schemas - this.schemas[fileType] = {} - - await iterateDirParallel( - baseDir, - async (fileHandle, filePath) => { - await this.evalComponentSchema( - jsRuntime, - fileType, - filePath, - await fileHandle.getFile(), - v1CompatMode - ) - }, - undefined, - fromFilePath - ) - } - - protected async evalComponentSchema( - jsRuntime: JsRuntime, - fileType: TComponentFileType, - filePath: string, - file: File | VirtualFile, - v1CompatMode: boolean - ) { - let fileContent = new Uint8Array(await file.arrayBuffer()) - - if (filePath.endsWith('.ts')) { - fileContent = ( - await this.dash!.compileFile(filePath, fileContent) - )[1] - } - - const transformedFile = new File([fileContent], file.name) - const component = new Component( - new DefaultConsole(), - fileType, - await transformedFile.text(), - 'development', - v1CompatMode - ) - - const loadedCorrectly = await component.load( - jsRuntime, - filePath, - 'client' - ) - - if (loadedCorrectly && component.name) { - this.schemas[fileType][component.name] = component.getSchema() - this.schemaLookup.set(filePath, [fileType, component.name]) - } - } -} diff --git a/src/components/Compiler/Worker/Service.ts b/src/components/Compiler/Worker/Service.ts deleted file mode 100644 index 94cdd04a8..000000000 --- a/src/components/Compiler/Worker/Service.ts +++ /dev/null @@ -1,258 +0,0 @@ -import '/@/utils/worker/inject' - -import '/@/components/FileSystem/Virtual/Comlink' -import { expose } from 'comlink' -import { FileTypeLibrary, IFileType } from '/@/components/Data/FileType' -import { DataLoader } from '/@/components/Data/DataLoader' -import type { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { Dash, initRuntimes, FileSystem } from 'dash-compiler' -import { PackTypeLibrary } from '/@/components/Data/PackType' -import { DashFileSystem } from './FileSystem' -import { Signal } from '/@/components/Common/Event/Signal' -import { dirname } from '/@/utils/path' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { ForeignConsole } from './Console' -import { Mutex } from '../../Common/Mutex' -import wasmUrl from '@swc/wasm-web/wasm-web_bg.wasm?url' -import { VirtualDirectoryHandle } from '../../FileSystem/Virtual/DirectoryHandle' -import { TauriFsStore } from '../../FileSystem/Virtual/Stores/TauriFs' - -initRuntimes(wasmUrl) - -export interface ICompilerOptions { - config: string - compilerConfig?: string - mode: 'development' | 'production' - projectName: string - pluginFileTypes: IFileType[] -} -const dataLoader = new DataLoader() -const consoles = new Map() - -/** - * Dispatches an event whenever a task starts with progress steps - */ -export class DashService extends EventDispatcher { - protected _fileSystem?: FileSystem - public _fileType?: FileTypeLibrary - protected _dash?: Dash - public isDashFree = new Mutex() - protected _projectDir?: string - public isSetup = false - public completedStartUp = new Signal() - protected _console?: ForeignConsole - - constructor() { - super() - } - - async setup( - baseDirectory: AnyDirectoryHandle, - comMojangDirectory: AnyDirectoryHandle | undefined, - options: ICompilerOptions - ) { - await this.isDashFree.lock() - - if (!dataLoader.hasFired) await dataLoader.loadData() - - this._fileSystem = await this.createFileSystem(baseDirectory) - const outputFileSystem = - comMojangDirectory && options.mode === 'development' - ? await this.createFileSystem(comMojangDirectory) - : undefined - this._fileType = new FileTypeLibrary() - this._fileType.setPluginFileTypes(options.pluginFileTypes) - - let console = consoles.get(options.projectName) - if (!console) { - console = new ForeignConsole() - consoles.set(options.projectName, console) - } - this._console = console - - this._projectDir = dirname(options.config) - - this._dash = new Dash(this._fileSystem, outputFileSystem, { - config: options.config, - compilerConfig: options.compilerConfig, - console, - mode: options.mode, - fileType: this._fileType, - packType: new PackTypeLibrary(), - verbose: true, - requestJsonData: (path) => dataLoader.readJSON(path), - }) - await this.dash.setup(dataLoader) - - this.isDashFree.unlock() - this.isSetup = true - } - protected async createFileSystem(directoryHandle: AnyDirectoryHandle) { - // Default file system on PWA builds - if (!import.meta.env.VITE_IS_TAURI_APP) - return new DashFileSystem(directoryHandle) - - if (!(directoryHandle instanceof VirtualDirectoryHandle)) - throw new Error( - `Expected directoryHandle to be a virtual directory handle` - ) - const baseStore = directoryHandle.getBaseStore() - if (!(baseStore instanceof TauriFsStore)) - throw new Error( - `Expected virtual directory to be backed by TauriFsStore` - ) - - const { TauriBasedDashFileSystem } = await import('./TauriFs') - return new TauriBasedDashFileSystem(baseStore.getBaseDirectory()) - } - - get dash() { - if (!this._dash) throw new Error('Dash not initialized') - return this._dash - } - get fileSystem() { - if (!this._fileSystem) throw new Error('File system not initialized') - return this._fileSystem - } - get console() { - if (!this._console) throw new Error('Console not initialized') - return this._console - } - get projectDir() { - if (!this._projectDir) - throw new Error('Project directory not initialized') - return this._projectDir - } - - getCompilerLogs() { - return this.console.getLogs() - } - clearCompilerLogs() { - this.console.clear() - } - onConsoleUpdate(cb: () => void) { - this.console.addChangeListener(cb) - } - removeConsoleListeners() { - this.console.removeChangeListeners() - } - - async compileFile(filePath: string, fileContent: Uint8Array) { - await this.isDashFree.lock() - - const [deps, data] = await this.dash.compileFile(filePath, fileContent) - this.isDashFree.unlock() - return [deps, data ?? fileContent] - } - - async start(changedFiles: string[], deletedFiles: string[]) { - await this.isDashFree.lock() - - const fileExists = (path: string) => - this.fileSystem - .readFile(path) - .then(() => true) - .catch(() => false) - - if ( - (await fileExists( - `${this.projectDir}/.bridge/.restartWatchMode` - )) || - !(await fileExists( - // TODO(Dash): Replace with call to "this.dash.dashFilePath" once the accessor is no longer protected - `${this.projectDir}/.bridge/.dash.${this.dash.getMode()}.json` - )) - ) { - await Promise.all([ - this.build(false).catch((err) => console.error(err)), - this.fileSystem - .unlink(`${this.projectDir}/.bridge/.restartWatchMode`) - .catch((err) => console.error(err)), - ]) - } else { - if (deletedFiles.length > 0) - await this.unlinkMultiple(deletedFiles, false) - - if (changedFiles.length > 0) - await this.updateFiles(changedFiles, false) - } - - this.completedStartUp.dispatch() - this.isDashFree.unlock() - } - - async build(acquireLock = true) { - if (acquireLock) await this.isDashFree.lock() - this.dispatch() - - // Reload plugins so we can be sure that e.g. custom commands/components get discovered correctly - await this.dash.reload() - await this.dash.build() - - if (acquireLock) this.isDashFree.unlock() - } - async updateFiles(filePaths: string[], acquireLock = true) { - if (acquireLock) await this.isDashFree.lock() - this.dispatch() - - // Reload plugins so we can be sure that e.g. custom commands/components get discovered correctly - await this.dash.reload() - await this.dash.updateFiles(filePaths) - - if (acquireLock) this.isDashFree.unlock() - } - async unlink(path: string, updateDashFile = true) { - await this.isDashFree.lock() - - await this.dash.unlink(path, updateDashFile) - - this.isDashFree.unlock() - } - async unlinkMultiple(paths: string[], acquireLock = true) { - if (acquireLock) await this.isDashFree.lock() - - await this.dash.unlinkMultiple(paths) - - if (acquireLock) this.isDashFree.unlock() - } - async rename(oldPath: string, newPath: string) { - await this.isDashFree.lock() - this.dispatch() - - await this.dash.rename(oldPath, newPath) - - this.isDashFree.unlock() - } - async renameMultiple(renamePaths: [string, string][]) { - for (const [oldPath, newPath] of renamePaths) { - await this.dash.rename(oldPath, newPath) - } - } - getCompilerOutputPath(filePath: string) { - return this.dash.getCompilerOutputPath(filePath) - } - getFileDependencies(filePath: string) { - return this.dash.getFileDependencies(filePath) - } - - async reloadPlugins() { - await this.isDashFree.lock() - - await this.dash.reload() - - this.isDashFree.unlock() - } - - onProgress(cb: (progress: number) => void) { - const disposable = this.dash.progress.onChange((progress) => { - if (progress.percentage === 1) { - disposable.dispose() - cb(1) - } else { - cb(progress.percentage) - } - }) - } -} - -expose(DashService, self) diff --git a/src/components/Compiler/Worker/TauriFs.ts b/src/components/Compiler/Worker/TauriFs.ts deleted file mode 100644 index 5d5db3b98..000000000 --- a/src/components/Compiler/Worker/TauriFs.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { FileSystem } from 'dash-compiler' -import { IDirEntry } from 'dash-compiler/dist/FileSystem/FileSystem' -import { - writeFile, - writeBinaryFile, - createDir, - removeDir, - removeFile, - readDir, - FileEntry, - copyFile, -} from '@tauri-apps/api/fs' -import { join, basename, dirname, isAbsolute, sep } from '@tauri-apps/api/path' -import json5 from 'json5' -import { invoke } from '@tauri-apps/api' - -export class TauriBasedDashFileSystem extends FileSystem { - constructor(protected baseDirectory?: string) { - super() - } - - async resolvePath(path: string) { - path = path.replaceAll(/\\|\//g, sep) - if (!this.baseDirectory || (await isAbsolute(path))) return path - - return join(this.baseDirectory, path) - } - - async readJson(path: string) { - const file = await this.readFile(path) - - return json5.parse(await file.text()) - } - async writeJson(path: string, content: any, beautify?: boolean) { - await this.writeFile( - path, - JSON.stringify(content, null, beautify ? '\t' : undefined) - ) - } - async readFile(path: string): Promise { - const resolvedPath = await this.resolvePath(path) - const binaryData = new Uint8Array( - await invoke>('read_file', { - path: resolvedPath, - }) - ) - - return new File([binaryData], await basename(path)) - } - async writeFile(path: string, content: string | Uint8Array) { - const resolvedPath = await this.resolvePath(path) - await createDir(await dirname(resolvedPath), { recursive: true }) - - if (typeof content === 'string') await writeFile(resolvedPath, content) - else await writeBinaryFile(resolvedPath, content) - } - async copyFile(from: string, to: string, outputFs = this) { - const outputPath = await outputFs.resolvePath(to) - await createDir(await dirname(outputPath), { recursive: true }).catch( - () => {} - ) - - await copyFile(await this.resolvePath(from), outputPath) - } - - async mkdir(path: string) { - await createDir(await this.resolvePath(path), { recursive: true }) - } - async unlink(path: string) { - const resolvedPath = await this.resolvePath(path) - - await Promise.all([ - removeDir(resolvedPath, { recursive: true }).catch(() => {}), - removeFile(resolvedPath).catch(() => {}), - ]) - } - async allFiles(path: string) { - const entries = await readDir(await this.resolvePath(path), { - recursive: true, - }) - - return this.flattenEntries(entries) - } - protected relative(path: string) { - if (!this.baseDirectory) return path - - return path - .replace(`${this.baseDirectory}${sep}`, '') - .replaceAll('\\', '/') // Dash expects forward slashes - } - protected flattenEntries(entries: FileEntry[]) { - const files: string[] = [] - - for (const { path, children } of entries) { - if (children) { - files.push(...this.flattenEntries(children)) - continue - } - - files.push(this.relative(path)) - } - - return files - } - async readdir(path: string): Promise { - const entries = await readDir(await this.resolvePath(path)) - - return entries.map((entry) => ({ - name: entry.name!, - kind: entry.children ? 'directory' : 'file', - })) - } - async lastModified(filePath: string) { - return 0 - } -} diff --git a/src/components/Composables/Display/useDisplay.ts b/src/components/Composables/Display/useDisplay.ts deleted file mode 100644 index a85151c5d..000000000 --- a/src/components/Composables/Display/useDisplay.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { computed } from 'vue' -import { vuetify } from '../../App/Vuetify' - -export function useDisplay() { - return { - isMobile: computed(() => vuetify.framework.breakpoint.mobile), - isMinimalDisplay: computed(() => { - return !vuetify.framework.breakpoint.mdAndUp - }), - } -} diff --git a/src/components/Composables/DoubleClick.ts b/src/components/Composables/DoubleClick.ts deleted file mode 100644 index ecb042fb9..000000000 --- a/src/components/Composables/DoubleClick.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { pointerDevice } from '/@/utils/pointerDevice' - -export function useDoubleClick( - onClick: (isDoubleClick: boolean, ...args: T[]) => void, - alwaysTriggerSingleClick: boolean = false -) { - let timer: number | null = null - let clickedAmount = 0 - - return (...args: T[]) => { - if (pointerDevice.value === 'touch') return onClick(false, ...args) - - if (clickedAmount === 0) { - clickedAmount++ - if (alwaysTriggerSingleClick) onClick(false, ...args) - - timer = window.setTimeout(() => { - clickedAmount = 0 - timer = null - if (!alwaysTriggerSingleClick) onClick(false, ...args) - }, 500) - } else { - if (timer) window.clearTimeout(timer) - clickedAmount = 0 - onClick(true, ...args) - } - } -} diff --git a/src/components/Composables/LongPress.ts b/src/components/Composables/LongPress.ts deleted file mode 100644 index 77ea7541b..000000000 --- a/src/components/Composables/LongPress.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * A function that returns two functions (onTouchStart & onTouchEnd) given a callback that should be run when a user - * long presses on an element on touch devices - */ -export function useLongPress( - longPressCallback: (...args: any[]) => unknown, - shortPressCallback?: (...args: any[]) => unknown, - touchEndCallback?: (...args: any[]) => unknown, - longPressDuration: number = 500 -) { - let timeoutId: number | null = null - - const onTouchStart = (...args: any[]) => { - // Set a timeout to fire the callback - timeoutId = window.setTimeout(() => { - longPressCallback(...args) - timeoutId = null - }, longPressDuration) - } - - const onTouchEnd = () => { - // Clear the timeout - if (timeoutId) { - window.clearTimeout(timeoutId) - timeoutId = null - shortPressCallback?.() - } - touchEndCallback?.() - } - - return { onTouchStart, onTouchEnd } -} diff --git a/src/components/Composables/Sidebar/useSidebarState.ts b/src/components/Composables/Sidebar/useSidebarState.ts deleted file mode 100644 index 185539835..000000000 --- a/src/components/Composables/Sidebar/useSidebarState.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { computed, watch } from 'vue' -import { settingsState } from '../../Windows/Settings/SettingsState' -import { App } from '/@/App' - -export function useSidebarState() { - const isNavVisible = computed(() => App.sidebar.isNavigationVisible.value) - const isContentVisible = computed( - () => isNavVisible.value && App.sidebar.isContentVisible.value - ) - const isAttachedRight = computed( - () => settingsState.sidebar && settingsState.sidebar.isSidebarRight - ) - - return { - isNavVisible, - isContentVisible, - isAttachedRight, - } -} diff --git a/src/components/Composables/UseProject.ts b/src/components/Composables/UseProject.ts deleted file mode 100644 index 1fcd8a0f6..000000000 --- a/src/components/Composables/UseProject.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Ref, ref, watch, onUnmounted, nextTick, watchEffect } from 'vue' -import { Project } from '../Projects/Project/Project' -import { App } from '/@/App' -import { IDisposable } from '/@/types/disposable' - -export function useProject() { - const project = >ref(null) - let disposable: IDisposable | null = null - - App.getApp().then(async (app) => { - await app.projectManager.projectReady.fired - project.value = app.project - - disposable = App.eventSystem.on('projectChanged', (newProject) => { - project.value = newProject - }) - }) - - onUnmounted(() => { - disposable?.dispose() - disposable = null - }) - - return { - project, - } -} diff --git a/src/components/Composables/UseTabSystem.ts b/src/components/Composables/UseTabSystem.ts deleted file mode 100644 index 688e59a32..000000000 --- a/src/components/Composables/UseTabSystem.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { computed, ref } from 'vue' -import { useProject } from './UseProject' - -export function useTabSystem(tabSystemId = ref(0)) { - const { project } = useProject() - const tabSystem = computed( - () => project.value?.tabSystems[tabSystemId.value] - ) - const activeTabSystem = computed(() => project.value?.tabSystem) - const tabSystems = computed(() => project.value?.tabSystems) - const shouldRenderWelcomeScreen = computed(() => { - return ( - tabSystems.value && - (tabSystems.value?.[0].shouldRender.value || - tabSystems.value?.[1].shouldRender.value) - ) - }) - - return { - tabSystem, - activeTabSystem, - tabSystems, - shouldRenderWelcomeScreen, - } -} diff --git a/src/components/Composables/useDarkMode.ts b/src/components/Composables/useDarkMode.ts deleted file mode 100644 index 4b05e97c0..000000000 --- a/src/components/Composables/useDarkMode.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { computed } from 'vue' -import { vuetify } from '../App/Vuetify' - -export function useDarkMode() { - return { - isDarkMode: computed(() => vuetify.framework.theme.dark), - } -} diff --git a/src/components/Composables/useHighContrast.ts b/src/components/Composables/useHighContrast.ts deleted file mode 100644 index 8a8b4ee70..000000000 --- a/src/components/Composables/useHighContrast.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { computed } from 'vue' -import { settingsState } from '../Windows/Settings/SettingsState' - -export function useHighContrast() { - return { - highContrast: computed( - () => settingsState.appearance?.highContrast ?? false - ), - } -} diff --git a/src/components/Composables/useTranslations.ts b/src/components/Composables/useTranslations.ts deleted file mode 100644 index 9308f5c6c..000000000 --- a/src/components/Composables/useTranslations.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { LocaleManager } from '../Locales/Manager' - -export function useTranslations() { - return { - t: (translationKey?: string) => LocaleManager.translate(translationKey), - } -} diff --git a/src/components/ContextMenu/ContextMenu.ts b/src/components/ContextMenu/ContextMenu.ts deleted file mode 100644 index d8a476ae1..000000000 --- a/src/components/ContextMenu/ContextMenu.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { markRaw, reactive, ref } from 'vue' -import { ActionManager } from '../Actions/ActionManager' - -export interface IPosition { - clientX: number - clientY: number -} - -interface ICard { - title: string - text: string -} - -export interface IContextMenuOptions { - /** - * Context menu cards appear above the regular action list - */ - card?: ICard - mayCloseOnClickOutside?: boolean -} - -export class ContextMenu { - protected mayCloseOnClickOutside = true - protected isVisible = ref(false) - protected actionManager = ref() - protected position = reactive({ - x: 0, - y: 0, - }) - protected menuHeight = 0 - protected card: ICard = { - title: '', - text: '', - } - - show( - event: IPosition, - actionManager: ActionManager, - { - card = { title: '', text: '' }, - mayCloseOnClickOutside = true, - }: IContextMenuOptions - ) { - this.position.x = event.clientX - this.position.y = event.clientY - this.actionManager.value = markRaw(actionManager) - this.mayCloseOnClickOutside = mayCloseOnClickOutside - this.card = Object.assign(this.card, card) - - // Add up size of each context menu element + top/bottom padding - this.menuHeight = - this.actionManager.value - .getAllElements() - .reduce((result, action) => { - switch (action.type) { - case 'submenu': - return result + 40 - case 'divider': - return result + 1 - case 'action': - default: - return result + 40 - } - }, 0) + 16 - this.isVisible.value = true - } - - setMayCloseOnClickOutside(mayCloseOnClickOutside: boolean) { - setTimeout(() => { - this.mayCloseOnClickOutside = mayCloseOnClickOutside - }, 100) - } -} diff --git a/src/components/ContextMenu/ContextMenu.vue b/src/components/ContextMenu/ContextMenu.vue deleted file mode 100644 index 22ae33e7c..000000000 --- a/src/components/ContextMenu/ContextMenu.vue +++ /dev/null @@ -1,110 +0,0 @@ - - - - - diff --git a/src/components/ContextMenu/List.vue b/src/components/ContextMenu/List.vue deleted file mode 100644 index 54bc9c557..000000000 --- a/src/components/ContextMenu/List.vue +++ /dev/null @@ -1,168 +0,0 @@ - - - diff --git a/src/components/ContextMenu/showContextMenu.ts b/src/components/ContextMenu/showContextMenu.ts deleted file mode 100644 index 68e44aee1..000000000 --- a/src/components/ContextMenu/showContextMenu.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { App } from '/@/App' -import { IActionConfig } from '../Actions/SimpleAction' -import { ActionManager } from '../Actions/ActionManager' -import { IContextMenuOptions, IPosition } from './ContextMenu' - -export interface ISubmenuConfig { - type: 'submenu' - name: string - icon: string - actions: (IActionConfig | null | { type: 'divider' })[] -} - -export type TActionConfig = - | IActionConfig - | ISubmenuConfig - | { type: 'divider' } - | null - -export async function showContextMenu( - event: MouseEvent | IPosition, - actions: TActionConfig[], - options: IContextMenuOptions = {} -) { - let filteredActions = < - (IActionConfig | ISubmenuConfig | { type: 'divider' })[] - >actions.filter((action) => action !== null) - - if (event instanceof MouseEvent) { - event.preventDefault() - event.stopImmediatePropagation() - } - if (filteredActions.length === 0) return - - const app = await App.getApp() - const actionManager = new ActionManager() - - filteredActions.forEach((action) => { - switch (action.type) { - case 'submenu': - actionManager.addSubMenu(action) - break - case 'divider': - actionManager.addDivider() - break - default: - actionManager.create(action) - break - } - }) - - app.contextMenu.show(event, actionManager, options) -} diff --git a/src/components/Data/DataLoader.ts b/src/components/Data/DataLoader.ts deleted file mode 100644 index b44fda63b..000000000 --- a/src/components/Data/DataLoader.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { baseUrl } from '/@/utils/baseUrl' -import { unzip, Unzipped } from 'fflate' -import { VirtualDirectoryHandle } from '../FileSystem/Virtual/DirectoryHandle' -import { basename, dirname } from '/@/utils/path' -import { FileSystem } from '../FileSystem/FileSystem' -import { zipSize } from '/@/utils/app/dataPackage' -import { whenIdle } from '/@/utils/whenIdle' -import { get, set } from 'idb-keyval' -import { compareVersions } from 'bridge-common-utils' -import { version as appVersion } from '/@/utils/app/version' -import { IndexedDbStore } from '../FileSystem/Virtual/Stores/IndexedDb' -import { MemoryStore } from '../FileSystem/Virtual/Stores/Memory' - -export class DataLoader extends FileSystem { - _virtualFileSystem?: VirtualDirectoryHandle - - get virtualFileSystem() { - if (!this._virtualFileSystem) { - throw new Error('DataLoader: virtualFileSystem is not initialized') - } - return this._virtualFileSystem - } - constructor(protected isMainLoader = false) { - super() - } - - async loadData(forceDataDownload = false) { - if (this.hasFired) { - console.warn( - `This dataLoader instance already loaded data. You called loadData() twice.` - ) - return - } - - let savedDataForVersion = await get( - 'savedDataForVersion' - ) - if (forceDataDownload) { - savedDataForVersion = undefined - await set('savedDataForVersion', undefined) - } - const savedAllDataInIdb = savedDataForVersion - ? compareVersions(appVersion, savedDataForVersion, '=') - : false - - if (this.isMainLoader) - console.log( - savedAllDataInIdb - ? '[APP] Data saved; restoring from cache...' - : `[APP] Latest data not saved; fetching now...` - ) - - console.time('[App] Data') - - const indexedDbStore = new IndexedDbStore( - 'data-fs', - // Do not allow writes to data-fs - true - ) - // Clear data-fs if the version has changed - const mayClearDb = this.isMainLoader && !savedAllDataInIdb - if (mayClearDb) await indexedDbStore.clear() - - // Create virtual filesystem - this._virtualFileSystem = new VirtualDirectoryHandle( - savedAllDataInIdb ? indexedDbStore : new MemoryStore('data-fs'), - '' - ) - await this._virtualFileSystem.setupDone.fired - - // All current data is already downloaded & saved in IDB, no need to do it again - if (savedAllDataInIdb) { - this.setup(this._virtualFileSystem) - console.timeEnd('[App] Data') - return - } - - // Read packages.zip file - const rawData = await fetch(baseUrl + 'packages.zip').then((response) => - response.arrayBuffer() - ) - if (rawData.byteLength !== zipSize) { - throw new Error( - `Error: Data package was larger than the expected size of ${zipSize} bytes; got ${rawData.byteLength} bytes` - ) - } - - // Unzip data - const unzipped = await new Promise((resolve, reject) => - unzip(new Uint8Array(rawData), async (error, zip) => { - if (error) return reject(error) - resolve(zip) - }) - ) - - const defaultHandle = await this._virtualFileSystem.getDirectoryHandle( - 'data', - { create: true } - ) - const folders: Record = { - '.': defaultHandle, - } - - for (const path in unzipped) { - const name = basename(path) - const parentDir = dirname(path) - - if (path.endsWith('/')) { - // Current entry is a folder - const handle = await folders[parentDir].getDirectoryHandle( - name, - { create: true } - ) - folders[path.slice(0, -1)] = handle - } else { - // Current entry is a file - await folders[parentDir].getFileHandle(name, { - create: true, - initialData: unzipped[path], - }) - } - } - - if (this.isMainLoader && !forceDataDownload) { - await this._virtualFileSystem!.moveToIdb() - await set('savedDataForVersion', appVersion) - } - - this.setup(this._virtualFileSystem) - console.timeEnd('[App] Data') - } -} diff --git a/src/components/Data/FileType.ts b/src/components/Data/FileType.ts deleted file mode 100644 index dee5257fd..000000000 --- a/src/components/Data/FileType.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { ILightningInstruction } from '/@/components/PackIndexer/Worker/Main' -import type { IPackSpiderInstruction } from '/@/components/PackIndexer/Worker/PackSpider/PackSpider' -import { Signal } from '/@/components/Common/Event/Signal' -import { DataLoader } from './DataLoader' -import { isMatch } from 'bridge-common-utils' -import type { ProjectConfig } from '/@/components/Projects/Project/Config' -import { FileType } from 'mc-project-core' - -export type { IFileType, IDefinition } from 'mc-project-core' - -/** - * Used for return type of FileType.getMonacoSchemaArray() function - */ -export interface IMonacoSchemaArrayEntry { - fileMatch?: string[] - uri: string - schema?: any -} - -/** - * Utilities around bridge.'s file definitions - */ -export class FileTypeLibrary extends FileType { - public ready = new Signal() - - constructor(projectConfig?: ProjectConfig) { - super(projectConfig, isMatch) - } - - setProjectConfig(projectConfig: ProjectConfig) { - this.projectConfig = projectConfig - } - - async setup(dataLoader: DataLoader) { - if (this.fileTypes.length > 0) return - await dataLoader.fired - - this.fileTypes = await dataLoader.readJSON( - 'data/packages/minecraftBedrock/fileDefinitions.json' - ) - - this.loadLightningCache(dataLoader) - this.loadPackSpider(dataLoader) - - this.ready.dispatch() - } - - /** - * Get a JSON schema array that can be used to set Monaco's JSON defaults - */ - getMonacoSchemaEntries() { - return this.fileTypes - .map(({ detect = {}, schema }) => { - if (!detect.matcher) return null - - const packTypes = - detect?.packType === undefined - ? [] - : Array.isArray(detect?.packType) - ? detect?.packType - : [detect?.packType] - - return { - fileMatch: this.prefixMatchers( - packTypes, - Array.isArray(detect.matcher) - ? [...detect.matcher] - : [detect.matcher] - ).map((fileMatch) => - encodeURI(fileMatch) - // Monaco doesn't like these characters in fileMatch - .replaceAll(/;|,|@|&|=|\+|\$|\.|!|'|\(|\)|#/g, '*') - ), - uri: schema, - } - }) - .filter((schemaEntry) => schemaEntry !== null) - .flat() - } - - protected lCacheFiles: Record = {} - protected lCacheFilesLoaded = new Signal() - async loadLightningCache(dataLoader: DataLoader) { - const lightningCache = await dataLoader.readJSON( - `data/packages/minecraftBedrock/lightningCaches.json` - ) - - const findCacheFile = (fileName: string) => - Object.entries(lightningCache).find(([filePath]) => - filePath.endsWith(fileName) - ) - - for (const fileType of this.fileTypes) { - if (!fileType.lightningCache) continue - - const [filePath, cacheFile] = - findCacheFile(fileType.lightningCache) ?? [] - if (!filePath) { - throw new Error( - `Lightning cache file "${fileType.lightningCache}" for file type "${fileType.id}" not found` - ) - } - - this.lCacheFiles[fileType.lightningCache] = < - string | ILightningInstruction[] - >cacheFile - } - this.lCacheFilesLoaded.dispatch() - } - - async getLightningCache(filePath: string) { - const { lightningCache } = this.get(filePath) ?? {} - if (!lightningCache) return [] - - await this.lCacheFilesLoaded.fired - - return this.lCacheFiles[lightningCache] ?? [] - } - - protected packSpiderFiles: Record = {} - protected packSpiderFilesLoaded = new Signal() - async loadPackSpider(dataLoader: DataLoader) { - const packSpiderFiles = await dataLoader.readJSON( - `data/packages/minecraftBedrock/packSpiders.json` - ) - - this.packSpiderFiles = Object.fromEntries( - <[string, IPackSpiderInstruction][]>this.fileTypes - .map(({ id, packSpider }) => { - if (!packSpider) return - - return [ - id, - packSpiderFiles[ - `file:///data/packages/minecraftBedrock/packSpider/${packSpider}` - ], - ] - }) - .filter((data) => data !== undefined) - ) - - this.packSpiderFilesLoaded.dispatch() - } - async getPackSpiderData() { - await this.packSpiderFilesLoaded.fired - - return this.packSpiderFiles - } -} diff --git a/src/components/Data/FormatVersions.ts b/src/components/Data/FormatVersions.ts deleted file mode 100644 index 66b67e93a..000000000 --- a/src/components/Data/FormatVersions.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { App } from '/@/App' -import { compareVersions } from 'bridge-common-utils' -import type { DataLoader } from './DataLoader' - -interface IFormatVersionDefs { - currentStable: string - formatVersions: string[] -} - -export async function getFilteredFormatVersions(targetVersion?: string) { - const app = await App.getApp() - await app.dataLoader.fired - - await app.projectManager.projectReady.fired - if (!targetVersion) targetVersion = app.projectConfig.get().targetVersion - - return getFormatVersions().then((formatVersions) => - formatVersions.filter( - (formatVersion) => - !targetVersion || - compareVersions(formatVersion, targetVersion, '<=') - ) - ) -} -export async function getFormatVersions() { - const app = await App.getApp() - await app.dataLoader.fired - - const def: IFormatVersionDefs = await app.dataLoader.readJSON( - 'data/packages/minecraftBedrock/formatVersions.json' - ) - - return def.formatVersions.reverse() -} - -export function getLatestFormatVersion() { - return getFormatVersions().then((formatVersions) => formatVersions[0]) -} - -export async function getStableFormatVersion(dataLoader: DataLoader) { - await dataLoader.fired - - const def: IFormatVersionDefs = await dataLoader.readJSON( - 'data/packages/minecraftBedrock/formatVersions.json' - ) - - return def.currentStable -} diff --git a/src/components/Data/JSONDefaults.ts b/src/components/Data/JSONDefaults.ts deleted file mode 100644 index 52f226c38..000000000 --- a/src/components/Data/JSONDefaults.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { App } from '/@/App' -import { IMonacoSchemaArrayEntry } from '/@/components/Data/FileType' -import { Project } from '../Projects/Project/Project' -import { IDisposable } from '/@/types/disposable' -import { FileTab } from '../TabSystem/FileTab' -import { SchemaScript } from './SchemaScript' -import { SchemaManager } from '../JSONSchema/Manager' -import { EventDispatcher } from '../Common/Event/EventDispatcher' -import { AnyFileHandle } from '../FileSystem/Types' -import { Tab } from '../TabSystem/CommonTab' -import { ComponentSchemas } from '../Compiler/Worker/Plugins/CustomComponent/ComponentSchemas' -import { loadMonaco, useMonaco } from '../../utils/libs/useMonaco' -import { Task } from '../TaskManager/Task' - -let globalSchemas: Record = {} -let loadedGlobalSchemas = false - -export class JsonDefaults extends EventDispatcher { - protected loadedSchemas = false - protected localSchemas: Record = {} - protected disposables: IDisposable[] = [] - public readonly componentSchemas: ComponentSchemas - protected task: Task | null = null - - constructor(protected project: Project) { - super() - - this.componentSchemas = new ComponentSchemas(project) - } - - get isReady() { - return this.loadedSchemas && loadedGlobalSchemas - } - - async activate() { - console.time('[SETUP] JSONDefaults') - await this.project.app.project.packIndexer.fired - - // Don't await to start loading schemas as soon as possible - this.componentSchemas.activate() - - this.disposables = [ - // Updating currentContext/ references - App.eventSystem.on('currentTabSwitched', (tab: Tab) => { - if ( - tab instanceof FileTab && - App.fileType.isJsonFile(tab.getPath()) - ) - this.updateDynamicSchemas(tab.getPath()) - }), - App.eventSystem.on('refreshCurrentContext', (filePath: string) => - this.updateDynamicSchemas(filePath) - ), - App.eventSystem.on('disableValidation', () => { - this.setJSONDefaults(false) - }), - ].filter((disposable) => disposable !== undefined) - - await this.loadAllSchemas() - await this.setJSONDefaults() - console.timeEnd('[SETUP] JSONDefaults') - } - - deactivate() { - this.disposables.forEach((disposable) => disposable.dispose()) - this.componentSchemas.dispose() - this.disposables = [] - this.task?.complete() - this.task = null - } - - async loadAllSchemas() { - this.localSchemas = {} - const app = await App.getApp() - this.task = app.taskManager.create({ - icon: 'mdi-book-open-outline', - name: 'taskManager.tasks.loadingSchemas.name', - description: 'taskManager.tasks.loadingSchemas.description', - totalTaskSteps: 10, - }) - - await app.dataLoader.fired - this.task?.update(1) - const packages = await app.dataLoader.readdir('data/packages') - this.task?.update(2) - - // Static schemas - for (const packageName of packages) { - try { - await this.loadStaticSchemas( - await app.dataLoader.getFileHandle( - `data/packages/${packageName}/schemas.json` - ), - packageName === 'minecraftBedrock' - ) - } catch (err) { - console.error(err) - continue - } - } - loadedGlobalSchemas = true - this.task?.update(3) - - // Schema scripts - await this.runSchemaScripts(app) - this.task?.update(5) - const tab = this.project.tabSystem?.selectedTab - if (tab && tab instanceof FileTab) { - const fileType = App.fileType.getId(tab.getPath()) - this.addSchemas( - await this.requestSchemaFor(fileType, tab.getPath()) - ) - await this.runSchemaScripts( - app, - tab.isForeignFile ? undefined : tab.getPath() - ) - } - - // Schemas generated from lightning cache - this.addSchemas(await this.getDynamicSchemas()) - this.task?.update(4) - - this.loadedSchemas = true - this.task?.update(6) - this.task?.complete() - } - - async setJSONDefaults(validate = true) { - const schemas = Object.assign({}, globalSchemas, this.localSchemas) - - if (loadMonaco.hasFired) { - const { languages } = await useMonaco() - - languages.json.jsonDefaults.setDiagnosticsOptions({ - enableSchemaRequest: false, - allowComments: true, - validate, - schemas: Object.values(schemas), - }) - } - - SchemaManager.setJSONDefaults(schemas) - - this.dispatch() - } - - async reload() { - const app = await App.getApp() - - app.windows.loadingWindow.open() - this.loadedSchemas = false - this.localSchemas = {} - loadedGlobalSchemas = false - globalSchemas = {} - await this.deactivate() - await this.activate() - app.windows.loadingWindow.close() - } - - async updateDynamicSchemas(filePath: string) { - const app = await App.getApp() - const fileType = App.fileType.getId(filePath) - - this.addSchemas(await this.requestSchemaFor(fileType, filePath)) - this.addSchemas(await this.requestSchemaFor(fileType)) - await this.runSchemaScripts(app, filePath) - await this.setJSONDefaults() - } - async updateMultipleDynamicSchemas(filePaths: string[]) { - const app = await App.getApp() - - const updatedFileTypes = new Set() - - for (const filePath of filePaths) { - const fileType = App.fileType.getId(filePath) - if (updatedFileTypes.has(fileType)) continue - - this.addSchemas(await this.requestSchemaFor(fileType)) - await this.runSchemaScripts(app, filePath) - updatedFileTypes.add(fileType) - } - - await this.setJSONDefaults() - } - - addSchemas(addSchemas: IMonacoSchemaArrayEntry[]) { - addSchemas.forEach((addSchema) => { - if (this.localSchemas[addSchema.uri]) { - if (addSchema.schema) - this.localSchemas[addSchema.uri].schema = addSchema.schema - if (addSchema.fileMatch) - this.localSchemas[addSchema.uri].fileMatch = - addSchema.fileMatch - } else { - this.localSchemas[addSchema.uri] = addSchema - } - }) - } - - async requestSchemaFor(fileType: string, fromFilePath?: string) { - const packIndexer = this.project.packIndexer - await packIndexer.fired - - return await packIndexer.service!.getSchemasFor(fileType, fromFilePath) - } - async getDynamicSchemas() { - return ( - await Promise.all( - App.fileType.getIds().map((id) => this.requestSchemaFor(id)) - ) - ).flat() - } - async loadStaticSchemas( - fileHandle: AnyFileHandle, - updateSchemaEntries = false - ) { - if (!loadedGlobalSchemas) { - const file = await fileHandle.getFile() - const schemas = JSON.parse(await file.text()) - - for (const uri in schemas) { - globalSchemas[uri] = { uri, schema: schemas[uri] } - } - } - - if (updateSchemaEntries) { - // Fetch schema entry points - const schemaEntries = App.fileType.getMonacoSchemaEntries() - - // Reset old file matchers - schemaEntries.forEach((schemaEntry) => { - if (!schemaEntry.uri) return - - const currSchema = globalSchemas[schemaEntry.uri] - - if (currSchema && currSchema.fileMatch && schemaEntry.fileMatch) - currSchema.fileMatch = undefined - }) - - // Add schema entry points - schemaEntries.forEach((schemaEntry) => { - // Non-json files; e.g. .lang - if (!schemaEntry.uri) return - - if (globalSchemas[schemaEntry.uri]) { - if (schemaEntry.schema) - globalSchemas[schemaEntry.uri].schema = - schemaEntry.schema - - if (schemaEntry.fileMatch) { - if (globalSchemas[schemaEntry.uri].fileMatch) - globalSchemas[schemaEntry.uri].fileMatch!.push( - ...schemaEntry.fileMatch - ) - else - globalSchemas[schemaEntry.uri].fileMatch = - schemaEntry.fileMatch - } - } else { - globalSchemas[schemaEntry.uri] = schemaEntry - } - }) - } - } - - addSchemaEntries() {} - - async runSchemaScripts(app: App, filePath?: string) { - const schemaScript = new SchemaScript(this, app, filePath) - await schemaScript.runSchemaScripts(this.localSchemas) - } -} diff --git a/src/components/Data/PackType.ts b/src/components/Data/PackType.ts deleted file mode 100644 index 2ab768b8c..000000000 --- a/src/components/Data/PackType.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Signal } from '../Common/Event/Signal' -import { DataLoader } from './DataLoader' -import { - PackType as BasePackType, - ProjectConfig, - type IPackType, -} from 'mc-project-core' - -export type { IPackType, TPackTypeId } from 'mc-project-core' - -/** - * Utilities around bridge.'s pack definitions - */ -export class PackTypeLibrary extends BasePackType { - public readonly ready = new Signal() - - constructor(projectConfig?: ProjectConfig) { - super(projectConfig) - } - - async setup(dataLoader: DataLoader) { - if (this.packTypes.length > 0) return - await dataLoader.fired - - this.packTypes = ( - await dataLoader - .readJSON('data/packages/minecraftBedrock/packDefinitions.json') - .catch(() => []) - ) - this.ready.dispatch() - } -} diff --git a/src/components/Data/PackTypeViewer.vue b/src/components/Data/PackTypeViewer.vue deleted file mode 100644 index 4f3832342..000000000 --- a/src/components/Data/PackTypeViewer.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - - - diff --git a/src/components/Data/RequiresMatcher/FailureMessage.ts b/src/components/Data/RequiresMatcher/FailureMessage.ts deleted file mode 100644 index 9fed8e825..000000000 --- a/src/components/Data/RequiresMatcher/FailureMessage.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { translate } from '../../Locales/Manager' -import { IFailure, IRequirements } from './RequiresMatcher' -import { App } from '/@/App' - -export async function createFailureMessage( - failure: IFailure, - requirements: IRequirements -) { - const app = await App.getApp() - - const failureMessageHeader = translate( - `windows.createPreset.disabledPreset.${failure.type}` - ) - let failureMessageDetails - switch (failure.type) { - case 'experimentalGameplay': - if (requirements.experimentalGameplay) - failureMessageDetails = requirements.experimentalGameplay - .map( - (exp) => - `${ - exp.startsWith('!') - ? translate('general.no') - : '' - } ${translate( - `experimentalGameplay.${exp.replace( - '!', - '' - )}.name` - )}` - ) - .join(', ') - - break - case 'packTypes': - if (requirements.packTypes) - failureMessageDetails = requirements.packTypes - .map( - (packType) => - `${ - packType.startsWith('!') - ? translate('general.no') - : '' - } ${translate( - `packType.${packType.replace('!', '')}.name` - )}` - ) - .join(', ') - - break - case 'targetVersion': - if (Array.isArray(requirements.targetVersion)) - failureMessageDetails = requirements.targetVersion.join(' ') - else if ( - requirements.targetVersion && - requirements.targetVersion.min && - requirements.targetVersion.max - ) - failureMessageDetails = `Min: ${requirements.targetVersion.min} | Max: ${requirements.targetVersion.max}` - break - case 'manifestDependency': - if (requirements.dependencies) - failureMessageDetails = requirements.dependencies.join(', ') - break - } - - return failureMessageDetails - ? `${failureMessageHeader}: ${failureMessageDetails}` - : undefined -} diff --git a/src/components/Data/RequiresMatcher/RequiresMatcher.ts b/src/components/Data/RequiresMatcher/RequiresMatcher.ts deleted file mode 100644 index 07487f01c..000000000 --- a/src/components/Data/RequiresMatcher/RequiresMatcher.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { TCompareOperator, compareVersions } from 'bridge-common-utils' -import { TPackTypeId } from '/@/components/Data/PackType' -import { App } from '/@/App' -import { getLatestFormatVersion } from '/@/components/Data/FormatVersions' -import json5 from 'json5' - -export interface IRequirements { - /** - * Compare a version with the project's target version. - */ - targetVersion?: [TCompareOperator, string] | { min: string; max: string } - /** - * Check for the status of experimental gameplay toggles in the project. - */ - experimentalGameplay?: string[] - /** - * Check whether pack types are present in the project. - */ - packTypes?: TPackTypeId[] - /** - * Check for manifest dependencies to be present in the pack. - */ - dependencies?: { module_name: string; version?: string }[] - /** - * Whether all conditions must be met. If set to false, any condition met makes the matcher valid. - */ - matchAll?: boolean -} - -export interface IFailure { - type: - | 'targetVersion' - | 'experimentalGameplay' - | 'packTypes' - | 'manifestDependency' -} - -export class RequiresMatcher { - protected experimentalGameplay: Record = {} - protected projectTargetVersion: string = '' - public failures: IFailure[] = [] - // The following properties will only be defined after calling setup() - protected latestFormatVersion!: string - protected bpManifest: any - protected isSetup = false - protected app!: App - - constructor() {} - - async setup() { - if (this.isSetup) return - - this.app = await App.getApp() - const [_, latestFormatVersion, bpManifest] = await Promise.all([ - this.app.projectManager.projectReady.fired, - getLatestFormatVersion(), - this.app.fileSystem - .readJSON( - this.app.project.config.resolvePackPath( - 'behaviorPack', - 'manifest.json' - ) - ) - .catch(() => null), - ]) - - const config = this.app.project.config.get() - - this.experimentalGameplay = config.experimentalGameplay ?? {} - - this.latestFormatVersion = latestFormatVersion - this.projectTargetVersion = - config.targetVersion ?? this.latestFormatVersion - - this.bpManifest = bpManifest - this.isSetup = true - } - protected resetFailures() { - this.failures = [] - } - - isValid(requires?: IRequirements) { - this.resetFailures() - - if (!requires) return true - if (!this.isSetup) - throw new Error( - 'RequiresMatcher is not setup. Make sure to call setup() before isValid().' - ) - requires.matchAll ??= true - - // Pack type - const matchesPackTypes = this.app.project.hasPacks( - requires.packTypes ?? [] - ) - // Target version - const matchesTargetVersion = - !requires.targetVersion || - (!Array.isArray(requires.targetVersion) - ? compareVersions( - this.projectTargetVersion, - requires.targetVersion?.min ?? '1.8.0', - '>=' - ) && - compareVersions( - this.projectTargetVersion, - requires.targetVersion?.max ?? '1.18.0', - '<=' - ) - : compareVersions( - this.projectTargetVersion, - requires.targetVersion[1], - requires.targetVersion[0] - )) - // Experimental gameplay - const matchesExperimentalGameplay = - !requires.experimentalGameplay || - requires.experimentalGameplay.every((experimentalFeature) => - experimentalFeature.startsWith('!') - ? !this.experimentalGameplay[ - experimentalFeature.replace('!', '') - ] - : this.experimentalGameplay[experimentalFeature] - ) - // Manifest dependencies - - const dependencies: - | { module_name: string; version?: string }[] - | undefined = this.bpManifest?.dependencies?.map((dep: any) => { - if (dep?.module_name) { - // Convert old module names to new naming convention - switch (dep.module_name) { - case 'mojang-minecraft': - return { - module_name: '@minecraft/server', - version: dep.version, - } - case 'mojang-gametest': - return { - module_name: '@minecraft/server-gametest', - version: dep.version, - } - case 'mojang-minecraft-server-ui': - return { - module_name: '@minecraft/server-ui', - version: dep.version, - } - case 'mojang-minecraft-server-admin': - return { - module_name: '@minecraft/server-admin', - version: dep.version, - } - case 'mojang-net': - return { - module_name: '@minecraft/server-net', - version: dep.version, - } - default: - return { - module_name: dep.module_name, - version: dep.version, - } - } - } else { - switch (dep.uuid ?? '') { - case 'b26a4d4c-afdf-4690-88f8-931846312678': - return { - module_name: '@minecraft/server', - version: dep.version, - } - case '6f4b6893-1bb6-42fd-b458-7fa3d0c89616': - return { - module_name: '@minecraft/server-gametest', - version: dep.version, - } - case '2bd50a27-ab5f-4f40-a596-3641627c635e': - return { - module_name: '@minecraft/server-ui', - version: dep.version, - } - case '53d7f2bf-bf9c-49c4-ad1f-7c803d947920': - return { - module_name: '@minecraft/server-admin', - version: dep.version, - } - case '777b1798-13a6-401c-9cba-0cf17e31a81b': - return { - module_name: '@minecraft/server-net', - version: dep.version, - } - default: - return { - module_name: dep.uuid ?? '', - version: dep.version, - } - } - } - }) - - const matchesManifestDependency = - !requires.dependencies || - !dependencies || - requires?.dependencies.every((dep) => { - for (const dependency of dependencies) { - if (dependency.module_name !== dep.module_name) continue - if ( - dependency.version && - dependency.version !== dep.version - ) - continue - - return true - } - - return false - }) - - if (!matchesPackTypes) this.failures.push({ type: 'packTypes' }) - if (!matchesTargetVersion) this.failures.push({ type: 'targetVersion' }) - if (!matchesExperimentalGameplay) - this.failures.push({ type: 'experimentalGameplay' }) - if (!matchesManifestDependency) - this.failures.push({ type: 'manifestDependency' }) - - return requires.matchAll - ? matchesPackTypes && - matchesExperimentalGameplay && - matchesTargetVersion && - matchesManifestDependency - : (matchesPackTypes && requires.packTypes) || - (matchesExperimentalGameplay && - requires.experimentalGameplay) || - (matchesTargetVersion && requires.targetVersion) || - (matchesManifestDependency && requires.dependencies) - } -} diff --git a/src/components/Data/SchemaScript.ts b/src/components/Data/SchemaScript.ts deleted file mode 100644 index c90ca636c..000000000 --- a/src/components/Data/SchemaScript.ts +++ /dev/null @@ -1,190 +0,0 @@ -import json5 from 'json5' -import { run } from '../Extensions/Scripts/run' -import { getFilteredFormatVersions } from './FormatVersions' -import { App } from '/@/App' -import { walkObject } from 'bridge-common-utils' -import { v4 as uuid } from 'uuid' -import { compareVersions } from 'bridge-common-utils' -import { TPackTypeId } from './PackType' -import type { JsonDefaults } from './JSONDefaults' -import { TComponentFileType } from '../Compiler/Worker/Plugins/CustomComponent/ComponentSchemas' - -export class SchemaScript { - constructor( - protected jsonDefaults: JsonDefaults, - protected app: App, - protected filePath?: string - ) {} - - protected async runScript(scriptPath: string, script: string) { - const fs = this.app.fileSystem - - let currentJson = {} - let failedFileLoad = true - if (this.filePath) { - try { - const currentFile = await this.app.project.getFileFromDiskOrTab( - this.filePath - ) - - currentJson = json5.parse(await currentFile.text()) - failedFileLoad = false - } catch {} - } - - try { - return await run({ - async: true, - script, - env: { - readdir: (path: string) => - fs.readFilesFromDir(path).catch(() => []), - uuid, - getFormatVersions: getFilteredFormatVersions, - getCacheDataFor: async ( - fileType: string, - filePath?: string, - cacheKey?: string - ) => { - const packIndexer = this.app.project.packIndexer - await packIndexer.fired - - return packIndexer.service.getCacheDataFor( - fileType, - filePath, - cacheKey - ) - }, - getIndexedPaths: async ( - fileType?: string, - sort?: boolean - ) => { - const packIndexer = this.app.project.packIndexer - await packIndexer.fired - - const paths = await packIndexer.service.getAllFiles( - fileType, - sort - ) - return paths - }, - getProjectPrefix: () => - this.app.projectConfig.get().namespace ?? 'bridge', - getProjectConfig: () => this.app.projectConfig.get(), - getFileName: () => - !this.filePath - ? undefined - : this.filePath.split(/\/|\\/g).pop(), - customComponents: (fileType: TComponentFileType) => - this.jsonDefaults.componentSchemas.get(fileType), - get: (path: string) => { - const data: any[] = [] - walkObject(path, currentJson, (d) => data.push(d)) - return data - }, - compare: compareVersions, - resolvePackPath: ( - packId?: TPackTypeId, - filePath?: string - ) => - this.app.projectConfig.resolvePackPath( - packId, - filePath - ), - failedCurrentFileLoad: failedFileLoad, - }, - }) - } catch (err: any) { - console.error( - `Error evaluating schemaScript "${scriptPath}": ${err.message}` - ) - } - } - protected processScriptResult( - scriptPath: string, - schemaScript: any, - scriptResult: any, - localSchemas: any - ) { - if (!scriptResult) return - if (scriptPath.endsWith('.js')) { - if (scriptResult.keep) return - - schemaScript = { - ...schemaScript, - type: scriptResult.type, - generateFile: scriptResult.generateFile, - } - scriptResult = scriptResult.data - } - - if ( - schemaScript.type === 'object' && - !Array.isArray(scriptResult) && - typeof scriptResult === 'object' - ) { - localSchemas[ - `file:///data/packages/minecraftBedrock/schema/${schemaScript.generateFile}` - ] = { - uri: `file:///data/packages/minecraftBedrock/schema/${schemaScript.generateFile}`, - schema: { - type: 'object', - properties: scriptResult, - }, - } - } else if (schemaScript.type === 'custom') { - localSchemas[ - `file:///data/packages/minecraftBedrock/schema/${schemaScript.generateFile}` - ] = { - uri: `file:///data/packages/minecraftBedrock/schema/${schemaScript.generateFile}`, - schema: scriptResult, - } - } else { - localSchemas[ - `file:///data/packages/minecraftBedrock/schema/${schemaScript.generateFile}` - ] = { - uri: `file:///data/packages/minecraftBedrock/schema/${schemaScript.generateFile}`, - schema: { - type: schemaScript.type === 'enum' ? 'string' : 'object', - enum: - schemaScript.type === 'enum' ? scriptResult : undefined, - properties: - schemaScript.type === 'properties' - ? Object.fromEntries( - scriptResult.map((res: string) => [res, {}]) - ) - : undefined, - }, - } - } - } - - async runSchemaScripts(localSchemas: any) { - const schemaScripts = await this.app.dataLoader.readJSON( - 'data/packages/minecraftBedrock/schemaScripts.json' - ) - - const promises = [] - - for (const [scriptPath, script] of Object.entries(schemaScripts)) { - let schemaScript: any - if (scriptPath.endsWith('.js')) schemaScript = { script } - else schemaScript = script - - promises.push( - this.runScript(scriptPath, schemaScript.script).then( - (scriptResult) => { - this.processScriptResult( - scriptPath, - schemaScript, - scriptResult, - localSchemas - ) - } - ) - ) - } - - await Promise.all(promises) - } -} diff --git a/src/components/Data/TypeLoader.ts b/src/components/Data/TypeLoader.ts deleted file mode 100644 index 7795097ad..000000000 --- a/src/components/Data/TypeLoader.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { App } from '/@/App' -import { IDisposable } from '/@/types/disposable' -import { DataLoader } from './DataLoader' -import { Tab } from '/@/components/TabSystem/CommonTab' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { - IRequirements, - RequiresMatcher, -} from './RequiresMatcher/RequiresMatcher' -import { useMonaco } from '/@/utils/libs/useMonaco' -import { v4 as uuid } from 'uuid' - -/** - * A map of type locations to type defintions that have been loaded - */ -const types = new Map() - -export class TypeLoader { - protected disposables: IDisposable[] = [] - protected typeDisposables: IDisposable[] = [] - protected userTypeDisposables: IDisposable[] = [] - protected currentTypeEnv: string | null = null - protected isLoading: boolean = false - - constructor(protected dataLoader: DataLoader) {} - - async activate(filePath?: string) { - this.disposables = [ - App.eventSystem.on('currentTabSwitched', async (tab: Tab) => { - if (!tab.isForeignFile && tab instanceof FileTab) { - await this.setTypeEnv(tab.getPath()) - - await this.loadUserTypes() - } - }), - ] - if (filePath) await this.setTypeEnv(filePath) - - await this.loadUserTypes() - } - deactivate() { - this.currentTypeEnv = null - this.typeDisposables.forEach((disposable) => disposable.dispose()) - this.disposables.forEach((disposable) => disposable.dispose()) - this.typeDisposables = [] - this.disposables = [] - } - - protected async load(typeLocations: [string, string?][]) { - // Ignore if we are already loading types (e.g. if a tab has been switched while loading) - if (this.isLoading) return [] - this.isLoading = true - - const app = await App.getApp() - - // Load the cache index which maps the urls to the location of the files in cache - let cacheIndex: Record = {} - try { - cacheIndex = await app.fileSystem.readJSON( - `~local/data/cache/types/index.json` - ) - } catch {} - - // Create promises for loading each type definition. Once this resolves, the types will be cached appropriately - const toCache = await Promise.all( - typeLocations.map(([typeLocation, moduleName]) => { - return new Promise<[string, string, boolean, string?]>( - async (resolve) => { - // Before we try to load anything, make sure the type definition hasn't already been loaded and set in the type map - let src = types.get(typeLocation) - if (src) resolve([typeLocation, src, false, moduleName]) - - // Decide whether the type is being loaded from data or needs to be fetched externally - const isFromData = typeLocation.startsWith('types/') - if (isFromData) { - // Load types directly from bridge.'s data - await this.dataLoader.fired - - const file = await this.dataLoader.readFile( - `data/packages/minecraftBedrock/${typeLocation}` - ) - src = await file.text() - resolve([typeLocation, src, false, moduleName]) - return - } - - // First check cache to see if we have already cached the file, if so resolve with the file from cache - const cacheLocation = cacheIndex[typeLocation] - const file = cacheLocation - ? await app.fileSystem - .readFile(cacheLocation) - .catch(() => null) - : null - - // File is cached, so resolve with the file from cache - if (file) { - resolve([ - typeLocation, - await file.text(), - false, - moduleName, - ]) - return - } - - // The file couldn't be fetched from cache (because it is not in index or at the path specified) - // So we need to fetch it - const res = await fetch(typeLocation).catch(() => null) - // TODO: Maybe set a variable (failedToFetchAtLeastOnce) to later open an information window that tells the user that some types couldn't be fetched - - // If the fetch failed, resolve with an empty string but don't cache it - const text = res ? await res.text() : '' - - resolve([typeLocation, text, text !== '', moduleName]) - } - ) - }) - ) - - for (const [typeLocation, definition, updateCache] of toCache) { - // First, save types to 'types' map - types.set(typeLocation, definition) - - // Then if don't need to update cache, continue processing the next type - if (!updateCache) continue - - // Create a random file name for the file to be stored in cache under. We can't use the location since it is a url and contains illegal file name characters - const cacheFile = `~local/data/cache/types/${uuid()}.d.ts` - cacheIndex = { - ...cacheIndex, - [typeLocation]: cacheFile, - } - // Write the actual type definition in cache - await app.fileSystem.writeFile(cacheFile, definition) - } - - // Update the cache index - await app.fileSystem.writeJSON( - '~local/data/cache/types/index.json', - cacheIndex - ) - - this.isLoading = false - - return toCache.map( - ([typeLocation, definition, updateCache, moduleName]) => [ - typeLocation, - this.wrapTypesInModule(definition, moduleName), - ] - ) - } - - async setTypeEnv(filePath: string) { - if (filePath === this.currentTypeEnv) return - - const { languages, Uri } = await useMonaco() - - this.currentTypeEnv = filePath - this.typeDisposables.forEach((disposable) => disposable.dispose()) - this.typeDisposables = [] - - await App.fileType.ready.fired - const { types = [] } = App.fileType.get(filePath) ?? {} - - const matcher = new RequiresMatcher() - await matcher.setup() - - const libs = await this.load( - types - .map((type) => { - if (typeof type === 'string') return [type] - - const { definition, requires, moduleName } = type - - if (!requires || matcher.isValid(requires as IRequirements)) - return [definition, moduleName] - - return [] - }) - .filter((type) => type[0]) as [string, string?][] - ) - - for (const [typePath, lib] of libs) { - const uri = Uri.file(typePath) - this.typeDisposables.push( - languages.typescript.javascriptDefaults.addExtraLib( - lib, - uri.toString() - ), - languages.typescript.typescriptDefaults.addExtraLib( - lib, - uri.toString() - ) - ) - } - } - - async loadUserTypes() { - const app = await App.getApp() - - await app.project.packIndexer.fired - let allFiles - try { - allFiles = await app.project.packIndexer.service.getAllFiles() - } catch { - // We failed to access the pack indexer service -> fail silently - return - } - - const typeScriptFiles = allFiles.filter( - (filePath) => filePath.endsWith('.ts') || filePath.endsWith('.js') - ) - - const { languages, Uri } = await useMonaco() - - this.userTypeDisposables.forEach((disposable) => { - disposable.dispose() - }) - this.userTypeDisposables = [] - - for (const typeScriptFile of typeScriptFiles) { - const fileUri = Uri.file( - // This for some reason fixes monaco suggesting the wrong path when using quickfixes. See issue #932 - typeScriptFile.replace('/BP/', '/bp/') - ) - const file = await app.fileSystem - .readFile(typeScriptFile) - .catch(() => null) - if (!file) continue - - this.userTypeDisposables.push( - languages.typescript.typescriptDefaults.addExtraLib( - await file.text(), - fileUri.toString() - ) - ) - } - } - - wrapTypesInModule(typeSrc: string, moduleName?: string) { - if (!moduleName) return typeSrc - - return `declare module '${moduleName}' {\n${typeSrc}\n}` - } -} diff --git a/src/components/Definitions/GoTo.ts b/src/components/Definitions/GoTo.ts deleted file mode 100644 index 0a941e415..000000000 --- a/src/components/Definitions/GoTo.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { getLocation } from '/@/utils/monaco/getLocation' -import type { - Uri, - Range, - editor, - Position, - CancellationToken, -} from 'monaco-editor' -import { App } from '/@/App' -import { IDefinition } from '/@/components/Data/FileType' -import { getJsonWordAtPosition } from '/@/utils/monaco/getJsonWord' -import { ILightningInstruction } from '/@/components/PackIndexer/Worker/Main' -import { run } from '/@/components/Extensions/Scripts/run' -import { findFileExtension } from '/@/components/FileSystem/FindFile' -import { findAsync } from '/@/utils/array/findAsync' -import { AnyFileHandle } from '../FileSystem/Types' -import { isMatch } from 'bridge-common-utils' -import { getCacheScriptEnv } from '../PackIndexer/Worker/LightningCache/CacheEnv' -import { useMonaco } from '../../utils/libs/useMonaco' - -export class DefinitionProvider { - async provideDefinition( - model: editor.IModel, - position: Position, - cancellationToken: CancellationToken - ) { - const app = await App.getApp() - const { word, range } = await getJsonWordAtPosition(model, position) - const currentPath = app.project.tabSystem?.selectedTab?.getPath() - if (!currentPath) return - - const { definitions } = App.fileType.get(currentPath) ?? {} - const lightningCache = await App.fileType.getLightningCache(currentPath) - - // lightningCache is string for lightning cache text scripts - if ( - !definitions || - typeof lightningCache === 'string' || - lightningCache.length === 0 - ) - return - - const location = await getLocation(model, position) - const { definitionId, transformedWord } = await this.getDefinition( - word, - location, - lightningCache - ) - if (!definitionId || !transformedWord) return - - let definition = definitions[definitionId] - if (!definition) return - if (!Array.isArray(definition)) definition = [definition] - - const connectedFiles = await this.getFilePath( - transformedWord, - definition - ) - - const { editor, Uri, Range } = await useMonaco() - - const result = await Promise.all( - connectedFiles.map(async (filePath) => { - const uri = Uri.file(filePath) - - if (!editor.getModel(uri)) { - let fileHandle: AnyFileHandle - try { - fileHandle = await app.fileSystem.getFileHandle( - filePath - ) - } catch { - return undefined - } - - const model = editor.createModel( - await fileHandle.getFile().then((file) => file.text()), - undefined, - uri - ) - - // Try to remove model after 5 seconds - setTimeout(() => { - // Model is not in use - if (!app.project.getFileTab(fileHandle)) { - model.dispose() - } - }, 5 * 1000) - } - - return { - uri, - range: new Range(0, 0, Infinity, Infinity), - } - }) - ) - - return <{ uri: Uri; range: Range }[]>( - result.filter((res) => res !== undefined) - ) - } - - async getDefinition( - word: string, - location: string, - lightningCache: ILightningInstruction[] - ) { - const app = await App.getApp() - - let transformedWord: string | undefined = word - const definitions = lightningCache - .map( - (def) => - [ - def.cacheKey, - def.path, - { script: def.script, filter: def.filter }, - ] - ) - .filter((def) => def !== undefined) - - return { - definitionId: await findAsync( - definitions, - async ([def, path, { script, filter }]) => { - if (path === '') return false - - const matches = isMatch(location, path) - if (matches) { - if (filter && filter.includes(word)) - transformedWord = undefined - if (transformedWord && script) - transformedWord = await run({ - script, - async: true, - env: { - ...getCacheScriptEnv(transformedWord, { - fileSystem: app.fileSystem, - config: app.project.config, - }), - }, - }) - - return true - } - return false - } - )?.then((value) => value?.[0]), - transformedWord, - } - } - - async getFilePath(word: string, definition: IDefinition[]) { - const app = await App.getApp() - const connectedFiles = [] - - for (const def of definition) { - // Direct references are e.g. loot table paths - if (def.directReference) { - connectedFiles.push(word) - continue - } - - const matches = - (await app.project.packIndexer.service?.find( - def.from, - def.match, - [word], - true - )) ?? [] - - connectedFiles.push(...matches) - } - - return connectedFiles - } -} diff --git a/src/components/Developer/Actions.ts b/src/components/Developer/Actions.ts deleted file mode 100644 index 91b3c8cc7..000000000 --- a/src/components/Developer/Actions.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { del, set } from 'idb-keyval' -import { SimpleAction, IActionConfig } from '../Actions/SimpleAction' -import { comMojangKey } from '/@/components/OutputFolders/ComMojang/ComMojang' -import { ConfirmationWindow } from '../Windows/Common/Confirm/ConfirmWindow' -import { App } from '/@/App' - -const devActionConfigs: IActionConfig[] = [ - { - icon: 'mdi-delete', - name: '[Dev: Reset local fs]', - description: - '[Reset the local fs (navigator.storage.getDirectory()) to be completely emtpy]', - onTrigger: async () => { - const app = await App.getApp() - - const confirm = new ConfirmationWindow({ - description: '[Are you sure you want to reset the local fs?]', - }) - confirm.open() - const choice = await confirm.fired - - if (!choice) return - - await Promise.all([ - app.fileSystem.unlink('~local/data'), - app.fileSystem.unlink('~local/projects'), - app.fileSystem.unlink('~local/extensions'), - ]) - }, - }, - { - icon: 'mdi-delete', - name: "[Dev: Reset 'com.mojang' folder]", - description: "[Remove the 'com.mojang' folder from local storage]", - onTrigger: async () => { - del(comMojangKey) - }, - }, - { - icon: 'mdi-cancel', - name: '[Dev: Clear app data]', - description: '[Clear data from bridge-core/editor-packages repository]', - onTrigger: async () => { - await del('savedDataForVersion') - }, - }, - { - icon: 'mdi-refresh', - name: '[Dev: Reset initial setup]', - description: '[Resets editor type and com.mojang selection]', - onTrigger: async () => { - await del('didChooseEditorType') - await del(comMojangKey) - }, - }, - { - icon: 'mdi-open-in-new', - name: '[Dev: Open local fs]', - description: '[Open the local fs within the editor]', - onTrigger: async () => { - const app = await App.getApp() - - app.viewFolders.addDirectoryHandle({ - directoryHandle: await navigator.storage.getDirectory(), - startPath: '~local', - }) - }, - }, - { - icon: 'mdi-database-outline', - name: '[Dev: Open data directory]', - description: '[Open the data directory within the editor]', - onTrigger: async () => { - const app = await App.getApp() - - app.viewFolders.addDirectoryHandle({ - directoryHandle: app.dataLoader.baseDirectory, - }) - }, - }, - { - icon: 'mdi-minecraft', - name: '[Dev: Open com.mojang]', - description: '[Open the com.mojang folder within the editor]', - onTrigger: async () => { - const app = await App.getApp() - - if (!app.comMojang.setup.hasFired) return - - app.viewFolders.addDirectoryHandle({ - directoryHandle: app.comMojang.fileSystem.baseDirectory, - }) - }, - }, -] - -export const devActions = devActionConfigs.map( - (devActionConfig) => new SimpleAction(devActionConfig) -) diff --git a/src/components/Documentation/view.ts b/src/components/Documentation/view.ts deleted file mode 100644 index 39a12dd7f..000000000 --- a/src/components/Documentation/view.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { translate } from '../Locales/Manager' -import { InformationWindow } from '../Windows/Common/Information/InformationWindow' -import { App } from '/@/App' - -export async function viewDocumentation(filePath: string, word?: string) { - await App.fileType.ready.fired - const t = (str: string) => translate(str) - - const { id, documentation } = App.fileType.get(filePath) ?? {} - - if (!documentation) { - new InformationWindow({ - description: `[${t( - 'actions.documentationLookup.noDocumentation' - )} ${id ? t(`fileType.${id}`) : '"' + filePath + '"'}.]`, - }) - return - } - - let url = documentation.baseUrl - if (word && (documentation.supportsQuerying ?? true)) url += `#${word}` - - App.openUrl(url) -} diff --git a/src/components/Editor/Editor.ts b/src/components/Editor/Editor.ts new file mode 100644 index 000000000..ceaca771c --- /dev/null +++ b/src/components/Editor/Editor.ts @@ -0,0 +1,17 @@ +import { ref, Ref } from 'vue' + +export class Editor { + public static sideCollapsed: Ref = ref(false) + + public static showTabs() { + this.sideCollapsed.value = true + } + + public static hideTabs() { + this.sideCollapsed.value = false + } + + public static toggleTabs() { + this.sideCollapsed.value = !this.sideCollapsed.value + } +} diff --git a/src/components/Editor/Editor.vue b/src/components/Editor/Editor.vue new file mode 100644 index 000000000..d0bcc0c32 --- /dev/null +++ b/src/components/Editor/Editor.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/components/Editors/BlockModel/Tab.ts b/src/components/Editors/BlockModel/Tab.ts deleted file mode 100644 index 02be5f49a..000000000 --- a/src/components/Editors/BlockModel/Tab.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { App } from '/@/App' -import { RenderDataContainer } from '../GeometryPreview/Data/RenderContainer' -import { GeometryPreviewTab } from '../GeometryPreview/Tab' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { TabSystem } from '/@/components/TabSystem/TabSystem' -import json5 from 'json5' -import { FileWatcher } from '/@/components/FileSystem/FileWatcher' -import { findFileExtension } from '/@/components/FileSystem/FindFile' -import { walkObject } from 'bridge-common-utils' -import { isValidPositionArray } from '/@/utils/minecraft/validPositionArray' -import { markRaw } from 'vue' -import { IOutlineBox } from '../GeometryPreview/Data/EntityData' -import { compare as compareVersions } from 'compare-versions' - -export interface IBlockPreviewOptions { - loadComponents?: boolean -} - -const collisionComponents = { - '1.16.100': [ - { name: 'minecraft:entity_collision', color: '#ffff00' }, - { name: 'minecraft:pick_collision', color: '#0000ff' }, - ], - // Our auto-completions version this name change as '1.18.10' but the formatVersionCorrection plugin changes it to 1.18.0 - '1.18.0': [ - { name: 'minecraft:block_collision', color: '#ffff00' }, - { name: 'minecraft:aim_collision', color: '#0000ff' }, - ], - '1.19.10': [ - { name: 'minecraft:collision_box', color: '#ffff00' }, - { name: 'minecraft:aim_collision', color: '#0000ff' }, - ], -} -function loadCollisionComponents(formatVersion = '1.16.100') { - for (const [currFormatVersion, components] of Object.entries( - collisionComponents - )) { - if (compareVersions(currFormatVersion, formatVersion, '<')) continue - - return components - } -} - -export class BlockModelTab extends GeometryPreviewTab { - protected blockWatcher: FileWatcher - protected blockJson: any = {} - protected formatVersion = '1.16.100' - protected previewOptions: IBlockPreviewOptions = {} - - constructor( - protected blockFilePath: string, - tab: FileTab, - parent: TabSystem - ) { - super(tab, parent) - - this.blockWatcher = new FileWatcher(App.instance, blockFilePath) - - this.blockWatcher.on((file) => this.reload(file)) - } - - setPreviewOptions({ loadComponents = true }: IBlockPreviewOptions) { - this.previewOptions = { - loadComponents, - } - } - - async close() { - const didClose = await super.close() - if (didClose) this.blockWatcher.dispose() - - return didClose - } - async reload(file?: File) { - if (!file) file = await this.blockWatcher.getFile() - - this._renderContainer?.dispose() - this._renderContainer = undefined - this.loadRenderContainer(file) - } - - async loadRenderContainer(file: File) { - if (this._renderContainer !== undefined) return - await this.setupComplete - const app = await App.getApp() - - await app.project.packIndexer.fired - const packIndexer = app.project.packIndexer.service - const config = app.project.config - if (!packIndexer) return - - let rawJsonData: any = undefined - try { - rawJsonData = json5.parse(await file.text()) - } catch (err) { - console.error(`Invalid JSON within block file`) - return - } - - this.blockJson = markRaw(rawJsonData?.['minecraft:block'] ?? {}) - this.formatVersion = rawJsonData?.format_version ?? '1.16.100' - - const blockCacheData = await packIndexer.getCacheDataFor( - 'block', - this.blockFilePath - ) - - const textureReferences: string[] = blockCacheData?.texture ?? [] - let terrainTexture: any - try { - terrainTexture = - json5.parse( - await app.project - .getFileFromDiskOrTab( - config.resolvePackPath( - 'resourcePack', - 'textures/terrain_texture.json' - ) - ) - .then((file) => file.text()) - )?.['texture_data'] ?? {} - } catch (err) { - console.error(`Invalid JSON within terrain_texture.json file`) - return - } - const connectedTextures = await Promise.all( - textureReferences - .map((ref) => terrainTexture[ref]?.textures ?? []) - .flat() - .map((texturePath) => - findFileExtension( - app.fileSystem, - config.resolvePackPath('resourcePack', texturePath), - ['.tga', '.png', '.jpg', '.jpeg'] - ) - ) - ) - if (connectedTextures.length === 0) return - - const connectedGeometries = await packIndexer.find( - 'geometry', - 'identifier', - blockCacheData.geometryIdentifier ?? [] - ) - if (connectedGeometries.length === 0) return - - this._renderContainer = markRaw( - new RenderDataContainer(app, { - identifier: blockCacheData.geometryIdentifier[0], - texturePaths: ( - connectedTextures.filter( - (texturePath) => texturePath !== undefined - ) - ), - connectedAnimations: new Set([]), - }) - ) - this._renderContainer.createGeometry(connectedGeometries[0]) - - // Once the renderContainer is ready loading, create the initial model... - this.renderContainer.ready.then(() => { - this.createModel() - }) - // ...and listen to further changes to the files for hot-reloading - this._renderContainer.on(() => { - this.createModel() - }) - } - - async createModel() { - await super.createModel() - - if (this.previewOptions.loadComponents) { - const collisionComponents = - loadCollisionComponents(this.formatVersion) ?? [] - const outlineBoxes = collisionComponents - .map(({ name, color }) => this.loadCollisionBoxes(name, color)) - .flat() - - this.createOutlineBoxes(outlineBoxes) - } - } - - findComponents(blockJson: any, id: string) { - const components: any[] = [] - const onReach = (data: any) => components.push(data) - const locations = [ - `components/${id}`, - `permutations/*/components/${id}`, - ] - locations.forEach((loc) => walkObject(loc, blockJson, onReach)) - - return components - } - loadCollisionBoxes(componentName: string, color: string) { - return this.findComponents(this.blockJson, componentName) - .filter( - (collisionBox) => - isValidPositionArray(collisionBox.origin) && - isValidPositionArray(collisionBox.size) - ) - .map( - ({ origin, size }) => - { - color, - position: { - x: origin[0] + size[0] / 2, - y: origin[1], - z: origin[2] + size[2] / 2, - }, - size: { x: size[0], y: size[1], z: size[2] }, - } - ) - } -} diff --git a/src/components/Editors/Blockbench/BlockbenchTab.ts b/src/components/Editors/Blockbench/BlockbenchTab.ts deleted file mode 100644 index 5f542cad0..000000000 --- a/src/components/Editors/Blockbench/BlockbenchTab.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { TabSystem } from '../../TabSystem/TabSystem' -import { IframeTab, IOpenWithPayload } from '../IframeTab/IframeTab' - -export interface IBlockbenchOptions { - openWithPayload?: IOpenWithPayload -} - -export const blockbenchUrl = import.meta.env.DEV - ? 'http://localhost:5173' - : 'https://blockbench.bridge-core.app' - -export class BlockbenchTab extends IframeTab { - constructor( - tabSystem: TabSystem, - { openWithPayload }: IBlockbenchOptions = {} - ) { - super(tabSystem, { - icon: '$blockbench', - name: 'Blockbench', - url: blockbenchUrl, - iconColor: 'primary', - openWithPayload, - }) - } -} diff --git a/src/components/Editors/EntityModel/Tab.ts b/src/components/Editors/EntityModel/Tab.ts deleted file mode 100644 index d842183f1..000000000 --- a/src/components/Editors/EntityModel/Tab.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { App } from '/@/App' -import { RenderDataContainer } from '../GeometryPreview/Data/RenderContainer' -import { GeometryPreviewTab } from '../GeometryPreview/Tab' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { TabSystem } from '/@/components/TabSystem/TabSystem' -import json5 from 'json5' -import { FileWatcher } from '/@/components/FileSystem/FileWatcher' -import { InformationWindow } from '../../Windows/Common/Information/InformationWindow' -import { markRaw } from 'vue' -import { DropdownWindow } from '../../Windows/Common/Dropdown/DropdownWindow' - -export interface IPreviewOptions { - clientEntityFilePath?: string - loadServerEntity?: boolean - geometryFilePath?: string - geometryIdentifier?: string -} - -export class EntityModelTab extends GeometryPreviewTab { - protected previewOptions: IPreviewOptions = {} - protected clientEntityWatcher?: FileWatcher - - constructor(options: IPreviewOptions, tab: FileTab, parent: TabSystem) { - super(tab, parent) - - this.previewOptions = options - - if (this.clientEntityFilePath) { - this.clientEntityWatcher = new FileWatcher( - App.instance, - this.clientEntityFilePath - ) - - this.clientEntityWatcher.on((file) => this.reload(file)) - } else { - this.reload() - } - } - setPreviewOptions(previewOptions: IPreviewOptions) { - this.previewOptions = Object.assign(this.previewOptions, previewOptions) - } - get clientEntityFilePath() { - return this.previewOptions.clientEntityFilePath - } - get geometryFilePath() { - return this.previewOptions.geometryFilePath - } - get geometryIdentifier() { - return this.previewOptions.geometryIdentifier - } - - async close() { - const didClose = await super.close() - if (didClose) this.clientEntityWatcher?.dispose() - - return didClose - } - - async reload(file?: File) { - if (!file) file = await this.clientEntityWatcher?.getFile() - - const runningAnims = this._renderContainer?.runningAnimations - - this._renderContainer?.dispose() - this._renderContainer = undefined - this.loadRenderContainer(file, runningAnims) - } - - async loadRenderContainer(file?: File, runningAnims = new Set()) { - if (this._renderContainer !== undefined) return - await this.setupComplete - const app = await App.getApp() - - const packIndexer = app.project.packIndexer.service - if (!packIndexer) return - - // No client entity connected, try to load from geometry only - if (file === undefined) return await this.fallbackToOnlyGeometry() - - let clientEntity: any - try { - clientEntity = - json5.parse(await file.text())?.['minecraft:client_entity'] ?? - {} - } catch { - return - } - - const clientEntityData = await packIndexer.getCacheDataFor( - 'clientEntity', - this.clientEntityFilePath - ) - - const connectedTextures = clientEntityData.texturePath - - const connectedAnimations = await packIndexer.find( - 'clientAnimation', - 'identifier', - clientEntityData.animationIdentifier ?? [], - true - ) - const connectedGeometries = await packIndexer.find( - 'geometry', - 'identifier', - clientEntityData.geometryIdentifier ?? [] - ) - if (connectedGeometries.length === 0) { - new InformationWindow({ description: 'preview.noGeometry' }) - this.close() - return - } - - const connectedParticles = await Promise.all( - Object.entries( - clientEntity?.description?.particle_effects ?? {} - ).map(async ([shortName, particleId]) => { - return [ - shortName, - ( - await packIndexer.find( - 'particle', - 'identifier', - [particleId], - false - ) - )[0], - ] - }) - ) - - this._renderContainer = markRaw( - new RenderDataContainer(app, { - identifier: clientEntityData.geometryIdentifier[0], - texturePaths: connectedTextures, - connectedAnimations: new Set( - clientEntityData.animationIdentifier - ), - }) - ) - this._renderContainer.createGeometry(connectedGeometries[0]) - - connectedAnimations.forEach((filePath) => - this._renderContainer!.createAnimation(filePath) - ) - connectedParticles.forEach(([shortName, filePath], i) => { - if (!filePath) return - - this._renderContainer!.createParticle(shortName, filePath) - }) - - if (runningAnims) - runningAnims.forEach((animId) => - this._renderContainer?.runningAnimations.add(animId) - ) - - // If requested, load server entity - if ( - this.previewOptions.loadServerEntity && - clientEntity?.description?.identifier - ) { - const serverEntityFilePath = await packIndexer.find( - 'entity', - 'identifier', - [clientEntity?.description?.identifier], - false - ) - - if (serverEntityFilePath.length > 0) { - this._renderContainer!.addServerEntity(serverEntityFilePath[0]) - } - } - - // Once the renderContainer is ready loading, create the initial model... - this.renderContainer.ready.then(() => { - this.createModel() - }) - // ...and listen to further changes to the files for hot-reloading - this._renderContainer.on(() => { - this.createModel() - }) - } - - /** - * Store chosen fallback texture to avoid showing the texture picker again upon reload - */ - protected chosenFallbackTexturePath?: string - /** - * This function enables bridge. to display models without a client entity. - * By default, bridge. will use the geometry file path and identifier as the geometry source and - * the user is prompted to choose any entity/block texture file for the model - */ - async fallbackToOnlyGeometry() { - // No geometry file connected, no way to fallback to geometry only - if (!this.geometryFilePath || !this.geometryIdentifier) return - - const app = await App.getApp() - const packIndexer = app.project.packIndexer.service - - // Helper method for loading all textures from a specific textures/ subfolder - const loadTextures = (location: 'entity' | 'blocks') => - app.fileSystem - .readdir( - app.project.config.resolvePackPath( - 'resourcePack', - `textures/${location}` - ) - ) - .then((path) => - path.map((path) => ({ - text: `textures/${location}/${path}`, - value: app.project.config.resolvePackPath( - 'resourcePack', - `textures/${location}/${path}` - ), - })) - ) - .catch(() => <{ text: string; value: string }[]>[]) - - if (this.chosenFallbackTexturePath === undefined) { - // Load all textures from the entity and blocks folders - const textures = (await loadTextures('entity')).concat( - await loadTextures('blocks') - ) - - if (textures.length === 0) { - new InformationWindow({ - description: 'preview.noTextures', - }) - - return - } - - // Prompt user to select a texture - const choiceWindow = new DropdownWindow({ - options: textures, - name: 'preview.chooseTexture', - }) - - // Get selected texture - this.chosenFallbackTexturePath = await choiceWindow.fired - } - - // Load all available animations (file paths & identifiers) - const allAnimations = await packIndexer.getAllFiles('clientAnimation') - const allAnimationIdentifiers = await packIndexer.getCacheDataFor( - 'clientAnimation', - undefined, - 'identifier' - ) - - // Create fallback render container - this._renderContainer = markRaw( - new RenderDataContainer(app, { - identifier: this.geometryIdentifier, - texturePaths: [this.chosenFallbackTexturePath], - connectedAnimations: new Set(allAnimationIdentifiers), - }) - ) - - this._renderContainer.createGeometry(this.geometryFilePath) - - // Create animations so they are ready to be used within the render container - allAnimations.forEach((filePath) => - this._renderContainer!.createAnimation(filePath) - ) - - // Once the renderContainer is ready loading, create the initial model... - this.renderContainer.ready.then(() => { - this.createModel() - }) - // ...and listen to further changes to the files for hot-reloading - this._renderContainer.on(() => { - this.createModel() - }) - } -} diff --git a/src/components/Editors/EntityModel/create/fromClientEntity.ts b/src/components/Editors/EntityModel/create/fromClientEntity.ts deleted file mode 100644 index cd3685bd4..000000000 --- a/src/components/Editors/EntityModel/create/fromClientEntity.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EntityModelTab } from '../Tab' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { TabSystem } from '/@/components/TabSystem/TabSystem' - -export async function createFromClientEntity( - tabSystem: TabSystem, - tab: FileTab -) { - return new EntityModelTab( - { clientEntityFilePath: tab.getPath() }, - tab, - tabSystem - ) -} diff --git a/src/components/Editors/EntityModel/create/fromEntity.ts b/src/components/Editors/EntityModel/create/fromEntity.ts deleted file mode 100644 index 1fd3efe8c..000000000 --- a/src/components/Editors/EntityModel/create/fromEntity.ts +++ /dev/null @@ -1,44 +0,0 @@ -import json5 from 'json5' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { InformationWindow } from '/@/components/Windows/Common/Information/InformationWindow' -import { EntityModelTab } from '../Tab' -import { App } from '/@/App' -import { TabSystem } from '/@/components/TabSystem/TabSystem' - -export async function createFromEntity(tabSystem: TabSystem, tab: FileTab) { - const app = await App.getApp() - await app.project.packIndexer.fired - const packIndexer = app.project.packIndexer.service - - const file = await tab.getFile() - const fileContent = await file.text() - - let entityData: any - try { - entityData = json5.parse(fileContent)?.['minecraft:entity'] ?? {} - } catch { - new InformationWindow({ - description: 'preview.invalidEntity', - }) - return - } - - const clientEntity = await packIndexer.find('clientEntity', 'identifier', [ - entityData?.description?.identifier, - ]) - if (clientEntity.length === 0) { - new InformationWindow({ - description: 'preview.failedClientEntityLoad', - }) - return - } - - const previewTab = new EntityModelTab( - { clientEntityFilePath: clientEntity[0] }, - tab, - tabSystem - ) - previewTab.setPreviewOptions({ loadServerEntity: true }) - - return previewTab -} diff --git a/src/components/Editors/EntityModel/create/fromGeometry.ts b/src/components/Editors/EntityModel/create/fromGeometry.ts deleted file mode 100644 index b54969433..000000000 --- a/src/components/Editors/EntityModel/create/fromGeometry.ts +++ /dev/null @@ -1,80 +0,0 @@ -import json5 from 'json5' -import { BlockModelTab } from '../../BlockModel/Tab' -import { EntityModelTab } from '../Tab' -import { transformOldModels } from '../transformOldModels' -import { App } from '/@/App' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { TabSystem } from '/@/components/TabSystem/TabSystem' -import { DropdownWindow } from '/@/components/Windows/Common/Dropdown/DropdownWindow' -import { InformationWindow } from '/@/components/Windows/Common/Information/InformationWindow' - -export async function createFromGeometry(tabSystem: TabSystem, tab: FileTab) { - const app = await App.getApp() - await app.project.packIndexer.fired - const packIndexer = app.project.packIndexer.service - - const file = await tab.getFile() - const fileContent = await file.text() - - let modelJson: any - try { - modelJson = transformOldModels(json5.parse(fileContent)) - } catch { - return - } - - const availableModels = modelJson['minecraft:geometry'] - .map((geo: any) => geo?.description?.identifier) - .filter((id: string | undefined) => id !== undefined) - - // By default assume user wants to load first model - let choice = availableModels[0] - if (availableModels.length > 1) { - // Prompt user to choose a model if multiple are available - const choiceWindow = new DropdownWindow({ - default: availableModels[0], - options: availableModels, - name: 'preview.chooseGeometry', - isClosable: false, - }) - choice = await choiceWindow.fired - } - if (!choice) { - new InformationWindow({ description: 'preview.noGeometry' }) - return - } - - const clientEntity = await packIndexer.find( - 'clientEntity', - 'geometryIdentifier', - [choice] - ) - - if (clientEntity.length === 0) { - // Check whether geometry is connected to a block - const block = await packIndexer.find('block', 'geometryIdentifier', [ - choice, - ]) - // Connected block found - if (block.length > 0) { - const previewTab = new BlockModelTab(block[0], tab, tabSystem) - previewTab.setPreviewOptions({ loadComponents: false }) - return previewTab - } - } - - /** - * If we reach this point either... - * - ...a connected client entity was found (clientEntity.length > 0)... - * - ...or no connected client entity and no connected block was found -> Fallback to geometry preview in this case. - */ - return new EntityModelTab( - { - clientEntityFilePath: clientEntity[0], - geometryFilePath: tab.getPath(), - geometryIdentifier: choice, - }, - tab, - tabSystem - ) -} diff --git a/src/components/Editors/EntityModel/transformOldModels.ts b/src/components/Editors/EntityModel/transformOldModels.ts deleted file mode 100644 index 2249e3838..000000000 --- a/src/components/Editors/EntityModel/transformOldModels.ts +++ /dev/null @@ -1,27 +0,0 @@ -export function transformOldModels(geometry: any) { - // New model format - if (geometry['minecraft:geometry']) return geometry - - // Old model format - // Filter out format_version - const models: any = Object.entries(geometry).filter( - ([_, modelData]) => typeof modelData !== 'string' - ) - - const transformedModels = [] - for (const [modelId, model] of models) { - transformedModels.push({ - description: { - identifier: modelId, - texture_width: model.texturewidth, - texture_height: model.textureheight, - }, - bones: model.bones, - }) - } - - return { - format_version: '1.12.0', - 'minecraft:geometry': transformedModels, - } -} diff --git a/src/components/Editors/GeometryPreview/AssetPreview/Window.ts b/src/components/Editors/GeometryPreview/AssetPreview/Window.ts deleted file mode 100644 index d7e33bd91..000000000 --- a/src/components/Editors/GeometryPreview/AssetPreview/Window.ts +++ /dev/null @@ -1,92 +0,0 @@ -import AssetPreviewWindowComponent from './Window.vue' -import type { StandaloneModelViewer } from 'bridge-model-viewer' -import { markRaw, reactive } from 'vue' -import { Color } from 'three' -import { useBridgeModelViewer } from '/@/utils/libs/useModelViewer' -import { NewBaseWindow } from '/@/components/Windows/NewBaseWindow' - -export interface IAssetPreviewWindowConfig { - assetName: string - textureUrl: string - modelData: any -} - -interface IAssetPreviewConfig { - assetName: string - previewScale: number - outputResolution: number - boneVisibility: Record - backgroundColor: string -} - -export class AssetPreviewWindow extends NewBaseWindow { - protected textureUrl: string - protected modelData: any - protected modelViewer?: StandaloneModelViewer - protected state = reactive({ - ...super.state, - bones: {}, - assetName: '', - backgroundColor: '#121212', - previewScale: 1.5, - outputResolution: 4, - }) - - constructor({ - assetName, - textureUrl, - modelData, - }: IAssetPreviewWindowConfig) { - super(AssetPreviewWindowComponent, true, false) - - this.state.assetName = assetName - this.textureUrl = textureUrl - this.modelData = markRaw(modelData) - - this.defineWindow() - this.open() - } - - async receiveCanvas(canvas: HTMLCanvasElement) { - const { StandaloneModelViewer } = await useBridgeModelViewer() - - const modelViewer = new StandaloneModelViewer( - canvas, - this.modelData, - this.textureUrl, - { - antialias: true, - height: 500, - width: 500, - } - ) - - await modelViewer.loadedModel - - // Initialize bone visibility map - this.state.bones = Object.fromEntries( - modelViewer.getModel().bones.map((boneName) => [boneName, true]) - ) - - // @ts-ignore - modelViewer.scene.background = new Color(this.state.backgroundColor) - - modelViewer.positionCamera(this.state.previewScale) - - this.modelViewer = markRaw(modelViewer) - } - - startClosing(wasCancelled: boolean) { - this.close( - wasCancelled - ? null - : { - assetName: this.state.assetName, - previewScale: this.state.previewScale, - boneVisibility: this.state.bones, - backgroundColor: this.state.backgroundColor, - outputResolution: this.state.outputResolution, - } - ) - } -} diff --git a/src/components/Editors/GeometryPreview/AssetPreview/Window.vue b/src/components/Editors/GeometryPreview/AssetPreview/Window.vue deleted file mode 100644 index a6bc384e4..000000000 --- a/src/components/Editors/GeometryPreview/AssetPreview/Window.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - diff --git a/src/components/Editors/GeometryPreview/Data/AnimationData.ts b/src/components/Editors/GeometryPreview/Data/AnimationData.ts deleted file mode 100644 index dd4ebbe49..000000000 --- a/src/components/Editors/GeometryPreview/Data/AnimationData.ts +++ /dev/null @@ -1,40 +0,0 @@ -import json5 from 'json5' -import { PreviewFileWatcher } from './PreviewFileWatcher' -import { RenderDataContainer } from './RenderContainer' - -export class AnimationData extends PreviewFileWatcher { - protected animationJson: any = {} - - constructor( - protected parent: RenderDataContainer, - animationFilePath: string, - protected includedAnimationIdentifiers?: string[] - ) { - super(parent.app, animationFilePath) - } - - async onChange(file: File, isInitial = false) { - try { - this.animationJson = json5.parse(await file.text()) - if (!isInitial) this.parent.onChange() - } catch { - // If parsing JSON fails, do nothing - } - } - - get includedAnimations(): [string, any][] { - if (this.includedAnimationIdentifiers === undefined) - return Object.entries(this.animationJson?.['animations'] ?? {}) - - return Object.entries(this.animationJson['animations']).filter( - ([_, { description }]) => - !this.includedAnimationIdentifiers!.includes( - description.identifier - ) - ) - } - - get identifiers(): string[] { - return this.includedAnimations.map(([id]) => id) - } -} diff --git a/src/components/Editors/GeometryPreview/Data/EntityData.ts b/src/components/Editors/GeometryPreview/Data/EntityData.ts deleted file mode 100644 index 4ae42c89d..000000000 --- a/src/components/Editors/GeometryPreview/Data/EntityData.ts +++ /dev/null @@ -1,123 +0,0 @@ -import json5 from 'json5' -import { PreviewFileWatcher } from './PreviewFileWatcher' -import { RenderDataContainer } from './RenderContainer' -import { walkObject } from 'bridge-common-utils' - -export interface IOutlineBox { - color: `#${string}` - position: { x: number; y: number; z: number } - size: { x: number; y: number; z: number } -} - -export class EntityData extends PreviewFileWatcher { - protected entityData: any = {} - - constructor(protected parent: RenderDataContainer, filePath: string) { - super(parent.app, filePath) - } - - async onChange(file: File, isInitial = false) { - try { - this.entityData = json5.parse(await file.text()) - - if (!isInitial) this.parent.onChange() - } catch { - // If parsing JSON fails, do nothing - } - } - - findComponent(id: string) { - const components: any[] = [] - const onReach = (data: any) => components.push(data) - const locations = [`*/components/${id}`, `*/component_groups/*/${id}`] - locations.forEach((loc) => walkObject(loc, this.entityData, onReach)) - - return components - } - - getSeatBoxHelpers() { - const components = this.findComponent('minecraft:rideable') - - const playerSize = { x: 16 * 0.8, y: 16 * 1.8, z: 16 * 0.8 } - - return components - .map((rideable) => rideable?.seats ?? []) - .flat() - .map((seat) => seat?.position) - .filter( - (position) => - Array.isArray(position) && - typeof position[0] === 'number' && - typeof position[1] === 'number' && - typeof position[2] === 'number' - ) - ?.map( - (position) => - { - color: '#ff0000', - position: { - x: position[0] * -16, - y: position[1] * 16, - z: position[2] * -16, - }, - size: playerSize, - } - ) - } - - getCollisionBoxes() { - return this.findComponent('minecraft:collision_box') - .filter( - (collisionBox) => - typeof collisionBox?.width === 'number' && - typeof collisionBox?.height === 'number' - ) - .map( - (collisionBox) => - { - color: '#ffff00', - position: { x: 0, y: 0, z: 0 }, - size: { - x: collisionBox.width * 16, - y: collisionBox.height * 16, - z: collisionBox.width * 16, - }, - } - ) - } - - getHitboxes() { - return this.findComponent('minecraft:custom_hit_test') - .map((customHitTest) => customHitTest?.hitboxes ?? []) - .flat() - .filter( - (hitbox) => - typeof hitbox?.width === 'number' && - typeof hitbox?.height === 'number' && - (hitbox?.pivot === undefined || - (Array.isArray(hitbox?.pivot) && - typeof hitbox.pivot[0] === 'number' && - typeof hitbox.pivot[1] === 'number' && - typeof hitbox.pivot[2] === 'number')) - ) - .map( - (hitbox) => - { - color: '#0000ff', - position: { - x: (hitbox?.pivot?.[0] ?? 0) * -16, - y: - ((hitbox?.pivot?.[1] ?? 0) - - hitbox.height / 2) * - 16, - z: (hitbox?.pivot?.[2] ?? 0) * -16, - }, - size: { - x: hitbox.width * 16, - y: hitbox.height * 16, - z: hitbox.width * 16, - }, - } - ) - } -} diff --git a/src/components/Editors/GeometryPreview/Data/GeometryData.ts b/src/components/Editors/GeometryPreview/Data/GeometryData.ts deleted file mode 100644 index d4eedbb20..000000000 --- a/src/components/Editors/GeometryPreview/Data/GeometryData.ts +++ /dev/null @@ -1,60 +0,0 @@ -import json5 from 'json5' -import { transformOldModels } from '../../EntityModel/transformOldModels' -import { PreviewFileWatcher } from './PreviewFileWatcher' -import { RenderDataContainer } from './RenderContainer' - -export class GeometryData extends PreviewFileWatcher { - protected geometryJson: any = {} - protected selected!: string - - constructor( - protected parent: RenderDataContainer, - geometryFilePath: string, - protected includedGeometryIdentifiers?: string[] - ) { - super(parent.app, geometryFilePath) - } - - select(id: string) { - this.selected = id - } - - async onChange(file: File, isInitial = false) { - try { - this.geometryJson = transformOldModels( - json5.parse(await file.text()) - ) - if (!isInitial) this.parent.onChange() - } catch { - // If parsing JSON fails, do nothing - } - } - - get includedGeometries(): any[] { - if (this.includedGeometryIdentifiers === undefined) - return this.geometryJson?.['minecraft:geometry'] ?? [] - - return ( - this.geometryJson?.['minecraft:geometry']?.filter( - ({ description }: any) => - !this.includedGeometryIdentifiers!.includes( - description.identifier - ) - ) ?? [] - ) - } - - get identifiers(): string[] { - return this.includedGeometries.map( - ({ description }) => description.identifier - ) - } - get geometry() { - return this.includedGeometries.find( - ({ description }) => description.identifier === this.selected - ) - } - get fallbackGeometry() { - return this.includedGeometries[0] - } -} diff --git a/src/components/Editors/GeometryPreview/Data/ParticleData.ts b/src/components/Editors/GeometryPreview/Data/ParticleData.ts deleted file mode 100644 index 72e11c9ec..000000000 --- a/src/components/Editors/GeometryPreview/Data/ParticleData.ts +++ /dev/null @@ -1,32 +0,0 @@ -import json5 from 'json5' -import { PreviewFileWatcher } from './PreviewFileWatcher' -import { RenderDataContainer } from './RenderContainer' - -export class ParticleData extends PreviewFileWatcher { - protected particleData: any = {} - - constructor( - protected parent: RenderDataContainer, - public readonly shortName: string | undefined, - particleFilePath: string - ) { - super(parent.app, particleFilePath) - } - - async onChange(file: File, isInitial = false) { - try { - this.particleData = json5.parse(await file.text()) - - if (!isInitial) this.parent.onChange() - } catch { - // If parsing JSON fails, do nothing - } - } - - get identifier(): string | undefined { - return this.particleData?.particle_effect?.description?.identifier - } - get json() { - return this.particleData - } -} diff --git a/src/components/Editors/GeometryPreview/Data/PreviewFileWatcher.ts b/src/components/Editors/GeometryPreview/Data/PreviewFileWatcher.ts deleted file mode 100644 index 822fe4c43..000000000 --- a/src/components/Editors/GeometryPreview/Data/PreviewFileWatcher.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { App } from '/@/App' -import { FileWatcher } from '/@/components/FileSystem/FileWatcher' - -export abstract class PreviewFileWatcher extends FileWatcher { - constructor(app: App, filePath: string) { - super(app, filePath) - - // Make sure that the initial setup is complete - this.ready.on(() => { - // Then, listen for any further changes - this.on((file) => this.onChange(file)) - }) - } - - async setup(file: File) { - return await this.onChange(await this.compileFile(file), true) - } - abstract onChange(file: File, isInitial?: boolean): Promise | void -} diff --git a/src/components/Editors/GeometryPreview/Data/RenderContainer.ts b/src/components/Editors/GeometryPreview/Data/RenderContainer.ts deleted file mode 100644 index f2ce54132..000000000 --- a/src/components/Editors/GeometryPreview/Data/RenderContainer.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * An RenderDataContainer instance holds references to all data needed to render a visual preview of an entity/block - */ - -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { App } from '/@/App' -import { GeometryData } from './GeometryData' -import { AnimationData } from './AnimationData' -import { ParticleData } from './ParticleData' -import { EntityData } from './EntityData' - -export interface IRenderData { - identifier: string - texturePaths: string[] - connectedAnimations: Set -} - -export class RenderDataContainer extends EventDispatcher { - protected _geometries: GeometryData[] = [] - protected _animations: AnimationData[] = [] - protected readyPromises: Promise[] = [] - protected _currentTexturePath: string - protected _runningAnimations = new Set() - protected particleEffects: ParticleData[] = [] - protected _serverEntity?: EntityData - - get ready() { - return Promise.all(this.readyPromises) - } - - constructor(public readonly app: App, protected renderData: IRenderData) { - super() - this._currentTexturePath = this.texturePaths[0] - } - update(renderData: IRenderData) { - this.renderData = renderData - } - - createGeometry( - geometryFilePath: string, - includedGeometryIdentifiers?: string[] - ) { - const geo = new GeometryData( - this, - geometryFilePath, - includedGeometryIdentifiers - ) - this._geometries.push(geo) - this.readyPromises.push(geo.ready.fired) - } - createAnimation( - animationFilePath: string, - includedAnimationIdentifiers?: string[] - ) { - const anim = new AnimationData( - this, - animationFilePath, - includedAnimationIdentifiers - ) - this._animations.push(anim) - this.readyPromises.push(anim.ready.fired) - } - createParticle(shortName: string | undefined, particleFilePath: string) { - const particle = new ParticleData(this, shortName, particleFilePath) - this.particleEffects.push(particle) - this.readyPromises.push(particle.ready.fired) - } - addServerEntity(filePath: string) { - this._serverEntity = new EntityData(this, filePath) - this.readyPromises.push(this._serverEntity.ready.fired) - } - selectGeometry(id: string) { - for (const geo of this._geometries) { - if (geo.identifiers.includes(id)) return geo.select(id) - } - throw new Error(`Failed to find geometry with ID "${id}"`) - } - selectTexturePath(path: string) { - this._currentTexturePath = path - } - onChange() { - this.dispatch() - } - - get identifier() { - return this.renderData.identifier - } - get texturePaths() { - return this.renderData.texturePaths - } - get geometryIdentifiers() { - return this._geometries.map((geo) => geo.identifiers).flat() - } - get animations() { - return this._animations - .map((anim) => anim.includedAnimations) - .flat() - .filter(([anim]) => this.renderData.connectedAnimations.has(anim)) - } - get runningAnimations() { - return this._runningAnimations - } - get modelData() { - for (const geo of this._geometries) { - let currGeo = geo.geometry - if (currGeo) return currGeo - } - - return this._geometries[0].fallbackGeometry - } - get currentTexturePath() { - return this._currentTexturePath - } - get particles() { - return this.particleEffects.map( - (effect) => [effect.shortName, effect.json] - ) - } - get serverEntity() { - return this._serverEntity - } - - activate() { - this._geometries.forEach((geo) => geo.activate()) - } - dispose() { - this._geometries.forEach((geo) => geo.dispose()) - this._animations.forEach((anim) => anim.dispose()) - this._serverEntity?.dispose() - } -} diff --git a/src/components/Editors/GeometryPreview/Tab.ts b/src/components/Editors/GeometryPreview/Tab.ts deleted file mode 100644 index aab7710c6..000000000 --- a/src/components/Editors/GeometryPreview/Tab.ts +++ /dev/null @@ -1,492 +0,0 @@ -import type { Model } from 'bridge-model-viewer' -import { App } from '/@/App' -import { loadAsDataURL } from '/@/utils/loadAsDataUrl' -import { ThreePreviewTab } from '../ThreePreview/ThreePreviewTab' -import { SimpleAction } from '/@/components/Actions/SimpleAction' -import { RenderDataContainer } from './Data/RenderContainer' -import { DropdownWindow } from '/@/components/Windows/Common/Dropdown/DropdownWindow' -import { MultiOptionsWindow } from '/@/components/Windows/Common/MultiOptions/Window' -import type Wintersky from 'wintersky' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { TabSystem } from '/@/components/TabSystem/TabSystem' -import { IDisposable } from '/@/types/disposable' -import { IOutlineBox } from './Data/EntityData' -import { markRaw } from 'vue' -import { Box3, Vector3, Color } from 'three' -import { saveOrDownload } from '/@/components/FileSystem/saveOrDownload' -import { wait } from '/@/utils/wait' -import { AssetPreviewWindow } from './AssetPreview/Window' -import { useWintersky } from '/@/utils/libs/useWintersky' -import { useBridgeModelViewer } from '/@/utils/libs/useModelViewer' - -export abstract class GeometryPreviewTab extends ThreePreviewTab { - protected winterskyScene!: Wintersky.Scene - protected model?: Model - protected _renderContainer?: RenderDataContainer - protected boxHelperDisposables: IDisposable[] = [] - - constructor(tab: FileTab, tabSystem: TabSystem) { - super(tab, tabSystem) - } - - async setup() { - const { default: Wintersky } = await useWintersky() - - this.winterskyScene = markRaw( - new Wintersky.Scene({ - fetchTexture: async (config) => { - const app = await App.getApp() - - try { - return await loadAsDataURL( - config.particle_texture_path, - app.project.fileSystem - ) - } catch (err) { - // Fallback to Wintersky's default handling of textures - } - }, - }) - ) - this.winterskyScene.global_options.loop_mode = 'once' - this.winterskyScene.global_options.tick_rate = 60 - this.winterskyScene.global_options.max_emitter_particles = 1000 - this.winterskyScene.global_options.scale = 16 - this.setupComplete.once(() => this.scene.add(this.winterskyScene.space)) - - await super.setup() - } - - get renderContainer() { - if (!this._renderContainer) - throw new Error(`Preview.renderContainer was not defined yet`) - return this._renderContainer - } - - get icon() { - return this.tab.icon - } - get iconColor() { - return this.tab.iconColor - } - - async onActivate() { - await super.onActivate() - this._renderContainer?.activate() - } - - onCreate() { - this.registerActions() - } - - registerActions() { - this.actions = [] - this.addAction( - new SimpleAction({ - icon: 'mdi-refresh', - name: 'general.reload', - onTrigger: () => this.reload(), - }), - new SimpleAction({ - icon: 'mdi-image-outline', - name: 'fileType.texture', - isDisabled: () => { - return ( - (this._renderContainer?.texturePaths?.length ?? 0) <= 1 - ) - }, - onTrigger: async () => { - const textures = this.renderContainer.texturePaths - const chooseTexture = new DropdownWindow({ - name: 'fileType.texture', - isClosable: false, - options: textures, - default: this.renderContainer.currentTexturePath, - }) - const choice = await chooseTexture.fired - - this.renderContainer.selectTexturePath(choice) - this.createModel() - }, - }), - new SimpleAction({ - icon: 'mdi-cube-outline', - name: 'fileType.geometry', - isDisabled: () => { - return ( - (this._renderContainer?.geometryIdentifiers?.length ?? - 0) <= 1 - ) - }, - onTrigger: async () => { - const geomtries = this.renderContainer.geometryIdentifiers - const chooseGeometry = new DropdownWindow({ - name: 'fileType.geometry', - isClosable: false, - options: geomtries, - default: geomtries[0], - }) - const choice = await chooseGeometry.fired - - this.renderContainer.selectGeometry(choice) - this.createModel() - }, - }), - new SimpleAction({ - icon: 'mdi-movie-open-outline', - name: 'fileType.clientAnimation', - isDisabled: () => { - return (this._renderContainer?.animations?.length ?? 0) == 0 - }, - onTrigger: async () => { - const animations = this.renderContainer.animations - const chooseAnimation = new MultiOptionsWindow({ - name: 'fileType.clientAnimation', - options: animations.map(([animId]) => ({ - name: animId, - isSelected: - this.renderContainer.runningAnimations.has( - animId - ), - })), - }) - const choices = await chooseAnimation.fired - - this.model?.animator.pauseAll() - this.renderContainer.runningAnimations.clear() - choices.forEach((choice) => { - this.renderContainer.runningAnimations.add(choice) - }) - this.createModel() - }, - }), - new SimpleAction({ - icon: 'mdi-collage', - name: '[Asset Preview]', - onTrigger: () => { - this.renderAssetPreview() - }, - }) - ) - } - - abstract loadRenderContainer(file: File): Promise - - onDestroy() { - this._renderContainer?.dispose() - super.onDestroy() - } - onChange() {} - - protected async createModel() { - if (!this._renderContainer) return - - const app = await App.getApp() - const { Model } = await useBridgeModelViewer() - - if (this.model) { - this.scene?.remove(this.model.getGroup()) - this.model.animator.disposeAnimations() - this.boxHelperDisposables.forEach((disposable) => - disposable.dispose() - ) - } - this.registerActions() - - // No texture available for model -> nothing to render - if (!this.renderContainer.currentTexturePath) return - - this.model = markRaw( - new Model( - this.renderContainer.modelData, - await loadAsDataURL( - this.renderContainer.currentTexturePath, - app.fileSystem - ) - ) - ) - await this.model.create() - - this.scene.add(this.model.getGroup()) - this.model.animator.setupWintersky(this.winterskyScene) - - const { default: Wintersky } = await useWintersky() - this.renderContainer.particles.forEach(([shortName, json]) => { - if (!shortName) return - - this.model?.animator.addEmitter( - shortName, - new Wintersky.Config(this.winterskyScene, json) - ) - }) - - for (const [animId, anim] of this.renderContainer.animations) { - this.model.animator.addAnimation(animId, anim) - } - this.renderContainer.runningAnimations.forEach((animId) => - this.model?.animator.play(animId) - ) - - const serverEntity = this.renderContainer.serverEntity - if (serverEntity) { - this.boxHelperDisposables.push() - this.createOutlineBoxes([ - ...serverEntity.getHitboxes(), - ...serverEntity.getCollisionBoxes(), - ...serverEntity.getSeatBoxHelpers(), - ]) - } - - this.requestRendering() - setTimeout(() => { - this.requestRendering() - }, 100) - } - - protected createOutlineBoxes(boxes: IOutlineBox[]) { - this.boxHelperDisposables = boxes.map((box) => - this.model!.createOutlineBox(box.color, box.position, box.size) - ) - } - - protected render(checkShouldTick = true) { - this.winterskyScene.updateFacingRotation(this.camera) - super.render() - - if (checkShouldTick && this.model && this.model.shouldTick) { - this.model.tick() - if (this.isActive) this.requestRendering() - } - } - - async close() { - const didClose = await super.close() - if (didClose) { - this._renderContainer?.dispose() - this._renderContainer = undefined - } - - return didClose - } - - async renderAssetPreview() { - if (!this._renderContainer || !this.renderContainer.currentTexturePath) - return - - const { StandaloneModelViewer } = await useBridgeModelViewer() - - const fileSystem = this.parent.app.fileSystem - const texture = await fileSystem.loadFileHandleAsDataUrl( - await fileSystem.getFileHandle( - this.renderContainer.currentTexturePath - ) - ) - - const configWindow = new AssetPreviewWindow({ - assetName: this.tab.getFileHandle().name.split('.').shift()!, - modelData: this.renderContainer.modelData, - textureUrl: texture, - }) - const renderConfig = await configWindow.fired - if (!renderConfig) return - const { - backgroundColor, - boneVisibility, - previewScale, - outputResolution, - } = renderConfig - let assetName = renderConfig.assetName - - const modelCanvas = document.createElement('canvas') - modelCanvas.width = 500 * outputResolution - modelCanvas.height = 500 * outputResolution - - const modelViewer = new StandaloneModelViewer( - modelCanvas, - this.renderContainer.modelData, - texture, - { - antialias: true, - height: 500 * outputResolution, - width: 500 * outputResolution, - } - ) - await modelViewer.loadedModel - - // Hide bones which were disabled by the user - for (const [boneName, isVisible] of Object.entries(boneVisibility)) { - if (!isVisible) modelViewer.getModel().hideBone(boneName) - } - - // @ts-ignore - modelViewer.scene.background = new Color(backgroundColor) - - const resultCanvas = document.createElement('canvas') - resultCanvas.width = 1400 * outputResolution - resultCanvas.height = 600 * outputResolution - const resultCtx = resultCanvas.getContext('2d') - if (!resultCtx) return - - resultCtx.imageSmoothingEnabled = false - - const model = modelViewer.getModel().getGroup() - modelViewer.positionCamera(previewScale) - - modelViewer.requestRendering() - await wait(100) - - const urls = [] - for (let i = 0; i < 5; i++) { - if (i === 1) model.rotateY(Math.PI / 4) - if (i !== 0) model.rotateY(Math.PI / 2) - - modelViewer.requestRendering(true) - urls.push(modelCanvas.toDataURL('image/png')) - } - - // Bottom - const box = new Box3().setFromObject(model) - const center = box.getCenter(new Vector3()) - model.position.setY(center.y) - model.rotation.set(0.25 * Math.PI, 1.75 * Math.PI, 0.75 * Math.PI) - modelViewer.positionCamera(previewScale, false) - modelViewer.requestRendering(true) - urls.push(modelCanvas.toDataURL('image/png')) - model.position.setY(0) - - // Top - model.rotation.set(0, 1.75 * Math.PI, 1.75 * Math.PI) - modelViewer.positionCamera(previewScale, false) - modelViewer.requestRendering(true) - urls.push(modelCanvas.toDataURL('image/png')) - - // Load textures - const authorImagePath = this.parent.project.config.getAuthorImage() - - const [entityTexture, authorImageUrl, ...modelRenders] = - await Promise.all([ - this.loadImageFromDisk(this.renderContainer.currentTexturePath), - authorImagePath - ? this.loadImageFromDisk(authorImagePath) - : null, - ...urls.map((url) => this.loadImage(url)), - ]) - - resultCtx.fillStyle = backgroundColor - resultCtx.fillRect(0, 0, resultCanvas.width, resultCanvas.height) - resultCtx.drawImage( - modelRenders[0], - 0, - 100 * outputResolution, - 400 * outputResolution, - 400 * outputResolution - ) - - for (let i = 0; i < 3; i++) { - resultCtx.drawImage( - modelRenders[i + 1], - 650 * outputResolution, - i * 200 * outputResolution, - 200 * outputResolution, - 200 * outputResolution - ) - } - for (let i = 0; i < 3; i++) { - resultCtx.drawImage( - modelRenders[i + 4], - 1050 * outputResolution, - i * 200 * outputResolution, - 200 * outputResolution, - 200 * outputResolution - ) - } - - // Render texture correctly even if it's not square - const xOffset = - (entityTexture.width > entityTexture.height - ? 0 - : (entityTexture.height - entityTexture.width) / - entityTexture.width / - 2) * 200 - const yOffset = - (entityTexture.height > entityTexture.width - ? 0 - : (entityTexture.width - entityTexture.height) / - entityTexture.height / - 2) * 200 - const xSize = - entityTexture.width > entityTexture.height - ? 200 - : (entityTexture.width / entityTexture.height) * 200 - const ySize = - entityTexture.height > entityTexture.width - ? 200 - : (entityTexture.height / entityTexture.width) * 200 - - resultCtx.drawImage( - entityTexture, - (400 + xOffset) * outputResolution, - (200 + yOffset) * outputResolution, - xSize * outputResolution, - ySize * outputResolution - ) - - // Draw watermark of author's logo - if (authorImageUrl) - resultCtx.drawImage( - authorImageUrl, - resultCanvas.width - 50 * outputResolution, - resultCanvas.height - 50 * outputResolution, - 50 * outputResolution, - 50 * outputResolution - ) - - // Asset preview title - if (assetName !== '') { - // Prepare for writing text - resultCtx.fillStyle = '#ffffff' - - resultCtx.font = 30 * outputResolution + 'px Arial' - resultCtx.fillText( - assetName, - 20 * outputResolution, - 50 * outputResolution - ) - } else { - assetName = this.tab.getFileHandle().name.split('.')[0] - } - - await new Promise((resolve) => { - resultCanvas.toBlob(async (blob) => { - if (!blob) return - - await saveOrDownload( - `previews/${assetName}.png`, - new Uint8Array(await blob.arrayBuffer()), - this.parent.project.fileSystem - ) - resolve() - }) - }) - - modelViewer.dispose() - } - - protected loadImage(imageSrc: string) { - return new Promise(async (resolve, reject) => { - const image = new Image() - image.addEventListener('load', () => resolve(image)) - - image.src = imageSrc - }) - } - protected async loadImageFromDisk(imageSrc: string) { - const fileSystem = this.parent.app.fileSystem - - return await this.loadImage( - await fileSystem.loadFileHandleAsDataUrl( - await fileSystem.getFileHandle(imageSrc) - ) - ) - } -} diff --git a/src/components/Editors/HTMLPreview/HTMLPreview.ts b/src/components/Editors/HTMLPreview/HTMLPreview.ts deleted file mode 100644 index 561174e58..000000000 --- a/src/components/Editors/HTMLPreview/HTMLPreview.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { TabSystem } from '../../TabSystem/TabSystem' -import { IDisposable } from '/@/types/disposable' -import type { ThemeManager } from '/@/components/Extensions/Themes/ThemeManager' -import { IframeTab } from '../IframeTab/IframeTab' -import { Tab } from '../../TabSystem/CommonTab' -import { AnyFileHandle } from '../../FileSystem/Types' -import { iframeApiVersion } from '/@/utils/app/iframeApiVersion' -import { translate } from '../../Locales/Manager' -import { VirtualFile } from '../../FileSystem/Virtual/File' - -export class HTMLPreviewTab extends IframeTab { - public rawHtml = '' - - protected defaultStyles = `` - protected themeListener?: IDisposable - protected fileListener?: IDisposable - protected messageListener?: IDisposable - protected scrollY = 0 - constructor( - parent: TabSystem, - protected previewOptions: { - filePath?: string - fileHandle: AnyFileHandle - } - ) { - super(parent) - - const themeManager = parent.app.themeManager - this.updateDefaultStyles(themeManager) - this.themeListener = themeManager.on(() => - this.updateDefaultStyles(themeManager) - ) - - if (previewOptions.filePath) - this.fileListener = parent.app.project.fileChange.on( - previewOptions.filePath, - async (file) => { - await this.load(file) - } - ) - - this.api.loaded.once(() => { - this.api.on('saveScrollPosition', (scrollY) => { - if (scrollY !== 0) this.scrollY = scrollY - }) - }) - } - - async setup() { - await this.load() - - await super.setup() - } - async onActivate() { - await super.onActivate() - await this.api.loaded.fired - - this.api.trigger('loadScrollPosition', this.scrollY) - } - onDeactivate() { - this.messageListener?.dispose() - this.messageListener = undefined - - super.onDeactivate() - } - - onDestroy() { - this.themeListener?.dispose() - this.themeListener = undefined - this.fileListener?.dispose() - this.fileListener = undefined - } - - get icon() { - return 'mdi-language-html5' - } - get iconColor() { - return 'behaviorPack' - } - get name() { - return `${translate('preview.name')}: ${this.fileHandle.name}` - } - get fileHandle() { - return this.previewOptions.fileHandle - } - - get html() { - return ( - this.rawHtml.replaceAll('href="#', 'href="about:srcdoc#') + - `` + - `` + - `` - ) - } - updateDefaultStyles(themeManager: ThemeManager) { - this.defaultStyles = `html { - color: ${themeManager.getColor('text')}; - font-family: Roboto; - } - - a { - color: ${themeManager.getColor('primary')}; - } - - textarea, input { - background-color: ${themeManager.getColor('background')}; - color: ${themeManager.getColor('text')}; - }` - - if (this.rawHtml !== '') this.updateHtml() - } - - async updateHtml() { - this.srcdoc = this.html - - await this.api.loaded.fired - this.api.trigger('loadScrollPosition', this.scrollY) - } - - async load(file?: File | VirtualFile) { - if (!file) file = await this.fileHandle.getFile() - this.rawHtml = await file.text() - - this.updateHtml() - } - - async is(tab: Tab): Promise { - return ( - tab instanceof HTMLPreviewTab && - (await tab.fileHandle.isSameEntry(this.fileHandle)) - ) - } -} diff --git a/src/components/Editors/IframeTab/API/Events/GenericEvent.ts b/src/components/Editors/IframeTab/API/Events/GenericEvent.ts deleted file mode 100644 index eeb0aecb5..000000000 --- a/src/components/Editors/IframeTab/API/Events/GenericEvent.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IframeApi } from '../IframeApi' -import { IDisposable } from '/@/types/disposable' - -export abstract class GenericEvent { - protected disposables: IDisposable[] = [] - constructor(protected api: IframeApi) { - this.disposables.push( - this.api.loaded.on(() => this.onApiLoaded(), true), - this.api.loaded.once(() => this.setup(), true)! - ) - } - - onApiLoaded() {} - - abstract setup(): Promise | void - - dispose() { - this.disposables.forEach((disposable) => disposable.dispose()) - this.disposables = [] - } -} diff --git a/src/components/Editors/IframeTab/API/Events/Tab/OpenFile.ts b/src/components/Editors/IframeTab/API/Events/Tab/OpenFile.ts deleted file mode 100644 index fc9a9ea83..000000000 --- a/src/components/Editors/IframeTab/API/Events/Tab/OpenFile.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { GenericEvent } from '../GenericEvent' - -export class OpenFileEvent extends GenericEvent { - setup() { - this.api.trigger('tab.openFile', this.api.openWithPayload) - } -} diff --git a/src/components/Editors/IframeTab/API/Events/ThemeChange.ts b/src/components/Editors/IframeTab/API/Events/ThemeChange.ts deleted file mode 100644 index 07ee99cc6..000000000 --- a/src/components/Editors/IframeTab/API/Events/ThemeChange.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { GenericEvent } from './GenericEvent' -import { App } from '/@/App' - -export class ThemeChangeEvent extends GenericEvent { - async setup() { - const app = await App.getApp() - - this.disposables.push( - app.themeManager.on(() => { - this.api.trigger( - 'themeManager.themeChange', - app.themeManager.getCurrentTheme() - ) - }) - ) - - this.api.trigger( - 'themeManager.themeChange', - app.themeManager.getCurrentTheme() - ) - } -} diff --git a/src/components/Editors/IframeTab/API/IframeApi.ts b/src/components/Editors/IframeTab/API/IframeApi.ts deleted file mode 100644 index 40ce3c7a8..000000000 --- a/src/components/Editors/IframeTab/API/IframeApi.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Channel } from 'bridge-iframe-api' -import { GenericEvent } from './Events/GenericEvent' -import { ThemeChangeEvent } from './Events/ThemeChange' -import { GenericRequest } from './Requests/GenericRequest' -import { ReadFileRequest } from './Requests/FileSystem/ReadFile' -import { Signal } from '/@/components/Common/Event/Signal' -import { IDisposable } from '/@/types/disposable' -import { isNightly as isNightlyBuild } from '/@/utils/app/isNightly' -import { version as appVersion } from '/@/utils/app/version' -import { WriteFileRequest } from './Requests/FileSystem/WriteFile' -import { ReadTextFileRequest } from './Requests/FileSystem/ReadTextFile' -import { IframeTab } from '../IframeTab' -import { OpenFileEvent } from './Events/Tab/OpenFile' -import { openedFileReferenceName } from './Requests/FileSystem/ResolveFileReference' -import { GetItemPreviewRequest } from './Requests/Project/GetItemPreview' -import { ReadAsDataUrlRequest } from './Requests/FileSystem/ReadAsDataUrl' -import { FindRequest } from './Requests/PackIndexer/Find' -import { GetFileRequest } from './Requests/PackIndexer/GetFile' -import { SetIsUnsavedRequest } from './Requests/Tab/SetIsUnsaved' -import { PlatformRequest } from './Requests/Util/Platform' -import { UpdateFileRequest } from './Requests/Dash/UpdateFile' -import { SetIsLoadingRequest } from './Requests/Tab/SetIsLoading' -import { wait } from '/@/utils/wait' - -export class IframeApi { - didSetup = false - loaded = new Signal() - channelSetup = new Signal() - protected disposables: IDisposable[] = [] - protected _channel?: Channel - protected openFileEvent = new OpenFileEvent(this) - protected events: GenericEvent[] = [new ThemeChangeEvent(this)] - protected requests: GenericRequest[] = [ - // FileSystem - new ReadFileRequest(this), - new ReadTextFileRequest(this), - new ReadAsDataUrlRequest(this), - new WriteFileRequest(this), - - // Project - new GetItemPreviewRequest(this), - - // Tab - new SetIsUnsavedRequest(this), - new SetIsLoadingRequest(this), - - // PackIndexer, - new FindRequest(this), - new GetFileRequest(this), - - // Dash - new UpdateFileRequest(this), - - // Util - new PlatformRequest(this), - ] - - constructor( - public readonly tab: IframeTab, - protected iframe: HTMLIFrameElement - ) { - this.iframe.addEventListener('load', async () => { - if (!iframe.src && !iframe.srcdoc) return - - this._channel = new Channel(this.iframe.contentWindow) - this.channelSetup.dispatch() - - await wait(20) - await this.channel.open() - - this.loaded.dispatch() - this.onLoad() - }) - } - - get app() { - return this.tab.project.app - } - - get openWithPayload() { - const payload = this.tab.getOptions().openWithPayload ?? {} - - return { - filePath: payload.filePath, - fileReference: openedFileReferenceName, - isReadOnly: payload.isReadOnly ?? false, - } - } - get openedFileHandle() { - return this.tab.getOptions().openWithPayload?.fileHandle ?? null - } - get openedFilePath() { - return this.tab.getOptions().openWithPayload?.filePath ?? null - } - - get channel() { - if (!this._channel) - throw new Error( - 'Channel is not initialized yet. Make sure to await iframeApi.loaded.fired' - ) - return this._channel - } - - on(event: string, callback: (data: T, origin: string) => void) { - return this.channel.on(event, callback) - } - trigger(event: string, data: T) { - return this.channel.simpleTrigger(event, data) - } - - protected onLoad() { - this.disposables.forEach((disposable) => disposable.dispose()) - this.disposables = [] - - this.trigger('app.buildInfo', { - appVersion, - isNightlyBuild, - }) - } - // The underlying tab is supposed to open a new file - triggerOpenWith() { - this.openFileEvent.setup() - } - - dispose() { - this.events.forEach((event) => event.dispose()) - this.events = [] - this.requests.forEach((request) => request.dispose()) - this.requests = [] - this.openFileEvent.dispose() - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/Dash/UpdateFile.ts b/src/components/Editors/IframeTab/API/Requests/Dash/UpdateFile.ts deleted file mode 100644 index 49adbaf71..000000000 --- a/src/components/Editors/IframeTab/API/Requests/Dash/UpdateFile.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { resolveFileReferencePath } from '../FileSystem/ResolveFileReference' -import { GenericRequest } from '../GenericRequest' - -export class UpdateFileRequest extends GenericRequest { - protected filesToUpdate = new Set() - protected updateScheduled = false - constructor(api: IframeApi) { - super('dash.updateFile', api) - } - - async handle(fileReference: string, origin: string) { - const filePath = resolveFileReferencePath(fileReference, this.api) - - this.filesToUpdate.add(filePath) - this.scheduleUpdate() - } - - scheduleUpdate() { - if (this.updateScheduled) return - this.updateScheduled = true - - // Update all files after 200ms - setTimeout(() => { - this.updateScheduled = false - - this.api.app.project.compilerService.updateFiles([ - ...this.filesToUpdate, - ]) - this.filesToUpdate.clear() - }, 200) - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/FileSystem/ReadAsDataUrl.ts b/src/components/Editors/IframeTab/API/Requests/FileSystem/ReadAsDataUrl.ts deleted file mode 100644 index f074f88f6..000000000 --- a/src/components/Editors/IframeTab/API/Requests/FileSystem/ReadAsDataUrl.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { GenericRequest } from '../GenericRequest' -import { resolveFileReference } from './ResolveFileReference' -import { loadHandleAsDataURL } from '/@/utils/loadAsDataUrl' - -export class ReadAsDataUrlRequest extends GenericRequest { - constructor(api: IframeApi) { - super('fs.readAsDataUrl', api) - } - - async handle(filePath: string, origin: string): Promise { - const fileHandle = await resolveFileReference(filePath, this.api) - - return await loadHandleAsDataURL(fileHandle) - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/FileSystem/ReadFile.ts b/src/components/Editors/IframeTab/API/Requests/FileSystem/ReadFile.ts deleted file mode 100644 index f13750c2e..000000000 --- a/src/components/Editors/IframeTab/API/Requests/FileSystem/ReadFile.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { GenericRequest } from '../GenericRequest' -import { resolveFileReference } from './ResolveFileReference' - -export class ReadFileRequest extends GenericRequest { - constructor(api: IframeApi) { - super('fs.readFile', api) - } - - async handle(filePath: string, origin: string): Promise { - const fileHandle = await resolveFileReference(filePath, this.api) - const file = await fileHandle.getFile() - - return new Uint8Array(await file.arrayBuffer()) - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/FileSystem/ReadTextFile.ts b/src/components/Editors/IframeTab/API/Requests/FileSystem/ReadTextFile.ts deleted file mode 100644 index dbb412c82..000000000 --- a/src/components/Editors/IframeTab/API/Requests/FileSystem/ReadTextFile.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { GenericRequest } from '../GenericRequest' -import { resolveFileReference } from './ResolveFileReference' - -export class ReadTextFileRequest extends GenericRequest { - constructor(api: IframeApi) { - super('fs.readTextFile', api) - } - - async handle(filePath: string, origin: string): Promise { - const fileHandle = await resolveFileReference(filePath, this.api) - const file = await fileHandle.getFile() - - return await file.text() - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/FileSystem/ResolveFileReference.ts b/src/components/Editors/IframeTab/API/Requests/FileSystem/ResolveFileReference.ts deleted file mode 100644 index f4606fb40..000000000 --- a/src/components/Editors/IframeTab/API/Requests/FileSystem/ResolveFileReference.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { IframeApi } from '../../IframeApi' - -export const openedFileReferenceName = '~bridge://OPENED-FILE' - -export async function resolveFileReference( - fileReference: string, - api: IframeApi, - createFile = false -) { - if (fileReference === openedFileReferenceName) { - const fileHandle = api.openedFileHandle - - if (!fileHandle) - throw new Error( - `Failed to de-reference file reference to opened file!` - ) - - return fileHandle - } - - return await api.app.fileSystem.getFileHandle(fileReference, createFile) -} - -export function resolveFileReferencePath( - fileReference: string, - api: IframeApi -) { - if (fileReference === openedFileReferenceName) { - const filePath = api.openedFilePath - - if (!filePath) - throw new Error( - `Failed to de-reference file reference to opened file!` - ) - - return filePath - } - - return fileReference -} diff --git a/src/components/Editors/IframeTab/API/Requests/FileSystem/WriteFile.ts b/src/components/Editors/IframeTab/API/Requests/FileSystem/WriteFile.ts deleted file mode 100644 index 1f84f04c2..000000000 --- a/src/components/Editors/IframeTab/API/Requests/FileSystem/WriteFile.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { GenericRequest } from '../GenericRequest' -import { resolveFileReference } from './ResolveFileReference' -import { App } from '/@/App' - -export interface IWriteFilePayload { - filePath: string - data: Uint8Array | string -} - -export class WriteFileRequest extends GenericRequest { - constructor(api: IframeApi) { - super('fs.writeFile', api) - } - - async handle( - { filePath, data }: IWriteFilePayload, - origin: string - ): Promise { - const app = await App.getApp() - - const fileHandle = await resolveFileReference(filePath, this.api, true) - - await app.fileSystem.write(fileHandle, data) - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/GenericRequest.ts b/src/components/Editors/IframeTab/API/Requests/GenericRequest.ts deleted file mode 100644 index 4fae5fa07..000000000 --- a/src/components/Editors/IframeTab/API/Requests/GenericRequest.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IframeApi } from '../IframeApi' -import { IDisposable } from '/@/types/disposable' - -export abstract class GenericRequest { - protected disposables: IDisposable[] = [] - - constructor(name: string, protected api: IframeApi) { - this.api.channelSetup.once(() => { - this.disposables.push( - this.api.channel.on(name, (data, origin) => - this.handle(data, origin) - ) - ) - }) - } - - abstract handle(data: Payload, origin: string): Promise | Response - - dispose() { - this.disposables.forEach((disposable) => disposable.dispose()) - this.disposables = [] - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/PackIndexer/Find.ts b/src/components/Editors/IframeTab/API/Requests/PackIndexer/Find.ts deleted file mode 100644 index da542ee89..000000000 --- a/src/components/Editors/IframeTab/API/Requests/PackIndexer/Find.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { GenericRequest } from '../GenericRequest' - -interface IRequestData { - findFileType: string - whereCacheKey: string - matchesOneOf: string[] - fetchAll?: boolean -} - -export class FindRequest extends GenericRequest { - constructor(api: IframeApi) { - super('packIndexer.find', api) - } - - async handle( - { findFileType, whereCacheKey, matchesOneOf, fetchAll }: IRequestData, - origin: string - ) { - const packIndexer = this.api.app.project.packIndexer - await packIndexer.fired - - return await packIndexer.service.find( - findFileType, - whereCacheKey, - matchesOneOf, - fetchAll - ) - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/PackIndexer/GetFile.ts b/src/components/Editors/IframeTab/API/Requests/PackIndexer/GetFile.ts deleted file mode 100644 index 149ed0882..000000000 --- a/src/components/Editors/IframeTab/API/Requests/PackIndexer/GetFile.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { resolveFileReferencePath } from '../FileSystem/ResolveFileReference' -import { GenericRequest } from '../GenericRequest' -import { App } from '/@/App' - -export class GetFileRequest extends GenericRequest< - string, - Record -> { - constructor(api: IframeApi) { - super('packIndexer.getFile', api) - } - - async handle(fileReference: string, origin: string) { - const packIndexer = this.api.app.project.packIndexer - await packIndexer.fired - - const filePath = resolveFileReferencePath(fileReference, this.api) - - const fileType = App.fileType.getId(filePath) - - return await packIndexer.service.getCacheDataFor(fileType, filePath) - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/Project/GetItemPreview.ts b/src/components/Editors/IframeTab/API/Requests/Project/GetItemPreview.ts deleted file mode 100644 index 67c95c07f..000000000 --- a/src/components/Editors/IframeTab/API/Requests/Project/GetItemPreview.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { GenericRequest } from '../GenericRequest' -import { findFileExtension } from '/@/components/FileSystem/FindFile' - -export class GetItemPreviewRequest extends GenericRequest< - string, - string | null -> { - constructor(api: IframeApi) { - super('project.getItemPreview', api) - } - - async handle(identifier: string, origin: string) { - const app = this.api.app - const project = app.project - const packIndexer = project.packIndexer - const fs = app.fileSystem - await packIndexer.fired - - const [identifierReference] = await packIndexer.service.find( - 'item', - 'identifier', - [identifier], - false - ) - - // Read item behavior file - const itemBehaviorFile = await fs - .readJSON(identifierReference) - .catch(() => null) - if (itemBehaviorFile === null) return null - - // Get 'minecraft:icon' component from item behavior - const iconComponent = - itemBehaviorFile['minecraft:item']?.components?.[ - 'minecraft:icon' - ] ?? null - if (iconComponent === null) return null - - // Get current texture name from icon component - const iconTextureName = iconComponent.texture ?? null - if (iconTextureName === null) return null - - // Lookup texture name within item_texture.json file - const itemTextureFile = await fs - .readJSON( - project.config.resolvePackPath( - 'resourcePack', - 'textures/item_texture.json' - ) - ) - .catch(() => null) - if (itemTextureFile === null || !itemTextureFile.texture_data) - return null - - const iconTextureDataObj = - itemTextureFile.texture_data[iconTextureName] ?? null - if (iconTextureDataObj === null) return null - - // Load icon texture path from icon texture data object ({ textures: '...' }, { textures: ['...'] } or '...') - let iconTexturePath = null - if (typeof iconTextureDataObj === 'string') - iconTexturePath = iconTextureDataObj - else if ( - typeof iconTextureDataObj === 'object' && - typeof iconTextureDataObj.textures === 'string' - ) - iconTexturePath = iconTextureDataObj.textures - else if ( - typeof iconTextureDataObj === 'object' && - Array.isArray(iconTextureDataObj.textures) - ) - iconTexturePath = iconTextureDataObj.textures[0] ?? null - - if (iconTexturePath === null) return null - - // Find icon texture file extension - const absolutePathWithoutExt = project.config.resolvePackPath( - 'resourcePack', - iconTexturePath - ) - console.log(absolutePathWithoutExt) - - const absolutePath = await findFileExtension( - fs, - absolutePathWithoutExt, - ['.png', '.jpg', '.jpeg', '.tga'] - ) - console.log(absolutePath) - if (absolutePath === undefined) return null - - // Load file handle as data url - const imageHandle = await fs - .getFileHandle(absolutePath) - .catch(() => null) - if (imageHandle === null) return null - - return await fs.loadFileHandleAsDataUrl(imageHandle) - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/Tab/SetIsLoading.ts b/src/components/Editors/IframeTab/API/Requests/Tab/SetIsLoading.ts deleted file mode 100644 index 5b8846061..000000000 --- a/src/components/Editors/IframeTab/API/Requests/Tab/SetIsLoading.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { GenericRequest } from '../GenericRequest' - -export class SetIsLoadingRequest extends GenericRequest { - constructor(api: IframeApi) { - super('tab.setIsLoading', api) - } - - async handle(isLoading: boolean, origin: string) { - this.api.tab.setIsLoading(isLoading) - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/Tab/SetIsUnsaved.ts b/src/components/Editors/IframeTab/API/Requests/Tab/SetIsUnsaved.ts deleted file mode 100644 index c3e769615..000000000 --- a/src/components/Editors/IframeTab/API/Requests/Tab/SetIsUnsaved.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { GenericRequest } from '../GenericRequest' - -export class SetIsUnsavedRequest extends GenericRequest { - constructor(api: IframeApi) { - super('tab.setIsUnsaved', api) - } - - async handle(isUnsaved: boolean, origin: string) { - this.api.tab.setIsUnsaved(isUnsaved) - } -} diff --git a/src/components/Editors/IframeTab/API/Requests/Util/Platform.ts b/src/components/Editors/IframeTab/API/Requests/Util/Platform.ts deleted file mode 100644 index a93fa72ee..000000000 --- a/src/components/Editors/IframeTab/API/Requests/Util/Platform.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IframeApi } from '../../IframeApi' -import { GenericRequest } from '../GenericRequest' -import { platform } from '/@/utils/os' - -export class PlatformRequest extends GenericRequest { - constructor(api: IframeApi) { - super('util.platform', api) - } - - async handle(_: undefined, origin: string) { - return platform() - } -} diff --git a/src/components/Editors/IframeTab/IframeTab.ts b/src/components/Editors/IframeTab/IframeTab.ts deleted file mode 100644 index baab70234..000000000 --- a/src/components/Editors/IframeTab/IframeTab.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { TabSystem } from '../../TabSystem/TabSystem' -import IframeTabComponent from './IframeTab.vue' -import { Tab } from '../../TabSystem/CommonTab' -import { IframeApi } from './API/IframeApi' -import { markRaw } from 'vue' -import { AnyFileHandle } from '../../FileSystem/Types' -import { getFullScreenElement } from '../../TabSystem/TabContextMenu/Fullscreen' - -interface IIframeTabOptions { - icon?: string - name?: string - url?: string - html?: string - iconColor?: string - openWithPayload?: IOpenWithPayload -} - -export interface IOpenWithPayload { - filePath?: string - fileHandle?: AnyFileHandle - isReadOnly?: boolean -} - -export class IframeTab extends Tab { - component = IframeTabComponent - - private iframe = document.createElement('iframe') - protected loaded: Promise - protected api = markRaw(new IframeApi(this, this.iframe)) - - constructor(parent: TabSystem, protected options: IIframeTabOptions = {}) { - super(parent) - - this.isTemporary = false - this.iframe.setAttribute( - 'sandbox', - 'allow-scripts allow-same-origin allow-modals allow-popups allow-forms allow-downloads' - ) - this.loaded = new Promise((resolve) => - this.iframe.addEventListener('load', () => resolve()) - ) - - if (this.url) this.setUrl(this.url) - if (this.options.html) this.iframe.srcdoc = this.options.html - - this.iframe.width = '100%' - this.iframe.style.display = 'none' - this.iframe.style.position = 'absolute' - this.iframe.classList.add('outlined') - this.iframe.style.borderRadius = '12px' - this.iframe.style.margin = '8px' - getFullScreenElement()?.appendChild(this.iframe) - } - - getOptions() { - return this.options - } - setOpenWithPayload(payload?: IOpenWithPayload) { - this.options.openWithPayload = payload - if (payload) this.api.triggerOpenWith() - } - - async setup() { - await super.setup() - } - async onActivate() { - await super.onActivate() - - this.isLoading = true - await this.loaded - this.isLoading = false - - // Only show iframe if tab is still active - if (this.isActive) this.iframe.style.display = 'block' - } - onDeactivate() { - super.onDeactivate() - this.iframe.style.display = 'none' - } - onDestroy() { - if (getFullScreenElement()?.contains(this.iframe)) - getFullScreenElement()?.removeChild(this.iframe) - - this.api.dispose() - } - - get icon() { - return this.options.icon ?? 'mdi-web' - } - get iconColor() { - return this.options.iconColor - } - get name() { - return this.options.name ?? 'Web' - } - get url() { - return this.options.url - } - set srcdoc(val: string) { - this.api.loaded.resetSignal() - this.iframe.srcdoc = val - } - set src(val: string) { - this.api.loaded.resetSignal() - this.iframe.src = val - } - - setUrl(url: string) { - this.iframe.src = url - } - - async is(tab: Tab): Promise { - return tab instanceof IframeTab && tab.url === this.url - } -} diff --git a/src/components/Editors/IframeTab/IframeTab.vue b/src/components/Editors/IframeTab/IframeTab.vue deleted file mode 100644 index 8e42bff23..000000000 --- a/src/components/Editors/IframeTab/IframeTab.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - - - diff --git a/src/components/Editors/Image/ImageTab.ts b/src/components/Editors/Image/ImageTab.ts deleted file mode 100644 index d349769c2..000000000 --- a/src/components/Editors/Image/ImageTab.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { FileTab, TReadOnlyMode } from '/@/components/TabSystem/FileTab' -import { loadHandleAsDataURL } from '/@/utils/loadAsDataUrl' -import ImageTabComponent from './ImageTab.vue' -import { AnyFileHandle } from '../../FileSystem/Types' - -export class ImageTab extends FileTab { - component = ImageTabComponent - dataUrl?: string = undefined - - static is(fileHandle: AnyFileHandle) { - const fileName = fileHandle.name - return ( - fileName.endsWith('.png') || - fileName.endsWith('.jpg') || - fileName.endsWith('.jpeg') - ) - } - - setReadOnly(val: TReadOnlyMode) { - this.readOnlyMode = val - } - - async onActivate() { - this.dataUrl = await loadHandleAsDataURL(this.fileHandle) - } - - get icon() { - return 'mdi-file-image-outline' - } - get iconColor() { - return 'resourcePack' - } - - _save() {} -} diff --git a/src/components/Editors/Image/ImageTab.vue b/src/components/Editors/Image/ImageTab.vue deleted file mode 100644 index 8a07972e2..000000000 --- a/src/components/Editors/Image/ImageTab.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/src/components/Editors/Image/TargaTab.ts b/src/components/Editors/Image/TargaTab.ts deleted file mode 100644 index 7aff2ac48..000000000 --- a/src/components/Editors/Image/TargaTab.ts +++ /dev/null @@ -1,104 +0,0 @@ -import TgaLoader from 'tga-js' -import { ImageTab } from './ImageTab' -import { AnyFileHandle } from '../../FileSystem/Types' -import { SimpleAction } from '../../Actions/SimpleAction' - -export class TargaTab extends ImageTab { - protected tga = new TgaLoader() - maskIsApplied: boolean = true - - static is(fileHandle: AnyFileHandle) { - return fileHandle.name.endsWith('.tga') - } - - async setup() { - await super.setup() - - this.addAction( - new SimpleAction({ - icon: 'mdi-image-filter-black-white', - name: 'actions.tgaMaskToggle.name', - onTrigger: async () => { - if (this.maskIsApplied) { - await this.applyUnmaskedImageUrl() - this.maskIsApplied = false - return - } - - this.applyMaskedImageUrl() - this.maskIsApplied = true - }, - }) - ) - } - - async onActivate() { - this.isLoading = true - - const file = await this.fileHandle.getFile() - this.tga.load(new Uint8Array(await file.arrayBuffer())) - - this.isLoading = false - - if (this.maskIsApplied) { - this.applyMaskedImageUrl() - return - } - - await this.applyUnmaskedImageUrl() - } - - _save() { - /// TODO: Save `this.dataUrl` value to `${this.fileHandle.name}.png` file - } - - async saveAs() { - /// TODO: Save `this.dataUrl` value to user input - } - - applyMaskedImageUrl() { - this.dataUrl = this.tga.getDataURL('image/png') - } - - async applyUnmaskedImageUrl() { - /// Get ImageData from TGALoader - const { width, height, data } = this.tga.getImageData() - - // @ts-ignore OffscreenCanvas API types not available in TypeScript - const offscreen = new OffscreenCanvas(width, height) - - /// Create context to contain new image - const ctx = offscreen.getContext('2d') - const imageData = ctx.createImageData(width, height) - - /// Rewrite ImageData - /// Copies RGB channels from original data - /// Clamps alpha channel to be fully opaque (white) - const len = data.length - - for (let itr = 0; itr < len; itr += 4) { - imageData.data[itr] = data[itr] - imageData.data[itr + 1] = data[itr + 1] - imageData.data[itr + 2] = data[itr + 2] - imageData.data[itr + 3] = 255 - } - - ctx.putImageData(imageData, 0, 0) - - const canvasBlob = await offscreen.convertToBlob({ - type: 'image/png', - }) - - /// Convert OffscreenCanvas content to Blob, so it can be converted to base64 - const reader = new FileReader() - reader.readAsDataURL(canvasBlob) - - reader.onload = () => { - this.dataUrl = reader.result?.toString() - } - - reader.onerror = () => { - throw new Error('Failed reading OffscreenCanvas Blob') - } - } -} diff --git a/src/components/Editors/ParticlePreview/ParticlePreview.ts b/src/components/Editors/ParticlePreview/ParticlePreview.ts deleted file mode 100644 index 6d22eb8ce..000000000 --- a/src/components/Editors/ParticlePreview/ParticlePreview.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type Wintersky from 'wintersky' -import type { Emitter, Config } from 'wintersky' -import { ThreePreviewTab } from '../ThreePreview/ThreePreviewTab' -import { SimpleAction } from '/@/components/Actions/SimpleAction' -import json5 from 'json5' -import { AxesHelper } from 'three' -import { FileWatcher } from '/@/components/FileSystem/FileWatcher' -import { ParticleWatcher } from './ParticleWatcher' -import { TabSystem } from '/@/components/TabSystem/TabSystem' -import { loadAsDataURL } from '/@/utils/loadAsDataUrl' -import { App } from '/@/App' -import { Signal } from '/@/components/Common/Event/Signal' -import { FileTab } from '../../TabSystem/FileTab' -import { markRaw } from 'vue' -import { useWintersky } from '/@/utils/libs/useWintersky' - -export class ParticlePreviewTab extends ThreePreviewTab { - protected emitter?: Emitter - protected config?: Config - protected fileWatcher?: FileWatcher - protected isReloadingDone = new Signal() - - protected wintersky!: Wintersky.Scene - - constructor(tab: FileTab, tabSystem: TabSystem) { - super(tab, tabSystem) - - this.setupComplete.once(() => { - this.scene.add(new AxesHelper(16)) - this.wintersky.global_options.tick_rate = 60 - this.wintersky.global_options.max_emitter_particles = 1000 - this.wintersky.global_options.scale = 16 - - // this.scene.add(new GridHelper(64, 64)) - }) - this.isReloadingDone.dispatch() - } - - async setup() { - const { default: Wintersky } = await useWintersky() - - this.wintersky = markRaw( - new Wintersky.Scene({ - fetchTexture: async (config) => { - const app = await App.getApp() - const projectConfig = app.project.config - - try { - return await loadAsDataURL( - projectConfig.resolvePackPath( - 'resourcePack', - `${config.particle_texture_path}.png` - ), - app.fileSystem - ) - } catch (err) { - // Fallback to Wintersky's default handling of textures - } - }, - }) - ) - - await super.setup() - } - - async onActivate() { - await super.onActivate() - await this.onChange() - } - onDeactivate() { - this.emitter?.stop(true) - - super.onDeactivate() - } - onDestroy() { - this.fileWatcher?.dispose() - this.emitter?.delete() - super.onDestroy() - } - - onCreate() { - this.addAction( - new SimpleAction({ - icon: 'mdi-refresh', - name: 'general.reload', - onTrigger: () => this.reload(), - }) - ) - } - async receiveCanvas(canvas: HTMLCanvasElement) { - const shouldSetPosition = !this._camera - await super.receiveCanvas(canvas) - if (shouldSetPosition) this.camera.position.set(60, 30, 60) - } - - async loadParticle(file?: File) { - if (!this.fileWatcher) - this.fileWatcher = markRaw( - new ParticleWatcher(this, this.tab.getPath()) - ) - if (!file) - file = - (await this.fileWatcher?.requestFile(await this.getFile())) ?? - (await this.getFile()) - - let particle: any - try { - particle = json5.parse(await file.text()) - } catch { - return - } - - const { default: Wintersky } = await useWintersky() - - this.emitter?.delete() - if (!this.scene.children.includes(this.wintersky.space)) - this.scene.add(this.wintersky.space) - - this.config = markRaw( - new Wintersky.Config(this.wintersky, particle, { - path: this.tab.getPath(), - }) - ) - - this.emitter = markRaw( - new Wintersky.Emitter(this.wintersky, this.config, { - loop_mode: 'looping', - parent_mode: 'world', - }) - ) - // console.log(this.scene) - - this.emitter.start() - } - - protected render() { - // console.log('loop') - this.controls?.update() - this.wintersky.updateFacingRotation(this.camera) - this.emitter?.tick() - - this.renderer?.render(this.scene, this.camera) - this.renderingRequested = false - - if (this.isActive) this.requestRendering() - } - - async onChange(file?: File) { - await this.isReloadingDone.fired - this.isReloadingDone.resetSignal() - - await this.loadParticle(file) - this.isReloadingDone.dispatch() - } - async close() { - const didClose = await super.close() - if (didClose) this.fileWatcher?.dispose() - - return didClose - } - - async reload() { - await this.onChange() - } - - get icon() { - return this.tab.icon - } - get iconColor() { - return this.tab.iconColor - } -} diff --git a/src/components/Editors/ParticlePreview/ParticleWatcher.ts b/src/components/Editors/ParticlePreview/ParticleWatcher.ts deleted file mode 100644 index 5c43c9a65..000000000 --- a/src/components/Editors/ParticlePreview/ParticleWatcher.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { PreviewFileWatcher } from '/@/components/Editors/GeometryPreview/Data/PreviewFileWatcher' -import { ParticlePreviewTab } from './ParticlePreview' - -export class ParticleWatcher extends PreviewFileWatcher { - constructor(protected tab: ParticlePreviewTab, filePath: string) { - super(tab.tabSystem.app, filePath) - } - - onChange(file: File) { - this.tab.onChange(file) - } -} diff --git a/src/components/Editors/Sound/SoundTab.ts b/src/components/Editors/Sound/SoundTab.ts deleted file mode 100644 index 8a4f2af85..000000000 --- a/src/components/Editors/Sound/SoundTab.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { FileTab, TReadOnlyMode } from '/@/components/TabSystem/FileTab' -import { loadHandleAsDataURL } from '/@/utils/loadAsDataUrl' -import SoundTabComponent from './SoundTab.vue' -import { AnyFileHandle } from '../../FileSystem/Types' -import { addDisposableEventListener } from '/@/utils/disposableListener' -import { IDisposable } from '/@/types/disposable' - -export class SoundTab extends FileTab { - component = SoundTabComponent - dataUrl?: string = undefined - - audio: HTMLAudioElement | null = null - intervalId: number | null = null - currentTime = 0 - timeTriggeredManually = false - isPlaying = false - loadedAudioMetadata = false - audioShouldLoop = false - disposables: IDisposable[] = [] - - get icon() { - return 'mdi-file-music-outline' - } - get iconColor() { - return 'resourcePack' - } - - static is(fileHandle: AnyFileHandle) { - const fileName = fileHandle.name - return fileName.endsWith('.mp3') || fileName.endsWith('.ogg') - } - - setReadOnly(val: TReadOnlyMode) { - this.readOnlyMode = val - } - - // Tab events - async setup() { - this.dataUrl = await loadHandleAsDataURL(this.fileHandle) - this.audio = document.createElement('audio') - if (!this.audio) return - - this.audio.preload = 'metadata' - this.audio.loop = this.audioShouldLoop - this.audio.src = this.dataUrl - - this.intervalId = window.setInterval( - () => this.updateCurrentTime(), - 100 - ) - - this.disposables = [ - addDisposableEventListener('play', () => this.onPlay(), this.audio), - addDisposableEventListener( - 'pause', - () => this.onPause(), - this.audio - ), - addDisposableEventListener( - 'loadedmetadata', - () => this.onLoadedMetadata(), - this.audio - ), - ] - - await super.setup() - } - onDestroy() { - if (this.intervalId) window.clearInterval(this.intervalId) - this.intervalId = null - - this.disposables.forEach((disposable) => disposable.dispose()) - this.disposables = [] - - this.audio?.pause() - this.audio = null - } - - // Sound element events - onPlay() { - this.isPlaying = true - } - onPause() { - this.isPlaying = false - } - onLoadedMetadata() { - this.loadedAudioMetadata = true - } - - updateCurrentTime() { - this.timeTriggeredManually = false - this.currentTime = this.audio?.currentTime ?? 0 - } - toggleAudioLoop() { - this.audioShouldLoop = !this.audioShouldLoop - if (this.audio) this.audio.loop = this.audioShouldLoop - } - setCurrentTime(time: number) { - if (!this.audio) return - - if (!this.timeTriggeredManually) { - this.timeTriggeredManually = true - return - } - if (Number.isNaN(time)) return - - this.audio.currentTime = Math.round(time * 100) / 100 - } - - _save() {} -} diff --git a/src/components/Editors/Sound/SoundTab.vue b/src/components/Editors/Sound/SoundTab.vue deleted file mode 100644 index bce13148c..000000000 --- a/src/components/Editors/Sound/SoundTab.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - diff --git a/src/components/Editors/Text/TextTab.ts b/src/components/Editors/Text/TextTab.ts deleted file mode 100644 index cacdd42e7..000000000 --- a/src/components/Editors/Text/TextTab.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { FileTab, TReadOnlyMode } from '/@/components/TabSystem/FileTab' -import TextTabComponent from './TextTab.vue' -import type { editor } from 'monaco-editor' -import { IDisposable } from '/@/types/disposable' -import { App } from '/@/App' -import { TabSystem } from '/@/components/TabSystem/TabSystem' -import { settingsState } from '/@/components/Windows/Settings/SettingsState' -import { debounce } from 'lodash-es' -import { Signal } from '/@/components/Common/Event/Signal' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { markRaw } from 'vue' -import { loadMonaco, useMonaco } from '../../../utils/libs/useMonaco' -import { wait } from '/@/utils/wait' - -const throttledCacheUpdate = debounce<(tab: TextTab) => Promise | void>( - async (tab) => { - if (!tab.editorModel || tab.editorModel.isDisposed()) return - - const fileContent = tab.editorModel?.getValue() - const app = await App.getApp() - - app.project.fileChange.dispatch(tab.getPath(), await tab.getFile()) - - await app.project.packIndexer.updateFile( - tab.getPath(), - fileContent, - tab.isForeignFile, - true - ) - await app.project.jsonDefaults.updateDynamicSchemas(tab.getPath()) - }, - 600 -) - -export class TextTab extends FileTab { - component = TextTabComponent - editorModel: editor.ITextModel | undefined - editorViewState: editor.ICodeEditorViewState | undefined - disposables: (IDisposable | undefined)[] = [] - isActive = false - protected modelLoaded = new Signal() - protected initialVersionId: number = 0 - - get editorInstance() { - return this.parent.monacoEditor - } - - constructor( - parent: TabSystem, - fileHandle: AnyFileHandle, - readOnlyMode?: TReadOnlyMode - ) { - super(parent, fileHandle, readOnlyMode) - - this.fired.then(async () => { - const app = await App.getApp() - await app.projectManager.projectReady.fired - - app.project.tabActionProvider.addTabActions(this) - }) - } - async getFile() { - if (!this.editorModel || this.editorModel.isDisposed()) - return await super.getFile() - - return new File([this.editorModel.getValue()], this.name) - } - - updateUnsavedStatus() { - if (!this.editorModel || this.editorModel.isDisposed()) return - - this.setIsUnsaved( - this.initialVersionId !== - this.editorModel?.getAlternativeVersionId() - ) - } - - fileDidChange() { - // Updates the isUnsaved status of the tab - this.updateUnsavedStatus() - - super.fileDidChange() - } - - async onActivate() { - if (this.isActive) return - this.isActive = true - - // Load monaco in - if (!loadMonaco.hasFired) { - this.isLoading = true - loadMonaco.dispatch() - - // Monaco theme isn't loaded yet - await this.parent.app.themeManager.applyMonacoTheme() - } - - const { editor, Uri } = await useMonaco() - - await this.parent.fired //Make sure a monaco editor is loaded - await wait(1) - this.isLoading = false - - if (!this.editorModel || this.editorModel.isDisposed()) { - const file = await this.fileHandle.getFile() - const fileContent = await file.text() - // This for some reason fixes monaco suggesting the wrong path for quickfixes #932 - const filePath = this.getPath() - const uri = Uri.file( - filePath.endsWith('.ts') - ? filePath.replace('/BP/', '/bp/') - : filePath - ) - - this.editorModel = markRaw( - editor.getModel(uri) ?? - editor.createModel( - fileContent, - App.fileType.get(this.getPath())?.meta?.language, - uri - ) - ) - this.initialVersionId = this.editorModel.getAlternativeVersionId() - - this.modelLoaded.dispatch() - await this.loadEditor(false) - } else { - await this.loadEditor() - } - - this.disposables.push( - this.editorModel?.onDidChangeContent(() => { - throttledCacheUpdate(this) - this.fileDidChange() - }) - ) - this.disposables.push( - this.editorInstance?.onDidFocusEditorText(() => { - this.parent.setActive(true) - }) - ) - - this.editorInstance?.layout() - super.onActivate() - } - async onDeactivate() { - await super.onDeactivate() - - // MonacoEditor is defined - if (this.tabSystem.hasFired) { - const viewState = this.editorInstance.saveViewState() - if (viewState) this.editorViewState = markRaw(viewState) - } - - this.disposables.forEach((disposable) => disposable?.dispose()) - this.isActive = false - } - onDestroy() { - this.disposables.forEach((disposable) => disposable?.dispose()) - this.editorModel?.dispose() - this.editorModel = undefined - this.editorViewState = undefined - this.isActive = false - this.modelLoaded.resetSignal() - } - updateParent(parent: TabSystem) { - super.updateParent(parent) - } - focus() { - this.editorInstance?.focus() - } - - async loadEditor(shouldFocus = true) { - await this.parent.fired //Make sure a monaco editor is loaded - - if (this.editorModel && !this.editorModel.isDisposed()) - this.editorInstance.setModel(this.editorModel) - if (this.editorViewState) - this.editorInstance.restoreViewState(this.editorViewState) - - this.editorInstance?.updateOptions({ readOnly: this.isReadOnly }) - if (shouldFocus) setTimeout(() => this.focus(), 10) - } - - async _save() { - this.isTemporary = false - - const app = await App.getApp() - const action = this.editorInstance?.getAction( - 'editor.action.formatDocument' - ) - const fileType = App.fileType.get(this.getPath()) - - const fileContentStr = this.editorModel?.getValue() - - if ( - // Make sure that there is fileContent to format, - fileContentStr && - fileContentStr !== '' && - // ...that we have an action to trigger, - action && - // ...that the file is a valid fileType, - fileType && - // ...that formatOnSave is enabled, - (settingsState?.general?.formatOnSave ?? true) && - // ...and that the current file type supports formatting - (fileType?.formatOnSaveCapable ?? true) - ) { - // This is a terrible hack because we need to make sure that the formatter triggers the "onDidChangeContent" event - // The promise returned by action.run() actually resolves before formatting is done so we need the "onDidChangeContent" event to tell when the formatter is done - this.makeFakeEdit('\t') - - const editPromise = new Promise((resolve) => { - if (!this.editorModel || this.editorModel.isDisposed()) - return resolve() - - const disposable = this.editorModel?.onDidChangeContent(() => { - disposable?.dispose() - - resolve() - }) - }) - - const actionPromise = action.run() - - let didAnyFinish = false - await Promise.race([ - // Wait for the action to finish - Promise.all([editPromise, actionPromise]), - // But don't wait longer than 1.5s, action then likely failed for some weird reason - wait(1500).then(() => { - if (didAnyFinish) return - - this.makeFakeEdit(null) - }), - ]) - didAnyFinish = true - } - - await this.saveFile() - } - protected makeFakeEdit(text: string | null) { - if (!text) { - this.editorInstance.trigger('automatic', 'undo', null) - } else { - this.editorInstance.pushUndoStop() - this.editorInstance?.executeEdits('automatic', [ - { - forceMoveMarkers: false, - range: { - startLineNumber: 1, - endLineNumber: 1, - startColumn: 1, - endColumn: 1, - }, - text, - }, - ]) - this.editorInstance.pushUndoStop() - } - } - protected async saveFile() { - if (this.editorModel && !this.editorModel.isDisposed()) { - App.eventSystem.dispatch('beforeModifiedProject', null) - - const writeWorked = await this.writeFile( - this.editorModel.getValue() - ) - - App.eventSystem.dispatch('modifiedProject', null) - - if (writeWorked) { - this.setIsUnsaved(false) - this.initialVersionId = - this.editorModel.getAlternativeVersionId() - } - } else { - console.error(`Cannot save file content without active editorModel`) - } - } - - setReadOnly(val: TReadOnlyMode) { - this.readOnlyMode = val - this.editorInstance?.updateOptions({ readOnly: val !== 'off' }) - } - - async paste() { - if (this.isReadOnly) return - - this.focus() - this.editorInstance?.trigger('keyboard', 'paste', { - text: await navigator.clipboard.readText(), - }) - } - cut() { - if (this.isReadOnly) return - - this.focus() - document.execCommand('cut') - } - async close() { - const didClose = await super.close() - - // We need to clear the lightning cache store from temporary data if the user doesn't save changes - if (didClose && this.isUnsaved) { - const app = await App.getApp() - - if (this.isForeignFile) { - await app.fileSystem.unlink(this.getPath()) - } else { - const file = await this.fileHandle.getFile() - const fileContent = await file.text() - await app.project.packIndexer.updateFile( - this.getPath(), - fileContent - ) - } - } - - return didClose - } - - showContextMenu(event: MouseEvent) { - this.parent.showCustomMonacoContextMenu(event, this) - } -} diff --git a/src/components/Editors/Text/TextTab.vue b/src/components/Editors/Text/TextTab.vue deleted file mode 100644 index 60ea2195c..000000000 --- a/src/components/Editors/Text/TextTab.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - - - diff --git a/src/components/Editors/ThreePreview/ThreePreviewTab.ts b/src/components/Editors/ThreePreview/ThreePreviewTab.ts deleted file mode 100644 index 5de48e7b5..000000000 --- a/src/components/Editors/ThreePreview/ThreePreviewTab.ts +++ /dev/null @@ -1,145 +0,0 @@ -import ThreePreviewTabComponent from './ThreePreviewTab.vue' -import { IDisposable } from '/@/types/disposable' -import { PreviewTab } from '/@/components/TabSystem/PreviewTab' -import { - AmbientLight, - Color, - PerspectiveCamera, - Scene, - WebGLRenderer, -} from 'three' -import { Signal } from '/@/components/Common/Event/Signal' -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' -import { App } from '/@/App' -import { markRaw } from 'vue' - -export abstract class ThreePreviewTab extends PreviewTab { - public component = ThreePreviewTabComponent - public readonly setupComplete = new Signal() - - protected disposables: IDisposable[] = [] - - protected canvas?: HTMLCanvasElement - protected renderer?: WebGLRenderer - protected _camera?: PerspectiveCamera - protected controls?: OrbitControls - protected _scene?: Scene - protected renderingRequested = false - protected width: number = 0 - protected height: number = 0 - - get scene() { - if (!this._scene) throw new Error(`Scene is not defined yet`) - return this._scene - } - get camera() { - if (!this._camera) throw new Error(`Camera is not defined yet`) - return this._camera - } - - async is() { - return false - } - - async receiveCanvas(canvas: HTMLCanvasElement) { - const app = await App.getApp() - - this.canvas = markRaw(canvas) - - this.renderer = markRaw( - new WebGLRenderer({ - antialias: true, - canvas, - }) - ) - this.renderer.setPixelRatio(window.devicePixelRatio) - - if (!this._camera) { - this._camera = markRaw(new PerspectiveCamera(70, 2, 0.1, 1000)) - this._camera.position.x = -16 - this._camera.position.y = 16 - this._camera.position.z = -16 - } - - this.controls?.dispose() - this.controls = markRaw(new OrbitControls(this.camera, canvas)) - this.controls.addEventListener('change', () => { - this.requestRendering() - if (!this.parent.isActive.value) this.parent.setActive(true) - }) - - if (!this._scene) { - this._scene = markRaw(new Scene()) - this._scene.add(new AmbientLight(0xffffff)) - } - - this._scene.background = new Color( - app.themeManager.getColor('background') - ) - - this.disposables.push( - app.windowResize.on(() => setTimeout(() => this.onResize())) - ) - - this.onResize() - - await this.fired - this.setupComplete.dispatch() - } - async onActivate() { - await this.setupComplete.fired - await super.onActivate() - const app = await App.getApp() - - this.disposables.push( - app.themeManager.on(() => { - const background = app.themeManager.getColor('background') - this.scene.background = new Color(background) - this.requestRendering() - }) - ) - } - onDeactivate() { - this.setupComplete.resetSignal() - this.controls?.dispose() - this.renderer?.resetState() - this.renderer?.dispose() - this.renderer = undefined - this.controls = undefined - super.onDeactivate() - } - - /** - * @internal Do not call directly - */ - protected render() { - this.controls?.update() - this.renderer?.render(this.scene, this.camera) - this.renderingRequested = false - } - - async requestRendering() { - if (this.renderingRequested) return - - this.renderingRequested = true - await this.setupComplete.fired - requestAnimationFrame(() => this.render()) - } - protected onResize() { - const dimensions = this.canvas?.parentElement?.getBoundingClientRect() - this.width = dimensions?.width ?? 0 - this.height = dimensions?.height ?? 0 - - this.renderer?.setSize(this.width, this.height, true) - if (this.camera) { - this.camera.aspect = this.width / this.height - this.camera.updateProjectionMatrix() - } - this.requestRendering() - } - protected async toOtherTabSystem(updateParentTabs?: boolean) { - await super.toOtherTabSystem(updateParentTabs) - await this.setupComplete.fired - this.onResize() - } -} diff --git a/src/components/Editors/ThreePreview/ThreePreviewTab.vue b/src/components/Editors/ThreePreview/ThreePreviewTab.vue deleted file mode 100644 index 633b5e2d5..000000000 --- a/src/components/Editors/ThreePreview/ThreePreviewTab.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/src/components/Editors/TreeEditor/CompletionItems/FilterDuplicates.ts b/src/components/Editors/TreeEditor/CompletionItems/FilterDuplicates.ts deleted file mode 100644 index 17f95cad0..000000000 --- a/src/components/Editors/TreeEditor/CompletionItems/FilterDuplicates.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ICompletionItem } from '/@/components/JSONSchema/Schema/Schema' - -function getItemId(item: ICompletionItem) { - return `t-${item.type}:v-${item.value}` -} - -export function filterDuplicates(items: ICompletionItem[]): ICompletionItem[] { - const seen = new Set() - return items.filter((item) => { - const id = getItemId(item) - - const result = !seen.has(id) - seen.add(id) - - return result - }) -} diff --git a/src/components/Editors/TreeEditor/Highlight.vue b/src/components/Editors/TreeEditor/Highlight.vue deleted file mode 100644 index d7ad870b6..000000000 --- a/src/components/Editors/TreeEditor/Highlight.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - diff --git a/src/components/Editors/TreeEditor/History/CollectedEntry.ts b/src/components/Editors/TreeEditor/History/CollectedEntry.ts deleted file mode 100644 index 1b665fc01..000000000 --- a/src/components/Editors/TreeEditor/History/CollectedEntry.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HistoryEntry } from './HistoryEntry' - -export class CollectedEntry extends HistoryEntry { - constructor(protected entries: HistoryEntry[]) { - super() - } - - get unselectTrees() { - return this.entries.map((entry) => entry.unselectTrees).flat() - } - - undo() { - return new CollectedEntry( - this.entries.reverse().map((entry) => entry.undo()) - ) - } -} diff --git a/src/components/Editors/TreeEditor/History/DeleteEntry.ts b/src/components/Editors/TreeEditor/History/DeleteEntry.ts deleted file mode 100644 index 47a964e5b..000000000 --- a/src/components/Editors/TreeEditor/History/DeleteEntry.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Tree } from '../Tree/Tree' -import { HistoryEntry } from './HistoryEntry' - -export class UndoDeleteEntry extends HistoryEntry { - unselectTrees: Tree[] = [] - - constructor( - protected tree: Tree, - protected index: number, - protected key = '' - ) { - super() - this.unselectTrees = [this.tree] - } - - undo() { - const parent = this.tree.getParent() - - if (!parent) - throw new Error( - `Invalid state: Undo delete action on global tree node` - ) - - if (parent.type === 'array') - parent.children.splice(this.index, 0, this.tree) - else parent.children.splice(this.index, 0, [this.key, this.tree]) - - return new DeleteEntry(this.tree, this.index, this.key) - } -} - -export class DeleteEntry extends HistoryEntry { - unselectTrees: Tree[] = [] - - constructor( - protected tree: Tree, - protected index: number, - protected key = '' - ) { - super() - this.unselectTrees = [this.tree] - } - - undo() { - const parent = this.tree.getParent() - - if (!parent) - throw new Error( - `Invalid state: Redo delete action on global tree node` - ) - - parent.children.splice(this.index, 1) - - return new UndoDeleteEntry(this.tree, this.index, this.key) - } -} diff --git a/src/components/Editors/TreeEditor/History/EditPropertyEntry.ts b/src/components/Editors/TreeEditor/History/EditPropertyEntry.ts deleted file mode 100644 index 0c87ad417..000000000 --- a/src/components/Editors/TreeEditor/History/EditPropertyEntry.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ObjectTree } from '../Tree/ObjectTree' -import { Tree } from '../Tree/Tree' -import { HistoryEntry } from './HistoryEntry' - -export class EditPropertyEntry extends HistoryEntry { - unselectTrees: Tree[] - - constructor( - protected parent: ObjectTree, - protected oldValue: string, - protected newValue: string - ) { - super() - this.unselectTrees = [parent] - } - - undo() { - this.parent.updatePropertyName(this.newValue, this.oldValue) - - return new EditPropertyEntry(this.parent, this.newValue, this.oldValue) - } -} diff --git a/src/components/Editors/TreeEditor/History/EditValueEntry.ts b/src/components/Editors/TreeEditor/History/EditValueEntry.ts deleted file mode 100644 index cda6e1311..000000000 --- a/src/components/Editors/TreeEditor/History/EditValueEntry.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ObjectTree } from '../Tree/ObjectTree' -import { PrimitiveTree } from '../Tree/PrimitiveTree' -import { Tree } from '../Tree/Tree' -import { HistoryEntry } from './HistoryEntry' - -export class EditValueEntry extends HistoryEntry { - unselectTrees: Tree[] - - constructor(protected tree: PrimitiveTree, protected value: any) { - super() - this.unselectTrees = [tree] - } - - undo() { - const oldValue = `${this.tree.value}` - - this.tree.edit(this.value) - - return new EditValueEntry(this.tree, oldValue) - } -} diff --git a/src/components/Editors/TreeEditor/History/EditorHistory.ts b/src/components/Editors/TreeEditor/History/EditorHistory.ts deleted file mode 100644 index 537995fa3..000000000 --- a/src/components/Editors/TreeEditor/History/EditorHistory.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { TreeEditor } from '../TreeEditor' -import { CollectedEntry } from './CollectedEntry' -import type { HistoryEntry } from './HistoryEntry' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' - -/** - * Dispatches an event when the isUnsaved status of the editor changes: - * - * - false > tab contains no changes - * - true > tab is unsaved - */ - -export class EditorHistory extends EventDispatcher { - public readonly changed = new EventDispatcher() - protected undoStack: HistoryEntry[] = [] - protected redoStack: HistoryEntry[] = [] - protected lastUndoLength = 0 - - constructor(protected parent: TreeEditor) { - super() - } - - get length() { - return this.undoStack.length - } - - updateHasChanges() { - this.dispatch(this.undoStack.length !== this.lastUndoLength) - } - saveState() { - this.lastUndoLength = this.undoStack.length - this.updateHasChanges() - } - - undo() { - const entry = this.undoStack.pop() - if (!entry) return - - entry.unselectTrees.forEach((tree) => - this.parent.removeSelectionOf(tree) - ) - - this.redoStack.push(entry.undo()) - - this.updateHasChanges() - this.changed.dispatch() - } - - redo() { - const entry = this.redoStack.pop() - if (!entry) return - - entry.unselectTrees.forEach((tree) => - this.parent.removeSelectionOf(tree) - ) - - this.undoStack.push(entry.undo()) - - this.updateHasChanges() - this.changed.dispatch() - } - - push(entry: HistoryEntry) { - this.undoStack.push(entry) - this.redoStack = [] - - this.updateHasChanges() - this.changed.dispatch() - } - - pushAll(entries: HistoryEntry[]) { - if (entries.length === 0) return - else if (entries.length === 1) this.push(entries[0]) - else this.push(new CollectedEntry(entries)) - } -} diff --git a/src/components/Editors/TreeEditor/History/HistoryEntry.ts b/src/components/Editors/TreeEditor/History/HistoryEntry.ts deleted file mode 100644 index 73058b23f..000000000 --- a/src/components/Editors/TreeEditor/History/HistoryEntry.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Tree } from '../Tree/Tree' - -export abstract class HistoryEntry { - public abstract readonly unselectTrees: Tree[] - abstract undo(): HistoryEntry -} diff --git a/src/components/Editors/TreeEditor/History/MoveEntry.ts b/src/components/Editors/TreeEditor/History/MoveEntry.ts deleted file mode 100644 index fb459ae38..000000000 --- a/src/components/Editors/TreeEditor/History/MoveEntry.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ArrayTree } from '../Tree/ArrayTree' -import { ObjectTree } from '../Tree/ObjectTree' -import { Tree } from '../Tree/Tree' -import { HistoryEntry } from './HistoryEntry' - -export class MoveEntry extends HistoryEntry { - unselectTrees: Tree[] = [] - - constructor( - protected oldParent: ArrayTree | ObjectTree, - protected tree: Tree, - protected index: number, - protected key = '' - ) { - super() - - this.unselectTrees = [this.tree] - } - - undo() { - const parent = this.tree.getParent() - - if (!parent) - throw new Error( - `Invalid state: Undo delete action on global tree node` - ) - - const oldIndex = this.tree.findParentIndex() - this.tree.delete() - - if (this.oldParent.type === 'array') - this.oldParent.children.splice(this.index, 0, this.tree) - else - this.oldParent.children.splice(this.index, 0, [this.key, this.tree]) - - this.tree.setParent(this.oldParent) - - return new MoveEntry(parent, this.tree, oldIndex, this.key) - } -} diff --git a/src/components/Editors/TreeEditor/History/ReplaceTree.ts b/src/components/Editors/TreeEditor/History/ReplaceTree.ts deleted file mode 100644 index 4fb6e02f9..000000000 --- a/src/components/Editors/TreeEditor/History/ReplaceTree.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Tree } from '../Tree/Tree' -import { HistoryEntry } from './HistoryEntry' - -export class ReplaceTreeEntry extends HistoryEntry { - unselectTrees: Tree[] - - constructor( - protected oldTree: Tree, - protected newTree: Tree - ) { - super() - this.unselectTrees = [newTree, oldTree] - } - - undo() { - this.newTree.replace(this.oldTree) - - return new ReplaceTreeEntry(this.newTree, this.oldTree) - } -} diff --git a/src/components/Editors/TreeEditor/InlineDiagnostic.vue b/src/components/Editors/TreeEditor/InlineDiagnostic.vue deleted file mode 100644 index f44ccd519..000000000 --- a/src/components/Editors/TreeEditor/InlineDiagnostic.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/src/components/Editors/TreeEditor/Tab.ts b/src/components/Editors/TreeEditor/Tab.ts deleted file mode 100644 index 0b6fce259..000000000 --- a/src/components/Editors/TreeEditor/Tab.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { FileTab, TReadOnlyMode } from '/@/components/TabSystem/FileTab' -import TreeTabComponent from './Tab.vue' -import { App } from '/@/App' -import { TabSystem } from '/@/components/TabSystem/TabSystem' -import { TreeEditor } from './TreeEditor' -import json5 from 'json5' -import { settingsState } from '/@/components/Windows/Settings/SettingsState' -import { debounce } from 'lodash-es' -import { InformationWindow } from '../../Windows/Common/Information/InformationWindow' -import { TreeValueSelection } from './TreeSelection' -import { PrimitiveTree } from './Tree/PrimitiveTree' -import { AnyFileHandle } from '../../FileSystem/Types' -import { HistoryEntry } from './History/HistoryEntry' - -const throttledCacheUpdate = debounce<(tab: TreeTab) => Promise | void>( - async (tab) => { - const fileContent = tab.treeEditor.toJsonString() - const app = await App.getApp() - - app.project.fileChange.dispatch(tab.getPath(), await tab.getFile()) - - await app.project.packIndexer.updateFile( - tab.getPath(), - fileContent, - tab.isForeignFile, - true - ) - await app.project.jsonDefaults.updateDynamicSchemas(tab.getPath()) - }, - 600 -) - -export class TreeTab extends FileTab { - component = TreeTabComponent - protected _treeEditor?: TreeEditor - - constructor( - parent: TabSystem, - fileHandle: AnyFileHandle, - readOnlyMode?: TReadOnlyMode - ) { - super(parent, fileHandle, readOnlyMode) - - this.fired.then(async () => { - const app = await App.getApp() - await app.projectManager.projectReady.fired - - app.project.tabActionProvider.addTabActions(this) - }) - } - - get app() { - return this.parent.app - } - get project() { - return this.parent.project - } - - static is(fileHandle: AnyFileHandle) { - return ( - settingsState?.editor?.jsonEditor === 'treeEditor' && - fileHandle.name.endsWith('.json') - ) - } - get treeEditor() { - if (!this._treeEditor) - throw new Error(`Trying to access TreeEditor before it was setup.`) - return this._treeEditor - } - async setup() { - let json: unknown - try { - const fileStr = await this.fileHandle - .getFile() - .then((file) => file.text()) - - if (fileStr === '') json = {} - else json = json5.parse(fileStr.replaceAll('\\n', '\\\\n')) - } catch { - new InformationWindow({ - name: 'windows.invalidJson.title', - description: 'windows.invalidJson.description', - }) - this.close() - return - } - - this._treeEditor = new TreeEditor(this, json) - - await super.setup() - } - async getFile() { - return new File([this.treeEditor.toJsonString()], this.name) - } - - updateCache() { - throttledCacheUpdate(this) - } - - async onActivate() { - await super.onActivate() - - this.treeEditor.activate() - } - async onDeactivate() { - await super.onDeactivate() - - this._treeEditor?.deactivate() - } - - loadEditor() {} - setReadOnly(val: TReadOnlyMode) { - this.readOnlyMode = val - } - - async _save() { - this.isTemporary = false - - const fileContent = this.treeEditor.toJsonString(true) - - const writeWorked = await this.writeFile(fileContent) - if (writeWorked) this.treeEditor.saveState() - } - - async paste() { - if (this.isReadOnly) return - - const text = await navigator.clipboard.readText() - - let data: any = undefined - // Try parsing clipboard text - try { - data = json5.parse(text) - } catch { - // Parsing fails, now try again with brackets around text - // -> To support pasting text like this: "minecraft:can_fly": {} - try { - data = json5.parse(`{${text}}`) - } catch { - return - } - } - if (data === undefined) return - - this.treeEditor.addFromJSON(data) - } - - async copy() { - let copyText = '' - - this.treeEditor.forEachSelection((sel) => { - const tree = sel.getTree() - - if (sel instanceof TreeValueSelection) { - if ((tree).isValueSelected) - copyText += tree.toJSON() - else copyText += tree.key - } else { - copyText += `"${tree.key}": ${JSON.stringify( - sel.getTree().toJSON(), - null, - '\t' - )}` - } - }) - - if (copyText !== '') await navigator.clipboard.writeText(copyText) - } - - async cut() { - if (this.isReadOnly) return - - await this.copy() - const entries: HistoryEntry[] = [] - this.treeEditor.forEachSelection((sel) => { - sel.dispose() - const entry = sel.delete() - if (entry) entries.push(entry) - }) - - this.treeEditor.pushAllHistoryEntries(entries) - } - - async close() { - const didClose = await super.close() - - // We need to clear the lightning cache store from temporary data if the user doesn't save changes - if (!this.isForeignFile && didClose && this.isUnsaved) { - // TODO: Well... this looks completely messed up. Look into what's the correct way to fix it. Should foreign files really get unlinked or can we just remove this? - if (this.isForeignFile) { - await this.app.fileSystem.unlink(this.getPath()) - } else { - const file = await this.fileHandle.getFile() - const fileContent = await file.text() - await this.project.packIndexer.updateFile( - this.getPath(), - fileContent - ) - } - } - - return didClose - } -} diff --git a/src/components/Editors/TreeEditor/Tab.vue b/src/components/Editors/TreeEditor/Tab.vue deleted file mode 100644 index 0940ed006..000000000 --- a/src/components/Editors/TreeEditor/Tab.vue +++ /dev/null @@ -1,584 +0,0 @@ - - - - - diff --git a/src/components/Editors/TreeEditor/Tree/ArrayTree.ts b/src/components/Editors/TreeEditor/Tree/ArrayTree.ts deleted file mode 100644 index 3769935c2..000000000 --- a/src/components/Editors/TreeEditor/Tree/ArrayTree.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { createTree } from './createTree' -import { Tree, treeElementHeight } from './Tree' -import ArrayTreeComponent from './CommonTree.vue' -import type { ObjectTree } from './ObjectTree' -import { markRaw } from 'vue' -import { TreeEditor } from '../TreeEditor' - -export class ArrayTree extends Tree> { - public component = markRaw(ArrayTreeComponent) - public _isOpen = false - public readonly type = 'array' - protected _children: Tree[] - - constructor( - parent: ObjectTree | ArrayTree | TreeEditor | null, - protected _value: Array - ) { - super(parent) - this._children = _value.map((val) => createTree(this, val)) - } - - get height() { - if (!this.isOpen) return treeElementHeight - - return ( - 2 * treeElementHeight + - this._children.reduce((prev, val) => prev + val.height, 0) - ) - } - get children() { - return this._children - } - get hasChildren() { - return this._children.length > 0 - } - get isOpen() { - if (!this.hasChildren) return false - return this._isOpen - } - - get(path: (string | number)[]) { - if (path.length === 0) return this - - const currentKey = path.shift() - if (typeof currentKey !== 'number') return null - - const child = this.children[currentKey] - - if (!child) return null - - return child.get(path) - } - - hasChild(child: Tree) { - return this.children.includes(child) - } - addChild(child: Tree) { - this._children.push(child) - } - - setOpen(val: boolean, force = false) { - if (this.hasChildren || force) this._isOpen = val - } - toggleOpen() { - this.setOpen(!this._isOpen) - } - - toJSON() { - return this._children.map((child) => child.toJSON()) - } - updatePropertyName(oldIndex: number, newIndex: number) { - let oldTree = this.children[oldIndex] - let newTree = this.children[newIndex] - this.children[newIndex] = oldTree - delete this.children[oldIndex] - - return { - undo: () => { - this.children[oldIndex] = oldTree - this.children[newIndex] = newTree - }, - } - } - - validate() { - super.validate() - - this.children.forEach((child) => child.requestValidation()) - } - - protected _cachedChildHasDiagnostics: boolean | null = null - get childHasDiagnostics() { - if (this._cachedChildHasDiagnostics !== null) - return this._cachedChildHasDiagnostics - - this._cachedChildHasDiagnostics = this.children.some((child) => { - if (child.type === 'array' || child.type === 'object') - return ( - !!child.highestSeverityDiagnostic || - (child).childHasDiagnostics - ) - return !!child.highestSeverityDiagnostic - }) - - return this._cachedChildHasDiagnostics - } - - clearDiagnosticsCache() { - super.clearDiagnosticsCache() - this._cachedChildHasDiagnostics = null - } -} diff --git a/src/components/Editors/TreeEditor/Tree/CommonTree.vue b/src/components/Editors/TreeEditor/Tree/CommonTree.vue deleted file mode 100644 index 236c1aa50..000000000 --- a/src/components/Editors/TreeEditor/Tree/CommonTree.vue +++ /dev/null @@ -1,207 +0,0 @@ - - - - - diff --git a/src/components/Editors/TreeEditor/Tree/ObjectTree.ts b/src/components/Editors/TreeEditor/Tree/ObjectTree.ts deleted file mode 100644 index e63920f22..000000000 --- a/src/components/Editors/TreeEditor/Tree/ObjectTree.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createTree } from './createTree' -import { Tree, treeElementHeight } from './Tree' -import ObjecTreeComponent from './CommonTree.vue' -import type { ArrayTree } from './ArrayTree' -import { set, del, markRaw } from 'vue' -import { TreeEditor } from '../TreeEditor' - -export class ObjectTree extends Tree { - public component = markRaw(ObjecTreeComponent) - public _isOpen = false - public readonly type = 'object' - protected _children: [string, Tree][] - - constructor( - parent: ObjectTree | ArrayTree | TreeEditor | null, - protected _value: object - ) { - super(parent) - this._children = Object.entries(_value).map(([key, val]) => [ - key, - createTree(this, val), - ]) - } - - get height() { - if (!this.isOpen) return treeElementHeight - - return ( - 2 * treeElementHeight + - this._children.reduce((prev, [_, val]) => prev + val.height, 0) - ) - } - get children() { - return this._children - } - get hasChildren() { - return this._children.length > 0 - } - get isOpen() { - if (!this.hasChildren) return false - return this._isOpen - } - - get(path: (string | number)[]): Tree | null { - if (path.length === 0) return this - - const currentKey = path.shift() - - const [_, child] = - this.children.find(([key]) => key === currentKey) ?? [] - - if (!child) return null - - return child.get(path) - } - - hasChild(child: Tree) { - return this.children.some(([_, currChild]) => currChild === child) - } - addChild(key: string, child: Tree) { - this._children.push([key, child]) - } - - setOpen(val: boolean, force = false) { - if (this.hasChildren || force) this._isOpen = val - } - toggleOpen() { - this.setOpen(!this._isOpen) - } - - toJSON() { - return Object.fromEntries( - this._children.map(([key, val]) => [key, val.toJSON()]) - ) - } - - updatePropertyName(oldName: string, newName: string) { - const oldIndex = this.children.findIndex( - (child) => child[0] === oldName - ) - let [_, oldTree] = this.children[oldIndex] - if (!oldTree) return - - let i = 0 - let namePart1 = newName - let index = this.children.findIndex((child) => child[0] === newName) - if (index !== oldIndex) { - while (index !== -1) { - index = this.children.findIndex((child) => child[0] === newName) - newName = namePart1 + `_${i++}` - } - } - - set(this.children, oldIndex, [newName, oldTree]) - } - - validate() { - super.validate() - - this.children.forEach(([, child]) => child.requestValidation()) - } - - protected _cachedChildHasDiagnostics: boolean | null = null - get childHasDiagnostics() { - if (this._cachedChildHasDiagnostics !== null) - return this._cachedChildHasDiagnostics - - this._cachedChildHasDiagnostics = this.children.some(([, child]) => { - if (child.type === 'array' || child.type === 'object') - return ( - !!child.highestSeverityDiagnostic || - (child).childHasDiagnostics - ) - return !!child.highestSeverityDiagnostic - }) - - return this._cachedChildHasDiagnostics - } - clearDiagnosticsCache() { - super.clearDiagnosticsCache() - this._cachedChildHasDiagnostics = null - } -} diff --git a/src/components/Editors/TreeEditor/Tree/PrimitiveTree.ts b/src/components/Editors/TreeEditor/Tree/PrimitiveTree.ts deleted file mode 100644 index 99695a63a..000000000 --- a/src/components/Editors/TreeEditor/Tree/PrimitiveTree.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Tree, TPrimitiveTree, treeElementHeight } from './Tree' -import PrimitiveTreeComponent from './PrimitiveTree.vue' -import type { ArrayTree } from './ArrayTree' -import type { ObjectTree } from './ObjectTree' -import { markRaw } from 'vue' -import { TreeEditor } from '../TreeEditor' - -export class PrimitiveTree extends Tree { - public component = markRaw(PrimitiveTreeComponent) - public isValueSelected = false - - constructor( - parent: ObjectTree | ArrayTree | TreeEditor | null, - protected _value: TPrimitiveTree - ) { - super(parent) - } - - get type() { - if (this.value === null) return 'null' - return <'string' | 'number' | 'boolean'>typeof this.value - } - get height() { - return treeElementHeight - } - - setValue(value: TPrimitiveTree) { - this._value = value - } - - toJSON() { - return this.value - } - - get(path: (string | number)[]) { - if (path.length === 0) return this - return null - } - - edit(value: string) { - if (value === 'true' || value === 'false') - this.setValue(value === 'true') - else if (!Number.isNaN(Number(value))) this.setValue(Number(value)) - else if (value === 'null') this.setValue(null) - else this.setValue(value) - } - - isEmpty() { - return this.value === '' - } -} diff --git a/src/components/Editors/TreeEditor/Tree/PrimitiveTree.vue b/src/components/Editors/TreeEditor/Tree/PrimitiveTree.vue deleted file mode 100644 index 5d1edb2c8..000000000 --- a/src/components/Editors/TreeEditor/Tree/PrimitiveTree.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - diff --git a/src/components/Editors/TreeEditor/Tree/Tree.ts b/src/components/Editors/TreeEditor/Tree/Tree.ts deleted file mode 100644 index 8b809db64..000000000 --- a/src/components/Editors/TreeEditor/Tree/Tree.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { set } from 'vue' -import { v4 as uuid } from 'uuid' -import type { ArrayTree } from './ArrayTree' -import type { ObjectTree } from './ObjectTree' -import { pointerDevice } from '/@/utils/pointerDevice' -import type { TreeEditor } from '../TreeEditor' -import { IDiagnostic } from '/@/components/JSONSchema/Schema/Schema' -import { whenIdle } from '/@/utils/whenIdle' -export type TPrimitiveTree = string | number | boolean | null -export type TTree = TPrimitiveTree | Object | Array - -export const treeElementHeight = pointerDevice.value === 'mouse' ? 19 : 26 - -export abstract class Tree { - public readonly uuid = uuid() - public abstract readonly component: Vue.Component - protected isSelected: boolean = false - public abstract type: TTree - public abstract height: number - protected abstract _value: T - abstract toJSON(): T - protected parent: ObjectTree | ArrayTree | null = null - protected _treeEditor: TreeEditor | null = null - protected diagnostics: IDiagnostic[] = [] - - constructor(parent: ObjectTree | ArrayTree | TreeEditor | null) { - if (parent?.type === 'treeEditor') { - this.parent = null - this._treeEditor = parent - } else { - this.parent = parent - } - } - - get value() { - return this._value - } - getParent() { - return this.parent - } - setParent(parent: ObjectTree | ArrayTree | null) { - this.parent = parent - } - get treeEditor(): TreeEditor { - if (this._treeEditor) return this._treeEditor - if (!this.parent) throw new Error(`Tree has no parent or treeEditor`) - return this.parent.treeEditor - } - get path(): (string | number)[] { - if (!this.parent) return [] - else return [...this.parent.path, this.key] - } - - abstract get(path: (string | number)[]): Tree | null - - findParentIndex() { - if (!this.parent) - throw new Error(`Cannot findParentIndex for tree without parent`) - - let index: number - - if (this.parent.type === 'array') { - index = this.parent.children.findIndex( - (currentTree) => currentTree === this - ) - } else { - index = this.parent.children.findIndex( - ([_, currentTree]) => currentTree === this - ) - } - - if (index === -1) { - console.log(this) - throw new Error( - `Invalid state: TreeChild with parent couldn't be found inside of parent's children` - ) - } - - return index - } - - get key(): string | number { - if (!this.parent) - throw new Error(`Trees without parent do not have a key`) - - const parentIndex = this.findParentIndex() - - return this.parent.type === 'array' - ? parentIndex - : this.parent.children[parentIndex][0] - } - - setIsSelected(val: boolean) { - this.isSelected = val - } - - replace(tree: Tree) { - if (!this.parent) throw new Error(`Cannot replace tree without parent`) - - const index = this.findParentIndex() - - set( - this.parent.children, - index, - this.parent.type === 'array' ? tree : [this.key, tree] - ) - } - - delete() { - if (!this.parent) throw new Error(`Cannot delete tree without parent`) - - let index: number - if (this.parent.type === 'array') { - index = this.parent.children.findIndex( - (currentTree) => currentTree === this - ) - - if (index === -1) - throw new Error( - `Invalid state: TreeChild with parent couldn't be found inside of parent's children` - ) - } else { - index = this.parent.children.findIndex( - ([_, currentTree]) => currentTree === this - ) - - if (index === -1) - throw new Error( - `Invalid state: TreeChild with parent couldn't be found inside of parent's children` - ) - } - - const [deleted] = this.parent.children.splice(index, 1) - - return [index, Array.isArray(deleted) ? deleted[0] : ''] - } - - validate() { - let hadDiagnostics = this.diagnostics.length > 0 - - this.diagnostics = this.treeEditor - .getSchemas(this) - .map((schema) => schema.validate(this.toJSON())) - .flat() - // Sort by optional priority number - .sort((a, b) => { - if (a.priority === undefined) return 1 - if (b.priority === undefined) return -1 - return a.priority - b.priority - }) - - // There are two cases in which we need to clear the parent's cache: - // 1. We had diagnostics and now we don't - // 2. We didn't have diagnostics and now we do - if ( - (!hadDiagnostics && this.diagnostics.length > 0) || - (hadDiagnostics && this.diagnostics.length === 0) - ) { - this.clearParentDiagnosticsCache() - } else { - // Otherwise, we can just clear our own cache - this.clearDiagnosticsCache() - } - } - requestValidation() { - whenIdle(() => this.validate()) - } - - protected _cachedHighestSeverityDiagnostic: IDiagnostic | undefined | null = - null - get highestSeverityDiagnostic() { - if (this._cachedHighestSeverityDiagnostic !== null) - return this._cachedHighestSeverityDiagnostic - - let highestSeverity: IDiagnostic | undefined = undefined - - for (const diagnostic of this.diagnostics) { - if (diagnostic.severity === 'error') { - this._cachedHighestSeverityDiagnostic = diagnostic - return diagnostic - } else if ( - diagnostic.severity === 'warning' && - (!highestSeverity || highestSeverity.severity === 'info') - ) - highestSeverity = diagnostic - else if (!highestSeverity) highestSeverity = diagnostic - } - - this._cachedHighestSeverityDiagnostic = highestSeverity - return highestSeverity - } - - clearDiagnosticsCache() { - this._cachedHighestSeverityDiagnostic = null - } - clearParentDiagnosticsCache() { - this.clearDiagnosticsCache() - this.parent?.clearParentDiagnosticsCache() - } -} diff --git a/src/components/Editors/TreeEditor/Tree/TreeChildren.vue b/src/components/Editors/TreeEditor/Tree/TreeChildren.vue deleted file mode 100644 index cb33c2c4e..000000000 --- a/src/components/Editors/TreeEditor/Tree/TreeChildren.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - diff --git a/src/components/Editors/TreeEditor/Tree/createTree.ts b/src/components/Editors/TreeEditor/Tree/createTree.ts deleted file mode 100644 index 8efe28c10..000000000 --- a/src/components/Editors/TreeEditor/Tree/createTree.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ObjectTree } from './ObjectTree' -import { Tree } from './Tree' -import { ArrayTree } from './ArrayTree' -import { PrimitiveTree } from './PrimitiveTree' -import { TreeEditor } from '../TreeEditor' - -export function createTree( - parent: ObjectTree | ArrayTree | TreeEditor, - value: unknown -): Tree { - if (value === null) return new PrimitiveTree(parent, null) - else if (Array.isArray(value)) return new ArrayTree(parent, value) - else if (typeof value === 'object') return new ObjectTree(parent, value!) - else if (['string', 'number', 'boolean'].includes(typeof value)) - return new PrimitiveTree(parent, value) - else - throw new Error(`Undefined type handler: "${typeof value}" -> ${value}`) -} diff --git a/src/components/Editors/TreeEditor/TreeEditor.ts b/src/components/Editors/TreeEditor/TreeEditor.ts deleted file mode 100644 index 63a6357e6..000000000 --- a/src/components/Editors/TreeEditor/TreeEditor.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { ActionManager } from '/@/components/Actions/ActionManager' -import { KeyBindingManager } from '/@/components/Actions/KeyBindingManager' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { SchemaManager } from '/@/components/JSONSchema/Manager' -import { RootSchema } from '/@/components/JSONSchema/Schema/Root' -import { - ICompletionItem, - pathWildCard, - TSchemaType, -} from '/@/components/JSONSchema/Schema/Schema' -import { DeleteEntry, UndoDeleteEntry } from './History/DeleteEntry' -import { EditorHistory } from './History/EditorHistory' -import { HistoryEntry } from './History/HistoryEntry' -import { ReplaceTreeEntry } from './History/ReplaceTree' -import { TreeTab } from './Tab' -import { ArrayTree } from './Tree/ArrayTree' -import { createTree } from './Tree/createTree' -import { ObjectTree } from './Tree/ObjectTree' -import { PrimitiveTree } from './Tree/PrimitiveTree' -import type { TPrimitiveTree, Tree } from './Tree/Tree' -import { TreeSelection, TreeValueSelection } from './TreeSelection' -import { App } from '/@/App' -import { debounce } from 'lodash-es' -import { showContextMenu } from '/@/components/ContextMenu/showContextMenu' -import { IActionConfig } from '/@/components/Actions/SimpleAction' -import { viewDocumentation } from '/@/components/Documentation/view' -import { platformRedoBinding } from '/@/utils/constants' -import { getLatestFormatVersion } from '/@/components/Data/FormatVersions' -import { filterDuplicates } from './CompletionItems/FilterDuplicates' -import { inferType } from '/@/utils/inferType' - -export class TreeEditor { - public type = 'treeEditor' - public propertySuggestions: ICompletionItem[] = [] - public valueSuggestions: ICompletionItem[] = [] - public editSuggestions: ICompletionItem[] = [] - - protected tree: Tree - protected selections: (TreeSelection | TreeValueSelection)[] = [] - protected _keyBindings: KeyBindingManager | undefined - protected _actions: ActionManager | undefined - protected history = new EditorHistory(this) - protected schemaRoot?: RootSchema - protected selectionChange = new EventDispatcher() - protected containerElement?: HTMLDivElement - protected scrollTop = 0 - - get keyBindings() { - if (!this._keyBindings) - throw new Error( - `Cannot access keyBindings before they were initialized.` - ) - - return this._keyBindings - } - get actions() { - if (!this._actions) - throw new Error( - `Cannot access keyBindings before they were initialized.` - ) - - return this._actions - } - - constructor(protected parent: TreeTab, protected json: unknown) { - const tree = createTree(this, json) - this.setSelection(tree) - this.tree = tree - - this.history.on((isUnsaved) => { - this.parent.setIsUnsaved(isUnsaved) - }) - this.history.changed.on(() => { - this.propertySuggestions = [] - this.valueSuggestions = [] - - this.parent.updateCache() - this.parent.fileDidChange() - }) - - App.getApp().then(async (app) => { - await app.projectManager.projectReady.fired - - this.parent.once(() => { - if (!app.project.jsonDefaults.isReady) return - - this.createSchemaRoot() - if (this.parent.isSelected) tree.requestValidation() - }) - - app.project.jsonDefaults.on(() => { - if (!this.parent.hasFired) return - - this.createSchemaRoot() - if (this.parent.isSelected) tree.requestValidation() - }) - }) - - this.selectionChange.on(() => this.updateSuggestions()) - } - - updateSuggestions = debounce(async () => { - const currentFormatVersion: string = - (this.tree.toJSON()).format_version || - this.parent.project.config.get().targetVersion || - (await getLatestFormatVersion()) - - const { tree, isValueSelection } = this.getSelectedTree() - - const suggestions = this.getSuggestions(tree) - // console.log(suggestions) - - this.propertySuggestions = filterDuplicates( - suggestions - .filter( - (suggestion) => - ['object', 'array'].includes(suggestion.type) && - !((tree ?? this.tree)).children?.find( - (test: any) => { - if (test.type === 'array') return false - return test[0] === suggestion.value - } - ) - ) - .concat( - this.parent.app.project.snippetLoader - .getSnippetsFor( - currentFormatVersion, - this.parent.getFileType(), - this.selections.map((sel) => sel.getLocation()) - ) - .map( - (snippet) => - { - type: 'snippet', - label: snippet.displayData.name, - value: snippet.insertData, - } - ) - ) - ) - // console.log(this.propertySuggestions) - - // Only suggest values for empty objects, arrays or "empty" primitive trees - // (a primitive tree is empty if it contains an empty string) - if ( - (tree instanceof ObjectTree && tree?.children?.length === 0) || - tree instanceof ArrayTree || - (tree instanceof PrimitiveTree && tree.isEmpty()) - ) { - this.valueSuggestions = filterDuplicates( - suggestions.filter((suggestion) => suggestion.type === 'value') - ) - } else { - this.valueSuggestions = [] - } - - this.editSuggestions = [] - // Support auto-completions for value edits - if (isValueSelection && tree instanceof PrimitiveTree) { - this.editSuggestions = filterDuplicates( - suggestions.filter((suggestion) => suggestion.type === 'value') - ) - } - // Support auto-completions for property edits - if (tree instanceof ObjectTree) { - this.editSuggestions = filterDuplicates( - this.getSuggestions(tree.getParent() ?? undefined).filter( - (suggestion) => suggestion.type === 'object' - ) - ) - } - }, 50) - - createSchemaRoot() { - const schemaUri = App.fileType.get(this.parent.getPath())?.schema - if (schemaUri) - this.schemaRoot = SchemaManager.addRootSchema( - schemaUri, - new RootSchema( - schemaUri, - '$global', - SchemaManager.request(schemaUri) - ) - ) - this.updateSuggestions() - } - - getSchemas(tree: Tree | undefined, next = false) { - if ( - (this.selections.length === 0 && tree === undefined) || - tree === this.tree - ) { - return this.schemaRoot ? [this.schemaRoot] : [] - } else if (tree) { - let path = null - try { - path = next ? [...tree.path, undefined] : tree.path - } catch { - // An error may occur if the tree was deleted while a suggestion update was still scheduled - return [] - } - - return [ - ...new Set( - this.schemaRoot?.getSchemasFor(this.tree.toJSON(), path) ?? - [] - ), - ] - } - - return [] - } - getSuggestions(tree: Tree | undefined) { - const json = tree?.toJSON() - - const schemas = this.getSchemas(tree) - return schemas - .filter((schema) => schema !== undefined) - .map((schema) => schema.getCompletionItems(json)) - .flat() - } - getSchemaTypes(next?: boolean) { - const { tree, isValueSelection } = this.getSelectedTree() - if (isValueSelection) return new Set() - - // We need to skip the items schema property for correct array item types - if (next === undefined) next = tree instanceof ArrayTree - - const schemas = this.getSchemas(tree, next) - if (schemas.length === 0) return new Set() - - return new Set( - schemas.reduce((types, schema) => { - return types.concat(schema.types) - }, []) - ) - } - getSelectedTree() { - if (this.selections.length === 0) - return { tree: this.tree, isValueSelection: false } - - const selection = this.selections[0] - return { - tree: ( - selection?.getTree() - ), - isValueSelection: selection instanceof TreeValueSelection, - } - } - - receiveContainer(container: HTMLDivElement) { - this._keyBindings?.dispose() - this._actions?.dispose() - this._keyBindings = new KeyBindingManager(container) - this._actions = new ActionManager(this._keyBindings) - this.containerElement = container - - this.actions.create({ - keyBinding: ['Ctrl + DELETE', 'Ctrl + BACKSPACE'], - onTrigger: () => { - const entries: HistoryEntry[] = [] - - this.forEachSelection((sel) => { - sel.dispose() - - const entry = sel.delete() - if (entry) entries.push(entry) - }) - - this.history.pushAll(entries) - }, - }) - - this.actions.create({ - keyBinding: ['ESCAPE'], - onTrigger: () => { - this.setSelection(this.tree) - }, - }) - } - deactivate() { - this._keyBindings?.dispose() - this._actions?.dispose() - } - activate() { - if (!this.containerElement) return - this.receiveContainer(this.containerElement) - - this.containerElement.children[0].scrollTop = this.scrollTop - } - - saveState() { - this.history.saveState() - } - - toJSON() { - return this.tree.toJSON() - } - toJsonString(beautify = false) { - return JSON.stringify( - this.toJSON(), - null, - beautify ? '\t' : undefined - ).replaceAll('\\\\', '\\') - } - - forEachSelection( - cb: (selection: TreeSelection | TreeValueSelection) => void - ) { - this.selections.forEach(cb) - } - - removeSelection(selection: TreeSelection | TreeValueSelection) { - this.selections = this.selections.filter((sel) => selection !== sel) - this.selectionChange.dispatch() - } - removeSelectionOf(tree: Tree) { - this.selections = this.selections.filter((sel) => { - const currTree = sel.getTree() - if ( - currTree !== tree && - (tree instanceof PrimitiveTree || - !(tree).hasChild(currTree)) - ) - return true - - sel.dispose(false) - return false - }) - this.selectionChange.dispatch() - } - - setSelection(tree: Tree, selectPrimitiveValue = false) { - this.selections.forEach((selection) => selection.dispose(false)) - this.selections = [ - selectPrimitiveValue && tree instanceof PrimitiveTree - ? new TreeValueSelection(this, tree) - : new TreeSelection(this, tree), - ] - this.selectionChange.dispatch() - } - toggleSelection(tree: Tree, selectPrimitiveValue = false) { - let didRemoveSelection = false - this.selections = this.selections.filter((selection) => { - if ( - selection.getTree() !== tree || - selection instanceof TreeValueSelection !== selectPrimitiveValue - ) - return true - - selection.dispose(false) - didRemoveSelection = true - return false - }) - - if (!didRemoveSelection) - this.selections.push( - selectPrimitiveValue && tree instanceof PrimitiveTree - ? new TreeValueSelection(this, tree) - : new TreeSelection(this, tree) - ) - this.selectionChange.dispatch() - } - - addKey(value: string, type: 'array' | 'object') { - const entries: HistoryEntry[] = [] - - this.forEachSelection((selection) => { - if (selection instanceof TreeValueSelection) return - - const entry = selection.addKey(value, type) - if (entry) entries.push(entry) - }) - - this.history.pushAll(entries) - } - - addValue( - value: string, - type: 'value', - forcedValueType?: 'number' | 'string' | 'null' | 'boolean' | 'integer' - ) { - let transformedValue: TPrimitiveTree = inferType(value) - - // Force values for bridge. prediction schema type hints - if (forcedValueType) { - if (forcedValueType === 'string') transformedValue = value - else if (forcedValueType === 'integer') - transformedValue = parseInt(value) - else if (forcedValueType === 'number') - transformedValue = parseFloat(value) - else if (forcedValueType === 'boolean') - transformedValue = value === 'true' - else if (forcedValueType === 'null') transformedValue = null - } - - const entries: HistoryEntry[] = [] - - this.forEachSelection((selection) => { - if (selection instanceof TreeValueSelection) return - - const entry = selection.addValue(transformedValue, type) - if (entry) entries.push(entry) - }) - - this.history.pushAll(entries) - } - addFromJSON(json: any) { - if (typeof json !== 'object') return - - let entries: HistoryEntry[] = [] - - this.forEachSelection((sel) => { - if (sel instanceof TreeValueSelection) return - const parentTree = sel.getTree() - if (parentTree instanceof PrimitiveTree) return - - const index = parentTree.children.length - - for (const key in json) { - const newTree = createTree(parentTree, json[key]) - if (parentTree instanceof ObjectTree) - parentTree.addChild(key, newTree) - else parentTree.addChild(newTree) - - entries.push( - new DeleteEntry( - newTree, - index, - parentTree instanceof ObjectTree ? key : undefined - ) - ) - } - }) - - this.history.pushAll(entries) - } - - edit(value: string) { - const historyEntries: HistoryEntry[] = [] - - this.forEachSelection((selection) => { - const entry = selection.edit(value) - if (entry) historyEntries.push(entry) - }) - - this.history.pushAll(historyEntries) - } - - delete(tree: Tree) { - this.removeSelectionOf(tree) - - const [index, key] = tree.delete() - - this.history.push(new UndoDeleteEntry(tree, index, key)) - } - /** - * This delete action on a primitive value replaces the PrimitiveTree with an emtpy ObjectTree - * @param tree - */ - objectValueDeletion(tree: PrimitiveTree) { - this.removeSelectionOf(tree) - const newTree = new ObjectTree(tree.getParent(), {}) - - tree.replace(newTree) - - this.history.push(new ReplaceTreeEntry(tree, newTree)) - } - - /** - * Get description and title for a given tree - * @param tree - */ - getDocumentation(tree: Tree) { - const schemas = ( - this.getSchemas(tree).filter( - (schema) => schema instanceof RootSchema - ) - ) - - if (schemas.length === 0) return - - const title = - schemas.find((schema) => schema.title !== undefined)?.title ?? '' - const description = - schemas.filter((schema) => schema.description !== undefined)?.[0] - ?.description ?? '' - - return { title, text: description } - } - - pushHistoryEntry(entry: HistoryEntry) { - this.history.push(entry) - } - pushAllHistoryEntries(entries: HistoryEntry[]) { - this.history.pushAll(entries) - } - - onPasteMenu(event?: MouseEvent, tree = this.tree) { - const pasteMenu = [ - { - name: 'actions.paste.name', - icon: 'mdi-content-paste', - onTrigger: () => { - this.setSelection(tree) - this.parent.paste() - }, - }, - ] - - if (event && !this.parent.isReadOnly) - showContextMenu(event, pasteMenu, { - card: this.getDocumentation(tree), - }) - - return pasteMenu - } - - onReadOnlyMenu(event?: MouseEvent, tree = this.tree, selectedKey = true) { - const readOnlyMenu = [ - { - name: 'actions.documentationLookup.name', - icon: 'mdi-book-open-outline', - onTrigger: () => { - const word = selectedKey ? tree.key : tree.value - - if (typeof word === 'string') - viewDocumentation(this.parent.getPath(), word) - }, - }, - { - name: 'actions.copy.name', - icon: 'mdi-content-copy', - onTrigger: () => { - this.setSelection(tree, !selectedKey) - this.parent.copy() - }, - }, - ] - - if (event) - showContextMenu(event, readOnlyMenu, { - card: this.getDocumentation(tree), - }) - - return readOnlyMenu - } - - onContextMenu( - event: MouseEvent, - tree: PrimitiveTree | ArrayTree | ObjectTree, - selectedKey = true - ) { - if (this.parent.isReadOnly) - return this.onReadOnlyMenu(event, tree, selectedKey) - const [viewDocs, copy] = this.onReadOnlyMenu( - undefined, - tree, - selectedKey - ) - - const contextMenu: (IActionConfig | null)[] = [viewDocs] - // Delete node - if (tree instanceof PrimitiveTree) - contextMenu.push({ - name: 'general.delete', - icon: 'mdi-delete-outline', - onTrigger: () => { - if (tree.getParent()!.type === 'object' && !selectedKey) - this.objectValueDeletion(tree) - else this.delete(tree) - }, - }) - else - contextMenu.push({ - name: 'general.delete', - icon: 'mdi-delete-outline', - onTrigger: () => { - this.delete(tree) - }, - }) - - // Copy, cut & paste - contextMenu.push( - copy, - { - name: 'actions.cut.name', - icon: 'mdi-content-cut', - onTrigger: () => { - this.setSelection(tree, !selectedKey) - this.parent.cut() - }, - }, - ...(tree instanceof PrimitiveTree - ? [] - : this.onPasteMenu(undefined, tree)) - ) - - // Object -> array and vice versa - if (!(tree instanceof PrimitiveTree) && tree.children.length === 0) { - contextMenu.push({ - name: - tree instanceof ArrayTree - ? 'actions.toObject.name' - : 'actions.toArray.name', - icon: - tree instanceof ArrayTree - ? 'mdi-code-braces-box' - : 'mdi-code-array', - onTrigger: () => { - this.removeSelectionOf(tree) - - if (tree instanceof ArrayTree) { - const newTree = new ObjectTree(tree.getParent(), {}) - tree.replace(newTree) - this.history.push(new ReplaceTreeEntry(tree, newTree)) - } else { - const newTree = new ArrayTree(tree.getParent(), []) - tree.replace(newTree) - this.history.push(new ReplaceTreeEntry(tree, newTree)) - } - }, - }) - } - - showContextMenu(event, contextMenu, { - card: this.getDocumentation(tree), - mayCloseOnClickOutside: true, - }) - } - undo() { - this.history.undo() - } - redo() { - this.history.redo() - } -} diff --git a/src/components/Editors/TreeEditor/TreeSelection.ts b/src/components/Editors/TreeEditor/TreeSelection.ts deleted file mode 100644 index e92df47de..000000000 --- a/src/components/Editors/TreeEditor/TreeSelection.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { CollectedEntry } from './History/CollectedEntry' -import { DeleteEntry, UndoDeleteEntry } from './History/DeleteEntry' -import { EditPropertyEntry } from './History/EditPropertyEntry' -import { EditValueEntry } from './History/EditValueEntry' -import type { HistoryEntry } from './History/HistoryEntry' -import { ReplaceTreeEntry } from './History/ReplaceTree' -import { ArrayTree } from './Tree/ArrayTree' -import { ObjectTree } from './Tree/ObjectTree' -import { PrimitiveTree } from './Tree/PrimitiveTree' -import type { TPrimitiveTree } from './Tree/Tree' -import type { TreeEditor } from './TreeEditor' - -export class TreeSelection { - constructor( - protected parent: TreeEditor, - protected tree: ArrayTree | ObjectTree | PrimitiveTree - ) { - tree.setIsSelected(true) - } - - getTree() { - return this.tree - } - getLocation() { - return this.tree.path.join('/') - } - - select(tree: ArrayTree | ObjectTree) { - this.tree.setIsSelected(false) - this.tree = tree - this.tree.setIsSelected(true) - } - - dispose(removeSel = true) { - this.tree.setIsSelected(false) - if (removeSel) this.parent.removeSelection(this) - } - - edit(value: string) { - const parent = this.tree.getParent() - if (!parent) throw new Error(`Cannot edit property name without parent`) - if (parent instanceof ArrayTree) - throw new Error(`Cannot edit array indices`) - - const key = this.tree.key - // The tree key must be of type string because of the instanceof check above - parent.updatePropertyName(key, value) - - return new EditPropertyEntry(parent, key, value) - } - - addKey(key: string, type: 'array' | 'object') { - if (this.tree instanceof PrimitiveTree) return - - const index = this.tree.children.length - const historyEntries: HistoryEntry[] = [] - - this.tree.setOpen(true, true) - - let addToTree = this.tree - const newTree = - type === 'object' - ? new ObjectTree(addToTree, {}) - : new ArrayTree(addToTree, []) - - if (this.tree instanceof ArrayTree) { - // Pushing a key to an array should add it inside of an object - addToTree = new ObjectTree(this.tree, {}) - // Make sure to update parent reference before adding tree as child - newTree.setParent(addToTree) - - this.tree.addChild(addToTree) - addToTree.setOpen(true, true) - } - - addToTree.addChild(key, newTree) - this.parent.setSelection(newTree) - - this.tree.setOpen(true, true) - newTree.setOpen(true, true) - - if (this.tree instanceof ArrayTree) { - historyEntries.push(new DeleteEntry(addToTree, index)) - } else { - historyEntries.push(new DeleteEntry(newTree, index, key)) - } - - return new CollectedEntry(historyEntries) - } - - addValue(value: TPrimitiveTree, type: 'value') { - const historyEntries: HistoryEntry[] = [] - if (this.tree instanceof PrimitiveTree && !this.tree.isEmpty()) return - - if (this.tree.type === 'array') { - // Push primitive trees into array trees - const newTree = new PrimitiveTree(this.tree, value) - - this.tree.children.push(newTree) - this.parent.setSelection(this.tree) - - historyEntries.push( - new DeleteEntry(newTree, this.tree.children.length - 1) - ) - - this.tree.setOpen(true) - } else if ( - this.tree instanceof PrimitiveTree || - this.tree.children.length === 0 - ) { - // Otherwise only add value to empty objects - const newTree = new PrimitiveTree(this.tree.getParent(), value) - - this.tree.replace(newTree) - this.parent.setSelection(newTree.getParent()!) - this.dispose() - - historyEntries.push(new ReplaceTreeEntry(this.tree, newTree)) - } - - return new CollectedEntry(historyEntries) - } - - delete() { - const parent = this.tree.getParent() - if (!parent) return - - this.parent.toggleSelection(parent) - - const [index, key] = this.tree.delete() - - return new UndoDeleteEntry(this.tree, index, key) - } -} - -export class TreeValueSelection { - constructor(protected parent: TreeEditor, protected tree: PrimitiveTree) { - tree.isValueSelected = true - } - - getTree() { - return this.tree - } - getLocation() { - return this.tree.path.join('/') - } - - dispose(removeSel = true) { - this.tree.isValueSelected = false - if (removeSel) this.parent.removeSelection(this) - } - - edit(value: string) { - const oldValue = `${this.tree.value}` - - this.tree.edit(value) - - return new EditValueEntry(this.tree, oldValue) - } - - delete() { - const parent = this.tree.getParent() - if (!parent) return - - if (parent.type === 'object') { - // A delete action on a primitive value replaces the PrimitiveTree with an emtpy ObjectTree - const newTree = new ObjectTree(parent, {}) - this.parent.setSelection(newTree) - - this.tree.replace(newTree) - - return new ReplaceTreeEntry(this.tree, newTree) - } else { - this.parent.toggleSelection(parent) - - const [index, key] = this.tree.delete() - - return new UndoDeleteEntry(this.tree, index, key) - } - } -} diff --git a/src/components/Editors/TreeEditor/mayCastTo.ts b/src/components/Editors/TreeEditor/mayCastTo.ts deleted file mode 100644 index e7c05f090..000000000 --- a/src/components/Editors/TreeEditor/mayCastTo.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const mayCastTo = { - string: [], - number: ['string'], - boolean: ['string'], - null: ['string'], - integer: ['string', 'number'], -} diff --git a/src/components/Extensions/ActiveStatus.ts b/src/components/Extensions/ActiveStatus.ts deleted file mode 100644 index b3f124442..000000000 --- a/src/components/Extensions/ActiveStatus.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Signal } from '../Common/Event/Signal' -import { FileSystem } from '../FileSystem/FileSystem' - -export class ActiveStatus extends Signal { - protected inactiveExtensions = new Set() - constructor(protected fileSystem: FileSystem, protected savePath: string) { - super() - - this.loadFile().finally(() => this.dispatch()) - } - - protected async loadFile() { - try { - this.inactiveExtensions = new Set( - await this.fileSystem.readJSON(this.savePath) - ) - } catch {} - } - - protected async save() { - await this.fileSystem.writeJSON(this.savePath, [ - ...this.inactiveExtensions, - ]) - } - - async setActive(id: string, value: boolean) { - if (value) { - this.inactiveExtensions.delete(id) - } else { - this.inactiveExtensions.add(id) - } - - await this.save() - } - - isActive(id: string) { - return !this.inactiveExtensions.has(id) - } -} diff --git a/src/components/Extensions/Extension.ts b/src/components/Extensions/Extension.ts deleted file mode 100644 index 38cfcc1ea..000000000 --- a/src/components/Extensions/Extension.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { IDisposable } from '/@/types/disposable' -import { FileSystem } from '../FileSystem/FileSystem' -import { createErrorNotification } from '/@/components/Notifications/Errors' -import { ExtensionLoader, IExtensionManifest } from './ExtensionLoader' -import { loadUIComponents } from './UI/load' -import { createUIStore } from './UI/store' -import { App } from '/@/App' -import { loadScripts } from './Scripts/loadScripts' -import { ExtensionViewer } from '../Windows/ExtensionStore/ExtensionViewer' -import { ExtensionStoreWindow } from '../Windows/ExtensionStore/ExtensionStore' -import { iterateDir } from '/@/utils/iterateDir' -import { loadFileDefinitions } from './FileDefinition/load' -import { InstallFiles } from './InstallFiles' -import { AnyDirectoryHandle } from '../FileSystem/Types' -import { idbExtensionStore } from './Scripts/Modules/persistentStorage' -import { compareVersions } from 'bridge-common-utils' -import { version as appVersion } from '/@/utils/app/version' -import { JsRuntime } from './Scripts/JsRuntime' -import { createEnv } from './Scripts/require' -import { UIModule } from './Scripts/Modules/ui' - -export class Extension { - protected disposables: IDisposable[] = [] - protected uiStore = createUIStore() - protected fileSystem: FileSystem - protected _compilerPlugins: Record = {} - protected isLoaded = false - protected installFiles: InstallFiles - protected hasPresets = false - public jsRuntime: JsRuntime - - get isActive() { - if (!this.parent.activeStatus) - throw new Error( - `Accessed activeStatus property before the corresponding ActiveClass instance was initialized` - ) - - return this.parent.activeStatus?.isActive(this.manifest.id) - } - get compilerPlugins() { - return this._compilerPlugins - } - get contributesCompilerPlugins() { - return Object.keys(this.manifest?.compiler?.plugins ?? {}).length > 0 - } - get description() { - return this.manifest.description - } - get version() { - return this.manifest.version - } - get isGlobal() { - return this._isGlobal - } - get id() { - return this.manifest.id - } - get manifest() { - return this._manifest - } - - constructor( - protected parent: ExtensionLoader, - protected _manifest: IExtensionManifest, - protected baseDirectory: AnyDirectoryHandle, - protected _isGlobal = false - ) { - this.fileSystem = new FileSystem(this.baseDirectory) - this.installFiles = new InstallFiles( - this.fileSystem, - _manifest?.contributeFiles ?? {} - ) - - this.jsRuntime = new JsRuntime( - createEnv(this.id, this.disposables, this.uiStore, this.isGlobal) - ) - } - - isCompatibleAppEnv() { - if (!this.manifest.compatibleAppVersions) return true - - return ( - ((this.manifest.compatibleAppVersions.min && - compareVersions( - appVersion, - this.manifest.compatibleAppVersions.min, - '>=' - )) || - !this.manifest.compatibleAppVersions.min) && - ((this.manifest.compatibleAppVersions.max && - compareVersions( - appVersion, - this.manifest.compatibleAppVersions.max, - '<' - )) || - !this.manifest.compatibleAppVersions.max) - ) - } - - async activate() { - /** - * Make sure we load an extension only once - * and that the bridge. app version is compatible - */ - if (this.isLoaded || !this.isCompatibleAppEnv()) return - - this.isLoaded = true - const app = await App.getApp() - const pluginPath = ( - (await app.fileSystem.baseDirectory.resolve( - this.baseDirectory - )) ?? [] - ).join('/') - - // Activate all dependencies before - for (const dep of this.manifest.dependencies ?? []) { - if (!(await this.parent.activate(dep))) { - createErrorNotification( - new Error( - `Failed to activate extension "${this.manifest.name}": Failed to load one of its dependencies` - ) - ) - return - } - } - - // Disable global extension with same ID if such an extension exists - if (!this.isGlobal) { - const globalExtensions = App.instance.extensionLoader - - if (globalExtensions.has(this.id)) - globalExtensions.deactivate(this.id) - } - - // Compiler plugins - for (const [pluginId, compilerPlugin] of Object.entries( - this.manifest.compiler?.plugins ?? {} - )) { - this._compilerPlugins[pluginId] = `${pluginPath}/${compilerPlugin}` - } - - // If the extension has a presets.json file, add this file to the preset store - if (await this.fileSystem.fileExists('presets.json')) { - this.hasPresets = true - App.eventSystem.dispatch('presetsChanged', null) - this.disposables.push( - app.windows.createPreset.addPresets( - `${pluginPath}/presets.json` - ) - ) - } - // Otherwise if the extension has a presets folder, add this folder to the preset store - else if (await this.fileSystem.directoryExists('presets')) { - this.hasPresets = true - this.disposables.push( - app.windows.createPreset.addPresets(`${pluginPath}/presets`) - ) - - App.eventSystem.dispatch('presetsChanged', null) - } - - try { - await iterateDir( - await this.baseDirectory.getDirectoryHandle('themes'), - (fileHandle) => - app.themeManager.loadTheme( - fileHandle, - this.isGlobal, - this.disposables - ) - ) - } catch {} - - try { - await loadFileDefinitions( - await this.baseDirectory.getDirectoryHandle('fileDefinitions'), - this.disposables - ) - } catch {} - - let scriptHandle: AnyDirectoryHandle | null = null - try { - scriptHandle = await this.baseDirectory.getDirectoryHandle( - 'scripts' - ) - } catch {} - - let uiHandle: AnyDirectoryHandle | null = null - try { - uiHandle = await this.baseDirectory.getDirectoryHandle('ui') - } catch {} - - if (uiHandle) - await loadUIComponents( - this.jsRuntime, - uiHandle, - this.uiStore, - this.disposables - ) - - if (scriptHandle) await loadScripts(this.jsRuntime, scriptHandle) - - // Loading snippets - if (await this.fileSystem.directoryExists('snippets')) { - const snippetDir = await this.baseDirectory.getDirectoryHandle( - 'snippets' - ) - - if (this.isGlobal) { - this.disposables.push( - app.projectManager.onActiveProject(async (project) => { - this.disposables.push( - ...(await project.snippetLoader.loadFrom( - snippetDir - )) - ) - }) - ) - } else { - this.disposables.push( - ...(await app.project.snippetLoader.loadFrom(snippetDir)) - ) - } - } - - if (await this.fileSystem.fileExists('.installed')) return - - await this.installFiles.execute(this.isGlobal) - await this.fileSystem.writeFile('.installed', '') - } - - async resetInstalled() { - await this.fileSystem.unlink('.installed') - } - - deactivate() { - if (this.hasPresets) App.eventSystem.dispatch('presetsChanged', null) - this.disposables.forEach((disposable) => disposable.dispose()) - this.jsRuntime.clearCache() - this.isLoaded = false - - // Enable global extension with same ID if such an extension exists - if (!this.isGlobal) { - const globalExtensions = App.instance.extensionLoader - - if (globalExtensions.has(this.id)) - globalExtensions.activate(this.id) - } - } - - async delete() { - this.deactivate() - await idbExtensionStore.del(this.id) - this.parent.deleteExtension(this.manifest.id) - } - - async setActive(value: boolean) { - if (value) await this.activate() - else this.deactivate() - - await this.parent.activeStatus?.setActive(this.manifest.id, value) - } - - forStore(extensionStore: ExtensionStoreWindow) { - const viewer = new ExtensionViewer(extensionStore, this.manifest, true) - viewer.setInstalled() - viewer.setConnected(this) - return viewer - } - - async installFilesToCurrentProject() { - await this.installFiles.execute(false) - } -} diff --git a/src/components/Extensions/ExtensionLoader.ts b/src/components/Extensions/ExtensionLoader.ts deleted file mode 100644 index f1e99a64b..000000000 --- a/src/components/Extensions/ExtensionLoader.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { Signal } from '/@/components/Common/Event/Signal' -import { unzip, Unzipped } from 'fflate' -import { FileSystem } from '../FileSystem/FileSystem' -import { createErrorNotification } from '../Notifications/Errors' -import { Extension } from './Extension' -import { ActiveStatus } from './ActiveStatus' -import { - AnyDirectoryHandle, - AnyFileHandle, - AnyHandle, -} from '../FileSystem/Types' -import { TPackTypeId } from '../Data/PackType' -import { emitWarning } from '../Notifications/warn' -import { translate } from '../Locales/Manager' - -export interface IExtensionManifest { - icon?: string - author: string - name: string - releaseTimestamp: number - version: string - id: string - description: string - link: string - readme: string - tags: string[] - dependencies: string[] - compiler: { - plugins: Record - } - contributeFiles: Record - compatibleAppVersions?: { - min?: string - max?: string - } -} - -export class ExtensionLoader extends Signal { - protected _extensions = new Map() - protected extensionPaths = new Map() - protected _loadedInstalledExtensions = new Signal(2) - public activeStatus: ActiveStatus | undefined - - get extensions() { - return this._extensions - } - - constructor( - protected fileSystem: FileSystem, - protected loadExtensionsFrom: string, - protected saveInactivePath: string, - protected isGlobal = false - ) { - super() - } - - async loadActiveExtensions() { - this.activeStatus = new ActiveStatus( - this.fileSystem, - this.saveInactivePath - ) - await this.activeStatus.fired - } - - async getInstalledExtensions() { - await this.fired - return new Set(this.extensions.values()) - } - - async loadExtensions(path: string = this.loadExtensionsFrom) { - const baseDirectory = await this.fileSystem.getDirectoryHandle(path, { - create: true, - }) - - await this.loadActiveExtensions() - const promises: Promise[] = [] - - for await (const entry of baseDirectory.values()) { - if (entry.name === '.DS_Store') continue - - promises.push(this.loadExtension(baseDirectory, entry, false)) - } - - await Promise.all(promises) - this._loadedInstalledExtensions.dispatch() - - for (const [_, extension] of this._extensions) { - if (extension.isActive) await extension.activate() - } - - this.dispatch() - } - - async loadExtension( - baseDirectory: AnyDirectoryHandle, - handle: AnyHandle, - activate = false - ) { - let extension: Extension | undefined - if (handle.kind === 'file' && handle.name.endsWith('.zip')) { - extension = await this.unzipExtension( - handle, - await baseDirectory.getDirectoryHandle( - handle.name.replace('.zip', ''), - { create: true } - ) - ) - await baseDirectory.removeEntry(handle.name) - } else if (handle.kind === 'directory') { - extension = await this.loadManifest(handle) - } - - if (activate && extension) await extension.activate() - return extension - } - - protected async unzipExtension( - fileHandle: AnyFileHandle, - baseDirectory: AnyDirectoryHandle - ) { - const fs = new FileSystem(baseDirectory) - const file = await fileHandle.getFile() - const zip = await new Promise(async (resolve, reject) => - unzip(new Uint8Array(await file.arrayBuffer()), (err, data) => { - if (err) reject(err) - else resolve(data) - }) - ) - - for (const fileName in zip) { - if (fileName.startsWith('.')) continue - - if (fileName.endsWith('/')) { - await fs.mkdir(fileName.slice(0, -1), { - recursive: true, - }) - - continue - } - - await fs.writeFile(fileName, zip[fileName]) - } - - return await this.loadManifest(baseDirectory) - } - - protected async loadManifest(baseDirectory: AnyDirectoryHandle) { - let manifestHandle: AnyFileHandle - try { - manifestHandle = await baseDirectory.getFileHandle('manifest.json') - } catch { - return - } - - const manifestFile = await manifestHandle.getFile() - const manifest: Partial | null = await manifestFile - .text() - .then((str) => JSON.parse(str)) - .catch(() => { - // Invalid JSON - // Show message informing that an extension failed to load - emitWarning( - `${baseDirectory.name} ${translate( - 'windows.extensionStore.failedExtensionLoad.title' - )}`, - `[${translate( - 'windows.extensionStore.failedExtensionLoad.description1' - )} "${baseDirectory.name}/manifest.json" ${translate( - 'windows.extensionStore.failedExtensionLoad.description2' - )}]` - ) - - // Return null to indicate that the extension failed to load - return null - }) - - if (manifest === null) return - - if (manifest.id) { - const extension = new Extension( - this, - manifest, - baseDirectory, - this.isGlobal - ) - - this._extensions.set(manifest.id, extension) - this.extensionPaths.set( - manifest.id, - `${this.loadExtensionsFrom}/${baseDirectory.name}` - ) - return extension - } else { - createErrorNotification( - new Error( - `Failed to load extension "${ - manifest.name ?? baseDirectory.name - }": Invalid extension ID` - ) - ) - } - } - - async activate(id: string) { - const extension = this.extensions.get(id) - - if (extension) { - if (extension.isActive) await extension.activate() - - return true - } else { - createErrorNotification( - new Error( - `Failed to activate extension with ID "${id}": Extension not found` - ) - ) - return false - } - } - - deactivate(id: string) { - const extension = this.extensions.get(id) - - if (extension) { - extension.deactivate() - return true - } else { - createErrorNotification( - new Error( - `Failed to deactivate extension with ID "${id}": Extension not found` - ) - ) - return false - } - } - has(id: string) { - return this.extensions.has(id) - } - deactiveAll(dispose = false) { - for (const [key, ext] of this._extensions) { - ext.deactivate() - if (dispose) this._extensions.delete(key) - } - } - disposeAll() { - this.deactiveAll(true) - this.resetSignal() - } - deleteExtension(id: string) { - const extensionPath = this.extensionPaths.get(id) - - if (extensionPath) { - this.fileSystem.unlink(extensionPath) - this.extensionPaths.delete(id) - this.extensions.delete(id) - } - } - - mapActive(cb: (ext: Extension) => T) { - const res: T[] = [] - - for (const ext of this._extensions.values()) { - if (ext.isActive) res.push(cb(ext)) - } - - return res - } -} diff --git a/src/components/Extensions/FileDefinition/load.ts b/src/components/Extensions/FileDefinition/load.ts deleted file mode 100644 index 2f7c7c987..000000000 --- a/src/components/Extensions/FileDefinition/load.ts +++ /dev/null @@ -1,17 +0,0 @@ -import json5 from 'json5' -import { AnyDirectoryHandle } from '../../FileSystem/Types' -import { App } from '/@/App' -import { IDisposable } from '/@/types/disposable' -import { iterateDir } from '/@/utils/iterateDir' - -export function loadFileDefinitions( - baseDirectory: AnyDirectoryHandle, - disposables: IDisposable[] -) { - return iterateDir(baseDirectory, async (fileHandle) => { - const file = await fileHandle.getFile() - const fileDefinition = json5.parse(await file.text()) - - disposables.push(App.fileType.addPluginFileType(fileDefinition)) - }) -} diff --git a/src/components/Extensions/GlobalExtensionLoader.ts b/src/components/Extensions/GlobalExtensionLoader.ts deleted file mode 100644 index 73da3b5a9..000000000 --- a/src/components/Extensions/GlobalExtensionLoader.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Extension } from './Extension' -import { ExtensionLoader } from './ExtensionLoader' -import { App } from '/@/App' - -export class GlobalExtensionLoader extends ExtensionLoader { - constructor(protected app: App) { - super( - app.fileSystem, - '~local/extensions', - 'data/inactiveExtensions.json', - true - ) - } - - async getInstalledExtensions() { - await this.app.projectManager.projectReady.fired - - return new Set([ - ...(await super.getInstalledExtensions()).values(), - ...( - await this.app.project.extensionLoader.getInstalledExtensions() - ).values(), - ]) - } - - mapActive(cb: (ext: Extension) => T) { - const res: T[] = this.app.project.extensionLoader.mapActive(cb) - - for (const ext of this._extensions.values()) { - if (ext.isActive) res.push(cb(ext)) - } - - return res - } - - installFilesToCurrentProject() { - return Promise.all( - [...this.extensions.values()].map((ext) => - ext.installFilesToCurrentProject() - ) - ) - } - - async reload() { - this.disposeAll() - this.loadExtensions() - } -} diff --git a/src/components/Extensions/InstallFiles.ts b/src/components/Extensions/InstallFiles.ts deleted file mode 100644 index 0be0735b0..000000000 --- a/src/components/Extensions/InstallFiles.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { TPackTypeId } from '../Data/PackType' -import { App } from '/@/App' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { iterateDir, iterateDirParallel } from '/@/utils/iterateDir' -import { join } from '/@/utils/path' - -export class InstallFiles { - constructor( - protected fileSystem: FileSystem, - protected contributeFiles: Record< - string, - { pack: TPackTypeId; path: string } - > - ) {} - - async execute(isGlobal: boolean) { - const app = App.instance - - await app.projectManager.projectReady.fired - - const promises = [] - - for (const [from, to] of Object.entries(this.contributeFiles)) { - const projects = isGlobal ? app.projects : [app.project] - - if (await this.fileSystem.fileExists(from)) { - // Handle file contributions - const file = await this.fileSystem.readFile(from) - - for (const project of projects) { - const target = project.config.resolvePackPath( - to.pack, - to.path - ) - promises.push( - app.fileSystem.writeFile( - target, - await file.arrayBuffer() - ) - ) - } - } else if (await this.fileSystem.directoryExists(from)) { - // Handle directory contributions - promises.push( - iterateDirParallel( - await this.fileSystem.getDirectoryHandle(from), - async (fileHandle, filePath) => { - const copyPromises = [] - - for (const project of projects) { - const target = project.config.resolvePackPath( - to.pack, - to.path - ) - const newFileHandle = - await app.fileSystem.getFileHandle( - join(target, filePath), - true - ) - copyPromises.push( - app.fileSystem.copyFileHandle( - fileHandle, - newFileHandle - ) - ) - } - - await Promise.all(copyPromises) - } - ) - ) - } else { - console.warn( - `Failed to install files from "${from}": No such file or directory` - ) - continue - } - } - - await Promise.all(promises) - - // Refresh the pack explorer once files have been added - app.actionManager.trigger('bridge.action.refreshProject') - } -} diff --git a/src/components/Extensions/Scripts/JsRuntime.ts b/src/components/Extensions/Scripts/JsRuntime.ts deleted file mode 100644 index d9abc8a4c..000000000 --- a/src/components/Extensions/Scripts/JsRuntime.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Runtime } from 'bridge-js-runtime' -import { App } from '/@/App' -import { dirname } from '/@/utils/path' - -export class JsRuntime extends Runtime { - async readFile(filePath: string) { - const app = await App.getApp() - - // Convince TypeScript that this is a real "File" and not a "VirtualFile" - // Because our VirtualFile implements all File methods the JS runtime needs - const file = await app.fileSystem.readFile(filePath) - - return file - } - - run(filePath: string, env: any = {}, fileContent?: string) { - return super.run( - filePath, - Object.assign(env, { - require: (x: string) => this.require(x, dirname(filePath), env), - }), - fileContent - ) - } -} diff --git a/src/components/Extensions/Scripts/Modules/CommandBar.ts b/src/components/Extensions/Scripts/Modules/CommandBar.ts deleted file mode 100644 index 1899aeee6..000000000 --- a/src/components/Extensions/Scripts/Modules/CommandBar.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IModuleConfig } from '../types' -import { SimpleAction, IActionConfig } from '/@/components/Actions/SimpleAction' - -export const CommandBarExtensionItems = new Set() - -export const CommandBarModule = ({ disposables }: IModuleConfig) => { - return { - registerAction(actionConfig: IActionConfig) { - const action = new SimpleAction(actionConfig) - CommandBarExtensionItems.add(action) - - const disposable = { - dispose: () => { - CommandBarExtensionItems.delete(action) - }, - } - - disposables.push(disposable) - - return disposable - }, - } -} diff --git a/src/components/Extensions/Scripts/Modules/ModelViewer.ts b/src/components/Extensions/Scripts/Modules/ModelViewer.ts deleted file mode 100644 index 45a5c4e61..000000000 --- a/src/components/Extensions/Scripts/Modules/ModelViewer.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useBridgeModelViewer } from '/@/utils/libs/useModelViewer' - -export const ModelViewerModule = async () => { - const { Model, StandaloneModelViewer } = await useBridgeModelViewer() - - return { - Model, - StandaloneModelViewer, - } -} diff --git a/src/components/Extensions/Scripts/Modules/Tab.ts b/src/components/Extensions/Scripts/Modules/Tab.ts deleted file mode 100644 index e3212633a..000000000 --- a/src/components/Extensions/Scripts/Modules/Tab.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { IModuleConfig } from '../types' -import { App } from '/@/App' -import { IframeTab } from '/@/components/Editors/IframeTab/IframeTab' -import { ThreePreviewTab } from '/@/components/Editors/ThreePreview/ThreePreviewTab' -import { Tab } from '/@/components/TabSystem/CommonTab' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { TabProvider } from '/@/components/TabSystem/TabProvider' - -export const TabModule = async ({ disposables }: IModuleConfig) => { - const app = await App.getApp() - const project = () => app.project - - return { - ContentTab: Tab, - FileTab, - ThreePreviewTab, - IframeTab, - - /** - * Register new FileTabs to be picked up by the isTabFor tab system method - * @param FileTabClass FileTab class - */ - register: (FileTabClass: typeof FileTab) => { - const disposable = TabProvider.register(FileTabClass) - - disposables.push(disposable) - - return disposable - }, - - /** - * Useful for ContentTabs: Programmatically add the tab to the tab system - * @param tab Tab to add to the tab system - * @deprecated Use TabSystem.addTab(...) instead - */ - openTab: async (FileTabClass: typeof Tab, splitScreen = false) => { - const tabSystem = splitScreen - ? project().inactiveTabSystem - : project().tabSystem - - if (!tabSystem) return - - // @ts-ignore - const tab = new FileTabClass(tabSystem) - - if (splitScreen) tabSystem.setActive(true) - tabSystem.add(tab, true) - - disposables.push({ - dispose: () => tabSystem.remove(tab), - }) - - return tab - }, - addTab(tab: Tab) { - const tabSystem = project().tabSystem - if (!tabSystem) return - - tabSystem.add(tab, true) - - disposables.push({ - dispose: () => tabSystem.remove(tab), - }) - }, - getCurrentTabSystem() { - return project().tabSystem - }, - - /** - * Given a file path relative to the project root, open the corresponding file inside of bridge.'s tab system - * @param filePath File to open - * @param selectTab Whether to automatically select the tab - */ - openFilePath: async (filePath: string, selectTab = false) => { - const fileHandle = await project().fileSystem.getFileHandle( - filePath - ) - - await project().openFile(fileHandle, { selectTab }) - }, - } -} diff --git a/src/components/Extensions/Scripts/Modules/TabAction.ts b/src/components/Extensions/Scripts/Modules/TabAction.ts deleted file mode 100644 index b7a7706ab..000000000 --- a/src/components/Extensions/Scripts/Modules/TabAction.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { IModuleConfig } from '../types' -import { App } from '/@/App' -import type { Project } from '/@/components/Projects/Project/Project' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { - ITabActionConfig, - ITabPreviewConfig, -} from '/@/components/TabSystem/TabActions/Provider' -import { IDisposable } from '/@/types/disposable' - -interface IForEachProjectConfig { - app: App - disposables: IDisposable[] - isGlobal: boolean - func: (project: Project) => void -} - -// Call a function for every current project and projects newly added in the future -function forEachProject({ - isGlobal, - func, - disposables, - app, -}: IForEachProjectConfig) { - if (isGlobal) { - disposables.push(app.projectManager.forEachProject(func)) - } else { - func(app.project) - } -} - -export const TabActionsModule = async ({ - disposables, - isGlobal, -}: IModuleConfig) => ({ - /** - * Add the default tab actions for the specific file tab - * @param tab - */ - addTabActions: async (tab: FileTab) => { - const app = await App.getApp() - - app.project.tabActionProvider.addTabActions(tab) - }, - - /** - * Register a new tab action - * @param definition - * @returns Disposable - */ - register: async (definition: ITabActionConfig) => { - const app = await App.getApp() - - forEachProject({ - app, - disposables, - isGlobal, - func: (project) => { - disposables.push(project.tabActionProvider.register(definition)) - }, - }) - }, - - /** - * Register a new tab preview - * @param definition - * @returns Disposable - */ - registerPreview: async (definition: ITabPreviewConfig) => { - const app = await App.getApp() - - forEachProject({ - app, - disposables, - isGlobal, - func: (project) => { - disposables.push( - project.tabActionProvider.registerPreview(definition) - ) - }, - }) - }, -}) diff --git a/src/components/Extensions/Scripts/Modules/Three.ts b/src/components/Extensions/Scripts/Modules/Three.ts deleted file mode 100644 index 60438f1db..000000000 --- a/src/components/Extensions/Scripts/Modules/Three.ts +++ /dev/null @@ -1 +0,0 @@ -export const ThreeModule = () => import('three') diff --git a/src/components/Extensions/Scripts/Modules/comMojang.ts b/src/components/Extensions/Scripts/Modules/comMojang.ts deleted file mode 100644 index 9f1c202ec..000000000 --- a/src/components/Extensions/Scripts/Modules/comMojang.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { App } from '/@/App' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { IModuleConfig } from '../types' - -export const ComMojangModule = async ({}: IModuleConfig) => { - const app = await App.getApp() - - return { - setup: app.comMojang.setup, - requestFileSystem: async () => { - const fs: any = {} - - // Extensions will destructure the fileSystem instance - // so we actually need to add its methods to a temp object - // and rebind them to the class instance - for (const key of Object.getOwnPropertyNames( - Object.getPrototypeOf(app.comMojang.fileSystem) - )) { - if ( - key !== 'constructor' && - typeof (app.comMojang.fileSystem)[key] === 'function' - ) - fs[key] = (app.comMojang.fileSystem)[key].bind( - app.comMojang.fileSystem - ) - } - - return fs - }, - } -} diff --git a/src/components/Extensions/Scripts/Modules/compareVersions.ts b/src/components/Extensions/Scripts/Modules/compareVersions.ts deleted file mode 100644 index 3c7cadec7..000000000 --- a/src/components/Extensions/Scripts/Modules/compareVersions.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { compareVersions } from 'bridge-common-utils' - -export const CompareVersions = () => ({ - compare: compareVersions, -}) diff --git a/src/components/Extensions/Scripts/Modules/env.ts b/src/components/Extensions/Scripts/Modules/env.ts deleted file mode 100644 index ccd49e8b7..000000000 --- a/src/components/Extensions/Scripts/Modules/env.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IModuleConfig } from '../types' -import { version } from '/@/utils/app/version' -import { App } from '/@/App' -import { isNightly } from '/@/utils/app/isNightly' -import { TPackTypeId } from 'mc-project-core' - -export const ContextEnv: { value: any } = { value: {} } - -export const ENVModule = ({}: IModuleConfig) => ({ - APP_VERSION: version, - isNightlyBuild: isNightly, - - getCurrentBP() { - return App.getApp().then((app) => - app.projectConfig.resolvePackPath('behaviorPack') - ) - }, - getCurrentRP() { - return App.getApp().then((app) => - app.projectConfig.resolvePackPath('resourcePack') - ) - }, - getCurrentProject() { - return App.instance.project.projectPath - }, - getProjectPrefix() { - return App.getApp().then((app) => app.projectConfig.get().namespace) - }, - getProjectTargetVersion() { - return App.getApp().then((app) => app.projectConfig.get().targetVersion) - }, - getProjectAuthors() { - return App.getApp().then((app) => app.projectConfig.get().authors) - }, - resolvePackPath(packId?: TPackTypeId, filePath?: string) { - return App.getApp().then((app) => - app.projectConfig.resolvePackPath(packId, filePath) - ) - }, - - getContext() { - console.warn('This API is deprecated!') - return {} - }, -}) diff --git a/src/components/Extensions/Scripts/Modules/fetchDefinition.ts b/src/components/Extensions/Scripts/Modules/fetchDefinition.ts deleted file mode 100644 index 305d626cf..000000000 --- a/src/components/Extensions/Scripts/Modules/fetchDefinition.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { App } from '/@/App' -import { IModuleConfig } from '../types' - -export const FetchDefinitionModule = ({}: IModuleConfig) => ({ - fetchDefinition: async ( - fileType: string, - fetchDefs: string[], - fetchSearch: string, - fetchAll = false - ) => { - const app = await App.getApp() - const packIndexer = app.project?.packIndexer - if (!packIndexer) return [] - - const files = await Promise.all( - fetchDefs.map( - fetchDef => - packIndexer.service?.find( - fileType, - fetchDef, - [fetchSearch], - fetchAll - ) ?? [] - ) - ) - - return files.flat() - }, -}) diff --git a/src/components/Extensions/Scripts/Modules/fflate.ts b/src/components/Extensions/Scripts/Modules/fflate.ts deleted file mode 100644 index 22a3b2c2a..000000000 --- a/src/components/Extensions/Scripts/Modules/fflate.ts +++ /dev/null @@ -1 +0,0 @@ -export const FflateModule = () => import('fflate') diff --git a/src/components/Extensions/Scripts/Modules/fs.ts b/src/components/Extensions/Scripts/Modules/fs.ts deleted file mode 100644 index 8b75cdb80..000000000 --- a/src/components/Extensions/Scripts/Modules/fs.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { App } from '/@/App' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { IModuleConfig } from '../types' - -export const FSModule = ({ disposables }: IModuleConfig) => { - return new Promise((resolve) => { - App.ready.once((app) => { - const res: any = { - onBridgeFolderSetup: (cb: () => Promise | void) => { - disposables.push(app.bridgeFolderSetup.once(cb, true)) - }, - } - - // Extensions will destructure the fileSystem instance - // so we actually need to add its methods to a temp object - // and rebind them to the class instance - for (const key of Object.getOwnPropertyNames( - Object.getPrototypeOf(app.fileSystem) - )) { - if ( - key !== 'constructor' && - typeof (app.fileSystem)[key] === 'function' - ) - res[key] = (app.fileSystem)[key].bind(app.fileSystem) - } - - resolve(res) - }) - }) -} diff --git a/src/components/Extensions/Scripts/Modules/globals.ts b/src/components/Extensions/Scripts/Modules/globals.ts deleted file mode 100644 index c6b400c31..000000000 --- a/src/components/Extensions/Scripts/Modules/globals.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { IModuleConfig } from '../types' -import { App } from '/@/App' - -let cachedGlobals: Record | undefined = undefined - -// App.eventSystem.on('projectChanged', () => { -// cachedGlobals = undefined -// }) - -export const GlobalsModule = async ({}: IModuleConfig) => { - try { - if (cachedGlobals === undefined) { - return new Promise>((resolve) => { - App.ready.once(async (app) => { - cachedGlobals = await app.fileSystem - .readJSON(`${app.project.projectPath}/globals.json`) - .catch(() => {}) - resolve(cachedGlobals!) - }) - }) - } - } catch { - return {} - } - - return { ...cachedGlobals } -} diff --git a/src/components/Extensions/Scripts/Modules/import.ts b/src/components/Extensions/Scripts/Modules/import.ts deleted file mode 100644 index 5fff0f61b..000000000 --- a/src/components/Extensions/Scripts/Modules/import.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { IModuleConfig } from '../types' -import { App } from '/@/App' -import { showFolderPicker } from '/@/components/FileSystem/Pickers/showFolderPicker' -import { AnyHandle } from '/@/components/FileSystem/Types' -import { IFolderHandler } from '/@/components/ImportFolder/Manager' -import { - IPluginOpenWithAction, - pluginActionStore, -} from '/@/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith' - -interface IPickDirectoryOptions { - multiple?: boolean -} - -export const ImportModule = ({ disposables }: IModuleConfig) => ({ - addFolderImporter: async (handle: IFolderHandler) => { - const app = await App.getApp() - disposables.push(app.folderImportManager.addImporter(handle)) - }, - - async importHandle(handle: AnyHandle) { - const app = await App.getApp() - await app.fileDropper.import(handle) - }, - - registerOpenWithHandler: (handler: IPluginOpenWithAction) => { - pluginActionStore.add(handler) - - const disposable = { - dispose: () => { - pluginActionStore.delete(handler) - }, - } - disposables.push(disposable) - - return disposable - }, - - showDirectoryPicker: async (opts: IPickDirectoryOptions) => { - return await showFolderPicker(opts) - }, -}) diff --git a/src/components/Extensions/Scripts/Modules/json5.ts b/src/components/Extensions/Scripts/Modules/json5.ts deleted file mode 100644 index f3b2bdae6..000000000 --- a/src/components/Extensions/Scripts/Modules/json5.ts +++ /dev/null @@ -1,10 +0,0 @@ -import json5 from 'json5' - -export const Json5Module = () => ({ - parse: (str: string) => json5.parse(str), - stringify: ( - obj: any, - replacer?: ((this: any, key: string, value: any) => any) | undefined, - space?: string | number | undefined - ) => JSON.stringify(obj, replacer, space), -}) diff --git a/src/components/Extensions/Scripts/Modules/monaco.ts b/src/components/Extensions/Scripts/Modules/monaco.ts deleted file mode 100644 index 694e11e5c..000000000 --- a/src/components/Extensions/Scripts/Modules/monaco.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IModuleConfig } from '../types' -import { useMonaco } from '../../../../utils/libs/useMonaco' -import type { languages } from 'monaco-editor' - -export const MonacoModule = ({ disposables }: IModuleConfig) => ({ - registerDocumentFormattingEditProvider: async ( - languageId: string, - provider: languages.DocumentFormattingEditProvider - ) => { - const { languages } = await useMonaco() - - disposables.push( - languages.registerDocumentFormattingEditProvider( - languageId, - provider - ) - ) - }, -}) diff --git a/src/components/Extensions/Scripts/Modules/notifications.ts b/src/components/Extensions/Scripts/Modules/notifications.ts deleted file mode 100644 index b64877ee0..000000000 --- a/src/components/Extensions/Scripts/Modules/notifications.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IModuleConfig } from '../types' -import { INotificationConfig } from '/@/components/Notifications/Notification' -import { createNotification } from '/@/components/Notifications/create' -import { createErrorNotification } from '/@/components/Notifications/Errors' - -export const NotificationModule = ({ disposables }: IModuleConfig) => ({ - create(config: INotificationConfig) { - const notification = createNotification(config) - disposables.push(notification) - return notification - }, - createError(error: Error) { - const notification = createErrorNotification(error) - disposables.push(notification) - return notification - }, -}) diff --git a/src/components/Extensions/Scripts/Modules/path.ts b/src/components/Extensions/Scripts/Modules/path.ts deleted file mode 100644 index 24ebd64ba..000000000 --- a/src/components/Extensions/Scripts/Modules/path.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as path from '/@/utils/path' - -export const PathModule = () => path diff --git a/src/components/Extensions/Scripts/Modules/persistentStorage.ts b/src/components/Extensions/Scripts/Modules/persistentStorage.ts deleted file mode 100644 index cf8a7e20f..000000000 --- a/src/components/Extensions/Scripts/Modules/persistentStorage.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IModuleConfig } from '../types' -import { IDBWrapper } from '/@/components/FileSystem/Virtual/IDB' - -export const idbExtensionStore = new IDBWrapper('persistent-extension-storage') - -export const PersistentStorageModule = ({ extensionId }: IModuleConfig) => ({ - async save(data: any) { - await idbExtensionStore.set(extensionId, data) - }, - - async load() { - return await idbExtensionStore.get(extensionId) - }, - - async delete() { - await idbExtensionStore.del(extensionId) - }, -}) diff --git a/src/components/Extensions/Scripts/Modules/project.ts b/src/components/Extensions/Scripts/Modules/project.ts deleted file mode 100644 index 28754784c..000000000 --- a/src/components/Extensions/Scripts/Modules/project.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { App } from '/@/App' -import { IModuleConfig } from '../types' -import { - Exporter, - IExporter, -} from '/@/components/Projects/Export/Extensions/Exporter' -import { TPackTypeId } from '/@/components/Data/PackType' -import { Project } from '/@/components/Projects/Project/Project' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { IOpenTabOptions } from '/@/components/TabSystem/TabSystem' - -export const ProjectModule = async ({ - disposables, - isGlobal, -}: IModuleConfig) => { - const app = await App.getApp() - - return { - hasPacks(packs: TPackTypeId[]) { - return app.project.hasPacks(packs) - }, - - registerExporter(exporter: IExporter) { - if (isGlobal) { - app.projectManager.forEachProject((project) => { - disposables.push( - project.exportProvider.register(new Exporter(exporter)) - ) - }) - } else { - disposables.push( - app.project.exportProvider.register(new Exporter(exporter)) - ) - } - }, - - async compile(configFile: string) { - const service = await app.project.createDashService( - 'production', - configFile === 'default' - ? undefined - : `${app.project.projectPath}/.bridge/compiler/${configFile}` - ) - - await service.build() - }, - - async compileFiles(paths: string[]) { - await app.project.compilerService.updateFiles(paths) - }, - - async unlinkFile(path: string) { - await app.project.unlinkFile(path) - }, - - onProjectChanged(cb: (projectName: string) => any) { - const disposable = App.eventSystem.on( - 'projectChanged', - (project: Project) => cb(project.name) - ) - disposables.push(disposable) - - return disposable - }, - onFileChanged(filePath: string, cb: (filePath: string) => any) { - const disposable = App.eventSystem.on( - 'fileChange', - ([currFilePath, file]) => { - if (currFilePath === filePath) cb(file) - } - ) - disposables.push(disposable) - - return disposable - }, - - async openFile(fileHandle: AnyFileHandle, opts: IOpenTabOptions) { - await app.project.openFile(fileHandle, opts) - }, - } -} diff --git a/src/components/Extensions/Scripts/Modules/reactivity.ts b/src/components/Extensions/Scripts/Modules/reactivity.ts deleted file mode 100644 index 02d670159..000000000 --- a/src/components/Extensions/Scripts/Modules/reactivity.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - computed, - del, - markRaw, - reactive, - readonly, - ref, - set, - shallowReactive, - shallowReadonly, - watch, - watchEffect, - shallowRef, -} from 'vue' -import { IModuleConfig } from '../types' -import { SimpleAction, IActionConfig } from '/@/components/Actions/SimpleAction' - -export const CommandBarExtensionItems = new Set() - -export const ReactivityModule = () => { - return { - ref, - shallowRef, - computed, - reactive, - shallowReactive, - readonly, - shallowReadonly, - markRaw, - watch, - watchEffect, - del, - set, - } -} diff --git a/src/components/Extensions/Scripts/Modules/settings.ts b/src/components/Extensions/Scripts/Modules/settings.ts deleted file mode 100644 index 1a92a1c57..000000000 --- a/src/components/Extensions/Scripts/Modules/settings.ts +++ /dev/null @@ -1 +0,0 @@ -export const SettingsModule = () => ({}) diff --git a/src/components/Extensions/Scripts/Modules/sidebar.ts b/src/components/Extensions/Scripts/Modules/sidebar.ts deleted file mode 100644 index edbd4fd74..000000000 --- a/src/components/Extensions/Scripts/Modules/sidebar.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { IModuleConfig } from '../types' -import { createSidebar } from '../../../Sidebar/SidebarElement' -import { SettingsWindow } from '/@/components/Windows/Settings/SettingsWindow' -import { Component } from 'vue' -import { SidebarContent } from '/@/components/Sidebar/Content/SidebarContent' -import { SidebarAction } from '/@/components/Sidebar/Content/SidebarAction' -import { SelectableSidebarAction } from '/@/components/Sidebar/Content/SelectableSidebarAction' - -export const SidebarModule = ({ disposables, extensionId }: IModuleConfig) => ({ - SidebarContent, - SidebarAction, - SelectableSidebarAction, - - create(config: { - id?: string - displayName: string - component: Component - icon: string - }) { - if (!config.id) { - console.warn('SidebarModule: config.id is required') - config.id = `${extensionId}//${config.displayName}` - } - const sidebar = createSidebar(config) - - if (config.id) { - SettingsWindow.loadedSettings.once((settingsState) => { - sidebar.isVisibleSetting = - (settingsState)?.sidebar?.sidebarElements?.[ - config.id! - ] ?? true - }) - } - - disposables.push(sidebar) - return sidebar - }, - - getSelected() { - throw new Error(`This function no longer works with bridge. v2`) - }, - onChange() { - throw new Error(`This function no longer works with bridge. v2`) - }, - select() { - throw new Error(`This function no longer works with bridge. v2`) - }, -}) diff --git a/src/components/Extensions/Scripts/Modules/theme.ts b/src/components/Extensions/Scripts/Modules/theme.ts deleted file mode 100644 index c79fac3e0..000000000 --- a/src/components/Extensions/Scripts/Modules/theme.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { App } from '/@/App' -import { IModuleConfig } from '../types' -import { TColorName } from '../../Themes/ThemeManager' - -export const ThemeModule = async ({ disposables }: IModuleConfig) => { - const app = await App.getApp() - const themeManager = app.themeManager - - return { - onChange: (func: (mode: 'dark' | 'light') => void) => { - const disposable = themeManager.on(func) - disposables.push(disposable) - return disposable - }, - getCurrentMode() { - return app.themeManager.getCurrentMode() - }, - getColor(name: TColorName) { - return themeManager.getColor(name) - }, - getHighlighterInfo(name: string) { - return themeManager.getHighlighterInfo(name) - }, - } -} diff --git a/src/components/Extensions/Scripts/Modules/toolbar.ts b/src/components/Extensions/Scripts/Modules/toolbar.ts deleted file mode 100644 index 2327cf768..000000000 --- a/src/components/Extensions/Scripts/Modules/toolbar.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { App } from '/@/App' -import { ToolbarCategory } from '/@/components/Toolbar/ToolbarCategory' -import { IModuleConfig } from '../types' - -export const ToolbarModule = async ({ disposables }: IModuleConfig) => ({ - ToolbarCategory, - actionManager: (await App.getApp()).actionManager, - addCategory(category: ToolbarCategory) { - App.toolbar.addCategory(category) - disposables.push(category) - }, -}) diff --git a/src/components/Extensions/Scripts/Modules/ui.ts b/src/components/Extensions/Scripts/Modules/ui.ts deleted file mode 100644 index a977b120c..000000000 --- a/src/components/Extensions/Scripts/Modules/ui.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IModuleConfig } from '../types' -import BaseWindow from '/@/components/Windows/Layout/BaseWindow.vue' -import SidebarWindow from '/@/components/Windows/Layout/SidebarWindow.vue' -import DirectoryViewer from '/@/components/UIElements/DirectoryViewer/DirectoryViewer.vue' -import BridgeSheet from '/@/components/UIElements/Sheet.vue' - -export const UIModule = async ({ uiStore }: IModuleConfig) => { - await uiStore?.allLoaded.fired - - return { - ...uiStore?.UI, - BuiltIn: { - BaseWindow, - SidebarWindow, - DirectoryViewer, - BridgeSheet, - }, - } -} diff --git a/src/components/Extensions/Scripts/Modules/utils.ts b/src/components/Extensions/Scripts/Modules/utils.ts deleted file mode 100644 index 90f0a5b5f..000000000 --- a/src/components/Extensions/Scripts/Modules/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IModuleConfig } from '../types' -import { App } from '/@/App' - -export const UtilsModule = ({}: IModuleConfig) => ({ - openExternal: (url: string) => App.openUrl(url, undefined, true), -}) diff --git a/src/components/Extensions/Scripts/Modules/windows.ts b/src/components/Extensions/Scripts/Modules/windows.ts deleted file mode 100644 index 3cbeb1266..000000000 --- a/src/components/Extensions/Scripts/Modules/windows.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { IModuleConfig } from '../types' -import { createWindow } from '/@/components/Windows/create' -import { Component as VueComponent } from 'vue' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { InformationWindow } from '/@/components/Windows/Common/Information/InformationWindow' -import { DropdownWindow } from '/@/components/Windows/Common/Dropdown/DropdownWindow' -import { InputWindow } from '/@/components/Windows/Common/Input/InputWindow' -import { NewBaseWindow } from '/@/components/Windows/NewBaseWindow' - -export const WindowModule = ({}: IModuleConfig) => ({ - BaseWindow: NewBaseWindow, - createWindow: ( - vueComponent: VueComponent, - state: Record - ) => { - console.warn( - '[@bridge/windows] Calling "createWindow" is deprecated. Please replace direct function calls by defining a class based window instead.' - ) - - return createWindow(vueComponent, state) - }, - createInformationWindow: (displayName: string, displayContent: string) => - new InformationWindow({ - name: displayName, - description: displayContent, - }), - createInputWindow: ( - displayName: string, - inputLabel: string, - defaultValue: string, - onConfirm: (input: string) => void, - expandText?: string - ) => - new InputWindow({ - name: displayName, - label: inputLabel, - default: defaultValue, - expandText, - onConfirm, - }), - createDropdownWindow: ( - displayName: string, - placeholder: string, - options: Array, - defaultSelected: string, - onConfirm: (input: string) => void - ) => - new DropdownWindow({ - name: displayName, - default: defaultSelected, - options, - placeholder, - onConfirm, - }), - createConfirmWindow: ( - displayContent: string, - confirmText: string, - cancelText: string, - onConfirm: () => void, - onCancel: () => void - ) => - new ConfirmationWindow({ - description: displayContent, - confirmText, - cancelText, - onConfirm, - onCancel, - }), -}) diff --git a/src/components/Extensions/Scripts/loadScripts.ts b/src/components/Extensions/Scripts/loadScripts.ts deleted file mode 100644 index a8a02c7d7..000000000 --- a/src/components/Extensions/Scripts/loadScripts.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { IDisposable } from '/@/types/disposable' -import { TUIStore } from '../UI/store' - -import { AnyDirectoryHandle } from '../../FileSystem/Types' -import { JsRuntime } from './JsRuntime' -import { iterateDir } from '/@/utils/iterateDir' - -export async function loadScripts( - jsRuntime: JsRuntime, - baseDirectory: AnyDirectoryHandle -) { - await iterateDir( - baseDirectory, - async (fileHandle, filePath) => { - const fileContent = await fileHandle - .getFile() - .then((file) => file.text()) - await jsRuntime.run(filePath, undefined, fileContent) - }, - undefined, - 'scripts' - ) -} - -export interface IScriptContext { - jsRuntime?: JsRuntime - uiStore: TUIStore - disposables: IDisposable[] - extensionId: string - language?: 'javaScript' | 'typeScript' - isGlobal?: boolean -} - -export function executeScript( - jsRuntime: JsRuntime, - filePath: string, - code: string -) { - return jsRuntime.run(filePath, undefined, code) -} diff --git a/src/components/Extensions/Scripts/require.ts b/src/components/Extensions/Scripts/require.ts deleted file mode 100644 index 98bad9c62..000000000 --- a/src/components/Extensions/Scripts/require.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { TUIStore } from '../UI/store' -import { IDisposable } from '/@/types/disposable' -import { IModuleConfig } from './types' - -import { SidebarModule } from './Modules/sidebar' -import { UIModule } from './Modules/ui' -import { NotificationModule } from './Modules/notifications' -import { FSModule } from './Modules/fs' -import { ENVModule } from './Modules/env' -import { UtilsModule } from './Modules/utils' -import { PathModule } from './Modules/path' -import { FetchDefinitionModule } from './Modules/fetchDefinition' -import { WindowModule } from './Modules/windows' -import { GlobalsModule } from './Modules/globals' -import { ToolbarModule } from './Modules/toolbar' -import { CompareVersions } from './Modules/compareVersions' -import { MonacoModule } from './Modules/monaco' -import { Json5Module } from './Modules/json5' -import { ComMojangModule } from './Modules/comMojang' -import { TabModule } from './Modules/Tab' -import { TabActionsModule } from './Modules/TabAction' -import { ThemeModule } from './Modules/theme' -import { ProjectModule } from './Modules/project' -import { ThreeModule } from './Modules/Three' -import { ModelViewerModule } from './Modules/ModelViewer' -import { ImportModule } from './Modules/import' -import { PersistentStorageModule } from './Modules/persistentStorage' -import { CommandBarModule } from './Modules/CommandBar' -import { FflateModule } from './Modules/fflate' -import { ReactivityModule } from './Modules/reactivity' - -export const BuiltInModules = new Map any>([ - ['@bridge/ui', UIModule], - ['@bridge/sidebar', SidebarModule], - ['@bridge/notification', NotificationModule], - ['@bridge/fs', FSModule], - ['@bridge/path', PathModule], - ['@bridge/env', ENVModule], - ['@bridge/project', ProjectModule], - ['@bridge/globals', GlobalsModule], - ['@bridge/utils', UtilsModule], - ['@bridge/fetch-definition', FetchDefinitionModule], - ['@bridge/windows', WindowModule], - ['@bridge/toolbar', ToolbarModule], - ['@bridge/compare-versions', CompareVersions], - ['@bridge/monaco', MonacoModule], - ['@bridge/json5', Json5Module], - ['@bridge/com-mojang', ComMojangModule], - ['@bridge/tab', TabModule], - ['@bridge/tab-actions', TabActionsModule], - ['@bridge/theme', ThemeModule], - ['@bridge/three', ThreeModule], - ['@bridge/model-viewer', ModelViewerModule], - ['@bridge/import', ImportModule], - ['@bridge/persistent-storage', PersistentStorageModule], - ['@bridge/command-bar', CommandBarModule], - ['@bridge/fflate', FflateModule], - ['@bridge/reactivity', ReactivityModule], -]) -//For usage inside of custom commands, components etc. -const LimitedModules = new Map any>([ - ['@bridge/notification', NotificationModule], - ['@bridge/fs', FSModule], - ['@bridge/path', PathModule], - ['@bridge/env', ENVModule], - ['@bridge/globals', GlobalsModule], - ['@bridge/utils', UtilsModule], - ['@bridge/fetch-definition', FetchDefinitionModule], - ['@bridge/compare-versions', CompareVersions], -]) - -function createGenericEnv( - extensionId: string, - disposables: IDisposable[] = [], - uiStore?: TUIStore, - isGlobal: boolean = false, - modules = BuiltInModules -) { - return <[string, any][]>[...modules.entries()].map( - ([moduleName, module]) => [ - moduleName, - () => - module({ - extensionId, - disposables, - uiStore, - isGlobal, - }), - ] - ) -} - -export function createEnv( - extensionId: string, - disposables: IDisposable[] = [], - uiStore?: TUIStore, - isGlobal: boolean = false -) { - return createGenericEnv(extensionId, disposables, uiStore, isGlobal) -} -export function createLimitedEnv( - extensionId: string, - disposables: IDisposable[] = [], - uiStore?: TUIStore, - isGlobal: boolean = false -) { - return createGenericEnv( - extensionId, - disposables, - uiStore, - isGlobal, - LimitedModules - ) -} diff --git a/src/components/Extensions/Scripts/run.ts b/src/components/Extensions/Scripts/run.ts deleted file mode 100644 index 123f10f34..000000000 --- a/src/components/Extensions/Scripts/run.ts +++ /dev/null @@ -1,70 +0,0 @@ -export interface IScriptContext { - script: string - env: Record - modules?: Record - language?: 'javaScript' | 'typeScript' - async?: boolean -} - -export function run(context: IScriptContext) { - return createRunner(context)(...Object.values(context.env)) -} - -export function createRunner({ - script, - env, - language, - modules, - async = false, -}: IScriptContext) { - if (language === 'typeScript') - throw new Error( - `bridge.'s legacy script runner no longer supports TypeScript. Use "bridge-js-runner" instead.` - ) - let transformedScript = transformScript(script) - - // Helper which allows for quickly setting up importable modules - if (modules) { - const currRequire = env.require - - env.require = async (moduleName: string) => { - if (modules[moduleName]) return modules[moduleName] - - if (typeof currRequire === 'function') - return await currRequire?.(moduleName) - } - } - - try { - if (async) - return new Function( - ...Object.keys(env), - `return (async () => {\n${transformedScript}\n})()` - ) - return new Function(...Object.keys(env), transformedScript) - } catch (err) { - console.error(script) - throw new Error(`Error within script: ${err}`) - } -} - -export function transformScript(script: string) { - return ( - script - .replace(/export default([ \(])/g, (_, char) => { - return `module.exports =${char}` - }) - // TODO: Support named exports - // .replace(/export (var|const|let|function|class) /g, (substr) => { - // return substr - // }) - .replace( - /import\s+(\* as [a-z][a-z0-9]*|[a-z][a-z0-9]+|{[a-z\s][a-z0-9,\s]*})\s+from\s+["'](.+)["']/gi, - (_, imports, moduleName) => { - if (imports.startsWith(`* as `)) - imports = imports.replace('* as ', '') - return `const ${imports} = await require('${moduleName}')` - } - ) - ) -} diff --git a/src/components/Extensions/Scripts/types.d.ts b/src/components/Extensions/Scripts/types.d.ts deleted file mode 100644 index 1eac7b932..000000000 --- a/src/components/Extensions/Scripts/types.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IDisposable } from '../../Types/disposable' -import { TUIStore } from '../UI/store' - -export interface IModuleConfig { - extensionId: string - uiStore?: TUIStore - disposables: IDisposable[] - isGlobal: boolean -} diff --git a/src/components/Extensions/Settings/ExtensionSetting.ts b/src/components/Extensions/Settings/ExtensionSetting.ts deleted file mode 100644 index cf5780065..000000000 --- a/src/components/Extensions/Settings/ExtensionSetting.ts +++ /dev/null @@ -1,21 +0,0 @@ -import json5 from 'json5' -import { AnyDirectoryHandle } from '../../FileSystem/Types' -import { IPresetFieldOpts } from '/@/components/Windows/Project/CreatePreset/PresetWindow' -import { iterateDir } from '/@/utils/iterateDir' - -export interface ISettingDef { - name: string - fields: [string, string, IPresetFieldOpts][] -} - -export class ExtensionSetting { - static async load(baseDirectory: AnyDirectoryHandle) { - iterateDir(baseDirectory, async (fileHandle) => { - const fileContent = await fileHandle - .getFile() - .then((file) => file.text()) - - let settingsDefinition: ISettingDef = json5.parse(fileContent) - }) - } -} diff --git a/src/components/Extensions/Styles/createStyle.ts b/src/components/Extensions/Styles/createStyle.ts deleted file mode 100644 index 9a8b08642..000000000 --- a/src/components/Extensions/Styles/createStyle.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function createStyleSheet(styles: string) { - const styleTag = document.createElement('style') - styleTag.innerHTML = styles - document.head.appendChild(styleTag) - - return { - dispose: () => { - if (document.head.contains(styleTag)) - document.head.removeChild(styleTag) - }, - } -} diff --git a/src/components/Extensions/Themes/MonacoSubTheme.ts b/src/components/Extensions/Themes/MonacoSubTheme.ts deleted file mode 100644 index 20d653635..000000000 --- a/src/components/Extensions/Themes/MonacoSubTheme.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { keyword } from 'color-convert' -import { Signal } from '../../Common/Event/Signal' -import { Theme } from './Theme' -import { loadMonaco, useMonaco } from '../../../utils/libs/useMonaco' - -export const anyMonacoThemeLoaded = new Signal() -export class MonacoSubTheme { - constructor(protected theme: Theme) {} - - async apply() { - if (!loadMonaco.hasFired) return - - const { editor } = await useMonaco() - - editor.defineTheme(`bridgeMonacoDefault`, { - base: this.theme.colorScheme === 'light' ? 'vs' : 'vs-dark', - inherit: false, - colors: { - 'editor.background': this.convertColor( - this.theme.getColor('background') - ), - 'editor.lineHighlightBackground': this.convertColor( - this.theme.getColor('lineHighlightBackground') - ), - 'editorWidget.background': this.convertColor( - this.theme.getColor('background') - ), - 'editorWidget.border': this.convertColor( - this.theme.getColor('sidebarNavigation') - ), - 'pickerGroup.background': this.convertColor( - this.theme.getColor('background') - ), - 'pickerGroup.border': this.convertColor( - this.theme.getColor('sidebarNavigation') - ), - 'badge.background': this.convertColor( - this.theme.getColor('background') - ), - - 'input.background': this.convertColor( - this.theme.getColor('sidebarNavigation') - ), - 'input.border': this.convertColor(this.theme.getColor('menu')), - 'inputOption.activeBorder': this.convertColor( - this.theme.getColor('primary') - ), - focusBorder: this.convertColor(this.theme.getColor('primary')), - 'list.focusBackground': this.convertColor( - this.theme.getColor('menu') - ), - 'list.hoverBackground': this.convertColor( - this.theme.getColor('sidebarNavigation') - ), - contrastBorder: this.convertColor( - this.theme.getColor('sidebarNavigation') - ), - - 'peekViewTitle.background': this.convertColor( - this.theme.getColor('background') - ), - 'peekView.border': this.convertColor( - this.theme.getColor('primary') - ), - 'peekViewResult.background': this.convertColor( - this.theme.getColor('sidebarNavigation') - ), - 'peekViewResult.selectionBackground': this.convertColor( - this.theme.getColor('menu') - ), - 'peekViewEditor.background': this.convertColor( - this.theme.getColor('background') - ), - 'peekViewEditor.matchHighlightBackground': this.convertColor( - this.theme.getColor('menu') - ), - ...this.theme.getMonacoDefinition(), - }, - rules: [ - //@ts-ignore Token is not required - { - background: this.convertColor( - this.theme.getColor('background') - ), - foreground: this.convertColor(this.theme.getColor('text')), - }, - ...Object.entries(this.theme.getHighlighterDefinition()) - .map( - ([ - token, - { color, background, textDecoration, isItalic }, - ]) => ({ - token: token, - foreground: this.convertColor(color as string), - background: background - ? this.convertColor(background as string) - : undefined, - fontStyle: `${ - isItalic ? 'italic ' : '' - }${textDecoration}`, - }) - ) - .filter(({ foreground }) => foreground !== undefined), - ], - }) - - editor.setTheme(`bridgeMonacoDefault`) - anyMonacoThemeLoaded.dispatch() - } - - convertColor(color: string) { - if (!color) return color - if (color.startsWith('#')) { - if (color.length === 4) { - return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}` - } - return color - } - - return keyword.hex(color as any) - } -} diff --git a/src/components/Extensions/Themes/Theme.ts b/src/components/Extensions/Themes/Theme.ts deleted file mode 100644 index c603cac94..000000000 --- a/src/components/Extensions/Themes/Theme.ts +++ /dev/null @@ -1,63 +0,0 @@ -import Vue from 'vue' -import { TColorName, IThemeDefinition, ThemeManager } from './ThemeManager' -import { MonacoSubTheme } from './MonacoSubTheme' - -export class Theme { - public readonly id: string - public readonly colorScheme: 'dark' | 'light' - public readonly name: string - - protected colorMap: Map - protected highlighterDef: IThemeDefinition['highlighter'] - protected monacoDef: IThemeDefinition['monaco'] - - protected monacoSubTheme: MonacoSubTheme - - constructor( - protected themeDefinition: IThemeDefinition, - public readonly isGlobal: boolean - ) { - this.id = themeDefinition.id - this.name = themeDefinition.name - this.colorScheme = themeDefinition.colorScheme ?? 'dark' - - this.colorMap = new Map( - <[TColorName, string][]>Object.entries(themeDefinition.colors) - ) - this.monacoDef = themeDefinition.monaco - this.highlighterDef = themeDefinition.highlighter - - this.monacoSubTheme = new MonacoSubTheme(this) - } - - apply(themeManager: ThemeManager, vuetify: any) { - Vue.set( - vuetify.theme.themes, - this.colorScheme, - Object.fromEntries(this.colorMap.entries()) - ) - Vue.set(vuetify.theme, 'dark', this.colorScheme === 'dark') - - themeManager.setThemeColor(this.colorMap.get('toolbar') ?? 'red') - this.applyMonacoTheme() - } - async applyMonacoTheme() { - await this.monacoSubTheme.apply() - } - - getColor(colorName: TColorName) { - return this.colorMap.get(colorName) ?? 'red' - } - getHighlighterInfo(colorName: string) { - return this.highlighterDef?.[colorName] - } - getMonacoDefinition() { - return this.monacoDef ?? {} - } - getHighlighterDefinition() { - return this.highlighterDef ?? {} - } - getThemeDefinition() { - return this.themeDefinition - } -} diff --git a/src/components/Extensions/Themes/ThemeManager.ts b/src/components/Extensions/Themes/ThemeManager.ts deleted file mode 100644 index f175df72a..000000000 --- a/src/components/Extensions/Themes/ThemeManager.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { Signal } from '/@/components/Common/Event/Signal' -import { App } from '/@/App' -import { settingsState } from '/@/components/Windows/Settings/SettingsState' -import { IDisposable } from '/@/types/disposable' -import json5 from 'json5' -import { deepMerge } from 'bridge-common-utils' -import { bridgeDark, bridgeLight } from './Default' -import { Theme } from './Theme' -import { AnyFileHandle } from '../../FileSystem/Types' - -const colorNames = [ - 'text', - 'toolbar', - 'expandedSidebar', - 'sidebarNavigation', - 'primary', - 'secondary', - 'accent', - 'error', - 'info', - 'warning', - 'success', - 'background', - 'menu', - 'footer', - 'tooltip', - 'sidebarSelection', - 'scrollbarThumb', - 'tabActive', - 'tabInactive', - 'lineHighlightBackground', - 'behaviorPack', - 'resourcePack', - 'worldTemplate', - 'skinPack', -] as const -export type TColorName = typeof colorNames[number] - -export class ThemeManager extends EventDispatcher<'light' | 'dark'> { - protected mode: 'light' | 'dark' - protected themeMap = new Map() - protected themeColorTag: Element | null = null - protected currentTheme = 'bridge.default.dark' - public readonly colorScheme = new Signal<'light' | 'dark'>() - - constructor(protected vuetify: any) { - super() - - // Listen for dark/light mode changes - const media = window.matchMedia('(prefers-color-scheme: light)') - this.mode = media.matches ? 'light' : 'dark' - const onMediaChange = (mediaQuery: MediaQueryListEvent) => { - this.colorScheme.dispatch(mediaQuery.matches ? 'light' : 'dark') - this.mode = mediaQuery.matches ? 'light' : 'dark' - this.updateTheme() - } - - if ('addEventListener' in media) { - media.addEventListener('change', (mediaQuery) => - onMediaChange(mediaQuery) - ) - } else { - // @ts-ignore This is for supporting older versions of Safari - media.addListener((mediaQuery) => onMediaChange(mediaQuery)) - } - - /** - * Setup theme meta tag - * @see ThemeManager.setThemeColor - */ - const allThemeColorTags = document.querySelectorAll( - "meta[name='theme-color']" - ) - this.themeColorTag = allThemeColorTags[0] ?? null - this.themeColorTag.removeAttribute('media') - allThemeColorTags[1]?.remove() - if (!this.themeColorTag) { - this.themeColorTag = document.createElement('meta') - this.themeColorTag.setAttribute('name', 'theme-color') - document.head.appendChild(this.themeColorTag) - } - this.themeColorTag.id = 'theme-color-tag' - - this.addTheme(bridgeDark, true) - this.addTheme(bridgeLight, true) - this.applyTheme(this.themeMap.get('bridge.default.dark')) - } - - getCurrentMode() { - return this.mode - } - getCurrentTheme() { - return this.themeMap.get(this.currentTheme) - } - - protected applyTheme(theme?: Theme) { - if (!theme) return - - theme.apply(this, this.vuetify) - // This is to support TailwindCSS dark mode (class based) - if (theme.colorScheme === 'dark') { - document.body.classList.add('dark') - } else { - document.body.classList.remove('dark') - } - } - async applyMonacoTheme() { - this.themeMap.get(this.currentTheme)?.applyMonacoTheme() - } - async updateTheme() { - const app = await App.getApp() - - let colorScheme = settingsState?.appearance?.colorScheme - if (!colorScheme || colorScheme === 'auto') colorScheme = this.mode - - const themeId = - settingsState?.appearance?.[`${colorScheme}Theme`] ?? - `bridge.default.${colorScheme}` - - let localThemeId = 'bridge.noSelection' - - if (!app.isNoProjectSelected) { - const bridgeConfig = app.projectConfig.get().bridge - localThemeId = - (colorScheme === 'light' - ? bridgeConfig?.lightTheme - : bridgeConfig?.darkTheme) ?? 'bridge.noSelection' - } - - const themeToSelect = - localThemeId !== 'bridge.noSelection' ? localThemeId : themeId - const theme = this.themeMap.get( - localThemeId !== 'bridge.noSelection' ? localThemeId : themeId - ) - - const baseTheme = this.themeMap.get(`bridge.default.${colorScheme}`) - - if ( - this.currentTheme !== - (theme ? themeToSelect : `bridge.default.${colorScheme}`) - ) { - this.currentTheme = theme - ? themeToSelect - : `bridge.default.${colorScheme}` - this.applyTheme(theme ?? baseTheme) - this.dispatch(theme?.colorScheme ?? 'dark') - } - } - async loadDefaultThemes(app: App) { - await app.dataLoader.fired - - const themes = await app.dataLoader.readJSON( - 'data/packages/common/themes.json' - ) - - themes.map((theme: any) => this.addTheme(theme, true)) - - this.updateTheme() - } - async loadTheme( - fileHandle: AnyFileHandle, - isGlobal = true, - disposables?: IDisposable[] - ) { - const file = await fileHandle.getFile() - - let themeDefinition - try { - themeDefinition = json5.parse(await file.text()) - } catch { - throw new Error(`Failed to load theme "${file.name}"`) - } - - const disposable = this.addTheme(themeDefinition, isGlobal) - if (disposables) disposables.push(disposable) - } - - getThemes(colorScheme?: 'dark' | 'light', global?: boolean) { - const themes: Theme[] = [] - - for (const [_, theme] of this.themeMap) { - if ( - (!colorScheme || theme.colorScheme === colorScheme) && - (theme.isGlobal || global === theme.isGlobal) - ) - themes.push(theme) - } - - return themes - } - - /** - * Updates the top browser toolbar to match the main app's toolbar color - * @param color Color to set the toolbar to - */ - setThemeColor(color: string) { - this.themeColorTag!.setAttribute('content', color) - } - - addTheme(themeConfig: IThemeDefinition, isGlobal: boolean) { - const baseTheme = this.themeMap.get( - `bridge.default.${themeConfig.colorScheme ?? 'dark'}` - ) - - this.themeMap.set( - themeConfig.id, - new Theme( - deepMerge(baseTheme?.getThemeDefinition() ?? {}, themeConfig), - isGlobal - ) - ) - this.updateTheme() - - return { - dispose: () => this.themeMap.delete(themeConfig.id), - } - } - - getColor(colorName: TColorName) { - const theme = this.themeMap.get(this.currentTheme) - if (!theme) throw new Error(`No theme currently loaded`) - return theme.getColor(colorName) - } - getHighlighterInfo(colorName: string) { - const theme = this.themeMap.get(this.currentTheme) - if (!theme) throw new Error(`No theme currently loaded`) - return theme.getHighlighterInfo(colorName) - } -} - -export interface IThemeDefinition { - id: string - name: string - colorScheme?: 'dark' | 'light' - colors: Record - highlighter?: Record< - string, - { - color?: string - background?: string - textDecoration?: string - isItalic?: boolean - } - > - monaco?: Record -} diff --git a/src/components/Extensions/UI/load.ts b/src/components/Extensions/UI/load.ts deleted file mode 100644 index 5ee0f3589..000000000 --- a/src/components/Extensions/UI/load.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { extname, basename } from '/@/utils/path' -import { createErrorNotification } from '/@/components/Notifications/Errors' -import { TUIStore } from './store' -import { IDisposable } from '/@/types/disposable' -import { createStyleSheet } from '../Styles/createStyle' -import Vue from 'vue' -import { - VBtn, - VAlert, - VApp, - VToolbar, - VToolbarItems, - VAutocomplete, - VCombobox, - VSwitch, - VTextField, - VWindow, - VTooltip, -} from 'vuetify/lib' -import { AnyDirectoryHandle } from '../../FileSystem/Types' -import { useVueTemplateCompiler } from '/@/utils/libs/useVueTemplateCompiler' -import { iterateDir } from '/@/utils/iterateDir' -import { JsRuntime } from '../Scripts/JsRuntime' - -const VuetifyComponents = { - VBtn, - VAlert, - VApp, - VToolbar, - VToolbarItems, - VAutocomplete, - VCombobox, - VSwitch, - VTextField, - VWindow, - VTooltip, -} - -export async function loadUIComponents( - jsRuntime: JsRuntime, - baseDirectory: AnyDirectoryHandle, - uiStore: TUIStore, - disposables: IDisposable[] -) { - await iterateDir( - baseDirectory, - async (fileHandle, filePath) => { - await loadUIComponent( - jsRuntime, - filePath, - await fileHandle.getFile().then((file) => file.text()), - uiStore, - disposables - ) - }, - undefined, - 'ui' - ) - - uiStore.allLoaded.dispatch() -} - -export async function loadUIComponent( - jsRuntime: JsRuntime, - componentPath: string, - fileContent: string, - uiStore: TUIStore, - disposables: IDisposable[] -) { - if (extname(componentPath) !== '.vue') { - createErrorNotification( - new Error( - `NOT A VUE FILE: Provided UI file "${basename( - componentPath - )}" is not a vue file!` - ) - ) - return - } - - const { parseComponent } = await useVueTemplateCompiler() - - const promise = new Promise(async (resolve, reject) => { - //@ts-expect-error "errors" is not defined in .d.ts file - const { template, script, styles, errors } = parseComponent(fileContent) - - if (errors.length > 0) { - ;(errors as Error[]).forEach((error) => - createErrorNotification(error) - ) - return reject(errors[0]) - } - - const { __default__: componentDef } = script?.content - ? await jsRuntime.run( - componentPath, - undefined, - script?.content ?? '' - ) - : { __default__: {} } - - const component = { - name: basename(componentPath), - ...componentDef, - ...Vue.compile( - template?.content ?? `

NO TEMPLATE DEFINED

` - ), - } - // Add vuetify components in - component.components = Object.assign( - component.components ?? {}, - VuetifyComponents - ) - - styles.forEach((style) => - disposables.push(createStyleSheet(style.content)) - ) - - resolve(component) - }) - - uiStore.set(componentPath.replace('ui/', '').split('/'), () => promise) -} diff --git a/src/components/Extensions/UI/store.ts b/src/components/Extensions/UI/store.ts deleted file mode 100644 index 921bbb3a1..000000000 --- a/src/components/Extensions/UI/store.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { v4 as uuid } from 'uuid' -import { Signal } from '../../Common/Event/Signal' -import { basename, extname } from '/@/utils/path' - -export type TUIStore = ReturnType -export function createUIStore() { - let UI: any = {} - let storeUUID: string | null = uuid() - - return { - get UI() { - return UI - }, - allLoaded: new Signal(), - set(path: string[], component: () => Promise) { - let current = UI - - while (path.length > 1) { - const key = path.shift() - if (current[key] === undefined) current[key] = {} - - current = current[key] - } - - const key = path.shift() - current[basename(key, extname(key))] = component - }, - dispose() { - UI = null - storeUUID = null - }, - } -} diff --git a/src/components/FileDropper/FileDropper.ts b/src/components/FileDropper/FileDropper.ts deleted file mode 100644 index efc58f182..000000000 --- a/src/components/FileDropper/FileDropper.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - AnyDirectoryHandle, - AnyFileHandle, - AnyHandle, -} from '/@/components/FileSystem/Types' -import { App } from '/@/App' -import { extname } from '/@/utils/path' - -export class FileDropper { - protected fileHandlers = new Map< - string, - (fileHandle: AnyFileHandle) => Promise | void - >() - protected defaultImporter?: ( - fileHandle: AnyFileHandle - ) => Promise | void - - constructor(protected app: App) { - window.addEventListener('dragover', (event) => { - event.preventDefault() - }) - - window.addEventListener('drop', (event) => { - event.preventDefault() - - this.onDrop([...(event.dataTransfer?.items ?? [])]) - }) - } - - protected async onDrop(dataTransferItems: DataTransferItem[]) { - const handles: Promise[] = [] - - for (const item of dataTransferItems) { - handles.push( - >item.getAsFileSystemHandle() - ) - } - - for (const handlePromise of handles) { - const handle = await handlePromise - - if (handle === null) continue - - await this.import(handle) - } - } - - async import(handle: AnyHandle) { - if (handle.kind === 'directory') { - await this.importFolder(handle) - } else if (handle.kind === 'file') { - await this.importFile(handle) - } - } - - async importFile(fileHandle: AnyFileHandle) { - if (!this.app.isNoProjectSelected) - await this.app.projectManager.projectReady.fired - - const ext = extname(fileHandle.name) - let handler = this.fileHandlers.get(ext) ?? this.defaultImporter - - if (!handler) return false - - try { - await handler(fileHandle) - } catch (err) { - console.error(err) - return false - } - return true - } - - async importFolder(directoryHandle: AnyDirectoryHandle) { - await this.app.folderImportManager.onImportFolder(directoryHandle) - } - - addFileImporter( - ext: string, - importHandler: (fileHandle: AnyFileHandle) => Promise | void - ) { - if (this.fileHandlers.has(ext)) - throw new Error(`Handler for ${ext} already exists`) - - this.fileHandlers.set(ext, importHandler) - - return { - dispose: () => this.fileHandlers.delete(ext), - } - } - setDefaultFileImporter( - importHandler: (fileHandle: AnyFileHandle) => Promise | void - ) { - this.defaultImporter = importHandler - - return { - dispose: () => (this.defaultImporter = undefined), - } - } -} diff --git a/src/components/FileExplorer/Directory.vue b/src/components/FileExplorer/Directory.vue new file mode 100644 index 000000000..410d0d849 --- /dev/null +++ b/src/components/FileExplorer/Directory.vue @@ -0,0 +1,259 @@ + + + diff --git a/src/components/FileExplorer/File.vue b/src/components/FileExplorer/File.vue new file mode 100644 index 000000000..b755e6b52 --- /dev/null +++ b/src/components/FileExplorer/File.vue @@ -0,0 +1,118 @@ + + + diff --git a/src/components/FileExplorer/FileExplorer.ts b/src/components/FileExplorer/FileExplorer.ts new file mode 100644 index 000000000..98a62a765 --- /dev/null +++ b/src/components/FileExplorer/FileExplorer.ts @@ -0,0 +1,52 @@ +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { useCurrentProjectHeadless } from '@/libs/project/ProjectManager' +import { Settings } from '@/libs/settings/Settings' +import { IPackType } from 'mc-project-core' +import { join } from 'pathe' +import { computed, ComputedRef, Ref, ref } from 'vue' + +export class FileExplorer { + public static open = ref(true) + + public static draggedItem: Ref = ref(null) + + private static currentProject = useCurrentProjectHeadless() + + public static selectedPack: Ref = ref('') + + public static selectedPackDefinition: ComputedRef = computed(() => { + if (!this.currentProject.value) return null + if (!(this.currentProject.value instanceof BedrockProject)) return null + + return this.currentProject.value.packDefinitions.find((pack: IPackType) => pack.id === this.selectedPack.value) ?? null + }) + + public static selectedPackPath: ComputedRef = computed(() => { + if (!this.currentProject.value) return '' + if (!(this.currentProject.value instanceof BedrockProject)) return '' + + return ( + this.currentProject.value.packs[this.selectedPack.value] ?? + join( + this.currentProject.value.path, + this.currentProject.value.packDefinitions.find((pack: IPackType) => pack.id === this.selectedPack.value)?.defaultPackPath ?? + '' + ) + ) + }) + + public static setup() { + Settings.addSetting('fileExplorerIndentation', { + default: 'normal', + }) + } + + public static toggle() { + FileExplorer.open.value = !FileExplorer.open.value + } + + public static isItemDragging(): boolean { + return this.draggedItem.value !== null + } +} diff --git a/src/components/FileExplorer/FileExplorer.vue b/src/components/FileExplorer/FileExplorer.vue new file mode 100644 index 000000000..6d7067fdc --- /dev/null +++ b/src/components/FileExplorer/FileExplorer.vue @@ -0,0 +1,265 @@ + + + diff --git a/src/components/FileSystem/CombinedFs.ts b/src/components/FileSystem/CombinedFs.ts deleted file mode 100644 index 4a70d50b9..000000000 --- a/src/components/FileSystem/CombinedFs.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * A FileSystem that switches between the dataLoader virtual FS & normal FS based on the file path - */ -import { IGetHandleConfig } from './Common' -import { AnyDirectoryHandle } from './Types' -import { DataLoader } from '/@/components/Data/DataLoader' -import { FileSystem } from '/@/components/FileSystem/FileSystem' - -export class CombinedFileSystem extends FileSystem { - constructor( - baseDirectory: AnyDirectoryHandle, - protected dataLoader: DataLoader - ) { - super(baseDirectory) - } - - getDirectoryHandle(path: string, opts: IGetHandleConfig) { - if (path.startsWith('data/packages/')) - return this.dataLoader.getDirectoryHandle(path, opts) - else if (path.startsWith('file:///data/packages/')) - return this.dataLoader.getDirectoryHandle(path.slice(8), opts) - else return super.getDirectoryHandle(path, opts) - } -} diff --git a/src/components/FileSystem/Common.ts b/src/components/FileSystem/Common.ts deleted file mode 100644 index 7a720bb92..000000000 --- a/src/components/FileSystem/Common.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface IMkdirConfig { - recursive: boolean -} - -export interface IGetHandleConfig { - create: boolean - createOnce: boolean -} diff --git a/src/components/FileSystem/Fast/getDirectoryHandle.ts b/src/components/FileSystem/Fast/getDirectoryHandle.ts deleted file mode 100644 index 8f771e3fc..000000000 --- a/src/components/FileSystem/Fast/getDirectoryHandle.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { isAbsolute } from '@tauri-apps/api/path' -import { IGetHandleConfig } from '../Common' -import { VirtualDirectoryHandle } from '../Virtual/DirectoryHandle' -import { pathFromHandle } from '../Virtual/pathFromHandle' -import { IndexedDbStore } from '../Virtual/Stores/IndexedDb' -import { join } from '/@/utils/path' - -export async function getDirectoryHandleTauri( - baseDirectory: VirtualDirectoryHandle, - pathArr: string[], - { create, createOnce }: Partial -) { - // Cannot apply fast path if baseDirectory is not a virtual directory - const baseStore = baseDirectory.getBaseStore() - - // Cannot apply fast path if baseStore is not a TauriFsStore - const { TauriFsStore } = await import('../Virtual/Stores/TauriFs') - if (!(baseStore instanceof TauriFsStore)) return false - - const { createDir, exists } = await import('@tauri-apps/api/fs') - const { join, basename, sep } = await import('@tauri-apps/api/path') - - const path = await join(...pathArr) - const fullPath = (await isAbsolute(path)) - ? path - : await join(await pathFromHandle(baseDirectory), path) - - if (create) { - await createDir(fullPath, { recursive: !createOnce }).catch((err) => { - throw new Error( - `Failed to access "${fullPath}": Directory does not exist: ${err}` - ) - }) - } else if (!(await exists(fullPath))) { - throw new Error( - `Failed to access "${fullPath}": Directory does not exist` - ) - } - - const basePath = baseStore.getBaseDirectory() - - return new VirtualDirectoryHandle( - baseStore, - await basename(fullPath), - basePath - ? // pathArr may not contain full path starting from basePath if baseDirectory is not the root directory - fullPath.replace(`${basePath}${sep}`, '').split(sep) - : pathArr - ) -} - -export async function getDirectoryHandleIndexedDb( - baseDirectory: VirtualDirectoryHandle, - pathArr: string[], - { create, createOnce }: Partial -) { - // Cannot use fast path if createOnce is true - if (createOnce) return false - - const baseStore = baseDirectory.getBaseStore() - - // Cannot apply fast path if baseStore is not a IndexedDbStore - if (!(baseStore instanceof IndexedDbStore)) return false - - const fullPath = join(baseDirectory.idbKey, ...pathArr) - let directoryData = await baseStore - .getDirectoryEntries(fullPath) - .catch(() => null) - - if (create) { - await baseStore.createDirectory(fullPath) - directoryData = [] - } - - if (!directoryData) - throw new Error( - `Failed to access "${fullPath}": Directory does not exist` - ) - - return new VirtualDirectoryHandle( - baseStore, - pathArr[pathArr.length - 1], - fullPath.split(/\\|\//g) - ) -} diff --git a/src/components/FileSystem/FileSystem.ts b/src/components/FileSystem/FileSystem.ts deleted file mode 100644 index b486d28c4..000000000 --- a/src/components/FileSystem/FileSystem.ts +++ /dev/null @@ -1,460 +0,0 @@ -// This import is relative so the compiler types build correctly -import { Signal } from '../Common/Event/Signal' -import json5 from 'json5' -import type { IGetHandleConfig, IMkdirConfig } from './Common' -import { iterateDirParallel } from '/@/utils/iterateDir' -import { join, dirname, basename } from '/@/utils/path' -import { AnyDirectoryHandle, AnyFileHandle, AnyHandle } from './Types' -import { getStorageDirectory } from '/@/utils/getStorageDirectory' -import { VirtualFileHandle } from './Virtual/FileHandle' -import { VirtualDirectoryHandle } from './Virtual/DirectoryHandle' -import { - getDirectoryHandleIndexedDb, - getDirectoryHandleTauri, -} from './Fast/getDirectoryHandle' -import { pathFromHandle } from './Virtual/pathFromHandle' - -export class FileSystem extends Signal { - protected _baseDirectory!: AnyDirectoryHandle - get baseDirectory() { - return this._baseDirectory - } - - // To prevent multiple files from saving to the same file at once, we will delay saving until all previous save calls are finished - protected savingQueue: Map< - string, - { - currentSaveID: number - lastSaveID: number - } - > = new Map() - - constructor(baseDirectory?: AnyDirectoryHandle) { - super() - if (baseDirectory) this.setup(baseDirectory) - } - - setup(baseDirectory: AnyDirectoryHandle) { - this._baseDirectory = baseDirectory - - if (!this.hasFired) this.dispatch() - } - - async getDirectoryHandle( - path: string, - { create, createOnce }: Partial = {} - ) { - if (path === '' || path === '.') return this.baseDirectory - - let current = this.baseDirectory - const pathArr = path.split(/\\|\//g) - if (pathArr[0] === '.') pathArr.shift() - // Dash loads global extensions from "extensions/" but this folder is no longer used for bridge. projects - if (pathArr[0] === 'extensions') { - pathArr[0] = '~local' - pathArr.splice(1, 0, 'extensions') - } - if (pathArr[0] === '~local') { - current = await getStorageDirectory() - pathArr.shift() - } - - // Cannot apply fast path if baseDirectory is not a virtual directory - if (this.baseDirectory instanceof VirtualDirectoryHandle) { - /** - * Fast path for native app - * Every path segment costs at least 1 syscall on the slow path, whereas the fast path costs 1 syscall for the entire path - * Therefore, switch to the fast path if the path has more than 1 segment - */ - if (import.meta.env.VITE_IS_TAURI_APP && pathArr.length > 1) { - const fastCallResult = await getDirectoryHandleTauri( - this.baseDirectory, - pathArr, - { create, createOnce } - ) - // Returns false if the fast call failed - if (fastCallResult) return fastCallResult - } - - /** - * Fast path for IndexedDB backed file systems - */ - const fastCallResult = await getDirectoryHandleIndexedDb( - this.baseDirectory, - pathArr, - { create, createOnce } - ) - if (fastCallResult) return fastCallResult - } - - for (const folder of pathArr) { - try { - current = await current.getDirectoryHandle(folder, { - create: createOnce || create, - }) - } catch (err) { - throw new Error( - `Failed to access "${path}": Directory does not exist: ${err}` - ) - } - - if (createOnce) { - createOnce = false - create = false - } - } - - return current - } - async getFileHandle(path: string, create = false) { - if (path.length === 0) throw new Error(`Error: filePath is empty`) - - const folder = await this.getDirectoryHandle(dirname(path), { - create, - }) - - try { - return await folder.getFileHandle(basename(path), { create }) - } catch { - throw new Error(`File does not exist: "${path}"`) - } - } - async pathTo(handle: AnyHandle) { - const localHandle = await getStorageDirectory() - // We can only resolve paths to virtual files if the user uses the file system polyfill - if ( - handle instanceof VirtualFileHandle && - !(localHandle instanceof VirtualDirectoryHandle) - ) - return - - let path = await localHandle - .resolve(handle) - .then((path) => path?.join('/')) - - if (path) { - // Local projects don't exist for Tauri builds - if (!import.meta.env.VITE_IS_TAURI_APP) path = '~local/' + path - } else { - path = await this.baseDirectory - .resolve(handle) - .then((path) => path?.join('/')) - } - - return path - } - - async mkdir(path: string, { recursive }: Partial = {}) { - await this.getDirectoryHandle(path, { create: true }) - - // TODO: Fix non recursive mode - // if (recursive) await this.getDirectoryHandle(path, { create: true }) - // else await this.getDirectoryHandle(path, { createOnce: true }) - } - - readdir(path: string, config: { withFileTypes: true }): Promise - readdir(path: string, config?: { withFileTypes?: false }): Promise - async readdir( - path: string, - { withFileTypes }: { withFileTypes?: true | false } = {} - ) { - const dirHandle = await this.getDirectoryHandle(path) - const files: (string | AnyHandle)[] = [] - - for await (const handle of dirHandle.values()) { - if (handle.kind === 'file' && handle.name === '.DS_Store') continue - - if (withFileTypes) files.push(handle) - else files.push(handle.name) - } - - return files - } - async readFilesFromDir( - path: string, - dirHandle: - | AnyDirectoryHandle - | Promise = this.getDirectoryHandle(path) - ) { - dirHandle = await dirHandle - - const files: { name: string; path: string; kind: string }[] = [] - const promises = [] - - for await (const handle of dirHandle.values()) { - if (handle.kind === 'file' && handle.name === '.DS_Store') continue - - if (handle.kind === 'file') - files.push({ - name: handle.name, - kind: handle.kind, - path: `${path}/${handle.name}`, - }) - else if (handle.kind === 'directory') { - promises.push( - this.readFilesFromDir( - `${path}/${handle.name}`, - handle - ).then((subFiles) => files.push(...subFiles)) - ) - } - } - - await Promise.allSettled(promises) - - return files - } - - readFile(path: string) { - return this.getFileHandle(path).then((fileHandle) => - fileHandle.getFile() - ) - } - - async unlink(path: string) { - if (path.length === 0) throw new Error(`Error: filePath is empty`) - const pathArr = path.split(/\\|\//g) - - // This has to be a string because path.length > 0 - const file = pathArr.pop() - let parentDir: AnyDirectoryHandle - try { - parentDir = await this.getDirectoryHandle(pathArr.join('/')) - } catch { - return - } - - try { - await parentDir.removeEntry(file, { recursive: true }) - } catch {} - } - - async writeFile(path: string, data: FileSystemWriteChunkType) { - const fileHandle = await this.getFileHandle(path, true) - await this.write(fileHandle, data) - return fileHandle - } - - async write(fileHandle: AnyFileHandle, data: FileSystemWriteChunkType) { - // Safari doesn't support createWritable yet - // if (typeof fileHandle.createWritable !== 'function') { - // // @ts-ignore - // const handle = await fileHandle.createAccessHandle({ - // mode: 'in-place', - // }) - // await handle.writable.getWriter().write(data) - // handle.close() - // } else { - if (!this.savingQueue.has(fileHandle.name)) - this.savingQueue.set(fileHandle.name, { - currentSaveID: 0, - lastSaveID: 0, - }) - - const savingQueueEntry = this.savingQueue.get(fileHandle.name)! - const currentSaveID = savingQueueEntry.lastSaveID - savingQueueEntry.lastSaveID++ - - while (savingQueueEntry.currentSaveID !== currentSaveID) { - await new Promise((resolve) => setTimeout(resolve, 1)) - } - - const writable = await fileHandle.createWritable({ - keepExistingData: false, - }) - - await writable.write(data) - await writable.close() - - savingQueueEntry.currentSaveID++ - - if (savingQueueEntry.currentSaveID === savingQueueEntry.lastSaveID) - this.savingQueue.delete(fileHandle.name) - // } - } - - async readJSON(path: string) { - const file = await this.readFile(path) - try { - return await json5.parse(await file.text()) - } catch { - throw new Error(`Invalid JSON: ${path}`) - } - } - async readJsonHandle(fileHandle: AnyFileHandle) { - const file = await fileHandle.getFile() - - try { - return await json5.parse(await file.text()) - } catch { - throw new Error(`Invalid JSON: ${fileHandle.name}`) - } - } - writeJSON(path: string, data: any, beautify = false) { - return this.writeFile( - path, - JSON.stringify(data, null, beautify ? '\t' : undefined) - ) - } - - // TODO: Use moveHandle() util function - // This function can utilize FileSystemHandle.move() where available - // and therefore become more efficient - async move(path: string, newPath: string) { - if (await this.fileExists(path)) { - await this.copyFile(path, newPath) - } else if (await this.directoryExists(path)) { - await this.copyFolder(path, newPath) - } else { - throw new Error(`File or folder does not exist: ${path}`) - } - - await this.unlink(path) - } - - async copyFile(originPath: string, destPath: string) { - const originHandle = await this.getFileHandle(originPath, false) - const destHandle = await this.getFileHandle(destPath, true) - - return await this.copyFileHandle(originHandle, destHandle) - } - - async copyFileHandle( - originHandle: AnyFileHandle, - destHandle: AnyFileHandle - ) { - const file = await originHandle.getFile() - await this.write( - destHandle, - file.isVirtual ? await file.toBlobFile() : file - ) - - return destHandle - } - async copyFolder(originPath: string, destPath: string) { - const originHandle = await this.getDirectoryHandle(originPath, { - create: false, - }) - const destHandle = await this.getDirectoryHandle(destPath, { - create: true, - }) - - await this.copyFolderByHandle(originHandle, destHandle) - } - async copyFolderByHandle( - originHandle: AnyDirectoryHandle, - destHandle: AnyDirectoryHandle, - ignoreFolders?: Set - ) { - // Tauri build: Both handles are virtual -> Elligible for fast path - if ( - import.meta.env.VITE_IS_TAURI_APP && - originHandle instanceof VirtualDirectoryHandle && - destHandle instanceof VirtualDirectoryHandle - ) { - let src = null - - // Due to #964, the virtual file will not have a path, so instead we need to just read it from the virtual file handle - try { - await pathFromHandle(originHandle) - } catch {} - - if (src !== null) { - const dest = await pathFromHandle(destHandle) - - const { invoke } = await import('@tauri-apps/api') - await invoke('copy_directory', { src, dest }) - - return - } - } - - const destFs = new FileSystem(destHandle) - - await iterateDirParallel( - originHandle, - async (fileHandle, filePath) => { - await this.copyFileHandle( - fileHandle, - await destFs.getFileHandle(filePath, true) - ) - }, - ignoreFolders - ) - } - - loadFileHandleAsDataUrl(fileHandle: AnyFileHandle) { - return new Promise(async (resolve, reject) => { - const reader = new FileReader() - - try { - const file = await fileHandle.getFile() - - reader.addEventListener('load', () => { - resolve(reader.result) - }) - reader.addEventListener('error', reject) - reader.readAsDataURL( - file.isVirtual ? await file.toBlobFile() : file - ) - } catch { - reject(`File does not exist: "${fileHandle.name}"`) - } - }) - } - - async fileExists(path: string) { - try { - await this.getFileHandle(path) - return true - } catch { - return false - } - } - - async directoryExists(path: string) { - try { - await this.getDirectoryHandle(path) - return true - } catch { - return false - } - } - - async getDirectoryHandlesFromGlob(glob: string, startPath = '.') { - const globParts = glob.split(/\/|\\/g) - const handles = new Set([ - await this.getDirectoryHandle(startPath), - ]) - - for (const part of globParts) { - if (part === '*') { - for (const current of [...handles.values()]) { - handles.delete(current) - - for await (const child of current.values()) { - if (child.kind === 'directory') { - handles.add(child) - } - } - } - } else if (part === '**') { - console.warn('"**" is not supported yet') - return [] - } else { - for (const current of [...handles.values()]) { - handles.delete(current) - - try { - handles.add(await current.getDirectoryHandle(part)) - } catch (err) {} - } - } - - // If there are no more handles, we're done - if (handles.size === 0) return [] - } - - return [...handles.values()] - } -} diff --git a/src/components/FileSystem/FileWatcher.ts b/src/components/FileSystem/FileWatcher.ts deleted file mode 100644 index 5a5b81ad0..000000000 --- a/src/components/FileSystem/FileWatcher.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { App } from '/@/App' -import { Signal } from '/@/components/Common/Event/Signal' -import { IDisposable } from '/@/types/disposable' -import { VirtualFile } from './Virtual/File' - -export class FileWatcher extends EventDispatcher { - protected fileContent: any - protected disposable?: IDisposable - protected children: ChildFileWatcher[] = [] - public readonly ready = new Signal() - - constructor(protected app: App, public readonly filePath: string) { - super() - this.activate() - - app.project.getFileFromDiskOrTab(filePath).then(async (file) => { - await this.onFileChange(file) - await this.setup(file) - this.ready.dispatch() - }) - } - - async setup(file: File | VirtualFile) {} - - async activate() { - if (this.disposable !== undefined) return - - this.disposable = this.app.project.fileChange.on( - this.filePath, - (file) => this.onFileChange(file) - ) - } - async requestFile(file: File | VirtualFile) { - await this.onFileChange(file) - - return new Promise((resolve) => { - this.once((file) => resolve(file)) - }) - } - - async compileFile(file: File | VirtualFile) { - const [dependencies, compiled] = - await this.app.project.compilerService.compileFile( - this.filePath, - new Uint8Array(await file.arrayBuffer()) - ) - - this.children.forEach((child) => child.dispose()) - this.children = [] - - dependencies.forEach((dep) => { - const watcher = new ChildFileWatcher(this.app, dep) - this.children.push(watcher) - watcher.on(() => this.onFileChange(file)) - }) - - return new File([compiled], file.name) - } - - protected async onFileChange(file: File | VirtualFile) { - this.dispatch(await this.compileFile(file)) - } - async getFile() { - return this.compileFile( - await this.app.project.getFileFromDiskOrTab(this.filePath) - ) - } - dispose() { - this.disposable?.dispose() - this.disposable = undefined - this.children.forEach((child) => child.dispose()) - } -} - -export class ChildFileWatcher extends EventDispatcher { - protected disposable?: IDisposable - - constructor(protected app: App, public readonly filePath: string) { - super() - this.activate() - } - - async activate() { - if (this.disposable !== undefined) return - - this.disposable = this.app.project.fileSave.on(this.filePath, (file) => - this.onFileChange(file) - ) - } - - protected async onFileChange(data: File | VirtualFile) { - this.dispatch() - } - dispose() { - this.disposable?.dispose() - this.disposable = undefined - } -} diff --git a/src/components/FileSystem/FindFile.ts b/src/components/FileSystem/FindFile.ts deleted file mode 100644 index 693a603f6..000000000 --- a/src/components/FileSystem/FindFile.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { FileSystem } from './FileSystem' - -export async function findFileExtension( - fileSystem: FileSystem, - basePath: string, - possibleExtensions: string[] -) { - for (const extension of possibleExtensions) { - const currPath = `${basePath}${extension}` - if (await fileSystem.fileExists(currPath)) return currPath - } -} diff --git a/src/components/FileSystem/Pickers/showFolderPicker.ts b/src/components/FileSystem/Pickers/showFolderPicker.ts deleted file mode 100644 index 918f4397c..000000000 --- a/src/components/FileSystem/Pickers/showFolderPicker.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { VirtualDirectoryHandle } from '../Virtual/DirectoryHandle' - -interface IOpenFolderOpts { - multiple?: boolean - defaultPath?: string -} - -export async function showFolderPicker({ - defaultPath, - multiple, -}: IOpenFolderOpts = {}) { - if (!import.meta.env.VITE_IS_TAURI_APP) { - if (multiple) - console.warn( - 'Multiple folder selection is not supported in the browser yet' - ) - - const handle = await window - .showDirectoryPicker({ multiple: false, mode: 'readwrite' }) - .catch(() => null) - return handle ? [handle] : null - } - - const { TauriFsStore } = await import('../Virtual/Stores/TauriFs') - const { open } = await import('@tauri-apps/api/dialog') - const { basename } = await import('@tauri-apps/api/path') - - let selectedPath = await open({ - directory: true, - multiple, - defaultPath, - }).catch(() => null) - if (!selectedPath) return null - if (!Array.isArray(selectedPath)) selectedPath = [selectedPath] - - return await Promise.all( - selectedPath.map( - async (path) => - new VirtualDirectoryHandle( - new TauriFsStore(path), - await basename(path) - ) - ) - ) -} diff --git a/src/components/FileSystem/Polyfill.ts b/src/components/FileSystem/Polyfill.ts deleted file mode 100644 index af92dfd49..000000000 --- a/src/components/FileSystem/Polyfill.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { ref, markRaw } from 'vue' -import { VirtualDirectoryHandle } from './Virtual/DirectoryHandle' -import { VirtualFileHandle } from './Virtual/FileHandle' -import { IndexedDbStore } from './Virtual/Stores/IndexedDb' - -/** - * Chrome 93 and 94 crash when we try to call createWritable on a file handle inside of a web worker - * We therefore enable this polyfill to work around the bug - * - * Additionally, Brave, Opera and similar browsers do not support the FileSystem API so we enable - * the polyfill for all browsers which are not Chrome or Edge - * (Brave and Opera still have the API methods but they're NOOPs so our detection doesn't work) - */ -function isUnsupportedBrowser() { - const unsupportedChromeVersions = ['93', '94'] - - // @ts-ignore: TypeScript doesn't know about userAgentData yet - const userAgentData: any = navigator.userAgentData - if (!userAgentData) return true - - const chromeBrand = userAgentData.brands.find( - ({ brand }: any) => brand === 'Google Chrome' - ) - const edgeBrand = userAgentData.brands.find( - ({ brand }: any) => brand === 'Microsoft Edge' - ) - const operaBrand = userAgentData.brands.find( - ({ brand }: any) => brand === 'Opera GX' || brand === 'Opera' - ) - if (chromeBrand) - return unsupportedChromeVersions.includes(chromeBrand.version) - if (edgeBrand || operaBrand) return false - - return true -} - -export let isUsingFileSystemPolyfill = ref(false) -export let isUsingSaveAsPolyfill = false -export let isUsingOriginPrivateFs = false - -if ( - isUnsupportedBrowser() || - typeof window.showDirectoryPicker !== 'function' -) { - // TODO: Enable once safari properly supports file handles (createWritable) - if ( - false && - isUnsupportedBrowser() && - typeof navigator.storage.getDirectory === 'function' - ) { - isUsingOriginPrivateFs = true - - window.showDirectoryPicker = () => navigator.storage.getDirectory() - } else { - isUsingFileSystemPolyfill.value = true - - window.showDirectoryPicker = async () => - // @ts-ignore Typescript doesn't like our polyfill - markRaw( - new VirtualDirectoryHandle(new IndexedDbStore(), 'bridgeFolder') - ) - } -} - -if (isUnsupportedBrowser() || typeof window.showOpenFilePicker !== 'function') { - // @ts-ignore Typescript doesn't like our polyfill - window.showOpenFilePicker = async (options: OpenFilePickerOptions) => { - const opts = { types: [], ...options } - - const input = document.createElement('input') - input.type = 'file' - input.multiple = opts.multiple ?? false - input.accept = opts.types - .map((e: FilePickerAcceptType) => Object.values(e.accept)) - .flat(2) - .join(',') - input.style.display = 'none' - document.body.appendChild(input) - - let isLocked = false - return new Promise((resolve, reject) => { - input.addEventListener( - 'change', - async (event) => { - isLocked = true - const files = [...(input.files ?? [])] - - if (document.body.contains(input)) - document.body.removeChild(input) - - resolve( - // @ts-ignore - await Promise.all( - files.map(async (file) => - markRaw( - new VirtualFileHandle( - null, - file.name, - new Uint8Array(await file.arrayBuffer()) - ) - ) - ) - ) - ) - }, - { once: true } - ) - window.addEventListener( - 'focus', - () => { - setTimeout(() => { - if (isLocked) return - - reject('User aborted selecting file') - if (document.body.contains(input)) - document.body.removeChild(input) - }, 300) - }, - { once: true } - ) - - input.click() - }) - } -} - -export interface ISaveFilePickerOptions { - suggestedName?: string -} -if (isUnsupportedBrowser() || typeof window.showSaveFilePicker !== 'function') { - isUsingSaveAsPolyfill = true - - // @ts-ignore - window.showSaveFilePicker = async ( - // @ts-ignore - options?: ISaveFilePickerOptions = {} - ) => { - return new VirtualFileHandle( - null, - options.suggestedName ?? 'newFile.txt', - new Uint8Array() - ) - } -} - -/** - * In order to support drag and drop of files on our native Windows build, - * we need to polyfill the DataTransferItem.getAsFileSystemHandle method - * to also use a VirtualFileHandle (just like the base file system) - */ -if ( - import.meta.env.VITE_IS_TAURI_APP || - isUnsupportedBrowser() || - (globalThis.DataTransferItem && - !DataTransferItem.prototype.getAsFileSystemHandle) -) { - // @ts-ignore - DataTransferItem.prototype.getAsFileSystemHandle = async function () { - if (this.kind === 'file') { - const file = this.getAsFile() - - if (!file) return null - - // We need to use this to differentiate between file and folder on Tauri build - if (import.meta.env.VITE_IS_TAURI_APP) { - const entry = this.webkitGetAsEntry() - - if (entry !== null && entry.isDirectory) { - return markRaw( - createVirtualDirectoryFromFileSystemDirectoryEntry( - entry, - null, - [entry.name] - ) - ) - } - } - - return new VirtualFileHandle( - null, - file.name, - new Uint8Array(await file.arrayBuffer()) - ) - } else if (this.kind === 'directory') { - return markRaw(new VirtualDirectoryHandle(null, 'unknown')) - } - - return null - } -} - -async function createVirtualDirectoryFromFileSystemDirectoryEntry( - directoryEntry: FileSystemDirectoryEntry, - parent: VirtualDirectoryHandle | null, - path: string[] -): Promise { - const virtualDirectory = new VirtualDirectoryHandle( - parent, - directoryEntry.name, - path, - true - ) - await virtualDirectory.setupDone.fired - - const reader = directoryEntry.createReader() - - await new Promise((res) => { - reader.readEntries(async (results) => { - for (const entry of results) { - if (entry.isDirectory) { - createVirtualDirectoryFromFileSystemDirectoryEntry( - entry, - virtualDirectory, - [...path, entry.name] - ) - } - - if (entry.isFile) { - const file = await new Promise((res) => { - ;(entry).file((file) => { - res(file) - }) - }) - - const virtualFileHandle = - await virtualDirectory.getFileHandle(entry.name, { - create: true, - initialData: new Uint8Array( - await file.arrayBuffer() - ), - }) - - await virtualFileHandle.setupDone.fired - } - } - - res() - }) - }) - - return virtualDirectory -} diff --git a/src/components/FileSystem/Setup.ts b/src/components/FileSystem/Setup.ts deleted file mode 100644 index 862df15e5..000000000 --- a/src/components/FileSystem/Setup.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { App } from '/@/App' -import { get, set } from 'idb-keyval' -import { InformationWindow } from '../Windows/Common/Information/InformationWindow' -import { markRaw, ref } from 'vue' -import { Signal } from '../Common/Event/Signal' -import { AnyDirectoryHandle } from './Types' -import { VirtualDirectoryHandle } from './Virtual/DirectoryHandle' -import { isUsingFileSystemPolyfill, isUsingOriginPrivateFs } from './Polyfill' -import { - get as getVirtualDirectory, - has as hasVirtualDirectory, -} from './Virtual/IDB' -import { ConfirmationWindow } from '../Windows/Common/Confirm/ConfirmWindow' -import { FileSystem } from './FileSystem' -import { VirtualFileHandle } from './Virtual/FileHandle' -import { IndexedDbStore } from './Virtual/Stores/IndexedDb' - -type TFileSystemSetupStatus = 'waiting' | 'userInteracted' | 'done' - -export class FileSystemSetup { - static state = { - showInitialSetupDialog: ref(false), - receiveDirectoryHandle: new Signal(), - setupDone: new Signal(), - } - - protected confirmPermissionWindow: InformationWindow | null = null - protected _status: TFileSystemSetupStatus = 'waiting' - get status() { - return this._status - } - - setStatus(status: TFileSystemSetupStatus) { - this._status = status - } - - async setupFileSystem(app: App) { - let fileHandle = await get( - 'bridgeBaseDir' - ) - - if (!isUsingFileSystemPolyfill.value && fileHandle) { - // Request permissions to current bridge folder - fileHandle = await this.verifyPermissions(fileHandle) - } - - let isUpgradingVirtualFs = false - - // Test whether the user has a virtual file system setup - if ( - isUsingFileSystemPolyfill.value || - ((await hasVirtualDirectory('bridgeFolder')) && - ((await getVirtualDirectory('projects')) ?? []) - .length > 0) - ) { - if (!isUsingFileSystemPolyfill.value) { - // Ask user whether to upgrade to the real file system - const confirmWindow = new ConfirmationWindow({ - title: 'windows.upgradeFs.title', - description: 'windows.upgradeFs.description', - cancelText: 'general.later', - onCancel: () => { - isUsingFileSystemPolyfill.value = true - }, - onConfirm: async () => { - isUpgradingVirtualFs = true - isUsingFileSystemPolyfill.value = false - }, - }) - - await confirmWindow.fired - } - - // Only create virtual folder if we are not migrating away from the virtual file system - if (!isUpgradingVirtualFs) { - fileHandle = markRaw( - new VirtualDirectoryHandle( - new IndexedDbStore(), - 'bridgeFolder' - ) - ) - await fileHandle.setupDone.fired - } - } - - // There's currently no bridge folder yet/the bridge folder has been deleted - if (!fileHandle) { - const globalState = FileSystemSetup.state - globalState.showInitialSetupDialog.value = true - fileHandle = isUsingOriginPrivateFs - ? await window.showDirectoryPicker() - : await globalState.receiveDirectoryHandle.fired - - await this.verifyPermissions(fileHandle) - - // Safari doesn't support storing file handles inside of IndexedDB yet - try { - if (!(fileHandle instanceof VirtualDirectoryHandle)) - await set('bridgeBaseDir', fileHandle) - } catch {} - - globalState.setupDone.dispatch() - } - - if ( - isUsingFileSystemPolyfill.value && - !(await get('confirmedUnsupportedBrowser')) - ) { - // The user's browser doesn't support the native file system API - app.windows.browserUnsupported.open() - } - - // Migrate virtual projects over - if (isUpgradingVirtualFs) { - const virtualFolder = markRaw( - new VirtualDirectoryHandle(null, 'bridgeFolder') - ) - await virtualFolder.setupDone.fired - const virtualFs = new FileSystem(virtualFolder) - - const fs = new FileSystem(fileHandle) - - // 1. Migrate folders - const foldersToMigrate = ['projects', 'extensions'] - for (const folder of foldersToMigrate) { - const handle = ( - await virtualFs - .getDirectoryHandle(folder) - .catch(() => undefined) - ) - if (!handle) continue - - await fs.copyFolderByHandle( - handle, - await fs.getDirectoryHandle(folder, { create: true }) - ) - - await handle.removeSelf() - } - - // 2. Migrate files - const filesToMigrate = ['data/settings.json'] - for (const file of filesToMigrate) { - const handle = ( - await virtualFs.getFileHandle(file).catch(() => undefined) - ) - if (!handle || (await fs.fileExists(file))) continue - - await fs.copyFileHandle( - handle, - await fs.getFileHandle(file, true) - ) - - await handle.removeSelf() - } - } - - this._status = 'done' - return fileHandle - } - async verifyPermissions( - fileHandle: AnyDirectoryHandle, - tryImmediateRequest = true - ) { - // Safari doesn't support these functions just yet - if ( - typeof fileHandle.requestPermission !== 'function' || - typeof fileHandle.queryPermission !== 'function' - ) { - // Create the data directory and return - await fileHandle.getDirectoryHandle('data', { create: true }) - return fileHandle - } - - const opts = { writable: true, mode: 'readwrite' } as const - - // An additional user activation is no longer needed from Chromium 92 onwards. - // We can show our first prompt without an additional InformationWindow! - try { - if ( - tryImmediateRequest && - (await fileHandle.requestPermission(opts)) !== 'granted' - ) { - await this.verifyPermissions(fileHandle, false) - } - } catch {} - - if ( - (await fileHandle.queryPermission(opts)) !== 'granted' && - this.confirmPermissionWindow === null - ) { - this.confirmPermissionWindow = new InformationWindow({ - name: 'windows.projectFolder.title', - description: 'windows.projectFolder.content', - onClose: async () => { - this._status = 'userInteracted' - this.confirmPermissionWindow = null - - // Check if we already have permission && request permission if not - if ( - (await fileHandle.requestPermission(opts)) !== 'granted' - ) { - await this.verifyPermissions(fileHandle, false) - } - }, - }) - - await this.confirmPermissionWindow.fired - } - - // This checks whether the bridge directory still exists. - // Might get a more elegant API in the future but this method works for now - try { - await fileHandle.getDirectoryHandle('data', { create: true }) - } catch { - return undefined - } - return fileHandle - } -} diff --git a/src/components/FileSystem/Types.ts b/src/components/FileSystem/Types.ts deleted file mode 100644 index 229b31f26..000000000 --- a/src/components/FileSystem/Types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { VirtualDirectoryHandle } from './Virtual/DirectoryHandle' -import { VirtualFileHandle } from './Virtual/FileHandle' -import { VirtualHandle } from './Virtual/Handle' - -export type AnyHandle = - | FileSystemFileHandle - | FileSystemDirectoryHandle - | VirtualHandle -export type AnyDirectoryHandle = - | FileSystemDirectoryHandle - | VirtualDirectoryHandle -export type AnyFileHandle = FileSystemFileHandle | VirtualFileHandle diff --git a/src/components/FileSystem/Virtual/Comlink.ts b/src/components/FileSystem/Virtual/Comlink.ts deleted file mode 100644 index 2650a55c9..000000000 --- a/src/components/FileSystem/Virtual/Comlink.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { TransferHandler, transferHandlers } from 'comlink' -import { IDirectory, IQueryResult } from '../../FindAndReplace/Worker/Worker' -import { AnyDirectoryHandle } from '../Types' -import { VirtualDirectoryHandle } from './DirectoryHandle' -import { VirtualFileHandle } from './FileHandle' - -export interface ISerializedFileHandle { - kind: 'file' - name: string - path: string[] - fileData?: Uint8Array - baseStore?: any -} - -transferHandlers.set('VirtualFileHandle', < - TransferHandler ->{ - canHandle: (obj): obj is VirtualFileHandle => - obj instanceof VirtualFileHandle, - serialize: (obj) => { - return [obj.serialize(), []] - }, - deserialize: (obj) => new VirtualFileHandle(null, obj.name, obj.fileData), -}) - -export interface ISerializedDirectoryHandle { - kind: 'directory' - name: string - path: string[] - baseStore?: any - children?: (ISerializedFileHandle | ISerializedDirectoryHandle)[] -} - -transferHandlers.set('VirtualDirectoryHandle', < - TransferHandler ->{ - canHandle: (obj): obj is VirtualDirectoryHandle => { - return obj instanceof VirtualDirectoryHandle - }, - - serialize: (obj) => { - return [obj.serialize(), []] - }, - deserialize: (obj) => VirtualDirectoryHandle.deserialize(obj), -}) - -/** - * Make IDirectory[] work for Find & Replace tab - */ -interface IVirtualDirectoryHandleArray { - directory: VirtualDirectoryHandle - [key: string]: any -} -interface ISerializedDirectoryHandleArray { - directory: ISerializedDirectoryHandle - [key: string]: any -} -transferHandlers.set('VirtualDirectoryHandleArray', < - TransferHandler< - IVirtualDirectoryHandleArray[], - ISerializedDirectoryHandleArray[] - > ->{ - canHandle: (arr): arr is IVirtualDirectoryHandleArray[] => { - return ( - Array.isArray(arr) && - arr.every( - ({ directory }) => directory instanceof VirtualDirectoryHandle - ) - ) - }, - - serialize: (arr) => { - return [ - arr.map(({ directory, ...other }) => ({ - directory: directory.serialize(), - ...other, - })), - [], - ] - }, - deserialize: (arr) => - arr.map(({ directory, ...other }) => ({ - directory: VirtualDirectoryHandle.deserialize(directory), - ...other, - })), -}) - -/** - * Make IQueryResult transfer correctly - */ -interface IVirtualFileHandleArray { - fileHandle: VirtualFileHandle - [key: string]: any -} -interface ISerializedVirtualFileHandleArray { - fileHandle: ISerializedFileHandle - [key: string]: any -} -transferHandlers.set('VirtualFileHandleArray', < - TransferHandler< - IVirtualFileHandleArray[], - ISerializedVirtualFileHandleArray[] - > ->{ - canHandle: (arr): arr is IVirtualFileHandleArray[] => { - return ( - Array.isArray(arr) && - arr.every( - ({ fileHandle }) => fileHandle instanceof VirtualFileHandle - ) - ) - }, - - serialize: (arr) => { - return [ - arr.map(({ fileHandle, ...other }) => ({ - fileHandle: fileHandle.serialize(), - ...other, - })), - [], - ] - }, - deserialize: (arr) => - arr.map(({ fileHandle, ...other }) => ({ - fileHandle: VirtualFileHandle.deserialize(fileHandle), - ...other, - })), -}) diff --git a/src/components/FileSystem/Virtual/DirectoryHandle.ts b/src/components/FileSystem/Virtual/DirectoryHandle.ts deleted file mode 100644 index 2773a8437..000000000 --- a/src/components/FileSystem/Virtual/DirectoryHandle.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { VirtualHandle, BaseVirtualHandle } from './Handle' -import { VirtualFileHandle } from './FileHandle' -import { ISerializedDirectoryHandle } from './Comlink' -import { BaseStore, FsKindEnum, IDirEntry } from './Stores/BaseStore' -import { MemoryStore } from './Stores/Memory' -import { deserializeStore } from './Stores/Deserialize' -import { getParent } from './getParent' - -/** - * A class that implements a virtual folder - */ -export class VirtualDirectoryHandle extends BaseVirtualHandle { - public readonly kind = 'directory' - /** - * @depracted - */ - public readonly isDirectory = true - /** - * @depracted - */ - public readonly isFile = false - - async moveToIdb() { - if (!this._baseStore) - throw new Error(`Must call method on top-level directory`) - if (!(this._baseStore instanceof MemoryStore)) - throw new Error(`Must call method on memory store`) - - this._baseStore = await this._baseStore.toIdb( - // Do not allow writes to data-fs - true - ) - } - - constructor( - parent: VirtualDirectoryHandle | BaseStore | null, - name: string, - path: string[] = [], - create = false - ) { - super(parent, name, path) - - this.setup(create) - } - - async setup(create: boolean) { - await this.setupStore() - - /** - * TauriFsStore should not create the base directory because that'll lead to duplicated directories - */ - if (this.idbKey !== '' && create) - await this.baseStore.createDirectory(this.idbKey) - - this.setupDone.dispatch() - } - - protected async fromStore() { - return (await this.baseStore.getDirectoryEntries(this.idbKey)) ?? [] - } - - protected async getChildren() { - return ( - ( - await Promise.all( - (await this.fromStore()).map((name) => this.getChild(name)) - ) - ).filter((child) => child !== undefined) - ) - } - protected getChildPath(child: IDirEntry | string) { - const childName = typeof child === 'string' ? child : child.name - - return this.path.concat(childName).join('/') - } - protected async getChild(child: IDirEntry | string) { - const childName = typeof child === 'string' ? child : child.name - const type = - typeof child === 'string' - ? await this.baseStore.typeOf(this.getChildPath(child)) - : child.kind === FsKindEnum.Directory - ? 'directory' - : 'file' - - if (type === 'file') { - return new VirtualFileHandle(this, childName) - } else if (type === 'directory') { - return new VirtualDirectoryHandle(this, childName) - } else if (type === null) { - return undefined - } else { - throw new Error(`Unknown type ${type}`) - } - } - - protected async hasChildren() { - return (await this.fromStore()).length > 0 - } - serialize(): ISerializedDirectoryHandle { - let baseStore: BaseStore | undefined = undefined - if (this.baseStore) baseStore = this.baseStore.serialize() - - return { - baseStore, - kind: 'directory', - name: this.name, - path: this.path, - } - } - static deserialize(data: ISerializedDirectoryHandle) { - let baseStore: BaseStore | null = null - - if (data.baseStore) baseStore = deserializeStore(data.baseStore) - - const dir = new VirtualDirectoryHandle(baseStore, data.name, data.path) - - return dir - } - - async getDirectoryHandle( - name: string, - { create }: { create?: boolean } = {} - ) { - let entry = await this.getChild(name) - - if (entry && entry.kind === 'file') { - throw new Error( - `TypeMismatch: Expected directory with name "${name}", found file` - ) - } else if (!entry) { - if (create) { - entry = new VirtualDirectoryHandle(this, name, [], true) - await entry.setupDone.fired - } else { - throw new Error( - `No directory with the name "${name}" exists in this folder` - ) - } - } - - return entry - } - async getFileHandle( - name: string, - { - create, - initialData, - }: { create?: boolean; initialData?: Uint8Array } = {} - ) { - let entry = await this.getChild(name) - - if (entry && entry.kind === 'directory') { - throw new Error( - `TypeMismatch: Expected file with name "${name}", found directory` - ) - } else if (!entry) { - if (create) { - entry = new VirtualFileHandle( - this, - name, - initialData ?? new Uint8Array() - ) - await entry.setupDone.fired - } else { - throw new Error( - `No file with the name "${name}" exists in this folder` - ) - } - } - - return entry - } - async removeEntry( - name: string, - { recursive }: { recursive?: boolean } = {} - ) { - const entry = await this.getChild(name) - - if (!entry) { - throw new Error( - `No entry with the name "${name}" exists in this folder` - ) - } else if ( - entry.kind === 'directory' && - !recursive && - (await (entry).hasChildren()) - ) { - throw new Error( - `Cannot remove directory with children without "recursive" option being set to true` - ) - } - - await entry.removeSelf() - } - async resolve(possibleDescendant: VirtualHandle) { - const path: string[] = [possibleDescendant.name] - - let current = possibleDescendant.getParent() - while (current !== null && !(await current.isSameEntry(this))) { - path.unshift(current.name) - current = current.getParent() - } - - if (current === null) return null - return path - } - async removeSelf() { - const children = await this.getChildren() - - for (const child of children) { - await child.removeSelf() - } - - await this.baseStore.unlink(this.idbKey) - } - getParent() { - // We don't have a parent but we do have a base path -> We can traverse path backwards to create parent handle - if (this.parent === null && this.basePath.length > 0) { - this.parent = getParent(this.baseStore, this.basePath) - } - return this.parent - } - - [Symbol.asyncIterator]() { - return this.entries() - } - - keys() { - return { - childNamesPromise: this.fromStore(), - - [Symbol.asyncIterator]() { - return < - AsyncIterableIterator & { - i: number - childNamesPromise: Promise - } - >{ - i: 0, - childNamesPromise: this.childNamesPromise, - - async next() { - const childNames = await this.childNamesPromise - - if (this.i < childNames.length) - return { - done: false, - value: childNames[this.i++], - } - else return { done: true } - }, - } - }, - } - } - entries() { - return { - childrenPromise: this.getChildren(), - - [Symbol.asyncIterator]() { - return < - AsyncIterableIterator<[string, VirtualHandle]> & { - i: number - childrenPromise: Promise - } - >{ - i: 0, - childrenPromise: this.childrenPromise, - - async next() { - const children = await this.childrenPromise - - if (this.i < children.length) - return { - done: false, - value: [ - children[this.i].name, - children[this.i++], - ], - } - else return { done: true } - }, - } - }, - } - } - values() { - return { - childrenPromise: this.getChildren(), - - [Symbol.asyncIterator]() { - return < - AsyncIterableIterator & { - i: number - childrenPromise: Promise - } - >{ - i: 0, - childrenPromise: this.childrenPromise, - - async next() { - const children = await this.childrenPromise - - if (this.i < children.length) - return { - done: false, - value: children[this.i++], - } - else return { done: true } - }, - } - }, - } - } - - /** - * @deprecated - */ - getEntries() { - return this.values() - } - /** - * @deprecated - */ - getDirectory(name: string, opts: { create?: boolean } = {}) { - return this.getDirectoryHandle(name, opts) - } - /** - * @deprecated - */ - getFile(name: string, opts: { create?: boolean } = {}) { - return this.getFileHandle(name, opts) - } -} diff --git a/src/components/FileSystem/Virtual/File.ts b/src/components/FileSystem/Virtual/File.ts deleted file mode 100644 index 6eba0d619..000000000 --- a/src/components/FileSystem/Virtual/File.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { BaseStore } from './Stores/BaseStore' - -const textDecoder = new TextDecoder() - -export class VirtualFile { - public readonly isVirtual = true - public readonly type: string - public readonly lastModified: number - public readonly size: number - protected _cachedBuffer: ArrayBuffer | null = null - - constructor( - protected baseStore: BaseStore, - protected readonly path: string, - [size, lastModified, type]: readonly [number, number, string] - ) { - this.type = type - this.size = size - this.lastModified = lastModified - } - - static async for(baseStore: BaseStore, path: string): Promise { - return new VirtualFile(baseStore, path, await baseStore.metadata(path)) - } - - get name() { - return this.path.split('/').pop()! - } - - async arrayBuffer() { - if (this._cachedBuffer) return this._cachedBuffer - - this._cachedBuffer = typedArrayToBuffer( - await this.baseStore.read(this.path) - ) - - return this._cachedBuffer - } - - async text() { - return textDecoder.decode(await this.arrayBuffer()) - } - - stream() { - return new ReadableStream({ - start: (controller) => { - this.arrayBuffer().then((arrayBuffer) => { - controller.enqueue(new Uint8Array(arrayBuffer)) - controller.close() - }) - }, - }) - } - - async toBlobFile() { - return new File([await this.arrayBuffer()], this.name, { - type: this.type, - }) - } -} - -function typedArrayToBuffer(array: Uint8Array): ArrayBuffer { - return array.buffer.slice( - array.byteOffset, - array.byteLength + array.byteOffset - ) -} - -declare global { - interface File { - isVirtual: false - } -} - -File.prototype.isVirtual = false diff --git a/src/components/FileSystem/Virtual/FileHandle.ts b/src/components/FileSystem/Virtual/FileHandle.ts deleted file mode 100644 index 65eb56e48..000000000 --- a/src/components/FileSystem/Virtual/FileHandle.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { BaseVirtualHandle } from './Handle' -import { VirtualDirectoryHandle } from './DirectoryHandle' -import { VirtualWritable, writeMethodSymbol } from './VirtualWritable' -import { ISerializedFileHandle } from './Comlink' -import { BaseStore } from './Stores/BaseStore' -import { deserializeStore } from './Stores/Deserialize' -import { MemoryStore } from './Stores/Memory' -import { getParent } from './getParent' - -/** - * A class that implements a virtual file - */ -export class VirtualFileHandle extends BaseVirtualHandle { - public readonly kind = 'file' - /** - * @depracted - */ - public readonly isFile = true - /** - * @depracted - */ - public readonly isDirectory = false - - constructor( - parent: VirtualDirectoryHandle | BaseStore | null, - name: string, - data?: Uint8Array, - path?: string[] - ) { - super(parent, name, path) - - this.setup(data) - } - protected async setup(fileData?: Uint8Array) { - await this.setupStore() - - // We only need to write data files from the main thread, web workers can just load the already written data from the main thread - // Since MemoryStore extends IndexedDBStore, we cannot use instanceof on the IndexedDBStore directly - if (!globalThis.document && !(this.baseStore instanceof MemoryStore)) { - this.setupDone.dispatch() - return - } - - if (fileData) await this.baseStore.writeFile(this.idbKey, fileData) - - this.setupDone.dispatch() - } - - serialize(): ISerializedFileHandle { - let baseStore: BaseStore | undefined = undefined - if (this.baseStore) baseStore = this.baseStore.serialize() - - return { - baseStore, - kind: 'file', - name: this.name, - path: this.path, - } - } - static deserialize(data: ISerializedFileHandle) { - let baseStore: BaseStore | null = null - if (data.baseStore) baseStore = deserializeStore(data.baseStore) - - return new VirtualFileHandle( - baseStore, - data.name, - data.fileData, - data.path - ) - } - - getParent() { - // We don't have a parent but we do have a base path -> We can traverse path backwards to create parent handle - if (this.parent === null && this.basePath.length > 0) { - this.parent = getParent(this.baseStore, this.basePath) - } - return this.parent - } - - override async isSameEntry(other: BaseVirtualHandle): Promise { - if (this.parent === null) return false - - return super.isSameEntry(other) - } - - async removeSelf() { - await this.baseStore.unlink(this.idbKey) - } - - async getFile() { - return await this.baseStore.readFile(this.idbKey) - } - async createWritable() { - return new VirtualWritable(this) - } - async [writeMethodSymbol](data: Uint8Array) { - await this.baseStore.writeFile(this.idbKey, data) - } -} diff --git a/src/components/FileSystem/Virtual/Handle.ts b/src/components/FileSystem/Virtual/Handle.ts deleted file mode 100644 index 377446c70..000000000 --- a/src/components/FileSystem/Virtual/Handle.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { VirtualDirectoryHandle } from './DirectoryHandle' -import type { VirtualFileHandle } from './FileHandle' -import { v4 as v4Uuid } from 'uuid' -import { Signal } from '../../Common/Event/Signal' -import { BaseStore } from './Stores/BaseStore' -import { MemoryStore } from './Stores/Memory' -import { TauriFsStore } from './Stores/TauriFs' - -export type VirtualHandle = VirtualDirectoryHandle | VirtualFileHandle - -export abstract class BaseVirtualHandle { - protected _baseStore: BaseStore | null = null - protected parent: VirtualDirectoryHandle | null = null - public readonly isVirtual = true - public abstract readonly kind: 'directory' | 'file' - public readonly setupDone = new Signal() - protected includeSelfInPath = true - - constructor( - parent: VirtualDirectoryHandle | BaseStore | null, - protected _name: string, - protected basePath: string[] = [] - ) { - if (parent === null) { - this._baseStore = new MemoryStore() - } else if (parent instanceof BaseStore) { - this._baseStore = parent - } else { - this.parent = parent - } - - /** - * TauriFs should not include self in path for top-level directory handle - */ if (this._baseStore instanceof TauriFsStore) { - this.includeSelfInPath = false - } - } - - async setupStore() { - if (this._baseStore) await this._baseStore.setup() - } - getBaseStore() { - return this.baseStore - } - - protected get path(): string[] { - return this.parent - ? [...this.parent.path.concat(this.name)] - : [...this.basePath] - } - /** - * Returns whether a handle has parent context - * - * e.g. whether a FileHandle is backed by an IDB entry - */ - get hasParentContext() { - return this.path.length > 1 - } - get idbKey() { - if (this.path.length === 0) - return this.includeSelfInPath ? this.name : '' - return this.path.join('/') - } - protected get baseStore(): BaseStore { - if (this._baseStore === null) { - return this.parent!.baseStore - } - return this._baseStore - } - abstract removeSelf(): Promise - - get name() { - return this._name - } - abstract getParent(): VirtualDirectoryHandle | null - abstract serialize(): unknown - - async isSameEntry(other: BaseVirtualHandle) { - return other.idbKey === this.idbKey - } - - async queryPermission( - _: FileSystemHandlePermissionDescriptor - ): Promise { - return 'granted' - } - async requestPermission( - _: FileSystemHandlePermissionDescriptor - ): Promise { - return 'granted' - } -} diff --git a/src/components/FileSystem/Virtual/IDB.ts b/src/components/FileSystem/Virtual/IDB.ts deleted file mode 100644 index 615be6763..000000000 --- a/src/components/FileSystem/Virtual/IDB.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - createStore, - set as rawSet, - get as rawGet, - del as rawDel, - setMany as rawSetMany, - getMany as rawGetMany, - clear as clearRaw, - keys as rawKeys, - UseStore, -} from 'idb-keyval' - -const virtualFs = createStore('virtual-fs', 'virtual-fs-store') - -export const set = (key: IDBValidKey, value: any) => - rawSet(key, value, virtualFs) -export const get = (key: IDBValidKey) => rawGet(key, virtualFs) -export const del = (key: IDBValidKey) => rawDel(key, virtualFs) -export const setMany = (arr: [IDBValidKey, any][]) => rawSetMany(arr, virtualFs) -export const getMany = (arr: IDBValidKey[]) => rawGetMany(arr, virtualFs) -export const clear = () => clearRaw(virtualFs) -export const has = async (key: IDBValidKey) => (await get(key)) !== undefined -export const keys = () => rawKeys(virtualFs) - -export class IDBWrapper { - protected store: UseStore - - constructor(public readonly storeName: string = 'virtual-fs') { - this.store = createStore(storeName, `${storeName}-store`) - } - - set(key: IDBValidKey, value: any) { - return rawSet(key, value, this.store) - } - get(key: IDBValidKey) { - return rawGet(key, this.store) - } - del(key: IDBValidKey) { - return rawDel(key, this.store) - } - setMany(arr: [IDBValidKey, any][]) { - return rawSetMany(arr, this.store) - } - getMany(arr: IDBValidKey[]) { - return rawGetMany(arr, this.store) - } - clear() { - return clearRaw(this.store) - } - async has(key: IDBValidKey) { - return (await get(key)) !== undefined - } - keys() { - return rawKeys(this.store) - } -} diff --git a/src/components/FileSystem/Virtual/ProjectWindow.ts b/src/components/FileSystem/Virtual/ProjectWindow.ts deleted file mode 100644 index 59277ce2e..000000000 --- a/src/components/FileSystem/Virtual/ProjectWindow.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { exportAsBrproject } from '/@/components/Projects/Export/AsBrproject' -import { InformedChoiceWindow } from '/@/components/Windows/InformedChoice/InformedChoice' -import { App } from '/@/App' -import { importNewProject } from '/@/components/Projects/Import/ImportNew' - -export async function createVirtualProjectWindow() { - // Prompt user whether to open new project or to save the current one - const choiceWindow = new InformedChoiceWindow( - 'windows.projectChooser.title', - { - isPersistent: false, - } - ) - const actions = await choiceWindow.actionManager - - actions.create({ - icon: 'mdi-plus', - name: 'windows.projectChooser.newProject.name', - description: 'windows.projectChooser.newProject.description', - onTrigger: async () => { - const app = await App.getApp() - app.windows.createProject.open() - }, - }) - - actions.create({ - icon: 'mdi-content-save-outline', - name: 'windows.projectChooser.saveCurrentProject.name', - description: 'windows.projectChooser.saveCurrentProject.description', - onTrigger: () => exportAsBrproject(), - }) - - actions.create({ - icon: 'mdi-folder-open-outline', - name: 'windows.projectChooser.openNewProject.name', - description: 'windows.projectChooser.openNewProject.description', - onTrigger: () => importNewProject(), - }) -} diff --git a/src/components/FileSystem/Virtual/Stores/BaseStore.ts b/src/components/FileSystem/Virtual/Stores/BaseStore.ts deleted file mode 100644 index c1f2540ac..000000000 --- a/src/components/FileSystem/Virtual/Stores/BaseStore.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { VirtualFile } from '../File' - -export const FsKindEnum = { - Directory: 0, - File: 1, -} -export type TFsKind = typeof FsKindEnum['Directory' | 'File'] -export interface IFsEntry { - kind: TFsKind - data: T -} -export interface IFileData extends IFsEntry { - kind: typeof FsKindEnum.File - lastModified: number -} -export interface IDirectoryData extends IFsEntry { - kind: typeof FsKindEnum.Directory -} -export interface IDirEntry { - kind: TFsKind - name: string -} - -export type TStoreType = 'idbStore' | 'memoryStore' | 'tauriFsStore' - -/** - * Base implementation of a file system backing store that can be used by our file system access polyfill - */ -export abstract class BaseStore { - public abstract readonly type: TStoreType - - constructor(protected isReadOnly = false) {} - - /** - * Any async setup that needs to be done before the store can be used - */ - async setup() {} - - abstract serialize(): T & { type: TStoreType } - static deserialize(data: any & { type: TStoreType }): BaseStore { - throw new Error('BaseStore deserialization not implemented') - } - - /** - * Create directory - */ - abstract createDirectory(path: string): Promise - - /** - * Get directory entries - */ - abstract getDirectoryEntries(path: string): Promise<(IDirEntry | string)[]> - - /** - * Write file - */ - abstract writeFile(path: string, data: Uint8Array): Promise - - /** - * Read file - */ - abstract readFile(path: string): Promise - - /** - * Return when a file was last modified and its size - * - * @returns [size, lastModified] - */ - metadata(path: string) { - return this.readFile(path).then( - (file) => [file.size, file.lastModified, file.type] - ) - } - /** - * Return the content of a file as a Uint8Array - */ - read(path: string) { - return this.readFile(path) - .then((file) => file.arrayBuffer()) - .then((buffer) => new Uint8Array(buffer)) - } - - /** - * Unlink a file or directory - */ - abstract unlink(path: string): Promise - - /** - * Return the type of a given path - * @returns null if the path does not exist - */ - abstract typeOf(path: string): Promise -} diff --git a/src/components/FileSystem/Virtual/Stores/Deserialize.ts b/src/components/FileSystem/Virtual/Stores/Deserialize.ts deleted file mode 100644 index 6b76e7b47..000000000 --- a/src/components/FileSystem/Virtual/Stores/Deserialize.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { BaseStore, TStoreType } from './BaseStore' -import { IndexedDbStore } from './IndexedDb' -import { MemoryStore } from './Memory' -import { TauriFsStore } from './TauriFs' - -export function deserializeStore(data: any & { type: TStoreType }): BaseStore { - if (typeof data !== 'object' || data === null) - throw new Error('BaseStore deserialization data must be an object') - if (!('type' in data)) - throw new Error( - 'BaseStore deserialization data must have a type property' - ) - - switch (data.type) { - case 'idbStore': - return IndexedDbStore.deserialize(data) - case 'memoryStore': - return MemoryStore.deserialize(data) - case 'tauriFsStore': - return TauriFsStore.deserialize(data) - default: - throw new Error(`Unknown base store type: ${data.type}`) - } -} diff --git a/src/components/FileSystem/Virtual/Stores/IndexedDb.ts b/src/components/FileSystem/Virtual/Stores/IndexedDb.ts deleted file mode 100644 index f664a85b7..000000000 --- a/src/components/FileSystem/Virtual/Stores/IndexedDb.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { IDBWrapper } from '../IDB' -import { - BaseStore, - FsKindEnum, - IDirectoryData, - IDirEntry, - IFileData, - TFsKind, - TStoreType, -} from './BaseStore' -import { GlobalMutex } from '/@/components/Common/GlobalMutex' -import { basename, dirname } from '/@/utils/path' - -export interface IIndexedDbSerializedData { - storeName?: string - mapData?: [string, any][] -} - -export class IndexedDbStore extends BaseStore { - public readonly type = 'idbStore' - protected idb: IDBWrapper - protected globalMutex = new GlobalMutex() - - constructor(storeName?: string, isReadOnly = false) { - super(isReadOnly) - this.idb = new IDBWrapper(storeName) - } - - serialize() { - return { - type: this.type, - storeName: this.idb.storeName, - } - } - static deserialize(data: IIndexedDbSerializedData & { type: TStoreType }) { - return new IndexedDbStore(data.storeName) - } - - lockAccess(path: string) { - return this.globalMutex.lock(path) - } - unlockAccess(path: string) { - return this.globalMutex.unlock(path) - } - - clear() { - if (this.isReadOnly) return - - return this.idb.clear() - } - - protected async addChild( - parentDir: string, - childName: string, - childKind: TFsKind - ) { - // If parent directory is root directory, we don't need to manually add a child entry - if (parentDir === '' || parentDir === '.') return - - await this.lockAccess(parentDir) - - let parentChilds = await this.idb.get( - parentDir - ) - - if (parentChilds === undefined) { - await this.createDirectory(parentDir) - parentChilds = [] - } - - if (Array.isArray(parentChilds)) { - // Old format where we stored dir entries as strings - - // Filter out childName from parentChilds - parentChilds = parentChilds - .filter((child) => typeof child === 'string') // This filter call is only there to fix already duplicated entries in the database. Can be removed in a future update - .filter((child) => child !== childName) - - // Push new child entry - parentChilds.push(childName) - } else { - // New format where we store dir entries as objects - - // Filter out childName from parentChilds - parentChilds = { - kind: FsKindEnum.Directory, - data: parentChilds.data.filter((child) => - typeof child === 'string' - ? child !== childName - : child.name !== childName - ), - } - - // Push new child entry - parentChilds.data.push({ - kind: childKind, - name: childName, - }) - } - - await this.idb.set(parentDir, parentChilds) - - this.unlockAccess(parentDir) - } - - async createDirectory(path: string) { - if (this.isReadOnly) return - if (path === '' || path === '.') return - - const dirExists = await this.idb.has(path) - if (dirExists) return // No work to do, directory already exists - - const parentDir = dirname(path) - const dirName = basename(path) - - await this.addChild(parentDir, dirName, FsKindEnum.Directory) - await this.idb.set(path, { kind: FsKindEnum.Directory, data: [] }) - } - - async getDirectoryEntries(path: string) { - const entries = await this.idb.get(path) - if (entries === undefined) { - throw new Error( - `Trying to get directory entries for ${path} but it does not exist` - ) - } - - return Array.isArray(entries) ? entries : entries.data - } - - async writeFile(path: string, data: Uint8Array) { - if (this.isReadOnly) return - - const parentDir = dirname(path) - const fileName = basename(path) - - await this.addChild(parentDir, fileName, FsKindEnum.File) - - await this.idb.set(path, { - kind: FsKindEnum.File, - lastModified: Date.now(), - data, - }) - } - - async readFile(path: string) { - const rawData = await this.idb.get(path) - - if (rawData === undefined) { - throw new Error(`Trying to read file ${path} that does not exist`) - } - - let data: Uint8Array - let lastModified = Date.now() - // Old format where we stored Uint8Array directly - if (rawData instanceof Uint8Array) { - data = rawData - } else { - lastModified = rawData.lastModified ?? Date.now() - data = rawData.data ?? new Uint8Array() - } - - return new File([data], basename(path), { lastModified }) - } - - async unlink(path: string) { - if (this.isReadOnly) return - - const parentDir = dirname(path) - const fileName = basename(path) - - await this.lockAccess(parentDir) - - const parentChilds = await this.idb.get( - parentDir - ) - if (parentChilds !== undefined) { - if (Array.isArray(parentChilds)) { - await this.idb.set( - parentDir, - parentChilds.filter((child) => child !== fileName) - ) - } else { - await this.idb.set(parentDir, { - kind: FsKindEnum.Directory, - data: parentChilds.data.filter( - (child) => child.name !== fileName - ), - }) - } - } - // TODO: Fix directory unlinking - - await this.idb.del(path) - - this.unlockAccess(parentDir) - } - - async typeOf(path: string) { - const data = await this.idb.get(path) - if (data === undefined) return null - - if (data instanceof Uint8Array) return 'file' - else if (Array.isArray(data)) return 'directory' - else return data.kind === FsKindEnum.Directory ? 'directory' : 'file' // New object based file format - } -} diff --git a/src/components/FileSystem/Virtual/Stores/Memory.ts b/src/components/FileSystem/Virtual/Stores/Memory.ts deleted file mode 100644 index 4a5c78290..000000000 --- a/src/components/FileSystem/Virtual/Stores/Memory.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { IDBWrapper } from '../IDB' -import { TFsKind, TStoreType } from './BaseStore' -import { IIndexedDbSerializedData, IndexedDbStore } from './IndexedDb' - -export class MemoryDb extends IDBWrapper { - public readonly type = 'memoryStore' - protected _store: Map - - constructor( - public readonly storeName: string = 'virtual-fs', - mapData?: [IDBValidKey, any][] - ) { - super(storeName) - - this._store = new Map(mapData) - } - - async set(key: IDBValidKey, value: any) { - this._store.set(key, value) - } - async get(key: IDBValidKey) { - return this._store.get(key) - } - async del(key: IDBValidKey) { - this._store.delete(key) - } - async setMany(arr: [IDBValidKey, any][]) { - for (const [key, value] of arr) { - await this.set(key, value) - } - } - async getMany(arr: IDBValidKey[]) { - const res: T[] = [] - for (const key of arr) { - res.push(await this.get(key)) - } - return res - } - async clear() { - return this._store.clear() - } - async has(key: IDBValidKey) { - return this._store.has(key) - } - async keys() { - return [...this._store.keys()] - } - entries() { - return this._store.entries() - } - - async toIdb(isReadOnly = false) { - await super.setMany([...this._store.entries()]) - return new IndexedDbStore(this.storeName, isReadOnly) - } -} - -export class MemoryStore extends IndexedDbStore { - protected idb: MemoryDb - - constructor(storeName?: string, mapData?: [IDBValidKey, any][]) { - super(storeName) - this.idb = new MemoryDb(storeName, mapData) - } - - serialize() { - return { - type: this.type, - storeName: this.idb.storeName, - mapData: [...this.idb.entries()], - } - } - static deserialize(data: IIndexedDbSerializedData & { type: TStoreType }) { - return new MemoryStore(data.storeName, data.mapData) - } - - toIdb(isReadOnly = false) { - return this.idb.toIdb(isReadOnly) - } -} diff --git a/src/components/FileSystem/Virtual/Stores/TauriFs.ts b/src/components/FileSystem/Virtual/Stores/TauriFs.ts deleted file mode 100644 index 54b3b9ade..000000000 --- a/src/components/FileSystem/Virtual/Stores/TauriFs.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - createDir, - readDir, - writeBinaryFile, - readBinaryFile, - removeDir, - removeFile, -} from '@tauri-apps/api/fs' -import { isAbsolute, join, sep } from '@tauri-apps/api/path' -import { invoke } from '@tauri-apps/api/tauri' -import { VirtualFile } from '../File' -import { BaseStore, FsKindEnum, type TStoreType } from './BaseStore' - -export interface ITauriFsSerializedData { - baseDirectory?: string -} - -export class TauriFsStore extends BaseStore { - public readonly type = 'tauriFsStore' - - constructor(protected baseDirectory?: string) { - super() - } - - getBaseDirectory() { - return this.baseDirectory - } - - serialize() { - return { - type: this.type, - baseDirectory: this.baseDirectory, - } - } - static deserialize(data: ITauriFsSerializedData & { type: TStoreType }) { - return new TauriFsStore(data.baseDirectory) - } - - async setup() { - if (this.isReadOnly) return - - if (this.baseDirectory) - await createDir(this.baseDirectory, { recursive: true }).catch( - () => { - // Ignore error if directory already exists - } - ) - } - - async resolvePath(path: string) { - path = path.replaceAll(/\\|\//g, sep) - if (!this.baseDirectory) return path - if (await isAbsolute(path)) return path - return await join(this.baseDirectory, path) - } - - async createDirectory(path: string) { - if (this.isReadOnly) return - - await createDir(await this.resolvePath(path)).catch(() => { - // Ignore error if directory already exists - }) - } - - async getDirectoryEntries(path: string) { - const entries = await readDir(await this.resolvePath(path)) - - return entries.map((entry) => ({ - kind: entry.children ? FsKindEnum.Directory : FsKindEnum.File, - name: entry.name!, - })) - } - - async writeFile(path: string, data: Uint8Array) { - if (this.isReadOnly) return - - await writeBinaryFile(await this.resolvePath(path), data) - } - - async readFile(path: string): Promise { - return await VirtualFile.for(this, path) - } - - async metadata(path: string) { - return await invoke( - 'get_file_metadata', - { - path: await this.resolvePath(path), - } - ) - } - - async read(path: string) { - const rawArray = await invoke>('read_file', { - path: await this.resolvePath(path), - }) - - return new Uint8Array(rawArray) - } - - async unlink(path: string) { - if (this.isReadOnly) return - - const type = await this.typeOf(path) - // Path does not exist, nothing to unlink - if (type === null) return - - if (type === 'file') { - await removeFile(await this.resolvePath(path)) - } else { - await removeDir(await this.resolvePath(path)) - } - } - - async typeOf(path: string) { - const resolvedPath = await this.resolvePath(path) - - try { - await readBinaryFile(resolvedPath) - return 'file' - } catch (err) { - try { - await readDir(resolvedPath) - return 'directory' - } catch (err) { - return null - } - } - } -} diff --git a/src/components/FileSystem/Virtual/VirtualWritable.ts b/src/components/FileSystem/Virtual/VirtualWritable.ts deleted file mode 100644 index cf69f1178..000000000 --- a/src/components/FileSystem/Virtual/VirtualWritable.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { VirtualFileHandle } from './FileHandle' - -const textEncoder = new TextEncoder() -export const writeMethodSymbol = Symbol('writeMethod') - -export class VirtualWritable { - protected tmpData = new Uint8Array() - protected cursorOffset = 0 - locked = false - - constructor(protected fileHandle: VirtualFileHandle) {} - - async write(data: FileSystemWriteChunkType): Promise { - let rawData: Uint8Array - if (typeof data === 'string') rawData = textEncoder.encode(data) - else if (data instanceof Blob) - rawData = await data - .arrayBuffer() - .then((buffer) => new Uint8Array(buffer)) - else if ('type' in data) { - if (data.type === 'seek') return await this.seek(data.position) - else if (data.type === 'truncate') - return await this.truncate(data.size) - else if (data.type === 'write') { - if (data.position) await this.seek(data.position) - - return await this.write(data.data) - } else { - // @ts-expect-error - throw new Error(`Unknown data type: ${data.type}`) - } - } else if (!ArrayBuffer.isView(data)) rawData = new Uint8Array(data) - else rawData = new Uint8Array(data.buffer) - - this.tmpData = new Uint8Array([ - ...this.tmpData.slice(0, this.cursorOffset), - ...rawData, - ]) - this.cursorOffset = this.tmpData.length - } - - async seek(offset: number) { - this.cursorOffset = Math.floor(offset) - } - async truncate(size: number) { - this.tmpData = this.tmpData.slice(0, size) - - if (this.cursorOffset > size) { - this.cursorOffset = size - } - } - - async close() { - await this.fileHandle[writeMethodSymbol](this.tmpData) - } - async abort() { - throw new Error(`WriteStream was aborted`) - } - getWriter(): any {} -} diff --git a/src/components/FileSystem/Virtual/getParent.ts b/src/components/FileSystem/Virtual/getParent.ts deleted file mode 100644 index eba429f27..000000000 --- a/src/components/FileSystem/Virtual/getParent.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { VirtualDirectoryHandle } from './DirectoryHandle' -import { BaseStore } from './Stores/BaseStore' - -export function getParent(baseStore: BaseStore, basePath: string[]) { - return new VirtualDirectoryHandle( - baseStore, - // Base path always contains itself so new directory handle name is at index - 2 - basePath.length > 1 ? basePath[basePath.length - 2] : '', - basePath.slice(0, -1) - ) -} diff --git a/src/components/FileSystem/Virtual/pathFromHandle.ts b/src/components/FileSystem/Virtual/pathFromHandle.ts deleted file mode 100644 index a3404446e..000000000 --- a/src/components/FileSystem/Virtual/pathFromHandle.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { AnyHandle } from '../Types' -import { BaseVirtualHandle } from './Handle' - -export async function pathFromHandle(handle: AnyHandle) { - if (!import.meta.env.VITE_IS_TAURI_APP) - throw new Error('Can only get path from handle in Tauri builds') - if (!(handle instanceof BaseVirtualHandle)) - throw new Error(`Expected a virtual handle`) - - const { TauriFsStore } = await import('./Stores/TauriFs') - - const baseStore = handle.getBaseStore() - if (!(baseStore instanceof TauriFsStore)) - throw new Error( - `Expected a TauriFsStore instance to back VirtualHandle` - ) - - return await baseStore.resolvePath(handle.idbKey) -} diff --git a/src/components/FileSystem/Zip/GenericUnzipper.ts b/src/components/FileSystem/Zip/GenericUnzipper.ts deleted file mode 100644 index 9f46c75f6..000000000 --- a/src/components/FileSystem/Zip/GenericUnzipper.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ITaskDetails, Task } from '/@/components/TaskManager/Task' -import { TaskManager } from '/@/components/TaskManager/TaskManager' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { AnyDirectoryHandle } from '../Types' - -export abstract class GenericUnzipper { - protected fileSystem: FileSystem - protected task?: Task - - constructor(protected directory: AnyDirectoryHandle) { - this.fileSystem = new FileSystem(directory) - } - - createTask( - taskManager: TaskManager, - taskDetails: ITaskDetails = { - icon: 'mdi-folder-zip', - name: 'taskManager.tasks.unzipper.name', - description: 'taskManager.tasks.unzipper.description', - } - ) { - this.task = taskManager.create(taskDetails) - } - - abstract unzip(data: T): Promise -} diff --git a/src/components/FileSystem/Zip/StreamingUnzipper.ts b/src/components/FileSystem/Zip/StreamingUnzipper.ts deleted file mode 100644 index 8b86f70ca..000000000 --- a/src/components/FileSystem/Zip/StreamingUnzipper.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { AsyncUnzipInflate, Unzip, UnzipFile } from 'fflate' -import { GenericUnzipper } from './GenericUnzipper' -import { FileSystem } from '../FileSystem' -import { wait } from '/@/utils/wait' -import { invoke } from '@tauri-apps/api/tauri' - -/** - * Streaming variant of the Unzipper class. It is slightly faster and consumes less memory. - */ -export class StreamingUnzipper extends GenericUnzipper { - /** - * Unzip the given data - * @param data Data to unzip - * @returns Promise - */ - async unzip(data: Uint8Array) { - const fs = new FileSystem(this.directory) - - if (import.meta.env.VITE_IS_TAURI_APP) { - this.task?.update(0, 200) - - // returns in the form of path --> array of u8s - const files: { [key: string]: number[] } = await invoke( - 'unzip_command', - { data: Array.from(data) } - ) - - this.task?.update(100, 200) - - const paths = Object.keys(files) - for (let fileIndex = 0; fileIndex < paths.length; fileIndex++) { - const path = paths[fileIndex] - await fs.writeFile(path, new Uint8Array(files[path])) - - this.task?.update( - 100 + Math.floor((fileIndex / paths.length) * 100), - 200 - ) - } - - this.task?.complete() - - return - } - - this.task?.update(0, data.length) - let streamedBytes = 0 - let totalFiles = 0 - let currentFileCount = 0 - - const unzipStream = new Promise(async (resolve, reject) => { - const unzip = new Unzip(async (stream) => { - totalFiles++ - - // Skip directories - if (!stream.name.endsWith('/')) { - await this.streamFile(fs, stream).catch((err) => - reject(err) - ) - } - - streamedBytes += stream.size ?? 0 - this.task?.update(streamedBytes) - - currentFileCount++ - // Is this safe to do? There seems to be no better way to detect that the stream is done processing - if (currentFileCount === totalFiles) { - this.task?.complete() - resolve() - } - }) - - unzip.register(AsyncUnzipInflate) - unzip.push(data, true) - }) - - await unzipStream - - // For some reason, Chromium doesn't transfer all files upon calling writable.close() at the desired time. - // This means that some files are imported incorrectly if we don't wait a bit. - // (Data would still be within .crswap files) - await wait(100) - } - - /** - * Handle a single streamed file - */ - protected async streamFile(fs: FileSystem, stream: UnzipFile) { - const fileHandle = await fs.getFileHandle(stream.name, true) - const writable = await fileHandle.createWritable() - let writeIndex = 0 - const writePromises: Promise[] = [] - - const streamedFile = new Promise((resolve, reject) => { - stream.ondata = (err, chunk, final) => { - if (err) return reject(err) - - if (chunk) { - writePromises.push( - writable.write({ - type: 'write', - data: chunk, - position: writeIndex, - }) - ) - writeIndex += chunk.length - } - - if (final) { - resolve() - } - } - }) - - stream.start() - - await streamedFile - await Promise.all(writePromises) - await writable.close() - } -} diff --git a/src/components/FileSystem/Zip/Unzipper.ts b/src/components/FileSystem/Zip/Unzipper.ts deleted file mode 100644 index f5d89cb68..000000000 --- a/src/components/FileSystem/Zip/Unzipper.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { unzip } from 'fflate' -import { basename } from '/@/utils/path' -import { GenericUnzipper } from './GenericUnzipper' -import { FileSystem } from '../FileSystem' - -/** - * @deprecated Use StreamingUnzipper where possible. It is slightly faster and consumes less memory. - */ -export class Unzipper extends GenericUnzipper { - unzip(data: Uint8Array) { - return new Promise(async (resolve, reject) => { - unzip(data, async (error, zip) => { - if (error) return reject(error) - const fs = new FileSystem(this.directory) - - this.task?.update(0, Object.keys(zip).length) - - let currentFileCount = 0 - for (const filePath in zip) { - const name = basename(filePath) - if (name.startsWith('.')) { - this.task?.update(++currentFileCount) - // @ts-expect-error - zip[filePath] = undefined - continue - } - - if (filePath.endsWith('/')) { - this.task?.update(++currentFileCount) - // @ts-expect-error - zip[filePath] = undefined - continue - } - - await this.fileSystem.write( - await fs.getFileHandle(filePath, true), - zip[filePath] - ) - // @ts-expect-error - zip[filePath] = undefined - this.task?.update(++currentFileCount) - } - - this.task?.complete() - resolve() - }) - - // @ts-expect-error - data = undefined - }) - } -} diff --git a/src/components/FileSystem/Zip/ZipDirectory.ts b/src/components/FileSystem/Zip/ZipDirectory.ts deleted file mode 100644 index 5dfd79140..000000000 --- a/src/components/FileSystem/Zip/ZipDirectory.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { zip, Zippable, zipSync } from 'fflate' -import { AnyDirectoryHandle } from '../Types' -import { iterateDirParallel } from '/@/utils/iterateDir' -import { invoke } from '@tauri-apps/api/tauri' - -export class ZipDirectory { - constructor(protected handle: AnyDirectoryHandle) {} - - async package(ignoreFolders?: Set) { - if (import.meta.env.VITE_IS_TAURI_APP) { - const files: { [key: string]: number[] } = {} - - await iterateDirParallel( - this.handle, - async (fileHandle, filePath) => { - const file = await fileHandle.getFile() - files[filePath] = Array.from( - new Uint8Array(await file.arrayBuffer()) - ) - }, - ignoreFolders - ) - - return new Uint8Array(await invoke('zip_command', { files })) - } - - let directoryContents: Zippable = {} - await iterateDirParallel( - this.handle, - async (fileHandle, filePath) => { - const file = await fileHandle.getFile() - directoryContents[filePath] = new Uint8Array( - await file.arrayBuffer() - ) - }, - ignoreFolders - ) - - return new Promise((resolve, reject) => - zip(directoryContents, { level: 6 }, (error, data) => { - if (error) { - reject(error) - return - } - - resolve(data) - }) - ) - } -} diff --git a/src/components/FileSystem/saveOrDownload.ts b/src/components/FileSystem/saveOrDownload.ts deleted file mode 100644 index 5fb8c8d2f..000000000 --- a/src/components/FileSystem/saveOrDownload.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { translate } from '../Locales/Manager' -import { createNotification } from '../Notifications/create' -import { InformationWindow } from '../Windows/Common/Information/InformationWindow' -import { FileSystem } from './FileSystem' -import { isUsingFileSystemPolyfill, isUsingOriginPrivateFs } from './Polyfill' -import { App } from '/@/App' -import { basename, extname } from '/@/utils/path' -import { revealInFileExplorer } from '/@/utils/revealInFileExplorer' - -export async function saveOrDownload( - filePath: string, - fileData: Uint8Array, - fileSystem: FileSystem -) { - if ( - import.meta.env.VITE_IS_TAURI_APP || - !isUsingOriginPrivateFs || - isUsingFileSystemPolyfill.value - ) { - await fileSystem.writeFile(filePath, fileData) - } - - const notification = createNotification({ - icon: 'mdi-download', - color: 'success', - textColor: 'white', - message: 'general.successfulExport.title', - isVisible: true, - onClick: async () => { - const app = await App.getApp() - - if (import.meta.env.VITE_IS_TAURI_APP) { - const { join } = await import('@tauri-apps/api/path') - const { getBridgeFolderPath } = await import( - '/@/utils/getBridgeFolderPath' - ) - - revealInFileExplorer( - await join(await getBridgeFolderPath(), filePath) - ) - } else if ( - app.project.isLocal || - isUsingOriginPrivateFs || - isUsingFileSystemPolyfill.value - ) { - download(basename(filePath), fileData) - } else { - new InformationWindow({ - description: `[${translate( - 'general.successfulExport.description' - )}: "${filePath}"]`, - }) - } - - notification.dispose() - }, - }) -} - -const knownExtensions = new Set([ - '.mcpack', - '.mcaddon', - '.mcworld', - '.mctemplate', - '.brproject', - - '.mcfunction', - '.lang', - '.material', -]) - -export function download(fileName: string, fileData: Uint8Array) { - const extension = extname(fileName) - let type: string | undefined = undefined - - // File downloads are not working on Tauri atm - if (import.meta.env.VITE_IS_TAURI_APP) { - tauriDownload(fileName, fileData) - return - } - - // Maintain the extension from the fileName, if the file that is being downloaded has a known extension - if (knownExtensions.has(extension)) type = 'application/file-export' - - const url = URL.createObjectURL(new Blob([fileData], { type })) - const a = document.createElement('a') - a.download = fileName - a.href = url - a.click() - - URL.revokeObjectURL(url) -} - -async function tauriDownload(fileName: string, fileData: Uint8Array) { - const { writeBinaryFile, exists } = await import('@tauri-apps/api/fs') - const { downloadDir, join } = await import('@tauri-apps/api/path') - - let freeFileName = fileName - let i = 1 - while (await exists(await join(await downloadDir(), freeFileName))) { - const extension = extname(freeFileName) - // Remove extension and counter (i) from the file name - const name = basename(freeFileName, extension).replace(/ \(\d+\)$/, '') - freeFileName = `${name} (${i})${extension}` - i++ - } - - await writeBinaryFile( - await join(await downloadDir(), freeFileName), - fileData - ) - await revealInFileExplorer(await join(await downloadDir(), freeFileName)) -} diff --git a/src/components/FindAndReplace/Controls/SearchType.vue b/src/components/FindAndReplace/Controls/SearchType.vue deleted file mode 100644 index c6cc5ba1e..000000000 --- a/src/components/FindAndReplace/Controls/SearchType.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - diff --git a/src/components/FindAndReplace/Controls/searchType.ts b/src/components/FindAndReplace/Controls/searchType.ts deleted file mode 100644 index 379e696a9..000000000 --- a/src/components/FindAndReplace/Controls/searchType.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const searchType = { - matchCase: 0, - ignoreCase: 1, - useRegExp: 2, -} diff --git a/src/components/FindAndReplace/FilePath.vue b/src/components/FindAndReplace/FilePath.vue deleted file mode 100644 index 2da6ac64c..000000000 --- a/src/components/FindAndReplace/FilePath.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - - - diff --git a/src/components/FindAndReplace/Match.vue b/src/components/FindAndReplace/Match.vue deleted file mode 100644 index f68ab6e5f..000000000 --- a/src/components/FindAndReplace/Match.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - - - diff --git a/src/components/FindAndReplace/Tab.ts b/src/components/FindAndReplace/Tab.ts deleted file mode 100644 index 5e6b620e2..000000000 --- a/src/components/FindAndReplace/Tab.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { reactive, markRaw } from 'vue' -import { Remote, wrap } from 'comlink' -import { searchType } from './Controls/searchType' -import FindAndReplaceComponent from './Tab.vue' -import type { - FindAndReplace, - IDirectory, - IQueryOptions, - IQueryResult, -} from './Worker/Worker' -import { Tab } from '/@/components/TabSystem/CommonTab' -import Worker from './Worker/Worker?worker' -import { TabSystem } from '../TabSystem/TabSystem' -import { Mutex } from '../Common/Mutex' -import { AnyFileHandle } from '../FileSystem/Types' -import { translate } from '../Locales/Manager' -import { setupWorker } from '/@/utils/worker/setup' - -const worker = new Worker() -const FindAndReplaceClass = wrap(worker) -setupWorker(worker) - -interface ITabState { - scrollTop: number - searchFor: string - replaceWith: string - queryOptions: IQueryOptions - queryResults: IQueryResult[] -} - -export class FindAndReplaceTab extends Tab { - component = markRaw(FindAndReplaceComponent) - findAndReplace!: Remote - directories = markRaw([]) - state = reactive({ - scrollTop: 0, - searchFor: '', - replaceWith: '', - queryOptions: { - isReadOnly: false, - searchType: searchType.matchCase, - }, - queryResults: [], - }) - shouldLoadPackDirs = false - isSearchFree = new Mutex() - - constructor( - protected parent: TabSystem, - directories?: IDirectory[], - queryOptions?: IQueryOptions - ) { - super(parent) - // Support setting queryOptions on tab creation - if (queryOptions) this.state.queryOptions = queryOptions - - if (directories) this.directories = markRaw(directories) - else this.shouldLoadPackDirs = true - } - - async setup() { - await this.isSearchFree.lock() - - if (this.shouldLoadPackDirs) { - this.shouldLoadPackDirs = false - const app = this.parent.app - const project = app.project - - const packs = project.getPacks() - for (const packId of packs) { - const path = project.config.resolvePackPath(packId) - const directory = await app.fileSystem - .getDirectoryHandle(path) - .catch(() => null) - - if (directory) - this.directories.push({ - directory, - path, - }) - } - } - - this.findAndReplace = await new FindAndReplaceClass( - this.parent.projectRoot, - this.parent.project.projectPath - ) - await super.setup() - - this.isSearchFree.unlock() - } - - async updateQuery() { - this.isTemporary = false - await this.isSearchFree.lock() - - this.isLoading = true - this.state.queryResults = await this.findAndReplace.createQuery( - this.directories, - this.state.searchFor, - this.state.queryOptions - ) - this.isLoading = false - this.state.scrollTop = 0 - - this.isSearchFree.unlock() - } - async executeQuery() { - await this.isSearchFree.lock() - this.isLoading = true - - await this.findAndReplace.executeQuery( - this.directories, - this.state.searchFor, - this.state.replaceWith, - this.state.queryOptions - ) - - await Promise.all( - this.state.queryResults.map(({ fileHandle }) => - this.parent.app.project.updateHandle(fileHandle) - ) - ) - - this.state.queryResults = [] - this.state.scrollTop = 0 - - this.isLoading = false - this.isSearchFree.unlock() - } - async executeSingleQuery(filePath: string, fileHandle: AnyFileHandle) { - await this.isSearchFree.lock() - this.isLoading = true - - const oldMatchedFiles = await this.findAndReplace.setMatchedFiles([ - filePath, - ]) - - await this.findAndReplace.executeQuery( - this.directories, - this.state.searchFor, - this.state.replaceWith, - this.state.queryOptions - ) - - await this.findAndReplace.setMatchedFiles( - oldMatchedFiles.filter( - (currentFilePath) => currentFilePath !== filePath - ) - ) - - await this.parent.app.project.updateHandle(fileHandle) - - this.state.queryResults = this.state.queryResults.filter( - (queryResult) => queryResult.filePath !== filePath - ) - - this.isLoading = false - this.isSearchFree.unlock() - } - - static is() { - return false - } - async is() { - return false - } - - async onActivate() {} - - get icon() { - return 'mdi-file-search-outline' - } - get iconColor() { - return 'success' - } - get name() { - const t = (key: string) => translate(key) - - return ( - (this.state.queryOptions.isReadOnly - ? t('findAndReplace.findOnly') - : t('findAndReplace.name')) + - (this.directories.length === 1 - ? `: "${this.directories[0].directory.name}"` - : '') - ) - } - - save() {} -} diff --git a/src/components/FindAndReplace/Tab.vue b/src/components/FindAndReplace/Tab.vue deleted file mode 100644 index 7d58cf340..000000000 --- a/src/components/FindAndReplace/Tab.vue +++ /dev/null @@ -1,219 +0,0 @@ - - - diff --git a/src/components/FindAndReplace/Utils.ts b/src/components/FindAndReplace/Utils.ts deleted file mode 100644 index 219f3e038..000000000 --- a/src/components/FindAndReplace/Utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import escapeRegExpString from 'escape-string-regexp' -import { searchType } from './Controls/searchType' - -export function processFileText( - fileText: string, - regExp: RegExp, - replaceWith: string -) { - return fileText.replace(regExp, (substring, ...groups) => { - groups = groups.slice(0, -2) - // This allows users to reference capture groups like this: {0} - return replaceWith.replace( - /{(\d+)}/g, - (match, ...replaceGroups) => - groups[Number(replaceGroups[0])] ?? match - ) - }) -} - -export function createRegExp(searchFor: string, type: number) { - let regExp: RegExp - try { - regExp = new RegExp( - type === searchType.useRegExp - ? searchFor - : escapeRegExpString(searchFor), - `g${type === searchType.ignoreCase ? 'i' : ''}` - ) - } catch { - return - } - return regExp -} diff --git a/src/components/FindAndReplace/Worker/Worker.ts b/src/components/FindAndReplace/Worker/Worker.ts deleted file mode 100644 index 81efc1486..000000000 --- a/src/components/FindAndReplace/Worker/Worker.ts +++ /dev/null @@ -1,206 +0,0 @@ -// @ts-ignore Make "path" work on this worker -import '/@/utils/worker/inject' - -import '/@/components/FileSystem/Virtual/Comlink' -import { expose } from 'comlink' -import { FileSystem } from '../../FileSystem/FileSystem' -import { iterateDir } from '/@/utils/iterateDir' -import { extname, join, relative } from '/@/utils/path' - -import { createRegExp, processFileText } from '../Utils' -import { AnyDirectoryHandle, AnyFileHandle } from '../../FileSystem/Types' -import { ProjectConfig } from '../../Projects/Project/Config' - -export interface IQueryOptions { - searchType: number - isReadOnly?: boolean -} -export interface IDirectory { - directory: AnyDirectoryHandle - path: string -} -export interface IQueryResult { - fileHandle: AnyFileHandle - filePath: string - displayFilePath: string - matches: IMatch[] -} -export interface IMatch { - isStartOfFile: boolean - beforeMatch: string - match: string - afterMatch: string -} - -const knownTextFiles = new Set([ - '.js', - '.ts', - '.lang', - '.mcfunction', - '.txt', - '.md', - '.molang', - '.json', - '.html', -]) -const ignoreFolders = new Set(['.bridge', 'builds', '.git s']) -const textPreviewLength = 100 - -export class FindAndReplace { - protected fileSystem: FileSystem - protected matchedFiles = new Set() - protected config: ProjectConfig - - constructor( - protected projectFolderHandle: AnyDirectoryHandle, - protected projectPath: string - ) { - this.fileSystem = new FileSystem(projectFolderHandle) - this.config = new ProjectConfig(this.fileSystem, projectPath) - } - - setMatchedFiles(matchedFiles: string[]) { - const oldFiles = this.matchedFiles - this.matchedFiles = new Set(matchedFiles) - - return [...oldFiles] - } - - async createQuery( - directories: IDirectory[], - searchFor: string, - { searchType }: IQueryOptions - ) { - this.matchedFiles.clear() - - if (searchFor === '') return [] - - const queryResults: IQueryResult[] = [] - const regExp = createRegExp(searchFor, searchType) - if (!regExp) return [] - - const promises = [] - for (const directory of directories) { - promises.push( - this.iterateDirectory(directory, regExp, queryResults) - ) - } - await Promise.all(promises) - - return queryResults - } - - async executeQuery( - directories: IDirectory[], - searchFor: string, - replaceWith: string, - { searchType }: IQueryOptions - ) { - if (searchFor === '') return [] - - const regExp = createRegExp(searchFor, searchType) - if (!regExp) return [] - - const promises = [] - for (const directory of directories) { - promises.push(this.replaceAll(directory, regExp, replaceWith)) - } - await Promise.all(promises) - } - - protected async iterateDirectory( - { directory, path }: IDirectory, - regExp: RegExp, - queryResults: IQueryResult[] - ) { - await iterateDir( - directory, - async (fileHandle, filePath) => { - const ext = extname(filePath) - if (!knownTextFiles.has(ext)) return - - const matches: IMatch[] = [] - const fileText = await fileHandle - .getFile() - .then((file) => file.text()) - - regExp.lastIndex = 0 - - let currentMatch = regExp.exec(fileText) - while (currentMatch !== null) { - let beforeMatchStart = - currentMatch.index - textPreviewLength / 2 - let beforeMatchLength = textPreviewLength / 2 - if (beforeMatchStart < 0) { - beforeMatchLength = beforeMatchLength + beforeMatchStart - beforeMatchStart = 0 - } - - matches.push({ - isStartOfFile: beforeMatchStart === 0, - beforeMatch: fileText.substr( - beforeMatchStart, - beforeMatchLength - ), - match: currentMatch[0], - afterMatch: fileText.substr( - currentMatch.index + currentMatch[0].length, - textPreviewLength / 2 - ), - }) - currentMatch = regExp.exec(fileText) - } - - if (matches.length > 0) { - this.matchedFiles.add(filePath) - queryResults.push({ - filePath, - displayFilePath: join( - `./${directory.name}`, - relative(path, filePath) - ), - fileHandle, - matches, - }) - } - }, - ignoreFolders, - path - ) - } - - protected async replaceAll( - { directory, path }: IDirectory, - regExp: RegExp, - replaceWith: string - ) { - await iterateDir( - directory, - async (fileHandle, filePath) => { - const ext = extname(filePath) - if ( - !knownTextFiles.has(ext) || - !this.matchedFiles.has(filePath) - ) - return - - const fileText = await fileHandle - .getFile() - .then((file) => file.text()) - - const newFileText = processFileText( - fileText, - regExp, - replaceWith - ) - - if (fileText !== newFileText) - await this.fileSystem.write(fileHandle, newFileText) - }, - ignoreFolders, - path - ) - } -} - -expose(FindAndReplace) diff --git a/src/components/Greet/Greet.vue b/src/components/Greet/Greet.vue index ff5d5281b..52692f579 100644 --- a/src/components/Greet/Greet.vue +++ b/src/components/Greet/Greet.vue @@ -1,364 +1,147 @@ + + - - diff --git a/src/components/Greet/ProjectGalleryEntry.vue b/src/components/Greet/ProjectGalleryEntry.vue new file mode 100644 index 000000000..e121031d4 --- /dev/null +++ b/src/components/Greet/ProjectGalleryEntry.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/components/ImportFile/BBModel.ts b/src/components/ImportFile/BBModel.ts deleted file mode 100644 index c5c13fb15..000000000 --- a/src/components/ImportFile/BBModel.ts +++ /dev/null @@ -1,786 +0,0 @@ -import { App } from '/@/App' -import { FileImporter } from '/@/components/ImportFile/Importer' -import { FileDropper } from '/@/components/FileDropper/FileDropper' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { extname, join } from '/@/utils/path' -import { DropdownWindow } from '../Windows/Common/Dropdown/DropdownWindow' -import { clamp } from '/@/utils/math/clamp' - -type Vector3D = [number, number, number] -type Vector2D = [number, number] - -interface IBone { - name: string - parent?: string - mirror?: boolean - binding?: unknown - reset?: boolean - material?: unknown - rotation?: Vector3D - pivot?: Vector3D - cubes?: ICube[] - locators?: { - [key: string]: ILocator - } -} - -interface ICube { - origin?: Vector3D - size: Vector3D - rotation?: Vector3D - inflate?: Vector3D - pivot?: Vector3D - uv?: Vector2D | any - mirror?: boolean -} - -interface ILocator { - offset: Vector3D - rotation?: Vector3D -} - -interface IAnimation { - loop?: string | boolean - animation_length?: number - override_previous_animation?: boolean - anim_time_update?: string - blend_weight?: string - start_delay?: string - loop_delay?: string - bones?: - | { - [key: string]: object - } - | undefined - sound_effects?: { - [key: string]: object - } - particle_effects?: { - [key: string]: object - } - timeline?: { - [key: string]: object - } -} - -export class BBModelImporter extends FileImporter { - constructor(fileDropper: FileDropper) { - super(['.bbmodel'], fileDropper) - } - - async onImport(fileHandle: AnyFileHandle) { - const app = await App.getApp() - - const file = await fileHandle.getFile() - const data = JSON.parse(await file.text()) - - app.windows.loadingWindow.open() - - const promises = [] - - if (data.textures) promises.push(this.exportImages(app, data.textures)) - if (data.elements && data.outliner) - promises.push(this.exportModel(app, data)) - if (data.animations) - promises.push( - this.exportAnimations(app, data.animations, data.name) - ) - - if (promises.length > 0) { - await Promise.allSettled(promises) - App.eventSystem.dispatch('fileAdded', undefined) - } - - app.windows.loadingWindow.close() - } - - async exportImages(app: App, textures: any[]) { - for (let texture of textures) { - const imageResponse = await fetch(texture.source) - const imageData = await imageResponse.arrayBuffer() - const imageType = imageResponse.headers.get('content-type') - - // Convert mime type to file extension - let extension = 'png' - switch (imageType) { - case 'image/png': - extension = 'png' - break - case 'image/jpeg': - case 'image/jpg': - extension = 'jpg' - break - case 'image/tga': - case 'image/x-tga': - case 'image/x-targa': - case 'image/target': - case 'application/x-tga': - case 'application/x-targa': - extension = 'tga' - break - } - - const folder = await new DropdownWindow({ - name: 'general.textureLocation', - default: 'entity', - options: ['entity', 'blocks', 'items'], - }).fired - - // Compose file path - const filePath = join( - 'RP', - 'textures', - folder, - extname(texture.name) === '' - ? `${texture.name}.${extension}` - : texture.name - ) - - // Check whether file already exists - const fileExists = await app.project.fileSystem.fileExists(filePath) - if (fileExists) { - const confirmWindow = new ConfirmationWindow({ - description: 'general.confirmOverwriteFile', - confirmText: 'windows.createPreset.overwriteFilesConfirm', - }) - - confirmWindow.open() - if (!(await confirmWindow.fired)) continue - } - - const destHandle = await app.project.fileSystem.writeFile( - filePath, - imageData - ) - - app.project.updateFile(app.project.absolutePath(filePath)) - await app.project.openFile(destHandle, { isTemporary: false }) - } - } - - async exportModel(app: App, data: any) { - const entityModel = { - description: { - identifier: 'geometry.' + (data.model_identifier || 'unknown'), - texture_width: data.resolution.width || 16, - texture_height: data.resolution.height || 16, - visible_bounds_width: data.visible_box?.[0] ?? 0, - visible_bounds_height: data.visible_box?.[1] ?? 0, - visible_bounds_offset: [0, data.visible_box?.[2] ?? 0, 0], - }, - } - const entityFile = { - format_version: '1.12.0', - 'minecraft:geometry': [entityModel], - } - - const cubes = this.extractCubes(data.elements, data.meta.box_uv) - const locators = this.extractLocators(data.elements) - const bones = this.createBones(data, cubes, locators) - - if (bones.size > 0) entityModel.bones = [...bones.values()] - - const filePath = join('RP', 'models', 'entity', data.name + '.json') - - const fileExists = await app.project.fileSystem.fileExists(filePath) - if (fileExists) { - const confirmWindow = new ConfirmationWindow({ - description: 'general.confirmOverwriteFile', - confirmText: 'windows.createPreset.overwriteFilesConfirm', - }) - - confirmWindow.open() - if (!(await confirmWindow.fired)) return - } - - const destHandle = await app.project.fileSystem.getFileHandle( - filePath, - true - ) - - await app.project.fileSystem.writeJSON(filePath, entityFile, true) - - app.project.updateFile(app.project.absolutePath(filePath)) - await app.project.openFile(destHandle, { isTemporary: false }) - } - - /** - * Extract cubes from bbmodel format (elements array) - * - * @param elements bbmodel elements - * @param hasBoxUV - * @returns Cube map - */ - extractCubes(elements: any[], hasBoxUV = true) { - const cubes = new Map() - - elements.forEach((element: any) => { - if (element.type === 'locator') return - - const isRotatedCube = - Array.isArray(element.rotation) && - element.rotation.every((r: number) => r !== 0) - - const cube: ICube = { - origin: element.from ? [...element.from] : undefined, - size: element.to.map( - (v: number, i: number) => v - element.from[i] - ), - rotation: isRotatedCube - ? element.rotation.map((rot: number, i: number) => - i === 2 ? rot : -rot - ) - : undefined, - pivot: isRotatedCube - ? [ - element.origin[0] * -1, - element.origin[1], - element.origin[2], - ] - : undefined, - inflate: element.inflate, - } - - if (cube.origin) cube.origin[0] = -(cube.origin[0] + cube.size[0]) - - if (hasBoxUV) { - cube.uv = element.uv_offset - if (element.mirror_uv) { - cube.mirror = element.mirror_uv - } - } else { - cube.uv = {} - - for (const [faceName, face] of Object.entries( - element.faces - )) { - if (face.texture !== null) { - cube.uv[faceName] = { - uv: [face.uv[0], face.uv[1]], - uv_size: [ - face.uv[2] - face.uv[0], - face.uv[3] - face.uv[1], - ], - } - - if (face.material_name) { - cube.uv[faceName].material_instance = - face.material_name - } - - if (faceName === 'up' || faceName === 'down') { - cube.uv[faceName].uv[0] += - cube.uv[faceName].uv_size[0] - cube.uv[faceName].uv[1] += - cube.uv[faceName].uv_size[1] - cube.uv[faceName].uv_size[0] *= -1 - cube.uv[faceName].uv_size[1] *= -1 - } - } - } - } - - cubes.set(element.uuid, cube) - }) - - return cubes - } - - /** - * Extract all locators from bbmodel format (elements array) - * - * @param elements bbmodel elements (locators or cubes) - * @returns Locator map - */ - extractLocators(elements: any[]) { - const locators = new Map() - - elements.forEach((element: any) => { - if (element.type !== 'locator') return - - const locator: ILocator = { - offset: [...element.from], - } - locator.offset[0] *= -1 - - if ( - element.rotation[0] !== 0 || - element.rotation[1] !== 0 || - element.rotation[2] !== 0 - ) { - locator.rotation = [ - -element.rotation[0], - -element.rotation[0], - element.rotation[0], - ] - } - - locators.set(element.name, locator) - }) - - return locators - } - - /** - * Create all bones of the model - * - * @param data bbmodel file data - * @param cubes - * @param locators - * @returns Bone map - */ - createBones( - data: any, - cubes: Map, - locators: Map - ) { - const bones = new Map() - - if (Array.isArray(data.outliner)) - data.outliner.forEach((outlinerElement: any) => - this.parseOutlinerElement( - outlinerElement, - bones, - cubes, - locators - ) - ) - - return bones - } - - /** - * An outliner element is Blockbench's equivalent to a bone - * - * @param outlinerElement Outliner element - * @param bones Bone map - * @param cubes - * @param locators - * @param parent Parent bone - */ - parseOutlinerElement( - outlinerElement: any, - bones: Map, - cubes: Map, - locators: Map, - parent?: IBone - ) { - const bone: IBone = { - name: outlinerElement.name, - parent: parent?.name, - pivot: outlinerElement.origin - ? [ - outlinerElement.origin[0] * -1, - outlinerElement.origin[1], - outlinerElement.origin[2], - ] - : undefined, - rotation: outlinerElement.rotation - ? [ - outlinerElement.rotation[0] * -1, - outlinerElement.rotation[1] * -1, - outlinerElement.rotation[2], - ] - : undefined, - binding: outlinerElement.bedrock_binding, - mirror: outlinerElement.mirror_uv, - material: outlinerElement.material, - reset: outlinerElement.reset, - cubes: outlinerElement.children - .filter((child: any) => typeof child === 'string') - .map((child: string) => cubes.get(child)) - .filter((child: ICube | undefined) => child !== undefined), - locators: Object.fromEntries( - outlinerElement.children - .filter( - (locatorName: any) => typeof locatorName === 'string' - ) - .map((locatorName: string) => [ - locatorName, - locators.get(locatorName), - ]) - .filter( - ([locatorName, locator]: [ - string, - ILocator | undefined - ]) => locator !== undefined - ) - ), - } - - if (bone.cubes!.length === 0) bone.cubes = undefined - if (Object.keys(bone.locators!).length === 0) bone.locators = undefined - - bones.set(outlinerElement.name, bone) - - if (Array.isArray(outlinerElement.children)) { - for (let child of outlinerElement.children) { - if (!(child instanceof Object)) continue - - this.parseOutlinerElement(child, bones, cubes, locators, bone) - } - } - } - - async exportAnimations(app: App, animations: any[], model_name: string) { - const animationFile = { - format_version: '1.8.0', - animations: {}, - } - - for (let animation of animations) { - let loop = undefined - if (animation.loop === 'hold') { - loop = 'hold_on_last_frame' - } else if ( - animation.loop === 'loop' || - this.getAnimationLength(animation) == 0 - ) { - loop = true - } - - const anim: IAnimation = { - loop: loop, - animation_length: animation.length - ? animation.length - : undefined, - override_previous_animation: animation.override - ? true - : undefined, - anim_time_update: animation.anim_time_update - ? animation.anim_time_update.replace(/\n/g, '') - : undefined, - blend_weight: animation.blend_weight - ? animation.blend_weight.replace(/\n/g, '') - : undefined, - start_delay: animation.start_delay - ? animation.start_delay.replace(/\n/g, '') - : undefined, - loop_delay: - animation.loop_delay && loop - ? animation.loop_delay.replace(/\n/g, '') - : undefined, - bones: {}, - sound_effects: {}, - particle_effects: {}, - timeline: {}, - } - - for (let uuid in animation.animators) { - let animator = animation.animators[uuid] - if (!animator.keyframes.length) continue - if (animator.type === 'effect') { - animator.keyframes - .filter((kf: any) => kf.channel === 'sound') - .sort((kf1: any, kf2: any) => kf1.time - kf2.time) - .forEach((kf: any) => { - anim.sound_effects![ - this.getTimecodeString(kf.time) - ] = this.compileBedrockKeyframe(kf, animator) - }) - animator.keyframes - .filter((kf: any) => kf.channel === 'particle') - .sort((kf1: any, kf2: any) => kf1.time - kf2.time) - .forEach((kf: any) => { - anim.particle_effects![ - this.getTimecodeString(kf.time) - ] = this.compileBedrockKeyframe(kf, animator) - }) - animator.keyframes - .filter((kf: any) => kf.channel === 'timeline') - .sort((kf1: any, kf2: any) => kf1.time - kf2.time) - .forEach((kf: any) => { - anim.timeline![this.getTimecodeString(kf.time)] = - this.compileBedrockKeyframe(kf, animator) - }) - } else if ( - animator.type === 'bone' || - animator.type === undefined // No defined type: Default is type "bone" - ) { - let bone_tag: any = (anim.bones![animator.name] = {}) - let channels: any = {} - - animator.keyframes.forEach((kf: any) => { - if (!channels[kf.channel]) { - channels[kf.channel] = {} - } - let timecode = this.getTimecodeString(kf.time) - channels[kf.channel][timecode] = - this.compileBedrockKeyframe(kf, animator) - }) - - //Sorting - for (let channel of [ - 'rotation', - 'position', - 'scale', - 'particle', - 'sound', - 'timeline', - ]) { - if (channels[channel]) { - let timecodes = Object.keys(channels[channel]) - if ( - timecodes.length === 1 && - animator.keyframes[0].data_points.length == 1 && - animator.keyframes[0].interpolation != - 'catmullrom' - ) { - bone_tag[channel] = - channels[channel][timecodes[0]] - if ( - channel === 'scale' && - channels[channel][timecodes[0]] instanceof - Array && - channels[channel][timecodes[0]].every( - (a: any) => - a !== - channels[channel][timecodes[0]][0] - ) - ) { - bone_tag[channel] = - channels[channel][timecodes[0]][0] - } - } else { - timecodes - .sort( - (a: string, b: string) => - parseFloat(a) - parseFloat(b) - ) - .forEach((timecode: string) => { - if (!bone_tag[channel]) { - bone_tag[channel] = {} - } - bone_tag[channel][timecode] = - channels[channel][timecode] - }) - } - } - } - } - } - - if (Object.keys(anim.bones!).length == 0) { - delete anim.bones - } - if (Object.keys(anim.sound_effects!).length == 0) { - delete anim.sound_effects - } - if (Object.keys(anim.particle_effects!).length == 0) { - delete anim.particle_effects - } - if (Object.keys(anim.timeline!).length == 0) { - delete anim.timeline - } - - animationFile.animations[animation.name] = anim - } - const filePath = join( - 'RP', - 'animations', - model_name + '.animation.json' - ) - - const fileExists = await app.project.fileSystem.fileExists(filePath) - if (fileExists) { - const confirmWindow = new ConfirmationWindow({ - description: 'general.confirmOverwriteFile', - confirmText: 'windows.createPreset.overwriteFilesConfirm', - }) - - confirmWindow.open() - if (!(await confirmWindow.fired)) return - } - - const destHandle = await app.project.fileSystem.getFileHandle( - filePath, - true - ) - - await app.project.fileSystem.writeJSON(filePath, animationFile, true) - - app.project.updateFile(app.project.absolutePath(filePath)) - await app.project.openFile(destHandle, { isTemporary: false }) - } - - /** - * Returns a compiled keyframe - * - * @param kf keyframe - * @param animator parent animator object - * @returns compiled keyframe - */ - compileBedrockKeyframe(kf: any, animator: any) { - if ( - kf.channel === 'rotation' || - kf.channel === 'position' || - kf.channel === 'scale' - ) { - if (kf.interpolation != 'linear' && kf.interpolation != 'step') { - let previous = this.getPreviousKeyframe(kf, animator) - let include_pre = - (!previous && kf.time > 0) || - (previous && previous.interpolation == 'catmullrom') - return { - pre: include_pre - ? this.getTransformArray(kf, 0) - : undefined, - post: this.getTransformArray(kf, include_pre ? 1 : 0), - lerp_mode: kf.interpolation, - } - } else if (kf.data_points.length == 1) { - let previous = this.getPreviousKeyframe(kf, animator) - if (previous && previous.interpolation == 'step') { - return { - pre: this.getTransformArray(previous, 1), - post: this.getTransformArray(kf), - } - } else { - return this.getTransformArray(kf) - } - } else { - return { - pre: this.getTransformArray(kf, 0), - post: this.getTransformArray(kf, 1), - } - } - } else if (kf.channel === 'timeline') { - let scripts: any[] = [] - kf.data_points.forEach((data_point: any) => { - if (data_point.script) { - scripts.push(...data_point.script.split('\n')) - } - }) - scripts = scripts.filter( - (script) => !!script.replace(/[\n\s;.]+/g, '') - ) - return scripts.length <= 1 ? scripts[0] : scripts - } else { - let points: any[] = [] - kf.data_points.forEach((data_point: any) => { - if (data_point.effect) { - let script = data_point.script || undefined - if (script && !script.replace(/[\n\s;.]+/g, '')) - script = undefined - if (script && !script.match(/;$/)) script += ';' - points.push({ - effect: data_point.effect, - locator: data_point.locator || undefined, - pre_effect_script: script, - }) - } - }) - return points.length <= 1 ? points[0] : points - } - } - - /** - * Returns the previous keyframe - * - * @param kf keyframe - * @param animator parent animator object - * @returns previous keyframe - */ - getPreviousKeyframe(kf: any, animator: any) { - let keyframes = animator.keyframes.filter( - (filter: any) => - filter.time < kf.time && filter.channel == kf.channel - ) - keyframes.sort((a: any, b: any) => b.time - a.time) - return keyframes[0] - } - - /** - * Transforms a data point to an array - * - * @param kf keyframe - * @param data_point Data point index - * @returns Array - */ - getTransformArray(kf: any, data_point: number = 0) { - function getAxis(kf: any, axis: string, data_point: any) { - if (data_point) - data_point = clamp(data_point, 0, kf.data_points.length - 1) - data_point = kf.data_points[data_point] - if (!data_point || !data_point[axis]) { - return 0 - } else if (!isNaN(data_point[axis])) { - let num = parseFloat(data_point[axis]) - return isNaN(num) ? 0 : num - } else { - return data_point[axis] - } - } - - let arr = [ - getAxis(kf, 'x', data_point), - getAxis(kf, 'y', data_point), - getAxis(kf, 'z', data_point), - ] - arr.forEach((n, i) => { - if (n.replace) arr[i] = n.replace(/\n/g, '') - }) - return arr - } - - /** - * Returns the length of the animation - * - * @param animation object - * @returns animation length - */ - getAnimationLength(animation: any) { - let length = animation.length || 0 - - for (let uuid in animation.animators) { - let bone = animation.animators[uuid] - let keyframes = bone.keyframes - if ( - keyframes.find((kf: any) => kf.interpolation === 'catmullrom') - ) { - keyframes = keyframes - .slice() - .sort((a: any, b: any) => a.time - b.time) - } - keyframes.forEach((kf: any, i: number) => { - if ( - kf.interpolation === 'catmullrom' && - i == keyframes.length - 1 - ) - return - length = Math.max(length, keyframes[i].time) - }) - } - return length - } - - /** - * Transforms a number into a string - * - * @param time number - * @returns time string - */ - getTimecodeString(time: number) { - let timecode = this.trimFloatNumber(time).toString() - if (!timecode.includes('.')) { - timecode += '.0' - } - return timecode - } - - /** - * Trims a float number to 4 digits - * - * @param number - * @returns trimmed number - */ - trimFloatNumber(number: any) { - if (number === '') return number - let string = number.toFixed(4) - //First regex removes all '0' at the end | Second regex removes the dot at the end if any - string = string.replace(/0+$/g, '').replace(/\.$/g, '') - if (string === '-0') return 0 - return string - } -} diff --git a/src/components/ImportFile/BasicFile.ts b/src/components/ImportFile/BasicFile.ts deleted file mode 100644 index c4bd07bdb..000000000 --- a/src/components/ImportFile/BasicFile.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { FileDropper } from '/@/components/FileDropper/FileDropper' -import { FileImporter } from './Importer' -import { App } from '/@/App' -import { InformedChoiceWindow } from '/@/components/Windows/InformedChoice/InformedChoice' -import { FilePathWindow } from '/@/components/Windows/Common/FilePath/Window' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { AnyFileHandle } from '../FileSystem/Types' -import { join } from '/@/utils/path' -import { translate } from '../Locales/Manager' - -export class BasicFileImporter extends FileImporter { - constructor(fileDropper: FileDropper) { - super( - [ - '.mcfunction', - '.mcstructure', - '.json', - '.molang', - '.js', - '.ts', - '.lang', - '.tga', - '.png', - '.jpg', - '.jpeg', - '.wav', - '.ogg', - '.mp3', - '.fsb', - ], - fileDropper, - true - ) - } - - async onImport(fileHandle: AnyFileHandle) { - const app = await App.getApp() - const t = translate - - // If current project is virtual project, simply open the file - await app.projectManager.projectReady.fired - if (app.project.isVirtualProject) { - return await this.onOpen(fileHandle) - } - - const saveOrOpenWindow = new InformedChoiceWindow( - 'fileDropper.importMethod.name', - { - isPersistent: false, - } - ) - const actionManager = await saveOrOpenWindow.actionManager - actionManager.create({ - icon: 'mdi-content-save-outline', - name: 'fileDropper.saveToProject.title', - description: `[${t('fileDropper.saveToProject.description1')} "${ - fileHandle.name - }" ${t('fileDropper.saveToProject.description2')}]`, - onTrigger: () => this.onSave(fileHandle), - }) - actionManager.create({ - icon: 'mdi-share-outline', - name: 'fileDropper.openFile.title', - description: `[${t('fileDropper.openFile.description1')} "${ - fileHandle.name - }" ${t('fileDropper.openFile.description2')}]`, - onTrigger: () => this.onOpen(fileHandle), - }) - - saveOrOpenWindow.open() - await saveOrOpenWindow.fired - } - - protected async onSave(fileHandle: AnyFileHandle) { - const app = await App.getApp() - - // Convince TypeScript that fileHandle is a FileSystemFileHandle - // We do that because our VirtualFileHandle's getFile method always returns a File object that is sufficient for the guessFolder method - const guessedFolder = await App.fileType.guessFolder( - fileHandle - ) - - // Allow user to change file path that the file is saved to - const filePathWindow = new FilePathWindow({ - fileName: fileHandle.name, - startPath: guessedFolder - ? app.project.relativePath(guessedFolder) - : '', - isPersistent: false, - }) - filePathWindow.open() - - const userInput = await filePathWindow.fired - if (userInput === null) return - const { filePath, fileName = fileHandle.name } = userInput - const newFilePath = join(filePath, fileName) - - // Get user confirmation if file overwrites already existing file - const fileExists = await app.project.fileSystem.fileExists(newFilePath) - if (fileExists) { - const confirmWindow = new ConfirmationWindow({ - description: 'windows.createPreset.overwriteFiles', - confirmText: 'windows.createPreset.overwriteFilesConfirm', - }) - - confirmWindow.open() - if (!(await confirmWindow.fired)) return - } - - app.windows.loadingWindow.open() - - const destHandle = await app.project.fileSystem.getFileHandle( - newFilePath, - true - ) - - App.eventSystem.dispatch('beforeModifiedProject', null) - - await app.project.fileSystem.copyFileHandle(fileHandle, destHandle) - App.eventSystem.dispatch('fileAdded', undefined) - - await app.project.updateFile( - app.project.config.resolvePackPath(undefined, newFilePath) - ) - - App.eventSystem.dispatch('modifiedProject', null) - - await app.project.openFile(destHandle, { isTemporary: false }) - - app.windows.loadingWindow.close() - } - protected async onOpen(fileHandle: AnyFileHandle) { - const app = await App.getApp() - - await app.project.openFile(fileHandle) - } -} diff --git a/src/components/ImportFile/Brproject.ts b/src/components/ImportFile/Brproject.ts deleted file mode 100644 index 7d6acb615..000000000 --- a/src/components/ImportFile/Brproject.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { FileDropper } from '/@/components/FileDropper/FileDropper' -import { FileImporter } from './Importer' -import { AnyFileHandle } from '../FileSystem/Types' -import { importFromBrproject } from '../Projects/Import/fromBrproject' - -export class BrprojectImporter extends FileImporter { - constructor(fileDropper: FileDropper) { - super(['.brproject'], fileDropper) - } - - async onImport(fileHandle: AnyFileHandle) { - await importFromBrproject(fileHandle) - } -} diff --git a/src/components/ImportFile/Importer.ts b/src/components/ImportFile/Importer.ts deleted file mode 100644 index ebdc5f624..000000000 --- a/src/components/ImportFile/Importer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { AnyFileHandle } from '../FileSystem/Types' -import type { FileDropper } from '/@/components/FileDropper/FileDropper' -import { IDisposable } from '/@/types/disposable' - -export abstract class FileImporter { - protected disposables: IDisposable[] = [] - - constructor( - extensions: string[], - fileDropper: FileDropper, - defaultImporter = false - ) { - for (const extension of extensions) { - this.disposables.push( - fileDropper.addFileImporter(extension, this.onImport.bind(this)) - ) - } - - if (defaultImporter) { - this.disposables.push( - fileDropper.setDefaultFileImporter(this.onImport.bind(this)) - ) - } - } - - protected abstract onImport(fileHandle: AnyFileHandle): Promise | void - - dispose() { - this.disposables.forEach((disposable) => disposable.dispose()) - } -} diff --git a/src/components/ImportFile/MCAddon.ts b/src/components/ImportFile/MCAddon.ts deleted file mode 100644 index c7ab38fa5..000000000 --- a/src/components/ImportFile/MCAddon.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { FileDropper } from '/@/components/FileDropper/FileDropper' -import { FileImporter } from './Importer' -import { App } from '/@/App' -import { AnyFileHandle } from '../FileSystem/Types' -import { importFromMcaddon } from '../Projects/Import/fromMcaddon' - -export class MCAddonImporter extends FileImporter { - constructor(fileDropper: FileDropper) { - super(['.mcaddon'], fileDropper) - } - - async onImport(fileHandle: AnyFileHandle) { - const app = await App.getApp() - - app.windows.loadingWindow.open() - - try { - await importFromMcaddon(fileHandle) - } catch (err) { - console.error(err) - } finally { - app.windows.loadingWindow.close() - } - } -} diff --git a/src/components/ImportFile/MCPack.ts b/src/components/ImportFile/MCPack.ts deleted file mode 100644 index e62c73d44..000000000 --- a/src/components/ImportFile/MCPack.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { FileDropper } from '/@/components/FileDropper/FileDropper' -import { FileImporter } from './Importer' -import { App } from '/@/App' -import { AnyFileHandle } from '../FileSystem/Types' -import { importFromMcpack } from '../Projects/Import/fromMcpack' - -export class MCPackImporter extends FileImporter { - constructor(fileDropper: FileDropper) { - super(['.mcpack'], fileDropper) - } - - async onImport(fileHandle: AnyFileHandle) { - const app = await App.getApp() - - app.windows.loadingWindow.open() - - try { - await importFromMcpack(fileHandle) - } catch (err) { - console.error(err) - } finally { - app.windows.loadingWindow.close() - } - } -} diff --git a/src/components/ImportFile/Manager.ts b/src/components/ImportFile/Manager.ts deleted file mode 100644 index 1aa660351..000000000 --- a/src/components/ImportFile/Manager.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { MCAddonImporter } from './MCAddon' -import { BasicFileImporter } from './BasicFile' -import type { FileDropper } from '/@/components/FileDropper/FileDropper' -import { BrprojectImporter } from './Brproject' -import { BBModelImporter } from '/@/components/ImportFile/BBModel' -import { ZipImporter } from './ZipImporter' -import { MCPackImporter } from './MCPack' - -export class FileImportManager { - constructor(fileDropper: FileDropper) { - new MCAddonImporter(fileDropper) - new BasicFileImporter(fileDropper) - new BrprojectImporter(fileDropper) - new BBModelImporter(fileDropper) - new ZipImporter(fileDropper) - new MCPackImporter(fileDropper) - } -} diff --git a/src/components/ImportFile/ZipImporter.ts b/src/components/ImportFile/ZipImporter.ts deleted file mode 100644 index 6015f0e37..000000000 --- a/src/components/ImportFile/ZipImporter.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { FileDropper } from '/@/components/FileDropper/FileDropper' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { StreamingUnzipper } from '/@/components/FileSystem/Zip/StreamingUnzipper' -import { FileImporter } from './Importer' -import { App } from '/@/App' -import { importFromBrproject } from '/@/components/Projects/Import/fromBrproject' -import { importFromMcaddon } from '/@/components/Projects/Import/fromMcaddon' - -export class ZipImporter extends FileImporter { - constructor(fileDropper: FileDropper) { - super(['.zip'], fileDropper) - } - - async onImport(fileHandle: AnyFileHandle) { - const app = await App.getApp() - const fs = app.fileSystem - const tmpHandle = await fs.getDirectoryHandle('import', { - create: true, - }) - - // Unzip to figure out how to handle the file - const unzipper = new StreamingUnzipper(tmpHandle) - - const file = await fileHandle.getFile() - const data = new Uint8Array(await file.arrayBuffer()) - unzipper.createTask(app.taskManager) - await unzipper.unzip(data) - - // If the "extensions", "projects", or "data" folders exist in the zip, assume it can be imported as a .brproject file - // If the "config.json" file exists in the zip, assume it can be imported as a .brproject file - // If there is a manifest in the project subfolder, assume it can be imported as a .mcaddon file - if ( - (await fs.fileExists('import/config.json')) || - (await fs.directoryExists('import/data')) || - (await fs.directoryExists('import/projects')) || - (await fs.directoryExists('import/extensions')) - ) { - await importFromBrproject(fileHandle, false) - } else { - for await (const pack of tmpHandle.values()) { - if (await fs.fileExists(`import/${pack.name}/manifest.json`)) { - await importFromMcaddon(fileHandle, false) - break - } - } - } - } -} diff --git a/src/components/ImportFolder/ImportProjects.ts b/src/components/ImportFolder/ImportProjects.ts deleted file mode 100644 index 40f13c1b9..000000000 --- a/src/components/ImportFolder/ImportProjects.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { FileSystem } from '../FileSystem/FileSystem' -import { showFolderPicker } from '../FileSystem/Pickers/showFolderPicker' -import { translate } from '../Locales/Manager' -import { virtualProjectName } from '../Projects/Project/Project' -import { ConfirmationWindow } from '../Windows/Common/Confirm/ConfirmWindow' -import { InformationWindow } from '../Windows/Common/Information/InformationWindow' -import { App } from '/@/App' - -export async function importProjects() { - const app = await App.getApp() - - // Show warning if the user has projects already - const projects = (await app.fileSystem.readdir('projects')).filter( - (projectName) => projectName !== virtualProjectName - ) - - if (projects.length > 0) { - const confirmWindow = new ConfirmationWindow({ - description: - 'packExplorer.noProjectView.importOldProjects.confirmOverwrite', - }) - const choice = await confirmWindow.fired - - if (!choice) return - } - - app.windows.loadingWindow.open() - - const bridgeProjects = await showFolderPicker({ multiple: true }) - - if (!bridgeProjects) { - app.windows.loadingWindow.close() - return - } - - let didSelectProject = false - for (const bridgeProject of bridgeProjects) { - const bridgeProjectFs = new FileSystem(bridgeProject) - // Ensure that the folder is a bridge project by checking for the config.json fike - if (!(await bridgeProjectFs.fileExists('config.json'))) { - new InformationWindow({ - title: `[${translate('toolbar.project.name')}: "${ - bridgeProject.name - }"]`, - description: - 'packExplorer.noProjectView.importOldProjects.notABridgeProject', - }) - continue - } - - const newProjectDir = await app.fileSystem.getDirectoryHandle( - `projects/${bridgeProject.name}`, - { create: true } - ) - await app.fileSystem.copyFolderByHandle( - bridgeProject, - newProjectDir, - new Set(['builds', '.git']) - ) - - // Load projects and only select the first one - app.projectManager.addProject( - bridgeProject, - true, - true, - !didSelectProject - ) - if (!didSelectProject) didSelectProject = true - } - - app.windows.loadingWindow.close() -} diff --git a/src/components/ImportFolder/Manager.ts b/src/components/ImportFolder/Manager.ts deleted file mode 100644 index 92f3cd166..000000000 --- a/src/components/ImportFolder/Manager.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { SimpleAction } from '../Actions/SimpleAction' -import { AnyDirectoryHandle } from '../FileSystem/Types' -import { InformedChoiceWindow } from '../Windows/InformedChoice/InformedChoice' -import { App } from '/@/App' - -export interface IFolderHandler { - icon: string - name: string - description: string - onSelect: (directoryHandle: AnyDirectoryHandle) => Promise | void -} -export class FolderImportManager { - protected readonly folderHandler = new Set() - - constructor() { - /** - * Setting the output folder isn't possible on Tauri builds - * We don't want a browser directory handle, we want our custom virtual directory handle - */ - if (!import.meta.env.VITE_IS_TAURI_APP) { - this.addImporter({ - icon: 'mdi-minecraft', - name: 'fileDropper.importMethod.folder.output.name', - description: - 'fileDropper.importMethod.folder.output.description', - onSelect: async (directoryHandle) => { - const app = await App.getApp() - - await app.comMojang.handleComMojangDrop(directoryHandle) - }, - }) - } - - this.addImporter({ - icon: 'mdi-folder-open-outline', - name: 'fileDropper.importMethod.folder.open.name', - description: 'fileDropper.importMethod.folder.open.description', - onSelect: async (directoryHandle) => { - const app = await App.getApp() - - try { - await directoryHandle.requestPermission({ - mode: 'readwrite', - }) - } catch { - return - } - - app.viewFolders.addDirectoryHandle({ directoryHandle }) - }, - }) - } - - addImporter(handler: IFolderHandler) { - this.folderHandler.add(handler) - - return { - dispose: () => { - this.folderHandler.delete(handler) - }, - } - } - - async onImportFolder(directoryHandle: AnyDirectoryHandle) { - /** - * If there is only one handler, use it without prompting the user for a choice - */ - if (this.folderHandler.size === 1) { - const handler = [...this.folderHandler][0] - await handler.onSelect(directoryHandle) - - return - } - - const informedChoiceWindow = new InformedChoiceWindow( - 'fileDropper.importMethod.name', - { - isPersistent: false, - } - ) - const actionManager = await informedChoiceWindow.actionManager - - this.folderHandler.forEach(({ onSelect, ...config }) => - actionManager.create({ - ...config, - onTrigger: () => onSelect(directoryHandle), - }) - ) - - informedChoiceWindow.open() - await informedChoiceWindow.fired - } -} diff --git a/src/components/InfoPanel/InfoPanel.ts b/src/components/InfoPanel/InfoPanel.ts deleted file mode 100644 index dc60e1440..000000000 --- a/src/components/InfoPanel/InfoPanel.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface IPanelOptions { - type: 'info' | 'warning' | 'error' | 'success' - text: string - isDismissible?: boolean -} - -export class InfoPanel { - protected type: 'info' | 'warning' | 'error' | 'success' - protected text: string - protected isDismissible: boolean - protected isVisible: boolean = true - - constructor({ type, text, isDismissible }: IPanelOptions) { - this.type = type - this.text = text - this.isDismissible = isDismissible ?? false - } -} diff --git a/src/components/InfoPanel/InfoPanel.vue b/src/components/InfoPanel/InfoPanel.vue deleted file mode 100644 index a92430821..000000000 --- a/src/components/InfoPanel/InfoPanel.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/src/components/JSONSchema/Manager.ts b/src/components/JSONSchema/Manager.ts deleted file mode 100644 index a69bf730b..000000000 --- a/src/components/JSONSchema/Manager.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { ignoreFields, schemaRegistry } from './Registry' -import { ConstSchema } from './Schema/Const' -import type { IfSchema } from './Schema/IfSchema' -import type { RootSchema } from './Schema/Root' -import type { Schema } from './Schema/Schema' -import type { ThenSchema } from './Schema/ThenSchema' -import { walkObject } from 'bridge-common-utils' -import { ElseSchema } from './Schema/ElseSchema' - -export class SchemaManager { - protected static lib: Record = {} - protected static rootSchemas = new Map() - - static setJSONDefaults(lib: Record) { - this.lib = lib - this.rootSchemas.clear() - } - - static request(fileUri: string, hash?: string) { - const requested = this.lib[fileUri] - if (!requested) { - console.warn( - `Couldn't find schema for "${fileUri}"${ - hash ? `(#${hash})` : '' - }` - ) - return {} - } - - if (!hash) { - return requested.schema - } else { - let subSchema: any - walkObject(hash, requested.schema, (data) => (subSchema = data)) - if (subSchema === undefined) - console.error(`Couldn't find hash ${hash} @ ${fileUri}`) - - return subSchema ?? {} - } - } - static addRootSchema(location: string, rootSchema: RootSchema) { - // Do not cache dynamic schemas - if (location.includes('/dynamic/')) return - - this.rootSchemas.set(location, rootSchema) - return rootSchema - } - static requestRootSchema(location: string) { - return this.rootSchemas.get(location) - } - - static createSchemas(location: string, obj: any) { - if (typeof obj !== 'object') { - console.warn(`Unexpected schema type "${typeof obj}" @ ${location}`) - return { schemas: [new ConstSchema(location, '', obj)] } - } - - const schemas: Schema[] = [] - // Parse out description and title of the current schema - const description = - typeof obj.description === 'string' ? obj.description : undefined - const title = typeof obj.title === 'string' ? obj.title : undefined - - for (const [key, value] of Object.entries(obj)) { - if (value === undefined) continue - - const Class = schemaRegistry.get(key) - - if (Class === undefined) { - if (ignoreFields.has(key)) continue - - console.warn( - `Schema field not implemented: <${key}, ${value}> @ ${location}` - ) - continue - } - - const schema = new Class(location, key, value) - - if (key === 'then' || key === 'else') { - let ifSchemas = schemas - .reverse() - .map((schema) => { - if (schema.schemaType === 'ifSchema') return schema - else if (schema.schemaType === 'refSchema') - return (schema).getFreeIfSchema() - }) - .filter((schema) => schema !== undefined) - - // We reverse the schemas array above so index 0 is the last schema - let lastIfSchema = ifSchemas[0] - - if (!lastIfSchema) { - console.warn(`"${key}" schema without "if" @ ${location}`) - lastIfSchema = ( - new (schemaRegistry.get('if')!)(location, 'if', true) - ) - } - - ;(schema).receiveIfSchema(lastIfSchema) - } - - schemas.push(schema) - } - - return { - schemas, - description, - title, - } - } -} diff --git a/src/components/JSONSchema/Registry.ts b/src/components/JSONSchema/Registry.ts deleted file mode 100644 index fe392aee0..000000000 --- a/src/components/JSONSchema/Registry.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { AdditionalPropertiesSchema } from './Schema/AdditionalProperties' -import { AllOfSchema } from './Schema/AllOf' -import { AnyOfSchema } from './Schema/AnyOf' -import { ConstSchema } from './Schema/Const' -import { DefaultSchema } from './Schema/Default' -import { DeprecationMessageSchema } from './Schema/DeprecationMessage' -import { DoNotSuggestSchema } from './Schema/DoNotSuggest' -import { ElseSchema } from './Schema/ElseSchema' -import { EnumSchema } from './Schema/Enum' -import { IfSchema } from './Schema/IfSchema' -import { ItemsSchema } from './Schema/Items' -import { NotSchema } from './Schema/Not' -import { OneOfSchema } from './Schema/OneOf' -import { PatternPropertiesSchema } from './Schema/PatternProperties' -import { PropertiesSchema } from './Schema/Properties' -import { PropertyNamesSchema } from './Schema/PropertyNames' -import { RefSchema } from './Schema/Ref' -import { RequiredSchema } from './Schema/Required' -import { Schema } from './Schema/Schema' -import { ThenSchema } from './Schema/ThenSchema' -import { TypeSchema } from './Schema/Type' - -interface ISchemaConstructor { - new (location: string, key: string, value: unknown): Schema -} - -export const schemaRegistry = new Map([ - ['$ref', RefSchema], - ['additionalProperties', AdditionalPropertiesSchema], - ['allOf', AllOfSchema], - ['anyOf', AnyOfSchema], - ['const', ConstSchema], - ['enum', EnumSchema], - ['if', IfSchema], - ['items', ItemsSchema], - ['oneOf', OneOfSchema], - ['patternProperties', PatternPropertiesSchema], - ['properties', PropertiesSchema], - ['propertyNames', PropertyNamesSchema], - ['required', RequiredSchema], - ['then', ThenSchema], - ['type', TypeSchema], - ['default', DefaultSchema], - ['else', ElseSchema], - ['deprecationMessage', DeprecationMessageSchema], - ['doNotSuggest', DoNotSuggestSchema], - ['not', NotSchema], -]) - -export const ignoreFields = new Set([ - '$schema', - '$id', - 'description', // Uses special parsing. Description is used for context menu information - 'title', // Uses special parsing. Title is used for context menu information - 'definitions', - - //TODO: Proper implementation of the following fields instead of ignoring them - 'pattern', - 'min', - 'max', - 'maxItems', - 'minItems', - 'examples', - 'minimum', - 'maximum', - 'format', - 'maxLength', - 'multipleOf', - 'markdownDescription', -]) diff --git a/src/components/JSONSchema/Schema/AdditionalProperties.ts b/src/components/JSONSchema/Schema/AdditionalProperties.ts deleted file mode 100644 index 5d5457216..000000000 --- a/src/components/JSONSchema/Schema/AdditionalProperties.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { RootSchema } from './Root' -import { Schema } from './Schema' - -export class AdditionalPropertiesSchema extends Schema { - protected rootSchema?: RootSchema - - public readonly types = [] - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (typeof value === 'object') - this.rootSchema = new RootSchema(location, key, value) - } - - validate(obj: unknown) { - return this.rootSchema?.validate(obj) ?? [] - } - - getCompletionItems(obj: unknown) { - return [] - } - getSchemasFor(obj: any, location: (string | number | undefined)[]) { - if (location.length === 0) return [] - else if (location.length === 1) - return this.rootSchema ? [this.rootSchema] : [] - const key = location.shift()! - if (Array.isArray(obj[key])) return [] - - return this.rootSchema?.getSchemasFor(obj[key], location) ?? [] - } -} diff --git a/src/components/JSONSchema/Schema/AllOf.ts b/src/components/JSONSchema/Schema/AllOf.ts deleted file mode 100644 index 5b64a9364..000000000 --- a/src/components/JSONSchema/Schema/AllOf.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ParentSchema } from './Parent' -import { RootSchema } from './Root' -import { Schema } from './Schema' - -export class AllOfSchema extends ParentSchema { - protected children: Schema[] - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (!Array.isArray(value)) - throw new Error( - `"allOf" schema must be of type array, found "${typeof value}"` - ) - - this.children = value.map( - (val) => new RootSchema(this.location, 'allOf', val) - ) - } - - validate(obj: unknown) { - const allDiagnostics = this.children - .map((child) => child.validate(obj)) - .flat() - - if (allDiagnostics.length === 0) return [] - return allDiagnostics - } - isValid(obj: unknown) { - return this.children.every((child) => child.isValid(obj)) - } -} diff --git a/src/components/JSONSchema/Schema/AnyOf.ts b/src/components/JSONSchema/Schema/AnyOf.ts deleted file mode 100644 index 9fff976e7..000000000 --- a/src/components/JSONSchema/Schema/AnyOf.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ParentSchema } from './Parent' -import { RootSchema } from './Root' -import { IDiagnostic, Schema } from './Schema' - -export class AnyOfSchema extends ParentSchema { - protected children: Schema[] - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (!Array.isArray(value)) - throw new Error( - `"anyOf" schema must be of type array, found "${typeof value}"` - ) - - this.children = value.map( - (val) => new RootSchema(this.location, 'anyOf', val) - ) - } - - validate(obj: unknown) { - const allDiagnostics: IDiagnostic[] = [] - - for (const child of this.children) { - const diagnostics = child.validate(obj) - - if (diagnostics.length === 0) return [] - - allDiagnostics.push(...diagnostics) - } - - return allDiagnostics - } - isValid(obj: unknown) { - return this.children.some((child) => child.isValid(obj)) - } -} diff --git a/src/components/JSONSchema/Schema/Const.ts b/src/components/JSONSchema/Schema/Const.ts deleted file mode 100644 index ab9f56a4d..000000000 --- a/src/components/JSONSchema/Schema/Const.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Schema } from './Schema' - -export class ConstSchema extends Schema { - public readonly types = [] - getSchemasFor() { - return [] - } - - getCompletionItems() { - return [ - { type: 'value', label: `${this.value}`, value: this.value }, - ] - } - - validate(val: unknown) { - if (this.value !== val) - return [ - { - severity: 'warning', - message: `Found "${val}" here; expected "${this.value}"`, - }, - ] - return [] - } -} diff --git a/src/components/JSONSchema/Schema/Default.ts b/src/components/JSONSchema/Schema/Default.ts deleted file mode 100644 index f26ecc630..000000000 --- a/src/components/JSONSchema/Schema/Default.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Schema } from './Schema' - -export class DefaultSchema extends Schema { - public readonly types = [] - getSchemasFor() { - return [] - } - - getCompletionItems() { - // TODO - Support object and array values - if (typeof this.value !== 'object' && !Array.isArray(this.value)) - return [ - { - type: 'value', - label: `${this.value}`, - value: this.value, - }, - ] - else return [] - } -} diff --git a/src/components/JSONSchema/Schema/DeprecationMessage.ts b/src/components/JSONSchema/Schema/DeprecationMessage.ts deleted file mode 100644 index f36ce6de5..000000000 --- a/src/components/JSONSchema/Schema/DeprecationMessage.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Schema } from './Schema' - -export class DeprecationMessageSchema extends Schema { - public readonly types = [] - - constructor(location: string, key: string, value: unknown) { - if (typeof value !== 'string') { - throw new Error( - `[${location}] Type of "deprecationMessage" must be string, found ${typeof value}` - ) - } - - super(location, key, value) - } - - getSchemasFor() { - return [] - } - - getCompletionItems() { - return [] - } - - validate() { - return [ - { - message: this.value, - severity: 'warning', - }, - ] - } -} diff --git a/src/components/JSONSchema/Schema/DoNotSuggest.ts b/src/components/JSONSchema/Schema/DoNotSuggest.ts deleted file mode 100644 index ea592cade..000000000 --- a/src/components/JSONSchema/Schema/DoNotSuggest.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Schema } from './Schema' - -export class DoNotSuggestSchema extends Schema { - public readonly types = [] - - constructor(location: string, key: string, value: unknown) { - if (typeof value !== 'boolean') { - throw new Error( - `[${location}] Type of "doNotSuggest" must be boolean, found ${typeof value}` - ) - } - - super(location, key, value) - } - - getSchemasFor() { - return [] - } - - getCompletionItems() { - return [] - } - - validate() { - return [] - } -} diff --git a/src/components/JSONSchema/Schema/ElseSchema.ts b/src/components/JSONSchema/Schema/ElseSchema.ts deleted file mode 100644 index ebdf0090e..000000000 --- a/src/components/JSONSchema/Schema/ElseSchema.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IfSchema } from './IfSchema' -import { RootSchema } from './Root' -import { Schema } from './Schema' - -export class ElseSchema extends Schema { - protected ifSchema!: IfSchema - protected rootSchema!: RootSchema - - get types() { - if (!this.ifSchema.isTrue(this.value)) return this.rootSchema.types - return [] - } - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - this.rootSchema = new RootSchema(this.location, 'else', value) - } - - receiveIfSchema(ifSchema: IfSchema) { - this.ifSchema = ifSchema - } - - getSchemasFor(obj: unknown, location: (string | number | undefined)[]) { - if (!this.ifSchema.isTrue(obj)) - return this.rootSchema.getSchemasFor(obj, [...location]) - return [] - } - - getCompletionItems(obj: unknown) { - if (!this.ifSchema.isTrue(obj)) - return this.rootSchema.getCompletionItems(obj) - - return [] - } - - validate(obj: unknown) { - if (!this.ifSchema.isTrue(obj)) { - const diagnostics = this.rootSchema.validate(obj) - - return diagnostics - } - return [] - } -} diff --git a/src/components/JSONSchema/Schema/Enum.ts b/src/components/JSONSchema/Schema/Enum.ts deleted file mode 100644 index d54d3fa21..000000000 --- a/src/components/JSONSchema/Schema/Enum.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Schema } from './Schema' -import { closestMatch } from '/@/utils/string/closestMatch' - -export class EnumSchema extends Schema { - public readonly types = [] - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (!Array.isArray(value)) - throw new Error(`Enum must be of type array; found ${typeof value}`) - } - - getSchemasFor() { - return [] - } - - getCompletionItems() { - return (this.value).map( - (value) => { type: 'value', label: `${value}`, value } - ) - } - - validate(val: unknown) { - const values = this.value - if (!values.includes(val)) { - if (values.length === 0) { - return [ - { - priority: 0, - severity: 'warning', - message: `Found "${val}"; but no values are valid`, - }, - ] - } - - // console.log(this.value) - const bestMatch = closestMatch( - `${val}`, - values.map((v) => `${v}`), - 0.6 - ) - return [ - { - priority: 1, - severity: 'warning', - message: bestMatch - ? `"${val}" not valid here. Did you mean "${bestMatch}"?` - : `"${val}" not valid here`, - }, - ] - } - return [] - } -} diff --git a/src/components/JSONSchema/Schema/IfSchema.ts b/src/components/JSONSchema/Schema/IfSchema.ts deleted file mode 100644 index 93a74ceda..000000000 --- a/src/components/JSONSchema/Schema/IfSchema.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { RootSchema } from './Root' -import { Schema } from './Schema' - -export class IfSchema extends Schema { - public readonly schemaType = 'ifSchema' - protected rootSchema?: RootSchema - public readonly types = [] - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (typeof value !== 'boolean') - this.rootSchema = new RootSchema(this.location, 'if', value) - } - - getSchemasFor() { - return [] - } - - getCompletionItems() { - return [] - } - - validate() { - return [] - } - - isTrue(obj: unknown) { - if (typeof this.value === 'boolean') return this.value - - return this.rootSchema?.isValid(obj) - } -} diff --git a/src/components/JSONSchema/Schema/Items.ts b/src/components/JSONSchema/Schema/Items.ts deleted file mode 100644 index fbf9ee7a1..000000000 --- a/src/components/JSONSchema/Schema/Items.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { RootSchema } from './Root' -import { IDiagnostic, Schema } from './Schema' - -export class ItemsSchema extends Schema { - protected children: RootSchema | RootSchema[] - - get types() { - return ['array'] - } - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (typeof value !== 'object' && typeof value !== 'undefined') - throw new Error( - `Invalid usage of "properties" schema field. Expected type "object", received "${typeof value}"` - ) - - if (Array.isArray(value)) - this.children = value.map( - (val) => new RootSchema(this.location, 'items', val) - ) - else this.children = new RootSchema(this.location, 'items', value) - } - - get arrayChildren() { - return Array.isArray(this.children) ? this.children : [this.children] - } - - getSchemasFor(obj: unknown, location: (string | number | undefined)[]) { - const key = location.shift() - - if (typeof key === 'string') return [] - else if (key === undefined) return this.arrayChildren - else if (location.length === 0) { - if (Array.isArray(this.children)) - return this.children[key] ? [this.children[key]] : [] - else return [this.children] - } - - if (Array.isArray(this.children)) - return ( - this.children[key]?.getSchemasFor((obj)[key], [ - ...location, - ]) ?? [] - ) - else return this.children.getSchemasFor((obj)[key], [...location]) - } - - getCompletionItems(obj: unknown) { - return this.arrayChildren - .filter((child) => !child.hasDoNotSuggest) - .map((child) => child.getCompletionItems(obj)) - .flat() - } - - // TODO: Implement proper item validation - validate(obj: unknown) { - return [] - } -} diff --git a/src/components/JSONSchema/Schema/Not.ts b/src/components/JSONSchema/Schema/Not.ts deleted file mode 100644 index c72de2a52..000000000 --- a/src/components/JSONSchema/Schema/Not.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { RootSchema } from './Root' -import { Schema } from './Schema' - -export class NotSchema extends Schema { - protected rootSchema: RootSchema - public readonly types = [] - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (typeof value !== 'object') { - throw new Error( - `[${location}] Type of "not" must be object, found ${typeof value}` - ) - } - - this.rootSchema = new RootSchema(this.location, 'not', value) - } - - getSchemasFor() { - return [] - } - - getCompletionItems() { - return [] - } - - validate(obj: unknown) { - if (this.rootSchema.isValid(obj)) { - return [ - { - severity: 'warning', - message: 'Expected schema to be invalid', - }, - ] - } - - return [] - } - - isValid(obj: unknown) { - return !this.rootSchema.isValid(obj) - } -} diff --git a/src/components/JSONSchema/Schema/OneOf.ts b/src/components/JSONSchema/Schema/OneOf.ts deleted file mode 100644 index 689564ac0..000000000 --- a/src/components/JSONSchema/Schema/OneOf.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ParentSchema } from './Parent' -import { RootSchema } from './Root' -import { IDiagnostic, Schema } from './Schema' - -export class OneOfSchema extends ParentSchema { - protected children: Schema[] - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (!Array.isArray(value)) - throw new Error( - `"oneOf" schema must be of type array, found "${typeof value}"` - ) - - this.children = value.map( - (val) => new RootSchema(this.location, 'oneOf', val) - ) - } - - validate(obj: unknown) { - const allDiagnostics: IDiagnostic[] = [] - let matchedOne = false - let hasTooManyMatches = false - - for (const child of this.children) { - const diagnostics = child.validate(obj) - - if (diagnostics.length === 0) { - if (matchedOne) hasTooManyMatches = true - matchedOne = true - } - - allDiagnostics.push(...diagnostics) - } - - if (hasTooManyMatches) - return [ - { - severity: 'warning', - message: `JSON matched more than one schema, expected exactly one match`, - }, - ] - else if (matchedOne) return [] - else return allDiagnostics - } - isValid(obj: unknown) { - let matchedOne = false - for (const child of this.children) { - if (child.isValid(obj)) { - if (matchedOne) return false - matchedOne = true - } - } - - return matchedOne - } -} diff --git a/src/components/JSONSchema/Schema/Parent.ts b/src/components/JSONSchema/Schema/Parent.ts deleted file mode 100644 index f53af3761..000000000 --- a/src/components/JSONSchema/Schema/Parent.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { DoNotSuggestSchema } from './DoNotSuggest' -import { Schema } from './Schema' - -export abstract class ParentSchema extends Schema { - protected abstract children: Schema[] - - get types() { - return this.children.map((child) => child.types).flat() - } - - get hasDoNotSuggest() { - return this.children.some( - (child) => - child instanceof DoNotSuggestSchema || child.hasDoNotSuggest - ) - } - - getCompletionItems(obj: unknown) { - if (this.hasDoNotSuggest) return [] - - return this.children - .map((child) => child.getCompletionItems(obj)) - .flat() - } - - getSchemasFor(obj: unknown, location: (string | number | undefined)[]) { - return this.children - .map((child) => child.getSchemasFor(obj, [...location])) - .flat() - } - - getFlatChildren() { - const children: Schema[] = [] - - for (const child of this.children) { - if (child instanceof ParentSchema) - children.push(...child.getFlatChildren()) - else children.push(child) - } - - return children - } -} diff --git a/src/components/JSONSchema/Schema/PatternProperties.ts b/src/components/JSONSchema/Schema/PatternProperties.ts deleted file mode 100644 index cf9aef380..000000000 --- a/src/components/JSONSchema/Schema/PatternProperties.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { RootSchema } from './Root' -import { IDiagnostic, Schema } from './Schema' - -export class PatternPropertiesSchema extends Schema { - protected children: Record = {} - - get types() { - return ['object'] - } - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (typeof value !== 'object' && typeof value !== 'undefined') - throw new Error( - `Invalid usage of "properties" schema field. Expected type "object", received "${typeof value}"` - ) - - this.children = Object.fromEntries( - Object.entries(value ?? {}).map(([key, val]) => [ - key, - new RootSchema(this.location, key, val), - ]) - ) - } - - getSchemasFor(obj: unknown, location: (string | number | undefined)[]) { - const key = location.shift() - - let schemas: Schema[] = [] - if (typeof key === 'number' || key === undefined) return schemas - if (Array.isArray((obj)[key])) return [] - - for (const [pattern, child] of Object.entries(this.children)) { - if (key.match(new RegExp(pattern)) !== null) schemas.push(child) - } - - return location.length === 0 - ? schemas - : schemas - .map((schema) => - schema.getSchemasFor((obj)[key], [...location]) - ) - .flat() - } - - getCompletionItems() { - return [] - } - - validate(obj: unknown) { - if (typeof obj !== 'object' || Array.isArray(obj)) - return [ - { - severity: 'warning', - message: `This node is of type ${ - Array.isArray(obj) ? 'array' : typeof obj - }; expected object`, - }, - ] - - return [] - } - isValid(obj: unknown) { - const isOwnValid = super.isValid(obj) - if (!isOwnValid) return false - - for (const [pattern, child] of Object.entries(this.children)) { - const regExp = new RegExp(pattern) - - for (const key in obj) { - if (key.match(regExp) !== null) { - if (!child.isValid((obj)[key])) return false - } - } - - regExp.lastIndex = 0 - } - - return true - } -} diff --git a/src/components/JSONSchema/Schema/Properties.ts b/src/components/JSONSchema/Schema/Properties.ts deleted file mode 100644 index 0db040a21..000000000 --- a/src/components/JSONSchema/Schema/Properties.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { RootSchema } from './Root' -import { ICompletionItem, IDiagnostic, Schema } from './Schema' - -export class PropertiesSchema extends Schema { - protected children: Record = {} - - get types() { - return ['object'] - } - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (typeof value !== 'object' && typeof value !== 'undefined') - throw new Error( - `[${ - this.location - }] Invalid usage of "properties" schema field. Expected type "object", received "${typeof value}"` - ) - - this.children = Object.fromEntries( - Object.entries(value ?? {}).map(([key, val]) => [ - key, - new RootSchema(this.location, key, val), - ]) - ) - } - - getSchemasFor(obj: unknown, location: (string | number | undefined)[]) { - const key = location.shift() - - if (key === undefined) return Object.values(this.children) - else if (location.length === 0) - return this.children[key] ? [this.children[key]] : [] - return ( - this.children[key]?.getSchemasFor((obj)[key], [...location]) ?? - [] - ) - } - - getCompletionItems(context: unknown) { - const propertyContext = Object.keys( - typeof context === 'object' ? context ?? {} : {} - ) - - return Object.entries(this.children) - .filter( - ([propertyName, schema]) => - !propertyContext.includes(propertyName) && - !schema.hasDoNotSuggest - ) - .map(([propertyName, schema]) => { - const types = new Set(schema.types) - const completionItems: ICompletionItem[] = [] - - const isOfTypeArray = types.has('array') - - if (isOfTypeArray) - completionItems.push({ - type: 'array', - label: `${propertyName}`, - value: propertyName, - }) - - /** - * Only push the suggestion item of type 'object' if - * the schema is not of type 'array' or if we have at - * least one other type within the array - */ - if (!isOfTypeArray || types.size > 1) - completionItems.push({ - type: 'object', - label: `${propertyName}`, - value: propertyName, - }) - - return completionItems - }) - .flat() - } - - validate(obj: unknown) { - if (typeof obj !== 'object' || Array.isArray(obj)) - return [ - { - severity: 'warning', - message: `This node is of type ${ - Array.isArray(obj) ? 'array' : typeof obj - }; expected object`, - }, - ] - - return [] - } - isValid(obj: unknown) { - return ( - super.isValid(obj) && - Object.entries(this.children).every(([key, child]) => - child.isValid((obj)[key]) - ) - ) - } -} diff --git a/src/components/JSONSchema/Schema/PropertyNames.ts b/src/components/JSONSchema/Schema/PropertyNames.ts deleted file mode 100644 index d0aebd70c..000000000 --- a/src/components/JSONSchema/Schema/PropertyNames.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { RootSchema } from './Root' -import { Schema } from './Schema' - -export class PropertyNamesSchema extends Schema { - protected rootSchema: RootSchema - public readonly types = [] - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - this.rootSchema = new RootSchema(this.location, 'propertyNames', value) - } - - getSchemasFor() { - return [] - } - - getCompletionItems(obj: unknown) { - return this.rootSchema - .getCompletionItems(obj) - .filter((completionItem) => completionItem.type === 'value') - .map( - (completionItem) => - { - type: 'object', - label: `${completionItem.value}`, - value: completionItem.value, - } - ) - } - - validate() { - // TODO: Add proper property name validation - return [] - } - - isTrue(obj: unknown) { - return this.rootSchema.isValid(obj) - } -} diff --git a/src/components/JSONSchema/Schema/Ref.ts b/src/components/JSONSchema/Schema/Ref.ts deleted file mode 100644 index a9c1d3dcb..000000000 --- a/src/components/JSONSchema/Schema/Ref.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { SchemaManager } from '../Manager' -import { RootSchema } from './Root' -import { Schema } from './Schema' -import { dirname, join } from '/@/utils/path' - -export class RefSchema extends Schema { - public readonly schemaType = 'refSchema' - - protected rootSchema: RootSchema - get types() { - return this.rootSchema.types - } - - get hasDoNotSuggest() { - return this.rootSchema.hasDoNotSuggest - } - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (typeof value !== 'string') - throw new Error( - `Invalid $ref type "${typeof value}": ${JSON.stringify(value)}` - ) - - if (value.startsWith('#')) { - const baseLoc = this.location.split('#/')[0] - const locWithHash = value === '#' ? baseLoc : `${baseLoc}${value}` - - this.rootSchema = - SchemaManager.requestRootSchema(locWithHash) ?? - new RootSchema( - locWithHash, - '$ref', - SchemaManager.request( - baseLoc, - value === '#' ? undefined : value.replace('#/', '') - ) - ) - } else { - const dir = this.location.includes('#/') - ? dirname(this.location.split('#/')[0]) - : dirname(this.location) - - let fileUri: string - if (value.startsWith('/')) fileUri = `file://${value}` - else fileUri = join(dir, value).replace('file:/', 'file:///') - - // Support hashes for navigating inside of schemas - let hash: string | undefined = undefined - if (fileUri.includes('#/')) { - ;[fileUri, hash] = fileUri.split('#/') - } - - this.location = fileUri - this.rootSchema = - SchemaManager.requestRootSchema( - hash ? `${fileUri}#/${hash}` : fileUri - ) ?? - new RootSchema( - hash ? `${fileUri}#/${hash}` : fileUri, - '$ref', - SchemaManager.request(this.location, hash) - ) - } - } - - getCompletionItems(obj: unknown) { - return this.rootSchema.getCompletionItems(obj) - } - validate(obj: string) { - return this.rootSchema.validate(obj) - } - getSchemasFor(obj: unknown, location: (string | number | undefined)[]) { - return this.rootSchema.getSchemasFor(obj, [...location]) - } - - getFreeIfSchema() { - return this.rootSchema.getFreeIfSchema() - } -} diff --git a/src/components/JSONSchema/Schema/Required.ts b/src/components/JSONSchema/Schema/Required.ts deleted file mode 100644 index cec34298d..000000000 --- a/src/components/JSONSchema/Schema/Required.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Schema } from './Schema' - -export class RequiredSchema extends Schema { - public readonly types = [] - - getSchemasFor() { - return [] - } - - getCompletionItems() { - return [] - } - - validate(obj: unknown) { - const values = Array.isArray(this.value) ? this.value : [this.value] - - if (Array.isArray(obj)) { - return [ - { - severity: 'warning', - message: `Required properties missing: ${values.join( - ', ' - )}`, - }, - ] - } - - /** - * @TODO - Support for passing down parent object so we can validate the parent object in the case described below. - * - * Case for key existence checks like this one: - * - * properties: { - * name: { - * required: ['name'] - * } - * } - * - * -> If typeof obj !== 'object, the validation should pass because we have no way to validate the parent object yet. - */ - if (typeof obj !== 'object') { - return [] - } - - for (const value of values) { - if ((obj)[value] === undefined || (obj)[value] === null) - return [ - { - severity: 'warning', - message: `Missing required property: ${value}`, - }, - ] - } - return [] - } -} diff --git a/src/components/JSONSchema/Schema/Root.ts b/src/components/JSONSchema/Schema/Root.ts deleted file mode 100644 index 0eb7f783b..000000000 --- a/src/components/JSONSchema/Schema/Root.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { SchemaManager } from '../Manager' -import { IfSchema } from './IfSchema' -import { ParentSchema } from './Parent' -import { Schema } from './Schema' -import { ThenSchema } from './ThenSchema' - -export class RootSchema extends ParentSchema { - protected children!: Schema[] - public description?: string - public title?: string - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - if (key === '$global' || key === '$ref') - SchemaManager.addRootSchema(location, this) - - const { schemas, description, title } = SchemaManager.createSchemas( - this.location, - value - ) - // Set main schema children - this.children = schemas - // Set description and title for root schema - this.description = description - this.title = title - - // console.log(this.children) - } - - validate(obj: unknown) { - return this.children.map((child) => child.validate(obj)).flat() - } - isValid(obj: unknown): boolean { - return this.children.every((child) => child.isValid(obj)) - } - - getFreeIfSchema(): IfSchema | undefined { - let isIfOccupied = false - for (const child of this.getFlatChildren().reverse()) { - if (child instanceof ThenSchema) { - isIfOccupied = true - } else if (child instanceof IfSchema) { - if (isIfOccupied) isIfOccupied = false - else return child - } - } - } -} diff --git a/src/components/JSONSchema/Schema/Schema.ts b/src/components/JSONSchema/Schema/Schema.ts deleted file mode 100644 index 45dfa4314..000000000 --- a/src/components/JSONSchema/Schema/Schema.ts +++ /dev/null @@ -1,55 +0,0 @@ -export interface ISchemaResult { - diagnostics: IDiagnostic[] -} - -export interface IDiagnostic { - priority?: number - severity: 'error' | 'warning' | 'info' - message: string - // location: string -} - -export interface ICompletionItem { - type: 'object' | 'array' | 'value' | 'snippet' - label: string - value: unknown -} - -export type TSchemaType = - | 'object' - | 'array' - | 'string' - | 'integer' - | 'number' - | 'boolean' - | 'null' - -export const pathWildCard = '-!!-' - -export abstract class Schema { - public readonly schemaType?: 'ifSchema' | 'refSchema' - public abstract readonly types: TSchemaType[] - - public get hasDoNotSuggest() { - return false - } - - constructor( - protected location: string, - protected key: string, - protected value: unknown - ) {} - - // abstract validate(obj: unknown): IDiagnostic[] - validate(obj: unknown): IDiagnostic[] { - return [] - } - isValid(obj: unknown) { - return this.validate(obj).length === 0 - } - abstract getCompletionItems(obj: unknown): ICompletionItem[] - abstract getSchemasFor( - obj: unknown, - location: (string | number | undefined)[] - ): Schema[] -} diff --git a/src/components/JSONSchema/Schema/ThenSchema.ts b/src/components/JSONSchema/Schema/ThenSchema.ts deleted file mode 100644 index 04f4fb845..000000000 --- a/src/components/JSONSchema/Schema/ThenSchema.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IfSchema } from './IfSchema' -import { RootSchema } from './Root' -import { Schema } from './Schema' - -export class ThenSchema extends Schema { - protected ifSchema!: IfSchema - protected rootSchema!: RootSchema - - get types() { - if (this.ifSchema.isTrue(this.value)) return this.rootSchema.types - return [] - } - - constructor(location: string, key: string, value: unknown) { - super(location, key, value) - - this.rootSchema = new RootSchema(this.location, 'then', value) - } - - receiveIfSchema(ifSchema: IfSchema) { - this.ifSchema = ifSchema - } - - getSchemasFor(obj: unknown, location: (string | number)[]) { - if (this.ifSchema.isTrue(obj)) - return this.rootSchema.getSchemasFor(obj, [...location]) - return [] - } - - getCompletionItems(obj: unknown) { - if (this.ifSchema.isTrue(obj)) - return this.rootSchema.getCompletionItems(obj) - - return [] - } - - validate(obj: unknown) { - if (this.ifSchema.isTrue(obj)) { - const diagnostics = this.rootSchema.validate(obj) - - return diagnostics - } - return [] - } -} diff --git a/src/components/JSONSchema/Schema/Type.ts b/src/components/JSONSchema/Schema/Type.ts deleted file mode 100644 index 21cbdfcb7..000000000 --- a/src/components/JSONSchema/Schema/Type.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ICompletionItem, Schema } from './Schema' -import { getTypeOf } from '/@/utils/typeof' - -export class TypeSchema extends Schema { - get values() { - return Array.isArray(this.value) ? this.value : [this.value] - } - get types() { - return this.values - } - - getSchemasFor() { - return [] - } - - getCompletionItems() { - const suggestions: ICompletionItem[] = [] - if (this.values.includes('boolean')) - suggestions.push( - ...[true, false].map( - (value) => - { type: 'value', label: `${value}`, value } - ) - ) - if (this.values.includes('integer')) - suggestions.push( - ...[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map( - (value) => - { type: 'value', label: `${value}`, value } - ) - ) - if (this.values.includes('number')) - suggestions.push( - ...[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0].map( - (value) => - { type: 'value', label: `${value}`, value } - ) - ) - if (this.values.includes('null')) - suggestions.push({ - type: 'value', - label: 'null', - value: null, - }) - - return suggestions - } - - validate(val: unknown) { - const values = Array.isArray(this.value) ? this.value : [this.value] - - // Every number is also an integer - if (values.includes('number') && !values.includes('integer')) { - values.push('integer') - } - - let valType = getTypeOf(val) - - if (!values.includes(valType)) - return [ - { - severity: 'warning', - message: `This node is of type ${valType}; expected ${values.join( - ', ' - )}`, - }, - ] - - return [] - } -} diff --git a/src/components/Languages/Json/ColorPicker/Color.ts b/src/components/Languages/Json/ColorPicker/Color.ts deleted file mode 100644 index 4e3a4560b..000000000 --- a/src/components/Languages/Json/ColorPicker/Color.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { languages } from 'monaco-editor' -import { toTwoDigitHex } from './parse/hex' - -export class Color { - constructor(public colorInfo: languages.IColor) {} - - /** - * Formats the color as #RRGGBB - */ - toHex() { - return `#${toTwoDigitHex(this.colorInfo.red * 255)}${toTwoDigitHex( - this.colorInfo.green * 255 - )}${toTwoDigitHex(this.colorInfo.blue * 255)}` - } - - /** - * Formats the color as #RRGGBBAA - */ - toHexA() { - return `#${toTwoDigitHex(this.colorInfo.red * 255)}${toTwoDigitHex( - this.colorInfo.green * 255 - )}${toTwoDigitHex(this.colorInfo.blue * 255)}${toTwoDigitHex( - Math.round(this.colorInfo.alpha * 255) - )}` - } - - /** - * Formats the color as #AARRGGBB - */ - toAHex() { - return `#${toTwoDigitHex( - Math.round(this.colorInfo.alpha * 255) - )}${toTwoDigitHex(this.colorInfo.red * 255)}${toTwoDigitHex( - this.colorInfo.green * 255 - )}${toTwoDigitHex(this.colorInfo.blue * 255)}` - } - - /** - * Formats the color as [r, g, b], 0-1 - */ - toDecRgbArray() { - return [ - +this.colorInfo.red.toFixed(5), - +this.colorInfo.green.toFixed(5), - +this.colorInfo.blue.toFixed(5), - ] - } - /** - * Formats the color as [r, g, b], 0-255 - */ - toRgbArray() { - return [ - Math.round(this.colorInfo.red * 255), - Math.round(this.colorInfo.green * 255), - Math.round(this.colorInfo.blue * 255), - ] - } - - /** - * Formats the color as [r, g, b, a] 0-1 - */ - toDecRgbaArray() { - return [ - +this.colorInfo.red.toFixed(5), - +this.colorInfo.green.toFixed(5), - +this.colorInfo.blue.toFixed(5), - this.colorInfo.alpha, - ] - } - /** - * Formats the color as [r, g, b, a], 0-255 - */ - toRgbaArray() { - return [ - Math.round(this.colorInfo.red * 255), - Math.round(this.colorInfo.green * 255), - Math.round(this.colorInfo.blue * 255), - Math.round(this.colorInfo.alpha * 255), - ] - } -} diff --git a/src/components/Languages/Json/ColorPicker/ColorPicker.ts b/src/components/Languages/Json/ColorPicker/ColorPicker.ts deleted file mode 100644 index af93a545d..000000000 --- a/src/components/Languages/Json/ColorPicker/ColorPicker.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { useMonaco } from '/@/utils/libs/useMonaco' -import type { editor, CancellationToken, languages } from 'monaco-editor' -import { Color } from './Color' -import { findColors } from './findColors' -import { parseColor } from './parse/main' - -export async function registerColorPicker() { - const { languages, Position, Range } = await useMonaco() - - languages.registerColorProvider('json', { - provideDocumentColors: async ( - model: editor.ITextModel, - token: CancellationToken - ) => { - return await findColors(model) - }, - provideColorPresentations: async ( - model: editor.ITextModel, - colorInfo: languages.IColorInformation, - token: CancellationToken - ) => { - const newColor = new Color(colorInfo.color) - const value = model.getValueInRange(colorInfo.range) - const position = new Position( - colorInfo.range.startLineNumber, - colorInfo.range.startColumn + 2 - ) - - // We need to decide which format this color is supposed to be in by doing 2 things: - // 1. Parse the value to find the format - // 2. Check the json path against valid locations to confirm the format, if necessary - - /** - * Takes an array as a string and inserts values into the array while preserving whitespace - */ - const insertToStringArray = (arr: string, newValues: any[]) => { - const valueTest = /(\d+(?:\.\d*)?)/gim - const split = arr.split(',') - let newArr = '' - // If the number of values to replace != the number of values available replace just return the orignal value - if (split.length !== newValues.length) return arr - for (const [i, value] of newValues.entries()) { - // Get the first value to replace - const toReplace = split[i].match(valueTest) - // If there is something to replace, replace it - if (toReplace && toReplace[0]) - newArr += `${split[i].replace(toReplace[0], value)}${ - i + 1 === newValues.length ? '' : ',' // Comma after value if not last element - }` - } - return newArr - } - - const { format } = await parseColor(value, { - model, - position, - }) - - switch (format) { - case 'hex': - return [ - { - label: `"${newColor.toHex().toUpperCase()}"`, - }, - ] - case 'hexa': - return [ - { - label: `"${newColor.toHexA().toUpperCase()}"`, - }, - ] - case 'ahex': - return [ - { - label: `"${newColor.toAHex().toUpperCase()}"`, - }, - ] - - case 'rgbDec': - return [ - { - label: `[${newColor.toDecRgbArray().join(', ')}]`, - textEdit: { - range: colorInfo.range, - text: insertToStringArray( - value, - newColor.toDecRgbArray() - ), - }, - }, - ] - case 'rgb': - return [ - { - label: `[${newColor.toRgbArray().join(', ')}]`, - textEdit: { - range: colorInfo.range, - text: insertToStringArray( - value, - newColor.toRgbArray() - ), - }, - }, - ] - case 'rgbaDec': - return [ - { - label: `[${newColor.toDecRgbaArray().join(', ')}]`, - textEdit: { - range: colorInfo.range, - text: insertToStringArray( - value, - newColor.toDecRgbaArray() - ), - }, - }, - ] - case 'rgba': - return [ - { - label: `[${newColor.toRgbaArray().join(', ')}]`, - textEdit: { - range: colorInfo.range, - text: insertToStringArray( - value, - newColor.toRgbaArray() - ), - }, - }, - ] - - default: - // If all fails, don't do anything to be safe - return [ - { - label: '', - textEdit: { - range: new Range(0, 0, 0, 0), - text: '', - }, - }, - ] - } - }, - }) -} diff --git a/src/components/Languages/Json/ColorPicker/Data.ts b/src/components/Languages/Json/ColorPicker/Data.ts deleted file mode 100644 index c1269f01d..000000000 --- a/src/components/Languages/Json/ColorPicker/Data.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { markRaw } from 'vue' -import { App } from '/@/App' -import { Signal } from '/@/components/Common/Event/Signal' - -export class ColorData extends Signal { - protected _data?: any - - async loadColorData() { - const app = await App.getApp() - - this._data = markRaw( - await app.dataLoader.readJSON( - `data/packages/minecraftBedrock/location/validColor.json` - ) - ) - - this.dispatch() - } - - async getDataForCurrentTab() { - await this.fired - - const app = await App.getApp() - - const currentTab = app.project.tabSystem?.selectedTab - if (!currentTab) return {} - - // Get the file definition id of the currently opened tab - const id = App.fileType.getId(currentTab.getPath()) - - // Get the color locations for this file type - return this._data[id] - } -} diff --git a/src/components/Languages/Json/ColorPicker/findColors.ts b/src/components/Languages/Json/ColorPicker/findColors.ts deleted file mode 100644 index 0cc664fd2..000000000 --- a/src/components/Languages/Json/ColorPicker/findColors.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { isMatch } from 'bridge-common-utils' -import type { JSONPath } from 'jsonc-parser' -import type { editor, languages } from 'monaco-editor' -import { useJsoncParser } from '/@/utils/libs/useJsoncParser' -import { useMonaco } from '/@/utils/libs/useMonaco' -import { getJsonWordAtPosition } from '/@/utils/monaco/getJsonWord' -import { getArrayValueAtOffset } from '/@/utils/monaco/getArrayValue' -import { parseColor } from './parse/main' -import { App } from '/@/App' -import { BedrockProject } from '/@/components/Projects/Project/BedrockProject' - -/** - * Takes a text model and detects the locations of colors in the file - */ -export async function findColors(model: editor.ITextModel) { - const { visit } = await useJsoncParser() - const { Range } = await useMonaco() - - const content = model.getValue() - - const app = await App.getApp() - const project = app.project - if (!(project instanceof BedrockProject)) return - - const locationPatterns = await project.colorData.getDataForCurrentTab() - const colorInfo: Promise[] = [] - - if (!locationPatterns) return [] - - // Walk through the json file - visit(content, { - // When we reach any literal value, e.g. a string, ... - onLiteralValue: async ( - value: any, - offset: number, - length: number, - startLine: number, - startCharacter: number, - pathSupplier: () => JSONPath - ) => { - // Call the path supplier and join the JSON segments into a path - const path = pathSupplier().join('/') - - // Iterate each color format for this file type - // Filter down to formats that are string only, as this is for literal values - for (const format of ['hex', 'hexa', 'ahex']) { - // Check whether the value at this JSON path matches a pattern in the valid colors file - if (!locationPatterns[format]) continue - const isValidColor = isMatch(path, locationPatterns[format]) - - // If this is a valid color, create a promise that will resolve when the color has been parsed and the range has been determined - if (isValidColor) { - colorInfo.push( - new Promise(async (resolve) => { - const position = model.getPositionAt(offset + 2) - const { color } = await parseColor(value, { - model, - position, - }) - const { range } = await getJsonWordAtPosition( - model, - position - ) - if (!color) return resolve(null) - - resolve({ - color: color.colorInfo, - range: new Range( - range.startLineNumber, - range.startColumn, - range.endLineNumber, - range.endColumn + 2 - ), - }) - }) - ) - break - } - } - }, - onArrayBegin( - offset: number, - length: number, - startLine: number, - startCharacter: number, - pathSupplier: () => JSONPath - ) { - // Handle similarly to when approaching a literal value - - // Call the path supplier and join the JSON segments into a path - const path = pathSupplier().join('/') - - // Iterate each color format for this file type - for (const format of ['rgb', 'rgba', 'rgbDec', 'rgbaDec']) { - // Check whether the value at this JSON path matches a pattern in the valid colors file - if (!locationPatterns[format]) continue - const isValidColor = isMatch(path, locationPatterns[format]) - - // If this is a valid color, create a promise that will resolve when the color has been parsed and the range has been determined - if (isValidColor) { - colorInfo.push( - new Promise(async (resolve) => { - const { range, word } = await getArrayValueAtOffset( - model, - offset - ) - const { color } = await parseColor(word, { - model, - position: model.getPositionAt(offset), - }) - if (!color) return resolve(null) - - resolve({ - color: color.colorInfo, - range: new Range( - range.startLineNumber, - range.startColumn, - range.endLineNumber, - range.endColumn - ), - }) - }) - ) - break - } - } - }, - }) - - // Await all promises for the color info and filter to ensure each color info contains the correct data - return ( - (await Promise.all(colorInfo)).filter((info) => info !== null) - ) -} diff --git a/src/components/Languages/Json/ColorPicker/parse/hex.ts b/src/components/Languages/Json/ColorPicker/parse/hex.ts deleted file mode 100644 index fcbfa8a35..000000000 --- a/src/components/Languages/Json/ColorPicker/parse/hex.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Color } from '../Color' - -/** - * Convert a single hex digit to denary value - * @param index The index of the hex string to convert - */ -const fromHex = (hex: string, index: number) => - parseInt(hex.slice(index, index + 1), 16) - -export function parseHex(hex: string) { - const r = 16 * fromHex(hex, 1) + fromHex(hex, 2) - const g = 16 * fromHex(hex, 3) + fromHex(hex, 4) - const b = 16 * fromHex(hex, 5) + fromHex(hex, 6) - return new Color({ - red: r / 255, - green: g / 255, - blue: b / 255, - alpha: 1, - }) -} - -export function parseAHex(hex: string) { - const a = 16 * fromHex(hex, 1) + fromHex(hex, 2) - const r = 16 * fromHex(hex, 3) + fromHex(hex, 4) - const g = 16 * fromHex(hex, 5) + fromHex(hex, 6) - const b = 16 * fromHex(hex, 7) + fromHex(hex, 8) - return new Color({ - red: r / 255, - green: g / 255, - blue: b / 255, - alpha: a / 255, - }) -} - -export function parseHexA(hex: string) { - const r = 16 * fromHex(hex, 1) + fromHex(hex, 2) - const g = 16 * fromHex(hex, 3) + fromHex(hex, 4) - const b = 16 * fromHex(hex, 5) + fromHex(hex, 6) - const a = 16 * fromHex(hex, 7) + fromHex(hex, 8) - return new Color({ - red: r / 255, - green: g / 255, - blue: b / 255, - alpha: a / 255, - }) -} - -export function toTwoDigitHex(value: number) { - const hex = value.toString(16) - return hex.length !== 2 ? '0' + hex : hex -} diff --git a/src/components/Languages/Json/ColorPicker/parse/main.ts b/src/components/Languages/Json/ColorPicker/parse/main.ts deleted file mode 100644 index 2a293899c..000000000 --- a/src/components/Languages/Json/ColorPicker/parse/main.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Color } from '../Color' -import { parseAHex, parseHex, parseHexA } from './hex' -import { parseRgbDec, parseRgb, parseRgbaDec, parseRgba } from './rgb' -import { getLocation } from '/@/utils/monaco/getLocation' -import type { editor, Position } from 'monaco-editor' -import { isMatch } from 'bridge-common-utils' -import { App } from '/@/App' -import { BedrockProject } from '/@/components/Projects/Project/BedrockProject' - -/** - * Takes a color value and some file context to figure out the format and color info - * @param value The string value of the color - * @param context The file context, including the text model and the position of the color - */ -export async function parseColor( - value: any, - context: { - model: editor.ITextModel - position: Position - } -): Promise<{ format: string; color?: Color }> { - const app = await App.getApp() - const project = app.project - if (!(project instanceof BedrockProject)) return { format: 'unknown' } - const colorData = project.colorData - - // Hex formats #RGB and #RGBA exist but don't appear in Minecraft, so we don't support parsing them - - // We should expect the value to have either no quotes or surrounding quotes - if ( - typeof value == 'string' && - value[0] === '"' && - value[value.length - 1] === '"' && - value[1] === '#' - ) - value = value.slice(1, -1) - - // Parse as a hex string, if this fails, parse as RGB array - if (value.startsWith('#')) { - switch (value.length) { - case 7: - return { - format: 'hex', - color: parseHex(value), - } - case 9: { - // Could either be hexa or ahex here, so we check with valid color data - const validColors = await colorData.getDataForCurrentTab() - // If either are valid in the file... - if (validColors) { - const location = await getLocation( - context.model, - context.position, - false - ) - // Check if hexa is valid at this location - if ( - validColors['hexa'] && - isMatch(location, validColors['hexa']) - ) - return { - format: 'hexa', - color: parseHexA(value), - } - // Check if ahex is valid at this location - if ( - validColors['ahex'] && - isMatch(location, validColors['ahex']) - ) - return { - format: 'ahex', - color: parseAHex(value), - } - } else { - // Otherwise, just default to hexa - return { - format: 'hexa', - color: parseHexA(value), - } - } - } - } - } - - // Parse as RGB, if this fails, just return unknown format - let raw - try { - raw = JSON.parse(value) - } catch { - return { - format: 'unknown', - } - } - - // Ignore if not an array or each value isn't a number - if (!Array.isArray(raw) || !raw.every((val) => typeof val === 'number')) - return { - format: 'unknown', - } - - if (raw.length === 3) { - // Could either be rgb or rgbDec here, so we check with valid color data - const validColors = await colorData.getDataForCurrentTab() - if (validColors) { - const location = await getLocation(context.model, context.position) - // Check if rgb is valid at this location - if (validColors['rgb'] && isMatch(location, validColors['rgb'])) - return { - format: 'rgb', - color: parseRgb(raw), - } - // Check if rgbDec is valid at this location - if ( - validColors['rgbDec'] && - isMatch(location, validColors['rgbDec']) - ) - return { - format: 'rgbDec', - color: parseRgbDec(raw), - } - } else { - // Otherwise, just default to rgb - return { - format: 'rgb', - color: parseRgb(raw), - } - } - } - if (raw.length === 4) { - // Could either be rgba or rgbaDec here, so we check with valid color data - const validColors = await colorData.getDataForCurrentTab() - if (validColors) { - const location = await getLocation(context.model, context.position) - // Check if rgba is valid at this location - if (validColors['rgba'] && isMatch(location, validColors['rgba'])) - return { - format: 'rgba', - color: parseRgba(raw), - } - // Check if rgbaDec is valid at this location - if ( - validColors['rgbaDec'] && - isMatch(location, validColors['rgbaDec']) - ) - return { - format: 'rgbaDec', - color: parseRgbaDec(raw), - } - } else { - // Otherwise, just default to rgba - return { - format: 'rgba', - color: parseRgba(raw), - } - } - } - - return { - format: 'unknown', - } -} diff --git a/src/components/Languages/Json/ColorPicker/parse/rgb.ts b/src/components/Languages/Json/ColorPicker/parse/rgb.ts deleted file mode 100644 index c44f52f62..000000000 --- a/src/components/Languages/Json/ColorPicker/parse/rgb.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Color } from '../Color' - -export function parseRgb(rgbArr: number[]) { - return new Color({ - red: rgbArr[0] / 255, - green: rgbArr[1] / 255, - blue: rgbArr[2] / 255, - alpha: 1, - }) -} - -export function parseRgba(rgbArr: number[]) { - return new Color({ - red: rgbArr[0] / 255, - green: rgbArr[1] / 255, - blue: rgbArr[2] / 255, - alpha: rgbArr[3] / 255, - }) -} - -export function parseRgbDec(rgbArr: number[]) { - return new Color({ - red: rgbArr[0], - green: rgbArr[1], - blue: rgbArr[2], - alpha: 1, - }) -} - -export function parseRgbaDec(rgbArr: number[]) { - return new Color({ - red: rgbArr[0], - green: rgbArr[1], - blue: rgbArr[2], - alpha: rgbArr[3], - }) -} diff --git a/src/components/Languages/Json/Highlighter.ts b/src/components/Languages/Json/Highlighter.ts deleted file mode 100644 index d0c13a796..000000000 --- a/src/components/Languages/Json/Highlighter.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { TextTab } from '/@/components/Editors/Text/TextTab' -import { ProjectConfig } from '../../Projects/Project/Config' -import { App } from '/@/App' -import { IDisposable } from '/@/types/disposable' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' -import { TreeTab } from '/@/components/Editors/TreeEditor/Tab' -import type { Tab } from '/@/components/TabSystem/CommonTab' -import { useMonaco } from '../../../utils/libs/useMonaco' -import { supportsLookbehind } from './supportsLookbehind' - -export interface IKnownWords { - keywords: string[] - typeIdentifiers: string[] - variables: string[] - definitions: string[] -} - -export class ConfiguredJsonHighlighter extends EventDispatcher { - protected currentLanguage?: IDisposable - protected loadedFileType?: string - protected baseKeywords: string[] = ['minecraft'] - protected dynamicKeywords: string[] = [] - protected typeIdentifiers: string[] = [] - protected variables: string[] = [] - protected definitions: string[] = [] - - get keywords() { - return this.baseKeywords.concat(this.dynamicKeywords) - } - get knownWords() { - return { - keywords: this.keywords, - typeIdentifiers: this.typeIdentifiers, - variables: this.variables, - definitions: this.definitions, - } - } - - constructor() { - super() - - App.getApp().then(async (app) => { - await app.projectManager.projectReady.fired - - app.projectManager.onActiveProject((project) => { - this.updateKeywords(project.config) - project.fileSave.on( - project.config.resolvePackPath(undefined, 'config.json'), - () => this.updateKeywords(project.config) - ) - }) - }) - - App.eventSystem.on('currentTabSwitched', (tab: Tab) => { - this.loadWords(tab) - }) - this.loadWords() - } - - async updateKeywords(projectConfig: ProjectConfig) { - await projectConfig.refreshConfig() - - this.baseKeywords = [ - ...new Set( - ['minecraft', 'bridge', projectConfig.get().namespace].filter( - (k) => k !== undefined - ) - ), - ] - - this.updateHighlighter() - } - - async loadWords(tabArg?: Tab) { - const app = await App.getApp() - await app.projectManager.projectReady.fired - await App.fileType.ready.fired - - const tab = tabArg ?? app.project.tabSystem?.selectedTab - if (!(tab instanceof TextTab) && !(tab instanceof TreeTab)) return - - const { id, highlighterConfiguration = {}, type } = - App.fileType.get(tab.getPath()) ?? {} - - // We have already loaded the needed file type - if (!id) return this.resetWords() - if ( - id === this.loadedFileType || - (type !== undefined && type !== 'json') - ) - return - - this.dynamicKeywords = highlighterConfiguration.keywords ?? [] - this.typeIdentifiers = highlighterConfiguration.typeIdentifiers ?? [] - this.variables = highlighterConfiguration.variables ?? [] - this.definitions = highlighterConfiguration.definitions ?? [] - - this.updateHighlighter() - this.dispatch(this.knownWords) - this.loadedFileType = id - } - resetWords() { - this.dynamicKeywords = [] - this.typeIdentifiers = [] - this.variables = [] - this.definitions = [] - - this.updateHighlighter() - this.dispatch(this.knownWords) - this.loadedFileType = 'unknown' - } - - async updateHighlighter() { - const { languages } = await useMonaco() - - const newLanguage = languages.setMonarchTokensProvider('json', { - // Set defaultToken to invalid to see what you do not tokenize yet - defaultToken: 'invalid', - tokenPostfix: '.json', - - atoms: ['false', 'true', 'null'], - keywords: this.keywords, - typeIdentifiers: this.typeIdentifiers, - variables: this.variables, - definitions: this.definitions, - - // we include these common regular expressions - symbols: /[=> { - const project = await App.getApp().then((app) => app.project) - if (!(project instanceof BedrockProject)) return - - const { Range, languages } = await useMonaco() - const langData = project.langData - await langData.fired - - const app = await App.getApp() - // Only auto-complete in a client lang file - const isClientLang = - App.fileType.getId( - app.project.tabSystem?.selectedTab?.getPath()! - ) === 'clientLang' - - if (!isClientLang) return { suggestions: [] } - - const currentLine = model.getLineContent(position.lineNumber) - - // Find out whether our cursor is positioned after a '=' - let isValueSuggestion = false - for (let i = position.column - 1; i >= 0; i--) { - const char = currentLine[i] - if (char === '=') { - isValueSuggestion = true - } - } - - const suggestions: languages.CompletionItem[] = [] - - if (!isValueSuggestion) { - // Get the lang keys that are already set in the file - const currentLangKeys = new Set( - model - .getValue() - .split('\n') - .map((line) => line.split('=')[0].trim()) - ) - - const validLangKeys = (await langData.getValidLangKeys()).filter( - (key) => !currentLangKeys.has(key) - ) - - suggestions.push( - ...validLangKeys.map((key) => ({ - range: new Range( - position.lineNumber, - position.column, - position.lineNumber, - position.column - ), - kind: languages.CompletionItemKind.Text, - label: key, - insertText: key, - })) - ) - } else { - // Generate a value based on the key - const line = model - .getValueInRange( - new Range( - position.lineNumber, - 0, - position.lineNumber, - position.column - ) - ) - .toLowerCase() - - // Check whether the cursor is after a key and equals sign, but no value yet (e.g. "tile.minecraft:dirt.name=") - if (line[line.length - 1] === '=') { - const translation = (await guessValue(line)) ?? '' - suggestions.push({ - label: translation, - insertText: translation, - kind: languages.CompletionItemKind.Text, - range: new Range( - position.lineNumber, - position.column, - position.lineNumber, - position.column - ), - }) - } - } - - return { - suggestions, - } - }, -} -const codeActionProvider: languages.CodeActionProvider = { - provideCodeActions: async ( - model: editor.ITextModel, - range: Range, - context: languages.CodeActionContext - ) => { - const { Range } = await useMonaco() - - const actions: languages.CodeAction[] = [] - for (const marker of context.markers) { - const line = model.getLineContent(marker.startLineNumber) - const val = await guessValue(line) - - actions.push({ - title: translate('editors.langValidation.noValue.quickFix'), - diagnostics: [marker], - kind: 'quickfix', - edit: { - edits: [ - { - resource: model.uri, - edit: { - range: new Range( - marker.startLineNumber, - marker.startColumn, - marker.endLineNumber, - marker.endColumn - ), - text: `${line}=${val}`, - }, - }, - ], - }, - isPreferred: true, - }) - } - return { - actions: actions, - dispose: () => {}, - } - }, -} - -export class LangLanguage extends Language { - constructor() { - super({ - id: 'lang', - extensions: ['lang'], - config, - tokenProvider, - completionItemProvider, - codeActionProvider, - }) - - // Highlight namespaces - this.disposables.push( - App.eventSystem.on('projectChanged', (project: Project) => { - const tokenizer = { - root: [ - ...new Set( - [ - 'minecraft', - 'bridge', - project.config.get().namespace, - ].filter((k) => k !== undefined) - ), - ] - .map((word) => [word, 'keyword']) - .concat(tokenProvider.tokenizer.root), - } - - this.updateTokenProvider({ tokenizer }) - }) - ) - } - - async validate(model: editor.IModel) { - const { editor, MarkerSeverity } = await useMonaco() - - const markers: editor.IMarkerData[] = [] - for (let l = 1; l <= model.getLineCount(); l++) { - const line = model.getLineContent(l) - if (line && !line.includes('=') && !line.startsWith('##')) - markers.push({ - startColumn: 1, - endColumn: line.length + 1, - startLineNumber: l, - endLineNumber: l, - message: translate( - 'editors.langValidation.noValue.errorMessage' - ), - severity: MarkerSeverity.Error, - }) - } - editor.setModelMarkers(model, this.id, markers) - } -} diff --git a/src/components/Languages/Lang/Data.ts b/src/components/Languages/Lang/Data.ts deleted file mode 100644 index 0854520c7..000000000 --- a/src/components/Languages/Lang/Data.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { inject, markRaw } from 'vue' -import { App } from '/@/App' -import { Signal } from '/@/components/Common/Event/Signal' - -interface ILangKey { - formats: string[] - inject: { - name: string - fileType: string - cacheKey: string - }[] -} - -export class LangData extends Signal { - protected _data?: any - - async loadLangData(packageName: string) { - const app = await App.getApp() - - this._data = markRaw( - await app.dataLoader.readJSON( - `data/packages/${packageName}/language/lang/main.json` - ) - ) - - this.dispatch() - } - - async getValidLangKeys() { - if (!this._data) return [] - - let keys: string[] = [] - for (const keyDef of this._data.keys as ILangKey[]) { - keys = keys.concat(await this.generateKeys(keyDef)) - } - return keys - } - - async generateKeys(key: ILangKey) { - const app = await App.getApp() - const packIndexer = app.project.packIndexer - - await packIndexer.fired - - // Find out what data to use for these keys - let keys: string[] = [] - for (const fromCache of key.inject) { - const fetchedData = - (await packIndexer.service.getCacheDataFor( - fromCache.fileType, - undefined, - fromCache.cacheKey - )) ?? [] - for (const format of key.formats) { - keys = keys.concat( - fetchedData.map((data: string) => - format.replace(`{{${fromCache.name}}}`, data) - ) - ) - } - } - - return keys - } -} diff --git a/src/components/Languages/Lang/guessValue.ts b/src/components/Languages/Lang/guessValue.ts deleted file mode 100644 index aa2a24f2e..000000000 --- a/src/components/Languages/Lang/guessValue.ts +++ /dev/null @@ -1,31 +0,0 @@ -export async function guessValue(line: string) { - // 1. Find the part of the key that isn't a common key prefix/suffix (e.g. the identifier) - const commonParts = ['name', 'tile', 'item', 'entity', 'action'] - const key = line.substring(0, line.length - 1) - let uniqueParts = key - .split('.') - .filter((part) => !commonParts.includes(part)) - - // 2. If there are 2 parts and one is spawn_egg, then state that "Spawn " should be added to the front of the value - const spawnEggIndex = uniqueParts.indexOf('spawn_egg') - const isSpawnEgg = uniqueParts.length === 2 && spawnEggIndex >= 0 - if (isSpawnEgg) uniqueParts.slice(spawnEggIndex, spawnEggIndex + 1) - - // 3. If there is still multiple parts left, search for the part with a namespaced identifier, as that is commonly the bit being translated (e.g. "minecraft:pig" -> "Pig") - if (uniqueParts.length > 1) { - const id = uniqueParts.find((part) => part.includes(':')) - if (id) uniqueParts = [id] - } - - // 4. Hopefully there is only one part left now, if there isn't, the first value will be used. If the value is a namespace (contains a colon), remove the namespace, then capitalise and propose - if (!uniqueParts[0]) return '' - - if (uniqueParts[0].includes(':')) - uniqueParts[0] = uniqueParts[0].split(':').pop() ?? '' - const translation = `${isSpawnEgg ? 'Spawn ' : ''}${uniqueParts[0] - .split('_') - .map((val) => `${val[0].toUpperCase()}${val.slice(1)}`) - .join(' ')}` - - return translation -} diff --git a/src/components/Languages/Language.ts b/src/components/Languages/Language.ts deleted file mode 100644 index dc82d9829..000000000 --- a/src/components/Languages/Language.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { IDisposable } from '/@/types/disposable' -import { debounce } from 'lodash-es' -import type { languages, editor } from 'monaco-editor' -import { useMonaco } from '../../utils/libs/useMonaco' - -export interface IAddLanguageOptions { - id: string - extensions: string[] - config: languages.LanguageConfiguration - tokenProvider: any - completionItemProvider?: languages.CompletionItemProvider - codeActionProvider?: languages.CodeActionProvider -} - -export abstract class Language { - protected id: string - protected disposables: IDisposable[] = [] - protected models = new Map() - protected highlighter?: IDisposable - - constructor({ - id, - extensions, - config, - tokenProvider, - completionItemProvider, - codeActionProvider, - }: IAddLanguageOptions) { - this.id = id - - useMonaco().then(({ languages, editor }) => { - languages.register({ id, extensions }) - this.disposables = [ - languages.setLanguageConfiguration(id, config), - editor.onDidCreateModel(this.onModelAdded.bind(this)), - editor.onWillDisposeModel(this.onModelRemoved.bind(this)), - editor.onDidChangeModelLanguage((event) => { - this.onModelRemoved(event.model) - this.onModelAdded(event.model) - }), - ] - this.highlighter = languages.setMonarchTokensProvider( - id, - tokenProvider - ) - - if (completionItemProvider) - this.disposables.push( - languages.registerCompletionItemProvider( - id, - completionItemProvider - ) - ) - if (codeActionProvider) - this.disposables.push( - languages.registerCodeActionProvider(id, codeActionProvider) - ) - }) - } - - updateTokenProvider(tokenProvider: any) { - useMonaco().then(({ languages }) => { - this.highlighter?.dispose() - this.highlighter = languages.setMonarchTokensProvider( - this.id, - tokenProvider - ) - }) - } - - protected onModelAdded(model: editor.IModel) { - if (model.getLanguageId() !== this.id) return false - - this.validate(model) - this.models.set( - model.uri.toString(), - model.onDidChangeContent( - debounce((event) => this.validate(model, event), 500) - ) - ) - - return true - } - protected onModelRemoved(model: editor.IModel) { - useMonaco().then(({ editor }) => { - editor.setModelMarkers(model, this.id, []) - }) - - const uriStr = model.uri.toString() - this.models.get(uriStr)?.dispose() - this.models.delete(uriStr) - } - - abstract validate( - model: editor.IModel, - event?: editor.IModelChangedEvent - ): Promise | void - - dispose() { - this.highlighter?.dispose() - this.disposables.forEach((disposable) => disposable.dispose()) - this.disposables = [] - } -} diff --git a/src/components/Languages/LanguageManager.ts b/src/components/Languages/LanguageManager.ts deleted file mode 100644 index b1e0d6d33..000000000 --- a/src/components/Languages/LanguageManager.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Language } from './Language' -import { LangLanguage } from './Lang' -import { McfunctionLanguage } from './Mcfunction' -import { MoLangLanguage } from './MoLang' - -export class LanguageManager { - protected otherLanguages = new Set([ - new MoLangLanguage(), - new LangLanguage(), - new McfunctionLanguage(), - ]) -} diff --git a/src/components/Languages/Mcfunction.ts b/src/components/Languages/Mcfunction.ts deleted file mode 100644 index 2a0dd36ef..000000000 --- a/src/components/Languages/Mcfunction.ts +++ /dev/null @@ -1,246 +0,0 @@ -import type { - CancellationToken, - editor, - languages, - Position, -} from 'monaco-editor' -import { BedrockProject } from '/@/components/Projects/Project/BedrockProject' -import { Language } from './Language' -import { tokenizeCommand, tokenizeTargetSelector } from 'bridge-common-utils' -import { App } from '/@/App' -import './Mcfunction/WithinJson' -import { tokenProvider } from './Mcfunction/TokenProvider' -import type { Project } from '/@/components/Projects/Project/Project' -import { isWithinTargetSelector } from './Mcfunction/TargetSelector/isWithin' -import { proxy } from 'comlink' -import { useMonaco } from '../../utils/libs/useMonaco' -import { CommandValidator } from './Mcfunction/Validator' - -export const config: languages.LanguageConfiguration = { - wordPattern: /[aA-zZ]+/, - comments: { - lineComment: '#', - }, - brackets: [ - ['(', ')'], - ['[', ']'], - ['{', '}'], - ], - autoClosingPairs: [ - { - open: '(', - close: ')', - }, - { - open: '[', - close: ']', - }, - { - open: '{', - close: '}', - }, - { - open: '"', - close: '"', - }, - ], -} - -const completionItemProvider: languages.CompletionItemProvider = { - triggerCharacters: [' ', '[', '{', '=', ',', '!'], - async provideCompletionItems( - model: editor.ITextModel, - position: Position, - context: languages.CompletionContext, - token: CancellationToken - ) { - const project = await App.getApp().then((app) => app.project) - if (!(project instanceof BedrockProject)) return - - const { Range } = await useMonaco() - const commandData = project.commandData - await commandData.fired - - const line = model.getLineContent(position.lineNumber) - - const lineUntilCursor = line.slice(0, position.column - 1) - - /** - * Auto-completions for target selector arguments - */ - const selector = isWithinTargetSelector(line, position.column - 1) - if (selector) { - const selectorStr = line.slice( - selector.selectorStart, - position.column - 1 - ) - const { tokens } = tokenizeTargetSelector( - selectorStr, - selector.selectorStart - ) - const lastToken = tokens[tokens.length - 1] - - return { - suggestions: await commandData.selectorArguments - .getNextCompletionItems(tokens.map((token) => token.word)) - .then((completionItems) => - completionItems.map( - ({ - label, - insertText, - documentation, - kind, - insertTextRules, - }) => ({ - label: label ?? insertText, - insertText, - documentation, - kind, - range: new Range( - position.lineNumber, - (lastToken?.startColumn ?? 0) + 1, - position.lineNumber, - (lastToken?.endColumn ?? 0) + 1 - ), - insertTextRules, - }) - ) - ), - } - } - - /** - * Normal command auto-completions - */ - const { tokens } = tokenizeCommand(lineUntilCursor) - // Get the last token - const lastToken = tokens[tokens.length - 1] - - const completionItems = await commandData.getNextCompletionItems( - tokens.map((token) => token.word) - ) - - return { - suggestions: completionItems.map( - ({ - label, - insertText, - documentation, - kind, - insertTextRules, - }) => ({ - label: label ?? insertText, - insertText, - documentation, - kind, - range: new Range( - position.lineNumber, - (lastToken?.startColumn ?? 0) + 1, - position.lineNumber, - (lastToken?.endColumn ?? 0) + 1 - ), - insertTextRules, - }) - ), - } - }, -} - -const loadCommands = async (lang: McfunctionLanguage) => { - const app = await App.getApp() - await app.projectManager.fired - - const project = app.project - if (!(project instanceof BedrockProject)) return - - await project.commandData.fired - await project.compilerReady.fired - - const commands = await project.commandData.allCommands( - undefined, - !(await project.compilerService.isSetup) - ) - tokenProvider.keywords = commands.map((command) => command) - - const targetSelectorArguments = - await project.commandData.allSelectorArguments() - tokenProvider.targetSelectorArguments = targetSelectorArguments - - lang.updateTokenProvider(tokenProvider) -} - -export class McfunctionLanguage extends Language { - protected validator: CommandValidator | undefined - - constructor() { - super({ - id: 'mcfunction', - extensions: ['mcfunction'], - config, - tokenProvider, - completionItemProvider, - }) - - let loadedProject: Project | null = null - const disposable = App.eventSystem.on( - 'projectChanged', - async (project: Project) => { - loadedProject = project - loadCommands(this) - - await project.compilerReady.fired - - await project.compilerService.once( - proxy(() => { - // Make sure that we are still supposed to update the language - // -> project didn't change - if (project === loadedProject) loadCommands(this) - }) - ) - } - ) - - App.getApp().then(async (app) => { - await app.projectManager.projectReady.fired - - this.disposables.push( - app.projectManager.forEachProject((project) => { - this.disposables.push( - project.fileSave.any.on(([filePath]) => { - // Whenever a custom command gets saved, we need to update the token provider to account for a potential custom command name change - if ( - App.fileType.getId(filePath) === 'customCommand' - ) - loadCommands(this) - }) - ) - }), - disposable - ) - - const project = app.project - if (!(project instanceof BedrockProject)) return - - this.validator = new CommandValidator(project.commandData) - }) - } - - onModelAdded(model: editor.ITextModel) { - const isLangFor = super.onModelAdded(model) - if (!isLangFor) return false - - loadCommands(this) - - return true - } - - async validate(model: editor.IModel) { - if (this.validator == undefined) return - - const { editor } = await useMonaco() - - const diagnostics = await this.validator.parse(model.getValue()) - - editor.setModelMarkers(model, this.id, diagnostics) - } -} diff --git a/src/components/Languages/Mcfunction/Data.ts b/src/components/Languages/Mcfunction/Data.ts deleted file mode 100644 index 32af2555e..000000000 --- a/src/components/Languages/Mcfunction/Data.ts +++ /dev/null @@ -1,671 +0,0 @@ -import { markRaw } from 'vue' -import { MoLang } from 'molang' -import type { languages } from 'monaco-editor' -import { generateCommandSchemas } from '../../Compiler/Worker/Plugins/CustomCommands/generateSchemas' -import { RequiresMatcher } from '../../Data/RequiresMatcher/RequiresMatcher' -import { RefSchema } from '../../JSONSchema/Schema/Ref' -import { strMatchArray } from './strMatch' -import { App } from '/@/App' -import { Signal } from '/@/components/Common/Event/Signal' -import { SelectorArguments } from './TargetSelector/SelectorArguments' -import { useMonaco } from '../../../utils/libs/useMonaco' -import { ResolvedCommandArguments } from './ResolvedCommandArguments' -/** - * An interface that describes a command - */ -export interface ICommand { - commandName: string - description: string - arguments: ICommandArgument[] -} - -export interface ISelectorArgument extends ICommandArgument { - additionalData?: { - schemaReference?: string - values?: string[] - multipleInstancesAllowed?: 'always' | 'whenNegated' | 'never' - supportsNegation?: boolean - } -} - -/** - * Type holding all possible argument types - */ -export type TArgumentType = - | 'string' - | 'number' - | 'boolean' - | 'selector' - | 'molang' - | 'blockState' - | 'jsonData' - | 'coordinate' - | 'command' - | 'scoreData' - | 'subcommand' - | 'integerRange' - | `$${string}` - -/** - * An interface that describes a command argument - */ -export interface ICommandArgument { - argumentName: string - description: string - type: TArgumentType - allowMultiple?: boolean - additionalData?: { - schemaReference?: string - values?: string[] - } - isOptional: boolean -} - -export interface ICompletionItem { - label?: string - insertText: string - documentation?: string - kind: languages.CompletionItemKind - insertTextRules?: languages.CompletionItemInsertTextRule -} - -/** - * A class that stores data on all Minecraft commands - */ -export class CommandData extends Signal { - public readonly selectorArguments = new SelectorArguments(this) - protected _data?: any - - async loadCommandData(packageName: string) { - const app = await App.getApp() - - this._data = markRaw( - await app.dataLoader.readJSON( - `data/packages/${packageName}/language/mcfunction/main.json` - ) - ) - - const customTypes: Record = - this._data.$customTypes ?? {} - - for (const { commands = [], subcommands = [] } of this._data.vanilla) { - const allCommands = commands.concat( - subcommands.map((subcommand: any) => subcommand.commands).flat() - ) - for (const command of allCommands) { - if (!command.arguments) continue - - command.arguments = command.arguments - .map((argument: ICommandArgument) => { - if (!argument.type || !argument.type.startsWith('$')) - return argument - - const replaceWith = customTypes[argument.type.slice(1)] - if (!replaceWith) return argument - - return replaceWith.map((replaceArgument) => ({ - ...argument, - ...replaceArgument, - })) - }) - .flat() - } - } - - this.dispatch() - } - - get shouldIgnoreCustomCommands() { - return App.getApp().then( - (app) => !app.projectManager.projectReady.hasFired - ) - } - - protected async getSchema() { - if (!this._data) - throw new Error(`Acessing commandData before it was loaded.`) - - const validEntries: any[] = [] - const requiresMatcher = new RequiresMatcher() - await requiresMatcher.setup() - - for await (const entry of this._data.vanilla) { - if (requiresMatcher.isValid(entry.requires)) - validEntries.push(entry) - else if (!entry.requires) validEntries.push(entry) - } - - return validEntries - } - protected async getCommandsSchema( - ignoreCustomCommands = false - ): Promise { - return (await this.getSchema()) - .map((entry: any) => entry.commands) - .flat() - .concat(ignoreCustomCommands ? [] : await generateCommandSchemas()) - .filter((command: unknown) => command !== undefined) - } - async getSelectorArgumentsSchema(): Promise { - return (await this.getSchema()) - .map((entry: any) => entry.selectorArguments) - .flat() - .filter( - (selectorArgument: unknown) => selectorArgument !== undefined - ) - } - - async getSubcommands(commandName: string): Promise { - const schemas = await this.getSchema() - - return schemas - .map( - (schema) => - schema.subcommands?.filter( - (subcommand: any) => - subcommand.commandName === commandName - ) ?? [] - ) - .flat(1) - .map((subcommands) => subcommands.commands) - .flat(1) - } - - allCommands(query?: string, ignoreCustomCommands = false) { - return this.getCommandsSchema(ignoreCustomCommands).then((schema) => [ - ...new Set( - schema - .map((command: any) => command?.commandName) - .filter( - (commandName: string) => - !query || commandName?.includes(query) - ) - ), - ]) - } - allSelectorArguments() { - return this.getSelectorArgumentsSchema().then((schema) => [ - ...new Set( - schema.map( - (selectorArgument: any) => selectorArgument?.argumentName - ) - ), - ]) - } - - async getCommandCompletionItems( - query?: string, - ignoreCustomCommands = false - ) { - const { languages } = await useMonaco() - - const schema = await this.getCommandsSchema(ignoreCustomCommands) - const completionItems: ICompletionItem[] = [] - - schema - .filter( - (command: ICommand) => - !query || command.commandName?.includes(query) - ) - .forEach((command) => { - if ( - completionItems.some( - (item) => item.label === command.commandName - ) - ) - return - - completionItems.push({ - insertText: command.commandName, - label: command.commandName, - documentation: command.description, - kind: languages.CompletionItemKind.Method, - }) - }) - - return completionItems - } - - /** - * Given a commandName, return all matching command definitions - */ - async getCommandDefinitions( - commandName: string, - ignoreCustomCommands: boolean - ) { - const commands = await this.getCommandsSchema( - ignoreCustomCommands - ).then((schema) => { - return schema.filter( - (command: any) => command.commandName === commandName - ) - }) - - return commands - } - - async getNextCompletionItems(path: string[]): Promise { - if (path.length <= 1) - return await this.getCommandCompletionItems( - path[0], - await this.shouldIgnoreCustomCommands - ) - - const commandName = path.shift() - const currentCommands = await this.getCommandDefinitions( - commandName!, - await this.shouldIgnoreCustomCommands - ) - - if (!currentCommands || currentCommands.length === 0) return [] - - const completionItems: ICompletionItem[] = [] - ;( - await Promise.all( - currentCommands.map(async (currentCommand: ICommand) => { - // Get command argument - const args = ( - await this.getNextCommandArgument(currentCommand, [ - ...path, - ]) - ).arguments - if (args.length === 0) return [] - - // Return possible completion items for the argument - return await Promise.all( - args.map((arg) => - this.getCompletionItemsForArgument( - arg, - currentCommand.commandName - ) - ) - ) - }) - ) - ) - .flat(2) - .forEach((item: ICompletionItem) => { - if ( - completionItems.some( - (completionItem) => completionItem.label === item.label - ) - ) - return - - completionItems.push(item) - }) - - return completionItems - } - - /** - * Given a sequence of command arguments & the currentCommand, return possible values for the next argument - */ - protected async getNextCommandArgument( - currentCommand: ICommand, - path: string[] - ): Promise { - if (!currentCommand.arguments || currentCommand.arguments.length === 0) - return new ResolvedCommandArguments([], 0, false) - - const args = currentCommand.arguments ?? [] - let argumentIndex = 0 - let subcommandStopArg = null - let shouldProposeStopArg = false - - for (let i = 0; i < path.length; i++) { - const currentStr = path[i] - - if (currentStr === '') continue - - const matchType = await this.isArgumentType( - currentStr, - args[argumentIndex], - currentCommand.commandName - ) - - if (matchType === 'none') - return new ResolvedCommandArguments([], i, false) - else if (matchType === 'partial') - return new ResolvedCommandArguments( - path.length === i + 1 ? [args[argumentIndex]] : [], - i - ) - - // Propose arguments from nested command when necessary - if (args[argumentIndex].type === 'command' && i + 1 < path.length) { - return ResolvedCommandArguments.from( - ( - await Promise.all( - ( - await this.getCommandDefinitions( - currentStr, - await this.shouldIgnoreCustomCommands - ) - ).map((command) => - this.getNextCommandArgument( - command, - path.slice(i + 1) - ) - ) - ) - ).flat() - ) - } else if (args[argumentIndex].type === 'subcommand') { - const subcommands = await this.getSubcommands( - currentCommand.commandName - ) - - // Try to find stop argument within path - subcommandStopArg = args[argumentIndex + 1] ?? null - let foundStopArg = false - let stopArgIndex = i - while ( - subcommandStopArg && - !foundStopArg && - stopArgIndex < path.length - ) { - stopArgIndex++ - foundStopArg = - (await this.isArgumentType( - path[stopArgIndex], - subcommandStopArg, - currentCommand.commandName - )) === 'full' - } - - // Stop argument was entered, skip to next argument after stop argument - if (foundStopArg) { - argumentIndex += 2 - i = stopArgIndex - continue - } - - const validSubcommands = subcommands.filter( - (subcommand) => subcommand.commandName === path[i] - ) - if (validSubcommands.length === 0) - return new ResolvedCommandArguments([], i) - - const resolvedArgs = ResolvedCommandArguments.from( - await Promise.all( - validSubcommands.map((validSubcommand) => - this.getNextCommandArgument( - validSubcommand, - path.slice(i + 1) - ) - ) - ) - ) - const nextArgs = resolvedArgs.arguments - - // We have no more arguments for the subcommand - if (nextArgs.length === 0) { - // If multiple subcommands are valid, we should propose the next subcommand - if (args[argumentIndex].allowMultiple) { - i += resolvedArgs.lastParsedIndex + 1 - shouldProposeStopArg = true - } else { - // If only one subcommand is valid, we should propose the next argument - argumentIndex++ - } - - continue - } - - return resolvedArgs - } - - // Check next argument - argumentIndex++ - // If argumentIndex to large, return - if (argumentIndex >= args.length) - return new ResolvedCommandArguments([], i) - } - - if (!args[argumentIndex]) - return new ResolvedCommandArguments([], path.length - 1) - // If we are here, we are at the next argument, return it - return new ResolvedCommandArguments( - [args[argumentIndex]].concat( - subcommandStopArg && shouldProposeStopArg - ? [subcommandStopArg] - : [] - ), - path.length - 1 - ) - } - - /** - * Given an argument type, test whether a string matches the type - */ - async isArgumentType( - testStr: string, - commandArgument: ICommandArgument, - commandName?: string - ): Promise<'none' | 'partial' | 'full'> { - switch (commandArgument.type) { - case 'string': { - if (!commandArgument.additionalData?.values) return 'full' - - const values = - commandArgument.additionalData?.values ?? - this.resolveDynamicReference( - commandArgument.additionalData.schemaReference! - ) ?? - [] - - return strMatchArray(testStr, values) - } - case 'command': { - return strMatchArray( - testStr, - await this.allCommands( - undefined, - await this.shouldIgnoreCustomCommands - ) - ) - } - - case 'number': - return !isNaN(Number(testStr)) ? 'full' : 'none' - case 'selector': - return testStr.startsWith('@') ? 'full' : 'none' - case 'boolean': - return testStr === 'true' || testStr === 'false' - ? 'full' - : 'none' - case 'coordinate': - return testStr.startsWith('~') || - testStr.startsWith('^') || - !isNaN(Number(testStr)) - ? 'full' - : 'none' - case 'jsonData': - return /^\{.*\}$/.test(testStr) ? 'full' : 'none' - case 'blockState': - return /^\[.*\]$/.test(testStr) ? 'full' : 'none' - case 'molang': { - const molang = new MoLang() - - try { - molang.parse(testStr) - } catch (e) { - return 'none' - } - - return 'full' - } - case 'subcommand': { - const subcommands = commandName - ? await this.getSubcommands(commandName) - : [] - return strMatchArray( - testStr, - subcommands.map((subcommand) => subcommand.commandName) - ) - } - case 'integerRange': - return /^([\d]*)?([.]{2})?([\d]*)?$/.test(testStr) - ? 'full' - : 'none' - default: - return 'none' - } - } - - /** - * Given a commandArgument, return completion items for it - */ - async getCompletionItemsForArgument( - commandArgument: ICommandArgument, - commandName?: string - ): Promise { - const { languages } = await useMonaco() - - // Test whether argument type is defined - if (!commandArgument.type) { - // If additionalData is defined, return its values - if (commandArgument.additionalData?.values) - return this.toCompletionItem( - commandArgument.additionalData?.values - ) - else if (commandArgument.additionalData?.schemaReference) - return this.toCompletionItem( - ( - this.resolveDynamicReference( - commandArgument.additionalData.schemaReference - ).map(({ value }) => value) - ) - ) - - return [] - } - - switch (commandArgument.type) { - case 'command': - return this.mergeCompletionItems( - await this.getCommandCompletionItems(), - { documentation: commandArgument.description } - ) - case 'selector': - return this.toCompletionItem( - ['@a', '@e', '@p', '@s', '@r', '@initiator'], - commandArgument.description, - languages.CompletionItemKind.TypeParameter - ) - case 'boolean': - return this.toCompletionItem( - ['true', 'false'], - commandArgument.description, - languages.CompletionItemKind.Value - ) - case 'number': - return this.toCompletionItem( - ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], - commandArgument.description, - languages.CompletionItemKind.Value - ) - case 'coordinate': - return this.toCompletionItem( - ['~', '^'], - commandArgument.description, - languages.CompletionItemKind.Operator - ) - case 'string': { - if (commandArgument.additionalData?.values) - return this.toCompletionItem( - commandArgument.additionalData.values, - commandArgument.description, - languages.CompletionItemKind.Constant - ) - else if (commandArgument.additionalData?.schemaReference) - return this.toCompletionItem( - ( - this.resolveDynamicReference( - commandArgument.additionalData.schemaReference - ).map(({ value }) => value) - ), - commandArgument.description, - languages.CompletionItemKind.Constant - ) - else return [] - } - case 'jsonData': - return this.toCompletionItem( - [['{}', '{${1:json data}}']], - commandArgument.description, - languages.CompletionItemKind.Struct - ) - case 'blockState': - return this.toCompletionItem( - [['[]', '[${1:block states}]']], - commandArgument.description, - languages.CompletionItemKind.Struct - ) - case 'scoreData': - return this.toCompletionItem( - [['{}', '{${1:scores}}']], - commandArgument.description, - languages.CompletionItemKind.Struct - ) - case 'subcommand': - return this.toCompletionItem( - commandName - ? (await this.getSubcommands(commandName)).map( - (command) => command.commandName - ) - : [], - undefined, - languages.CompletionItemKind.Constant - ) - case 'integerRange': - return this.toCompletionItem( - ['0', '1', '2', '3', '..0', '0..', '0..1'], - commandArgument.description, - languages.CompletionItemKind.Value - ) - } - - return [] - } - async toCompletionItem( - strings: (string | [string, string])[], - documentation?: string, - kind?: languages.CompletionItemKind - ): Promise { - const { languages } = await useMonaco() - if (!kind) kind = languages.CompletionItemKind.Text - - return strings.map((str) => ({ - label: Array.isArray(str) ? `${str[0]}` : `${str}`, - insertText: Array.isArray(str) ? `${str[1]}` : `${str}`, - kind: kind!, - documentation, - insertTextRules: Array.isArray(str) - ? languages.CompletionItemInsertTextRule.InsertAsSnippet - : undefined, - })) - } - protected mergeCompletionItems( - completionItems: ICompletionItem[], - partialItem: Partial - ) { - return completionItems.map((item) => ({ - ...item, - documentation: partialItem.documentation - ? `${partialItem.documentation}\n\n${item.documentation ?? ''}` - : item.documentation, - })) - } - - /** - * Dynamic references hook into our JSON schema engine to pull in dynamic data such as entity events - */ - protected resolveDynamicReference(reference: string) { - const refSchema = new RefSchema(reference, '$ref', reference) - - // Return completions items from refSchema - return refSchema.getCompletionItems({}) - } -} diff --git a/src/components/Languages/Mcfunction/ResolvedCommandArguments.ts b/src/components/Languages/Mcfunction/ResolvedCommandArguments.ts deleted file mode 100644 index 37c8853cc..000000000 --- a/src/components/Languages/Mcfunction/ResolvedCommandArguments.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ICommandArgument } from './Data' - -export class ResolvedCommandArguments { - constructor( - protected args: ICommandArgument[] = [], - public readonly lastParsedIndex: number = 0, - public readonly isValidResult: boolean = true - ) {} - - static from(other: ResolvedCommandArguments[]) { - const biggestLastParsed = - other - .filter((a) => a.isValidResult) - .sort((a, b) => b.lastParsedIndex - a.lastParsedIndex)[0] - ?.lastParsedIndex ?? 0 - - return new ResolvedCommandArguments( - other.map((x) => x.args).flat(), - biggestLastParsed - ) - } - - get arguments() { - return this.args - } -} diff --git a/src/components/Languages/Mcfunction/TargetSelector/SelectorArguments.ts b/src/components/Languages/Mcfunction/TargetSelector/SelectorArguments.ts deleted file mode 100644 index ef2783930..000000000 --- a/src/components/Languages/Mcfunction/TargetSelector/SelectorArguments.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { CommandData, ICompletionItem } from '../Data' -import { useMonaco } from '../../../../utils/libs/useMonaco' - -export class SelectorArguments { - constructor(protected commandData: CommandData) {} - - getSchema() { - return this.commandData.getSelectorArgumentsSchema() - } - - async getNextCompletionItems(tokens: string[]) { - if (tokens.length === 1 || tokens[tokens.length - 2] === ',') - return this.getArgumentNameCompletions() - - if ( - tokens[tokens.length - 2] === '=' || - tokens[tokens.length - 2] === '=!' - ) { - const argumentName = tokens[tokens.length - 3] - - return await this.getArgumentTypeCompletions(argumentName) - } - - return [] - } - - async getArgumentFromWord(word: string) { - const args = await this.getSchema() - - return args.find((arg) => arg.argumentName === word) - } - - async getArgumentNameCompletions(): Promise { - const { languages } = await useMonaco() - - const args = await this.getSchema() - - return args.map((arg) => ({ - label: arg.argumentName, - insertText: arg.argumentName, - kind: languages.CompletionItemKind.Property, - documentation: arg.description, - })) - } - - async getArgumentTypeCompletions(argumentName: string) { - const args = await this.getSchema() - - const completionItems = await Promise.all( - args - .filter((arg) => arg.argumentName === argumentName) - .map((arg) => - this.commandData.getCompletionItemsForArgument(arg) - ) - ) - - return completionItems.flat() - } -} diff --git a/src/components/Languages/Mcfunction/TargetSelector/isWithin.ts b/src/components/Languages/Mcfunction/TargetSelector/isWithin.ts deleted file mode 100644 index dbaf50843..000000000 --- a/src/components/Languages/Mcfunction/TargetSelector/isWithin.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Returns whether the user's cursor is currently within a Minecraft target selector - * - * @param line - * @param postion - * - * @example line = "say @e[name=test]", position = 10 > true - * - * @returns { selectorStart, selectorEnd } or false if none - */ -export function isWithinTargetSelector(line: string, index: number) { - const prevUnclosed = previousUnclosedBracket(line, index - 1) - - if (prevUnclosed === -1) { - return false - } - - // Test whether characters before prevUnclosed bracket start with an @ - let beforeBracket = '' - let i = prevUnclosed - 1 - while (i >= 0 && line[i] !== ' ') { - beforeBracket = line[i] + beforeBracket - i-- - } - - return beforeBracket.startsWith('@') - ? { - selectorStart: prevUnclosed + 1, - selectorEnd: nextClosingBracket(line, index), - } - : false -} - -/** - * Returns the index of the previous unclosed square bracket, -1 if none - * @param line - * @param index - */ -function previousUnclosedBracket(line: string, index: number) { - for (let i = index; i >= 0; i--) { - if (line[i] == '[') { - return i - } else if (line[i] === ']') { - return -1 - } - } - - return -1 -} - -function nextClosingBracket(line: string, index: number) { - for (let i = index; i < line.length; i++) { - if (line[i] == ']') { - return i - } - } - - return -1 -} diff --git a/src/components/Languages/Mcfunction/TokenProvider.ts b/src/components/Languages/Mcfunction/TokenProvider.ts deleted file mode 100644 index 31adf080a..000000000 --- a/src/components/Languages/Mcfunction/TokenProvider.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { colorCodes } from '../Common/ColorCodes' - -export const tokenProvider: any = { - brackets: [ - ['(', ')', 'delimiter.parenthesis'], - ['[', ']', 'delimiter.square'], - ['{', '}', 'delimiter.curly'], - ], - keywords: [], - selectors: ['@a', '@e', '@p', '@r', '@s', '@initiator'], - targetSelectorArguments: [], - - tokenizer: { - root: [ - [/#.*/, 'comment'], - - [ - /\{/, - { - token: 'delimiter.bracket', - bracket: '@open', - next: '@embeddedJson', - }, - ], - [ - /\[/, - { - token: 'delimiter.bracket', - next: '@targetSelectorArguments', - bracket: '@open', - }, - ], - { include: '@common' }, - ...colorCodes, - - [ - /[a-z_][\w\/]*/, - { - cases: { - '@keywords': 'keyword', - '@default': 'identifier', - }, - }, - ], - [ - /(@[a-z]+)/, - { - cases: { - '@selectors': 'type.identifier', - '@default': 'identifier', - }, - }, - ], - ], - - common: [ - [/(\\)?"[^"]*"|'[^']*'/, 'string'], - [/\=|\,|\!|%=|\*=|\+=|-=|\/=|<|=|>|<>/, 'definition'], - [/true|false/, 'number'], - [/-?([0-9]+(\.[0-9]+)?)|((\~|\^)-?([0-9]+(\.[0-9]+)?)?)/, 'number'], - ], - - embeddedJson: [ - [/\{/, 'delimiter.bracket', '@embeddedJson'], - [/\}/, 'delimiter.bracket', '@pop'], - { include: '@common' }, - ], - targetSelectorArguments: [ - [/\]/, { token: '@brackets', bracket: '@close', next: '@pop' }], - [ - /{/, - { - token: '@brackets', - bracket: '@open', - next: '@targetSelectorScore', - }, - ], - [ - /[a-z_][\w\/]*/, - { - cases: { - '@targetSelectorArguments': 'variable', - '@default': 'identifier', - }, - }, - ], - { include: '@common' }, - ], - targetSelectorScore: [ - [/}/, { token: '@brackets', bracket: '@close', next: '@pop' }], - { include: '@common' }, - ], - }, -} diff --git a/src/components/Languages/Mcfunction/Validator.ts b/src/components/Languages/Mcfunction/Validator.ts deleted file mode 100644 index 91c0282c7..000000000 --- a/src/components/Languages/Mcfunction/Validator.ts +++ /dev/null @@ -1,1108 +0,0 @@ -import { - tokenizeCommand, - tokenizeTargetSelector, - castType, -} from 'bridge-common-utils' -import { CommandData, ICommandArgument } from './Data' -import type { editor } from 'monaco-editor' -import { useMonaco } from '/@/utils/libs/useMonaco' -import { RefSchema } from '/@/components/JSONSchema/Schema/Ref' -import { - translateWithInsertions as twi, - translate as t, -} from '/@/components/Locales/Manager' - -export class CommandValidator { - protected commandData: CommandData - - constructor(commandData: CommandData) { - this.commandData = commandData - } - - protected async parseSubcommand( - baseCommandName: string, - leftTokens: { - startColumn: number - endColumn: number - word: string - }[] - ): Promise<{ - passed: boolean - argumentsConsumedCount?: number - warnings: editor.IMarkerData[] - diagnostics: editor.IMarkerData[] - }> { - const { MarkerSeverity } = await useMonaco() - - const subcommandName = leftTokens[0] - - let subcommandDefinitions = ( - await this.commandData.getSubcommands(baseCommandName) - ).filter((definition) => definition.commandName == subcommandName.word) - - if (subcommandDefinitions.length == 0) - return { - passed: false, - warnings: [], - diagnostics: [], - } - - let passedSubcommandDefinition - - let warnings: editor.IMarkerData[] = [] - let diagnostics: editor.IMarkerData[] = [] - - // Loop over every subcommand definition to check for a matching one - for (const definition of subcommandDefinitions) { - let definitionDiagnostics: editor.IMarkerData[] = [] - let definitionWarnings: editor.IMarkerData[] = [] - - let failed = false - - // Fail if there is not enought tokens to satisfy the definition - if (leftTokens.length - 1 <= definition.arguments.length) { - continue - } - - // Loop over every argument - for (let j = 0; j < definition.arguments.length; j++) { - const argument = leftTokens[j + 1] - const targetArgument = definition.arguments[j] - - let argumentType = await this.commandData.isArgumentType( - argument.word, - targetArgument - ) - - if ( - targetArgument.type == 'blockState' && - argumentType == 'full' && - !(await this.parseBlockState(argument.word)) - ) - argumentType = 'none' - - // Fail if type does not match - if ( - argumentType != 'full' && - (targetArgument.type != 'selector' || - !this.parsePlayerName(argument)) - ) { - failed = true - - break - } - - // Validate selector but don't completely fail if selector fail so rest of command can validate as well - if (targetArgument.type == 'selector') { - const result = await this.parseSelector(argument) - - if (result.diagnostic) - definitionDiagnostics.push(result.diagnostic) - - definitionWarnings = definitionWarnings.concat( - result.warnings - ) - } - - if (targetArgument.additionalData) { - // Fail if there are additional values that are not met - if ( - targetArgument.additionalData.values && - !targetArgument.additionalData.values.includes( - argument.word - ) - ) { - failed = true - - break - } - - // Warn if unknown schema value - if (targetArgument.additionalData.schemaReference) { - const referencePath = - targetArgument.additionalData.schemaReference - - const schemaReference = new RefSchema( - referencePath, - '$ref', - referencePath - ).getCompletionItems({}) - - if ( - !schemaReference.find( - (reference) => reference.value == argument.word - ) - ) { - definitionWarnings.push({ - severity: MarkerSeverity.Warning, - message: twi( - 'validation.mcfunction.unknownSchema.name', - [`"${argument.word}"`] - ), - startLineNumber: -1, - startColumn: argument.startColumn + 1, - endLineNumber: -1, - endColumn: argument.endColumn + 1, - }) - } - } - } - } - - // Only add definition if it is longer since it's the most likely correct one - if ( - !failed && - (!passedSubcommandDefinition || - passedSubcommandDefinition.arguments.length < - definition.arguments.length) - ) { - passedSubcommandDefinition = definition - warnings = definitionWarnings - diagnostics = definitionDiagnostics - } - } - - if (!passedSubcommandDefinition) { - return { - passed: false, - warnings: [], - diagnostics: [], - } - } else { - return { - passed: true, - argumentsConsumedCount: - passedSubcommandDefinition.arguments.length, - warnings, - diagnostics, - } - } - } - - protected async parseScoreData(token: string): Promise { - if (!token.startsWith('{')) return false - - if (!token.endsWith('}')) return false - - let pieces = token.substring(1, token.length - 1).split(',') - - // Check for weird comma syntax ex: ,, - if (pieces.find((argument) => argument == '')) return false - - for (const piece of pieces) { - const scoreName = piece.split('=')[0] - let scoreValue = piece.split('=').slice(1).join('=') - - if (!scoreValue) return false - - //Value is negated so remove negation - if (scoreValue.startsWith('!')) - scoreValue = scoreValue.substring(1, scoreValue.length) - - let argumentType = await this.commandData.isArgumentType( - scoreValue, - { - argumentName: 'scoreData', - description: 'scoreDataParser', - type: 'integerRange', - isOptional: false, - } - ) - - if (argumentType != 'full') return false - } - - return true - } - - protected async parseBlockState(token: string): Promise { - if (!token.startsWith('[')) return false - - if (!token.endsWith(']')) return false - - const pieces = token.substring(1, token.length - 1).split(',') - - // Check for weird comma syntax ex: ,, - if (pieces.find((argument) => argument == '')) return false - - for (const piece of pieces) { - const scoreName = piece.split(':')[0] - const scoreValue = piece.split(':').slice(1).join(':') - - if (!scoreValue) return false - - const isString = - (await this.commandData.isArgumentType(scoreValue, { - argumentName: 'scoreData', - description: 'scoreDataParser', - type: 'string', - isOptional: false, - })) == 'full' && - (/([a-zA-Z])/.test(scoreValue) || scoreValue == '""') && - ((scoreValue.startsWith('"') && scoreValue.endsWith('"')) || - (!scoreValue.startsWith('"') && - !scoreValue.endsWith('"'))) && - (scoreValue.split('"').length - 1 == 2 || - scoreValue.split('"').length - 1 == 0) - - const isNumber = - (await this.commandData.isArgumentType(scoreValue, { - argumentName: 'scoreData', - description: 'scoreDataParser', - type: 'number', - isOptional: false, - })) == 'full' - if (!isString && !isNumber) return false - } - - return true - } - - protected parsePlayerName(token: { - startColumn: number - endColumn: number - word: string - }) { - if (!token.word.startsWith('@')) { - if (!/[a-zA-Z_0-9]{3,16}/.test(token.word)) return false - } - - return true - } - - protected async parseSelector(selectorToken: { - startColumn: number - endColumn: number - word: string - }): Promise<{ - passed: boolean - diagnostic?: editor.IMarkerData - warnings: editor.IMarkerData[] - }> { - const { MarkerSeverity } = await useMonaco() - - let warnings: editor.IMarkerData[] = [] - - if (this.parsePlayerName(selectorToken)) { - return { - passed: true, - warnings, - } - } - - let baseSelector = selectorToken.word.substring(0, 2) - - // Check for base selector, we later check @i to be @initiator - if (!['@a', '@p', '@r', '@e', '@s', '@i'].includes(baseSelector)) - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.invalidSelectorBase.name', - [`"${baseSelector}"`] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - - if (baseSelector == '@i') { - if ( - selectorToken.word.substring(0, '@initiator'.length) != - '@initiator' - ) { - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.invalidSelectorBase.name', - [`"${baseSelector}"`] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - } - - baseSelector = '@initiator' - } - - // If the selector is merely the base we can just pass - if (baseSelector == selectorToken.word) - return { - passed: true, - warnings: [], - } - - if (selectorToken.word[baseSelector.length] != '[') - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.unexpectedSymbol.name', - [`"${selectorToken.word[baseSelector.length]}"`, '"["'] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - - if (!selectorToken.word.endsWith(']')) - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.unexpectedSymbol.name', - [`"${selectorToken.word[baseSelector.length]}"`, '"]"'] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - - let selectorArguments = selectorToken.word - .substring(baseSelector.length + 1, selectorToken.word.length - 1) - .split(',') - - // Check for weird comma syntax ex: ,, - if (selectorArguments.find((argument) => argument == '')) - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.unexpectedSymbol.name', - [ - `"${selectorToken.word[baseSelector.length]}"`, - `"${t( - 'validation.mcfunction.tokens.selectorArgument' - )}"`, - ] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - - const selectorArgumentsSchema = - await this.commandData.getSelectorArgumentsSchema() - - // Store argument names that can't be used multiple times or where not negated when they need to be - let canNotUseNames = [] - - for (const argument of selectorArguments) { - // Fail if there is for somereason no = - if (argument.split('=').length - 1 < 1) - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.unexpectedSymbol.name', - [`"${argument}"`, `"="`] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - - let argumentName = argument.split('=')[0] - let argumentValue = argument.split('=').slice(1).join('=') - - const argumentSchema = selectorArgumentsSchema.find( - (schema) => schema.argumentName == argumentName - ) - - if (!argumentSchema) - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.invalidSelectorArgument.name', - [`"${argumentName}"`] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - - const negated = argumentValue.startsWith('!') - const canNotUse = canNotUseNames.includes(argumentName) - - // Fail if negated and shouldn't be - if ( - negated && - (!argumentSchema.additionalData || - !argumentSchema.additionalData.supportsNegation) - ) - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.argumentNoSupport.name', - [ - `"${argumentName}"`, - t('validation.mcfunction.conditions.negation'), - ] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - - // Remove ! at the beginning - if (negated) - argumentValue = argumentValue.substring(1, argumentValue.length) - - // Check if this type should not be used again - if (canNotUse) { - if (!argumentSchema.additionalData) - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.argumentNoSupport.name', - [ - `"${argumentName}"`, - t( - 'validation.mcfunction.conditions.multipleInstances' - ), - ] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - - if ( - argumentSchema.additionalData.multipleInstancesAllowed == - 'whenNegated' - ) { - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.argumentNoSupport.bothConditions', - [`"${argumentName}"`] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - } - - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.argumentNoSupport.name', - [ - `"${argumentName}"`, - t( - 'validation.mcfunction.conditions.multipleInstances' - ), - ] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - } - - let argumentType = await this.commandData.isArgumentType( - argumentValue, - argumentSchema - ) - - // We need to parse scoreData on its own because the normal argument type checker seems to not work on it - if ( - argumentSchema.type == 'scoreData' && - (await this.parseScoreData(argumentValue)) - ) - argumentType = 'full' - - if (argumentType != 'full') { - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.invalidSelectorArgumentValue.name', - [`"${argumentValue}"`, `"${argumentName}"`] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - } - - if (argumentSchema.additionalData) { - // Fail if there are additional values that are not met - if ( - argumentSchema.additionalData.values && - !argumentSchema.additionalData.values.includes( - argumentValue - ) - ) { - return { - passed: false, - diagnostic: { - severity: MarkerSeverity.Error, - message: twi( - 'validation.mcfunction.invalidSelectorArgumentValue.name', - [`"${argumentValue}"`, `"${argumentName}"`] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }, - warnings: [], - } - } - - // Warn if unknown schema value - if (argumentSchema.additionalData.schemaReference) { - const referencePath = - argumentSchema.additionalData.schemaReference - - const schemaReference = new RefSchema( - referencePath, - '$ref', - referencePath - ).getCompletionItems({}) - - if ( - !schemaReference.find( - (reference) => reference.value == argumentValue - ) - ) { - warnings.push({ - severity: MarkerSeverity.Warning, - message: twi( - 'validation.mcfunction.unknownSchemaInArgument.name', - [`"${argumentValue}"`, `"${argumentName}"`] - ), - startLineNumber: -1, - startColumn: selectorToken.startColumn + 1, - endLineNumber: -1, - endColumn: selectorToken.endColumn + 1, - }) - } - } - } - - if ( - !argumentSchema.additionalData || - !argumentSchema.additionalData.multipleInstancesAllowed || - argumentSchema.additionalData.multipleInstancesAllowed == - 'never' || - (argumentSchema.additionalData.multipleInstancesAllowed == - 'whenNegated' && - !negated) - ) { - canNotUseNames.push(argumentName) - } - } - - return { - passed: true, - warnings, - } - } - - protected async parseCommand( - line: string | undefined, - tokens: any[], - offset: number - ): Promise { - const { MarkerSeverity } = await useMonaco() - - let diagnostics: editor.IMarkerData[] = [] - let warnings: editor.IMarkerData[] = [] - - if (line) tokens = tokenizeCommand(line).tokens - - // Reconstruct JSON because tokenizer doesn't handle this well - for (let i = 0; i < tokens.length; i++) { - if (tokens[i - 1]) { - // if we get a case where tokens are like "property", :"value" then we combine them - // or if we get a case where tokens are like ["state":"a","state":"b" then we combine them - // or if we get a case where tokens are like @e[name="Test"] then we combine them - if ( - (tokens[i].word.startsWith(':') || - tokens[i].word.startsWith(',') || - tokens[i].word.startsWith(']')) && - tokens[i - 1].word.endsWith('"') - ) { - tokens.splice(i - 1, 2, { - startColumn: tokens[i - 1].startColumn, - endColumn: tokens[i].endColumn, - word: tokens[i - 1].word + tokens[i].word, - }) - - i-- - - continue - } - - // add the beginning and ending of a json data or scoreData together - if ( - (tokens[i].word.startsWith('}') && - tokens[i - 1].word.startsWith('{')) || - (tokens[i].word.startsWith(']') && - tokens[i - 1].word.startsWith('[')) - ) { - tokens.splice(i - 1, 2, { - startColumn: tokens[i - 1].startColumn, - endColumn: tokens[i].endColumn, - word: tokens[i - 1].word + tokens[i].word, - }) - - i-- - - continue - } - } - } - - const commandName = tokens[0] - - // If first word is empty then this is an empty line - if (!commandName || commandName.word == '') return diagnostics - - if ( - !(await this.commandData.allCommands()).includes(commandName.word) - ) { - diagnostics.push({ - severity: MarkerSeverity.Error, - message: twi('validation.mcfunction.unknownCommand.name', [ - `"${commandName.word}"`, - ]), - startLineNumber: -1, - startColumn: commandName.startColumn + 1, - endLineNumber: -1, - endColumn: commandName.endColumn + 1, - }) - - // The command is not valid; it makes no sense to continue validating this line - return diagnostics - } - - // Remove empty tokens as to not confuse the argument checker - tokens = tokens.filter((token) => token.word != '') - - if (tokens.length < 2) { - diagnostics.push({ - severity: MarkerSeverity.Error, - message: twi('validation.mcfunction.missingArguments.name', [ - `"${commandName.word}"`, - ]), - startLineNumber: -1, - startColumn: commandName.startColumn + 1, - endLineNumber: -1, - endColumn: commandName.endColumn + 1, - }) - - // The command is not valid; it makes no sense to continue validating this line - return diagnostics - } - - let definitions = await this.commandData.getCommandDefinitions( - commandName.word, - false - ) - - // We only need to record the error of the most farthest in token because that is the most likely variation the user was attempting to type - let lastTokenError = 0 - let lastTokenErrorReason = '' - - let longestPassLength = -1 - - // Loop over every definition and test for validness - for (let j = 0; j < definitions.length; j++) { - let requiredArgurmentsCount = 0 - - for ( - requiredArgurmentsCount = 0; - requiredArgurmentsCount < definitions[j].arguments.length; - requiredArgurmentsCount++ - ) { - if ( - definitions[j].arguments[requiredArgurmentsCount].isOptional - ) - break - } - - let failed = false - let failedLongest = false - - let definitionWarnings: editor.IMarkerData[] = [] - let definitionDiagnostics: editor.IMarkerData[] = [] - - // Loop over every token that is not the command name - let targetArgumentIndex = 0 - for (let k = 1; k < tokens.length; k++) { - // Fail if there are not enough arguments in definition - if (definitions[j].arguments.length <= targetArgumentIndex) { - definitions.splice(j, 1) - - j-- - - if (lastTokenError < k) { - failedLongest = true - - lastTokenError = k - - lastTokenErrorReason = twi( - 'validation.mcfunction.invalidArgument.name', - [`"${tokens[k].word}"`] - ) - } - - failed = true - - break - } - - const argument = tokens[k] - - const targetArgument = - definitions[j].arguments[targetArgumentIndex] - - if (targetArgument.type == 'subcommand') { - const result = await this.parseSubcommand( - commandName.word, - tokens.slice(k, tokens.length) - ) - - definitionDiagnostics = definitionDiagnostics.concat( - result.diagnostics - ) - - if (result.passed) { - definitionWarnings = definitionWarnings.concat( - result.warnings - ) - - // Skip over tokens consumed in the subcommand validation - k += result.argumentsConsumedCount! - - // If there allows multiple subcommands keep going untill a subcommand fails - if (targetArgument.allowMultiple) { - let nextResult: { - passed: boolean - argumentsConsumedCount?: number - warnings: editor.IMarkerData[] - diagnostics: editor.IMarkerData[] - } = { - passed: true, - argumentsConsumedCount: 0, - warnings: [], - diagnostics: [], - } - - while (nextResult.passed) { - nextResult = await this.parseSubcommand( - commandName.word, - tokens.slice(k + 1, tokens.length) - ) - - definitionDiagnostics = - definitionDiagnostics.concat( - nextResult.diagnostics - ) - - if (nextResult.passed) { - definitionWarnings = - definitionWarnings.concat( - nextResult.warnings - ) - - k += nextResult.argumentsConsumedCount! + 1 - } - } - } - - targetArgumentIndex++ - - continue - } else { - // Fail because subcommand doesn't match any definitions - definitions.splice(j, 1) - - j-- - - if (lastTokenError < k) { - failedLongest = true - - lastTokenError = k - - lastTokenErrorReason = twi( - 'validation.mcfunction.invalidArgument.name', - [`"${tokens[k].word}"`] - ) - } - - failed = true - - break - } - } - - // If we need to validate a command we just validate all the other tokens and returns because we won't - // need to check any more tokens as they will be consumed within the new command - if (targetArgument.type == 'command') { - const leftTokens = tokens.slice(k, tokens.length) - - const result = await this.parseCommand( - undefined, - leftTokens, - offset + targetArgumentIndex - ) - - definitionDiagnostics = definitionDiagnostics.concat(result) - - targetArgumentIndex++ - - break - } - - let argumentType = await this.commandData.isArgumentType( - argument.word, - targetArgument, - commandName.word - ) - - if ( - targetArgument.type == 'blockState' && - argumentType == 'full' && - !(await this.parseBlockState(argument.word)) - ) - argumentType = 'none' - - // Fail if type does not match - if ( - argumentType != 'full' && - (targetArgument.type != 'selector' || - !this.parsePlayerName(argument)) - ) { - definitions.splice(j, 1) - - j-- - - if (lastTokenError < k) { - failedLongest = true - - lastTokenError = k - - lastTokenErrorReason = twi( - 'validation.mcfunction.invalidArgument.name', - [`"${tokens[k].word}"`] - ) - } - - failed = true - - break - } - - // Validate selector but don't completely fail if selector fail so rest of command can validate as well - if (targetArgument.type == 'selector') { - const result = await this.parseSelector(argument) - - if (result.diagnostic) - definitionDiagnostics.push(result.diagnostic) - - definitionWarnings = definitionWarnings.concat( - result.warnings - ) - } - - if (targetArgument.additionalData) { - // Fail if there are additional values that are not met - if ( - targetArgument.additionalData.values && - !targetArgument.additionalData.values.includes( - argument.word - ) - ) { - definitions.splice(j, 1) - - j-- - - if (lastTokenError < k) { - failedLongest = true - - lastTokenError = k - - lastTokenErrorReason = twi( - 'validation.mcfunction.invalidArgument.name', - [`"${tokens[k].word}"`] - ) - } - - failed = true - - break - } - - // Warn if unknown schema value - if (targetArgument.additionalData.schemaReference) { - const referencePath = - targetArgument.additionalData.schemaReference - - const schemaReference = new RefSchema( - referencePath, - '$ref', - referencePath - ).getCompletionItems({}) - - if ( - !schemaReference.find( - (reference) => reference.value == argument.word - ) - ) { - definitionWarnings.push({ - severity: MarkerSeverity.Warning, - message: twi( - 'validation.mcfunction.unknownSchema.name', - [`"${tokens[k].word}"`] - ), - startLineNumber: -1, - startColumn: argument.startColumn + 1, - endLineNumber: -1, - endColumn: argument.endColumn + 1, - }) - } - } - } - - // Skip back if allow multiple - if (targetArgument.allowMultiple) targetArgumentIndex-- - - targetArgumentIndex++ - } - - // Skip if already failed in case this leaves an undefined reference - if (failed) { - if (failedLongest) diagnostics = definitionDiagnostics - - continue - } - - // Fail if there are not enough tokens to satisfy definition - if ( - targetArgumentIndex < requiredArgurmentsCount && - !definitions[j].arguments[targetArgumentIndex].allowMultiple && - targetArgumentIndex < requiredArgurmentsCount - 1 - ) { - definitions.splice(j, 1) - - j-- - - if (lastTokenError < tokens.length - 1) { - lastTokenError = tokens.length - 1 - - lastTokenErrorReason = twi( - 'validation.mcfunction.missingArguments.name', - [`"${commandName.word}"`] - ) - } - - // Continue to not add warnings to the diagnostics - continue - } - - if (targetArgumentIndex < longestPassLength) break - - longestPassLength = targetArgumentIndex - - diagnostics = definitionDiagnostics - warnings = definitionWarnings - } - - if (definitions.length == 0) { - diagnostics.push({ - severity: MarkerSeverity.Error, - message: lastTokenErrorReason, - startLineNumber: -1, - startColumn: tokens[lastTokenError].startColumn + 1, - endLineNumber: -1, - endColumn: tokens[lastTokenError].endColumn + 1, - }) - - // Return here since we don't want warnings added - return diagnostics - } - - return diagnostics.concat(warnings) - } - - async parse(content: string) { - // Split content into lines - const lines = content.split('\n') - const diagnostics: editor.IMarkerData[] = [] - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - if (line[0] == '#') continue - - const results = await this.parseCommand(line, [], 0) - - for (const diagnostic of results) { - diagnostic.startLineNumber = i + 1 - diagnostic.endLineNumber = i + 1 - - diagnostics.push(diagnostic) - } - } - - return diagnostics - } -} diff --git a/src/components/Languages/Mcfunction/WithinJson.ts b/src/components/Languages/Mcfunction/WithinJson.ts deleted file mode 100644 index c9b9a0e88..000000000 --- a/src/components/Languages/Mcfunction/WithinJson.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { App } from '/@/App' -import { getLocation } from '/@/utils/monaco/getLocation' -import type { editor, Position, Range } from 'monaco-editor' -import { getJsonWordAtPosition } from '/@/utils/monaco/getJsonWord' -import { tokenizeCommand } from 'bridge-common-utils' -import { BedrockProject } from '/@/components/Projects/Project/BedrockProject' -import { isWithinQuotes } from '/@/utils/monaco/withinQuotes' -import { isMatch } from 'bridge-common-utils' -import { useMonaco } from '../../../utils/libs/useMonaco' - -export async function registerEmbeddedMcfunctionProvider() { - const { languages, Range } = await useMonaco() - - languages.registerCompletionItemProvider('json', { - provideCompletionItems: async ( - model: editor.ITextModel, - position: Position - ) => { - const app = await App.getApp() - const project = app.project - if (!(project instanceof BedrockProject)) return - - const commandData = project.commandData - const location = await getLocation(model, position) - const currentTab = app.project.tabSystem?.selectedTab - if (!currentTab) return - - const validCommands: Record< - string, - string[] - > = await app.dataLoader.readJSON( - `data/packages/minecraftBedrock/location/validCommand.json` - ) - const { - id, - meta: { commandsUseSlash } = { commandsUseSlash: false }, - } = App.fileType.get(currentTab.getPath()) ?? { - id: 'unknown', - meta: { commandsUseSlash: false }, - } - const locationPatterns = validCommands[id] ?? [] - - if (locationPatterns.length === 0) return - - const isCommand = isMatch(location, locationPatterns) - if (!isCommand || !isWithinQuotes(model, position)) return - - let { word, range } = await getJsonWordAtPosition(model, position) - - // e.g. animations/animation controller commands need to start with a slash char - if (commandsUseSlash && !word.startsWith('/')) return - - let replacedSlash = false - if (word.startsWith('/')) { - word = word.slice(1) - replacedSlash = true - } - - const { tokens } = tokenizeCommand(word) - - const lastToken = tokens[tokens.length - 1] - - const completionItems = await commandData.getNextCompletionItems( - tokens.map((token) => token.word) - ) - - return { - suggestions: completionItems.map( - ({ label, insertText, documentation, kind }) => ({ - label: label ?? insertText, - insertText, - documentation, - kind, - range: new Range( - position.lineNumber, - Math.min( - range.endColumn + 1, - range.startColumn + - lastToken.startColumn + - (replacedSlash ? 2 : 1) - ), - position.lineNumber, - Math.min( - range.endColumn + 1, - position.column + insertText.length - ) - ), - }) - ), - } - }, - }) -} diff --git a/src/components/Languages/Mcfunction/inSelector.ts b/src/components/Languages/Mcfunction/inSelector.ts deleted file mode 100644 index 75ea36966..000000000 --- a/src/components/Languages/Mcfunction/inSelector.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function inSelector(command: string) { - return command.indexOf('[') > command.indexOf(']') -} diff --git a/src/components/Languages/Mcfunction/strMatch.ts b/src/components/Languages/Mcfunction/strMatch.ts deleted file mode 100644 index dd9fa4452..000000000 --- a/src/components/Languages/Mcfunction/strMatch.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * A function that returns whether string a matches string b fully or partially or not at all. - */ -export function strMatch(a: string, b: string) { - if (a === b) return 'full' - else if (a.includes(b)) return 'partial' - else return 'none' -} - -/** - * A function that returns whether string a matches one string from array b fully, partially or not at all. - */ -export function strMatchArray(a: string, b: string[]) { - if (b.includes(a)) return 'full' - else if (b.some((x) => x.includes(a))) return 'partial' - else return 'none' -} diff --git a/src/components/Languages/MoLang.ts b/src/components/Languages/MoLang.ts deleted file mode 100644 index 5de42480d..000000000 --- a/src/components/Languages/MoLang.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { editor, languages } from 'monaco-editor' -import { Language } from './Language' -import { CustomMoLang } from 'molang' -import { useMonaco } from '../../utils/libs/useMonaco' - -export const config: languages.LanguageConfiguration = { - comments: { - lineComment: '#', - }, - brackets: [ - ['(', ')'], - ['[', ']'], - ['{', '}'], - ], - autoClosingPairs: [ - { - open: '(', - close: ')', - }, - { - open: '[', - close: ']', - }, - { - open: '{', - close: '}', - }, - { - open: "'", - close: "'", - }, - ], -} - -export const tokenProvider = { - ignoreCase: true, - brackets: [ - ['(', ')', 'delimiter.parenthesis'], - ['[', ']', 'delimiter.square'], - ['{', '}', 'delimiter.curly'], - ], - keywords: [ - 'return', - 'loop', - 'for_each', - 'break', - 'continue', - 'this', - 'function', - ], - identifiers: [ - 'v', - 't', - 'c', - 'q', - 'f', - 'a', - 'arg', - 'variable', - 'temp', - 'context', - 'query', - ], - tokenizer: { - root: [ - [/#.*/, 'comment'], - [/'[^']'/, 'string'], - [/[0-9]+(\.[0-9]+)?/, 'number'], - [/true|false/, 'number'], - [/\=|\,|\!|%=|\*=|\+=|-=|\/=|<|=|>|<>/, 'definition'], - [ - /[a-z_$][\w$]*/, - { - cases: { - '@keywords': 'keyword', - '@identifiers': 'type.identifier', - '@default': 'identifier', - }, - }, - ], - ], - }, -} - -export class MoLangLanguage extends Language { - protected molang = new CustomMoLang({}) - constructor() { - super({ - id: 'molang', - extensions: ['molang'], - config, - tokenProvider, - }) - } - - async validate(model: editor.IModel) { - const { editor, MarkerSeverity } = await useMonaco() - - try { - this.molang.parse(model.getValue()) - editor.setModelMarkers(model, this.id, []) - } catch (err: any) { - // const token = this.molang.getParser().getLastConsumed() - // console.log( - // token?.getType(), - // token?.getText(), - // token?.getPosition() - // ) - - let { - startColumn = 0, - endColumn = Infinity, - startLineNumber = 0, - endLineNumber = Infinity, - } = /*token?.getPosition() ??*/ {} - - editor.setModelMarkers(model, this.id, [ - { - startColumn: startColumn + 1, - endColumn: endColumn + 1, - startLineNumber: startLineNumber + 1, - endLineNumber: endLineNumber + 1, - message: err.message, - severity: MarkerSeverity.Error, - }, - ]) - } - } -} diff --git a/src/components/Locales/Manager.ts b/src/components/Locales/Manager.ts deleted file mode 100644 index b770e3c17..000000000 --- a/src/components/Locales/Manager.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { deepMerge } from 'bridge-common-utils' -import { get } from 'idb-keyval' -import enLang from '/@/locales/en.json' -import allLanguages from '/@/locales/languages.json' - -const languages = Object.fromEntries( - Object.entries( - import.meta.glob([ - '../../locales/*.json', - '!../../locales/en.json', - '!../../locales/languages.json', - ]) - ).map(([key, val]) => [key.split('/').pop(), val]) -) - -export class LocaleManager { - protected static currentLanguage: any = enLang - protected static currentLanuageId = 'english' - - static getAvailableLanguages() { - return allLanguages - .sort((a, b) => a.name.localeCompare(b.name)) - .map((l) => ({ - text: l.name, - value: l.id, - })) - } - static getCurrentLanguageId() { - return this.currentLanuageId - } - - static async setDefaultLanguage() { - const language = await get('language') - - // Set language based on bridge. setting - if (language) { - await this.applyLanguage(language) - } else { - // Set language based on browser language - for (const langCode of navigator.languages) { - const lang = allLanguages.find(({ codes }) => - codes.includes(langCode) - ) - if (!lang) continue - - await this.applyLanguage(lang.id) - break - } - } - } - - static async applyLanguage(id: string) { - if (id === this.currentLanuageId) return - - if (id === 'english') { - this.currentLanguage = clone(enLang) - this.currentLanuageId = id - return - } - - const fetchName = allLanguages.find((l) => l.id === id)?.file - if (!fetchName) - throw new Error(`[Locales] Language with id "${id}" not found`) - - const language = (await languages[fetchName]()).default - if (!language) - throw new Error( - `[Locales] Language with id "${id}" not found: File "${fetchName}" does not exist` - ) - - this.currentLanguage = deepMerge(clone(enLang), clone({ ...language })) - - this.currentLanuageId = id - } - - static translate(key?: string, lang = this.currentLanguage) { - if (!key) return '' - - if (key.startsWith('[') && key.endsWith(']')) { - return key.slice(1, -1) - } - - const parts = key.split('.') - - let current = lang - for (const part of parts) { - current = current[part] - - if (!current) { - console.warn(`[Locales] Translation key "${key}" not found`) - return key - } - } - - if (typeof current !== 'string') { - console.warn(`[Locales] Translation key "${key}" not found`) - return key - } - - return current - } -} - -export function translate(key?: string, langId?: 'en') { - let lang = undefined - if (langId === 'en') lang = enLang - - return LocaleManager.translate(key, lang) -} - -export function translateWithInsertions(key?: string, insert?: string[]) { - let translation = LocaleManager.translate(key) - if (!insert) return translation - - for (let i = 0; i <= insert.length; i++) { - translation = translation?.replace(`{{$${i + 1}}}`, insert[i]) - } - - return translation -} - -function clone(obj: any) { - if (typeof window.structuredClone === 'function') - return window.structuredClone(obj) - - return JSON.parse(JSON.stringify(obj)) -} diff --git a/src/components/Mixins/AppToolbarHeight.ts b/src/components/Mixins/AppToolbarHeight.ts deleted file mode 100644 index 308cb59ed..000000000 --- a/src/components/Mixins/AppToolbarHeight.ts +++ /dev/null @@ -1,32 +0,0 @@ -// @ts-nocheck - -import { WindowControlsOverlayMixin } from './WindowControlsOverlay' -import { isInFullScreen } from '/@/components/TabSystem/TabContextMenu/Fullscreen' -import { platform } from '/@/utils/os' - -export const AppToolbarHeightMixin = { - mixins: [WindowControlsOverlayMixin], - data: () => ({ - toolbarPaddingLeft: - import.meta.env.VITE_IS_TAURI_APP && platform() === 'darwin' - ? `70px` - : `0px`, - }), - computed: { - appToolbarHeight() { - if (isInFullScreen.value) return `0px` - if (import.meta.env.VITE_IS_TAURI_APP) return `28px` - - return `env(titlebar-area-height, ${ - this.$vuetify.breakpoint.mobile ? 0 : 24 - }px)` - }, - appToolbarHeightNumber() { - if (isInFullScreen.value) return 0 - if (import.meta.env.VITE_IS_TAURI_APP) return 28 - if (this.windowControlsOverlay) return 33 - - return this.$vuetify.breakpoint.mobile ? 0 : 24 - }, - }, -} diff --git a/src/components/Mixins/DevMode.ts b/src/components/Mixins/DevMode.ts deleted file mode 100644 index 1fdeeb4fa..000000000 --- a/src/components/Mixins/DevMode.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-nocheck -import { settingsState } from '/@/components/Windows/Settings/SettingsState' - -export const DevModeMixin = { - computed: { - isDevMode() { - return settingsState?.developers?.isDevMode ?? false - }, - }, -} diff --git a/src/components/Mixins/EnablePackSpider.ts b/src/components/Mixins/EnablePackSpider.ts deleted file mode 100644 index 37db1c566..000000000 --- a/src/components/Mixins/EnablePackSpider.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { settingsState } from '../Windows/Settings/SettingsState' - -export const EnablePackSpiderMixin = { - data: () => ({ - settingsState, - }), - computed: { - enablePackSpider() { - return settingsState?.general?.enablePackSpider ?? false - }, - }, -} diff --git a/src/components/Mixins/Highlighter.ts b/src/components/Mixins/Highlighter.ts deleted file mode 100644 index 579493d17..000000000 --- a/src/components/Mixins/Highlighter.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @ts-nocheck -import { App } from '/@/App' - -const toPropertyName = (name: string) => `${name}Def` -export const HighlighterMixin = (defNames: string[]) => ({ - data: () => - Object.fromEntries(defNames.map((name) => [toPropertyName(name), {}])), - mounted() { - App.instance.themeManager.on(this.onThemeChanged) - this.onThemeChanged() - }, - destroyed() { - App.instance.themeManager.off(this.onThemeChanged) - }, - methods: { - onThemeChanged() { - defNames.forEach((name) => { - this[ - toPropertyName(name) - ] = App.instance.themeManager.getHighlighterInfo(name) - }) - }, - }, -}) diff --git a/src/components/Mixins/TranslationMixin.ts b/src/components/Mixins/TranslationMixin.ts deleted file mode 100644 index c9704e352..000000000 --- a/src/components/Mixins/TranslationMixin.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { translate } from '../Locales/Manager' - -export const TranslationMixin = { - methods: { - t(translationKey?: string) { - return translate(translationKey) - }, - }, -} diff --git a/src/components/Mixins/WindowControlsOverlay.ts b/src/components/Mixins/WindowControlsOverlay.ts deleted file mode 100644 index 3e4b6fd88..000000000 --- a/src/components/Mixins/WindowControlsOverlay.ts +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-nocheck - -export const WindowControlsOverlayMixin = { - data: () => ({ - windowControlsOverlay: - navigator.windowControlsOverlay && - navigator.windowControlsOverlay.visible, - }), - created() { - if (import.meta.env.VITE_IS_TAURI_APP) { - this.windowControlsOverlay = true - } else if (navigator.windowControlsOverlay) { - navigator.windowControlsOverlay.addEventListener( - 'geometrychange', - (event) => { - this.windowControlsOverlay = event.visible - } - ) - } - }, -} diff --git a/src/components/Notifications/Errors.ts b/src/components/Notifications/Errors.ts deleted file mode 100644 index fd81f34b4..000000000 --- a/src/components/Notifications/Errors.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { InformationWindow } from '../Windows/Common/Information/InformationWindow' -import { openErrorWindow } from '../Windows/Error/ErrorWindow' -import { App } from '/@/App' -import { createNotification } from '/@/components/Notifications/create' -import { IDisposable } from '/@/types/disposable' - -function getStackTrace(error: Error) { - let stack = error.stack ?? 'at unknown' - - let stackArr = stack.split('\n').map((line) => line.trim()) - return stackArr.splice(stackArr[0].startsWith('Error') ? 1 : 0) -} - -/** - * Creates a new error notification - * @param config - */ -export function createErrorNotification(error: Error): IDisposable { - const message = error.message ?? '' - let short = message - if (message.includes(': ')) short = message.split(': ').shift() as string - if (short.length > 24) - short = message.length > 24 ? `${message.substr(0, 23)}...` : message - - const notification = createNotification({ - id: message, - icon: 'mdi-alert-circle-outline', - message: `[${short}]`, - color: 'error', - textColor: 'white', - disposeOnMiddleClick: true, - onClick: () => { - openErrorWindow({ error }) - notification.dispose() - }, - }) - - return notification -} - -window.addEventListener('error', (event) => { - createErrorNotification(event.error ?? event) - - App?.ready?.once((app) => app.windows.loadingWindow.closeAll()) -}) - -window.onunhandledrejection = (event: PromiseRejectionEvent) => { - createErrorNotification(new Error(event.reason)) - - App?.ready?.once((app) => app.windows.loadingWindow.closeAll()) -} diff --git a/src/components/Notifications/Notification.ts b/src/components/Notifications/Notification.ts deleted file mode 100644 index 649ba8e67..000000000 --- a/src/components/Notifications/Notification.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { NotificationStore } from './state' -import { v4 as uuid } from 'uuid' -import { del, set } from 'vue' - -export interface INotificationConfig { - id?: string - icon?: string - message?: string - color?: string - textColor?: string - disposeOnMiddleClick?: boolean - isVisible?: boolean - - onClick?: () => void - onMiddleClick?: () => void -} - -export class Notification { - protected id: string - protected _isVisible: boolean = false - - constructor(protected config: INotificationConfig) { - this.id = config.id ?? uuid() - - set(NotificationStore, this.id, this) - - if (this.config.isVisible ?? true) this.show() - } - - //#region Config getters - get icon() { - return this.config.icon - } - get message() { - return this.config.message - } - get color() { - return this.config.color - } - get textColor() { - return this.config.textColor - } - get isVisible() { - return this._isVisible - } - //#endregion - - onClick() { - if (typeof this.config.onClick === 'function') this.config.onClick() - } - onMiddleClick() { - this.config.onMiddleClick?.() - if (this.config.disposeOnMiddleClick) del(NotificationStore, this.id) - } - - addClickHandler(cb: () => void) { - this.config.onClick = cb - } - - show() { - if (!this._isVisible) this.updateAppBadge() - this._isVisible = true - } - - dispose() { - del(NotificationStore, this.id) - - this.updateAppBadge() - } - - protected updateAppBadge() { - // @ts-expect-error - if (typeof navigator.setAppBadge === 'function') - // @ts-expect-error - navigator.setAppBadge( - Object.values(NotificationStore).filter( - ({ isVisible }) => isVisible - ).length - ) - } -} diff --git a/src/components/Notifications/Notification.vue b/src/components/Notifications/Notification.vue new file mode 100644 index 000000000..cf9cd600a --- /dev/null +++ b/src/components/Notifications/Notification.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/components/Notifications/NotificationSystem.ts b/src/components/Notifications/NotificationSystem.ts new file mode 100644 index 000000000..9277093f9 --- /dev/null +++ b/src/components/Notifications/NotificationSystem.ts @@ -0,0 +1,99 @@ +import { Ref, ref } from 'vue' +import { v4 as uuid } from 'uuid' + +export interface Notification { + icon?: string + color?: string + callback?: () => void + id: string + type: 'button' | 'progress' + progress?: number + maxProgress?: number +} + +export class NotificationSystem { + public static notifications: Ref = ref([]) + + public static setup() { + this.notifications.value = [] + } + + public static addNotification(icon: string, callback?: () => void, color?: string): Notification { + const notification: Notification = { + icon, + callback, + color, + id: uuid(), + type: 'button', + } + + NotificationSystem.notifications.value.push(notification) + NotificationSystem.notifications.value = [...NotificationSystem.notifications.value] + + return notification + } + + public static addProgressNotification( + icon: string, + progress: number, + maxProgress: number, + callback?: () => void, + color?: string + ): Notification { + const notification: Notification = { + icon, + callback, + color, + id: uuid(), + type: 'progress', + progress, + maxProgress, + } + + NotificationSystem.notifications.value.push(notification) + NotificationSystem.notifications.value = [...NotificationSystem.notifications.value] + + return notification + } + + public static activateNotification(notification: Notification) { + if (notification.type === 'button') { + NotificationSystem.notifications.value.splice(NotificationSystem.notifications.value.indexOf(notification), 1) + NotificationSystem.notifications.value = [...NotificationSystem.notifications.value] + } + + if (notification.callback) notification.callback() + } + + public static clearNotification(notification: Notification) { + if (notification.type === 'progress') { + // Allow time for the progress bar to reach full value + setTimeout(() => { + this.notifications.value.splice( + this.notifications.value.findIndex((otherNotification) => otherNotification.id === notification.id), + 1 + ) + this.notifications.value = [...this.notifications.value] + }, 300) + + return + } + + this.notifications.value.splice( + this.notifications.value.findIndex((otherNotification) => otherNotification.id === notification.id), + 1 + ) + this.notifications.value = [...this.notifications.value] + } + + public static clearNotifications() { + for (const notification of [...this.notifications.value]) { + this.clearNotification(notification) + } + } + + public static setProgress(notification: Notification, progress: number) { + notification.progress = progress + NotificationSystem.notifications.value = [...NotificationSystem.notifications.value] + } +} diff --git a/src/components/Notifications/PersistentNotification.ts b/src/components/Notifications/PersistentNotification.ts deleted file mode 100644 index f89f9d27b..000000000 --- a/src/components/Notifications/PersistentNotification.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createStore, get, set } from 'idb-keyval' -import { INotificationConfig, Notification } from './Notification' - -const store = createStore('app-notifications', 'app-notification-store') - -export class PersistentNotification extends Notification { - constructor(config: INotificationConfig) { - super(config) - if (!config.id) throw new Error(`PersistentNotification requires an id`) - } - - async show() { - if (await get(this.id, store)) return - - super.show() - } - dispose() { - super.dispose() - - set(this.id, true, store) - } -} diff --git a/src/components/Notifications/create.ts b/src/components/Notifications/create.ts deleted file mode 100644 index 4d2077d59..000000000 --- a/src/components/Notifications/create.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NotificationStore } from './state' -import { v4 as uuid } from 'uuid' -import Vue from 'vue' -import { IDisposable } from '/@/types/disposable' -import { INotificationConfig, Notification } from './Notification' - -/** - * Creates a new notification - * @param config - */ -export function createNotification(config: INotificationConfig) { - return new Notification(config) -} - -export function clearAllNotifications() { - // @ts-expect-error - if (typeof navigator.clearAppBadge === 'function') navigator.clearAppBadge() - - for (const [key] of Object.entries(NotificationStore)) { - Vue.delete(NotificationStore, key) - } -} diff --git a/src/components/Notifications/state.ts b/src/components/Notifications/state.ts deleted file mode 100644 index f0c9926d8..000000000 --- a/src/components/Notifications/state.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Reactive vue store for the Notification API - */ - -import { reactive } from 'vue' -import { Notification } from './Notification' - -export const NotificationStore: Record = reactive({}) diff --git a/src/components/Notifications/warn.ts b/src/components/Notifications/warn.ts deleted file mode 100644 index abe8bb5b6..000000000 --- a/src/components/Notifications/warn.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { InformationWindow } from '../Windows/Common/Information/InformationWindow' -import { createNotification } from './create' - -export function emitWarning(title: string, message: string) { - const notification = createNotification({ - color: 'warning', - icon: 'mdi-alert', - message: title, - onClick: () => { - notification.dispose() - - new InformationWindow({ - description: message, - title: title, - }) - }, - }) -} diff --git a/src/components/OutputFolders/ComMojang/ComMojang.ts b/src/components/OutputFolders/ComMojang/ComMojang.ts deleted file mode 100644 index c2391a3ca..000000000 --- a/src/components/OutputFolders/ComMojang/ComMojang.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { del, get, set } from 'idb-keyval' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { App } from '/@/App' -import { Signal } from '/@/components/Common/Event/Signal' -import { InformationWindow } from '/@/components/Windows/Common/Information/InformationWindow' -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { VirtualDirectoryHandle } from '../../FileSystem/Virtual/DirectoryHandle' -import { TauriFsStore } from '../../FileSystem/Virtual/Stores/TauriFs' -import { basename, join } from '/@/utils/path' -import { platform } from '/@/utils/os' - -export const comMojangKey = 'comMojangDirectory' - -export class ComMojang extends Signal { - public readonly fileSystem = new FileSystem() - /** - * Stores whether com.mojang syncing is setup by the user - */ - public readonly setup = new Signal() - protected _hasComMojang = false - protected _permissionDenied = false - protected _hasComMojangHandle = false - - get hasComMojang() { - return this._hasComMojang - } - get hasComMojangHandle() { - return this._hasComMojangHandle - } - get status() { - return { - hasComMojang: this._hasComMojang, - didDenyPermission: this._permissionDenied, - } - } - - constructor(protected app: App) { - super() - } - async setupComMojang() { - if (this._hasComMojang) return false - - let directoryHandle = await get< - AnyDirectoryHandle | string | undefined - >(comMojangKey) - - // Auto-infer com.mojang folder for Tauri builds on windows - if ( - import.meta.env.VITE_IS_TAURI_APP && - directoryHandle === undefined && - platform() === 'win32' - ) { - const { join, localDataDir } = await import('@tauri-apps/api/path') - - directoryHandle = await join( - await localDataDir(), - 'Packages\\Microsoft.MinecraftUWP_8wekyb3d8bbwe\\LocalState\\games\\com.mojang' - ) - } - - if (typeof directoryHandle === 'string') { - if (!import.meta.env.VITE_IS_TAURI_APP) - throw new Error( - 'Cannot use path reference to com.mojang directoryHandle outside of Tauri builds' - ) - - const dirName = basename(directoryHandle) - directoryHandle = new VirtualDirectoryHandle( - new TauriFsStore(directoryHandle), - dirName - ) - } - - this._hasComMojangHandle = directoryHandle !== undefined - - if (directoryHandle) { - await this.requestPermissions(directoryHandle).catch(async () => { - // Permission request failed because user activation was too long ago - // -> Create window to get new activation - await this.createPermissionWindow( - directoryHandle - ) - }) - - if (this._hasComMojang) { - this.setup.dispatch() - this.dispatch() - return true - } - } - - this.dispatch() - return false - } - - protected async createPermissionWindow( - directoryHandle: AnyDirectoryHandle - ) { - const informWindow = new InformationWindow({ - name: 'comMojang.title', - description: 'comMojang.permissionRequest', - }) - informWindow.open() - - await informWindow.fired - await this.requestPermissions(directoryHandle) - } - - protected async requestPermissions(directoryHandle: AnyDirectoryHandle) { - const permission = await directoryHandle.requestPermission({ - mode: 'readwrite', - }) - - if (permission !== 'granted') { - this._hasComMojang = false - this._permissionDenied = true - this.app.projectManager.recompileAll() - } else { - this.fileSystem.setup(directoryHandle) - this._hasComMojang = true - } - } - - async set(directoryHandle: AnyDirectoryHandle) { - if (directoryHandle instanceof VirtualDirectoryHandle) { - const store = directoryHandle.getBaseStore() - if (!(store instanceof TauriFsStore)) - throw new Error( - 'Cannot set com.mojang directoryHandle to non-tauri-backed store' - ) - - set(comMojangKey, store.getBaseDirectory()) - } else { - set(comMojangKey, directoryHandle) - } - - await this.requestPermissions(directoryHandle) - if (this._hasComMojang) this.setup.dispatch() - - this._hasComMojangHandle = true - this.dispatch() - } - - async handleComMojangDrop(directoryHandle: AnyDirectoryHandle) { - // User wants to set default com.mojang folder - await this.set(directoryHandle) - if (this._hasComMojang) await this.app.projectManager.recompileAll() - } - - async unlink() { - await this.app.projectManager.recompileAll() - await del(comMojangKey) - } -} diff --git a/src/components/OutputFolders/ComMojang/ProjectLoader.ts b/src/components/OutputFolders/ComMojang/ProjectLoader.ts deleted file mode 100644 index a6b568312..000000000 --- a/src/components/OutputFolders/ComMojang/ProjectLoader.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { compareVersions } from 'bridge-common-utils' -import { computed, Ref, ref } from 'vue' -import { AnyDirectoryHandle } from '../../FileSystem/Types' -import { settingsState } from '../../Windows/Settings/SettingsState' -import { App } from '/@/App' -import { loadHandleAsDataURL } from '/@/utils/loadAsDataUrl' - -export interface IComMojangPack { - type: 'behaviorPack' | 'resourcePack' - uuid?: string - packPath: string - manifest: any - packIcon: string | null - directoryHandle: AnyDirectoryHandle -} -export interface IComMojangProject { - name: string - packs: IComMojangPack[] -} - -export class ComMojangProjectLoader { - protected cachedProjects = >ref(null) - public readonly hasProjects = computed( - () => (this.cachedProjects.value?.length ?? 0) > 0 - ) - protected initialLoadPromise?: Promise - - constructor(protected app: App) { - this.initialLoadPromise = this.loadProjects() - } - - protected get comMojang() { - return this.app.comMojang - } - protected get fileSystem() { - return this.app.comMojang.fileSystem - } - - clearCache() { - this.cachedProjects.value = null - } - - async loadProjects() { - if (this.initialLoadPromise) await this.initialLoadPromise - - if (!(settingsState?.projects?.loadComMojangProjects ?? true)) { - this.clearCache() - return [] - } - if (this.cachedProjects.value !== null) return this.cachedProjects.value - - await this.comMojang.fired - if ( - !this.comMojang.setup.hasFired || - !this.comMojang.status.hasComMojang - ) - return [] - - const behaviorPacks = await this.loadPacks('development_behavior_packs') - - // Fast path: No need to load resource packs if there are no behavior packs - if (behaviorPacks.size === 0) return [] - const resourcePacks = await this.loadPacks('development_resource_packs') - - const projects: IComMojangProject[] = [] - for (const behaviorPack of behaviorPacks.values()) { - const dependencies = behaviorPack.manifest?.dependencies - if (!dependencies) { - projects.push({ - name: behaviorPack.directoryHandle.name, - packs: [behaviorPack], - }) - continue - } - - let matchingRp - for (const dep of dependencies) { - matchingRp = resourcePacks.get(dep.uuid) - if (matchingRp) break - } - - if (!matchingRp) continue - projects.push({ - name: behaviorPack.directoryHandle.name, - packs: [behaviorPack, matchingRp], - }) - } - - this.cachedProjects.value = projects - return projects - } - - protected async loadPacks( - folderName: 'development_behavior_packs' | 'development_resource_packs' - ) { - const packs = new Map() - const storePackDir = await this.fileSystem - .getDirectoryHandle(folderName) - .catch(() => null) - - if (!storePackDir) return packs - - for await (const packHandle of storePackDir.values()) { - if (packHandle.kind === 'file') continue - - const manifest = await packHandle - .getFileHandle('manifest.json') - .then((fileHandle) => - this.fileSystem.readJsonHandle(fileHandle) - ) - .catch(() => null) - if (!manifest) continue - - const uuid = manifest?.header?.uuid - - // Check whether BP/RP is part of a v2 project - if (this.isV2Project(manifest)) continue - - const packIcon = await packHandle - .getFileHandle('pack_icon.png') - .then((fileHandle) => loadHandleAsDataURL(fileHandle)) - .catch(() => null) - - packs.set(uuid, { - type: - folderName === 'development_behavior_packs' - ? 'behaviorPack' - : 'resourcePack', - packPath: `${folderName}/${packHandle.name}`, - uuid, - manifest, - packIcon, - directoryHandle: packHandle, - }) - } - - return packs - } - - protected isV2Project(manifest: any) { - const uuid = manifest?.header?.uuid - - const bridgeVersion = - manifest?.metadata?.generated_with?.bridge?.pop?.() - if (bridgeVersion && compareVersions(bridgeVersion, '2.0.0', '>=')) - return true - - // Check whether BP is part of a v2 project - const isV2Project = this.app.projectManager.someProject( - (project) => !!project.bpUuid && project.bpUuid === uuid - ) - return isV2Project - } -} diff --git a/src/components/OutputFolders/ComMojang/Sidebar/ProjectHeader.vue b/src/components/OutputFolders/ComMojang/Sidebar/ProjectHeader.vue deleted file mode 100644 index d15dad4ba..000000000 --- a/src/components/OutputFolders/ComMojang/Sidebar/ProjectHeader.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/src/components/OutputFolders/ComMojang/Sidebar/ViewProject.ts b/src/components/OutputFolders/ComMojang/Sidebar/ViewProject.ts deleted file mode 100644 index 80f19bfcc..000000000 --- a/src/components/OutputFolders/ComMojang/Sidebar/ViewProject.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { markRaw } from 'vue' -import { IComMojangProject } from '../ProjectLoader' -import { App } from '/@/App' -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { InfoPanel } from '/@/components/InfoPanel/InfoPanel' -import { SelectableSidebarAction } from '/@/components/Sidebar/Content/SelectableSidebarAction' -import { SidebarAction } from '/@/components/Sidebar/Content/SidebarAction' -import { SidebarContent } from '/@/components/Sidebar/Content/SidebarContent' -import { SidebarElement } from '/@/components/Sidebar/SidebarElement' -import ViewProjectComponent from './ViewProject.vue' -import ProjectHeaderComponent from './ProjectHeader.vue' -import { showFolderContextMenu } from '/@/components/UIElements/DirectoryViewer/ContextMenu/Folder' -import { DirectoryWrapper } from '/@/components/UIElements/DirectoryViewer/DirectoryView/DirectoryWrapper' -import { IDisposable } from '/@/types/disposable' -import { addFilesToCommandBar } from '/@/components/CommandBar/AddFiles' - -export class ViewComMojangProject extends SidebarContent { - component = ViewProjectComponent - actions: SidebarAction[] = [] - topPanel: InfoPanel | undefined = undefined - headerHeight = '60px' - - hasComMojangProjectLoaded = false - - projectIcon?: string = undefined - projectName?: string = undefined - disposables: IDisposable[] = [] - - protected sidebarElement: SidebarElement - protected directoryEntries: Record = {} - protected closeAction = new SidebarAction({ - icon: 'mdi-close', - name: 'general.close', - color: 'error', - onTrigger: () => { - this.clearComMojangProject() - }, - }) - - constructor() { - super() - - this.actions = [this.closeAction] - - this.sidebarElement = markRaw( - new SidebarElement({ - id: 'viewComMojangProject', - group: 'packExplorer', - sidebarContent: this, - displayName: 'packExplorer.name', - icon: 'mdi-folder-outline', - isVisible: () => this.hasComMojangProjectLoaded, - }) - ) - } - - async loadComMojangProject(project: IComMojangProject) { - const app = await App.getApp() - this.hasComMojangProjectLoaded = true - this.actions = [this.closeAction] - - for (const pack of project.packs.reverse()) { - const packType = App.packType.getFromId(pack.type) - if (!packType) continue - - const action = new SelectableSidebarAction(this, { - icon: packType.icon, - name: `packType.${pack.type}.name`, - color: packType.color, - id: pack.type, - }) - this.actions.unshift(action) - - const wrapper = new DirectoryWrapper(null, pack.directoryHandle, { - startPath: pack.packPath, - defaultIconColor: packType.color, - }) - await wrapper.open() - - addFilesToCommandBar(wrapper.handle, packType.color).then( - (disposable) => { - if (!this.hasComMojangProjectLoaded) disposable.dispose() - else this.disposables.push(disposable) - } - ) - - this.directoryEntries[pack.type] = markRaw(wrapper) - if (pack.type === 'behaviorPack') action.select() - } - - this.projectName = project.name - this.projectIcon = - project.packs.find((p) => p.type === 'behaviorPack')?.packIcon ?? - undefined - - app.projectManager.title.setProject(this.projectName) - - this.headerSlot = ProjectHeaderComponent - this.sidebarElement.select() - } - async clearComMojangProject() { - const app = await App.getApp() - this.headerSlot = undefined - this.hasComMojangProjectLoaded = false - this.actions = [this.closeAction] - this.directoryEntries = {} - - app.projectManager.title.setProject('') - - // Unselect ViewFolders tab by selecting packExplorer instead - App.sidebar.elements.packExplorer.select() - - this.disposables.forEach((d) => d.dispose()) - this.disposables = [] - } - - onContentRightClick(event: MouseEvent): void { - const selectedId = this.selectedAction?.getConfig().id - if (!selectedId) return - - showFolderContextMenu(event, this.directoryEntries[selectedId], { - hideDelete: true, - hideRename: true, - hideDuplicate: true, - }) - } -} diff --git a/src/components/OutputFolders/ComMojang/Sidebar/ViewProject.vue b/src/components/OutputFolders/ComMojang/Sidebar/ViewProject.vue deleted file mode 100644 index 1a67d33f0..000000000 --- a/src/components/OutputFolders/ComMojang/Sidebar/ViewProject.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/src/components/PackExplorer/Actions/ToBridgeFolderProject.ts b/src/components/PackExplorer/Actions/ToBridgeFolderProject.ts deleted file mode 100644 index dcb0ff22e..000000000 --- a/src/components/PackExplorer/Actions/ToBridgeFolderProject.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Project } from '/@/components/Projects/Project/Project' - -export const ToBridgeFolderProjectAction = (project: Project) => ({ - name: 'packExplorer.move.toBridgeFolder', - icon: 'mdi-folder-sync-outline', - onTrigger: async () => { - let didSetupBridgeFolder = project.app.bridgeFolderSetup.hasFired - if (!didSetupBridgeFolder) - didSetupBridgeFolder = await project.app.setupBridgeFolder() - - if (didSetupBridgeFolder) project.switchProjectType() - }, -}) diff --git a/src/components/PackExplorer/Actions/ToLocalProject.ts b/src/components/PackExplorer/Actions/ToLocalProject.ts deleted file mode 100644 index 1b5fe5320..000000000 --- a/src/components/PackExplorer/Actions/ToLocalProject.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Project } from '/@/components/Projects/Project/Project' - -export const ToLocalProjectAction = (project: Project) => ({ - name: 'packExplorer.move.toLocal', - icon: 'mdi-lock-open-outline', - onTrigger: async () => { - project.switchProjectType() - }, -}) diff --git a/src/components/PackExplorer/HomeView/BridgeFolderBtn.vue b/src/components/PackExplorer/HomeView/BridgeFolderBtn.vue deleted file mode 100644 index f4e85b174..000000000 --- a/src/components/PackExplorer/HomeView/BridgeFolderBtn.vue +++ /dev/null @@ -1,110 +0,0 @@ - - - diff --git a/src/components/PackExplorer/HomeView/CreateProjectBtn.vue b/src/components/PackExplorer/HomeView/CreateProjectBtn.vue deleted file mode 100644 index ba44528d3..000000000 --- a/src/components/PackExplorer/HomeView/CreateProjectBtn.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - diff --git a/src/components/PackExplorer/HomeView/HomeView.vue b/src/components/PackExplorer/HomeView/HomeView.vue deleted file mode 100644 index 1d93e9e6f..000000000 --- a/src/components/PackExplorer/HomeView/HomeView.vue +++ /dev/null @@ -1,129 +0,0 @@ - - - diff --git a/src/components/PackExplorer/HomeView/ImportOldProjects.vue b/src/components/PackExplorer/HomeView/ImportOldProjects.vue deleted file mode 100644 index 0899ac680..000000000 --- a/src/components/PackExplorer/HomeView/ImportOldProjects.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - diff --git a/src/components/PackExplorer/HomeView/Project.vue b/src/components/PackExplorer/HomeView/Project.vue deleted file mode 100644 index 8d698c5a9..000000000 --- a/src/components/PackExplorer/HomeView/Project.vue +++ /dev/null @@ -1,157 +0,0 @@ - - - diff --git a/src/components/PackExplorer/HomeView/SetupHint.vue b/src/components/PackExplorer/HomeView/SetupHint.vue deleted file mode 100644 index d41eb9ac5..000000000 --- a/src/components/PackExplorer/HomeView/SetupHint.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/src/components/PackExplorer/HomeView/SetupView.vue b/src/components/PackExplorer/HomeView/SetupView.vue deleted file mode 100644 index 8bec0021c..000000000 --- a/src/components/PackExplorer/HomeView/SetupView.vue +++ /dev/null @@ -1,176 +0,0 @@ - - - diff --git a/src/components/PackExplorer/PackExplorer.ts b/src/components/PackExplorer/PackExplorer.ts deleted file mode 100644 index 46f4e9c3a..000000000 --- a/src/components/PackExplorer/PackExplorer.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { App } from '/@/App' -import { SidebarContent } from '/@/components/Sidebar/Content/SidebarContent' -import { SelectableSidebarAction } from '/@/components/Sidebar/Content/SelectableSidebarAction' -import { SidebarAction } from '/@/components/Sidebar/Content/SidebarAction' -import PackExplorerComponent from './PackExplorer.vue' -import ProjectDisplayComponent from './ProjectDisplay.vue' -import { InformationWindow } from '/@/components/Windows/Common/Information/InformationWindow' -import { showContextMenu } from '/@/components/ContextMenu/showContextMenu' -import { markRaw, ref, set } from 'vue' -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' -import { InfoPanel } from '/@/components/InfoPanel/InfoPanel' -import { exportAsBrproject } from '/@/components/Projects/Export/AsBrproject' -import { exportAsMcaddon } from '/@/components/Projects/Export/AsMcaddon' -import { - canExportMctemplate, - exportAsMctemplate, -} from '/@/components/Projects/Export/AsMctemplate' -import { FindAndReplaceTab } from '/@/components/FindAndReplace/Tab' -import { searchType } from '../FindAndReplace/Controls/searchType' -import { restartWatchModeConfig } from '../Compiler/Actions/RestartWatchMode' -import { DirectoryWrapper } from '../UIElements/DirectoryViewer/DirectoryView/DirectoryWrapper' -import { showFolderContextMenu } from '../UIElements/DirectoryViewer/ContextMenu/Folder' -import { IHandleMovedOptions } from '../UIElements/DirectoryViewer/DirectoryStore' -import { ViewConnectedFiles } from '../UIElements/DirectoryViewer/ContextMenu/Actions/ConnectedFiles' -import { ToLocalProjectAction } from './Actions/ToLocalProject' -import { ToBridgeFolderProjectAction } from './Actions/ToBridgeFolderProject' -import { revealInFileExplorer } from '/@/utils/revealInFileExplorer' -import { pathFromHandle } from '../FileSystem/Virtual/pathFromHandle' - -export class PackExplorer extends SidebarContent { - component = markRaw(PackExplorerComponent) - actions: SidebarAction[] = [] - directoryEntries: Record = {} - topPanel: InfoPanel | undefined = undefined - showNoProjectView = false - headerHeight = '60px' - - constructor() { - super() - - App.eventSystem.on('projectChanged', () => this.setup()) - App.eventSystem.on('fileAdded', () => this.refresh()) - - const updateHeaderSlot = async () => { - const app = await App.getApp() - await app.projectManager.projectReady.fired - - if (app.mobile.isCurrentDevice() || app.isNoProjectSelected) - this.headerSlot = undefined - else this.headerSlot = ProjectDisplayComponent - } - - App.getApp().then((app) => { - updateHeaderSlot() - - app.mobile.change.on(() => updateHeaderSlot()) - }) - - App.eventSystem.on('projectChanged', () => { - updateHeaderSlot() - }) - - this.setup() - } - - async setup() { - const app = await App.getApp() - - this.actions = [] - // Show select bridge. folder & create project buttons - if (app.isNoProjectSelected) { - this.showNoProjectView = true - - return - } else { - this.showNoProjectView = false - } - - this.unselectAllActions() - this.actions = [] - for (const pack of app.project.projectData.contains ?? []) { - const handle = await app.fileSystem - .getDirectoryHandle(pack.packPath) - .catch(() => null) - if (!handle) continue - - const wrapper = markRaw( - new DirectoryWrapper(null, handle, { - startPath: pack.packPath, - - provideFileContextMenu: async (fileWrapper) => [ - await ViewConnectedFiles(fileWrapper), - ], - provideFileDiagnostics: async (fileWrapper) => { - const packIndexer = app.project.packIndexer - await packIndexer.fired - - const filePath = fileWrapper.path - if (!filePath) return [] - - return packIndexer.service.getFileDiagnostics(filePath) - }, - onHandleMoved: (opts) => this.onHandleMoved(opts), - onFilesAdded: (filePaths) => this.onFilesAdded(filePaths), - }) - ) - await wrapper.open() - - set(this.directoryEntries, pack.packPath, wrapper) - this.actions.push( - new SelectableSidebarAction(this, { - id: pack.packPath, - name: `packType.${pack.id}.name`, - icon: pack.icon, - color: pack.color, - }) - ) - } - - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value - ) { - this.actions.push( - new SidebarAction({ - icon: 'mdi-content-save-outline', - name: 'general.save', - onTrigger: () => exportAsBrproject(), - }) - ) - } - - this.actions.push( - new SidebarAction({ - icon: 'mdi-dots-vertical', - name: 'general.more', - onTrigger: (event) => { - this.showMoreMenu(event) - }, - }) - ) - } - - async onHandleMoved({ - fromPath, - toPath, - movedHandle, - }: IHandleMovedOptions) { - const app = await App.getApp() - if (movedHandle.kind === 'file') - await app.project.onMovedFile(fromPath, toPath) - else await app.project.onMovedFolder(fromPath, toPath) - } - async onFilesAdded(filePaths: string[]) { - const app = await App.getApp() - - await app.project.updateFiles(filePaths) - } - - onContentRightClick(event: MouseEvent): void { - const selectedId = this.selectedAction?.getConfig().id - if (!selectedId) return - - showFolderContextMenu(event, this.directoryEntries[selectedId], { - hideDelete: true, - hideRename: true, - hideDuplicate: true, - }) - } - - async refresh() { - await Promise.all( - Object.values(this.directoryEntries).map((dirent) => - dirent.refresh() - ) - ) - } - - async getContextMenu( - type: 'file' | 'folder' | 'virtualFolder', - path: string - ) { - if (type === 'virtualFolder') return [] - const app = await App.getApp() - const project = app.project - - return [ - { - icon: 'mdi-file-search-outline', - name: 'actions.findInFolder.name', - description: 'actions.findInFolder.description', - onTrigger: () => { - project.tabSystem?.add( - new FindAndReplaceTab(project.tabSystem!, undefined, { - searchType: searchType.matchCase, - }) - ) - }, - }, - ] - } - - async showMoreMenu(event: MouseEvent) { - const app = await App.getApp() - - const moveAction = app.project.isLocal - ? ToBridgeFolderProjectAction(app.project) - : ToLocalProjectAction(app.project) - - showContextMenu(event, [ - // Add new file - { - icon: 'mdi-plus', - name: 'packExplorer.createPreset', - onTrigger: async () => { - await app.windows.createPreset.open() - }, - }, - - import.meta.env.VITE_IS_TAURI_APP || isUsingFileSystemPolyfill.value - ? null - : moveAction, - { type: 'divider' }, - // Reload project - { - icon: 'mdi-refresh', - name: 'packExplorer.refresh.name', - onTrigger: async () => { - app.actionManager.trigger('bridge.action.refreshProject') - }, - }, - // Restart dev server - restartWatchModeConfig(false), - { type: 'divider' }, - { - type: 'submenu', - icon: 'mdi-export', - name: 'packExplorer.exportAs.name', - actions: [ - // Export project as .brproject - { - icon: 'mdi-folder-zip-outline', - name: 'packExplorer.exportAs.brproject', - onTrigger: () => exportAsBrproject(), - }, - // Export project as .mcaddon - { - icon: 'mdi-minecraft', - name: 'packExplorer.exportAs.mcaddon', - onTrigger: () => exportAsMcaddon(), - }, - // Export project as .mcworld - { - icon: 'mdi-earth-box', - name: 'packExplorer.exportAs.mcworld', - isDisabled: !(await canExportMctemplate()), - onTrigger: () => exportAsMctemplate(true), - }, - // Export project as .mctemplate - { - icon: 'mdi-earth-box-plus', - name: 'packExplorer.exportAs.mctemplate', - isDisabled: !(await canExportMctemplate()), - onTrigger: () => exportAsMctemplate(), - }, - ...(await app.project.exportProvider.getExporters()), - ], - }, - - { type: 'divider' }, - - // Project config - { - icon: 'mdi-cog-outline', - name: 'packExplorer.projectConfig.name', - onTrigger: async () => { - const project = app.project - - // Test whether project config exists - if (!(await project.fileSystem.fileExists('config.json'))) { - new InformationWindow({ - description: 'packExplorer.projectConfig.missing', - }) - return - } - - // Open project config - await project.tabSystem?.openPath( - `${project.projectPath}/config.json` - ) - }, - }, - { - icon: 'mdi-folder-open-outline', - name: 'packExplorer.openProjectFolder.name', - onTrigger: async () => { - if (import.meta.env.VITE_IS_TAURI_APP) { - await revealInFileExplorer( - await pathFromHandle(app.project.baseDirectory) - ) - return - } - - app.viewFolders.addDirectoryHandle({ - directoryHandle: app.project.baseDirectory, - startPath: app.project.projectPath, - - onHandleMoved: (options) => this.onHandleMoved(options), - onFilesAdded: (filePaths) => - this.onFilesAdded(filePaths), - }) - }, - }, - ]) - } -} diff --git a/src/components/PackExplorer/PackExplorer.vue b/src/components/PackExplorer/PackExplorer.vue deleted file mode 100644 index 6d30deb42..000000000 --- a/src/components/PackExplorer/PackExplorer.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/src/components/PackExplorer/ProjectDisplay.vue b/src/components/PackExplorer/ProjectDisplay.vue deleted file mode 100644 index 0cff7ca6f..000000000 --- a/src/components/PackExplorer/ProjectDisplay.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/src/components/PackIndexer/PackIndexer.ts b/src/components/PackIndexer/PackIndexer.ts deleted file mode 100644 index c4dcefae8..000000000 --- a/src/components/PackIndexer/PackIndexer.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { App } from '/@/App' -import { proxy, Remote, wrap } from 'comlink' -import { settingsState } from '/@/components/Windows/Settings/SettingsState' -import { PackIndexerService as TPackIndexerService } from './Worker/Main' -import PackIndexerWorker from './Worker/Main?worker' -import { AnyDirectoryHandle } from '../FileSystem/Types' -import type { Project } from '/@/components/Projects/Project/Project' -import { Mutex } from '../Common/Mutex' -import { Signal } from '../Common/Event/Signal' -import { Task } from '../TaskManager/Task' -import { setupWorker } from '/@/utils/worker/setup' - -const worker = new PackIndexerWorker() -const PackIndexerService = wrap(worker) -setupWorker(worker) - -const taskOptions = { - icon: 'mdi-flash-outline', - name: 'taskManager.tasks.packIndexing.title', - description: 'taskManager.tasks.packIndexing.description', -} - -export class PackIndexer extends Signal<[string[], string[]]> { - protected isPackIndexerFree = new Mutex() - protected _service: Remote | null = null - protected task?: Task - - constructor( - protected project: Project, - protected baseDirectory: AnyDirectoryHandle - ) { - super() - } - - get service() { - if (!this._service) - throw new Error(`Accessing service without service being defined`) - return this._service - } - - async activate(forceRefreshCache: boolean) { - console.time('[TASK] Indexing Packs (Total)') - - this.isPackIndexerFree.lock() - const app = this.project.app - this.task = app.taskManager.create(taskOptions) - - // Instaniate the worker TaskService - this._service = await new PackIndexerService( - this.baseDirectory, - this.project.app.fileSystem.baseDirectory, - { - disablePackSpider: !( - settingsState?.general?.enablePackSpider ?? false - ), - pluginFileTypes: App.fileType.getPluginFileTypes(), - noFullLightningCacheRefresh: - !forceRefreshCache && - !settingsState?.general?.fullLightningCacheRefresh, - projectPath: this.project.projectPath, - } - ) - - // Listen to task progress and update UI - await this._service?.on( - proxy(([current, total]) => { - this.task?.update(current, total) - }), - false - ) - - // Start service - const [changedFiles, deletedFiles] = await this._service?.start( - forceRefreshCache - ) - await this._service?.disposeListeners() - this.task.complete() - this.isPackIndexerFree.unlock() - - console.timeEnd('[TASK] Indexing Packs (Total)') - - // Only dispatch signal if service wasn't disposed in the meantime - if (this._service) this.dispatch([changedFiles, deletedFiles]) - return [changedFiles, deletedFiles] - } - - async deactivate() { - this.resetSignal() - await this._service?.disposeListeners() - this._service = null - this.task?.complete() - } - - async updateFile( - filePath: string, - fileContent?: string, - isForeignFile = false, - hotUpdate = false - ) { - await this.isPackIndexerFree.lock() - - await this.service.updatePlugins(App.fileType.getPluginFileTypes()) - - const anyFileChanged = await this.service.updateFile( - filePath, - fileContent, - isForeignFile, - hotUpdate - ) - - this.isPackIndexerFree.unlock() - return anyFileChanged - } - async rename(fromPath: string, toPath: string, saveStore = true) { - await this.isPackIndexerFree.lock() - - await this.service.updatePlugins(App.fileType.getPluginFileTypes()) - - await this.service.rename(fromPath, toPath, saveStore) - - this.isPackIndexerFree.unlock() - } - - async hasFile(filePath: string) { - await this.isPackIndexerFree.lock() - - const res = await this.service.hasFile(filePath) - - this.isPackIndexerFree.unlock() - - return res - } - async updateFiles(filePaths: string[], hotUpdate = false) { - await this.isPackIndexerFree.lock() - - await this.service.updatePlugins(App.fileType.getPluginFileTypes()) - const anyFileChanged = await this.service.updateFiles( - filePaths, - hotUpdate - ) - - this.isPackIndexerFree.unlock() - return anyFileChanged - } - async unlinkFile(path: string, saveCache = true) { - await this.isPackIndexerFree.lock() - - await this.service.updatePlugins(App.fileType.getPluginFileTypes()) - - await this.service.unlinkFile(path, saveCache) - - this.isPackIndexerFree.unlock() - } - async saveCache() { - await this.isPackIndexerFree.lock() - - await this.service.saveCache() - - this.isPackIndexerFree.unlock() - } -} diff --git a/src/components/PackIndexer/Worker/LightningCache/CacheEnv.ts b/src/components/PackIndexer/Worker/LightningCache/CacheEnv.ts deleted file mode 100644 index fbfbe90c8..000000000 --- a/src/components/PackIndexer/Worker/LightningCache/CacheEnv.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { TPackTypeId } from '/@/components/Data/PackType' -import { findFileExtension } from '/@/components/FileSystem/FindFile' -import { ProjectConfig } from '/@/components/Projects/Project/Config' -import { FileSystem } from '/@/components/FileSystem/FileSystem' - -export function getCacheScriptEnv( - value: string, - ctx: { fileSystem: FileSystem; config: ProjectConfig } -) { - return { - Bridge: { - value, - withExtension: (basePath: string, extensions: string[]) => - findFileExtension(ctx.fileSystem, basePath, extensions), - resolvePackPath: (packId: TPackTypeId, filePath: string) => - ctx.config.resolvePackPath(packId, filePath), - }, - } -} diff --git a/src/components/PackIndexer/Worker/LightningCache/LightningCache.ts b/src/components/PackIndexer/Worker/LightningCache/LightningCache.ts deleted file mode 100644 index 8f30b1022..000000000 --- a/src/components/PackIndexer/Worker/LightningCache/LightningCache.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { run } from '/@/components/Extensions/Scripts/run' -import { walkObject } from 'bridge-common-utils' -import json5 from 'json5' -import type { PackIndexerService } from '../Main' -import type { LightningStore } from './LightningStore' -import { runScript } from './Script' -import { basename, extname, join } from '/@/utils/path' -import { - AnyDirectoryHandle, - AnyFileHandle, - AnyHandle, -} from '/@/components/FileSystem/Types' -import { TPackTypeId } from '/@/components/Data/PackType' -import { getCacheScriptEnv } from './CacheEnv' -import type { VirtualFile } from '/@/components/FileSystem/Virtual/File' - -const knownTextFiles = new Set([ - '.js', - '.ts', - '.lang', - '.mcfunction', - '.txt', - '.molang', - '.html', -]) - -export interface ILightningInstruction { - cacheKey: string - path: string - pathScript?: string - script?: string - filter?: string[] -} - -const baseIgnoreFolders = ['.bridge', 'builds', '.git', 'worlds'] - -export class LightningCache { - protected folderIgnoreList = new Set() - protected totalTime = 0 - - constructor( - protected service: PackIndexerService, - protected lightningStore: LightningStore - ) {} - - get fileType() { - return this.service.fileType - } - - async loadIgnoreFolders() { - try { - const file = await this.service.projectFileSystem.readFile( - '.bridge/.ignoreFolders' - ) - ;(await file.text()) - .split('\n') - .concat(baseIgnoreFolders) - .forEach((folder) => this.folderIgnoreList.add(folder)) - } catch { - baseIgnoreFolders.forEach((folder) => - this.folderIgnoreList.add(folder) - ) - } - } - - async start( - forceRefresh: boolean - ): Promise { - await this.lightningStore.setup() - - if (this.folderIgnoreList.size === 0) await this.loadIgnoreFolders() - - if ( - !forceRefresh && - this.service.getOptions().noFullLightningCacheRefresh - ) { - const filePaths = this.lightningStore.allFiles() - if (filePaths.length > 0) return [filePaths, [], []] - } - - let anyFileChanged = false - const filePaths: string[] = [] - const changedFiles: string[] = [] - - const availablePackPaths = this.service.config.getAvailablePackPaths() - // Find directory handles matching each available pack - const directoryHandles = await Promise.all( - availablePackPaths.map( - async (packPath) => - [ - packPath, - await this.service.fileSystem - .getDirectoryHandle(packPath) - .catch(() => undefined), - ] - ) - ) - - for (const [packPath, directoryHandle] of directoryHandles) { - if (!directoryHandle) { - console.warn( - `Cannot index pack "${packPath}" because it does not exist` - ) - continue - } - - await this.iterateDir( - directoryHandle, - async (fileHandle, filePath) => { - filePath = join(packPath, filePath) - const fileDidChange = await this.processFile( - filePath, - fileHandle - ) - - if (fileDidChange) { - if (!anyFileChanged) anyFileChanged = true - changedFiles.push(filePath) - } - filePaths.push(filePath) - - this.service.progress.addToCurrent() - } - ) - } - - let deletedFiles: string[] = [] - if ( - anyFileChanged || - this.lightningStore.visitedFiles !== this.lightningStore.totalFiles - ) - deletedFiles = await this.lightningStore.saveStore() - - return [filePaths, changedFiles, deletedFiles] - } - - async unlinkFile(path: string, saveCache = true) { - this.lightningStore.remove(path) - - if (saveCache) await this.lightningStore.saveStore(false) - } - - protected async iterateDir( - baseDir: AnyDirectoryHandle, - callback: ( - file: AnyFileHandle, - filePath: string - ) => void | Promise, - fullPath = '' - ) { - const promises = [] - - for await (const [fileName, entry] of baseDir.entries()) { - const currentFullPath = - fullPath.length === 0 ? fileName : `${fullPath}/${fileName}` - - if (entry.kind === 'directory') { - if (this.folderIgnoreList.has(currentFullPath)) continue - promises.push(this.iterateDir(entry, callback, currentFullPath)) - } else if (fileName[0] !== '.') { - this.service.progress.addToTotal(2) - promises.push(callback(entry, currentFullPath)) - } - } - - await Promise.allSettled(promises) - } - - /** - * @returns Whether this file did change - */ - async processFile( - filePath: string, - fileHandleOrContent: string | AnyFileHandle, - isForeignFile = false - ) { - const receivedContent = typeof fileHandleOrContent === 'string' - - const file = receivedContent - ? new File([fileHandleOrContent], basename(filePath)) - : await fileHandleOrContent.getFile() - const fileType = this.fileType.getId(filePath) - - if (!file) - throw new Error( - `Invalid call to "processFile": Either fileContent or fileHandle needs to be defined!` - ) - - // First step: Check lastModified time. If the file was not modified, we can skip all processing - // If custom fileContent is defined, we need to always run processFile - if ( - !receivedContent && - file.lastModified === - this.lightningStore.getLastModified(filePath, fileType) - ) { - this.lightningStore.setVisited(filePath, true, fileType) - return false - } - // console.log('File changed: ' + filePath) - - const ext = extname(filePath) - - // Second step: Process file - if (this.fileType.isJsonFile(filePath)) { - await this.processJSON( - filePath, - fileType, - file, - receivedContent ? fileHandleOrContent : undefined, - isForeignFile - ) - } else if (knownTextFiles.has(ext)) { - await this.processText(filePath, fileType, file, isForeignFile) - } else { - this.lightningStore.add( - filePath, - { lastModified: file.lastModified, isForeignFile }, - fileType - ) - } - - return true - } - - async processText( - filePath: string, - fileType: string, - file: File | VirtualFile, - isForeignFile?: boolean - ) { - const instructions = await this.fileType.getLightningCache(filePath) - - // JavaScript cache API - if (typeof instructions === 'string') { - const cacheFunction = runScript(instructions) - - // Only proceed if the script returned a function - if (typeof cacheFunction === 'function') { - const textData = cacheFunction(await file.text(), { - resolvePackPath: (packId: TPackTypeId, filePath: string) => - this.service.config.resolvePackPath(packId, filePath), - }) - - this.lightningStore.add( - filePath, - { - lastModified: file.lastModified, - isForeignFile, - data: - Object.keys(textData).length > 0 - ? textData - : undefined, - }, - fileType - ) - } - } - - this.lightningStore.add( - filePath, - { lastModified: file.lastModified, isForeignFile }, - fileType - ) - } - - /** - * Process a JSON file - * @returns Whether this json file did change - */ - async processJSON( - filePath: string, - fileType: string, - file: File | VirtualFile, - fileContent?: string, - isForeignFile?: boolean - ) { - // Load instructions for current file - const instructions = await this.fileType.getLightningCache(filePath) - - // No instructions = no work - if (instructions.length === 0 || typeof instructions === 'string') { - this.lightningStore.add( - filePath, - { - lastModified: file.lastModified, - isForeignFile, - }, - fileType - ) - return - } - - const isTemporaryUpdateCall = fileContent !== undefined - if (fileContent === undefined) fileContent = await file.text() - if (fileContent === '') fileContent = '{}' - - // JSON API - let data: any - try { - data = json5.parse(fileContent) - } catch (err: any) { - // Updating auto-completions in the background shouldn't get rid of all auto-completions currently saved for this file - if (!isTemporaryUpdateCall) { - console.error(`[${filePath}] ${err.message}`) - - this.lightningStore.add( - filePath, - { - lastModified: file.lastModified, - isForeignFile, - }, - fileType - ) - } - - return - } - - // console.log(instructions) - const collectedData: Record = {} - const asyncOnReceiveData = - (key: string, filter?: string[], mapFunc?: string) => - async (data: any) => { - let readyData: string[] - if (Array.isArray(data)) readyData = data - else if (typeof data === 'object') - readyData = Object.keys(data ?? {}) - else readyData = [data] - - if (filter) - readyData = readyData.filter((d) => !filter.includes(d)) - if (mapFunc) { - const ctx = { - config: this.service.config, - fileSystem: this.service.fileSystem, - } - - readyData = ( - await Promise.all( - readyData.map((value) => - run({ - async: true, - script: mapFunc, - env: { - ...getCacheScriptEnv(value, ctx), - }, - }) - ) - ) - ).filter((value) => value !== undefined) - } - - if (!collectedData[key]) collectedData[key] = readyData - else - collectedData[key] = [ - ...new Set(collectedData[key].concat(readyData)), - ] - } - const promises: Promise[] = [] - const onReceiveData = - (key: string, filter?: string[], mapFunc?: string) => - (data: any) => { - promises.push(asyncOnReceiveData(key, filter, mapFunc)(data)) - } - - for (const instruction of instructions) { - const key = instruction.cacheKey - if (!key) continue - let paths = Array.isArray(instruction.path) - ? instruction.path - : [instruction.path] - const generatePaths = instruction.pathScript - - if (generatePaths) { - paths = run({ - script: generatePaths, - env: { Bridge: { paths } }, - }) - - if (!Array.isArray(paths) || paths.length === 0) return - } - - for (const path of paths) { - walkObject( - path, - data, - onReceiveData(key, instruction.filter, instruction.script) - ) - } - } - - await Promise.all(promises) - this.lightningStore.add( - filePath, - { - lastModified: file.lastModified, - isForeignFile, - data: - Object.keys(collectedData).length > 0 - ? collectedData - : undefined, - }, - fileType - ) - - return - } -} diff --git a/src/components/PackIndexer/Worker/LightningCache/LightningStore.ts b/src/components/PackIndexer/Worker/LightningCache/LightningStore.ts deleted file mode 100644 index d55b19b4a..000000000 --- a/src/components/PackIndexer/Worker/LightningCache/LightningStore.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { - FileTypeLibrary, - IMonacoSchemaArrayEntry, -} from '/@/components/Data/FileType' -import type { FileSystem } from '/@/components/FileSystem/FileSystem' -import { join } from '/@/utils/path' - -type TStore = Record> -interface IStoreEntry { - lastModified: number - visited?: boolean - isForeignFile?: boolean - data?: Record -} - -/** - * Implements the lightning cache interaction with the file system - */ -export class LightningStore { - protected store: TStore | undefined - protected fs: FileSystem - protected _visitedFiles = 0 - protected _totalFiles = 0 - constructor( - fs: FileSystem, - protected fileType: FileTypeLibrary, - protected projectPath: string - ) { - this.fs = fs - } - - get visitedFiles() { - return this._visitedFiles - } - get totalFiles() { - return this._totalFiles - } - - reset() { - this.store = {} - } - async setup() { - let loadStore: string[] = [] - try { - loadStore = ( - await this.fs - .readFile('.bridge/.lightningCache') - .then((file) => file.text()) - ).split('\n') - } catch {} - - this.store = {} - this._visitedFiles = 0 - this._totalFiles = 0 - if (loadStore.length === 0) return - - let formatVersion = 0 - if (loadStore[0].startsWith('$formatVersion: ')) { - // Load formatVersion from string with format "$formatVersion: [number]" - formatVersion = Number(loadStore[0].match(/\d+/)?.[0] ?? 0) - loadStore.shift() - } - - const projectPrefix = this.projectPath - - let currentFileType = 'unknown' - for (const definition of loadStore) { - if (definition === '') continue - else if (definition[0] === '#') { - currentFileType = definition.slice(1) - this.store[currentFileType] = {} - continue - } - - let [filePath, lastModified, data] = definition.split('|') - if (formatVersion === 0) { - filePath = join(projectPrefix, filePath) - } - - this._totalFiles++ - this.store[currentFileType][filePath] = { - lastModified: Number(lastModified), - data: data ? JSON.parse(data) : undefined, - } - } - } - async saveStore(checkVisited = true) { - let saveStore = `$formatVersion: 1\n` - const deletedFiles: string[] = [] - - for (const fileType in this.store) { - saveStore += `#${fileType}\n` - - for (const filePath in this.store[fileType]) { - const entry = this.store[fileType][filePath] - - // Foreign files can simply be ignored - if (entry.isForeignFile) { - continue - } - - // This file no longer seems to exist, omit it from store output - if (checkVisited && !entry.visited) { - deletedFiles.push(filePath) - delete this.store[fileType][filePath] - continue - } - - saveStore += `${filePath}|${entry.lastModified}` - if (entry.data) saveStore += `|${JSON.stringify(entry.data)}\n` - else saveStore += '\n' - } - } - - await this.fs.writeFile('.bridge/.lightningCache', saveStore) - - return deletedFiles - } - - add( - filePath: string, - { - lastModified, - data, - isForeignFile, - }: IStoreEntry & { data?: Record }, - fileType = this.fileType.getId(filePath) - ) { - if (!this.store![fileType]) this.store![fileType] = {} - - this._visitedFiles++ - if (!this.store![fileType][filePath]) this._totalFiles++ - - this.store![fileType][filePath] = { - visited: true, - lastModified, - isForeignFile, - data: data ?? this.store![fileType]?.[filePath]?.data, - } - } - remove(filePath: string, fileType = this.fileType.getId(filePath)) { - if (!this.store![fileType]) return - - delete this.store![fileType][filePath] - } - rename(fromPath: string, toPath: string) { - // Store whether fromPath is a file path (ends with extension) - const isFilePath = /\.\w+$/.test(fromPath) - - for (const fileType in this.store) { - for (const filePath in this.store[fileType]) { - if ( - (isFilePath && filePath === fromPath) || - (!isFilePath && filePath.startsWith(fromPath)) - ) { - const composedNewPath = filePath.replace(fromPath, toPath) - const newFileType = this.fileType.getId(composedNewPath) - - this.store[newFileType][composedNewPath] = - this.store[fileType][filePath] - - delete this.store[fileType][filePath] - } - } - } - } - - setVisited( - filePath: string, - visited: boolean, - fileType = this.fileType.getId(filePath) - ) { - this._visitedFiles++ - - if (this.store?.[fileType]?.[filePath]) { - this.store![fileType][filePath].visited = visited - } - } - - getLastModified( - filePath: string, - fileType = this.fileType.getId(filePath) - ) { - return this.store![fileType]?.[filePath]?.lastModified - } - - find( - findFileType: string, - whereCacheKey: string, - matchesOneOf: string[], - fetchAll = false - ) { - if (!matchesOneOf || matchesOneOf.length === 0) return [] - const relevantStore = this.store![findFileType] - - const resultingFiles: string[] = [] - - // Iterating over files - for (const filePath in relevantStore) { - const cacheEntries = - relevantStore[filePath].data?.[whereCacheKey] ?? [] - - if (cacheEntries.find((entry) => matchesOneOf.includes(entry))) { - if (!fetchAll) return [filePath] - else resultingFiles.push(filePath) - } - } - - return resultingFiles - } - findMultiple( - findFileTypes: string[], - whereCacheKey: string, - matchesOneOf: string[], - fetchAll = false - ) { - const resultingFiles: string[] = [] - - for (const findFileType of findFileTypes) { - const foundFiles = this.find( - findFileType, - whereCacheKey, - matchesOneOf, - fetchAll - ) - if (foundFiles.length > 0 && !fetchAll) return foundFiles - - resultingFiles.push(...foundFiles) - } - - return resultingFiles - } - - async forEach( - fileType: string, - cb: (filePath: string, storeEntry: IStoreEntry) => Promise | void - ) { - const relevantStore = this.store![fileType] - - const promises: (void | Promise)[] = [] - for (const filePath in relevantStore) { - promises.push(cb(filePath, relevantStore[filePath])) - } - - await Promise.all(promises) - } - - get(filePath: string, fileType = this.fileType.getId(filePath)) { - return this.store![fileType]?.[filePath] ?? {} - } - has(filePath: string, fileType = this.fileType.getId(filePath)) { - return !!this.store![fileType]?.[filePath] - } - - allFiles(searchFileType?: string) { - const filePaths = [] - - if (searchFileType && this.store) { - for (const filePath in this.store[searchFileType]) { - // Exclude foreign files from result - if (this.store[searchFileType][filePath].isForeignFile) continue - - filePaths.push(filePath) - } - } else { - for (const fileType in this.store) { - for (const filePath in this.store[fileType]) { - // Exclude foreign files from result - if (this.store[fileType][filePath].isForeignFile) continue - - filePaths.push(filePath) - } - } - } - - return filePaths - } - - getAllFrom(fileType: string, fromFilePath?: string) { - const collectedData: Record = {} - - for (const filePath in this.store![fileType] ?? {}) { - if (fromFilePath && fromFilePath !== filePath) continue - - const cachedData = - (this.store![fileType][filePath] ?? {}).data ?? {} - - for (const cacheKey in cachedData) { - if (collectedData[cacheKey]) - collectedData[cacheKey].push(...cachedData[cacheKey]) - else collectedData[cacheKey] = [...cachedData[cacheKey]] - } - } - - // Remove duplicates from array using a set - for (const cacheKey in collectedData) { - collectedData[cacheKey] = [...new Set(collectedData[cacheKey])] - } - - return collectedData - } - - async getSchemasFor(fileType: string, fromFilePath?: string) { - const collectedData = await this.getAllFrom(fileType, fromFilePath) - const baseUrl = `file:///data/packages/minecraftBedrock/schema/${fileType}/dynamic` - const schemas: IMonacoSchemaArrayEntry[] = [] - - for (const key in collectedData) { - schemas.push({ - uri: `${baseUrl}/${ - fromFilePath ? 'currentContext/' : '' - }${key}Enum.json`, - schema: { - type: 'string', - enum: collectedData[key], - }, - }) - - schemas.push({ - uri: `${baseUrl}/${ - fromFilePath ? 'currentContext/' : '' - }${key}Property.json`, - schema: { - properties: Object.fromEntries( - collectedData[key].map((d) => [d, {}]) - ), - }, - }) - } - - return schemas - } - - getCacheDataFor( - fileType: string, - filePath?: string, - cacheKey?: string - ): any { - const collectedData = this.getAllFrom(fileType, filePath) - if (typeof cacheKey === 'string') return collectedData[cacheKey] - return collectedData - } -} diff --git a/src/components/PackIndexer/Worker/LightningCache/Script.ts b/src/components/PackIndexer/Worker/LightningCache/Script.ts deleted file mode 100644 index d8361cad4..000000000 --- a/src/components/PackIndexer/Worker/LightningCache/Script.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { run } from '/@/components/Extensions/Scripts/run' - -export function runScript(script: string) { - const module: any = { exports: undefined } - - run({ script, env: { module } }) - - return module.exports -} diff --git a/src/components/PackIndexer/Worker/Main.ts b/src/components/PackIndexer/Worker/Main.ts deleted file mode 100644 index 3a459d9a1..000000000 --- a/src/components/PackIndexer/Worker/Main.ts +++ /dev/null @@ -1,205 +0,0 @@ -import '/@/utils/worker/inject' - -import '/@/components/FileSystem/Virtual/Comlink' -import { FileTypeLibrary, IFileType } from '/@/components/Data/FileType' -import { expose } from 'comlink' -import { TaskService } from '/@/components/TaskManager/WorkerTask' -import { LightningStore } from './LightningCache/LightningStore' -import { PackSpider } from './PackSpider/PackSpider' -import { LightningCache } from './LightningCache/LightningCache' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { PackTypeLibrary } from '/@/components/Data/PackType' -import { DataLoader } from '/@/components/Data/DataLoader' -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { ProjectConfig } from '/@/components/Projects/Project/Config' -export type { ILightningInstruction } from './LightningCache/LightningCache' - -export interface IPackIndexerOptions { - pluginFileTypes: IFileType[] - disablePackSpider: boolean - noFullLightningCacheRefresh: boolean - projectPath: string -} - -const dataLoader: DataLoader = new DataLoader() -export class PackIndexerService extends TaskService< - readonly [string[], string[]], - boolean -> { - protected lightningStore: LightningStore - protected packSpider: PackSpider - protected lightningCache: LightningCache - public fileSystem: FileSystem - public projectFileSystem: FileSystem - public globalFileSystem: FileSystem - public config: ProjectConfig - public fileType: FileTypeLibrary - public packType: PackTypeLibrary - - constructor( - protected projectDirectory: AnyDirectoryHandle, - baseDirectory: AnyDirectoryHandle, - protected readonly options: IPackIndexerOptions - ) { - super() - - this.fileSystem = new FileSystem(baseDirectory) - this.projectFileSystem = new FileSystem(projectDirectory) - this.config = new ProjectConfig( - this.projectFileSystem, - options.projectPath - ) - this.fileType = new FileTypeLibrary(this.config) - this.packType = new PackTypeLibrary(this.config) - - this.globalFileSystem = new FileSystem(baseDirectory) - this.lightningStore = new LightningStore( - this.projectFileSystem, - this.fileType, - options.projectPath - ) - this.packSpider = new PackSpider(this, this.lightningStore) - this.lightningCache = new LightningCache(this, this.lightningStore) - this.fileType.setPluginFileTypes(options.pluginFileTypes) - } - - getOptions() { - return { - projectDirectory: this.projectDirectory, - baseDirectory: this.fileSystem.baseDirectory, - ...this.options, - } - } - - async onStart(forceRefresh: boolean) { - console.time('[WORKER] SETUP') - this.lightningStore.reset() - if (!dataLoader.hasFired) await dataLoader.loadData() - - await Promise.all([ - this.fileType.setup(dataLoader), - this.packType.setup(dataLoader), - this.config.setup(false), - ]) - - console.timeEnd('[WORKER] SETUP') - - console.time('[WORKER] LightningCache') - const [filePaths, changedFiles, deletedFiles] = - await this.lightningCache.start(forceRefresh) - console.timeEnd('[WORKER] LightningCache') - - console.time('[WORKER] PackSpider') - await this.packSpider.setup(filePaths) - console.timeEnd('[WORKER] PackSpider') - - return [changedFiles, deletedFiles] - } - - async updateFile( - filePath: string, - fileContent?: string, - isForeignFile = false, - hotUpdate = false - ) { - const fileDidChange = await this.lightningCache.processFile( - filePath, - fileContent ?? (await this.fileSystem.getFileHandle(filePath)), - isForeignFile - ) - - if (fileDidChange) { - if (!hotUpdate) await this.lightningStore.saveStore(false) - await this.packSpider.updateFile(filePath) - } - - return fileDidChange - } - async updateFiles(filePaths: string[], hotUpdate = false) { - let anyFileChanged = false - for (let i = 0; i < filePaths.length; i++) { - const fileDidChange = await this.updateFile( - filePaths[i], - undefined, - false, - false - ) - if (fileDidChange) anyFileChanged = true - } - - if (!hotUpdate && anyFileChanged) - await this.lightningStore.saveStore(false) - - return anyFileChanged - } - hasFile(filePath: string) { - return this.lightningStore.has(filePath) - } - - async rename(fromPath: string, toPath: string, saveStore = true) { - this.lightningStore.rename(fromPath, toPath) - if (saveStore) await this.lightningStore.saveStore(false) - } - - unlinkFile(path: string, saveCache = true) { - return this.lightningCache.unlinkFile(path, saveCache) - } - saveCache() { - return this.lightningStore.saveStore(false) - } - - updatePlugins(pluginFileTypes: IFileType[]) { - this.fileType.setPluginFileTypes(pluginFileTypes) - } - - find( - findFileType: string, - whereCacheKey: string, - matchesOneOf: string[], - fetchAll = false - ) { - return this.lightningStore.find( - findFileType, - whereCacheKey, - matchesOneOf, - fetchAll - ) - } - - findMultiple( - findFileTypes: string[], - whereCacheKey: string, - matchesOneOf: string[], - fetchAll = false - ) { - return this.lightningStore.findMultiple( - findFileTypes, - whereCacheKey, - matchesOneOf, - fetchAll - ) - } - - getAllFiles(fileType?: string, sorted = false) { - if (sorted) - return this.lightningStore - .allFiles(fileType) - .sort((a, b) => a.localeCompare(b)) - return this.lightningStore.allFiles(fileType) - } - getFileDiagnostics(filePath: string) { - return this.packSpider.getDiagnostics(filePath) - } - getConnectedFiles(filePath: string) { - return this.packSpider.getConnectedFiles(filePath) - } - - getSchemasFor(fileType: string, fromFilePath?: string) { - return this.lightningStore.getSchemasFor(fileType, fromFilePath) - } - getCacheDataFor(fileType: string, filePath?: string, cacheKey?: string) { - return this.lightningStore.getCacheDataFor(fileType, filePath, cacheKey) - } -} - -expose(PackIndexerService, self) diff --git a/src/components/PackIndexer/Worker/PackSpider/PackSpider.ts b/src/components/PackIndexer/Worker/PackSpider/PackSpider.ts deleted file mode 100644 index ae641baf8..000000000 --- a/src/components/PackIndexer/Worker/PackSpider/PackSpider.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { walkObject } from 'bridge-common-utils' -import { LightningStore } from '../LightningCache/LightningStore' -import { PackIndexerService } from '../Main' - -export interface IPackSpiderInstruction { - connect?: IFileDescription[] - includeFiles?: string[] - includeFromFiles?: { - from: string // filePath - take: string[] // ['texture_data', '@texture', 'textures'] - prefix?: string - suffix?: string - }[] - sharedFiles?: string[] - provideDiagnostics?: ({ - if: IConditionalFileDescription - } & IFileDiagnostic)[] -} - -export interface IFileDescription { - find: string | string[] - where: string - matches: string - shouldFindMultiple?: boolean -} -export interface IConditionalFileDescription extends IFileDescription { - not?: boolean -} -export interface IFileDiagnostic { - type?: 'error' | 'warning' | 'info' - opacity?: number - text: string - icon: string -} - -export class PackSpider { - public readonly packSpiderFiles = new Map() - - constructor( - public readonly packIndexer: PackIndexerService, - public readonly lightningStore: LightningStore - ) {} - - get fileType() { - return this.packIndexer.fileType - } - - async setup(filePaths: string[]) { - // console.log(await this.fileType.getPackSpiderData()) - // TODO(Dash): Re-enable pack spider - // if (this.packIndexer.getOptions().disablePackSpider || true) return - - const packSpiderData = await this.fileType.getPackSpiderData() - for (const fileTypeId in packSpiderData) { - const instructions = packSpiderData[fileTypeId] - - const allFilesOfType = this.lightningStore.allFiles(fileTypeId) - - for (const filePath of allFilesOfType) { - const packSpiderFile = new PackSpiderFile({ - lightningStore: this.lightningStore, - filePath: filePath, - fileType: fileTypeId, - instructions, - }) - this.packSpiderFiles.set(filePath, packSpiderFile) - } - } - } - - getDiagnostics(filePath: string) { - const packSpiderFile = this.packSpiderFiles.get(filePath) - if (!packSpiderFile) return [] - - return packSpiderFile.provideDiagnostics() - } - getConnectedFiles(filePath: string) { - const packSpiderFile = this.packSpiderFiles.get(filePath) - if (!packSpiderFile) return [] - - return [...packSpiderFile.loadAllConnected()] - } - - async updateFile(filePath: string) { - // TODO(Dash): Re-enable pack spider - // await File.create(filePath, this, true) - } -} - -export interface IPackSpiderFileOptions { - lightningStore: LightningStore - filePath: string - fileType: string - instructions: IPackSpiderInstruction -} -export class PackSpiderFile { - protected lightningStore: LightningStore - protected filePath: string - protected fileType: string - protected instructions: IPackSpiderInstruction - protected dependencies = new Map>() - - constructor({ - lightningStore, - filePath, - fileType, - instructions, - }: IPackSpiderFileOptions) { - this.lightningStore = lightningStore - this.filePath = filePath - this.fileType = fileType - this.instructions = instructions - } - - get cacheData() { - return this.lightningStore.get(this.filePath).data ?? {} - } - - loadAllConnected() { - return new Set([ - ...this.loadConnected(), - ...this.loadDirectReferences(), - ]) - } - - /** - * Load direct references from the cache to files such as loot table or texture path - * @returns Set - */ - protected loadDirectReferences() { - return new Set( - this.instructions.includeFiles - ?.map((cacheKey) => { - if (cacheKey) return this.cacheData[cacheKey] ?? [] - }) - .flat() ?? [] - ) - } - - /** - * Load files that can be connected to this file via a matching cache value - * Examples: Client entity identifier <-> server entity identifier - * @param connect Instructions on which files to connect - * @returns Set - */ - protected loadConnected(connect = this.instructions.connect) { - const connectedFiles = new Set() - - for (let { find, where, matches, shouldFindMultiple } of connect ?? - []) { - if (!Array.isArray(find)) find = [find] - const matchesOneOf = this.cacheData[matches] ?? [] - - const foundFilePaths = this.lightningStore.findMultiple( - find, - where, - matchesOneOf, - shouldFindMultiple ?? true - ) - for (const foundFilePath of foundFilePaths) { - if (foundFilePath !== this.filePath) - connectedFiles.add(foundFilePath) - } - } - - return connectedFiles - } - - provideDiagnostics() { - const diagnostics: IFileDiagnostic[] = [] - - for (let { if: ifCondition, ...diagnostic } of this.instructions - .provideDiagnostics ?? []) { - const matches = this.loadConnected([ifCondition]) - - if (ifCondition.not) { - if (matches.size > 0) continue - - diagnostics.push(diagnostic) - } else { - if (matches.size === 0) continue - - diagnostics.push(diagnostic) - } - } - - return diagnostics - } - - addDependency(fileType: string, filePath: string) { - if (!this.dependencies.has(fileType)) - this.dependencies.set(fileType, new Set(filePath)) - else this.dependencies.get(fileType)!.add(filePath) - } -} - -// export class File { -// protected identifier?: string -// protected parents = new Set() -// protected _connectedFiles: Set - -// constructor(public readonly filePath: string, parents: File[] = []) { -// parents.forEach((parent) => this.addParent(parent)) - -// this._connectedFiles = new Set() -// } -// get connectedFiles() { -// return this._connectedFiles -// } - -// static async create( -// filePath: string, -// packSpider: PackSpider, -// forceUpdate = false -// ) { -// const fileType = packSpider.packIndexer.fileType.getId(filePath) -// const { packPath: packType } = ( -// packSpider.packIndexer.packType.get(filePath) -// ) ?? { packPath: 'unknown' } - -// const storedFile = fileStore[packType]?.[fileType]?.[filePath] -// if (storedFile !== undefined) { -// if (!forceUpdate) return storedFile -// else -// storedFile.connectedFiles.forEach((file) => -// file.removeParent(storedFile) -// ) -// } - -// const packSpiderFile = packSpider.packSpiderFiles[fileType] ?? {} - -// // Load cache data of current file -// const { data: cacheData = {} } = packSpider.lightningStore.get( -// filePath, -// fileType -// ) - -// const connectedFiles: string[] = [] -// // Directly referenced files (includeFiles) -// const cacheKeysToInclude = packSpiderFile.includeFiles -// ?.map((cacheKey) => { -// if (cacheKey) return cacheData[cacheKey] ?? [] -// }) -// .flat() ?? [] -// for (const foundFilePath of cacheKeysToInclude) { -// if (foundFilePath !== filePath) connectedFiles.push(foundFilePath) -// } - -// // Dynamically referenced files (connect) -// for (let { -// find, -// where, -// matches, -// shouldFindMultiple, -// } of packSpiderFile.connect ?? []) { -// if (!Array.isArray(find)) find = [find] -// const matchesOneOf = cacheData[matches] ?? [] - -// const foundFilePaths = packSpider.lightningStore.findMultiple( -// find, -// where, -// matchesOneOf, -// shouldFindMultiple ?? true -// ) -// for (const foundFilePath of foundFilePaths) { -// if (foundFilePath !== filePath) -// connectedFiles.push(foundFilePath) -// } -// } - -// // Shared files (sharedFiles) -// for (const foundFilePath of packSpiderFile.sharedFiles ?? []) { -// if (foundFilePath !== filePath) connectedFiles.push(foundFilePath) -// } - -// // include from files (includeFromFiles) -// for (const { -// from, -// take, -// prefix = '', -// suffix = '', -// } of packSpiderFile.includeFromFiles ?? []) { -// const transformedTake = take -// .map((t) => { -// if (!t.startsWith('@')) return t - -// const data = cacheData[t.slice(1)] -// if (!data) return 'undefined' -// else if (data.length === 1) return data[0] - -// return `*{${data.join('|')}}` -// }) -// .join('/') -// const json = await packSpider.packIndexer.fileSystem.readJSON(from) - -// walkObject(transformedTake, json, (data) => { -// if (Array.isArray(data)) -// connectedFiles.push( -// ...data.map((d) => `${prefix}${d}${suffix}`) -// ) -// else if (typeof data === 'string') -// connectedFiles.push(`${prefix}${data}${suffix}`) -// }) -// } - -// const file = new File( -// filePath, -// forceUpdate ? [...(storedFile?.parents ?? [])] : undefined -// ) -// await file.addConnectedFiles(connectedFiles, packSpider) -// file.setIdentifier(cacheData.identifier) - -// if (!fileStore[packType]) fileStore[packType] = {} -// if (!fileStore[packType][fileType]) fileStore[packType][fileType] = {} -// fileStore[packType][fileType][filePath] = file -// return fileStore[packType][fileType][filePath] -// } - -// async addConnectedFiles(filePaths: string[], packSpider: PackSpider) { -// for (const filePath of filePaths) { -// const file = await File.create(filePath, packSpider) -// this.connectedFiles.add(file) -// file.addParent(this) -// } -// } - -// toDirectory() { -// const dirFiles: IFile[] = [] -// const deepFiles = this.deepConnectedFiles -// deepFiles.add(this) - -// deepFiles.forEach((file) => -// dirFiles.push({ -// kind: 'file', -// name: file.fileName, -// filePath: file.filePath, -// identifierName: file.identifierName, -// displayName: file.identifierName || file.fileName, -// }) -// ) - -// return dirFiles -// } -// get deepConnectedFiles() { -// const deepFiles = new Set() - -// this.connectedFiles.forEach((file) => { -// deepFiles.add(file) -// file.deepConnectedFiles.forEach((deepFile) => -// deepFiles.add(deepFile) -// ) -// }) - -// return deepFiles -// } -// get identifierName() { -// return this.identifier -// } -// get fileName() { -// const arrPath = this.filePath.split('/') -// return arrPath[arrPath.length - 1] -// } -// get isFeatureFolder() { -// return this.parents.size === 0 -// } - -// addParent(parent: File) { -// this.parents.add(parent) -// } -// removeParent(parent: File) { -// this.parents.delete(parent) -// } - -// setIdentifier(id?: string[] | string) { -// if (typeof id === 'string') this.identifier = id -// if (id?.length === 1) { -// this.identifier = id[0] -// return -// } - -// const path = this.filePath.split('/') - -// // Top level files should still be handled correctly -// if (path.length <= 2) { -// this.identifier = this.fileName -// } else { -// path.shift() -// path.shift() -// this.identifier = path.join('/') -// } -// } -// } diff --git a/src/components/Projects/CreateProject/CreateFile.vue b/src/components/Projects/CreateProject/CreateFile.vue deleted file mode 100644 index 427237458..000000000 --- a/src/components/Projects/CreateProject/CreateFile.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/src/components/Projects/CreateProject/CreateProject.ts b/src/components/Projects/CreateProject/CreateProject.ts deleted file mode 100644 index 9f82b387e..000000000 --- a/src/components/Projects/CreateProject/CreateProject.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { App } from '/@/App' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import CreateProjectComponent from './CreateProject.vue' -import { CreatePack } from './Packs/Pack' -import { CreateBP } from './Packs/BP' -import { CreateRP } from './Packs/RP' -import { CreateSP } from './Packs/SP' -import { CreateBridge } from './Packs/Bridge' -import { IPackType, TPackTypeId } from '/@/components/Data/PackType' -import { CreateGitIgnore } from './Files/GitIgnore' -import { CreateWT } from './Packs/WT' -import { CreateConfig } from './Files/Config' -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { exportAsBrproject } from '../Export/AsBrproject' -import { settingsState } from '/@/components/Windows/Settings/SettingsState' -import { CreateWorlds } from './Packs/worlds' -import { - getFormatVersions, - getStableFormatVersion, -} from '/@/components/Data/FormatVersions' -import { CreateDenoConfig } from './Files/DenoConfig' -import { IWindowState, NewBaseWindow } from '../../Windows/NewBaseWindow' -import { reactive } from 'vue' - -export interface ICreateProjectOptions { - author: string | string[] - description: string - icon: File | null - name: string - namespace: string - targetVersion: string - packs: (TPackTypeId | '.bridge')[] - rpAsBpDependency: boolean - bpAsRpDependency: boolean - uuids: { - resources?: string - data?: string - skin_pack?: string - world_template?: string - } - useLangForManifest: boolean - experimentalGameplay: Record - bdsProject: boolean -} -export interface IExperimentalToggle { - name: string - id: string - description: string -} - -export interface ICreateProjectState extends IWindowState { - isCreatingProject: boolean - createOptions: ICreateProjectOptions - availableTargetVersionsLoading: boolean -} -export class CreateProjectWindow extends NewBaseWindow { - protected availableTargetVersions: string[] = [] - protected stableVersion: string = '' - protected packs: Record = < - const - >{ - behaviorPack: new CreateBP(), - resourcePack: new CreateRP(), - skinPack: new CreateSP(), - worldTemplate: new CreateWT(), - '.bridge': new CreateBridge(), - worlds: new CreateWorlds(), - } - protected availablePackTypes: IPackType[] = [] - protected createFiles = [ - new CreateGitIgnore(), - new CreateConfig(), - new CreateDenoConfig(), - ] - protected experimentalToggles: IExperimentalToggle[] = [] - protected projectNameRules = [ - (val: string) => - val.match(/"|\\|\/|:|\||<|>|\*|\?|~/g) === null || - 'windows.createProject.projectName.invalidLetters', - (val: string) => - !val.endsWith('.') || - 'windows.createProject.projectName.endsInPeriod', - (val: string) => - val.trim() !== '' || - 'windows.createProject.projectName.mustNotBeEmpty', - ] - - protected state: ICreateProjectState = reactive({ - ...super.getState(), - isCreatingProject: false, - createOptions: this.getDefaultOptions(), - availableTargetVersionsLoading: true, - }) - - get createOptions() { - return this.state.createOptions - } - - get packCreateFiles() { - return Object.entries(this.packs) - .map(([packId, pack]) => { - if ( - !this.createOptions.packs.includes( - packId - ) - ) - return [] - return pack.createFiles.map((createFile) => - Object.assign(createFile, { packId }) - ) - }) - .flat() - .filter((createFile) => createFile.isConfigurable) - } - - constructor() { - super(CreateProjectComponent, false) - this.defineWindow() - - App.ready.once(async (app) => { - await app.dataLoader.fired - this.availableTargetVersions = await getFormatVersions() - // Set default version - this.stableVersion = await getStableFormatVersion(app.dataLoader) - this.createOptions.targetVersion = this.stableVersion - this.state.availableTargetVersionsLoading = false - - this.experimentalToggles = await app.dataLoader.readJSON( - 'data/packages/minecraftBedrock/experimentalGameplay.json' - ) - this.createOptions.experimentalGameplay = Object.fromEntries( - this.experimentalToggles.map((toggle) => [toggle.id, false]) - ) - }) - - App.packType.ready.once(() => { - this.availablePackTypes = App.packType.all - }) - } - - get hasRequiredData() { - return ( - this.createOptions.packs.length > 1 && - this.projectNameRules.every( - (rule) => rule(this.createOptions.name) === true - ) && - this.createOptions.namespace.length > 0 && - this.createOptions.author.length > 0 && - this.createOptions.targetVersion.length > 0 - ) - } - - open() { - this.state.createOptions = this.getDefaultOptions() - - this.packCreateFiles.forEach( - (createFile) => (createFile.isActive = true) - ) - - super.open() - } - - async createProject() { - const app = await App.getApp() - - const removeOldProject = - isUsingFileSystemPolyfill.value && !app.hasNoProjects - - // Save previous project name to delete it later - let previousProject = app.isNoProjectSelected - ? app.projects[0] - : app.project - - // Ask user whether we should save the current project - if (removeOldProject) { - const confirmWindow = new ConfirmationWindow({ - description: 'windows.createProject.saveCurrentProject', - cancelText: 'general.no', - confirmText: 'general.yes', - }) - if (await confirmWindow.fired) { - await exportAsBrproject(previousProject?.name) - } - } - - const fs = app.fileSystem - const projectDir = await fs.getDirectoryHandle( - `projects/${this.createOptions.name}`, - { create: true } - ) - const scopedFs = new FileSystem(projectDir) - - // Create individual files without a pack - for (const createFile of this.createFiles) { - await createFile.create(scopedFs, this.createOptions) - } - - // We need to ensure that we create the RP before the BP in order to link it up correctly inside of the BP manifest - // if the user chose the corresponding option. This line sorts packs in reverse alphabetical order to achieve that - const reversePacks = this.createOptions.packs.sort((a, b) => - b.localeCompare(a) - ) - // Create the different packs - for (const pack of reversePacks) { - await this.packs[pack].create(scopedFs, this.createOptions) - } - - await app.projectManager.addProject( - projectDir, - true, - app.bridgeFolderSetup.hasFired - ) - await app.extensionLoader.installFilesToCurrentProject() - - if (removeOldProject) - await app.projectManager.removeProject(previousProject!) - - // Reset options - this.state.createOptions = this.getDefaultOptions() - } - - static getDefaultOptions(): ICreateProjectOptions { - return { - author: - settingsState?.projects?.defaultAuthor === - '' - ? 'bridge.' - : ( - settingsState?.projects?.defaultAuthor - ) ?? 'bridge.', - description: '', - icon: null, - name: 'New Project', - namespace: 'bridge', - targetVersion: '', - packs: ['.bridge', 'behaviorPack', 'resourcePack'], - rpAsBpDependency: false, - bpAsRpDependency: false, - useLangForManifest: false, - experimentalGameplay: {}, - uuids: {}, - bdsProject: false, - } - } - getDefaultOptions(): ICreateProjectOptions { - return { - ...CreateProjectWindow.getDefaultOptions(), - targetVersion: this.availableTargetVersions - ? this.stableVersion - : '', - experimentalGameplay: this.experimentalToggles - ? Object.fromEntries( - this.experimentalToggles.map((toggle) => [ - toggle.id, - false, - ]) - ) - : {}, - } - } - - static async loadFromConfig(): Promise { - const app = await App.getApp() - - let config: any - try { - config = await app.project.fileSystem.readJSON('config.json') - - return { - author: config.author ?? '', - description: config.description ?? '', - icon: null, - name: config.name ?? '', - namespace: config.namespace ?? 'bridge', - targetVersion: config.targetVersion ?? '', - packs: Object.values(config.packs) ?? [ - '.bridge', - 'behaviorPack', - 'resourcePack', - ], - useLangForManifest: false, - rpAsBpDependency: false, - bpAsRpDependency: false, - experimentalGameplay: config.experimentalGameplay ?? {}, - uuids: {}, - bdsProject: false, - } - } catch { - return this.getDefaultOptions() - } - } -} diff --git a/src/components/Projects/CreateProject/CreateProject.vue b/src/components/Projects/CreateProject/CreateProject.vue deleted file mode 100644 index aeb08bd0f..000000000 --- a/src/components/Projects/CreateProject/CreateProject.vue +++ /dev/null @@ -1,333 +0,0 @@ - - - - - diff --git a/src/components/Projects/CreateProject/ExperimentalGameplay.vue b/src/components/Projects/CreateProject/ExperimentalGameplay.vue deleted file mode 100644 index 4bfc3ae29..000000000 --- a/src/components/Projects/CreateProject/ExperimentalGameplay.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - - - diff --git a/src/components/Projects/CreateProject/Files/BP/CreateTick.ts b/src/components/Projects/CreateProject/Files/BP/CreateTick.ts deleted file mode 100644 index 090e2504a..000000000 --- a/src/components/Projects/CreateProject/Files/BP/CreateTick.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateTick extends CreateFile { - public readonly id = 'tick' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.writeJSON(`BP/functions/tick.json`, { values: [] }, true) - } -} diff --git a/src/components/Projects/CreateProject/Files/BP/GameTest.ts b/src/components/Projects/CreateProject/Files/BP/GameTest.ts deleted file mode 100644 index 17e3b5b12..000000000 --- a/src/components/Projects/CreateProject/Files/BP/GameTest.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateGameTestMain extends CreateFile { - public readonly id = 'gameTestMain' - public isConfigurable = false - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - if (createOptions.experimentalGameplay.enableGameTestFramework) { - await fs.mkdir('BP/scripts', { recursive: true }) - await fs.writeFile('BP/scripts/main.js', '') - } - } -} diff --git a/src/components/Projects/CreateProject/Files/BP/Player.ts b/src/components/Projects/CreateProject/Files/BP/Player.ts deleted file mode 100644 index 620773170..000000000 --- a/src/components/Projects/CreateProject/Files/BP/Player.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { App } from '/@/App' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { CreateFile } from '../CreateFile' - -export class CreatePlayer extends CreateFile { - public readonly id = 'player' - - async create(fs: FileSystem) { - const app = await App.getApp() - await app.dataLoader.fired - const defaultPlayer = await app.dataLoader.readJSON( - 'data/packages/minecraftBedrock/vanilla/player.json' - ) - - await fs.writeJSON('BP/entities/player.json', defaultPlayer, true) - await fs.writeFile('BP/loot_tables/empty.json', '{}') - } -} diff --git a/src/components/Projects/CreateProject/Files/Bridge/Compiler.ts b/src/components/Projects/CreateProject/Files/Bridge/Compiler.ts deleted file mode 100644 index 2baf9c0b0..000000000 --- a/src/components/Projects/CreateProject/Files/Bridge/Compiler.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateCompilerConfig extends CreateFile { - public readonly id = 'compilerConfig' - public isConfigurable = false - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.mkdir('.bridge/compiler') - // Default compiler config moved to project config - } -} diff --git a/src/components/Projects/CreateProject/Files/Config.ts b/src/components/Projects/CreateProject/Files/Config.ts deleted file mode 100644 index 62457bbb9..000000000 --- a/src/components/Projects/CreateProject/Files/Config.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from './CreateFile' -import { TPackTypeId } from '/@/components/Data/PackType' -import { defaultPackPaths } from '../../Project/Config' - -export class CreateConfig extends CreateFile { - public readonly id = 'bridgeConfig' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.writeJSON( - `config.json`, - { - type: 'minecraftBedrock', - name: createOptions.name, - namespace: createOptions.namespace, - authors: Array.isArray(createOptions.author) - ? createOptions.author - : [createOptions.author], - targetVersion: createOptions.targetVersion, - description: createOptions.description, - experimentalGameplay: createOptions.experimentalGameplay, - bdsProject: createOptions.bdsProject, - packs: Object.fromEntries( - createOptions.packs - .filter((packId) => packId !== '.bridge') - .map((packId) => [ - packId, - defaultPackPaths[packId], - ]) - ), - worlds: ['./worlds/*'], - compiler: { - plugins: [ - 'generatorScripts', - 'typeScript', - 'entityIdentifierAlias', - 'customEntityComponents', - 'customItemComponents', - 'customBlockComponents', - 'customCommands', - 'moLang', - 'formatVersionCorrection', - [ - 'simpleRewrite', - { - packName: createOptions.name, - }, - ], - ], - }, - }, - true - ) - } -} diff --git a/src/components/Projects/CreateProject/Files/CreateFile.ts b/src/components/Projects/CreateProject/Files/CreateFile.ts deleted file mode 100644 index 3696b8943..000000000 --- a/src/components/Projects/CreateProject/Files/CreateFile.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '../CreateProject' - -export abstract class CreateFile { - public abstract id: string - public icon = 'mdi-file-outline' - public isActive = true - public isConfigurable = true - - abstract create( - fs: FileSystem, - projectOptions: ICreateProjectOptions - ): Promise | any -} diff --git a/src/components/Projects/CreateProject/Files/DenoConfig.ts b/src/components/Projects/CreateProject/Files/DenoConfig.ts deleted file mode 100644 index 293f2d6de..000000000 --- a/src/components/Projects/CreateProject/Files/DenoConfig.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from './CreateFile' - -export class CreateDenoConfig extends CreateFile { - public readonly id = 'denoConfig' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.writeJSON( - `deno.json`, - { - tasks: { - install_dash: - 'deno install -A --reload -f -n dash_compiler https://raw.githubusercontent.com/bridge-core/deno-dash-compiler/main/mod.ts', - watch: 'dash_compiler build --mode development && dash_compiler watch', - build: 'dash_compiler build', - }, - }, - true - ) - } -} diff --git a/src/components/Projects/CreateProject/Files/GitIgnore.ts b/src/components/Projects/CreateProject/Files/GitIgnore.ts deleted file mode 100644 index b04cbd3c5..000000000 --- a/src/components/Projects/CreateProject/Files/GitIgnore.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from './CreateFile' - -export class CreateGitIgnore extends CreateFile { - public readonly id = 'gitignore' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.writeFile( - `.gitignore`, - `Desktop.ini -.DS_Store -!.bridge/ -.bridge/* -!.bridge/compiler/ -!.bridge/extensions -builds` - ) - } -} diff --git a/src/components/Projects/CreateProject/Files/Lang.ts b/src/components/Projects/CreateProject/Files/Lang.ts deleted file mode 100644 index 29ea11ce9..000000000 --- a/src/components/Projects/CreateProject/Files/Lang.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { TPackType } from '/@/components/Projects/CreateProject/Packs/Pack' -import { CreateFile } from './CreateFile' - -export class CreateLang extends CreateFile { - public readonly id = 'lang' - public isConfigurable = false - - constructor(protected packPath: TPackType) { - super() - } - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - if (!(this.packPath === 'BP' && createOptions.useLangForManifest)) { - await fs.mkdir(`${this.packPath}/texts`) - await fs.writeFile( - `${this.packPath}/texts/en_US.lang`, - createOptions.useLangForManifest - ? '' - : `pack.name=${createOptions.name} ${this.packPath}\npack.description=${createOptions.description}` - ) - await fs.writeJSON( - `${this.packPath}/texts/languages.json`, - ['en_US'], - true - ) - } - } -} diff --git a/src/components/Projects/CreateProject/Files/Manifest.ts b/src/components/Projects/CreateProject/Files/Manifest.ts deleted file mode 100644 index 654aebac5..000000000 --- a/src/components/Projects/CreateProject/Files/Manifest.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { TPackType } from '/@/components/Projects/CreateProject/Packs/Pack' -import { CreateFile } from './CreateFile' -import { v4 as uuid } from 'uuid' -import { version as appVersion } from '/@/utils/app/version' -import { App } from '/@/App' -import { dashVersion } from '/@/utils/app/dashVersion' -import { compareVersions } from 'bridge-common-utils' - -export class CreateManifest extends CreateFile { - public readonly id = 'packManifest' - public isConfigurable = false - - constructor(protected pack: TPackType) { - super() - } - - get type() { - switch (this.pack) { - case 'BP': - return 'data' - case 'RP': - return 'resources' - case 'SP': - return 'skin_pack' - case 'WT': - return 'world_template' - } - } - - protected async transformTargetVersion(targetVersion: string) { - const app = await App.getApp() - const replaceTargetVersion: Record = - await app.dataLoader.readJSON( - 'data/packages/minecraftBedrock/minEngineVersionMap.json' - ) - - return replaceTargetVersion[targetVersion] ?? targetVersion - } - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - // Set uuids for packs - if (createOptions.packs.includes('behaviorPack')) - createOptions.uuids.data ??= uuid() - if (createOptions.packs.includes('resourcePack')) - createOptions.uuids.resources ??= uuid() - if (createOptions.packs.includes('skinPack')) - createOptions.uuids.skin_pack ??= uuid() - if (createOptions.packs.includes('worldTemplate')) - createOptions.uuids.world_template ??= uuid() - - // Base manifest - const manifest: any = { - format_version: 2, - metadata: { - authors: Array.isArray(createOptions.author) - ? createOptions.author - : [createOptions.author], - generated_with: { - bridge: [appVersion], - dash: [dashVersion], - }, - }, - header: { - name: createOptions.useLangForManifest - ? createOptions.name - : 'pack.name', - description: createOptions.useLangForManifest - ? createOptions.description - : 'pack.description', - min_engine_version: - this.type === 'data' || 'resources' - ? ( - await this.transformTargetVersion( - createOptions.targetVersion - ) - ) - .split('.') - .map((str) => Number(str)) - : undefined, - uuid: createOptions.uuids[this.type ?? 'data'] ?? uuid(), - version: [1, 0, 0], - }, - modules: [ - { - type: this.type, - uuid: uuid(), - version: [1, 0, 0], - }, - ], - } - - // Register the resource pack as a dependency of the BP - if ( - createOptions.rpAsBpDependency && - createOptions.packs.includes('resourcePack') && - this.type === 'data' - ) { - if (!createOptions.uuids.resources) - throw new Error( - 'Trying to register RP uuid before it was defined' - ) - - manifest.dependencies = [ - { uuid: createOptions.uuids.resources, version: [1, 0, 0] }, - ] - } - // Register the behavior pack as a dependency of the RP - if ( - createOptions.bpAsRpDependency && - createOptions.packs.includes('behaviorPack') && - this.type === 'resources' - ) { - if (!createOptions.uuids.data) - throw new Error( - 'Trying to register RP uuid before it was defined' - ) - - manifest.dependencies = [ - { uuid: createOptions.uuids.data, version: [1, 0, 0] }, - ] - } - - // GameTest - if ( - this.type === 'data' && - createOptions.experimentalGameplay.enableGameTestFramework - ) { - // Add module to enable GameTest in the project, make sure to add the correct format by version. - if (compareVersions(createOptions.targetVersion, '1.19.0', '>=')) - manifest.modules.push({ - type: 'script', - language: 'javascript', - uuid: uuid(), - entry: 'scripts/main.js', - version: [1, 0, 0], - }) - else - manifest.modules.push({ - type: 'javascript', - uuid: uuid(), - entry: 'scripts/main.js', - version: [1, 0, 0], - }) - - // Add the necessary GameTest dependencies to the manifest - manifest.dependencies ??= [] - // New 1.19.30+ format of dependencies - if (compareVersions(createOptions.targetVersion, '1.19.30', '>=')) { - if ( - compareVersions( - createOptions.targetVersion, - '1.19.40', - '>=' - ) - ) { - manifest.dependencies.push( - { - module_name: '@minecraft/server', - version: '1.0.0-beta', - }, - { - module_name: '@minecraft/server-gametest', - version: '1.0.0-beta', - }, - { - module_name: '@minecraft/server-ui', - version: '1.0.0-beta', - } - ) - if (createOptions.bdsProject) - manifest.dependencies.push( - { - module_name: '@minecraft/server-admin', - version: '1.0.0-beta', - }, - { - module_name: '@minecraft/server-net', - version: '1.0.0-beta', - } - ) - } else { - manifest.dependencies.push( - { - module_name: 'mojang-minecraft', - version: '1.0.0-beta', - }, - { - module_name: 'mojang-gametest', - version: '1.0.0-beta', - }, - { - module_name: 'mojang-minecraft-ui', - version: '1.0.0-beta', - } - ) - if (createOptions.bdsProject) - manifest.dependencies.push( - { - module_name: 'mojang-minecraft-server-admin', - version: '1.0.0-beta', - }, - { - module_name: 'mojang-net', - version: '1.0.0-beta', - } - ) - } - } else { - // Old dependency format - manifest.dependencies.push( - { - // 'mojang-minecraft' module - uuid: 'b26a4d4c-afdf-4690-88f8-931846312678', - version: [0, 1, 0], - }, - { - // 'mojang-gametest' module - uuid: '6f4b6893-1bb6-42fd-b458-7fa3d0c89616', - version: [0, 1, 0], - } - ) - if ( - compareVersions( - createOptions.targetVersion, - '1.18.20', - '>=' - ) - ) - manifest.dependencies.push({ - // 'mojang-minecraft-ui' module - uuid: '2bd50a27-ab5f-4f40-a596-3641627c635e', - version: [0, 1, 0], - }) - if ( - compareVersions( - createOptions.targetVersion, - '1.19.0', - '>=' - ) && - createOptions.bdsProject - ) - manifest.dependencies.push( - { - // 'mojang-minecraft-server-admin' module - uuid: '53d7f2bf-bf9c-49c4-ad1f-7c803d947920', - version: [0, 1, 0], - }, - { - // 'mojang-net' module - uuid: '777b1798-13a6-401c-9cba-0cf17e31a81b', - version: [0, 1, 0], - } - ) - } - } - - if (this.type === 'world_template') { - manifest.header.lock_template_options = true - manifest.header.base_game_version = createOptions.targetVersion - .split('.') - .map((str) => Number(str)) - } - - await fs.writeJSON(`${this.pack}/manifest.json`, manifest, true) - } -} diff --git a/src/components/Projects/CreateProject/Files/PackIcon.ts b/src/components/Projects/CreateProject/Files/PackIcon.ts deleted file mode 100644 index 361bcb799..000000000 --- a/src/components/Projects/CreateProject/Files/PackIcon.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { TPackType } from '/@/components/Projects/CreateProject/Packs/Pack' -import { CreateFile } from './CreateFile' -import { App } from '/@/App' -import { VirtualFile } from '/@/components/FileSystem/Virtual/File' - -export class CreatePackIcon extends CreateFile { - public readonly id = 'packIcon' - public isConfigurable = false - - constructor(protected packPath: TPackType) { - super() - } - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - let icon: File | VirtualFile | null = createOptions.icon - if (!icon) { - const app = await App.getApp() - await app.dataLoader.fired - icon = await app.dataLoader.readFile( - `data/packages/common/packIcon.png` - ) - } - - await fs.writeFile( - `${this.packPath}/pack_icon.png`, - icon.isVirtual ? await icon.toBlobFile() : icon - ) - } -} diff --git a/src/components/Projects/CreateProject/Files/RP/BiomesClient.ts b/src/components/Projects/CreateProject/Files/RP/BiomesClient.ts deleted file mode 100644 index 98504b2eb..000000000 --- a/src/components/Projects/CreateProject/Files/RP/BiomesClient.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateBiomesClient extends CreateFile { - public readonly id = 'biomesClient' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.writeJSON( - `RP/biomes_client.json`, - { - biomes: {}, - }, - true - ) - } -} diff --git a/src/components/Projects/CreateProject/Files/RP/Blocks.ts b/src/components/Projects/CreateProject/Files/RP/Blocks.ts deleted file mode 100644 index e08ea50b5..000000000 --- a/src/components/Projects/CreateProject/Files/RP/Blocks.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateBlocks extends CreateFile { - public readonly id = 'blocks' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.writeJSON( - `RP/blocks.json`, - { - format_version: [1, 1, 0], - }, - true - ) - } -} diff --git a/src/components/Projects/CreateProject/Files/RP/FlipbookTextures.ts b/src/components/Projects/CreateProject/Files/RP/FlipbookTextures.ts deleted file mode 100644 index 7245c56cd..000000000 --- a/src/components/Projects/CreateProject/Files/RP/FlipbookTextures.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateFlipbookTextures extends CreateFile { - public readonly id = 'flipbookTextures' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.mkdir('RP/textures', { recursive: true }) - - await fs.writeJSON(`RP/textures/flipbook_textures.json`, [], true) - } -} diff --git a/src/components/Projects/CreateProject/Files/RP/ItemTexture.ts b/src/components/Projects/CreateProject/Files/RP/ItemTexture.ts deleted file mode 100644 index a97992479..000000000 --- a/src/components/Projects/CreateProject/Files/RP/ItemTexture.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateItemTexture extends CreateFile { - public readonly id = 'itemTexture' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.mkdir('RP/textures', { recursive: true }) - - await fs.writeJSON( - `RP/textures/item_texture.json`, - { - resource_pack_name: createOptions.name, - texture_name: 'atlas.items', - texture_data: {}, - }, - true - ) - } -} diff --git a/src/components/Projects/CreateProject/Files/RP/SoundDefinitions.ts b/src/components/Projects/CreateProject/Files/RP/SoundDefinitions.ts deleted file mode 100644 index 95f31c9ea..000000000 --- a/src/components/Projects/CreateProject/Files/RP/SoundDefinitions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateSoundDefintions extends CreateFile { - public readonly id = 'soundDefinitions' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.mkdir('RP/sounds', { recursive: true }) - await fs.writeJSON( - `RP/sounds/sound_definitions.json`, - { - format_version: '1.14.0', - sound_definitions: {}, - }, - true - ) - } -} diff --git a/src/components/Projects/CreateProject/Files/RP/Sounds.ts b/src/components/Projects/CreateProject/Files/RP/Sounds.ts deleted file mode 100644 index f6e9ab01a..000000000 --- a/src/components/Projects/CreateProject/Files/RP/Sounds.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateSounds extends CreateFile { - public readonly id = 'sounds' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.writeJSON(`RP/sounds.json`, {}, true) - } -} diff --git a/src/components/Projects/CreateProject/Files/RP/TerrainTexture.ts b/src/components/Projects/CreateProject/Files/RP/TerrainTexture.ts deleted file mode 100644 index 875978e53..000000000 --- a/src/components/Projects/CreateProject/Files/RP/TerrainTexture.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateTerrainTexture extends CreateFile { - public readonly id = 'terrainTexture' - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.mkdir('RP/textures', { recursive: true }) - - await fs.writeJSON( - `RP/textures/terrain_texture.json`, - { - num_mip_levels: 4, - padding: 8, - resource_pack_name: createOptions.name, - texture_name: 'atlas.terrain', - texture_data: {}, - }, - true - ) - } -} diff --git a/src/components/Projects/CreateProject/Files/SP/Lang.ts b/src/components/Projects/CreateProject/Files/SP/Lang.ts deleted file mode 100644 index d3d7db362..000000000 --- a/src/components/Projects/CreateProject/Files/SP/Lang.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { FileSystem } from "/@/components/FileSystem/FileSystem"; -import { ICreateProjectOptions } from "/@/components/Projects/CreateProject/CreateProject"; -import { TPackType } from "/@/components/Projects/CreateProject/Packs/Pack"; -import { CreateFile } from "../CreateFile"; - -export class CreateLang extends CreateFile { - public readonly id = 'lang' - public isConfigurable = false - - constructor(protected packPath: TPackType) { - super() - } - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.mkdir(`${this.packPath}/texts`) - await fs.writeFile( - `${this.packPath}/texts/en_US.lang`, - createOptions.useLangForManifest - ? '' - : `skinpack.${createOptions.namespace}=${createOptions.name}` - ) - await fs.writeJSON( - `${this.packPath}/texts/languages.json`, - ['en_US'], - true - ) - } -} \ No newline at end of file diff --git a/src/components/Projects/CreateProject/Files/SP/Skins.ts b/src/components/Projects/CreateProject/Files/SP/Skins.ts deleted file mode 100644 index 57e75cade..000000000 --- a/src/components/Projects/CreateProject/Files/SP/Skins.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '/@/components/Projects/CreateProject/CreateProject' -import { CreateFile } from '../CreateFile' - -export class CreateSkins extends CreateFile { - public readonly id = 'skins' - - create(fs: FileSystem, createOptions: ICreateProjectOptions) { - return fs.writeJSON( - `SP/skins.json`, - { - geometry: 'skinpacks/skins.json', - skins: [], - serialize_name: createOptions.namespace, - localization_name: createOptions.namespace, - }, - true - ) - } -} diff --git a/src/components/Projects/CreateProject/Packs/BP.ts b/src/components/Projects/CreateProject/Packs/BP.ts deleted file mode 100644 index 5f8e34277..000000000 --- a/src/components/Projects/CreateProject/Packs/BP.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CreatePack } from './Pack' -import { CreateManifest } from '../Files/Manifest' -import { CreateLang } from '../Files/Lang' -import { CreatePackIcon } from '../Files/PackIcon' -import { CreateTick } from '../Files/BP/CreateTick' -import { CreateGameTestMain } from '../Files/BP/GameTest' -import { CreatePlayer } from '../Files/BP/Player' - -export class CreateBP extends CreatePack { - protected readonly packPath = 'BP' - public createFiles = [ - new CreateManifest(this.packPath), - new CreateLang(this.packPath), - new CreatePackIcon(this.packPath), - new CreateTick(), - new CreateGameTestMain(), - new CreatePlayer(), - ] -} diff --git a/src/components/Projects/CreateProject/Packs/Bridge.ts b/src/components/Projects/CreateProject/Packs/Bridge.ts deleted file mode 100644 index 3df84c816..000000000 --- a/src/components/Projects/CreateProject/Packs/Bridge.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CreateCompilerConfig } from '../Files/Bridge/Compiler' -import { CreatePack } from './Pack' - -export class CreateBridge extends CreatePack { - protected readonly packPath = '.bridge' - public createFiles = [new CreateCompilerConfig()] -} diff --git a/src/components/Projects/CreateProject/Packs/Pack.ts b/src/components/Projects/CreateProject/Packs/Pack.ts deleted file mode 100644 index 6278c7a55..000000000 --- a/src/components/Projects/CreateProject/Packs/Pack.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { ICreateProjectOptions } from '../CreateProject' -import { CreateFile } from '../Files/CreateFile' - -export type TPackType = 'BP' | 'RP' | 'SP' | 'WT' | '.bridge' | 'worlds' - -export abstract class CreatePack { - protected abstract packPath: TPackType - public abstract createFiles: CreateFile[] - - async create(fs: FileSystem, createOptions: ICreateProjectOptions) { - await fs.mkdir(this.packPath) - for (const createFile of this.createFiles) { - if (!createFile.isActive) continue - - await createFile.create(fs, createOptions) - } - } -} diff --git a/src/components/Projects/CreateProject/Packs/RP.ts b/src/components/Projects/CreateProject/Packs/RP.ts deleted file mode 100644 index a1d7f6fdb..000000000 --- a/src/components/Projects/CreateProject/Packs/RP.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CreatePack } from './Pack' -import { CreateManifest } from '../Files/Manifest' -import { CreateLang } from '../Files/Lang' -import { CreatePackIcon } from '../Files/PackIcon' -import { CreateBlocks } from '../Files/RP/Blocks' -import { CreateItemTexture } from '../Files/RP/ItemTexture' -import { CreateTerrainTexture } from '../Files/RP/TerrainTexture' -import { CreateFlipbookTextures } from '../Files/RP/FlipbookTextures' -import { CreateBiomesClient } from '../Files/RP/BiomesClient' -import { CreateSounds } from '../Files/RP/Sounds' -import { CreateSoundDefintions } from '../Files/RP/SoundDefinitions' - -export class CreateRP extends CreatePack { - protected readonly packPath = 'RP' - public createFiles = [ - new CreateManifest(this.packPath), - new CreateLang(this.packPath), - new CreatePackIcon(this.packPath), - new CreateBlocks(), - new CreateItemTexture(), - new CreateTerrainTexture(), - new CreateFlipbookTextures(), - new CreateBiomesClient(), - new CreateSounds(), - new CreateSoundDefintions(), - ] -} diff --git a/src/components/Projects/CreateProject/Packs/SP.ts b/src/components/Projects/CreateProject/Packs/SP.ts deleted file mode 100644 index 54d7da8e5..000000000 --- a/src/components/Projects/CreateProject/Packs/SP.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CreatePack } from './Pack' -import { CreateManifest } from '../Files/Manifest' -import { CreateLang } from '../Files/SP/Lang' -import { CreatePackIcon } from '../Files/PackIcon' -import { CreateSkins } from '../Files/SP/Skins' - -export class CreateSP extends CreatePack { - protected readonly packPath = 'SP' - public createFiles = [ - new CreateManifest(this.packPath), - new CreateLang(this.packPath), - new CreatePackIcon(this.packPath), - new CreateSkins(), - ] -} diff --git a/src/components/Projects/CreateProject/Packs/WT.ts b/src/components/Projects/CreateProject/Packs/WT.ts deleted file mode 100644 index c10a2cc20..000000000 --- a/src/components/Projects/CreateProject/Packs/WT.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CreateManifest } from '../Files/Manifest' -import { CreatePack } from './Pack' - -export class CreateWT extends CreatePack { - protected readonly packPath = 'WT' - public createFiles = [new CreateManifest(this.packPath)] -} diff --git a/src/components/Projects/CreateProject/Packs/worlds.ts b/src/components/Projects/CreateProject/Packs/worlds.ts deleted file mode 100644 index b4b62752b..000000000 --- a/src/components/Projects/CreateProject/Packs/worlds.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CreatePack } from './Pack' - -export class CreateWorlds extends CreatePack { - protected readonly packPath = 'worlds' - public createFiles = [] -} diff --git a/src/components/Projects/Export/AsBrproject.ts b/src/components/Projects/Export/AsBrproject.ts deleted file mode 100644 index bba8a6c3c..000000000 --- a/src/components/Projects/Export/AsBrproject.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { saveOrDownload } from '/@/components/FileSystem/saveOrDownload' -import { ZipDirectory } from '/@/components/FileSystem/Zip/ZipDirectory' -import { App } from '/@/App' -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' - -export async function exportAsBrproject(name?: string) { - const app = App.instance - app.windows.loadingWindow.open() - - const savePath = `${app.project.projectPath}/builds/${ - name ?? app.project.name - }.brproject` - - /** - * .brproject files come in two variants: - * - Complete global package including the data/ & extensions/ folder for browsers using the file system polyfill - * - Package only including the project files (no data/ & extensions/) for other browsers - * - * The global package variant is deprecated now because we persist settings and extensions in the browser's local storage - */ - const zipFolder = new ZipDirectory(app.project.baseDirectory) - - const zipFile = await zipFolder.package(new Set(['builds'])) - - try { - await saveOrDownload(savePath, zipFile, app.fileSystem) - } catch (err) { - console.error(err) - } - - app.windows.loadingWindow.close() -} diff --git a/src/components/Projects/Export/AsMcaddon.ts b/src/components/Projects/Export/AsMcaddon.ts deleted file mode 100644 index 2b8903eb5..000000000 --- a/src/components/Projects/Export/AsMcaddon.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - isUsingFileSystemPolyfill, - isUsingOriginPrivateFs, -} from '/@/components/FileSystem/Polyfill' -import { saveOrDownload } from '/@/components/FileSystem/saveOrDownload' -import { ZipDirectory } from '/@/components/FileSystem/Zip/ZipDirectory' -import { App } from '/@/App' -import { settingsState } from '/@/components/Windows/Settings/SettingsState' - -export async function exportAsMcaddon() { - const app = await App.getApp() - app.windows.loadingWindow.open() - - // Automatically increment manifest versions (by default only active if using a file system polyfill but can be manually turned on/off inside of the settings) - // This allows user to simply import the file into Minecraft even if the same pack - // with a lower version number is already installed - if ( - settingsState?.projects?.incrementVersionOnExport ?? - (isUsingOriginPrivateFs || isUsingFileSystemPolyfill.value) - ) { - const fs = app.fileSystem - - let manifests: Record = {} - - for (const pack of app.project.getPacks()) { - const manifestPath = app.project.config.resolvePackPath( - pack, - 'manifest.json' - ) - - if (await fs.fileExists(manifestPath)) { - let manifest - try { - manifest = (await fs.readJSON(manifestPath)) ?? {} - } catch { - continue - } - - const [major, minor, patch] = <[number, number, number]>( - manifest.header?.version - ) ?? [0, 0, 0] - - // Increment patch version - const newVersion = [major, minor, patch + 1] - - manifests[manifestPath] = { - ...manifest, - header: { - ...(manifest.header ?? {}), - version: newVersion, - }, - } - } - } - - // Update manifest dependency versions - const allManifests = Object.values(manifests) - for (const manifest of allManifests) { - if (!Array.isArray(manifest.dependencies)) continue - - manifest.dependencies.forEach((dep: any) => { - const depManifest = allManifests.find( - (manifest) => manifest.header.uuid === dep.uuid - ) - if (!depManifest) return - - dep.version = depManifest.header.version - }) - } - - // Write all manifest changes back to disk - for (const [path, manifest] of Object.entries(manifests)) { - await fs.writeJSON(path, manifest, true) - } - } - - const service = await app.project.createDashService('production') - await service.build() - - const zipFolder = new ZipDirectory( - await app.project.fileSystem.getDirectoryHandle('builds/dist', { - create: true, - }) - ) - const savePath = app.project.config.resolvePackPath( - undefined, - `builds/${app.project.name}.mcaddon` - ) - - try { - await saveOrDownload( - savePath, - await zipFolder.package(), - app.fileSystem - ) - } catch (err) { - console.error(err) - } - - app.windows.loadingWindow.close() -} diff --git a/src/components/Projects/Export/AsMctemplate.ts b/src/components/Projects/Export/AsMctemplate.ts deleted file mode 100644 index f25bbec4d..000000000 --- a/src/components/Projects/Export/AsMctemplate.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { DropdownWindow } from '/@/components/Windows/Common/Dropdown/DropdownWindow' -import { App } from '/@/App' -import { ZipDirectory } from '/@/components/FileSystem/Zip/ZipDirectory' -import { saveOrDownload } from '/@/components/FileSystem/saveOrDownload' -import { v4 as uuid } from 'uuid' -import { getLatestFormatVersion } from '../../Data/FormatVersions' - -export async function exportAsMctemplate(asMcworld = false) { - const app = await App.getApp() - const project = app.project - const fs = project.fileSystem - app.windows.loadingWindow.open() - - const service = await app.project.createDashService('production') - await service.build() - - let baseWorlds: string[] = [] - - if (project.hasPacks(['worldTemplate'])) baseWorlds.push('WT') - if (await fs.directoryExists('worlds')) - baseWorlds.push( - ...(await fs.readdir('worlds')).map((world) => `worlds/${world}`) - ) - - let exportWorldFolder: string - // No world to package - if (baseWorlds.length === 0) { - app.windows.loadingWindow.close() - return - } else if (baseWorlds.length === 1) { - exportWorldFolder = baseWorlds[0] - } else { - const optionsWindow = new DropdownWindow({ - default: baseWorlds[0], - name: 'packExplorer.exportAsMctemplate.chooseWorld', - options: baseWorlds, - }) - - exportWorldFolder = await optionsWindow.fired - } - - await fs.mkdir(`builds/mctemplate/behavior_packs`, { - recursive: true, - }) - await fs.mkdir(`builds/mctemplate/resource_packs`, { - recursive: true, - }) - - // Find out BP, RP & WT folders - const packs = await fs.readdir('builds/dist') - const packLocations = < - { [pack in 'WT' | 'BP' | 'RP']: string | undefined } - >Object.fromEntries( - Object.entries({ - BP: packs.find((pack) => pack.endsWith('BP')), - RP: packs.find((pack) => pack.endsWith('RP')), - WT: packs.find((pack) => pack.endsWith('WT')), - }).map(([pack, packPath]) => [pack, `builds/dist/${packPath}`]) - ) - - // Copy world folder into builds/mctemplate - if (exportWorldFolder === 'WT') { - await fs.move(packLocations.WT!, `builds/mctemplate`) - } else { - await fs.copyFolder(exportWorldFolder, `builds/mctemplate`) - } - - // Generate world_behavior_packs.json - if (packLocations.BP) { - const bpManifest = await fs - .readJSON(`${packLocations.BP}/manifest.json`) - .catch(() => null) - - if ( - bpManifest !== null && - bpManifest?.header?.uuid && - bpManifest?.header?.version - ) { - await fs.writeJSON('builds/mctemplate/world_behavior_packs.json', [ - { - pack_id: bpManifest.header.uuid, - version: bpManifest.header.version, - }, - ]) - } - } - - // Generate world_resource_packs.json - if (packLocations.RP) { - const rpManifest = await fs - .readJSON(`${packLocations.RP}/manifest.json`) - .catch(() => null) - - if ( - rpManifest !== null && - rpManifest?.header?.uuid && - rpManifest?.header?.version - ) { - await fs.writeJSON('builds/mctemplate/world_resource_packs.json', [ - { - pack_id: rpManifest.header.uuid, - version: rpManifest.header.version, - }, - ]) - } - } - - // Move BP & RP into behavior_packs/resource_packs - if (packLocations.BP) - await fs.move( - packLocations.BP, - `builds/mctemplate/behavior_packs/BP_${app.project.name}` - ) - if (packLocations.RP) - await fs.move( - packLocations.RP, - `builds/mctemplate/resource_packs/RP_${app.project.name}` - ) - - // Generate world template manifest if file doesn't exist yet - if ( - !(await fs.fileExists('builds/mctemplate/manifest.json')) && - !asMcworld - ) { - await fs.writeJSON('builds/mctemplate/manifest.json', { - format_version: 2, - header: { - name: 'pack.name', - description: 'pack.description', - version: [1, 0, 0], - uuid: uuid(), - lock_template_options: true, - base_game_version: ( - app.project.config.get().targetVersion ?? - (await getLatestFormatVersion()) - ) - .split('.') - .map((str) => Number(str)), - }, - modules: [ - { - type: 'world_template', - version: [1, 0, 0], - uuid: uuid(), - }, - ], - }) - } else if (asMcworld && exportWorldFolder === 'WT') { - await fs.unlink('builds/mctemplate/manifest.json') - } - - // ZIP builds/mctemplate folder - const zipFolder = new ZipDirectory( - await app.project.fileSystem.getDirectoryHandle('builds/mctemplate', { - create: true, - }) - ) - const savePath = `${app.project.projectPath}/builds/${app.project.name}.${ - asMcworld ? 'mcworld' : 'mctemplate' - }` - - try { - await saveOrDownload( - savePath, - await zipFolder.package(), - app.fileSystem - ) - } catch (err) { - console.error(err) - } - - // Delete builds/mctemplate folder - await fs.unlink(`builds/mctemplate`) - - project.app.windows.loadingWindow.close() -} - -export async function canExportMctemplate() { - const app = await App.getApp() - return ( - app.project.hasPacks(['worldTemplate']) || - ((await app.project.fileSystem.directoryExists('worlds')) && - (await app.project.fileSystem.readdir('worlds')).length > 0) - ) -} diff --git a/src/components/Projects/Export/Extensions/Exporter.ts b/src/components/Projects/Export/Extensions/Exporter.ts deleted file mode 100644 index 06fcaaba9..000000000 --- a/src/components/Projects/Export/Extensions/Exporter.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface IExporter { - icon: string - name: string - isDisabled?: () => Promise | boolean - export: () => Promise -} - -export class Exporter { - constructor(protected config: IExporter) {} - - get displayData() { - return { - icon: this.config.icon, - name: this.config.name, - } - } - - isDisabled() { - if (typeof this.config.isDisabled === 'function') - return this.config.isDisabled() - } - - export() { - if (typeof this.config.export === 'function') - return this.config.export() - } -} diff --git a/src/components/Projects/Export/Extensions/Provider.ts b/src/components/Projects/Export/Extensions/Provider.ts deleted file mode 100644 index 4c645801a..000000000 --- a/src/components/Projects/Export/Extensions/Provider.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Exporter } from './Exporter' -import { IActionConfig } from '/@/components/Actions/SimpleAction' - -/** - * A class that stores exporters registered by extensions - */ -export class ExportProvider { - protected exports = new Set() - - /** - * Register an exporter - * - * @param exporter An exporter provided by an extension - * @returns a disposable that unregisters the exporter - */ - public register(exporter: Exporter) { - this.exports.add(exporter) - - return { - dispose: () => this.exports.delete(exporter), - } - } - - public async getExporters(): Promise { - return await Promise.all( - [...this.exports].map(async (exporter) => ({ - ...exporter.displayData, - isDisabled: await exporter.isDisabled(), - onTrigger: () => exporter.export(), - })) - ) - } -} diff --git a/src/components/Projects/Import/ImportNew.ts b/src/components/Projects/Import/ImportNew.ts deleted file mode 100644 index bde6c2ff3..000000000 --- a/src/components/Projects/Import/ImportNew.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { InformationWindow } from '/@/components/Windows/Common/Information/InformationWindow' -import { importFromBrproject } from './fromBrproject' -import { importFromMcaddon } from './fromMcaddon' -import { AnyFileHandle } from '/@/components/FileSystem/Types' - -export async function importNewProject() { - // Prompt user to select new project to open - let projectHandle: AnyFileHandle - try { - ;[projectHandle] = await window.showOpenFilePicker({ - multiple: false, - types: [ - { - description: 'Choose a Project', - accept: { - 'application/zip': ['.brproject', '.mcaddon'], - }, - }, - ], - }) - } catch { - // User aborted selecting new project - return - } - - const projectName = projectHandle.name - - if (projectName.endsWith('.brproject')) { - await importFromBrproject(projectHandle) - } else if (projectName.endsWith('.mcaddon')) { - await importFromMcaddon(projectHandle) - } else { - new InformationWindow({ - description: 'windows.projectChooser.wrongFileType', - }) - } -} diff --git a/src/components/Projects/Import/fromBrproject.ts b/src/components/Projects/Import/fromBrproject.ts deleted file mode 100644 index 946cf8321..000000000 --- a/src/components/Projects/Import/fromBrproject.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { SettingsWindow } from '/@/components/Windows/Settings/SettingsWindow' -import { exportAsBrproject } from '../Export/AsBrproject' -import { App } from '/@/App' -import { basename } from '/@/utils/path' -import { Project } from '../Project/Project' -import { LocaleManager } from '../../Locales/Manager' -import { findSuitableFolderName } from '/@/utils/directory/findSuitableName' -import { StreamingUnzipper } from '../../FileSystem/Zip/StreamingUnzipper' - -export async function importFromBrproject( - fileHandle: AnyFileHandle, - unzip = true -) { - const app = await App.getApp() - const fs = app.fileSystem - await fs.unlink('import') - const tmpHandle = await fs.getDirectoryHandle('import', { - create: true, - }) - - if (!app.isNoProjectSelected) await app.projectManager.projectReady.fired - - // Unzip .brproject file, do not unzip if already unzipped - if (unzip) { - const unzipper = new StreamingUnzipper(tmpHandle) - const file = await fileHandle.getFile() - const data = new Uint8Array(await file.arrayBuffer()) - unzipper.createTask(app.taskManager) - await unzipper.unzip(data) - } - let importFrom = 'import' - - if (!(await fs.fileExists('import/config.json'))) { - // The .brproject file contains data/, projects/ & extensions/ folder - // We need to change the folder structure to process it correctly - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value - ) { - // Only load settings & extension if using the polyfill - try { - await fs.move('import/data', 'data') - } catch {} - try { - await fs.move('import/extensions', 'extensions') - } catch {} - - // Reload settings & extensions - await SettingsWindow.loadSettings(app) - await app.extensionLoader.reload() - LocaleManager.setDefaultLanguage() - } - - // Get project from projects/ folder - try { - const [projectName] = await fs.readdir('import/projects') - importFrom = `import/projects/${projectName}` - } catch { - return - } - } - - // Ask user whether he wants to save the current project if we are going to delete it later in the import process - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value && - !app.hasNoProjects - ) { - const confirmWindow = new ConfirmationWindow({ - description: - 'windows.projectChooser.openNewProject.saveCurrentProject', - cancelText: 'general.no', - confirmText: 'general.yes', - }) - if (await confirmWindow.fired) { - await exportAsBrproject() - } - } - - const projectName = await findSuitableFolderName( - basename(fileHandle.name, '.brproject'), - await fs.getDirectoryHandle('projects') - ) - - // Get the new project path - const importProject = - importFrom === 'import' - ? `projects/${projectName}` - : importFrom.replace('import/', '') - // Move imported project to the user's project directory - await fs.move(importFrom, importProject) - - // Get current project name - let currentProject: Project | undefined - if (!app.hasNoProjects) currentProject = app.project - - // Remove old project if browser is using fileSystem polyfill - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value && - !app.hasNoProjects - ) - await app.projectManager.removeProject(currentProject!) - - // Add new project - await app.projectManager.addProject( - await fs.getDirectoryHandle(importProject), - true - ) - - await fs.unlink('import') -} diff --git a/src/components/Projects/Import/fromMcaddon.ts b/src/components/Projects/Import/fromMcaddon.ts deleted file mode 100644 index 1d4300496..000000000 --- a/src/components/Projects/Import/fromMcaddon.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { App } from '/@/App' -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { StreamingUnzipper } from '/@/components/FileSystem/Zip/StreamingUnzipper' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { exportAsBrproject } from '../Export/AsBrproject' -import { TPackTypeId } from '/@/components/Data/PackType' -import { CreateProjectWindow } from '../CreateProject/CreateProject' -import { getLatestFormatVersion } from '/@/components/Data/FormatVersions' -import { CreateConfig } from '../CreateProject/Files/Config' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { defaultPackPaths } from '../Project/Config' -import { InformationWindow } from '../../Windows/Common/Information/InformationWindow' -import { basename, join } from '/@/utils/path' -import { getPackId, IManifestModule } from '/@/utils/manifest/getPackId' -import { findSuitableFolderName } from '/@/utils/directory/findSuitableName' - -export async function importFromMcaddon( - fileHandle: AnyFileHandle, - unzip = true -) { - const app = await App.getApp() - const fs = app.fileSystem - const tmpHandle = await fs.getDirectoryHandle('import', { - create: true, - }) - - if (!app.isNoProjectSelected) await app.projectManager.projectReady.fired - - // Unzip .mcaddon file - if (unzip) { - const unzipper = new StreamingUnzipper(tmpHandle) - const file = await fileHandle.getFile() - const data = new Uint8Array(await file.arrayBuffer()) - unzipper.createTask(app.taskManager) - await unzipper.unzip(data) - } - const projectName = await findSuitableFolderName( - fileHandle.name.replace('.mcaddon', '').replace('.zip', ''), - await fs.getDirectoryHandle('projects') - ) - - // Ask user whether they want to save the current project if we are going to delete it later in the import process - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value && - !app.hasNoProjects - ) { - const confirmWindow = new ConfirmationWindow({ - description: - 'windows.projectChooser.openNewProject.saveCurrentProject', - cancelText: 'general.no', - confirmText: 'general.yes', - }) - if (await confirmWindow.fired) { - await exportAsBrproject() - } - } - - let authors: string[] | string | undefined - let description: string | undefined - const packs: (TPackTypeId | '.bridge')[] = ['.bridge'] - - // 1. Unpack all .mcpack files - for await (const pack of tmpHandle.values()) { - if (pack.kind === 'file' && pack.name.endsWith('.mcpack')) { - const directory = await tmpHandle.getDirectoryHandle( - basename(pack.name, '.mcpack'), - { - create: true, - } - ) - - const unzipper = new StreamingUnzipper(directory) - const file = await pack.getFile() - const data = new Uint8Array(await file.arrayBuffer()) - unzipper.createTask(app.taskManager) - await unzipper.unzip(data) - - await tmpHandle.removeEntry(pack.name) - } - } - - // 2. Process all packs - for await (const pack of tmpHandle.values()) { - if ( - pack.kind === 'directory' && - (await fs.fileExists(`import/${pack.name}/manifest.json`)) - ) { - const manifest = await fs.readJSON( - `import/${pack.name}/manifest.json` - ) - const modules = manifest?.modules ?? [] - if (!authors) authors = manifest?.metadata?.authors - if (!description) description = manifest?.header?.description - - const packId = getPackId(modules) - if (!packId) return - - packs.push(packId) - const packPath = defaultPackPaths[packId] - - // Move the pack to the correct location - await fs.move( - `import/${pack.name}`, - join('projects', projectName, packPath) - ) - } - } - if (packs.length === 1) - new InformationWindow({ - description: 'fileDropper.mcaddon.missingManifests', - }) - - const defaultOptions = CreateProjectWindow.getDefaultOptions() - defaultOptions.name = projectName - defaultOptions.author = authors ?? ['Unknown'] - defaultOptions.description = description ?? '' - defaultOptions.packs = packs - defaultOptions.targetVersion = await getLatestFormatVersion() - await new CreateConfig().create( - new FileSystem( - await fs.getDirectoryHandle(`projects/${projectName}`, { - create: true, - }) - ), - defaultOptions - ) - - await fs.mkdir(`projects/${projectName}/.bridge/extensions`) - await fs.mkdir(`projects/${projectName}/.bridge/compiler`) - - // Remove old project if browser is using fileSystem polyfill - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value && - !app.hasNoProjects - ) - await app.projectManager.removeProject(app.project) - - // Add new project - await app.projectManager.addProject( - await fs.getDirectoryHandle(`projects/${projectName}`), - true - ) - - await fs.unlink('import') -} diff --git a/src/components/Projects/Import/fromMcpack.ts b/src/components/Projects/Import/fromMcpack.ts deleted file mode 100644 index 5a68e7781..000000000 --- a/src/components/Projects/Import/fromMcpack.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { App } from '/@/App' -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { StreamingUnzipper } from '/@/components/FileSystem/Zip/StreamingUnzipper' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { exportAsBrproject } from '../Export/AsBrproject' -import { TPackTypeId } from '/@/components/Data/PackType' -import { CreateProjectWindow } from '../CreateProject/CreateProject' -import { getLatestFormatVersion } from '/@/components/Data/FormatVersions' -import { CreateConfig } from '../CreateProject/Files/Config' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { defaultPackPaths } from '../Project/Config' -import { InformationWindow } from '../../Windows/Common/Information/InformationWindow' -import { getPackId, IManifestModule } from '/@/utils/manifest/getPackId' -import { findSuitableFolderName } from '/@/utils/directory/findSuitableName' -import { join } from '/@/utils/path' - -export async function importFromMcpack( - fileHandle: AnyFileHandle, - unzip = true -) { - const app = await App.getApp() - const fs = app.fileSystem - const tmpHandle = await fs.getDirectoryHandle('import', { - create: true, - }) - - if (!app.isNoProjectSelected) await app.projectManager.projectReady.fired - - // Unzip .mcpack file - if (unzip) { - const unzipper = new StreamingUnzipper(tmpHandle) - const file = await fileHandle.getFile() - const data = new Uint8Array(await file.arrayBuffer()) - unzipper.createTask(app.taskManager) - await unzipper.unzip(data) - } - // Make sure that we don't replace an existing project - const projectName = await findSuitableFolderName( - fileHandle.name.replace('.mcpack', '').replace('.zip', ''), - await fs.getDirectoryHandle('projects') - ) - - // Ask user whether they want to save the current project if we are going to delete it later in the import process - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value && - !app.hasNoProjects - ) { - const confirmWindow = new ConfirmationWindow({ - description: - 'windows.projectChooser.openNewProject.saveCurrentProject', - cancelText: 'general.no', - confirmText: 'general.yes', - }) - if (await confirmWindow.fired) { - await exportAsBrproject() - } - } - - let authors: string[] | string | undefined - let description: string | undefined - const packs: (TPackTypeId | '.bridge')[] = ['.bridge'] - - // 1. Process pack - if (await fs.fileExists(`import/manifest.json`)) { - const manifest = await fs.readJSON(`import/manifest.json`) - const modules = manifest?.modules ?? [] - if (!authors) authors = manifest?.metadata?.authors - if (!description) description = manifest?.header?.description - - const packId = getPackId(modules) - if (!packId) return - - packs.push(packId) - const packPath = defaultPackPaths[packId] - // Move the pack to the correct location - await fs.move(`import`, join('projects', projectName, packPath)) - } else { - new InformationWindow({ - description: 'fileDropper.mcaddon.missingManifests', - }) - } - - const defaultOptions = CreateProjectWindow.getDefaultOptions() - defaultOptions.name = projectName - defaultOptions.author = authors ?? ['Unknown'] - defaultOptions.description = description ?? '' - defaultOptions.packs = packs - defaultOptions.targetVersion = await getLatestFormatVersion() - await new CreateConfig().create( - new FileSystem( - await fs.getDirectoryHandle(`projects/${projectName}`, { - create: true, - }) - ), - defaultOptions - ) - - await fs.mkdir(`projects/${projectName}/.bridge/extensions`) - await fs.mkdir(`projects/${projectName}/.bridge/compiler`) - - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value && - !app.hasNoProjects - ) - // Remove old project if browser is using fileSystem polyfill - await app.projectManager.removeProject(app.project) - - // Add new project - await app.projectManager.addProject( - await fs.getDirectoryHandle(`projects/${projectName}`), - true - ) - - await fs.unlink('import') -} diff --git a/src/components/Projects/Project/BedrockProject.ts b/src/components/Projects/Project/BedrockProject.ts deleted file mode 100644 index 673c34c09..000000000 --- a/src/components/Projects/Project/BedrockProject.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Project } from './Project' -import { ITabPreviewConfig } from '/@/components/TabSystem/TabActions/Provider' - -import { createFromGeometry } from '/@/components/Editors/EntityModel/create/fromGeometry' -import { createFromClientEntity } from '/@/components/Editors/EntityModel/create/fromClientEntity' -import { createFromEntity } from '/@/components/Editors/EntityModel/create/fromEntity' -import { ParticlePreviewTab } from '/@/components/Editors/ParticlePreview/ParticlePreview' -import { BlockModelTab } from '/@/components/Editors/BlockModel/Tab' -import { CommandData } from '/@/components/Languages/Mcfunction/Data' -// import { WorldTab } from '/@/components/BedrockWorlds/Render/Tab' -import { FileTab } from '../../TabSystem/FileTab' -import { HTMLPreviewTab } from '../../Editors/HTMLPreview/HTMLPreview' -import { LangData } from '/@/components/Languages/Lang/Data' -import { ColorData } from '../../Languages/Json/ColorPicker/Data' - -const bedrockPreviews: ITabPreviewConfig[] = [ - { - name: 'preview.viewModel', - fileType: 'geometry', - createPreview: (tabSystem, tab) => createFromGeometry(tabSystem, tab), - }, - { - name: 'preview.viewModel', - fileType: 'clientEntity', - createPreview: (tabSystem, tab) => - createFromClientEntity(tabSystem, tab), - }, - { - name: 'preview.viewEntity', - fileType: 'entity', - createPreview: (tabSystem, tab) => createFromEntity(tabSystem, tab), - }, - { - name: 'preview.viewParticle', - fileType: 'particle', - createPreview: async (tabSystem, tab) => - new ParticlePreviewTab(tab, tabSystem), - }, - { - name: 'preview.viewBlock', - fileType: 'block', - createPreview: async (tabSystem, tab) => { - const previewTab = new BlockModelTab(tab.getPath(), tab, tabSystem) - previewTab.setPreviewOptions({ loadComponents: true }) - - return previewTab - }, - }, - /*{ - name: 'preview.simulateLoot', - fileType: 'lootTable', - createPreview: async (tabSystem, tab) => - new LootTableSimulatorTab(tab, tabSystem), - },*/ -] - -export class BedrockProject extends Project { - commandData = new CommandData() - langData = new LangData() - colorData = new ColorData() - - onCreate() { - bedrockPreviews.forEach((tabPreview) => - this.tabActionProvider.registerPreview(tabPreview) - ) - - this.tabActionProvider.register({ - name: 'preview.name', - icon: 'mdi-play', - isFor: (tab) => { - return ( - tab instanceof FileTab && - tab.getFileHandle().name.endsWith('.html') - ) - }, - trigger: (tab) => { - const inactiveTabSystem = this.app.project.inactiveTabSystem - if (!inactiveTabSystem) return - - inactiveTabSystem.add( - new HTMLPreviewTab(inactiveTabSystem, { - filePath: tab.getPath(), - fileHandle: tab.getFileHandle(), - }) - ) - inactiveTabSystem.setActive(true) - }, - }) - - this.commandData.loadCommandData('minecraftBedrock') - this.langData.loadLangData('minecraftBedrock') - this.colorData.loadColorData() - } - - getCurrentDataPackage() { - return this.app.dataLoader.getDirectoryHandle( - `data/packages/minecraftBedrock` - ) - } -} diff --git a/src/components/Projects/Project/Config.ts b/src/components/Projects/Project/Config.ts deleted file mode 100644 index 0639f006e..000000000 --- a/src/components/Projects/Project/Config.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { IExperimentalToggle } from '../CreateProject/CreateProject' -import type { Project } from './Project' -import { - defaultPackPaths, - IConfigJson, - ProjectConfig as BaseProjectConfig, -} from 'mc-project-core' - -export type { IConfigJson } from 'mc-project-core' -export { defaultPackPaths } from 'mc-project-core' - -/** - * Internal config format versions - * - * Format version data: - * [bridge. v2.2.8] Unset or "0": Projects may not contain the formatVersionCorrection compiler plugin - * [bridge. v2.2.9] "1": Configs were upgraded to add the plugin - */ - -export const latestFormatVersion = 1 - -export class ProjectConfig extends BaseProjectConfig { - constructor( - protected fileSystem: FileSystem, - projectPath: string, - protected project?: Project - ) { - super(projectPath) - - if (project) { - project.fileSave.on( - this.resolvePackPath(undefined, 'config.json'), - () => { - this.refreshConfig() - this.project!.app.windows.createPreset.onPresetsChanged() - this.project!.compilerService.reloadPlugins() - } - ) - } - } - - readConfig() { - return this.fileSystem.readJSON(`config.json`).catch(() => ({})) - } - async writeConfig(config: Partial) { - await this.fileSystem.writeJSON(`config.json`, config, true) - } - - async setup(upgradeConfig = true) { - // Load legacy project config & transform it to new format specified here: https://github.com/bridge-core/project-config-standard - if ( - upgradeConfig && - (await this.fileSystem.fileExists('.bridge/config.json')) - ) { - const { - darkTheme, - lightTheme, - gameTestAPI, - scriptingAPI, - prefix, - ...other - } = await this.fileSystem.readJSON('.bridge/config.json') - await this.fileSystem.unlink('.bridge/config.json') - - const experimentalGameplay: Record = {} - if (gameTestAPI) - experimentalGameplay['enableGameTestFramework'] = true - if (scriptingAPI) - experimentalGameplay['additionalModdingCapabilities'] = true - - const newFormat = { - type: 'minecraftBedrock', - name: this.fileSystem.baseDirectory.name, - namespace: prefix, - experimentalGameplay, - ...other, - authors: other.author ? [other.author] : ['Unknown'], - packs: defaultPackPaths, - bridge: { - darkTheme, - lightTheme, - }, - } - - await this.fileSystem.writeJSON('config.json', newFormat, true) - this.data = newFormat - - return - } - - await super.setup() - - const formatVersion = this.data.bridge?.formatVersion ?? 0 - if (!this.data.bridge) this.data.bridge = {} - let updatedConfig = false - - // Running in main thread, so we can use the App object - if (upgradeConfig && this.project && this.data.capabilities) { - // Transform old "capabilities" format to "experimentalGameplay" - const experimentalToggles: IExperimentalToggle[] = - await this.project.app.dataLoader.readJSON( - 'data/packages/minecraftBedrock/experimentalGameplay.json' - ) - const experimentalGameplay: Record = - this.data.experimentalGameplay ?? {} - const capabilities: string[] = this.data.capabilities ?? [] - - // Update scripting API/GameTest API toggles based on the old "capabilities" field - experimentalGameplay['enableGameTestFramework'] = - capabilities.includes('gameTestAPI') - experimentalGameplay['additionalModdingCapabilities'] = - capabilities.includes('scriptingAPI') - - for (const toggle of experimentalToggles) { - // Set all missing experimental toggles to true by default - experimentalGameplay[toggle.id] ??= true - } - this.data.experimentalGameplay = experimentalGameplay - this.data.capabilities = undefined - updatedConfig = true - } - - // Support reading from old "author" field - if (upgradeConfig && this.data.author && !this.data.authors) { - this.data.authors = - typeof this.data.author === 'string' - ? [this.data.author] - : this.data.author - this.data.author = undefined - updatedConfig = true - } - - if ( - upgradeConfig && - (await this.fileSystem.fileExists('.bridge/compiler/default.json')) - ) { - const compilerConfig = await this.fileSystem.readJSON( - '.bridge/compiler/default.json' - ) - this.data.compiler = { plugins: compilerConfig.plugins } - await this.fileSystem.unlink('.bridge/compiler/default.json') - updatedConfig = true - } - - if ( - upgradeConfig && - formatVersion === 0 && - this.data.compiler?.plugins && - !this.data.compiler.plugins.includes('formatVersionCorrection') - ) { - this.data.bridge.formatVersion = 1 - this.data.compiler.plugins.push('formatVersionCorrection') - updatedConfig = true - } - - if (updatedConfig) await this.writeConfig(this.data) - } - - async getWorldHandles() { - if (!this.project) - throw new Error( - `Cannot use getWorldHandles() within web workers yet` - ) - - const app = this.project.app - const globPatterns = (this.get().worlds ?? ['./worlds/*']).map((glob) => - this.resolvePackPath(undefined, glob) - ) - - return ( - await Promise.all( - globPatterns.map((glob) => - app.fileSystem.getDirectoryHandlesFromGlob(glob) - ) - ) - ).flat() - } - - getAuthorImage() { - const author = <{ logo: string; name: string } | undefined>( - this.get().authors?.find( - (author) => typeof author !== 'string' && author.logo - ) - ) - if (!author) return - - return this.resolvePackPath(undefined, author.logo) - } - - async toggleExperiment(project: Project, experiment: string) { - project.app.windows.loadingWindow.open() - - const experimentalGameplay = this.get()?.experimentalGameplay ?? {} - // Modify experimental gameplay - experimentalGameplay[experiment] = !experimentalGameplay[experiment] - // Save config - await this.save() - - // Only refresh project if it's active - if (project.isActiveProject) await project.refresh() - this.project!.app.windows.createPreset.onPresetsChanged() - - project.app.windows.loadingWindow.close() - } -} diff --git a/src/components/Projects/Project/FileChangeRegistry.ts b/src/components/Projects/Project/FileChangeRegistry.ts deleted file mode 100644 index 7cef355cb..000000000 --- a/src/components/Projects/Project/FileChangeRegistry.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { VirtualFile } from '../../FileSystem/Virtual/File' -import { EventSystem } from '/@/components/Common/Event/EventSystem' - -export class FileChangeRegistry extends EventSystem { - constructor() { - super([], true) - } - dispatch(name: string, fileContent: T) { - // We always want to dispatch the event for "any" file changed - this.any.dispatch([name, fileContent]) - - if (this.hasEvent(name)) { - // Specific events only get triggered when a listener is registered already - super.dispatch(name, fileContent) - } - } - on(name: string, listener: (data: T) => void) { - if (!this.hasEvent(name)) this.create(name) - - return super.on(name, listener) - } -} diff --git a/src/components/Projects/Project/Project.ts b/src/components/Projects/Project/Project.ts deleted file mode 100644 index c28010525..000000000 --- a/src/components/Projects/Project/Project.ts +++ /dev/null @@ -1,645 +0,0 @@ -import { App } from '/@/App' -import { IOpenTabOptions, TabSystem } from '/@/components/TabSystem/TabSystem' -import { TPackTypeId } from '/@/components/Data/PackType' -import { ProjectConfig, IConfigJson } from './Config' -import { RecentFiles } from '../RecentFiles' -import { loadIcon } from './loadIcon' -import { IPackData, loadPacks } from './loadPacks' -import { PackIndexer } from '/@/components/PackIndexer/PackIndexer' -import { ProjectManager } from '../ProjectManager' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { JsonDefaults } from '/@/components/Data/JSONDefaults' -import { TypeLoader } from '/@/components/Data/TypeLoader' -import { ExtensionLoader } from '/@/components/Extensions/ExtensionLoader' -import { FileChangeRegistry } from './FileChangeRegistry' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { TabActionProvider } from '/@/components/TabSystem/TabActions/Provider' -import { - AnyDirectoryHandle, - AnyFileHandle, - AnyHandle, -} from '/@/components/FileSystem/Types' -import { markRaw, set } from 'vue' -import { SnippetLoader } from '/@/components/Snippets/Loader' -import { ExportProvider } from '../Export/Extensions/Provider' -import { Tab } from '/@/components/TabSystem/CommonTab' -import { getFolderDifference } from '/@/components/TabSystem/Util/FolderDifference' -import { FileTypeLibrary } from '/@/components/Data/FileType' -import { relative } from '/@/utils/path' -import { DashCompiler } from '/@/components/Compiler/Compiler' -import { proxy, Remote } from 'comlink' -import { DashService } from '/@/components/Compiler/Worker/Service' -import { settingsState } from '/@/components/Windows/Settings/SettingsState' -import { isUsingFileSystemPolyfill } from '../../FileSystem/Polyfill' -import { iterateDir } from '/@/utils/iterateDir' -import { Signal } from '../../Common/Event/Signal' -import { moveHandle } from '/@/utils/file/moveHandle' -import { TreeTab } from '../../Editors/TreeEditor/Tab' -import { invoke } from '@tauri-apps/api' - -export interface IProjectData extends IConfigJson { - path: string - name: string - imgSrc: string - contains: IPackData[] -} - -export const virtualProjectName = 'bridge-temp-project' - -export abstract class Project { - public readonly tabSystems: readonly [TabSystem, TabSystem] - protected _projectData!: Partial - // Not directly assigned so they're not responsive - public readonly packIndexer: PackIndexer - protected _fileSystem: FileSystem - public _compilerService?: Remote - public compilerReady = new Signal() - public readonly jsonDefaults = markRaw(new JsonDefaults(this)) - protected typeLoader: TypeLoader - public refreshing: boolean = false - - /** - * A virtual project is a project with the exact name of "virtualProjectName" - * We use it as a placeholder project to skip the previously mandatory bridge folder selection dialog - */ - public readonly isVirtualProject: boolean - public readonly requiresPermissions: boolean - public readonly config: ProjectConfig - public readonly fileTypeLibrary: FileTypeLibrary - public readonly extensionLoader: ExtensionLoader - public readonly fileChange = new FileChangeRegistry() - public readonly beforeFileSave = new FileChangeRegistry() - public readonly fileSave = new FileChangeRegistry() - public readonly fileUnlinked = new FileChangeRegistry() - public readonly tabActionProvider = new TabActionProvider() - public readonly snippetLoader = new SnippetLoader(this) - public readonly exportProvider = new ExportProvider() - - //#region Getters - get projectData() { - return this._projectData - } - get name() { - return this.baseDirectory.name - } - get displayName() { - return this.config.get().name ?? this.name - } - get tabSystem() { - if (this.tabSystems[0].isActive.value) return this.tabSystems[0] - if (this.tabSystems[1].isActive.value) return this.tabSystems[1] - } - get inactiveTabSystem() { - if (!this.tabSystems[0].isActive.value) return this.tabSystems[0] - if (!this.tabSystems[1].isActive.value) return this.tabSystems[1] - } - get baseDirectory() { - return this._baseDirectory - } - get fileSystem() { - return this._fileSystem - } - get isActiveProject() { - return this.name === this.parent.selectedProject - } - get compilerService() { - if (this._compilerService === undefined) { - throw new Error( - `Trying to access compilerService before it was setup. Make sure to await compilerReady.fired before accessing it.` - ) - } - - return this._compilerService - } - protected get watchModeActive() { - /** - * Only update compilation results if the watch mode setting is active, - * the current project is not a virtual project - * ...and the filesystem polyfill is not active (it's also inactive if we're on a Tauri build) - * - * Explanation: - * Devices that need the filesystem polyfill will not be able to export - * the project to the com.mojang folder. This means that the only way to move over the project - * is by exporting to .mcaddon and thus compiling to a non-accessible "builds/dev" folder makes no sense - */ - return ( - (settingsState.compiler?.watchModeActive ?? true) && - !this.isVirtualProject && - (!isUsingFileSystemPolyfill.value || - import.meta.env.VITE_IS_TAURI_APP) - ) - } - get projectPath() { - if (this.requiresPermissions) return `projects/${this.name}` - return `~local/projects/${this.name}` - } - get bpUuid() { - return this.projectData.contains?.find( - (pack) => pack.id === 'behaviorPack' - )?.uuid - } - get isLocal() { - return !this.requiresPermissions - } - //#endregion - - constructor( - protected parent: ProjectManager, - public readonly app: App, - protected _baseDirectory: AnyDirectoryHandle, - { requiresPermissions }: { requiresPermissions?: boolean } = {} - ) { - this.isVirtualProject = virtualProjectName === this.name - this.requiresPermissions = requiresPermissions ?? false - - this._fileSystem = markRaw(new FileSystem(_baseDirectory)) - this.config = markRaw( - new ProjectConfig(this._fileSystem, this.projectPath, this) - ) - this.fileTypeLibrary = markRaw(new FileTypeLibrary(this.config)) - this.packIndexer = markRaw(new PackIndexer(this, _baseDirectory)) - this.extensionLoader = markRaw( - new ExtensionLoader( - app.fileSystem, - `${this.projectPath}/.bridge/extensions`, - `${this.projectPath}/.bridge/inactiveExtensions.json` - ) - ) - this.typeLoader = markRaw(new TypeLoader(this.app.dataLoader)) - - this.fileChange.any.on((data) => - App.eventSystem.dispatch('fileChange', data) - ) - this.beforeFileSave.any.on((data) => { - App.eventSystem.dispatch('beforeFileSave', data) - App.eventSystem.dispatch('beforeModifiedProject', null) - }) - this.fileSave.any.on((data) => { - App.eventSystem.dispatch('fileSave', data) - App.eventSystem.dispatch('modifiedProject', null) - }) - this.fileUnlinked.any.on((data) => - App.eventSystem.dispatch('fileUnlinked', data[0]) - ) - - this.tabSystems = [ - markRaw(new TabSystem(this)), - markRaw(new TabSystem(this, 1)), - ] - - this.createDashService('development').then((service) => { - this._compilerService = markRaw(service) - this.compilerReady.dispatch() - }) - - setTimeout(() => this.onCreate(), 0) - } - - async createDashService( - mode: 'development' | 'production', - compilerConfig?: string - ) { - if (!this.isVirtualProject) await this.app.comMojang.fired - - const compiler = await new DashCompiler() - - await compiler.setup( - this.app.fileSystem.baseDirectory, - this.app.comMojang.hasComMojang - ? this.app.comMojang.fileSystem.baseDirectory - : undefined, - { - config: `${this.projectPath}/config.json`, - compilerConfig, - mode, - projectName: this.name, - pluginFileTypes: this.fileTypeLibrary.getPluginFileTypes(), - } - ) - - compiler.on( - proxy(async () => { - const task = this.app.taskManager.create({ - icon: 'mdi-cogs', - name: 'taskManager.tasks.compiler.title', - description: 'taskManager.tasks.compiler.description', - totalTaskSteps: 100, - }) - - compiler.onProgress( - proxy((percentage) => { - if (percentage === 1) task.complete() - else task.update(100 * percentage) - }) - ) - }), - false - ) - - return compiler - } - - abstract onCreate(): Promise | void - - async activate(isReload = false) { - App.fileType.setProjectConfig(this.config) - App.packType.setProjectConfig(this.config) - this.parent.title.setProject(this.name) - this.parent.activatedProject.dispatch(this) - - await this.fileTypeLibrary.setup(this.app.dataLoader) - // Wait for compilerService to be ready - await this.compilerReady.fired - - if (!isReload) { - for (const tabSystem of this.tabSystems) await tabSystem.activate() - } - - await this.extensionLoader.loadExtensions() - - const selectedTab = this.tabSystem?.selectedTab - this.typeLoader.activate( - selectedTab instanceof FileTab ? selectedTab.getPath() : undefined - ) - - // Data needs to be loaded into IndexedDB before the PackIndexer can be used - await this.app.dataLoader.fired - - await this.packIndexer.activate(isReload) - const [changedFiles, deletedFiles] = await this.packIndexer.fired - - // Only recompile changed files if the setting is active and the project is not a virtual project - const autoFetchChangedFiles = - (settingsState.compiler?.autoFetchChangedFiles ?? true) && - !this.isVirtualProject - - await Promise.all([ - this.jsonDefaults.activate(), - autoFetchChangedFiles - ? this.compilerService.start(changedFiles, deletedFiles) - : Promise.resolve(), - ]) - - this.snippetLoader.activate() - - if (!this.isVirtualProject && import.meta.env.VITE_IS_TAURI_APP) - await invoke('watch_folder', { path: this.projectPath }) - } - - async deactivate(isReload = false) { - if (!isReload) - this.tabSystems.forEach((tabSystem) => tabSystem.deactivate()) - - this.typeLoader.deactivate() - this.jsonDefaults.deactivate() - this.extensionLoader.disposeAll() - - await Promise.all([ - this.packIndexer.deactivate(), - this.snippetLoader.deactivate(), - ]) - - if (!this.isVirtualProject && import.meta.env.VITE_IS_TAURI_APP) - await invoke('unwatch_folder', { path: this.projectPath }) - } - dispose() { - this.tabSystems.forEach((tabSystem) => tabSystem.dispose()) - this.extensionLoader.disposeAll() - } - - async refresh() { - if (this.refreshing) return - - this.refreshing = true - - this.app.packExplorer.refresh() - await this.deactivate(true) - await this.activate(true) - - this.refreshing = false - } - - async openFile( - fileHandle: AnyFileHandle, - options: IOpenTabOptions & { openInSplitScreen?: boolean } = {} - ) { - for (const tabSystem of this.tabSystems) { - const tab = await tabSystem.getTab(fileHandle) - if (tab) - return options.selectTab ?? true - ? tabSystem.select(tab) - : undefined - } - - if (!options.openInSplitScreen) - await this.tabSystem?.open(fileHandle, options) - else await this.inactiveTabSystem?.open(fileHandle, options) - } - async closeFile(fileHandle: AnyFileHandle) { - for (const tabSystem of this.tabSystems) { - const tabToClose = await tabSystem.getTab(fileHandle) - tabToClose?.close() - } - } - async getFileTab(fileHandle: AnyFileHandle) { - for (const tabSystem of this.tabSystems) { - const tab = await tabSystem.getTab(fileHandle) - if (tab !== undefined) return tab - } - } - async getFileTabWithPath(filePath: string) { - for (const tabSystem of this.tabSystems) { - const tab = await tabSystem.get( - (tab) => tab instanceof FileTab && tab.getPath() === filePath - ) - if (tab !== undefined) return tab - } - } - async openTab(tab: Tab, selectTab = true) { - for (const tabSystem of this.tabSystems) { - if (await tabSystem.hasTab(tab)) { - if (selectTab) tabSystem.select(tab) - return - } - } - this.tabSystem?.add(tab, selectTab) - } - updateTabFolders() { - const nameMap: Record = {} - for (const tabSystem of this.tabSystems) { - tabSystem.tabs.value.forEach((tab) => { - if (!(tab instanceof FileTab)) return - - const name = tab.name - - if (!nameMap[name]) nameMap[name] = [] - nameMap[name].push(tab) - }) - } - - for (const name in nameMap) { - const currentTabs = nameMap[name] - if (currentTabs.length === 1) currentTabs[0].setFolderName(null) - else { - const folderDifference = getFolderDifference( - currentTabs.map((tab) => tab.getPath()) - ) - currentTabs.forEach((tab, i) => - tab.setFolderName(folderDifference[i]) - ) - } - } - } - - absolutePath(filePath: string) { - return `${this.projectPath}/${filePath}` - } - relativePath(filePath: string) { - return relative(this.projectPath, filePath) - } - - async updateHandle(handle: AnyHandle) { - const path = await this.app.fileSystem.pathTo(handle) - if (!path) return - - if (handle.kind === 'file') return await this.updateFile(path) - - const files: string[] = [] - await iterateDir( - handle, - (_, filePath) => { - files.push(filePath) - }, - undefined, - path - ) - - await this.updateFiles(files) - } - async updateFile(filePath: string) { - const [anyFileChanged] = await Promise.all([ - this.packIndexer.updateFile(filePath), - this.watchModeActive - ? this.compilerService.updateFiles([filePath]) - : Promise.resolve(), - ]) - - if (anyFileChanged) - await this.jsonDefaults.updateDynamicSchemas(filePath) - } - async updateFiles(filePaths: string[]) { - const anyFileChanged = await this.packIndexer.updateFiles(filePaths) - - if (this.watchModeActive) - await this.compilerService.updateFiles(filePaths) - - if (anyFileChanged) - await this.jsonDefaults.updateMultipleDynamicSchemas(filePaths) - } - async unlinkFile(filePath: string) { - await this.unlinkFiles([filePath]) - } - async unlinkFiles(filePaths: string[]) { - await Promise.allSettled([ - ...filePaths.map((filePath) => - this.packIndexer.unlinkFile(filePath, false) - ), - ...filePaths.map((filePath) => - this.app.fileSystem.unlink(filePath) - ), - ]) - - await this.packIndexer.saveCache() - - if (this.watchModeActive) - await this.compilerService.unlinkMultiple(filePaths) - - filePaths.forEach((filePath) => { - this.fileUnlinked.dispatch(filePath) - - // Close tab if file is open - for (const tabSystem of this.tabSystems) { - tabSystem.forceCloseTabs((tab) => tab.getPath() === filePath) - } - }) - - // Reload dynamic schemas - const currentPath = this.tabSystem?.selectedTab?.getPath() - if (currentPath) - await this.jsonDefaults.updateDynamicSchemas(currentPath) - } - - /** - * Unlink a specific file handle from the project - * @param handle - * @returns Whether the file handle was successfully unlinked - */ - async unlinkHandle(handle: AnyHandle) { - const path = await this.app.fileSystem.pathTo(handle) - if (!path) return false - - if (handle.kind === 'file') { - await this.unlinkFile(path) - return true - } - - const files: string[] = [] - await iterateDir( - handle, - (_, filePath) => { - files.push(filePath) - }, - undefined, - path - ) - - await this.unlinkFiles(files) - await this.app.fileSystem.unlink(path) - return true - } - async onMovedFile(fromPath: string, toPath: string) { - await Promise.all([ - this.compilerService.rename(fromPath, toPath), - this.packIndexer.rename(fromPath, toPath), - ]) - - await this.jsonDefaults.updateDynamicSchemas(toPath) - } - async onMovedFolder(fromPath: string, toPath: string) { - const handle = await this.app.fileSystem.getDirectoryHandle(toPath) - - const renamePaths: [string, string][] = [] - - await iterateDir(handle, async (_, filePath) => { - const from = `${fromPath}/${filePath}` - const to = `${toPath}/${filePath}` - renamePaths.push([from, to]) - }) - - await Promise.all([ - this.compilerService.renameMultiple(renamePaths), - this.packIndexer.rename(fromPath, toPath), - ]) - - await this.jsonDefaults.updateDynamicSchemas(toPath) - } - async updateChangedFiles() { - this.packIndexer.deactivate() - - await this.packIndexer.activate(true) - await this.compilerService.build() - } - - async getFileFromDiskOrTab(filePath: string) { - const tab = await this.getFileTabWithPath(filePath) - - if (tab instanceof FileTab || tab instanceof TreeTab) - return await tab.getFile() - - return await this.app.fileSystem.readFile(filePath) - } - setActiveTabSystem(tabSystem: TabSystem, value: boolean) { - this.tabSystems.forEach((tS) => - tabSystem !== tS ? tS.setActive(value, false) : undefined - ) - } - - hasPacks(packTypes: TPackTypeId[]) { - for (const packType of packTypes) { - if (!this._projectData.contains?.some(({ id }) => id === packType)) - return false - } - return true - } - getPacks() { - return (this._projectData.contains ?? []).map((pack) => pack.id) - } - addPack(packType: IPackData) { - this._projectData.contains!.push(packType) - } - /** - * @deprecated Use `project.config.resolvePackPath(...)` instead - */ - getFilePath(packId: TPackTypeId, filePath?: string) { - return this.config.resolvePackPath(packId, filePath) - } - isFileWithinAnyPack(filePath: string) { - return this.getPacks() - .map((packId) => this.config.resolvePackPath(packId)) - .some((packPath) => filePath.startsWith(packPath)) - } - isFileWithinProject(filePath: string) { - return ( - filePath.startsWith(`${this.projectPath}/`) || - this.isFileWithinAnyPack(filePath) - ) - } - isFileOpen(filePath: string) { - return this.tabSystems.some((tabSystem) => - tabSystem.tabs.value.some( - (tab) => tab instanceof FileTab && tab.getPath() === filePath - ) - ) - } - - async loadProject() { - await this.config.setup() - - const [iconUrl, packs] = await Promise.all([ - loadIcon(this, this.app.fileSystem), - loadPacks(this.app, this), - ]) - - set(this, '_projectData', { - ...this.config.get(), - path: this.name, - name: this.name, - imgSrc: iconUrl, - contains: packs.sort((a, b) => a.id.localeCompare(b.id)), - }) - } - - async recompile(forceStartIfActive = true) { - if (this.isVirtualProject) return - - this._compilerService = markRaw( - await this.createDashService('development') - ) - - if (forceStartIfActive && this.isActiveProject) { - await this.fileSystem.writeFile('.bridge/.restartWatchMode', '') - await this.compilerService.start([], []) - } else { - await this.fileSystem.writeFile('.bridge/.restartWatchMode', '') - } - } - - /** - * Switches between a local and regular project - */ - async switchProjectType() { - this.app.windows.loadingWindow.open() - - const fromDir = this.requiresPermissions - ? 'projects' - : '~local/projects' - const toDir = this.requiresPermissions ? '~local/projects' : 'projects' - - const { type, handle } = await moveHandle({ - moveHandle: this.baseDirectory, - fromHandle: await this.app.fileSystem.getDirectoryHandle(fromDir), - toHandle: await this.app.fileSystem.getDirectoryHandle(toDir), - }) - if (type === 'cancel' || !handle || handle.kind !== 'directory') { - this.app.windows.loadingWindow.close() - return - } - - await this.parent.removeProject(this, false) - await this.parent.addProject(handle, true, !this.requiresPermissions) - - this.app.windows.loadingWindow.close() - } - - abstract getCurrentDataPackage(): Promise -} diff --git a/src/components/Projects/Project/loadIcon.ts b/src/components/Projects/Project/loadIcon.ts deleted file mode 100644 index f578fc77a..000000000 --- a/src/components/Projects/Project/loadIcon.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Project } from './Project' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { loadAsDataURL } from '/@/utils/loadAsDataUrl' -import { join } from '/@/utils/path' - -export async function loadIcon(project: Project, fileSystem: FileSystem) { - const config = project.config - - const packPaths = Object.values(config.getAvailablePacks()) - - if (packPaths.length === 0) return - - return await loadAsDataURL( - join(packPaths[0], 'pack_icon.png'), - fileSystem - ).catch(() => undefined) -} diff --git a/src/components/Projects/Project/loadManifest.ts b/src/components/Projects/Project/loadManifest.ts deleted file mode 100644 index 0d76008c5..000000000 --- a/src/components/Projects/Project/loadManifest.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { settingsState } from '../../Windows/Settings/SettingsState' -import { App } from '/@/App' -import { dashVersion } from '/@/utils/app/dashVersion' -import { version as appVersion } from '/@/utils/app/version' - -export async function loadManifest(app: App, manifestPath: string) { - let manifest = await app.fileSystem.readJSON(manifestPath) - - let addGeneratedWith = settingsState?.projects?.addGeneratedWith ?? true - let generatedWithBridge: string[] = - manifest?.metadata?.generated_with?.bridge ?? [] - let generatedWithDash: string[] = - manifest?.metadata?.generated_with?.dash ?? [] - - let updatedManifest = false - // Check that the user wants to add the generated_with section - if (addGeneratedWith) { - // Update generated_with bridge. version - if ( - !generatedWithBridge.includes(appVersion) || - generatedWithBridge.length > 1 - ) { - generatedWithBridge = [appVersion] - updatedManifest = true - } - - // Update generated_with dash version - if ( - !generatedWithDash.includes(dashVersion) || - generatedWithDash.length > 1 - ) { - generatedWithDash = [dashVersion] - updatedManifest = true - } - } else { - if (manifest?.metadata?.generated_with) { - updatedManifest = true - } - } - - // If the manifest changed, save changes to disk - if (updatedManifest) { - manifest = { - ...(manifest ?? {}), - metadata: { - ...(manifest?.metadata ?? {}), - generated_with: addGeneratedWith - ? { - ...(manifest?.metadata?.generated_with ?? {}), - ...{ bridge: generatedWithBridge, dash: generatedWithDash }, - } - : undefined, - }, - } - - await app.fileSystem.writeJSON(manifestPath, manifest, true) - } - return manifest -} diff --git a/src/components/Projects/Project/loadPacks.ts b/src/components/Projects/Project/loadPacks.ts deleted file mode 100644 index 6dde86023..000000000 --- a/src/components/Projects/Project/loadPacks.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { App } from '/@/App' -import { IPackType, TPackTypeId } from '/@/components/Data/PackType' -import { loadManifest } from './loadManifest' -import type { Project } from './Project' - -export interface IPackData extends IPackType { - version: number[] - packPath: string - uuid?: string -} - -export async function loadPacks(app: App, project: Project) { - await App.packType.ready.fired - const packs: IPackData[] = [] - const config = project.config - const definedPacks = config.getAvailablePacks() - - for (const [packId, packPath] of Object.entries(definedPacks)) { - // Load pack manifest - let manifest: any = {} - try { - manifest = await loadManifest( - app, - config.resolvePackPath(packId, 'manifest.json') - ) - } catch {} - - packs.push({ - ...App.packType.getFromId(packId)!, - packPath, - version: manifest?.header?.version ?? [1, 0, 0], - uuid: manifest?.header?.uuid, - }) - } - - return packs -} diff --git a/src/components/Projects/ProjectChooser/AddPack.ts b/src/components/Projects/ProjectChooser/AddPack.ts deleted file mode 100644 index 2455dade0..000000000 --- a/src/components/Projects/ProjectChooser/AddPack.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { InformedChoiceWindow } from '/@/components/Windows/InformedChoice/InformedChoice' -import { App } from '/@/App' -import { CreateBP } from '../CreateProject/Packs/BP' -import { CreateProjectWindow } from '../CreateProject/CreateProject' -import { CreateRP } from '../CreateProject/Packs/RP' -import { CreateSP } from '../CreateProject/Packs/SP' -import { CreateWT } from '../CreateProject/Packs/WT' -import { defaultPackPaths } from '../Project/Config' - -export async function addPack() { - const app = await App.getApp() - const window = new InformedChoiceWindow('windows.projectChooser.addPack') - const actionManager = await window.actionManager - - const createdPacks = app.project.getPacks() - - const createablePacks = App.packType.all.filter( - (packType) => !createdPacks.includes(packType.id) - ) - - createablePacks.forEach((packType) => - actionManager.create({ - icon: packType.icon, - color: packType.color, - name: `packType.${packType.id}.name`, - description: `packType.${packType.id}.description`, - - onTrigger: async () => { - app.windows.loadingWindow.open() - const scopedFs = app.project.fileSystem - const defaultOptions = await CreateProjectWindow.loadFromConfig() - - switch (packType.id) { - case 'behaviorPack': { - await new CreateBP().create(scopedFs, defaultOptions) - break - } - case 'resourcePack': { - await new CreateRP().create(scopedFs, defaultOptions) - break - } - case 'skinPack': { - await new CreateSP().create(scopedFs, defaultOptions) - break - } - case 'worldTemplate': { - await new CreateWT().create(scopedFs, defaultOptions) - break - } - default: { - app.windows.loadingWindow.close() - throw new Error( - `Unable to add pack with id "${packType.id}"` - ) - } - } - - const configJson = await scopedFs.readJSON('config.json') - await scopedFs.writeJSON( - 'config.json', - { - ...configJson, - packs: { - ...(configJson.packs ?? {}), - [packType.id]: defaultPackPaths[packType.id], - }, - }, - true - ) - - app.project.addPack({ - ...packType, - packPath: app.project.config.resolvePackPath(packType.id), - version: [1, 0, 0], - }) - - app.project.updateChangedFiles() - App.eventSystem.dispatch('projectChanged', app.project) - - app.windows.loadingWindow.close() - }, - }) - ) - - window.open() -} diff --git a/src/components/Projects/ProjectChooser/ProjectChooser.ts b/src/components/Projects/ProjectChooser/ProjectChooser.ts deleted file mode 100644 index 63ae4331c..000000000 --- a/src/components/Projects/ProjectChooser/ProjectChooser.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { App } from '/@/App' -import { IProjectData } from '/@/components/Projects/Project/Project' -import { Sidebar, SidebarItem } from '/@/components/Windows/Layout/Sidebar' -import ProjectChooserComponent from './ProjectChooser.vue' -import { SimpleAction } from '/@/components/Actions/SimpleAction' -import { v4 as uuid } from 'uuid' -import { IExperimentalToggle } from '../CreateProject/CreateProject' -import { importNewProject } from '../Import/ImportNew' -import { IPackData } from '/@/components/Projects/Project/loadPacks' -import { ComMojangProjectLoader } from '../../OutputFolders/ComMojang/ProjectLoader' -import { markRaw, reactive } from 'vue' -import { IWindowState, NewBaseWindow } from '../../Windows/NewBaseWindow' - -export interface IProjectChooserState extends IWindowState { - showLoadAllButton: 'isLoading' | boolean - currentProject?: string -} - -export class ProjectChooserWindow extends NewBaseWindow { - protected sidebar = new Sidebar([]) - protected experimentalToggles: (IExperimentalToggle & { - isActive: boolean - })[] = [] - public readonly comMojangProjectLoader - - protected state: IProjectChooserState = reactive({ - ...super.getState(), - showLoadAllButton: false, - currentProject: undefined, - }) - - constructor(app: App) { - super(ProjectChooserComponent, false, true) - - this.state.actions.push( - new SimpleAction({ - icon: 'mdi-refresh', - name: 'general.reload', - color: 'accent', - isDisabled: () => this.state.isLoading, - onTrigger: () => { - this.reload() - }, - }), - new SimpleAction({ - icon: 'mdi-import', - name: 'actions.importBrproject.name', - color: 'accent', - onTrigger: () => { - this.close() - importNewProject() - }, - }), - new SimpleAction({ - icon: 'mdi-plus', - name: 'windows.projectChooser.newProject.name', - color: 'accent', - onTrigger: async () => { - const app = await App.getApp() - this.close() - app.windows.createProject.open() - }, - }) - ) - this.comMojangProjectLoader = markRaw(new ComMojangProjectLoader(app)) - - this.defineWindow() - } - - async loadAllProjects() { - this.state.showLoadAllButton = 'isLoading' - const app = await App.getApp() - - // Only request permission if the user didn't already grant it - const wasSuccessful = - app.bridgeFolderSetup.hasFired || (await app.setupBridgeFolder()) - // For the com.mojang folder, we additionally check that bridge. already has its handle stored in IDB - const wasComMojangSuccesful = app.comMojang.hasComMojangHandle - ? app.comMojang.hasFired || (await app.comMojang.setupComMojang()) - : true - - if (wasSuccessful || wasComMojangSuccesful) { - await this.loadProjects() - this.state.showLoadAllButton = - !wasSuccessful || !wasComMojangSuccesful - } else { - this.state.showLoadAllButton = true - } - } - - addProject(id: string, name: string, project: Partial) { - this.sidebar.addElement( - new SidebarItem({ - color: 'primary', - text: name ?? 'Unknown', - icon: `mdi-alpha-${name[0].toLowerCase()}-box-outline`, - id: id ?? uuid(), - }), - project - ) - this.sidebar.setDefaultSelected() - } - - async loadProjects() { - this.sidebar.removeElements() - const app = await App.getApp() - - // Show the loadAllButton if the user didn't grant permissions to bridge folder or comMojang folder yet - this.state.showLoadAllButton = - !app.bridgeFolderSetup.hasFired || - (!app.comMojang.setup.hasFired && app.comMojang.hasComMojangHandle) - - const projects = await app.projectManager.getProjects() - const experimentalToggles = await app.dataLoader.readJSON( - 'data/packages/minecraftBedrock/experimentalGameplay.json' - ) - - projects.forEach((project) => - this.addProject(project.projectData.path!, project.displayName, { - displayName: project.displayName, - ...project.projectData, - isLocalProject: project.isLocal, - experimentalGameplay: experimentalToggles.map( - (toggle: IExperimentalToggle) => ({ - isActive: - project.config.get().experimentalGameplay?.[ - toggle.id - ] ?? false, - ...toggle, - }) - ), - }) - ) - - console.time('Load com.mojang projects') - const comMojangProjects = - await this.comMojangProjectLoader.loadProjects() - comMojangProjects.forEach((project) => - this.addProject(`comMojang/${project.name}`, project.name, { - name: project.name, - displayName: project.name, - imgSrc: - project.packs.find((pack) => !!pack.packIcon)?.packIcon ?? - undefined, - contains: project.packs - .map((pack) => { - const packType = App.packType.getFromId(pack.type) - if (!packType) return undefined - - return { - ...packType, - version: pack.manifest?.header?.version ?? [ - 1, 0, 0, - ], - packPath: pack.packPath, - uuid: pack.uuid, - } - }) - .filter((packType) => !!packType), - isLocalProject: false, - isComMojangProject: true, - project: markRaw(project), - }) - ) - console.timeEnd('Load com.mojang projects') - - this.sidebar.resetSelected() - if (app.isNoProjectSelected) this.sidebar.setDefaultSelected() - else this.sidebar.setDefaultSelected(app.projectManager.selectedProject) - - return app.projectManager.selectedProject - } - - async reload() { - this.state.isLoading = true - - this.comMojangProjectLoader.clearCache() - await this.loadProjects() - - this.state.isLoading = false - } - - async open() { - super.open() - - this.state.isLoading = true - console.time('Load projects') - this.state.currentProject = await this.loadProjects() - console.timeEnd('Load projects') - this.state.isLoading = false - } -} diff --git a/src/components/Projects/ProjectChooser/ProjectChooser.vue b/src/components/Projects/ProjectChooser/ProjectChooser.vue deleted file mode 100644 index c410f2742..000000000 --- a/src/components/Projects/ProjectChooser/ProjectChooser.vue +++ /dev/null @@ -1,318 +0,0 @@ - - - - - diff --git a/src/components/Projects/ProjectManager.ts b/src/components/Projects/ProjectManager.ts deleted file mode 100644 index f2b5a0687..000000000 --- a/src/components/Projects/ProjectManager.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { App } from '/@/App' -import { get as idbGet, set as idbSet } from 'idb-keyval' -import { shallowReactive, set, del, markRaw } from 'vue' -import { Signal } from '/@/components/Common/Event/Signal' -import { Project, virtualProjectName } from './Project/Project' -import { Title } from '/@/components/Projects/Title' -import type { editor } from 'monaco-editor' -import { BedrockProject } from './Project/BedrockProject' -import { EventDispatcher } from '../Common/Event/EventDispatcher' -import { AnyDirectoryHandle } from '../FileSystem/Types' -import { FileSystem } from '../FileSystem/FileSystem' -import { CreateConfig } from './CreateProject/Files/Config' -import { getStableFormatVersion } from '../Data/FormatVersions' -import { v4 as uuid } from 'uuid' -import { ICreateProjectOptions } from './CreateProject/CreateProject' -import { InformationWindow } from '../Windows/Common/Information/InformationWindow' -import { isUsingFileSystemPolyfill } from '../FileSystem/Polyfill' -import { listen } from '@tauri-apps/api/event' -import path from 'path-browserify' - -export class ProjectManager extends Signal { - public readonly addedProject = new EventDispatcher() - public readonly activatedProject = new EventDispatcher() - public readonly state: Record = shallowReactive({}) - public readonly title = markRaw(new Title()) - protected _selectedProject?: string = undefined - public readonly projectReady = new Signal() - protected projectBeingModified = 0 - - constructor(protected app: App) { - super() - - if (import.meta.env.VITE_IS_TAURI_APP) { - // Project watching is only supported on Tauri builds - listen('watch_event', this.handleWatchEvent.bind(this)) - - App.eventSystem.on( - 'beforeModifiedProject', - this.handleBeforeModifiedProjectEvent.bind(this) - ) - - App.eventSystem.on( - 'modifiedProject', - this.handleModifiedProjectEvent.bind(this) - ) - } else { - // Only load local projects on PWA builds - this.loadProjects() - } - } - - get currentProject() { - if (!this.selectedProject) return null - return this.state[this.selectedProject] - } - get selectedProject() { - return this._selectedProject - } - get totalProjects() { - return Object.keys(this.state).length - } - - async getProjects() { - await this.fired - return Object.values(this.state).filter((p) => !p.isVirtualProject) - } - getProject(projectName: string): Project | undefined { - return this.state[projectName] - } - async addProject( - projectDir: AnyDirectoryHandle, - isNewProject = true, - requiresPermissions = this.app.bridgeFolderSetup.hasFired, - select = true - ) { - const project = new BedrockProject(this, this.app, projectDir, { - requiresPermissions, - }) - await project.loadProject() - - if (this.state[project.name] !== undefined) { - this.state[project.name].deactivate() - this.state[project.name].dispose() - } - - set(this.state, project.name, project) - - if (isNewProject) { - if (select) await this.selectProject(project.name) - this.addedProject.dispatch(project) - } - - return project - } - async removeProject(project: Project, unlinkProject = true) { - if (!this.state[project.name]) - throw new Error('Project to delete not found') - - if (this._selectedProject === project.name) { - await this.selectProject(virtualProjectName) - project.dispose() - } - - del(this.state, project.name) - if (unlinkProject) await this.app.fileSystem.unlink(project.projectPath) - - await this.storeProjects(project.name) - } - async removeProjectWithName(projectName: string) { - const project = this.state[projectName] - if (!project) - throw new Error(`Project with name "${projectName}" not found`) - - await this.removeProject(project) - } - - async loadProjects(requiresPermissions = false) { - for (const key of Object.keys(this.state)) { - delete this.state[key] - } - - await this.app.fileSystem.fired - await this.app.dataLoader.fired - - const directoryHandle = await this.app.fileSystem.getDirectoryHandle( - 'projects', - { create: true } - ) - - const isBridgeFolderSetup = this.app.bridgeFolderSetup.hasFired - - const promises = [] - - // Load existing projects - for await (const handle of directoryHandle.values()) { - if (handle.kind !== 'directory') continue - - promises.push(this.addProject(handle, false, requiresPermissions)) - } - - // Only load local projects separately on PWA builds and when the bridge folder was setup - if (!import.meta.env.VITE_IS_TAURI_APP && isBridgeFolderSetup) { - const localDirectoryHandle = - await this.app.fileSystem.getDirectoryHandle( - '~local/projects', - { - create: true, - } - ) - - // Load local projects as well - for await (const handle of localDirectoryHandle.values()) { - if (handle.kind !== 'directory') continue - - promises.push(this.addProject(handle, false, false)) - } - } - - await Promise.allSettled(promises) - - // Update stored projects in the background (don't await it) - if (isBridgeFolderSetup) this.storeProjects(undefined, true) - - await this.createVirtualProject() - - this.dispatch() - } - - async createVirtualProject() { - // Ensure that we first unlink the previous virtual project - await this.app.fileSystem.unlink(`projects/${virtualProjectName}`) - - const handle = await this.app.fileSystem.getDirectoryHandle( - `projects/${virtualProjectName}`, - { - create: true, - } - ) - const fs = new FileSystem(handle) - - const createOptions: ICreateProjectOptions = { - name: 'bridge', - namespace: 'bridge', - author: [], - description: '', - bpAsRpDependency: false, - experimentalGameplay: {}, - icon: null, - packs: ['behaviorPack', '.bridge'], - rpAsBpDependency: false, - targetVersion: await getStableFormatVersion(this.app.dataLoader), - useLangForManifest: false, - bdsProject: false, - uuids: { - data: uuid(), - resources: uuid(), - skin_pack: uuid(), - world_template: uuid(), - }, - } - - await Promise.all(['BP', '.bridge'].map((folder) => fs.mkdir(folder))) - - await new CreateConfig().create(fs, createOptions) - - await this.addProject(handle, false) - } - - async selectProject(projectName: string, failGracefully = false) { - // Clear current comMojangProject - if (this.app.viewComMojangProject.hasComMojangProjectLoaded) { - await this.app.viewComMojangProject.clearComMojangProject() - } - if (this._selectedProject === projectName) return true - - if (this.state[projectName] === undefined) { - if (failGracefully) { - new InformationWindow({ - description: - 'packExplorer.noProjectView.projectNoLongerExists', - }) - return false - } - - throw new Error( - `Cannot select project "${projectName}" because it no longer exists` - ) - } - - // Setup com.mojang folder if necessary - if (!this.app.comMojang.hasFired && projectName !== virtualProjectName) - this.app.comMojang.setupComMojang() - - this.currentProject?.deactivate() - this._selectedProject = projectName - - App.eventSystem.dispatch('disableValidation', null) - - this.currentProject?.activate() - - await idbSet('selectedProject', projectName) - - this.app.themeManager.updateTheme() - App.eventSystem.dispatch('projectChanged', this.currentProject!) - - const projectDatas = await this.loadAvailableProjects() - const projectData = projectDatas.find( - (project: any) => project.name === projectName - ) - - if (projectData) projectData.lastOpened = Date.now() - - // Store projects in local storage fs - await this.storeProjects() - - if (!this.projectReady.hasFired) this.projectReady.dispatch() - return true - } - async selectLastProject() { - await this.fired - /** - * We can select the last selected project if the user is... - * - * 1. Not using a Tauri native build - * 2. Using our file system polyfill - * - * This is because this variant of bridge. only supports loading one project at once - */ - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value - ) { - const selectedProject = await idbGet('selectedProject') - let didSelectProject = false - if (typeof selectedProject === 'string') - didSelectProject = await this.selectProject( - selectedProject, - true - ) - - if (didSelectProject) return - } - - await this.selectProject(virtualProjectName) - } - - updateAllEditorOptions(options: editor.IEditorConstructionOptions) { - Object.values(this.state).forEach((project) => - project.tabSystems.forEach((tabSystem) => - tabSystem.updateOptions(options) - ) - ) - } - - /** - * Call a function for every current project and projects newly added in the future - */ - forEachProject(func: (project: Project) => Promise | void) { - Object.values(this.state).forEach(func) - - return this.addedProject.on(func) - } - /** - * Call a function for every project that gets activated - */ - onActiveProject(func: (project: Project) => Promise | void) { - if (this.projectReady.hasFired && this.currentProject) - func(this.currentProject) - - return this.activatedProject.on(func) - } - someProject(func: (project: Project) => boolean) { - return Object.values(this.state).some(func) - } - - async recompileAll(forceStartIfActive = true) { - for (const project of Object.values(this.state)) - await project.recompile(forceStartIfActive) - } - - async loadAvailableProjects(exceptProject?: string) { - return ( - await this.app.fileSystem - .readJSON('~local/data/projects.json') - .catch(() => []) - ).filter( - (project: any) => !exceptProject || project.name !== exceptProject - ) - } - async storeProjects(exceptProject?: string, forceRefresh = false) { - let data: { - displayName: string - name: string - icon?: string - requiresPermissions: boolean - isFavorite?: boolean - lastOpened?: number - }[] = await this.loadAvailableProjects(exceptProject) - - let newData: any[] = forceRefresh ? [] : [...data] - Object.values(this.state).forEach((project) => { - if (project.isVirtualProject) return - - const storedData = data.find( - (p) => - project.name === p.name && - project.requiresPermissions === p.requiresPermissions - ) - - if (!forceRefresh && storedData) return - - newData.push({ - name: project.name, - displayName: project.config.get().name ?? project.name, - icon: project.projectData.imgSrc, - requiresPermissions: project.requiresPermissions, - isFavorite: storedData?.isFavorite ?? false, - lastOpened: storedData?.lastOpened ?? 0, - }) - }) - - await this.app.fileSystem.writeJSON( - '~local/data/projects.json', - newData - ) - App.eventSystem.dispatch('availableProjectsFileChanged', undefined) - } - handleBeforeModifiedProjectEvent() { - this.projectBeingModified++ - } - handleModifiedProjectEvent() { - // Sometimes a the writer await will resolve before the poller actually detects the change so we need to wait a tiny bit before unlocking to catch the delay - setTimeout(() => { - this.projectBeingModified-- - }, 10) - } - async handleWatchEvent(event: any) { - if (this.selectedProject === null) return - - const project = this.getProject(this.selectedProject!) - - if (!project) return - - const paths = (event.payload).filter( - (entryPath) => - !entryPath.startsWith( - path.join(project.projectPath!, '.bridge') - ) - ) - - if (paths.length === 0) return - - if (this.projectBeingModified > 0) return - - const app = await App.getApp() - - app.actionManager.trigger('bridge.action.refreshProject') - } -} diff --git a/src/components/Projects/RecentFiles.ts b/src/components/Projects/RecentFiles.ts deleted file mode 100644 index 94325d842..000000000 --- a/src/components/Projects/RecentFiles.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { App } from '/@/App' -import { PersistentQueue } from '../Common/PersistentQueue' - -export interface IRecentFile { - icon: string - color?: string - name: string - path: string -} - -export class RecentFiles extends PersistentQueue { - constructor(app: App, savePath: string) { - super(app, 5, savePath) - } - - isEquals(file1: IRecentFile, file2: IRecentFile) { - return file1.path === file2.path - } - - removeFile(filePath: string) { - return this.remove({ - icon: '', - name: '', - path: filePath, - }) - } -} diff --git a/src/components/Projects/RecentProjects.ts b/src/components/Projects/RecentProjects.ts deleted file mode 100644 index 782e6d4f5..000000000 --- a/src/components/Projects/RecentProjects.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { App } from '/@/App' -import { PersistentQueue } from '../Common/PersistentQueue' -import { IProjectData } from './Project/Project' - -export class RecentProjects extends PersistentQueue> { - constructor(app: App, savePath: string) { - super(app, 5, savePath) - } - - protected isEquals( - file1: Partial, - file2: Partial - ) { - return file1.path === file2.path - } -} diff --git a/src/components/Projects/Title.ts b/src/components/Projects/Title.ts deleted file mode 100644 index c7012c057..000000000 --- a/src/components/Projects/Title.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ref } from 'vue' -import { virtualProjectName } from './Project/Project' -import { isNightly } from '/@/utils/app/isNightly' - -const appName = isNightly ? 'bridge. Nightly' : 'bridge. v2' -export class Title { - protected titleTag: HTMLTitleElement - public current = ref('') - public appName = appName - - constructor() { - this.titleTag = document.head.getElementsByTagName('title')[0] - } - - setProject(projectName: string) { - if (projectName === virtualProjectName) { - this.titleTag.innerText = '' - this.current.value = '' - } else { - this.titleTag.innerText = projectName - this.current.value = projectName - } - } -} diff --git a/src/components/Sidebar/Button.vue b/src/components/Sidebar/Button.vue deleted file mode 100644 index 286e3f9cb..000000000 --- a/src/components/Sidebar/Button.vue +++ /dev/null @@ -1,170 +0,0 @@ - - - - - diff --git a/src/components/Sidebar/Content/Action.vue b/src/components/Sidebar/Content/Action.vue deleted file mode 100644 index 908f18133..000000000 --- a/src/components/Sidebar/Content/Action.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - diff --git a/src/components/Sidebar/Content/ActionBar.vue b/src/components/Sidebar/Content/ActionBar.vue deleted file mode 100644 index e0a4cbb98..000000000 --- a/src/components/Sidebar/Content/ActionBar.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - - - diff --git a/src/components/Sidebar/Content/Main.vue b/src/components/Sidebar/Content/Main.vue deleted file mode 100644 index 83e7c3c12..000000000 --- a/src/components/Sidebar/Content/Main.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/src/components/Sidebar/Content/SelectableSidebarAction.ts b/src/components/Sidebar/Content/SelectableSidebarAction.ts deleted file mode 100644 index 25e189509..000000000 --- a/src/components/Sidebar/Content/SelectableSidebarAction.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SidebarAction, ISidebarAction } from './SidebarAction' -import type { SidebarContent } from './SidebarContent' - -export class SelectableSidebarAction extends SidebarAction { - protected _isSelected = false - - constructor(protected parent: SidebarContent, config: ISidebarAction) { - super(config) - if (!this.parent.selectedAction) this.select() - } - get isSelected() { - return this._isSelected - } - - select() { - this.parent.unselectAllActions() - this._isSelected = true - this.parent.selectedAction = this - } - unselect() { - if (this._isSelected) this.parent.selectedAction = undefined - this._isSelected = false - } -} diff --git a/src/components/Sidebar/Content/SidebarAction.ts b/src/components/Sidebar/Content/SidebarAction.ts deleted file mode 100644 index 0f573895c..000000000 --- a/src/components/Sidebar/Content/SidebarAction.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface ISidebarAction { - id?: string - icon: string - name: string - color?: string - - onTrigger?: (event: MouseEvent) => void -} -export class SidebarAction { - constructor(protected config: ISidebarAction) {} - - getConfig() { - return this.config - } - - trigger(event: MouseEvent) { - this.config.onTrigger?.(event) - } -} diff --git a/src/components/Sidebar/Content/SidebarContent.ts b/src/components/Sidebar/Content/SidebarContent.ts deleted file mode 100644 index ba10fbd9f..000000000 --- a/src/components/Sidebar/Content/SidebarContent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Component } from 'vue' -import { SelectableSidebarAction } from './SelectableSidebarAction' -import { SidebarAction } from './SidebarAction' -import { InfoPanel } from '/@/components/InfoPanel/InfoPanel' - -export abstract class SidebarContent { - protected headerSlot?: Component - protected headerHeight?: string - protected topPanel?: InfoPanel - protected actions?: SidebarAction[] = [] - public selectedAction?: SidebarAction = undefined - protected abstract component: Component - - unselectAllActions() { - this.actions?.forEach((action) => - action instanceof SelectableSidebarAction - ? action.unselect() - : undefined - ) - this.selectedAction = undefined - } - getSelectedAction() { - return this.actions?.find( - (action) => - action instanceof SelectableSidebarAction && action.isSelected - ) - } - - onContentRightClick(event: MouseEvent) {} -} diff --git a/src/components/Sidebar/Manager.ts b/src/components/Sidebar/Manager.ts deleted file mode 100644 index eed355a6d..000000000 --- a/src/components/Sidebar/Manager.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { reactive, ref, del, set, computed } from 'vue' -import { SidebarContent } from './Content/SidebarContent' -import { SidebarElement } from './SidebarElement' -import { App } from '/@/App' - -export class SidebarManager { - elements = reactive>({}) - isNavigationVisible = ref(true) - currentState = ref(null) - isContentVisible = computed(() => this.currentState.value !== null) - forcedInitialState = ref(false) - groupOrder = ['projectChooser', 'packExplorer'] - - sortedElements = ref([]) - - protected updateSortedElements() { - this.sortedElements.value = Object.values(this.elements).sort( - (a, b) => { - if (!a.group && !b.group) return 0 - - if (!a.group) return 1 - if (!b.group) return -1 - return ( - this.groupOrder.indexOf(a.group) - - this.groupOrder.indexOf(b.group) - ) - } - ) - } - - addSidebarElement(uuid: string, element: SidebarElement) { - set(this.elements, uuid, element) - this.updateSortedElements() - - return { - dispose: () => { - del(this.elements, uuid) - this.updateSortedElements() - }, - } - } - - toggleSidebarContent(content: SidebarContent | null) { - if (content === null) { - this.hideSidebarContent() - return - } - - if (content === this.currentState.value) { - this.hideSidebarContent(false) - } else { - this.currentState.value = content - if (!this.isNavigationVisible.value) - this.isNavigationVisible.value = true - } - - App.getApp().then((app) => app.windowResize.dispatch()) - } - async hideSidebarContent(hideNavigation = true) { - const app = await App.getApp() - - if (app.mobile.isCurrentDevice()) { - if (hideNavigation) this.isNavigationVisible.value = false - } else { - this.currentState.value = null - } - } - selectSidebarContent(content: SidebarContent | null) { - this.currentState.value = content - App.getApp().then((app) => app.windowResize.dispatch()) - } - hide() { - this.isNavigationVisible.value = false - App.getApp().then((app) => app.windowResize.dispatch()) - } -} diff --git a/src/components/Sidebar/Sidebar.ts b/src/components/Sidebar/Sidebar.ts new file mode 100644 index 000000000..c159f240c --- /dev/null +++ b/src/components/Sidebar/Sidebar.ts @@ -0,0 +1,63 @@ +import { Settings } from '@/libs/settings/Settings' + +export type Button = { + type: 'button' + id: string + label: string + icon: string + callback?: () => void +} + +export type Divider = { + type: 'divider' +} + +export type SidebarItem = Button | Divider + +export class Sidebar { + public static items: SidebarItem[] = [] + + public static setup() { + this.items = [] + + Settings.addSetting('sidebarRight', { + default: false, + }) + + Settings.addSetting('sidebarSize', { + default: 'normal', + }) + + Settings.addSetting('hiddenSidebarElements', { + default: [], + }) + } + + public static addButton(id: string, label: string, icon: string, callback: () => void): Button { + const item: Button = { + type: 'button', + id, + label, + icon, + callback, + } + + this.items.push(item) + + return item + } + + public static addDivider(): Divider { + const item: Divider = { + type: 'divider', + } + + this.items.push(item) + + return item + } + + public static remove(item: Button | Divider) { + this.items.splice(this.items.indexOf(item)) + } +} diff --git a/src/components/Sidebar/Sidebar.vue b/src/components/Sidebar/Sidebar.vue index 7aa90c68b..3e6173568 100644 --- a/src/components/Sidebar/Sidebar.vue +++ b/src/components/Sidebar/Sidebar.vue @@ -1,178 +1,66 @@ + + - - diff --git a/src/components/Sidebar/SidebarElement.ts b/src/components/Sidebar/SidebarElement.ts deleted file mode 100644 index d8d64742a..000000000 --- a/src/components/Sidebar/SidebarElement.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { v4 as uuid } from 'uuid' -import { Component } from 'vue' -import type { IDisposable } from '/@/types/disposable' -import { SidebarContent } from './Content/SidebarContent' -import { del, set, watch, WatchStopHandle } from 'vue' -import { settingsState } from '../Windows/Settings/SettingsState' -import { App } from '/@/App' - -export interface ISidebar { - id?: string - icon?: string - displayName?: string - group?: string - isVisible?: boolean | (() => boolean) - /** - * Change the default visibility setting of the sidebar element - */ - defaultVisibility?: boolean - component?: Component - sidebarContent?: SidebarContent - disabled?: () => boolean - - onClick?: (sidebarElement: SidebarElement) => void -} - -export interface SidebarInstance extends IDisposable, ISidebar { - readonly uuid: string - readonly isLoading: boolean - - click: () => void -} - -/** - * Creates a new sidebar - * @param config - */ -export function createSidebar(config: ISidebar) { - return new SidebarElement(config) -} - -export interface IBadge { - count: number - color?: string - icon?: string - dot?: boolean -} - -export class SidebarElement { - protected sidebarUUID: string - protected disposable: IDisposable - isLoading = false - isSelected = false - stopHandle: WatchStopHandle | undefined - badge: IBadge | null = null - isVisibleSetting?: boolean = undefined - - constructor(protected config: ISidebar) { - this.sidebarUUID = config.id ?? uuid() - this.disposable = App.sidebar.addSidebarElement(this.sidebarUUID, this) - - if (this.config.component) { - const component = this.config.component - - this.config.sidebarContent = new (class extends SidebarContent { - protected component = component - protected actions = undefined - protected topPanel = undefined - })() - } - this.stopHandle = watch( - App.sidebar.currentState, - () => { - this.isSelected = - (App.sidebar.currentState.value ?? false) && - App.sidebar.currentState.value === - this.config.sidebarContent - }, - { deep: false } - ) - } - - get isDisabled() { - return this.config.disabled?.() ?? false - } - get isVisible() { - if (typeof this.config.isVisible === 'function') - return this.config.isVisible() - - return this.config.isVisible ?? !!this.isVisibleSetting - } - get defaultVisibility() { - return this.config.defaultVisibility ?? true - } - get icon() { - return this.config.icon - } - get uuid() { - return this.sidebarUUID - } - get displayName() { - return this.config.displayName - } - get group() { - return this.config.group - } - get component() { - return this.config.component - } - dispose() { - this.disposable.dispose() - this.stopHandle?.() - this.stopHandle = undefined - } - setSidebarContent(sidebarContent: SidebarContent) { - this.config.sidebarContent = sidebarContent - } - setIsVisible(isVisible: boolean | (() => boolean)) { - this.config.isVisible = isVisible - } - attachBadge(badge: IBadge | null) { - this.badge = badge - - return this - } - - async click() { - this.isLoading = true - - if (this.config.sidebarContent) - App.sidebar.toggleSidebarContent(this.config.sidebarContent) - - if (typeof this.config.onClick === 'function') - await this.config.onClick(this) - this.isLoading = false - } - select() { - if (!this.config.sidebarContent) - throw new Error( - 'Cannot select sidebar element without sidebar content' - ) - - App.sidebar.selectSidebarContent(this.config.sidebarContent) - } -} diff --git a/src/components/Sidebar/SidebarSetup.ts b/src/components/Sidebar/SidebarSetup.ts new file mode 100644 index 000000000..be61a1d8b --- /dev/null +++ b/src/components/Sidebar/SidebarSetup.ts @@ -0,0 +1,47 @@ +import { Sidebar } from './Sidebar' +import { FindAndReplaceTab } from '@/components/Tabs/FindAnReplace/FindAndReplaceTab' +import { ExtensionLibraryWindow } from '@/components/Windows/ExtensionLibrary/ExtensionLibrary' +import { TabManager } from '@/components/TabSystem/TabManager' +import { Windows } from '@/components/Windows/Windows' +import { CompilerWindow } from '@/components/Windows/Compiler/CompilerWindow' +import { NotificationSystem } from '@/components/Notifications/NotificationSystem' +import { SocialsWindow } from '@/components/Windows/Socials/SocialsWindow' +import { openUrl } from '@/libs/OpenUrl' +import { ActionManager } from '@/libs/actions/ActionManager' +import { tauriBuild } from '@/libs/tauri/Tauri' + +export function setupSidebar() { + Sidebar.addButton('fileExplorer', 'sidebar.fileExplorer.name', 'folder', () => { + ActionManager.trigger('project.toggleFileExplorer') + }) + + Sidebar.addButton('findAndReplace', 'sidebar.findAndReplace.name', 'quick_reference_all', () => { + TabManager.openTab(TabManager.getTabByType(FindAndReplaceTab) ?? new FindAndReplaceTab()) + }) + + Sidebar.addButton('compiler', 'sidebar.compiler.name', 'manufacturing', () => { + Windows.open(CompilerWindow) + }) + Sidebar.addButton('extensionLibrary', 'sidebar.extensions.name', 'extension', () => { + ExtensionLibraryWindow.open() + }) + Sidebar.addDivider() + + if (!tauriBuild) { + NotificationSystem.addNotification( + 'download', + () => { + openUrl('https://bridge-core.app/guide/download/') + }, + 'primary' + ) + } + + NotificationSystem.addNotification('link', () => { + Windows.open(SocialsWindow) + }) + + NotificationSystem.addNotification('help', () => { + openUrl('https://bridge-core.app/guide/') + }) +} diff --git a/src/components/Sidebar/setup.ts b/src/components/Sidebar/setup.ts deleted file mode 100644 index a86d69bf3..000000000 --- a/src/components/Sidebar/setup.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { App } from '/@/App' -import { createSidebar } from './SidebarElement' -import { FindAndReplaceTab } from '/@/components/FindAndReplace/Tab' -import { SettingsWindow } from '/@/components/Windows/Settings/SettingsWindow' -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' -import { createVirtualProjectWindow } from '/@/components/FileSystem/Virtual/ProjectWindow' -import { createCompilerSidebar } from '../Compiler/Sidebar/create' -import { exportAsMcaddon } from '../Projects/Export/AsMcaddon' - -export async function setupSidebar() { - createSidebar({ - id: 'projects', - group: 'projectChooser', - displayName: 'windows.projectChooser.title', - icon: 'mdi-view-dashboard-outline', - disabled: () => - // Disable the projects chooser if... - // - We have no projects - App.instance.hasNoProjects && - // - We have already setup the bridge folder - App.instance.bridgeFolderSetup.hasFired && - // - We do not have com.mojang projects - !App.instance.windows.projectChooser.comMojangProjectLoader - .hasProjects.value, - - onClick: async () => { - if ( - App.instance.hasNoProjects && - !App.instance.bridgeFolderSetup.hasFired - ) { - const didSetup = await App.instance.setupBridgeFolder() - if (!didSetup) return - } - - // Show limited project chooser if current build is a PWA build using our file system polyfill - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value - ) { - createVirtualProjectWindow() - } else { - await App.instance.windows.projectChooser.open() - } - }, - }) - - const packExplorer = createSidebar({ - id: 'packExplorer', - group: 'packExplorer', - displayName: 'packExplorer.name', - icon: 'mdi-folder-outline', - }) - - App.getApp().then((app) => { - packExplorer.setSidebarContent(app.packExplorer) - packExplorer.setIsVisible( - () => !app.viewComMojangProject.hasComMojangProjectLoaded - ) - - if (!App.sidebar.forcedInitialState.value) packExplorer.click() - }) - - createSidebar({ - id: 'fileSearch', - displayName: 'findAndReplace.name', - icon: 'mdi-file-search-outline', - disabled: () => App.instance.isNoProjectSelected, - onClick: async () => { - const app = await App.getApp() - app.project.tabSystem?.add( - new FindAndReplaceTab(app.project.tabSystem!), - true - ) - }, - }) - - createCompilerSidebar() - - /** - * Enable one click exports of projects on mobile - * This should help users export projects faster - */ - createSidebar({ - id: 'quickExport', - displayName: 'sidebar.quickExport.name', - icon: 'mdi-export', - // Only show quick export option for devices on which com.mojang syncing is not available - defaultVisibility: - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value, - disabled: () => App.instance.isNoProjectSelected, - - onClick: () => { - exportAsMcaddon() - }, - }) - - createSidebar({ - id: 'extensions', - displayName: 'sidebar.extensions.name', - icon: 'mdi-puzzle-outline', - onClick: async () => { - const app = await App.getApp() - await app.windows.extensionStore.open() - }, - }) - - SettingsWindow.loadedSettings.once((settingsState) => { - for (const sidebar of Object.values(App.sidebar.elements)) { - sidebar.isVisibleSetting = - settingsState?.sidebar?.sidebarElements?.[sidebar.uuid] ?? - sidebar.defaultVisibility - } - }) -} diff --git a/src/components/Snippets/Loader.ts b/src/components/Snippets/Loader.ts deleted file mode 100644 index e71901d70..000000000 --- a/src/components/Snippets/Loader.ts +++ /dev/null @@ -1,70 +0,0 @@ -import json5 from 'json5' -import { Snippet } from './Snippet' -import { App } from '/@/App' -import { iterateDir } from '/@/utils/iterateDir' -import './Monaco' -import { AnyDirectoryHandle } from '../FileSystem/Types' -import { IDisposable } from '/@/types/disposable' -import type { Project } from '../Projects/Project/Project' - -export class SnippetLoader { - protected snippets = new Set() - - constructor(protected project: Project) {} - - async activate() { - const app = await App.getApp() - const packageHandle = await app.project.getCurrentDataPackage() - const snippetHandle = await packageHandle - .getDirectoryHandle('snippet') - .catch(() => null) - - if (!snippetHandle) return - - await this.loadFrom(snippetHandle) - } - - async loadFrom(directory: AnyDirectoryHandle) { - const disposables: IDisposable[] = [] - - await iterateDir(directory, async (fileHandle, filePath) => { - let snippetJson: any - try { - snippetJson = json5.parse( - await fileHandle.getFile().then((file) => file.text()) - ) - } catch (e) { - console.error(`Invalid JSON within snippet "${filePath}"`) - return - } - - disposables.push(this.addSnippet(new Snippet(snippetJson))) - }) - - return disposables - } - - async deactivate() { - this.snippets.clear() - } - - addSnippet(snippet: Snippet) { - this.snippets.add(snippet) - - return { - dispose: () => { - this.snippets.delete(snippet) - }, - } - } - - getSnippetsFor( - formatVersion: string, - fileType: string, - locations: string[] - ) { - return [...this.snippets].filter((snippet) => - snippet.isValid(formatVersion, fileType, locations) - ) - } -} diff --git a/src/components/Snippets/Monaco.ts b/src/components/Snippets/Monaco.ts deleted file mode 100644 index bcb4202db..000000000 --- a/src/components/Snippets/Monaco.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { languages, editor, Position } from 'monaco-editor' -import { getLocation } from '/@/utils/monaco/getLocation' -import { App } from '/@/App' -import { FileTab } from '../TabSystem/FileTab' -import json5 from 'json5' -import { getLatestFormatVersion } from '../Data/FormatVersions' -import { useMonaco } from '../../utils/libs/useMonaco' - -export async function registerJsonSnippetProvider() { - const { languages } = await useMonaco() - - languages.registerCompletionItemProvider('json', { - // @ts-ignore provideCompletionItems doesn't require a range property inside of the completion items - provideCompletionItems: async ( - model: editor.ITextModel, - position: Position - ) => { - const app = await App.getApp() - const location = await getLocation(model, position) - const currentTab = app.project.tabSystem?.selectedTab - - if (!(currentTab instanceof FileTab)) return { suggestions: [] } - const fileType = currentTab.getFileType() - - let json: any - try { - json = json5.parse(model.getValue()) - } catch { - json = {} - } - - const currentFormatVersion: string = - (json).format_version || - app.project.config.get().targetVersion || - (await getLatestFormatVersion()) - - return { - suggestions: app.project.snippetLoader - .getSnippetsFor(currentFormatVersion, fileType, [location]) - .map((snippet) => ({ - kind: languages.CompletionItemKind.Snippet, - label: snippet.displayData.name, - documentation: snippet.displayData.description, - insertText: snippet.insertText, - insertTextRules: - languages.CompletionItemInsertTextRule - .InsertAsSnippet, - })), - } - }, - }) -} - -export async function registerTextSnippetProvider() { - const { languages } = await useMonaco() - - const textSnippetProvider = ({ - provideCompletionItems: async ( - model: editor.ITextModel, - position: Position - ) => { - const app = await App.getApp() - const currentTab = app.project.tabSystem?.selectedTab - - if (!(currentTab instanceof FileTab)) return { suggestions: [] } - const fileType = currentTab.getFileType() - - const currentFormatVersion: string = - app.project.config.get().targetVersion || - (await getLatestFormatVersion()) - - return { - suggestions: app.project.snippetLoader - .getSnippetsFor(currentFormatVersion, fileType, []) - .map((snippet) => ({ - kind: languages.CompletionItemKind.Snippet, - label: snippet.displayData.name, - documentation: snippet.displayData.description, - insertText: snippet.insertText, - insertTextRules: - languages.CompletionItemInsertTextRule - .InsertAsSnippet, - })), - } - }, - }) - - ;['mcfunction', 'molang', 'javascript', 'typescript'].forEach((lang) => - languages.registerCompletionItemProvider(lang, textSnippetProvider) - ) -} diff --git a/src/components/Snippets/Snippet.ts b/src/components/Snippets/Snippet.ts deleted file mode 100644 index d8641e8a2..000000000 --- a/src/components/Snippets/Snippet.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { isMatch, compareVersions } from 'bridge-common-utils' - -export interface ISnippet { - name: string - targetFormatVersion?: { - min?: string - max?: string - } - description?: string - fileTypes: string[] - locations?: string[] - data: unknown -} - -export class Snippet { - protected name: string - protected description: string | undefined - protected fileTypes: Set - protected locations: string[] - protected data: unknown - protected minTargetFormatVersion?: string - protected maxTargetFormatVersion?: string - - constructor({ - name, - description, - fileTypes, - locations, - data, - targetFormatVersion, - }: ISnippet) { - this.name = name - this.description = description - this.fileTypes = new Set(fileTypes) - this.locations = locations ?? [] - this.data = data - this.minTargetFormatVersion = targetFormatVersion?.min - this.maxTargetFormatVersion = targetFormatVersion?.max - } - - get displayData() { - return { - name: this.name, - description: this.description, - } - } - get insertData() { - // This is a hacky solution for a vuetify bug - // Keeps the snippet searchable by name even though we just workaround - // Vuetify's missing ability to respect the "item-text" prop on the combobox component - // https://github.com/vuetifyjs/vuetify/issues/5479 - return [this.name, this.data] - } - get insertText() { - if (typeof this.data === 'string') return this.data - else if (Array.isArray(this.data)) return this.data.join('\n') - - return JSON.stringify(this.data, null, '\t') - .slice(1, -1) - .replaceAll('\n\t', '\n') - .trim() - } - - isValid(formatVersion: unknown, fileType: string, locations: string[]) { - const formatVersionValid = - typeof formatVersion !== 'string' || //Format version inside of file is a string - ((!this.minTargetFormatVersion || - compareVersions( - formatVersion, - this.minTargetFormatVersion, - '>=' - )) && - (!this.maxTargetFormatVersion || - compareVersions( - formatVersion, - this.maxTargetFormatVersion, - '<=' - ))) - - return ( - formatVersionValid && - this.fileTypes.has(fileType) && - (this.locations.length === 0 || - this.locations.some((locPattern) => - locations.some( - (locationInFile) => - locPattern === locationInFile || - (locPattern !== '' - ? isMatch(locationInFile, locPattern) - : false) - ) - )) - ) - } -} diff --git a/src/components/Solid/Directives/Model/Model.ts b/src/components/Solid/Directives/Model/Model.ts deleted file mode 100644 index 9449ebe58..000000000 --- a/src/components/Solid/Directives/Model/Model.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { createRenderEffect, onCleanup, Signal } from 'solid-js' - -declare module 'solid-js' { - namespace JSX { - interface Directives { - model: any // Solid directives are impossible to fully type :( - } - } -} - -/** - * A Solid equivalent of the v-model directive. - */ -function model(el: HTMLElement, value: (() => Signal) | undefined) { - const input = - el instanceof HTMLInputElement ? el : el.querySelector('input') - if (input === null) throw new Error('No input element found for use:model') - - if (value === undefined) return - const [field, setField] = value() - - createRenderEffect(() => { - input.value = field() - }) - - const onInput = (e: Event) => { - setField(input.value) - } - - input.addEventListener('input', onInput) - - onCleanup(() => { - input.removeEventListener('input', onInput) - }) -} - -export const useModel = () => model diff --git a/src/components/Solid/Directives/Ripple/Ripple.css b/src/components/Solid/Directives/Ripple/Ripple.css deleted file mode 100644 index 2bb06554b..000000000 --- a/src/components/Solid/Directives/Ripple/Ripple.css +++ /dev/null @@ -1,41 +0,0 @@ -.solid-ripple-container { - isolation: isolate; - position: relative; - overflow: hidden; -} - -.solid-ripple { - position: absolute; - background: #fff; - - transform: translate(50%, 50%) scale(0); - - pointer-events: none; - opacity: 0.6; -} -.solid-ripple-active { - animation: solid-ripple-scale-up 0.4s ease-in-out; - animation-fill-mode: forwards; -} -.solid-ripple-fade-out { - transform: translate(50%, 50%) scale(1); - opacity: 0.19; - animation: solid-ripple-fade-out 0.4s ease-in-out; -} - -.theme--light .solid-ripple { - background: #000; -} - -@keyframes solid-ripple-scale-up { - to { - transform: translate(50%, 50%) scale(1); - opacity: 0.2; - } -} - -@keyframes solid-ripple-fade-out { - to { - opacity: 0; - } -} diff --git a/src/components/Solid/Directives/Ripple/Ripple.ts b/src/components/Solid/Directives/Ripple/Ripple.ts deleted file mode 100644 index f02872a2c..000000000 --- a/src/components/Solid/Directives/Ripple/Ripple.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { onCleanup } from 'solid-js' -import './Ripple.css' - -declare module 'solid-js' { - namespace JSX { - interface Directives { - ripple: any - } - } -} - -function rippleDirective(el: HTMLElement, value: () => boolean) { - el.classList.add('solid-ripple-container') - - let span: HTMLElement | null = null - const onPointerDown = (event: MouseEvent) => { - if (span) { - reset() - } - if (typeof value === 'function' && !value()) return - - const bounds = el.getBoundingClientRect() - const x = event.pageX - const y = event.pageY - - const fromTop = y - bounds.top - const fromBottom = bounds.height - fromTop - const fromLeft = x - bounds.left - const fromRight = bounds.width - fromLeft - - const rippleDimension = - Math.ceil(Math.max(fromRight, fromLeft, fromTop, fromBottom)) * 2.3 - - span = generateRipple( - fromLeft - rippleDimension, - fromTop - rippleDimension, - rippleDimension - ) - - el.appendChild(span) - } - - let mayStartFadeOut = false - let pointerAlreadyUp = false - const onPointerUp = (event: MouseEvent) => { - if (!span) return - - if (mayStartFadeOut) { - span.classList.add('solid-ripple-fade-out') - } else { - pointerAlreadyUp = true - } - } - - const reset = () => { - span?.remove() - span = null - mayStartFadeOut = false - pointerAlreadyUp = false - } - - el.addEventListener('animationend', (event) => { - if (!span) return - - if (event.animationName === 'solid-ripple-scale-up') { - mayStartFadeOut = true - - if (pointerAlreadyUp) { - span.classList.add('solid-ripple-fade-out') - } - } else if (event.animationName === 'solid-ripple-fade-out') { - reset() - } - }) - - el.addEventListener('pointerdown', onPointerDown) - window.addEventListener('pointerup', onPointerUp) - - onCleanup(() => { - el.removeEventListener('click', onPointerDown) - window.removeEventListener('pointerup', onPointerUp) - el.classList.remove('solid-ripple-container') - - reset() - }) -} - -function generateRipple(x: number, y: number, rippleDimensions: number) { - const span = document.createElement('span') - span.classList.add('solid-ripple', 'solid-ripple-active') - - span.style.width = `${rippleDimensions}px` - span.style.height = `${rippleDimensions}px` - span.style.borderRadius = `${rippleDimensions}px` - - span.style.left = `${x}px` - span.style.top = `${y}px` - - return span -} - -export const useRipple = () => rippleDirective diff --git a/src/components/Solid/DirectoryViewer/Common/Name.css b/src/components/Solid/DirectoryViewer/Common/Name.css deleted file mode 100644 index 1aa183ef9..000000000 --- a/src/components/Solid/DirectoryViewer/Common/Name.css +++ /dev/null @@ -1,15 +0,0 @@ -.directory-viewer-name { - cursor: pointer; - transition: background-color 0.2s ease-in-out; - - /** New web thingy: https://web.dev/content-visibility/ */ - content-visibility: auto; - contain-intrinsic-size: 24px; -} -.directory-viewer-name.selected { - background: var(--v-background-base); - outline: none; -} -.directory-viewer-name:focus { - outline: none; -} diff --git a/src/components/Solid/DirectoryViewer/Common/Name.tsx b/src/components/Solid/DirectoryViewer/Common/Name.tsx deleted file mode 100644 index 58364d3a2..000000000 --- a/src/components/Solid/DirectoryViewer/Common/Name.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Dynamic, Show } from 'solid-js/web' -import { IconText } from '../../Icon/IconText' -import { toSignal } from '../../toSignal' -import { BaseWrapper } from '/@/components/UIElements/DirectoryViewer/Common/BaseWrapper' -import './Name.css' -import { createSignal } from 'solid-js' -import { SolidIcon } from '../../Icon/SolidIcon' - -export interface NameProps { - tagName: 'summary' | 'div' - baseWrapper: BaseWrapper -} - -export function Name(props: NameProps) { - const [isSelected] = toSignal(props.baseWrapper.isSelected) - const [isFocused, setIsFocused] = createSignal(false) - - const onFocus = (event: FocusEvent) => { - setIsFocused(true) - - if (props.baseWrapper.isSelected.value) return - select() - } - const select = () => { - props.baseWrapper.unselectAll() - props.baseWrapper.isSelected.value = true - } - const onMouseDown = (event: MouseEvent) => { - // Left click - if (event.button === 0) { - props.baseWrapper.onClick(event) - } else if (event.button === 2) { - props.baseWrapper.onRightClick(event) - } - } - - return ( - onFocus(event)} - onBlur={() => setIsFocused(false)} - > - - - - - - - ) -} diff --git a/src/components/Solid/DirectoryViewer/DirectoryView.tsx b/src/components/Solid/DirectoryViewer/DirectoryView.tsx deleted file mode 100644 index 9b97d6881..000000000 --- a/src/components/Solid/DirectoryViewer/DirectoryView.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Dynamic, Show, Switch, Match, For } from 'solid-js/web' -import { toSignal } from '../toSignal' -import { toVue } from '../toVue' -import { Name } from './Common/Name' -import { DirectoryWrapper } from '/@/components/UIElements/DirectoryViewer/DirectoryView/DirectoryWrapper' - -interface DirectoryViewProps { - hideDirectoryName?: boolean - directoryWrapper: DirectoryWrapper -} - -export function DirectoryView(props: DirectoryViewProps) { - const [children] = toSignal(props.directoryWrapper.children) - const [open] = toSignal(props.directoryWrapper.isOpen) - - return ( - - - - - -
- - {(child) => ( - - Unknown directory entry kind: "{child.kind}" - - } - > - - - - - - - - - )} - -
-
- ) -} - -export const VDirectoryView = toVue(DirectoryView) diff --git a/src/components/Solid/Icon/IconText.tsx b/src/components/Solid/Icon/IconText.tsx deleted file mode 100644 index 2ada36eeb..000000000 --- a/src/components/Solid/Icon/IconText.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Component } from 'solid-js' -import { useRipple } from '../Directives/Ripple/Ripple' -import { SolidIcon } from './SolidIcon' - -interface IconTextProps { - icon: string - text: string - opacity?: number - color?: string -} - -/** - * A component that displays text with an icon next to it - */ -export const IconText: Component = (props) => { - const ripple = useRipple() - const styles = { - 'white-space': 'nowrap', - overflow: 'hidden', - 'text-overflow': 'ellipsis', - width: '100%', - } as const - - return ( -
- - {props.text} -
- ) -} diff --git a/src/components/Solid/Icon/SolidIcon.tsx b/src/components/Solid/Icon/SolidIcon.tsx deleted file mode 100644 index 869d646f9..000000000 --- a/src/components/Solid/Icon/SolidIcon.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Component } from 'solid-js' -import { toVue } from '../toVue' - -interface SolidIconProps { - icon: string - class?: string - size?: 'small' | 'medium' | 'large' | number - offsetY?: number - color?: string - opacity?: number - onClick?: () => unknown - children?: string // Compat with Vuetify's v-icon which allows passing icon id as slot content -} - -/** - * A solid component which renders a mdi icon - */ -export const SolidIcon: Component = (props) => { - const icon = () => props.children?.trim() ?? props.icon - const iconSize = () => { - switch (props.size) { - case 'small': - return '1rem' - case 'medium': - return '1.4rem' - case 'large': - return '2rem' - default: - return props.size ? `${props.size}rem` : '1.4rem' - } - } - - const classList = () => ({ - [`mdi ${icon().startsWith('mdi-') ? icon() : `mdi-${icon()}`}`]: true, - 'cursor-pointer': !!props.onClick, - [`${props.color}--text`]: !!props.color, - }) - - return ( - - ) -} - -export const VIcon = toVue(SolidIcon) diff --git a/src/components/Solid/Inputs/Button/SolidButton.tsx b/src/components/Solid/Inputs/Button/SolidButton.tsx deleted file mode 100644 index 6698f0726..000000000 --- a/src/components/Solid/Inputs/Button/SolidButton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { JSX } from 'solid-js/types' -import { useRipple } from '../../Directives/Ripple/Ripple' - -interface SolidButtonProps { - class?: string - children: JSX.Element | string - color?: string - disabled?: boolean - onClick: () => void -} - -export function SolidButton(props: SolidButtonProps) { - const ripple = useRipple() - - return ( - - ) -} diff --git a/src/components/Solid/Inputs/IconButton/IconButton.tsx b/src/components/Solid/Inputs/IconButton/IconButton.tsx deleted file mode 100644 index 152875990..000000000 --- a/src/components/Solid/Inputs/IconButton/IconButton.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Component } from 'solid-js' -import { useRipple } from '../../Directives/Ripple/Ripple' -import { SolidIcon } from '../../Icon/SolidIcon' - -interface IconButtonProps { - icon: string - disabled?: boolean - size?: 'small' | 'medium' | 'large' | number - onClick: () => void -} - -export const SolidIconButton: Component = (props) => { - const ripple = useRipple() - - const isDisabled = () => props.disabled ?? false - - return ( - - ) -} diff --git a/src/components/Solid/Inputs/TextField/TextField.tsx b/src/components/Solid/Inputs/TextField/TextField.tsx deleted file mode 100644 index a3cb35f0e..000000000 --- a/src/components/Solid/Inputs/TextField/TextField.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Accessor, Component, Setter, Show } from 'solid-js' -import { useModel } from '../../Directives/Model/Model' -import { SolidIcon } from '../../Icon/SolidIcon' - -interface TextFieldProps { - class?: string - classList?: Record - disabled?: boolean - model?: [Accessor, Setter] - prependIcon?: string - placeholder?: string - - onEnter?: (input: string) => void -} - -export const TextField: Component = (props) => { - const model = useModel() - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter') { - props.onEnter?.((event.target as HTMLInputElement).value) - } - } - - return ( -
- - - - - -
- ) -} diff --git a/src/components/Solid/Logo.tsx b/src/components/Solid/Logo.tsx deleted file mode 100644 index aae720e4e..000000000 --- a/src/components/Solid/Logo.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Component } from 'solid-js' -import { isNightly } from '/@/utils/app/isNightly' - -export const SolidBridgeLogo: Component<{ - class: string -}> = (props) => { - const logoPath = isNightly - ? `/img/icons/nightly/favicon.svg` - : `/img/icons/favicon.svg` - - return ( - Logo of bridge. v2 - ) -} diff --git a/src/components/Solid/SolidRef.ts b/src/components/Solid/SolidRef.ts deleted file mode 100644 index f6dfce831..000000000 --- a/src/components/Solid/SolidRef.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createSignal } from 'solid-js' - -export function createRef(val: T) { - const [ref, setRef] = createSignal(val) - - return { - get value() { - return ref() - }, - set value(val: T) { - setRef(() => val) - }, - } -} diff --git a/src/components/Solid/SolidSpacer.tsx b/src/components/Solid/SolidSpacer.tsx deleted file mode 100644 index 5a20d09ed..000000000 --- a/src/components/Solid/SolidSpacer.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Component } from 'solid-js' - -export const SolidSpacer: Component = () => { - return
-} diff --git a/src/components/Solid/Window/Manager.tsx b/src/components/Solid/Window/Manager.tsx deleted file mode 100644 index 5a1f2c545..000000000 --- a/src/components/Solid/Window/Manager.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Component, createSignal, For } from 'solid-js' -import { Dynamic } from 'solid-js/web' -import { toVue } from '../toVue' -import { SolidWindow } from './Window' - -export class SolidWindowManager { - protected readState: () => SolidWindow[] - protected writeState: (val: SolidWindow[]) => void - - constructor() { - ;[this.readState, this.writeState] = createSignal([]) - } - - addWindow(window: SolidWindow) { - this.writeState([...this.readState(), window]) - - return { - dispose: () => - this.writeState(this.readState().filter((w) => w !== window)), - } - } - - getWindows() { - return this.readState() - } - - getComponent(): Component { - return () => ( - <> - - {(window) => } - - - ) - } - - getVueComponent() { - return toVue(this.getComponent()) - } -} diff --git a/src/components/Solid/Window/Window.tsx b/src/components/Solid/Window/Window.tsx deleted file mode 100644 index c398c8603..000000000 --- a/src/components/Solid/Window/Window.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { Component, createSignal, onMount, JSX, onCleanup } from 'solid-js' -import { Signal } from '../../Common/Event/Signal' -import { createRef } from '../SolidRef' -import { App } from '/@/App' - -export const WindowComponent: Component<{ - children: JSX.Element - currentWindow: SolidWindow -}> = (props) => { - const window = () => props.currentWindow - let dialog: HTMLDialogElement | undefined = undefined - const [shouldClose, setShouldClose] = createSignal(false) - - let delayedClose = false - const onClose = (event?: Event) => { - if (delayedClose) return - - // Prevent closing from ESC to show our close animation first - if (event) event.preventDefault() - setShouldClose(true) - - dialog?.addEventListener( - 'animationend', - () => { - setShouldClose(false) - delayedClose = true - window().close() - }, - { once: true } - ) - } - const onClickOutside = (event: any) => { - if (event.target.tagName !== 'DIALOG') - //This prevents issues with forms - return - - const rect = event.target.getBoundingClientRect() - - const clickedInDialog = - rect.top <= event.clientY && - event.clientY <= rect.top + rect.height && - rect.left <= event.clientX && - event.clientX <= rect.left + rect.width - - if (clickedInDialog === false) { - onClose() - } - } - - onMount(() => { - window().openEvent.on(() => { - // @ts-ignore - dialog?.showModal() - }) - window().closeEvent.on(() => { - // @ts-ignore - dialog?.close() - }) - - dialog?.addEventListener('cancel', onClose) - dialog?.addEventListener('click', onClickOutside) - }) - - onCleanup(() => { - dialog?.removeEventListener('cancel', onClose) - dialog?.removeEventListener('click', onClickOutside) - window().openEvent.disposeListeners() - window().closeEvent.disposeListeners() - }) - - return ( - <> - - {props.children} - - - ) -} - -export class SolidWindow { - public readonly isOpen = createRef(true) - public openEvent = new Signal() - public closeEvent = new Signal() - protected _disposeSelf: (() => void) | null = null - - constructor( - protected component: Component, - protected props: T = {} as T - ) { - this.open() - } - - get windowComponent(): Component { - return () => ( - - {this.component(this.props)} - - ) - } - - open() { - this._disposeSelf = App.solidWindows.addWindow(this).dispose - - this.openEvent.dispatch() - this.closeEvent.resetSignal() - } - close() { - this.closeEvent.dispatch() - this.openEvent.resetSignal() - this._disposeSelf?.() - this._disposeSelf = null - } -} diff --git a/src/components/Solid/toSignal.ts b/src/components/Solid/toSignal.ts deleted file mode 100644 index 753139010..000000000 --- a/src/components/Solid/toSignal.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Ref, watch, watchEffect } from 'vue' -import { createSignal, onCleanup } from 'solid-js' - -export function toSignal(ref: Ref) { - const [signal, setSignal] = createSignal(ref.value, { equals: false }) - - const dispose = watchEffect(() => { - setSignal(() => ref.value) - }) - - onCleanup(() => { - dispose() - }) - - return [ - signal, - (val: T) => { - ref.value = val - return val - }, - ] as const -} diff --git a/src/components/Solid/toVue.ts b/src/components/Solid/toVue.ts deleted file mode 100644 index 8ccf4c34f..000000000 --- a/src/components/Solid/toVue.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Component } from 'solid-js' -import { Component as VueComponent, ref, onMounted, onBeforeUnmount } from 'vue' -import { render } from 'solid-js/web' -import { SetupContext } from 'vue' - -/** - * An utility function to embed a Solid component into a Vue component. - */ -export function toVue(component: Component): VueComponent { - const vueWrapper: VueComponent = { - inheritAttrs: false, - template: `
`, - setup(_: any, { slots, attrs }: SetupContext) { - const mountRef = ref(null) - - // Hacky solution for getting compat with Vuetify's icons - // Tries to unwrap default slot into normal string - let childrenText: string | null = null - if (typeof slots.default === 'function') { - const [vnode] = slots.default() - if (vnode.text) childrenText = vnode.text - } - if (childrenText) attrs.children = childrenText - - let dispose: (() => void) | null = null - onMounted(() => { - if (!mountRef.value) return - - dispose = render( - () => component(attrs as unknown as T), - mountRef.value - ) - }) - - onBeforeUnmount(() => { - if (!dispose) return - - dispose() - dispose = null - }) - - return { - mountRef, - } - }, - } - - if (import.meta.env.DEV) { - vueWrapper.name = component.name.replace('_Hot$$', '') - } - - return vueWrapper -} diff --git a/src/components/StartParams/Action/openFileUrl.ts b/src/components/StartParams/Action/openFileUrl.ts deleted file mode 100644 index 74d36f7a3..000000000 --- a/src/components/StartParams/Action/openFileUrl.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { basename } from '/@/utils/path' -import type { IStartAction } from '../Manager' -import { VirtualFileHandle } from '/@/components/FileSystem/Virtual/FileHandle' -import { App } from '/@/App' - -export const openFileUrl: IStartAction = { - type: 'raw', - name: 'openFileUrl', - - onTrigger: async (value: string) => { - const resp = await fetch(value).catch(() => null) - if (!resp) return - - const file = new VirtualFileHandle( - null, - basename(value), - new Uint8Array(await resp.arrayBuffer()) - ) - - const app = await App.getApp() - - if (app.isNoProjectSelected) return - - await app.projectManager.projectReady.fired - - await app.fileDropper.importFile(file) - }, -} diff --git a/src/components/StartParams/Action/openRawFile.ts b/src/components/StartParams/Action/openRawFile.ts deleted file mode 100644 index 1bf054326..000000000 --- a/src/components/StartParams/Action/openRawFile.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { VirtualFileHandle } from '/@/components/FileSystem/Virtual/FileHandle' -import { App } from '/@/App' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import type { IStartAction } from '../Manager' -import { strFromU8, strToU8, zlibSync } from 'fflate' - -const textEncoder = new TextEncoder() - -export const openRawFileAction: IStartAction = { - type: 'compressed', - name: 'openRawFile', - onTrigger: async (value: string) => { - const firstNewLine = value.indexOf('\n') - - const [fileName, fileData] = [ - value.slice(0, firstNewLine), - value.slice(firstNewLine + 1), - ] - - const file = new VirtualFileHandle( - null, - fileName, - textEncoder.encode(fileData) - ) - const app = await App.getApp() - if (app.isNoProjectSelected) return - await app.projectManager.projectReady.fired - await app.fileDropper.importFile(file) - }, -} - -/** - * Share a file with other users - * @param file A file handle representing the file to share - */ -export async function shareFile(file: AnyFileHandle) { - const fileContent = await file.getFile().then((file) => file.text()) - - if (typeof navigator.share !== 'function') return - - const url = new URL(window.location.href) - - if (!App.sidebar.isContentVisible.value) - url.searchParams.set('setSidebarState', 'hidden') - - url.searchParams.set( - 'openRawFile', - btoa( - strFromU8( - zlibSync(strToU8(`${file.name}\n${fileContent}`), { - level: 9, - }), - true - ) - ) - ) - - await navigator - .share({ - title: `View File: ${file.name}`, - text: `Edit the file "${file.name}" file with bridge.!`, - url: url.href, - }) - .catch(() => {}) -} diff --git a/src/components/StartParams/Action/sidebarState.ts b/src/components/StartParams/Action/sidebarState.ts deleted file mode 100644 index b471e3085..000000000 --- a/src/components/StartParams/Action/sidebarState.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { IStartAction } from '../Manager' -import { App } from '/@/App' - -export const setSidebarState: IStartAction = { - type: 'raw', - name: 'setSidebarState', - onTrigger: async (value: string) => { - if (value === 'hidden') { - App.sidebar.toggleSidebarContent(null) - App.sidebar.forcedInitialState.value = true - } - }, -} diff --git a/src/components/StartParams/Action/viewExtension.ts b/src/components/StartParams/Action/viewExtension.ts deleted file mode 100644 index 1dccafd17..000000000 --- a/src/components/StartParams/Action/viewExtension.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IStartAction } from '../Manager' -import { App } from '/@/App' - -export const viewExtension: IStartAction = { - type: 'encoded', - name: 'viewExtension', - onTrigger: async (value: string) => { - const app = await App.getApp() - app.windows.extensionStore.open(value) - }, -} diff --git a/src/components/StartParams/Manager.ts b/src/components/StartParams/Manager.ts deleted file mode 100644 index 5dd1248d3..000000000 --- a/src/components/StartParams/Manager.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Start paramters are encoded within the URL's search query - */ - -import { strToU8, strFromU8, unzlibSync } from 'fflate' -import { openFileUrl } from './Action/openFileUrl' -import { openRawFileAction } from './Action/openRawFile' -import { setSidebarState } from './Action/sidebarState' -import { viewExtension } from './Action/viewExtension' - -export interface IStartAction { - type: 'compressed' | 'encoded' | 'raw' - name: string - onTrigger: (value: string) => Promise | void -} -export class StartParamManager { - protected startActions = new Map() - - constructor(actions: IStartAction[] = []) { - actions.forEach((action) => this.addStartAction(action)) - this.addStartAction(openRawFileAction) - this.addStartAction(openFileUrl) - this.addStartAction(setSidebarState) - this.addStartAction(viewExtension) - - this.parseRaw(window.location.search) - } - - async parse(url: string) { - // Get search query from url - const searchStr = url.split('?')[1] - if (!searchStr) return - // Parse search query - await this.parseRaw(searchStr) - } - - protected async parseRaw(searchStr: string) { - const urlParams = new URLSearchParams(searchStr) - - if ([...urlParams.keys()].length === 0) return - - urlParams.forEach(async (value, name) => { - const action = this.startActions.get(name) - if (!action) return - - let decoded: string - if (action.type === 'compressed') { - const binary = atob(value) - - // Support old compressed data; this was only shortly within a nightly build - // So we should be able to remove this in the future - if (!binary.startsWith('\x78\xDA')) { - const { decompressFromEncodedURIComponent } = await import( - 'lz-string' - ) - const tmp = decompressFromEncodedURIComponent(value) - if (!tmp) return - - decoded = tmp - } - - decoded = strFromU8(unzlibSync(strToU8(binary, true))) - } else if (action.type === 'encoded') { - decoded = decodeURIComponent(value) - } else if (action.type === 'raw') { - decoded = value - } else { - throw new Error(`Unknown start action type: "${action.type}"`) - } - - action.onTrigger(decoded) - }) - } - - addStartAction(action: IStartAction) { - this.startActions.set(action.name, action) - - return { - dispose: () => this.startActions.delete(action.name), - } - } -} diff --git a/src/components/TabSystem/CommonTab.ts b/src/components/TabSystem/CommonTab.ts deleted file mode 100644 index da4e3f846..000000000 --- a/src/components/TabSystem/CommonTab.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { v4 as uuid } from 'uuid' -import { TabSystem } from './TabSystem' -import { App } from '/@/App' -import { showContextMenu } from '/@/components/ContextMenu/showContextMenu' -import { Signal } from '/@/components/Common/Event/Signal' -import { SimpleAction } from '/@/components/Actions/SimpleAction' -import { EventDispatcher } from '../Common/Event/EventDispatcher' -import { AnyFileHandle } from '../FileSystem/Types' -import { shareFile } from '../StartParams/Action/openRawFile' -import { getDefaultFileIcon } from '/@/utils/file/getIcon' -import { settingsState } from '../Windows/Settings/SettingsState' -import { fullScreenAction } from './TabContextMenu/Fullscreen' - -export abstract class Tab extends Signal { - abstract component: Vue.Component - public uuid = uuid() - public hasRemoteChange = false - protected _isUnsaved = false - public isForeignFile = true - public connectedTabs: Tab[] = [] - public isTemporary = !settingsState?.editor?.keepTabsOpen ?? true - public readonly onClose = new EventDispatcher() - - protected path?: string = undefined - protected folderName: string | null = null - protected actions: SimpleAction[] = [] - protected isActive = false - protected isLoading = true - - static is(fileHandle: AnyFileHandle) { - return false - } - - constructor(protected parent: TabSystem) { - super() - window.setTimeout(() => this.setup()) - } - - async setup() { - this.dispatch(this) - this.isLoading = false - } - get project() { - return this.parent.project - } - - setIsLoading(val: boolean) { - this.isLoading = val - } - - setIsUnsaved(val: boolean) { - this._isUnsaved = val - this.isTemporary = false - } - get isUnsaved() { - return this._isUnsaved - } - - updateParent(parent: TabSystem) { - this.parent = parent - } - get tabSystem() { - return this.parent - } - get isSharingScreen() { - return this.parent.isSharingScreen.value - } - - abstract get name(): string - setFolderName(folderName: string | null) { - this.folderName = folderName - } - - /** - * @returns Undefined if the file that belongs to this tab is not inside of a bridge. project - */ - getPath() { - if (!this.path) - throw new Error( - `Trying to access projectPath before tab finished loading` - ) - return this.path - } - /** - * @deprecated Do not use! - * @returns Undefined if the file that belongs to this tab is not inside of the current project - */ - getProjectPath() { - console.warn( - `CommonTab.getProjectPath() is deprecated in favor of CommonTab.getPath()` - ) - if (!this.path) - throw new Error( - `Trying to access projectPath before tab finished loading` - ) - return this.path.split('/').slice(2).join('/') - } - get icon() { - return ( - App.fileType.get(this.getPath())?.icon ?? - getDefaultFileIcon(this.getPath()) - ) - } - get iconColor() { - if (!this.hasFired) return 'accent' - return App.packType.get(this.getPath(), true)?.color - } - - get isSelected(): boolean { - return this.parent.selectedTab === this - } - async select() { - await this.parent.select(this) - return this - } - /** - * - * @returns Whether the tab was closed - */ - async close(): Promise { - this.parent.setActive(true) - - const didClose = await this.parent.close(this) - if (didClose) { - this.connectedTabs.forEach((tab) => tab.close()) - this.onClose.dispatch() - } - return didClose - } - async is(tab: Tab): Promise { - return false - } - // abstract restore(data: TRestoreData): Promise - // abstract serialize(): Promise - - focus() {} - async onActivate() { - this.isActive = true - } - onDeactivate() { - this.isActive = false - } - onDestroy() {} - protected async toOtherTabSystem(updateParentTabs = true) { - const app = await App.getApp() - const tabSystems = app.projectManager.currentProject?.tabSystems! - const wasSelected = this.isSelected - - const from = - tabSystems[0] === this.parent ? tabSystems[0] : tabSystems[1] - const to = tabSystems[0] === this.parent ? tabSystems[1] : tabSystems[0] - - this.parent = to - - if (updateParentTabs) { - from.remove(this, false) - await to.add(this, true) - } else { - if (!this.isForeignFile) { - await to.openedFiles.add(this.getPath()) - await from.openedFiles.remove(this.getPath()) - } - - if (wasSelected) await from.select(from.tabs.value[0]) - - await to.select(this) - } - } - - addAction(...actions: SimpleAction[]) { - this.actions.push(...actions) - } - clearActions() { - this.actions = [] - } - - async onContextMenu(event: MouseEvent) { - const additionalItems = [] - // @ts-ignore - if (this.fileHandle) - additionalItems.push({ - icon: 'mdi-share', - name: 'general.shareFile', - onTrigger: async () => { - // @ts-ignore - await shareFile(this.fileHandle) - }, - }) - - // It makes no sense to move a file to the split-screen if the tab system only has one entry - if (this.isTemporary) { - additionalItems.push({ - name: 'actions.keepInTabSystem.name', - icon: 'mdi-pin-outline', - onTrigger: () => { - this.isTemporary = false - }, - }) - } - if (this.parent.tabs.value.length > 1) { - additionalItems.push({ - name: 'actions.moveToSplitScreen.name', - icon: 'mdi-arrow-split-vertical', - onTrigger: async () => { - this.toOtherTabSystem() - }, - }) - } - - if (additionalItems.length > 0) - additionalItems.push({ type: 'divider' }) - - await showContextMenu(event, [ - fullScreenAction(false), - ...additionalItems, - { - name: 'actions.closeTab.name', - icon: 'mdi-close', - onTrigger: () => { - this.close() - }, - }, - { - name: 'actions.closeAll.name', - icon: 'mdi-table-row', - onTrigger: () => { - this.parent.closeTabs(() => true) - }, - }, - { - name: 'actions.closeTabsToRight.name', - icon: 'mdi-chevron-right', - onTrigger: () => { - let closeTabs = true - this.parent.closeTabs((tab) => { - if (tab === this) closeTabs = false - return closeTabs - }) - }, - }, - { - name: 'actions.closeAllSaved.name', - icon: 'mdi-content-save-outline', - onTrigger: () => { - this.parent.closeTabs((tab) => !tab.isUnsaved) - }, - }, - { - name: 'actions.closeOtherTabs.name', - icon: 'mdi-unfold-more-vertical', - onTrigger: () => { - this.parent.closeTabs((tab) => tab !== this) - }, - }, - ]) - } - - copy() { - document.execCommand('copy') - } - cut() { - document.execCommand('cut') - } - paste() {} -} diff --git a/src/components/TabSystem/FileTab.ts b/src/components/TabSystem/FileTab.ts index d72774336..b22f9c724 100644 --- a/src/components/TabSystem/FileTab.ts +++ b/src/components/TabSystem/FileTab.ts @@ -1,269 +1,29 @@ -import { Tab } from './CommonTab' -import { TabSystem } from './TabSystem' -import { v4 as uuid } from 'uuid' -import { AnyFileHandle } from '../FileSystem/Types' -import { VirtualFileHandle } from '../FileSystem/Virtual/FileHandle' -import { App } from '/@/App' -import { - isUsingFileSystemPolyfill, - isUsingSaveAsPolyfill, -} from '../FileSystem/Polyfill' -import { download } from '../FileSystem/saveOrDownload' -import { writableToUint8Array } from '/@/utils/file/writableToUint8Array' -import { settingsState } from '../Windows/Settings/SettingsState' -import { debounce } from 'lodash-es' -import { setRichPresence } from '/@/utils/setRichPresence' -import { translate } from '../Locales/Manager' +import { basename } from 'pathe' +import { Tab } from './Tab' +import { ref, Ref } from 'vue' -export type TReadOnlyMode = 'forced' | 'manual' | 'off' +export class FileTab extends Tab { + public modified: Ref = ref(false) + public canSave: boolean = true -const shouldAutoSave = async () => { - const app = await App.getApp() + constructor(public path: string) { + super() - // Check that either the auto save setting is enabled or fallback to activating it by default on mobile - return ( - settingsState?.editor?.autoSaveChanges ?? app.mobile.isCurrentDevice() - ) -} - -const throttledFileDidChange = debounce<(tab: FileTab) => Promise | void>( - async (tab) => { - tab.tryAutoSave() - }, - 3000 // 3s delay for fileDidChange -) - -export abstract class FileTab extends Tab { - public isForeignFile = false - public isSaving = false - - constructor( - protected parent: TabSystem, - protected fileHandle: AnyFileHandle, - public readOnlyMode: TReadOnlyMode = 'off' - ) { - super(parent) - } - - get isReadOnly() { - return this.readOnlyMode !== 'off' - } - - async setup() { - this.isForeignFile = false - if ( - this.fileHandle instanceof VirtualFileHandle && - this.fileHandle.getParent() === null - ) - this.path = undefined - else - this.path = await this.parent.app.fileSystem.pathTo(this.fileHandle) - - // If the resolve above failed, we are dealing with a file which doesn't belong to this project - if (!this.path || !this.parent.project.isFileWithinProject(this.path)) { - await App.fileType.ready.fired - - this.isForeignFile = true - - let guessedFolder = - // Convince TypeScript that this is a FileSystemFileHandle - // We can do this because the VirtualFileHandle is sufficient for the guessFolder function - (await App.fileType.guessFolder( - this.fileHandle - )) ?? uuid() - if (!guessedFolder.endsWith('/')) guessedFolder += '/' - - this.path = `${guessedFolder}${uuid()}/${this.fileHandle.name}` - } - - this.parent.project.packIndexer.once(async () => { - const packIndexer = this.parent.project.packIndexer - - if (!(await packIndexer.hasFile(this.path!))) { - await packIndexer.updateFile( - this.path!, - await this.getFile().then((file) => file.text()), - this.isForeignFile - ) - - if (App.fileType.isJsonFile(this.getPath())) { - this.parent.project.jsonDefaults.updateDynamicSchemas( - this.getPath() - ) - } - } - }) - - await super.setup() + this.name.value = basename(path) } - async onDeactivate() { - await this.tryAutoSave() - super.onDeactivate() + public static canEdit(path: string): boolean { + return false } - async onActivate() { - const fileTypeName = translate( - 'fileType.' + this.getFileType(), - 'en' - ).toLowerCase() - - let a = 'a' - // Append "n" to the beginning of the file type name if it starts with a vowel - if (['a', 'e', 'i', 'o', 'u'].includes(fileTypeName.charAt(0))) a = 'an' - setRichPresence({ - details: 'Developing add-ons...', - state: `Editing ${a} ${fileTypeName} file`, - }) - await super.onActivate() - } - - get name() { - return this.fileHandle.name - } - getFileType() { - return App.fileType.getId(this.getPath()) + public static editPriority(path: string): number { + return 0 } - async is(tab: Tab): Promise { - if (!(tab instanceof FileTab)) return false - - return await this.isForFileHandle(tab.fileHandle) - } - async isForFileHandle(fileHandle: AnyFileHandle) { - if ( - (fileHandle).isVirtual !== - (this.fileHandle).isVirtual - ) - return false - - // @ts-ignore This error can be ignored because of the check above - return await fileHandle.isSameEntry(this.fileHandle) - } - - getFile() { - return this.fileHandle.getFile() - } - getFileHandle() { - return this.fileHandle + public is(path: string): boolean { + return false } - abstract setReadOnly(readonly: TReadOnlyMode): Promise | void - - /** - * **Important:** This function needs to be called when appropriate by the tabs implementing this class - */ - fileDidChange() { - throttledFileDidChange(this) - } - - // Logic for auto-saving - async tryAutoSave() { - // File handle has no parent context -> Auto-saving would have undesirable consequences such as constant file downloads - if (this.fileHandleWithoutParentContext()) return - - // Check whether we should auto save and that the file has been changed - if ((await shouldAutoSave()) && this.isUnsaved) { - await this.save() - } - } - - async save() { - if (this.isSaving) return - this.isSaving = true - // this.setReadOnly('forced') - - await this._save() - - this.isSaving = false - // this.setReadOnly('off') - } - protected abstract _save(): void | Promise - async saveAs() { - // Download the file if the user is using a file system polyfill - if (isUsingSaveAsPolyfill) { - const file = await this.fileHandle.getFile() - - download( - this.fileHandle.name, - new Uint8Array(await file.arrayBuffer()) - ) - return - } - - const fileHandle = await self - .showSaveFilePicker({ - // @ts-ignore The type package doesn't know about suggestedName yet - suggestedName: this.fileHandle.name, - // @ts-ignore The type package doesn't know about startIn yet - startIn: this.fileHandle, - }) - .catch(() => null) - if (!fileHandle) return - - // Remove tab from openedFiles list - if (!this.isForeignFile) this.parent.openedFiles.remove(this.getPath()) - - this.fileHandle = fileHandle - - this.resetSignal() - - // After updating the file handle, we need to re-setup the tab - await this.setup() - await this.parent.save(this) - - // Add tab with new path to openedFiles list - if (!this.isForeignFile) this.parent.openedFiles.add(this.getPath()) - } - - /** - * Check whether the given file handle has no parent context -> Save by "Save As" or file download - * @param fileHandle - * @returns boolean - */ - protected fileHandleWithoutParentContext() { - return ( - this.fileHandle instanceof VirtualFileHandle && - !this.fileHandle.hasParentContext - ) - } - - protected async writeFile(value: BufferSource | Blob | string) { - // Current file handle is a virtual file without parent - if (this.fileHandleWithoutParentContext()) { - // Download the file if the user is using a file system polyfill - if (isUsingFileSystemPolyfill.value) { - download( - this.fileHandle.name, - await writableToUint8Array(value) - ) - } - // Otherwise Prompt the user to choose a save location - else { - let fileHandle - try { - fileHandle = await window.showSaveFilePicker({ - suggestedName: this.fileHandle.name, - // @ts-ignore The type package doesn't know about startIn yet - startIn: this.parent.app.fileSystem.baseDirectory, - }) - } catch {} - - if (!fileHandle) return false - this.fileHandle = fileHandle - this.resetSignal() - - // After updating the file handle, we need to re-setup the tab - await this.setup() - } - - // We're done here - return true - } - - return await this.parent.app.fileSystem - .write(this.fileHandle, value) - .then(() => true) - .catch(() => false) - } + public async save() {} + public async saveAs(savePath: string) {} } diff --git a/src/components/TabSystem/MonacoHolder.ts b/src/components/TabSystem/MonacoHolder.ts deleted file mode 100644 index fe5e1726d..000000000 --- a/src/components/TabSystem/MonacoHolder.ts +++ /dev/null @@ -1,321 +0,0 @@ -import type { editor, KeyCode, KeyMod } from 'monaco-editor' -import { Signal } from '../Common/Event/Signal' -import { settingsState } from '../Windows/Settings/SettingsState' -import { App } from '/@/App' -import { IDisposable } from '/@/types/disposable' -import { DefinitionProvider } from '../Definitions/GoTo' -import { getJsonWordAtPosition } from '/@/utils/monaco/getJsonWord' -import { viewDocumentation } from '../Documentation/view' -import { isWithinQuotes } from '/@/utils/monaco/withinQuotes' -import { markRaw } from 'vue' -import { debounce } from 'lodash-es' -import { platform } from '/@/utils/os' -import { showContextMenu } from '../ContextMenu/showContextMenu' -import { TextTab } from '../Editors/Text/TextTab' -import { useMonaco } from '../../utils/libs/useMonaco' -import { registerTextSnippetProvider } from '../Snippets/Monaco' -import { anyMonacoThemeLoaded } from '../Extensions/Themes/MonacoSubTheme' - -let configuredMonaco = false - -export class MonacoHolder extends Signal { - protected _monacoEditor?: editor.IStandaloneCodeEditor - protected disposables: IDisposable[] = [] - - constructor(protected _app: App) { - super() - - if (!configuredMonaco) { - configuredMonaco = true - useMonaco().then(({ languages }) => { - languages.typescript.javascriptDefaults.setCompilerOptions({ - target: languages.typescript.ScriptTarget.ESNext, - allowNonTsExtensions: true, - alwaysStrict: true, - checkJs: true, - }) - languages.typescript.typescriptDefaults.setCompilerOptions({ - target: languages.typescript.ScriptTarget.ESNext, - allowNonTsExtensions: true, - alwaysStrict: true, - moduleResolution: - languages.typescript.ModuleResolutionKind.NodeJs, - module: languages.typescript.ModuleKind.ESNext, - }) - - languages.registerDefinitionProvider( - 'json', - new DefinitionProvider() - ) - - registerTextSnippetProvider() - }) - } - } - - get monacoEditor() { - if (!this._monacoEditor) - throw new Error(`Accessed Monaco Editor before it was defined`) - return this._monacoEditor - } - - getMobileOptions(isMobile: boolean) { - return { - lineNumbers: isMobile ? 'off' : 'on', - minimap: { enabled: !isMobile }, - tabSize: isMobile ? 2 : 4, - scrollbar: { - horizontalScrollbarSize: isMobile ? 15 : undefined, - verticalScrollbarSize: isMobile ? 20 : undefined, - horizontalSliderSize: isMobile ? 15 : undefined, - verticalSliderSize: isMobile ? 20 : undefined, - }, - } - } - - async createMonacoEditor(domElement: HTMLElement) { - const { KeyCode, KeyMod, editor } = await useMonaco() - - // Don't mount editor before theme is loaded - await anyMonacoThemeLoaded.fired - - this.dispose() - this._monacoEditor = markRaw( - editor.create(domElement, { - wordBasedSuggestions: false, - theme: `bridgeMonacoDefault`, - roundedSelection: false, - autoIndent: 'full', - fontSize: Number( - ( - settingsState?.appearance?.editorFontSize ?? - '14px' - ).replace('px', '') - ), - // @ts-expect-error The monaco team did not update the types yet - 'bracketPairColorization.enabled': - settingsState?.editor?.bracketPairColorization ?? false, - fontFamily: - (settingsState?.appearance?.editorFont ?? - (platform() === 'darwin' ? 'Menlo' : 'Consolas')) + - ', monospace', - ...this.getMobileOptions(this._app.mobile.isCurrentDevice()), - contextmenu: false, - // fontFamily: this.fontFamily, - wordWrap: settingsState?.editor?.wordWrap ? 'bounded' : 'off', - wordWrapColumn: Number( - settingsState?.editor?.wordWrapColumns ?? '80' - ), - }) - ) - // @ts-ignore - const editorService = this._monacoEditor._codeEditorService - const openEditorBase = editorService.openCodeEditor.bind(editorService) - editorService.openCodeEditor = debounce( - async (input: any, source: any, sideBySide?: boolean) => { - let result = await openEditorBase(input, source, sideBySide) - - if (!result) { - try { - const currentTab = this._app.tabSystem?.selectedTab - if (currentTab) currentTab.isTemporary = false - - await this._app.project.tabSystem?.openPath( - input.resource.path.slice(1) - ) - } catch { - console.error( - `Failed to open file "${input.resource.path.slice( - 1 - )}"` - ) - } - - // source.setModel(editor.getModel(input.resource)); - } - return result // always return the base result - }, - 100 - ) - - // Workaround to make the toggleLineComment action work - const commentLineAction = this._monacoEditor.getAction( - 'editor.action.commentLine' - ) - this._monacoEditor.addAction({ - ...commentLineAction, - - keybindings: [KeyMod.CtrlCmd | KeyCode.Backslash], - run(...args: any[]) { - // @ts-ignore - this._run(...args) - }, - }) - - // This snippet configures some extra trigger characters for JSON - this._monacoEditor.onDidChangeModelContent((event) => { - const filePath = this._app.tabSystem?.selectedTab?.getPath() - if (!filePath) return - - if (!App.fileType.isJsonFile(filePath)) return - - const model = this._monacoEditor?.getModel() - const position = this._monacoEditor?.getSelection()?.getPosition() - - // Better auto-complete within quotes that represent MoLang/commands - if (model && position && isWithinQuotes(model, position)) { - if (event.changes.some((change) => change.text === ' ')) { - // Timeout is needed for some reason. Otherwise the auto-complete menu doesn't show up - setTimeout(() => { - this._monacoEditor?.trigger( - 'auto', - 'editor.action.triggerSuggest', - {} - ) - }, 50) - } - } - }) - - this._monacoEditor.layout() - this.disposables.push( - this._app.windowResize.on(() => - setTimeout(() => this._monacoEditor?.layout()) - ) - ) - this.disposables.push( - this._app.mobile.change.on((isMobile) => { - this._monacoEditor?.updateOptions( - this.getMobileOptions(isMobile) - ) - }) - ) - - this.dispatch() - } - - updateOptions(options: editor.IEditorConstructionOptions) { - this._monacoEditor?.updateOptions(options) - } - - dispose() { - this._monacoEditor?.dispose() - this.disposables.forEach((d) => d.dispose()) - this.disposables = [] - this._monacoEditor = undefined - this.resetSignal() - } - - private onReadonlyCustomMonacoContextMenu() { - return [ - { - name: 'actions.documentationLookup.name', - icon: 'mdi-book-open-outline', - onTrigger: async () => { - const currentModel = this._monacoEditor?.getModel() - const selection = this._monacoEditor?.getSelection() - if (!currentModel || !selection) return - - const filePath = this._app.tabSystem?.selectedTab?.getPath() - if (!filePath) return - - let word: string | undefined - if (App.fileType.isJsonFile(filePath)) - word = await getJsonWordAtPosition( - currentModel, - selection.getPosition() - ).then((res) => res.word) - else - word = currentModel.getWordAtPosition( - selection.getPosition() - )?.word - - viewDocumentation(filePath, word) - }, - }, - { - name: 'actions.goToDefinition.name', - icon: 'mdi-magnify', - onTrigger: () => { - this._monacoEditor?.trigger( - 'contextmenu', - 'editor.action.revealDefinition', - null - ) - }, - }, - { - name: 'actions.goToSymbol.name', - icon: 'mdi-at', - onTrigger: () => { - setTimeout(() => { - this._monacoEditor?.focus() - this._monacoEditor?.trigger( - 'contextmenu', - 'editor.action.quickOutline', - null - ) - }) - }, - }, - ] - } - - showCustomMonacoContextMenu(event: MouseEvent, tab: TextTab) { - if (tab.isReadOnly) - return showContextMenu( - event, - this.onReadonlyCustomMonacoContextMenu() - ) - - showContextMenu(event, [ - ...this.onReadonlyCustomMonacoContextMenu(), - { type: 'divider' }, - { - name: 'actions.changeAllOccurrences.name', - icon: 'mdi-pencil-outline', - onTrigger: () => { - this._monacoEditor?.trigger( - 'contextmenu', - 'editor.action.rename', - null - ) - }, - }, - - { - name: 'actions.formatDocument.name', - icon: 'mdi-text-box-check-outline', - onTrigger: () => { - this._monacoEditor?.trigger( - 'contextmenu', - 'editor.action.formatDocument', - null - ) - }, - }, - { type: 'divider' }, - { - name: 'actions.copy.name', - icon: 'mdi-content-copy', - onTrigger: () => { - document.execCommand('copy') - }, - }, - { - name: 'actions.cut.name', - icon: 'mdi-content-cut', - onTrigger: () => { - tab.cut() - }, - }, - { - name: 'actions.paste.name', - icon: 'mdi-content-paste', - onTrigger: () => { - tab.paste() - }, - }, - ]) - } -} diff --git a/src/components/TabSystem/OpenedFiles.ts b/src/components/TabSystem/OpenedFiles.ts deleted file mode 100644 index 3d8dd051c..000000000 --- a/src/components/TabSystem/OpenedFiles.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { settingsState } from '/@/components/Windows/Settings/SettingsState' -import { App } from '/@/App' -import { PersistentQueue } from '/@/components/Common/PersistentQueue' -import { TabSystem } from '/@/components/TabSystem/TabSystem' - -export class OpenedFiles extends PersistentQueue { - constructor(protected tabSystem: TabSystem, app: App, savePath: string) { - super(app, Infinity, savePath) - } - - async restoreTabs() { - if (settingsState?.general?.restoreTabs ?? true) { - await this.fired - - for (const file of this.queue.elements.reverse()) { - try { - // Try to restore tab - await this.tabSystem.openPath(file, { - selectTab: file == this.queue.elements[0], - isTemporary: false, - }) - } catch {} - } - } - } -} diff --git a/src/components/TabSystem/PreviewTab.ts b/src/components/TabSystem/PreviewTab.ts deleted file mode 100644 index 860d734c5..000000000 --- a/src/components/TabSystem/PreviewTab.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { translate } from '../Locales/Manager' -import { Tab } from './CommonTab' -import { FileTab } from './FileTab' -import { TabSystem } from './TabSystem' - -export abstract class PreviewTab extends Tab { - public readonly isForeignFile = true - static is() { - return false - } - - constructor(protected tab: FileTab, parent: TabSystem) { - super(parent) - - this.onCreate() - } - onCreate() {} - async onActivate() { - this.onChange() - this.isActive = true - } - - get name() { - return `${translate('preview.name')}: ${this.tab.name}` - } - - abstract onChange(): Promise | void - - save() {} - getFile() { - return this.tab.getFile() - } - abstract reload(): Promise | void -} diff --git a/src/components/TabSystem/Tab.ts b/src/components/TabSystem/Tab.ts new file mode 100644 index 000000000..5ee936bde --- /dev/null +++ b/src/components/TabSystem/Tab.ts @@ -0,0 +1,28 @@ +import { Event } from '@/libs/event/Event' +import { v4 as uuid } from 'uuid' +import { Component, Ref, ref } from 'vue' + +export type RecoveryState = { id: string; state: any; type: string; temporary: boolean; [key: string]: any } + +export class Tab { + public id = uuid() + public component: Component | null = null + public name = ref('New Tab') + public icon: Ref = ref(null) + public active: boolean = false + public temporary: Ref = ref(false) + + public savedState = new Event() + + public async create() {} + public async destroy() {} + public async activate() {} + public async deactivate() {} + + public async getState(): Promise {} + public async recover(state: any) {} + + public async saveState() { + this.savedState.dispatch() + } +} diff --git a/src/components/TabSystem/Tab.vue b/src/components/TabSystem/Tab.vue deleted file mode 100644 index ae8769505..000000000 --- a/src/components/TabSystem/Tab.vue +++ /dev/null @@ -1,214 +0,0 @@ - - - - - diff --git a/src/components/TabSystem/TabActions/Action.vue b/src/components/TabSystem/TabActions/Action.vue deleted file mode 100644 index ae44e5203..000000000 --- a/src/components/TabSystem/TabActions/Action.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - - diff --git a/src/components/TabSystem/TabActions/ActionBar.vue b/src/components/TabSystem/TabActions/ActionBar.vue deleted file mode 100644 index bf4e4dd06..000000000 --- a/src/components/TabSystem/TabActions/ActionBar.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/src/components/TabSystem/TabActions/Provider.ts b/src/components/TabSystem/TabActions/Provider.ts deleted file mode 100644 index a2017b011..000000000 --- a/src/components/TabSystem/TabActions/Provider.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { App } from '/@/App' -import { SimpleAction } from '/@/components/Actions/SimpleAction' -import { Tab } from '../CommonTab' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { TabSystem } from '../TabSystem' - -export interface ITabActionConfig { - icon: string - name: string - trigger(tab: FileTab): Promise | void - isFor(tab: FileTab): Promise | boolean - isDisabled?: (tab: FileTab) => boolean -} - -export interface ITabPreviewConfig { - name: string - /** - * @deprecated Deprecated in favor of matching by file type - */ - fileMatch?: string - fileType: string - createPreview(tabSystem: TabSystem, tab: FileTab): Promise -} - -export class TabActionProvider { - protected definitions = new Set() - protected processedFileTabs = new Set() - - register(definition: ITabActionConfig) { - this.definitions.add(definition) - - // Update current tabs - this.processedFileTabs.forEach((fileTab) => { - if (definition.isFor(fileTab)) - fileTab.addAction( - new SimpleAction({ - icon: definition.icon, - name: definition.name, - isDisabled: () => - definition.isDisabled?.(fileTab) ?? false, - onTrigger: () => definition.trigger(fileTab), - }) - ) - }) - - return { - dispose: () => { - this.definitions.delete(definition) - - // Update current tabs - this.processedFileTabs.forEach((fileTab) => { - if (definition.isFor(fileTab)) { - fileTab.clearActions() - this.addTabActions(fileTab) - } - }) - }, - } - } - registerPreview(definition: ITabPreviewConfig) { - return this.register({ - icon: 'mdi-play', - name: definition.name, - isFor: (fileTab) => { - if (!(fileTab instanceof FileTab)) return false - else if (definition.fileType) - return fileTab.getFileType() === definition.fileType - else if (definition.fileMatch) - return fileTab - .getProjectPath() - .startsWith(definition.fileMatch) - - return false - }, - trigger: async (fileTab) => { - const app = await App.getApp() - - const previewTab = await definition.createPreview( - app.project.inactiveTabSystem!, - fileTab - ) - if (!previewTab) return - - previewTab.isTemporary = false - fileTab.connectedTabs.push(previewTab) - - app.project.inactiveTabSystem?.add(previewTab, true) - app.project.inactiveTabSystem?.setActive(true) - }, - }) - } - - async addTabActions(fileTab: FileTab) { - this.processedFileTabs.add(fileTab) - fileTab.onClose.on(() => this.processedFileTabs.delete(fileTab)) - - for (const def of this.definitions) { - if (await def.isFor(fileTab)) - fileTab.addAction( - new SimpleAction({ - icon: def.icon, - name: def.name, - onTrigger: () => def.trigger(fileTab), - }) - ) - } - } -} diff --git a/src/components/TabSystem/TabBar.vue b/src/components/TabSystem/TabBar.vue deleted file mode 100644 index d7829ae3d..000000000 --- a/src/components/TabSystem/TabBar.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - - - diff --git a/src/components/TabSystem/TabContextMenu/Fullscreen.ts b/src/components/TabSystem/TabContextMenu/Fullscreen.ts deleted file mode 100644 index 3e434f903..000000000 --- a/src/components/TabSystem/TabContextMenu/Fullscreen.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ref } from 'vue' - -export const isInFullScreen = ref(false) -let fullScreenElement: HTMLElement | null = null -export function setFullscreenElement(el: HTMLElement) { - fullScreenElement = el -} -export function getFullScreenElement() { - return fullScreenElement -} -export function useFullScreen() { - return { - isInFullScreen, - } -} - -export const fullScreenAction = (addDescription = true) => - typeof document.body.requestFullscreen === 'function' - ? { - icon: 'mdi-fullscreen', - name: 'actions.fullscreen.name', - description: addDescription - ? 'actions.fullscreen.description' - : undefined, - onTrigger: () => { - if (!fullScreenElement) return - - if (document.fullscreenElement) { - document.exitFullscreen() - } else { - fullScreenElement.requestFullscreen() - - isInFullScreen.value = true - const onFullScreenChange = () => { - isInFullScreen.value = - document.fullscreenElement != null - - // If no longer in fullscreen, remove the event listener - if (!isInFullScreen.value) { - document.removeEventListener( - 'fullscreenchange', - onFullScreenChange - ) - } - } - // Listen for fullscreen cancel - document.addEventListener( - 'fullscreenchange', - onFullScreenChange - ) - } - }, - } - : null diff --git a/src/components/TabSystem/TabManager.ts b/src/components/TabSystem/TabManager.ts new file mode 100644 index 000000000..640ad4969 --- /dev/null +++ b/src/components/TabSystem/TabManager.ts @@ -0,0 +1,241 @@ +import { ShallowRef, shallowRef } from 'vue' +import { Tab } from './Tab' +import { TabSystem, TabSystemRecoveryState } from './TabSystem' +import { FileTab } from './FileTab' +import { Settings } from '@/libs/settings/Settings' +import { TabTypes } from './TabTypes' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { Event } from '@/libs/event/Event' +import { fileSystem } from '@/libs/fileSystem/FileSystem' + +type TabManagerRecoveryState = { tabSystems: TabSystemRecoveryState[]; focusedTabSystem: string } + +export class TabManager { + public static tabSystems: ShallowRef = shallowRef([]) + public static focusedTabSystem: ShallowRef = shallowRef(null) + + public static focusedTabSystemChanged: Event = new Event() + + private static tabDisposables: Record = {} + + public static setup() { + Settings.addSetting('compactTabDesign', { + default: true, + }) + + ProjectManager.updatedCurrentProject.on(() => { + if (ProjectManager.currentProject === null) { + TabManager.clear() + } else { + TabManager.loadedProject() + } + }) + } + + public static async addTabSystem(recoveryState?: TabSystemRecoveryState): Promise { + const tabSystem = new TabSystem() + + if (recoveryState) await tabSystem.applyRecoverState(recoveryState) + + TabManager.tabDisposables[tabSystem.id] = [ + tabSystem.savedState.on(() => { + TabManager.save() + }), + tabSystem.removedTab.on(() => { + if (tabSystem.tabs.value.length > 0) return + + this.removeTabSystem(tabSystem) + }), + tabSystem.focused.on(() => { + this.focusTabSystem(tabSystem) + }), + ] + + TabManager.tabSystems.value.push(tabSystem) + TabManager.tabSystems.value = [...TabManager.tabSystems.value] + + return tabSystem + } + + public static async removeTabSystem(tabSystem: TabSystem) { + if (!TabManager.tabSystems.value.includes(tabSystem)) return + + if (TabManager.focusedTabSystem.value?.id === tabSystem.id) { + TabManager.focusedTabSystem.value = null + this.focusedTabSystemChanged.dispatch() + } + + TabManager.tabSystems.value.splice(TabManager.tabSystems.value.indexOf(tabSystem), 1) + TabManager.tabSystems.value = [...TabManager.tabSystems.value] + + disposeAll(TabManager.tabDisposables[tabSystem.id]) + delete TabManager.tabDisposables[tabSystem.id] + + await tabSystem.clear() + } + + public static async removeTab(tab: Tab) { + for (const tabSystem of this.tabSystems.value) { + if (tabSystem.hasTab(tab)) await tabSystem.removeTab(tab) + } + } + + public static async removeTabSafe(tab: Tab) { + for (const tabSystem of this.tabSystems.value) { + if (tabSystem.hasTab(tab)) await tabSystem.removeTabSafe(tab) + } + } + + public static async clear() { + const tabSystems = TabManager.tabSystems.value + + for (const tabSystem of tabSystems) { + await TabManager.removeTabSystem(tabSystem) + } + } + + public static async loadedProject() { + if (!ProjectManager.currentProject) return + + await TabManager.clear() + + await TabManager.recover() + } + + public static async openTab(tab: Tab, temporary = false) { + for (const tabSystem of TabManager.tabSystems.value) { + for (const otherTab of tabSystem.tabs.value) { + if (otherTab === tab) { + await tabSystem.selectTab(tab) + + this.focusTabSystem(tabSystem) + + return + } + } + } + + if (TabManager.tabSystems.value.length === 0) TabManager.addTabSystem() + + const tabSystem = TabManager.getFocusedTabSystem() ?? TabManager.getDefaultTabSystem() + + await tabSystem.addTab(tab, true, temporary) + + this.focusTabSystem(tabSystem) + } + + public static async openFile(path: string) { + if (!(await fileSystem.exists(path))) return + + for (const tabSystem of TabManager.tabSystems.value) { + for (const tab of tabSystem.tabs.value) { + if (tab instanceof FileTab && tab.is(path)) { + await tabSystem.selectTab(tab) + + this.focusTabSystem(tabSystem) + + return + } + } + } + + for (const TabType of TabTypes.fileTabTypes.toSorted((a, b) => b.editPriority(path) - a.editPriority(path))) { + if (TabType.canEdit(path)) { + await TabManager.openTab(new TabType(path), true) + + return + } + } + } + + public static getTabByType(tabType: { new (...args: any[]): T }): T | null { + for (const tabSystem of TabManager.tabSystems.value) { + for (const tab of tabSystem.tabs.value) { + if (tab instanceof tabType) { + return tab + } + } + } + + return null + } + + public static getDefaultTabSystem(): TabSystem { + return TabManager.tabSystems.value[0] + } + + public static getFocusedTab(): Tab | null { + if (TabManager.focusedTabSystem.value === null) return null + + return TabManager.focusedTabSystem.value.selectedTab.value + } + + public static getFocusedTabSystem(): TabSystem | null { + return TabManager.focusedTabSystem.value + } + + public static focusTabSystem(tabSystem: TabSystem | null) { + TabManager.focusedTabSystem.value = tabSystem + this.focusedTabSystemChanged.dispatch() + } + + public static isTabOpen(tab: Tab): boolean { + for (const tabSystem of TabManager.tabSystems.value) { + for (const otherTab of tabSystem.tabs.value) { + if (otherTab === tab) return true + } + } + + return false + } + + public static isFileOpen(path: string): boolean { + for (const tabSystem of TabManager.tabSystems.value) { + for (const tab of tabSystem.tabs.value) { + if (tab instanceof FileTab && tab.is(path)) return true + } + } + + return false + } + + public static async save() { + if (!ProjectManager.currentProject) return + + const state = { + tabSystems: await Promise.all(TabManager.tabSystems.value.map((tabSystem) => tabSystem.getRecoveryState())), + focusedTabSystem: TabManager.focusedTabSystem.value ? TabManager.focusedTabSystem.value.id : null, + } + + await ProjectManager.currentProject.saveTabManagerState(state) + } + + public static async recover() { + if (!ProjectManager.currentProject) return + + if (!Settings.get('restoreTabs')) return + + const state = (await ProjectManager.currentProject.getTabManagerState()) as TabManagerRecoveryState | null + + if (state === null) return + + await TabManager.clear() + + for (const tabSystemState of state.tabSystems) { + await TabManager.addTabSystem(tabSystemState) + } + + TabManager.tabSystems.value = [...TabManager.tabSystems.value] + + this.focusTabSystem(TabManager.tabSystems.value.find((tabSystem) => tabSystem.id === state.focusedTabSystem) ?? null) + } + + public static getTabSystemWithTab(tab: Tab): TabSystem | null { + for (const tabSystem of this.tabSystems.value) { + if (tabSystem.hasTab(tab)) return tabSystem + } + + return null + } +} diff --git a/src/components/TabSystem/TabProvider.ts b/src/components/TabSystem/TabProvider.ts deleted file mode 100644 index 195df07e7..000000000 --- a/src/components/TabSystem/TabProvider.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ImageTab } from '../Editors/Image/ImageTab' -import { TargaTab } from '../Editors/Image/TargaTab' -import { SoundTab } from '../Editors/Sound/SoundTab' -import { TextTab } from '../Editors/Text/TextTab' -import { TreeTab } from '../Editors/TreeEditor/Tab' -import { FileTab } from './FileTab' - -export class TabProvider { - protected static _tabs = new Set([ - TextTab, - TreeTab, - ImageTab, - TargaTab, - SoundTab, - ]) - static get tabs() { - return [...this._tabs].reverse() - } - - static register(tab: typeof FileTab) { - this._tabs.add(tab) - - return { - dispose: () => this._tabs.delete(tab), - } - } -} diff --git a/src/components/TabSystem/TabSystem.ts b/src/components/TabSystem/TabSystem.ts index a39ee2f2e..21bf34df9 100644 --- a/src/components/TabSystem/TabSystem.ts +++ b/src/components/TabSystem/TabSystem.ts @@ -1,383 +1,293 @@ -import { Tab } from './CommonTab' -import WelcomeScreen from './WelcomeScreen.vue' -import { TextTab } from '../Editors/Text/TextTab' -import Vue, { computed, Ref, ref } from 'vue' -import { App } from '/@/App' -import { UnsavedFileWindow } from '../Windows/UnsavedFile/UnsavedFile' -import { Project } from '../Projects/Project/Project' -import { OpenedFiles } from './OpenedFiles' import { v4 as uuid } from 'uuid' -import { MonacoHolder } from './MonacoHolder' -import { FileTab, TReadOnlyMode } from './FileTab' -import { TabProvider } from './TabProvider' -import { AnyFileHandle } from '../FileSystem/Types' -import { IframeTab } from '../Editors/IframeTab/IframeTab' - -export interface IOpenTabOptions { - selectTab?: boolean - isTemporary?: boolean - readOnlyMode?: TReadOnlyMode -} +import { RecoveryState as TabRecoveryState, Tab, RecoveryState } from './Tab' +import { ref, Ref, ShallowRef, shallowRef, watchEffect } from 'vue' +import { Editor } from '@/components/Editor/Editor' +import { Event } from '@/libs/event/Event' +import { Disposable } from '@/libs/disposeable/Disposeable' +import { TabTypes } from './TabTypes' +import { FileTab } from './FileTab' +import { Settings } from '@/libs/settings/Settings' +import { Windows } from '../Windows/Windows' +import { ConfirmWindow } from '../Windows/Confirm/ConfirmWindow' + +export type TabSystemRecoveryState = { id: string; selectedTab: string | null; tabs: TabRecoveryState[] } + +export class TabSystem { + public id = uuid() + // Monaco editor freezes browser when made deep reactive, so instead we make it shallow reactive + public tabs: ShallowRef = shallowRef([]) + public selectedTab: ShallowRef = shallowRef(null) + + public savedState = new Event() + public removedTab = new Event() + public focused = new Event() + + public static draggingTab: ShallowRef = shallowRef(null) + public static dropTargetTab: ShallowRef = shallowRef(null) + public static dropSide: Ref<'right' | 'left'> = ref('right') + + private tabSaveListenters: Record = {} + + public async addTab(tab: Tab, select = true, temporary = false, index?: number) { + if (this.hasTab(tab)) { + if (select) await this.selectTab(tab) + + await this.saveState() + + return + } -export class TabSystem extends MonacoHolder { - protected uuid = uuid() - public tabs = >ref([]) - protected _recentSelectedTab = >ref(undefined) - protected _selectedTab = >ref(undefined) - protected get tabTypes() { - return TabProvider.tabs - } - protected _isActive = ref(true) - public readonly openedFiles: OpenedFiles + tab.temporary.value = temporary - get isActive() { - return this._isActive - } - public readonly shouldRender = computed(() => this.tabs.value.length > 0) - public readonly isSharingScreen = computed(() => { - const other = this.project.tabSystems.find( - (tabSystem) => tabSystem !== this - ) - - return other?.shouldRender.value ?? false - }) - get app() { - return this._app - } - get project() { - return this._project - } - - get hasUnsavedTabs() { - return this.tabs.value.some((tab) => tab.isUnsaved) - } + this.tabSaveListenters[tab.id] = tab.savedState.on(() => { + this.saveState() + }) - constructor(protected _project: Project, id = 0) { - super(_project.app) + await tab.create() - this.openedFiles = new OpenedFiles( - this, - _project.app, - `${_project.projectPath}/.bridge/openedFiles_${id}.json` - ) - } + if (index === undefined) { + this.tabs.value.push(tab) + } else { + this.tabs.value.splice(index, 0, tab) + } - get recentSelectedTab() { - return this._recentSelectedTab.value - } + this.tabs.value = [...this.tabs.value] - get selectedTab() { - return this._selectedTab.value - } - get currentComponent() { - return this._selectedTab.value?.component ?? WelcomeScreen - } - get projectRoot() { - return this.project.baseDirectory - } - get projectName() { - return this.project.name - } + if (select) await this.selectTab(tab) - async open( - fileHandle: AnyFileHandle, - { - selectTab = true, - readOnlyMode = 'off', - isTemporary = true, - }: IOpenTabOptions = {} - ) { - const tab = await this.getTabFor(fileHandle, readOnlyMode) - - // Default value is true so we only need to update if the caller wants to create a permanent tab - if (!isTemporary) tab.isTemporary = false - - await this.add(tab, selectTab) - return tab - } - async openPath(path: string, options: IOpenTabOptions = {}) { - const fileHandle = await this.project.app.fileSystem.getFileHandle(path) + await this.saveState() - return await this.open(fileHandle, options) - } + if (temporary) { + const otherTemporaryTabs = this.tabs.value.filter((otherTab) => otherTab.temporary.value && otherTab.id !== tab.id) - protected async getTabFor( - fileHandle: AnyFileHandle, - readOnlyMode: TReadOnlyMode = 'off' - ) { - let tab: Tab | undefined = undefined - for (const CurrentTab of this.tabTypes) { - if (await CurrentTab.is(fileHandle)) { - // @ts-ignore - tab = new CurrentTab(this, fileHandle, readOnlyMode) - break + for (const otherTab of otherTemporaryTabs) { + if (Settings.get('keepTabsOpen')) { + otherTab.temporary.value = false + } else { + if (otherTab.temporary.value) await this.removeTab(otherTab) + } } } - // Default tab type: Text editor - if (!tab) tab = new TextTab(this, fileHandle, readOnlyMode) - - return await tab.fired } - async hasTab(tab: Tab) { - for (const currentTab of this.tabs.value) { - if (await currentTab.is(tab)) return true + + public async selectTab(tab: Tab) { + if (this.selectedTab.value?.id === tab.id) return + + if (this.selectedTab.value !== null) { + this.selectedTab.value.active = false + await this.selectedTab.value.deactivate() } - return false + this.selectedTab.value = tab + + Editor.showTabs() + + tab.active = true + await tab.activate() + + await this.saveState() } - async add(tab: Tab, selectTab = true, noTabExistanceCheck = false) { - await this.closeAllTemporary() - - if (!noTabExistanceCheck) { - for (const currentTab of this.tabs.value) { - if (await currentTab.is(tab)) { - // Trigger openWith event again for iframe tabs - if ( - tab instanceof IframeTab && - currentTab instanceof IframeTab - ) { - currentTab.setOpenWithPayload( - tab.getOptions().openWithPayload - ) - } + public async removeTab(tab: Tab) { + if (!this.hasTab(tab)) return - tab.onDeactivate() - return selectTab ? currentTab.select() : currentTab - } - } + const selectedTab = this.selectedTab.value?.id === tab.id + + if (selectedTab) { + tab.active = false + await tab.deactivate() } - if (!tab.hasFired) await tab.fired + const tabIndex = this.indexOfTab(tab) - this.tabs.value = [...this.tabs.value, tab] - if (!tab.isForeignFile && !(tab instanceof FileTab && tab.isReadOnly)) - await this.openedFiles.add(tab.getPath()) + this.tabs.value.splice(tabIndex, 1) + this.tabs.value = [...this.tabs.value] - if (selectTab) tab.select() + if (selectedTab) { + this.selectedTab.value = null - this.project.updateTabFolders() + if (this.tabs.value.length != 0) await this.selectTab(this.tabs.value[Math.max(tabIndex - 1, 0)]) + } - return tab - } - async remove(tab: Tab, destroyEditor = true, selectNewTab = true) { - tab.onDeactivate() - const tabIndex = this.tabs.value.findIndex((current) => current === tab) - if (tabIndex === -1) return + await tab.destroy() - this.tabs.value.splice(tabIndex, 1) - if (destroyEditor) tab.onDestroy() + this.tabSaveListenters[tab.id].dispose() + delete this.tabSaveListenters[tab.id] - if (selectNewTab && tab === this.selectedTab) - this.select(this.tabs.value[tabIndex === 0 ? 0 : tabIndex - 1]) - if (!tab.isForeignFile) await this.openedFiles.remove(tab.getPath()) + if (this.tabs.value.length === 0) Editor.hideTabs() - this.project.updateTabFolders() + await this.saveState() - return tab + this.removedTab.dispatch() } - async close(tab = this.selectedTab, checkUnsaved = true) { - if (!tab) return false - if (checkUnsaved && tab.isUnsaved) { - const unsavedWin = new UnsavedFileWindow(tab) + // TODO: Changet he tab API so that there is a seperation between creating the tab ui and creating the tab so we can support moving tabs without saving them + public async removeTabSafe(tab: Tab) { + if (!this.hasTab(tab)) return - return (await unsavedWin.fired) !== 'cancel' - } else { - await this.remove(tab) - return true + if (tab instanceof FileTab && tab.modified.value) { + if ( + !(await new Promise((resolve) => { + Windows.open( + new ConfirmWindow( + `windows.unsavedFile.closeFile`, + () => resolve(true), + () => resolve(false) + ) + ) + })) + ) + return } - } - async closeTabWithHandle(fileHandle: AnyFileHandle) { - const tab = await this.getTab(fileHandle) - if (tab) this.close(tab) + + await this.removeTab(tab) } - /** - * Select next tab - */ - async selectNextTab() { - const tabs = this.tabs.value - if (tabs.length === 0) return + public orderTab(tab: Tab, index: number) { + const currentIndex = this.tabs.value.findIndex((otherTab) => otherTab.id === tab.id) - const selectedTab = this.selectedTab - if (!selectedTab) return + this.tabs.value.splice(currentIndex, 1) - const index = tabs.indexOf(selectedTab) - const nextTab = tabs[index + 1] ?? tabs[0] + this.tabs.value.splice(index > currentIndex ? index - 1 : index, 0, tab) - await nextTab.select() + this.tabs.value = [...this.tabs.value] } - /** - * Select previous tab - */ - async selectPreviousTab() { - const tabs = this.tabs.value - if (tabs.length === 0) return - const selectedTab = this.selectedTab - if (!selectedTab) return + public async clear() { + for (const tab of this.tabs.value) { + if (this.selectedTab.value?.id === tab.id) { + tab.active = false + await tab.deactivate() + } - const index = tabs.indexOf(selectedTab) - const previousTab = tabs[index - 1] ?? tabs[tabs.length - 1] + await tab.destroy() - await previousTab.select() - } + this.tabSaveListenters[tab.id].dispose() + delete this.tabSaveListenters[tab.id] + } - async hasRecentTab() { - return this.recentSelectedTab !== undefined + this.tabs.value = [] + this.selectedTab.value = null } - async selectRecentTab() { - const tabs = this.tabs.value - if (tabs.length === 0) return + public async saveState() { + this.savedState.dispatch() + } - const recentTab = this.recentSelectedTab - if (!recentTab) return + public async getTabRecoveryState(tab: Tab): Promise { + if (tab instanceof FileTab) + return { + id: tab.id, + path: tab.path, + state: (await tab.getState()) ?? null, + temporary: tab.temporary.value, + type: tab.constructor.name, + } - await recentTab.select() + return { + id: tab.id, + state: (await tab.getState()) ?? null, + temporary: tab.temporary.value, + type: tab.constructor.name, + } } - async saveRecentTab(tab?: Tab) { - this._recentSelectedTab.value = tab + public async getRecoveryState(): Promise { + return { + id: this.id, + selectedTab: this.selectedTab.value ? this.selectedTab.value.id : null, + tabs: await Promise.all(this.tabs.value.map((tab) => this.getTabRecoveryState(tab))), + } } - async select(tab?: Tab) { - if (this.isActive.value !== !!tab) this.setActive(!!tab) + public async applyRecoverState(recoveryState: TabSystemRecoveryState) { + this.id = recoveryState.id - if (this.app.mobile.isCurrentDevice()) App.sidebar.hide() - if (tab?.isSelected) return + this.tabs.value = [] - this.selectedTab?.onDeactivate() - if (tab && tab !== this.selectedTab && this.project.isActiveProject) { - App.eventSystem.dispatch('currentTabSwitched', tab) - } + for (const tabRecoveryState of recoveryState.tabs) { + const tabType = TabTypes.getType(tabRecoveryState.type) - this.saveRecentTab(this.selectedTab) - this._selectedTab.value = tab + if (tabType === null) continue - // Next steps don't need to be done if we simply unselect tab - if (!tab) return + if (tabType.prototype instanceof FileTab) { + const tab = new (tabType as typeof FileTab)(tabRecoveryState.path) - await this.selectedTab?.onActivate() + tab.id = tabRecoveryState.id + tab.temporary.value = tabRecoveryState.temporary - Vue.nextTick(async () => { - this._monacoEditor?.layout() - }) - } - async save(tab = this.selectedTab) { - if (!tab || (tab instanceof FileTab && tab.isReadOnly)) return - tab?.setIsLoading(true) + this.tabSaveListenters[tab.id] = tab.savedState.on(() => { + this.saveState() + }) - // Save whether the tab was selected previously for use later - const tabWasActive = this.selectedTab === tab + await tab.create() + await tab.recover(tabRecoveryState.state) - // We need to select the tab before saving to format it correctly - const selectedTab = this.selectedTab - if (selectedTab !== tab) await this.select(tab) + this.tabs.value.push(tab) + } else { + const tab = new (tabType as typeof Tab)() - if (tab instanceof FileTab) await tab.save() + tab.id = tabRecoveryState.id + tab.temporary.value = tabRecoveryState.temporary - // Select the previously selected tab again - if (selectedTab !== tab) await this.select(selectedTab) + this.tabSaveListenters[tab.id] = tab.savedState.on(() => { + this.saveState() + }) - if (!tab.isForeignFile && tab instanceof FileTab) { - this.project.beforeFileSave.dispatch( - tab.getPath(), - await tab.getFile() - ) + await tab.create() + await tab.recover(tabRecoveryState.state) - await this.project.updateFile(tab.getPath()) - - this.project.fileSave.dispatch(tab.getPath(), await tab.getFile()) - - // Only refresh auto-completion content if tab is active - if (tabWasActive) - App.eventSystem.dispatch('refreshCurrentContext', tab.getPath()) + this.tabs.value.push(tab) + } } - tab.focus() - tab?.setIsLoading(false) - } - async saveAs() { - if (this.selectedTab instanceof FileTab) await this.selectedTab.saveAs() - } - async saveAll() { - const app = await App.getApp() - app.windows.loadingWindow.open() + this.tabs.value = [...this.tabs.value] - for (const tab of this.tabs.value) { - if (tab.isUnsaved) await this.save(tab) - } + this.selectedTab.value = this.tabs.value.find((tab) => tab.id === recoveryState.selectedTab) ?? null + + if (this.selectedTab.value === null) return - app.windows.loadingWindow.close() + this.selectedTab.value.active = true + await this.selectedTab.value.activate() } - async closeAllTemporary() { - for (const tab of [...this.tabs.value]) { - if (!tab.isTemporary) continue - await this.remove(tab, true, false) - } + public focus() { + this.focused.dispatch() } - async activate() { - await this.openedFiles.restoreTabs() + public async nextTab() { + const tab = this.selectedTab.value - if (this.tabs.value.length > 0) this.setActive(true) + if (!tab) return - if (!this.selectedTab && this.tabs.value.length > 0) - this.tabs.value[0].select() + const index = this.indexOfTab(tab) - await this.selectedTab?.onActivate() - } - deactivate() { - this.selectedTab?.onDeactivate() - this.dispose() + if (index === -1) return + + const nextIndex = index < this.tabs.value.length - 1 ? index + 1 : 0 + + await this.selectTab(this.tabs.value[nextIndex]) } - setActive(isActive: boolean, updateProject = true) { - if (updateProject) this.project.setActiveTabSystem(this, !isActive) - if (isActive === this._isActive.value) return + public async previousTab() { + const tab = this.selectedTab.value - this._isActive.value = isActive + if (!tab) return - if (isActive && this._selectedTab && this.project.isActiveProject) { - App.eventSystem.dispatch('currentTabSwitched', this._selectedTab) - } - } + const index = this.indexOfTab(tab) - async getTab(fileHandle: AnyFileHandle) { - for (const tab of this.tabs.value) { - if ( - tab instanceof FileTab && - (await tab.isForFileHandle(fileHandle)) - ) - return tab - } - } - closeTabs(predicate: (tab: Tab) => boolean) { - const tabs = [...this.tabs.value].reverse() + if (index === -1) return - for (const tab of tabs) { - if (predicate(tab)) tab.close() - } - } - forceCloseTabs(predicate: (tab: Tab) => boolean) { - const tabs = [...this.tabs.value].reverse() + const previousIndex = index > 0 ? index - 1 : this.tabs.value.length - 1 - for (const tab of tabs) { - if (predicate(tab)) this.remove(tab) - } + await this.selectTab(this.tabs.value[previousIndex]) } - has(predicate: (tab: Tab) => boolean) { - for (const tab of this.tabs.value) { - if (predicate(tab)) return true - } - return true + + public hasTab(tab: Tab): boolean { + return this.tabs.value.some((otherTab) => otherTab.id === tab.id) } - get(predicate: (tab: Tab) => boolean) { - for (const tab of this.tabs.value) { - if (predicate(tab)) return tab - } + + public indexOfTab(tab: Tab): number { + return this.tabs.value.findIndex((otherTab) => otherTab.id === tab.id) } } diff --git a/src/components/TabSystem/TabSystem.vue b/src/components/TabSystem/TabSystem.vue index a4098dbf8..7aea14d71 100644 --- a/src/components/TabSystem/TabSystem.vue +++ b/src/components/TabSystem/TabSystem.vue @@ -1,100 +1,335 @@ + + +
- + diff --git a/src/components/TabSystem/TabTypes.ts b/src/components/TabSystem/TabTypes.ts new file mode 100644 index 000000000..cd6d741b4 --- /dev/null +++ b/src/components/TabSystem/TabTypes.ts @@ -0,0 +1,40 @@ +import { TextTab } from '@/components/Tabs/Text/TextTab' +import { TreeEditorTab } from '@/components/Tabs/TreeEditor/TreeEditorTab' +import { ImageTab } from '@/components/Tabs/Image/ImageTab' +import { FileTab } from './FileTab' +import { FindAndReplaceTab } from '@/components/Tabs/FindAnReplace/FindAndReplaceTab' +import { Tab } from './Tab' +import { Settings } from '@/libs/settings/Settings' + +export class TabTypes { + public static tabTypes: (typeof Tab | typeof FileTab)[] = [ImageTab, TextTab, TreeEditorTab, FindAndReplaceTab] + public static fileTabTypes: (typeof FileTab)[] = [ImageTab, TextTab, TreeEditorTab] + + public static setup() { + Settings.addSetting('jsonEditor', { + default: 'text', + }) + } + + public static getType(id: string): typeof Tab | typeof FileTab | null { + return this.tabTypes.find((tabType) => tabType.name === id) ?? null + } + + public static addTabType(tabType: typeof Tab | typeof FileTab) { + this.tabTypes.push(tabType) + } + + public static removeTabType(tabType: typeof Tab | typeof FileTab) { + this.tabTypes.splice(this.tabTypes.indexOf(tabType)) + } + + public static addFileTabType(tabType: typeof FileTab) { + this.tabTypes.push(tabType) + this.fileTabTypes.push(tabType) + } + + public static removeFileTabType(tabType: typeof FileTab) { + this.tabTypes.splice(this.tabTypes.indexOf(tabType)) + this.fileTabTypes.splice(this.fileTabTypes.indexOf(tabType)) + } +} diff --git a/src/components/TabSystem/Tabs.vue b/src/components/TabSystem/Tabs.vue new file mode 100644 index 000000000..23e58a637 --- /dev/null +++ b/src/components/TabSystem/Tabs.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/components/TabSystem/Util/FolderDifference.ts b/src/components/TabSystem/Util/FolderDifference.ts deleted file mode 100644 index 53db14e08..000000000 --- a/src/components/TabSystem/Util/FolderDifference.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Given an array of file paths that lead to files with the same name, return the first directory that is different for all files - * @param filePaths - */ - -export function getFolderDifference(filePaths: string[]) { - // Create a matrix of all filePaths (in array form) - let filePathMatrix = filePaths.map((filePath) => - filePath.split('/').reverse() - ) - - // Result array - const folderDifference: (null | string)[] = filePathMatrix.map(() => null) - const pointers = filePathMatrix.map(() => 0) - - // Confirm that all files have the same name - if ( - !filePaths.every( - (filePath) => - filePath.split('/').reverse()[0] === - filePaths[0].split('/').reverse()[0] - ) - ) { - throw new Error('All files must have the same name') - } - - // Loop through the matrix - while (pointers.some((pointer, i) => pointer < filePathMatrix[i].length)) { - // Get the current value - const currentValue = filePathMatrix.map((row, i) => row[pointers[i]]) - // Check if all values are the same - const allSame = currentValue.every((value) => value === currentValue[0]) - // If they are not, add the difference to the result - if (!allSame) { - folderDifference.forEach((difference, i) => { - if (difference === null) { - folderDifference[i] = currentValue[i] - } - }) - } - - // Increment the pointers - pointers.forEach((pointer, index) => { - if (pointer < filePathMatrix[index].length) { - pointers[index]++ - } - }) - } - - // Return the result - return folderDifference -} diff --git a/src/components/TabSystem/WelcomeScreen.vue b/src/components/TabSystem/WelcomeScreen.vue deleted file mode 100644 index a16b1c478..000000000 --- a/src/components/TabSystem/WelcomeScreen.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - - - diff --git a/src/components/Tabs/FindAnReplace/FindAndReplaceTab.ts b/src/components/Tabs/FindAnReplace/FindAndReplaceTab.ts new file mode 100644 index 000000000..2f2999708 --- /dev/null +++ b/src/components/Tabs/FindAnReplace/FindAndReplaceTab.ts @@ -0,0 +1,181 @@ +import { Tab } from '@/components/TabSystem/Tab' +import { Component, Ref, ref } from 'vue' +import FindAndReplaceTabComponent from './FindAndReplaceTab.vue' +import { v4 as uuid } from 'uuid' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { ProjectManager } from '@/libs/project/ProjectManager' +import escapeRegExpString from 'escape-string-regexp' +import { BedrockProject } from '@/libs/project/BedrockProject' + +interface QueryResult { + value: string + previousContext: string | null + nextContext: string | null +} + +export class FindAndReplaceTab extends Tab { + public name = ref('Find and Replace') + public component: Component | null = FindAndReplaceTabComponent + public queryResult: Ref< + Record< + string, + { + prettyPath: string + icon: string + color: string + results: QueryResult[] + } + > + > = ref({}) + + public searchValue: Ref = ref('') + public replaceValue: Ref = ref('') + public matchWord: Ref = ref(false) + public matchCase: Ref = ref(false) + public useRegex: Ref = ref(false) + + private currentQueryId: string | null = null + private currentSearch: Promise | null = null + private currentRegex: RegExp | null = null + + public async startSearch(query: string, matchCase: boolean, useRegex: boolean) { + if (ProjectManager.currentProject === null) return + + const searchId = uuid() + const regex = this.createRegExp(query, matchCase, useRegex) + + this.queryResult.value = {} + + this.currentQueryId = searchId + + if (regex === undefined) return + + if (query.length === 0) return + + this.currentRegex = regex + this.currentSearch = this.searchDirectory(ProjectManager.currentProject.path, regex, searchId) + + this.saveState() + } + + private async searchDirectory(path: string, regex: RegExp, searchId: string) { + for (const entry of await fileSystem.readDirectoryEntries(path)) { + if (entry.kind === 'directory') await this.searchDirectory(entry.path, regex, searchId) + + if (entry.kind === 'file' && (entry.path.endsWith('.txt') || entry.path.endsWith('.json'))) { + const content = await fileSystem.readFileText(entry.path) + + if (this.currentQueryId !== searchId) return + + let match = regex.exec(content) + + while (match !== null) { + if (!match.index) continue + + const value = match[0] + + let previousContext = match.index !== 0 ? content.slice(Math.max(0, match.index - 50), match.index) : null + + let nextContext = + match.index !== content.length - 1 + ? content.slice(match.index + value.length, Math.min(content.length, match.index + value.length + 50)) + : null + + if (ProjectManager.currentProject === null) return + + let prettyPath = entry.path.substring(ProjectManager.currentProject.path.length + 1) + + if (!this.queryResult.value[entry.path]) { + let icon = 'data_object' + let color = 'text' + + if (ProjectManager.currentProject instanceof BedrockProject) { + const [packId, _] = Object.entries(ProjectManager.currentProject.packs).find(([id, path]) => + entry.path.startsWith(path) + ) ?? [null, null] + + if (packId) { + const definition = ProjectManager.currentProject.packDefinitions.find( + (definition) => definition.id === packId + ) + + if (definition) color = definition.color + } + + const fileType = await ProjectManager.currentProject.fileTypeData.get(entry.path) + + if (fileType && fileType.icon) icon = fileType.icon + } + + this.queryResult.value[entry.path] = { + prettyPath: prettyPath, + icon, + color, + results: [], + } + } + + this.queryResult.value[entry.path].results.push({ + value, + previousContext, + nextContext, + }) + + match = regex.exec(content) + } + } + } + + this.queryResult.value = { ...this.queryResult.value } + } + + public async replace(value: string) { + await this.currentSearch + + if (!this.currentRegex) return + + for (const path of Object.keys(this.queryResult.value)) { + const content = await fileSystem.readFileText(path) + + await fileSystem.writeFile(path, content.replaceAll(this.currentRegex, value)) + + for (const result of this.queryResult.value[path].results) { + result.value = value + } + + this.queryResult.value = { ...this.queryResult.value } + } + } + + private createRegExp(searchFor: string, matchCase: boolean, useRegex: boolean) { + let regExp: RegExp + + try { + regExp = new RegExp(useRegex ? searchFor : escapeRegExpString(searchFor), `g${matchCase ? '' : 'i'}`) + } catch { + return + } + + return regExp + } + + public async getState(): Promise { + return { + searchValue: this.searchValue.value, + replaceValue: this.replaceValue.value, + matchWord: this.matchWord.value, + matchCase: this.matchCase.value, + useRegex: this.useRegex.value, + } + } + + public async recover(state: any): Promise { + this.searchValue.value = state.searchValue + this.replaceValue.value = state.replaceValue + this.matchWord.value = state.matchWord + this.matchCase.value = state.matchCase + this.useRegex.value = state.useRegex + + this.startSearch(this.searchValue.value, this.matchCase.value, this.useRegex.value) + } +} diff --git a/src/components/Tabs/FindAnReplace/FindAndReplaceTab.vue b/src/components/Tabs/FindAnReplace/FindAndReplaceTab.vue new file mode 100644 index 000000000..424fe0ed3 --- /dev/null +++ b/src/components/Tabs/FindAnReplace/FindAndReplaceTab.vue @@ -0,0 +1,121 @@ + + + diff --git a/src/components/Tabs/Image/ImageTab.ts b/src/components/Tabs/Image/ImageTab.ts new file mode 100644 index 000000000..cacf19c11 --- /dev/null +++ b/src/components/Tabs/Image/ImageTab.ts @@ -0,0 +1,39 @@ +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { Component, Ref, ref } from 'vue' +import ImageTabComponent from './ImageTab.vue' +import { FileTab } from '@/components/TabSystem/FileTab' +import { disposeAll, Disposable } from '@/libs/disposeable/Disposeable' +import { TabManager } from '@/components/TabSystem/TabManager' + +export class ImageTab extends FileTab { + public component: Component | null = ImageTabComponent + public image: Ref = ref(null) + public canSave: boolean = false + + private disposables: Disposable[] = [] + + public static canEdit(path: string): boolean { + return path.endsWith('.png') + } + + public async create() { + this.image.value = await fileSystem.readFileDataUrl(this.path) + + this.disposables.push( + fileSystem.pathUpdated.on(async (path) => { + if (!path) return + if (path !== this.path) return + + if (!(await fileSystem.exists(path))) { + await TabManager.removeTab(this) + } else if (!this.modified.value) { + this.image.value = await fileSystem.readFileDataUrl(this.path) + } + }) + ) + } + + public async destroy() { + disposeAll(this.disposables) + } +} diff --git a/src/components/Tabs/Image/ImageTab.vue b/src/components/Tabs/Image/ImageTab.vue new file mode 100644 index 000000000..6814adf6f --- /dev/null +++ b/src/components/Tabs/Image/ImageTab.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/src/components/Tabs/Text/TextTab.ts b/src/components/Tabs/Text/TextTab.ts new file mode 100644 index 000000000..2494eb0bd --- /dev/null +++ b/src/components/Tabs/Text/TextTab.ts @@ -0,0 +1,589 @@ +import { Component, ref } from 'vue' +import TextTabComponent from './TextTab.vue' +import { Position, Uri, editor, editor as monaco, Range } from 'monaco-editor' +import { keyword } from 'color-convert' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { setMonarchTokensProvider } from '@/libs/monaco/Json' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { ThemeManager } from '@/libs/theme/ThemeManager' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { FileTab } from '@/components/TabSystem/FileTab' +import { Settings } from '@/libs/settings/Settings' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { openUrl } from '@/libs/OpenUrl' +import { debounce } from '@/libs/Debounce' +import { interupt } from '@/libs/Interupt' +import { TabManager } from '@/components/TabSystem/TabManager' + +export class TextTab extends FileTab { + public component: Component | null = TextTabComponent + public icon = ref('loading') + public language = ref('plaintext') + public hasDocumentation = ref(false) + public static priority: number = -1 + + private fileTypeIcon: string = 'data_object' + private editor: monaco.IStandaloneCodeEditor | null = null + private model: monaco.ITextModel | null = null + + private fileType: any | null = null + + private disposables: Disposable[] = [] + + private savedViewState: editor.ICodeEditorViewState | undefined = undefined + private initialVersionId: number = 0 + + private lastEditorElement: HTMLElement | null = null + + private recoveryState: null | any = null + private currentState: null | any = null + + public static canEdit(path: string): boolean { + return true + } + + public static editPriority(path: string): number { + if (path.endsWith('.json') || path.endsWith('.txt') || path.endsWith('.lang')) return 0 + + return -1 + } + + public is(path: string) { + return path === this.path + } + + public static setup() { + Settings.addSetting('bracketPairColorization', { + default: false, + }) + + Settings.addSetting('wordWrap', { + default: false, + }) + + Settings.addSetting('wordWrapColumns', { + default: 120, + async save(value) { + const number = parseInt(value) + + if (isNaN(number)) { + Settings.settings['wordWrapColumns'] = 120 + } else { + Settings.settings['wordWrapColumns'] = number + } + }, + }) + } + + public async create() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + this.disposables.push( + fileSystem.pathUpdated.on(async (path) => { + if (!path) return + if (path !== this.path) return + + if (!(await fileSystem.exists(path))) { + await TabManager.removeTab(this) + } else if (!this.modified.value) { + if (this.model) { + const value = await fileSystem.readFileText(path) + + if (value === this.model.getValue()) return + + this.model.setValue(value) + this.modified.value = false + } + } + }) + ) + + const fileTypeData = ProjectManager.currentProject.fileTypeData + + this.fileType = fileTypeData.get(this.path) + + if (this.fileType !== null) { + this.hasDocumentation.value = this.fileType.documentation !== undefined + + if (this.fileType.icon !== undefined) this.fileTypeIcon = this.fileType.icon + } + + const fileContent = await fileSystem.readFileText(this.path) + + this.model = monaco.getModel(Uri.file(this.path)) + + if (this.model === null) this.model = monaco.createModel(fileContent, this.fileType?.meta?.language, Uri.file(this.path)) + + this.initialVersionId = this.model.getAlternativeVersionId() + + this.language.value = this.model.getLanguageId() + + this.disposables.push( + this.model.onDidChangeLanguageConfiguration(() => { + if (this.editor === undefined) return + + this.updateEditorTheme() + }) + ) + + this.disposables.push( + this.model.onDidChangeContent(() => { + const modified = this.initialVersionId !== this.model?.getVersionId() + + this.modified.value = modified + + if (modified) { + this.temporary.value = false + + if (Settings.get('autoSaveChanges')) { + this.interruptAutoSave.invoke() + } + } + }) + ) + + const schemaData = ProjectManager.currentProject.schemaData + const scriptTypeData = ProjectManager.currentProject.scriptTypeData + + await schemaData.updateSchemaForFile(this.path, this.fileType?.id, this.fileType?.schema) + + await scriptTypeData.applyTypes(this.fileType?.types ?? []) + + this.icon.value = this.fileTypeIcon + + this.disposables.push( + Settings.updated.on((event: { id: string; value: any } | undefined) => { + if (!event) return + + if (['wordWrap', 'wordWrapColumns', 'bracketPairColorization', 'editorFont', 'editorFontSize'].includes(event.id)) + this.remountEditor() + }) + ) + } + + public async destroy() { + disposeAll(this.disposables) + + this.model?.dispose() + + this.debouncedSaveState.dispose() + this.interruptAutoSave.dispose() + } + + public async activate() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + const schemaData = ProjectManager.currentProject.schemaData + + schemaData.addFileForUpdate(this.path, this.fileType?.id, this.fileType?.schema) + + await schemaData.updateSchemaForFile(this.path, this.fileType?.id, this.fileType?.schema) + } + + public async deactivate() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + const schemaData = ProjectManager.currentProject.schemaData + + schemaData.removeFileForUpdate(this.path) + } + + public async getState(): Promise { + if (!this.editor) return + + return this.currentState + } + + public async recover(state: any): Promise { + if (!state) return + + this.recoveryState = state + } + + public async mountEditor(element: HTMLElement) { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + this.updateEditorTheme() + + this.disposables.push(ThemeManager.themeChanged.on(this.updateEditorTheme.bind(this))) + + this.editor = monaco.create(element, { + fontFamily: Settings.get('editorFont'), + fontSize: Settings.get('editorFontSize'), + //@ts-ignore Monaco types have not been update yet + 'bracketPairColorization.enabled': Settings.get('bracketPairColorization'), + wordWrap: Settings.get('wordWrap') ? 'wordWrapColumn' : 'off', + wordWrapColumn: Settings.get('wordWrapColumns'), + automaticLayout: true, + contextmenu: false, + }) + + this.editor.setModel(this.model) + + if (this.savedViewState) this.editor.restoreViewState(this.savedViewState) + + this.lastEditorElement = element + + this.disposables.push( + this.editor.onDidScrollChange(() => { + this.debouncedSaveState.invoke() + }) + ) + + this.disposables.push( + this.editor.onDidChangeCursorSelection(() => { + this.debouncedSaveState.invoke() + }) + ) + + if (!this.recoveryState) return + + this.editor.restoreViewState(this.recoveryState.viewState) + + this.recoveryState = null + } + + public unmountEditor() { + this.savedViewState = this.editor?.saveViewState() ?? undefined + + this.editor?.dispose() + } + + public async remountEditor() { + if (!this.lastEditorElement) return + + await this.unmountEditor() + await this.mountEditor(this.lastEditorElement) + } + + public async save() { + if (!this.model) return + if (!this.editor) return + + this.icon.value = 'loading' + + if (Settings.get('formatOnSave')) { + await this.format() + } + + this.initialVersionId = this.model.getVersionId() + this.modified.value = false + + await fileSystem.writeFile(this.path, this.model.getValue()) + + this.icon.value = this.fileTypeIcon + } + + public async saveAs(savePath: string) { + if (!this.model) return + if (!this.editor) return + + this.icon.value = 'loading' + + if (Settings.get('formatOnSave')) { + await this.format() + } + + this.initialVersionId = this.model.getVersionId() + this.modified.value = false + + await fileSystem.writeFile(savePath, this.model.getValue()) + } + + public copy() { + if (!this.model) return + if (!this.editor) return + + this.editor.focus() + this.editor.trigger('action', 'editor.action.clipboardCopyAction', undefined) + } + + public cut() { + if (!this.model) return + if (!this.editor) return + + this.editor.focus() + this.editor.trigger('action', 'editor.action.clipboardCutAction', undefined) + } + + public paste() { + if (!this.model) return + if (!this.editor) return + + this.editor.focus() + this.editor.trigger('action', 'editor.action.clipboardPasteAction', undefined) + } + + public async format() { + if (!this.editor) return + + const action = this.editor.getAction('editor.action.formatDocument') + + await action?.run() + } + + public goToDefinition() { + if (!this.editor) return + + this.editor.trigger('action', 'editor.action.revealDefinition', undefined) + } + + public goToSymbol() { + if (!this.editor) return + + this.editor.focus() + this.editor.trigger('action', 'editor.action.quickOutline', undefined) + } + + public changeAllOccurrences() { + if (!this.editor) return + + this.editor.trigger('action', 'editor.action.rename', undefined) + } + + public async viewDocumentation() { + if (!this.editor) return + if (!this.model) return + + const selection = this.editor.getSelection() + + if (!selection) return + + if (!this.fileType.documentation) return + + let word: string | undefined + if (this.language.value === 'json') { + word = (await this.getJsonWordAtPosition(this.model, selection.getPosition())).word + } else { + word = this.model.getWordAtPosition(selection.getPosition())?.word + } + + if (!word) return + + let url = this.fileType.documentation.baseUrl + if (word && (this.fileType.documentation.supportsQuerying ?? true)) url += `#${word}` + + openUrl(url) + } + + private async getJsonWordAtPosition(model: editor.ITextModel, position: Position) { + const line = model.getLineContent(position.lineNumber) + + const wordStart = this.getPreviousQuote(line, position.column) + const wordEnd = this.getNextQuote(line, position.column) + return { + word: line.substring(wordStart, wordEnd), + range: new Range(position.lineNumber, wordStart, position.lineNumber, wordEnd), + } + } + + private getNextQuote(line: string, startIndex: number) { + for (let i = startIndex - 1; i < line.length; i++) { + if (line[i] === '"') return i + } + return line.length + } + + private getPreviousQuote(line: string, startIndex: number) { + for (let i = startIndex - 2; i > 0; i--) { + if (line[i] === '"') return i + 1 + } + return 0 + } + + private getColor(name: string): string { + return this.convertColor( + //@ts-ignore Typescript doesn't like indexing the colors for some reason + ThemeManager.get(ThemeManager.currentTheme).colors[name] ?? 'pink' + ) + } + + private convertColor(color: string): string { + if (!color) return color + + if (color.startsWith('#')) { + if (color.length === 4) { + return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}` + } + return color + } + + return '#' + keyword.hex(color as any) + } + + private updateEditorTheme() { + const theme = ThemeManager.get(ThemeManager.currentTheme) + + monaco.defineTheme(`bridge`, { + base: theme.colorScheme === 'light' ? 'vs' : 'vs-dark', + inherit: false, + colors: { + 'editor.background': this.getColor('background'), + 'editor.lineHighlightBackground': this.getColor('lineHighlightBackground'), + 'editorWidget.background': this.getColor('background'), + 'editorWidget.border': this.getColor('backgroundSecondary'), + 'pickerGroup.background': this.getColor('background'), + 'pickerGroup.border': this.getColor('backgroundSecondary'), + 'badge.background': this.getColor('background'), + + 'input.background': this.getColor('backgroundSecondary'), + 'input.border': this.getColor('backgroundSecondary'), + 'inputOption.activeBorder': this.getColor('primary'), + focusBorder: this.getColor('primary'), + 'list.focusBackground': this.getColor('backgroundSecondary'), + 'list.hoverBackground': this.getColor('backgroundSecondary'), + contrastBorder: this.getColor('backgroundSecondary'), + + 'peekViewTitle.background': this.getColor('background'), + 'peekView.border': this.getColor('primary'), + 'peekViewResult.background': this.getColor('backgroundSecondary'), + 'peekViewResult.selectionBackground': this.getColor('backgroundSecondary'), + 'peekViewEditor.background': this.getColor('background'), + 'peekViewEditor.matchHighlightBackground': this.getColor('backgroundSecondary'), + ...theme.monaco, + }, + rules: [ + //@ts-ignore + { + background: this.getColor('background'), + foreground: this.getColor('text'), + }, + ...Object.entries(theme.highlighter ?? {}) + .map(([token, { color, background, textDecoration, isItalic }]) => ({ + token: token, + foreground: this.convertColor(color as string), + background: background ? this.convertColor(background as string) : undefined, + fontStyle: `${isItalic ? 'italic ' : ''}${textDecoration}`, + })) + .filter(({ foreground }) => foreground !== undefined), + ], + }) + + monaco.setTheme(`bridge`) + + let keywords: string[] = ['minecraft', 'bridge', ProjectManager.currentProject?.config?.namespace].filter( + (item) => item !== undefined + ) as string[] + let typeIdentifiers: string[] = [] + let variables: string[] = [] + let definitions: string[] = [] + + if (this.fileType && this.fileType.highlighterConfiguration) { + keywords = [...keywords, ...(this.fileType.highlighterConfiguration.keywords ?? [])] + typeIdentifiers = this.fileType.highlighterConfiguration.typeIdentifiers ?? [] + variables = this.fileType.highlighterConfiguration.variables ?? [] + definitions = this.fileType.highlighterConfiguration.definitions ?? [] + } + + setMonarchTokensProvider({ + defaultToken: 'identifier', + + keywords, + atoms: ['true', 'false', 'null'], + typeIdentifiers, + definitions, + variables, + + symbols: /[=> { + if (!this.active) return + + if (!this.editor) return + + const viewState = this.editor.saveViewState() + + this.currentState = { + viewState, + } + + this.saveState() + }, 50) + + private interruptAutoSave = interupt(() => { + this.save() + }, 1000) +} diff --git a/src/components/Tabs/Text/TextTab.vue b/src/components/Tabs/Text/TextTab.vue new file mode 100644 index 000000000..27e502390 --- /dev/null +++ b/src/components/Tabs/Text/TextTab.vue @@ -0,0 +1,71 @@ + + + diff --git a/src/components/Tabs/TreeEditor/EditorElements/TreeEditorContainerElement.vue b/src/components/Tabs/TreeEditor/EditorElements/TreeEditorContainerElement.vue new file mode 100644 index 000000000..ba5f7ed28 --- /dev/null +++ b/src/components/Tabs/TreeEditor/EditorElements/TreeEditorContainerElement.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/Tabs/TreeEditor/EditorElements/TreeEditorPropertyElement.vue b/src/components/Tabs/TreeEditor/EditorElements/TreeEditorPropertyElement.vue new file mode 100644 index 000000000..987d5c3d8 --- /dev/null +++ b/src/components/Tabs/TreeEditor/EditorElements/TreeEditorPropertyElement.vue @@ -0,0 +1,327 @@ + + + diff --git a/src/components/Tabs/TreeEditor/EditorElements/TreeEditorValueElement.vue b/src/components/Tabs/TreeEditor/EditorElements/TreeEditorValueElement.vue new file mode 100644 index 000000000..8ec053df9 --- /dev/null +++ b/src/components/Tabs/TreeEditor/EditorElements/TreeEditorValueElement.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/components/Tabs/TreeEditor/HighlightedText.vue b/src/components/Tabs/TreeEditor/HighlightedText.vue new file mode 100644 index 000000000..8bca2d481 --- /dev/null +++ b/src/components/Tabs/TreeEditor/HighlightedText.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/components/Tabs/TreeEditor/Tree.ts b/src/components/Tabs/TreeEditor/Tree.ts new file mode 100644 index 000000000..d41ddf791 --- /dev/null +++ b/src/components/Tabs/TreeEditor/Tree.ts @@ -0,0 +1,406 @@ +import { v4 as uuid } from 'uuid' + +export type TreeElements = ObjectElement | ArrayElement | ValueElement +export type ParentElements = ObjectElement | ArrayElement | null + +abstract class TreeElement { + public id = uuid() + + public constructor(public parent: ParentElements, public key: string | number | null) {} + + public abstract toJson(): any + + public abstract clone(parent: ParentElements): TreeElements +} + +export class ObjectElement extends TreeElement { + public children: Record = {} + + public constructor(parent: ParentElements = null, public key: string | number | null = null) { + super(parent, key) + } + + public toJson(): any { + return Object.fromEntries(Object.entries(this.children).map(([key, child]) => [key, child.toJson()])) + } + + public clone(parent: ParentElements = null): ObjectElement { + const clonedElement = new ObjectElement(parent) + + for (const key of Object.keys(this.children)) { + clonedElement.children[key] = this.children[key].clone(clonedElement) + } + + return clonedElement + } +} + +export class ArrayElement extends TreeElement { + public children: TreeElements[] = [] + + public constructor(parent: ParentElements = null, public key: string | number | null = null) { + super(parent, key) + } + + public toJson(): any { + return this.children.map((child) => child.toJson()) + } + + public clone(parent: ParentElements = null): ArrayElement { + const clonedElement = new ArrayElement(parent) + + for (let index = 0; index < this.children.length; index++) { + clonedElement.children[index] = this.children[index].clone(clonedElement) + } + + return clonedElement + } +} + +export class ValueElement extends TreeElement { + public constructor( + parent: ParentElements = null, + public key: string | number | null = null, + public value: number | string | boolean | null + ) { + super(parent, key) + } + + public toJson(): any { + return this.value + } + + public clone(parent: ParentElements = null): ValueElement { + return new ValueElement(parent, this.key, JSON.parse(JSON.stringify(this.value))) + } +} + +export function buildTree( + json: Object | number | string | boolean | null, + parent: ParentElements = null, + key: string | number | null = null +): TreeElements { + if (Array.isArray(json)) { + const element = new ArrayElement(parent, key) + + element.children = json.map((child, index) => buildTree(child, element, index)) + + return element + } else if (json === null) { + return new ValueElement(parent, key, null) + } else if (typeof json === 'object') { + const element = new ObjectElement(parent, key) + + element.children = Object.fromEntries( + Object.entries(json).map(([key, value]) => [key, buildTree(value, element, key)]) + ) + + return element + } else { + return new ValueElement(parent, key, json) + } +} + +export type TreeSelection = { type: 'value' | 'property'; tree: TreeElements } | null + +export interface TreeEdit { + apply(): TreeSelection + + undo(): TreeSelection +} + +export class ModifyValueEdit implements TreeEdit { + private oldValue: number | string | boolean | null + + public constructor(public element: ValueElement, public value: number | string | boolean | null) { + this.oldValue = element.value + } + + public apply(): TreeSelection { + this.element.value = this.value + + return { type: 'value', tree: this.element } + } + + public undo(): TreeSelection { + this.element.value = this.oldValue + + return { type: 'value', tree: this.element } + } +} + +export class ModifyPropertyKeyEdit implements TreeEdit { + private propertyIndex: number + + public constructor(public element: ObjectElement, public oldKey: string, public newKey: string) { + this.propertyIndex = Object.keys(element.children).indexOf(oldKey) + } + + public apply(): TreeSelection { + const child = this.element.children[this.oldKey] + + delete this.element.children[this.oldKey] + + const keys = Object.keys(this.element.children) + const values = Object.values(this.element.children) + + keys.splice(this.propertyIndex, 0, this.newKey) + values.splice(this.propertyIndex, 0, child) + + this.element.children = Object.fromEntries(keys.map((key, index) => [key, values[index]])) + + child.key = this.newKey + + return { type: 'property', tree: child } + } + + public undo(): TreeSelection { + const child = this.element.children[this.newKey] + + delete this.element.children[this.newKey] + + const keys = Object.keys(this.element.children) + const values = Object.values(this.element.children) + + keys.splice(this.propertyIndex, 0, this.oldKey) + values.splice(this.propertyIndex, 0, child) + + this.element.children = Object.fromEntries(keys.map((key, index) => [key, values[index]])) + + child.key = this.oldKey + + return { type: 'property', tree: child } + } +} + +export class AddPropertyEdit implements TreeEdit { + public constructor(public element: ObjectElement, public key: string, public value: TreeElements) {} + + public apply(): TreeSelection { + this.element.children[this.key] = this.value + + return { type: 'property', tree: this.element } + } + + public undo(): TreeSelection { + delete this.element.children[this.key] + + return null + } +} + +export class AddElementEdit implements TreeEdit { + public constructor(public element: ArrayElement, public value: TreeElements) {} + + public apply(): TreeSelection { + this.element.children.push(this.value) + + return { type: 'value', tree: this.element } + } + + public undo(): TreeSelection { + this.element.children.pop() + + return null + } +} + +export class DeleteElementEdit implements TreeEdit { + public oldPropertyIndex: number = -1 + + public constructor(public element: TreeElements) { + if (element.parent instanceof ObjectElement) + this.oldPropertyIndex = Object.keys(element.parent.children).indexOf(element.key as string) + } + + public apply(): TreeSelection { + if (this.element.parent instanceof ObjectElement) { + delete this.element.parent.children[this.element.key as string] + } + + if (this.element.parent instanceof ArrayElement) { + this.element.parent.children.splice(this.element.key as number, 1) + + for (let index = 0; index < this.element.parent.children.length; index++) { + this.element.parent.children[index].key = index + } + } + + return null + } + + public undo(): TreeSelection { + if (this.element.parent instanceof ObjectElement) { + this.element.parent.children[this.element.key as string] = this.element + + const keys = Object.keys(this.element.parent.children) + const values = Object.values(this.element.parent.children) + + keys.splice(this.oldPropertyIndex, 0, this.element.key as string) + values.splice(this.oldPropertyIndex, 0, this.element) + + this.element.parent.children = Object.fromEntries(keys.map((key, index) => [key, values[index]])) + } + + if (this.element.parent instanceof ArrayElement) { + this.element.parent.children.splice(this.element.key as number, 0, this.element) + + for (let index = 0; index < this.element.parent.children.length; index++) { + this.element.parent.children[index].key = index + } + } + + return { type: 'value', tree: this.element } + } +} + +export class MoveEdit implements TreeEdit { + private oldParent: ObjectElement | ArrayElement + private oldPropertyIndex: number + private oldKey: string | number + private newKey: string | number + + public constructor( + private element: TreeElements, + private newParent: ObjectElement | ArrayElement, + private newPropertyIndex: number + ) { + if (!element.parent) throw new Error('Element must have a parent') + if (element.key === null) throw new Error('Element must have a parent') + + this.oldParent = element.parent + this.oldKey = element.key + + if (this.oldParent instanceof ObjectElement) { + this.oldPropertyIndex = Object.keys(this.oldParent.children).indexOf(element.key as string) + } else { + this.oldPropertyIndex = element.key as number + } + + if (this.newParent instanceof ObjectElement) { + this.newKey = typeof this.oldKey === 'string' ? this.oldKey : 'new_property' + + while (Object.keys(this.newParent.children).includes(this.newKey)) { + this.newKey = 'new_' + this.newKey + } + } else { + this.newKey = this.newPropertyIndex + } + } + + public apply(): TreeSelection { + if (this.oldParent instanceof ObjectElement) { + delete this.oldParent.children[this.element.key as string] + } else { + this.oldParent.children.splice(this.oldPropertyIndex, 1) + + for (let index = 0; index < this.oldParent.children.length; index++) { + this.oldParent.children[index].key = index + } + } + + if (this.newParent instanceof ObjectElement) { + const keys = Object.keys(this.newParent.children) + const values = Object.values(this.newParent.children) + + keys.splice(this.newPropertyIndex, 0, this.newKey as string) + values.splice(this.newPropertyIndex, 0, this.element) + + this.newParent.children = Object.fromEntries(keys.map((key, index) => [key, values[index]])) + + this.element.parent = this.newParent + this.element.key = this.newKey + + return { type: 'property', tree: this.element } + } else { + this.newParent.children.splice(this.newPropertyIndex, 0, this.element) + + this.element.parent = this.newParent + + for (let index = 0; index < this.newParent.children.length; index++) { + this.newParent.children[index].key = index + } + + return { type: 'value', tree: this.element } + } + } + + public undo(): TreeSelection { + if (this.newParent instanceof ObjectElement) { + delete this.newParent.children[this.element.key as string] + } else { + this.newParent.children.splice(this.newPropertyIndex, 1) + + for (let index = 0; index < this.newParent.children.length; index++) { + this.newParent.children[index].key = index + } + } + + if (this.oldParent instanceof ObjectElement) { + const keys = Object.keys(this.oldParent.children) + const values = Object.values(this.oldParent.children) + + keys.splice(this.oldPropertyIndex, 0, this.oldKey as string) + values.splice(this.oldPropertyIndex, 0, this.element) + + this.oldParent.children = Object.fromEntries(keys.map((key, index) => [key, values[index]])) + + this.element.parent = this.oldParent + this.element.key = this.oldKey + + return { type: 'property', tree: this.element } + } else { + this.oldParent.children.splice(this.oldPropertyIndex, 0, this.element) + + this.element.parent = this.oldParent + + for (let index = 0; index < this.oldParent.children.length; index++) { + this.oldParent.children[index].key = index + } + + return { type: 'value', tree: this.element } + } + } +} + +export class ReplaceEdit implements TreeEdit { + constructor( + public element: TreeElements, + public newElement: TreeElements, + public setRoot: (element: TreeElements) => void + ) {} + + apply(): TreeSelection { + const parent = this.element.parent + + if (parent === null) { + this.setRoot(this.newElement) + } else if (parent instanceof ObjectElement) { + this.newElement.key = this.element.key + this.newElement.parent = parent + + parent.children[this.element.key!] = this.newElement + } else if (parent instanceof ArrayElement) { + this.newElement.key = this.element.key + this.newElement.parent = parent + + parent.children[this.element.key as number] = this.newElement + } + + return { tree: this.newElement, type: 'value' } + } + + undo(): TreeSelection { + const parent = this.newElement.parent + + if (parent === null) { + this.setRoot(this.element) + } else if (parent instanceof ObjectElement) { + parent.children[this.element.key!] = this.element + } else if (parent instanceof ArrayElement) { + parent.children[this.newElement.key as number] = this.element + } + + return { tree: this.element, type: 'value' } + } +} diff --git a/src/components/Tabs/TreeEditor/TreeEditorTab.ts b/src/components/Tabs/TreeEditor/TreeEditorTab.ts new file mode 100644 index 000000000..1a086a178 --- /dev/null +++ b/src/components/Tabs/TreeEditor/TreeEditorTab.ts @@ -0,0 +1,437 @@ +import { Component, Ref, ref, watch } from 'vue' +import TreeEditorTabComponent from './TreeEditorTab.vue' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { FileTab } from '@/components/TabSystem/FileTab' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { buildTree, ObjectElement, ParentElements, TreeEdit, TreeElements, TreeSelection } from './Tree' +import { CompletionItem, createSchema, Diagnostic } from '@/libs/jsonSchema/Schema' +import { Settings } from '@/libs/settings/Settings' +import * as JSONC from 'jsonc-parser' +import { interupt } from '@/libs/Interupt' +import { TabManager } from '@/components/TabSystem/TabManager' + +export class TreeEditorTab extends FileTab { + public component: Component | null = TreeEditorTabComponent + public icon = ref('loading') + public language = ref('plaintext') + public hasDocumentation = ref(false) + + public tree: Ref = ref(new ObjectElement(null)) + + private fileCache: string | null = null + + public history: TreeEdit[] = [] + public currentEditIndex = -1 + + public selectedTree: Ref = ref(null) + public draggedTree: Ref = ref(null) + public contextTree: Ref = ref(null) + + public knownWords: Record = { + keywords: [], + typeIdentifiers: [], + variables: [], + definitions: [], + } + + public diagnostics: Ref = ref([]) + public completions: Ref = ref([]) + public parentCompletions: Ref = ref([]) + + private fileTypeIcon: string = 'data_object' + + private fileType: any | null = null + + private disposables: Disposable[] = [] + + public static canEdit(path: string): boolean { + if (Settings.get('jsonEditor') !== 'tree') return false + + return path.endsWith('.json') + } + + public static editPriority(path: string): number { + return 1 + } + + public is(path: string) { + return path === this.path + } + + public static setup() { + Settings.addSetting('bridgePredictions', { + default: true, + }) + + Settings.addSetting('inlineDiagnostics', { + default: true, + }) + + Settings.addSetting('automaticallyOpenTreeNodes', { + default: true, + }) + + Settings.addSetting('dragAndDropTreeNodes', { + default: true, + }) + + Settings.addSetting('showArrayIndices', { + default: false, + }) + + Settings.addSetting('hideBrackets', { + default: false, + }) + } + + public async create() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + this.disposables.push( + fileSystem.pathUpdated.on(async (path) => { + if (!path) return + if (path !== this.path) return + + if (!(await fileSystem.exists(path))) { + await TabManager.removeTab(this) + } else if (!this.modified.value) { + const fileContent = await fileSystem.readFileText(this.path) + + if (this.fileCache === fileContent) return + + this.fileCache = fileContent + + try { + this.tree.value = buildTree(JSONC.parse(fileContent)) + } catch {} + } + }) + ) + + const fileTypeData = ProjectManager.currentProject.fileTypeData + + this.fileType = fileTypeData.get(this.path) + + if (this.fileType !== null) { + this.hasDocumentation.value = this.fileType.documentation !== undefined + + if (this.fileType.icon !== undefined) this.fileTypeIcon = this.fileType.icon + } + + const fileContent = await fileSystem.readFileText(this.path) + + this.fileCache = fileContent + + try { + this.tree.value = buildTree(JSONC.parse(fileContent)) + } catch {} + + const schemaData = ProjectManager.currentProject.schemaData + + await schemaData.updateSchemaForFile(this.path, this.fileType?.id, this.fileType?.schema) + + this.icon.value = this.fileTypeIcon + + let keywords: string[] = ['minecraft', 'bridge', ProjectManager.currentProject?.config?.namespace].filter( + (item) => item !== undefined + ) as string[] + let typeIdentifiers: string[] = [] + let variables: string[] = [] + let definitions: string[] = [] + + if (this.fileType && this.fileType.highlighterConfiguration) { + keywords = [...keywords, ...(this.fileType.highlighterConfiguration.keywords ?? [])] + typeIdentifiers = this.fileType.highlighterConfiguration.typeIdentifiers ?? [] + variables = this.fileType.highlighterConfiguration.variables ?? [] + definitions = this.fileType.highlighterConfiguration.definitions ?? [] + } + + this.knownWords = { + keywords, + typeIdentifiers, + variables, + definitions, + } + + this.disposables.push( + schemaData.updated.on((path) => { + if (path !== this.path) return + + this.validate() + this.updateCompletions() + }) + ) + + watch(this.selectedTree, () => { + this.updateCompletions() + }) + + this.disposables.push( + Settings.updated.on((event: { id: string; value: any } | undefined) => { + if (!event) return + + if (event.id === 'inlineDiagnostics') this.validate() + }) + ) + } + + public async destroy() { + disposeAll(this.disposables) + + this.interruptAutoSave.dispose() + } + + public async activate() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + const schemaData = ProjectManager.currentProject.schemaData + + schemaData.addFileForUpdate(this.path, this.fileType?.id, this.fileType?.schema) + + await schemaData.updateSchemaForFile(this.path, this.fileType?.id, this.fileType?.schema) + } + + public async deactivate() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + const schemaData = ProjectManager.currentProject.schemaData + + schemaData.removeFileForUpdate(this.path) + } + + public async save() { + this.modified.value = false + this.icon.value = 'loading' + + const content = JSON.stringify(this.tree.value.toJson(), null, 2) + + this.fileCache = content + + await fileSystem.writeFile(this.path, content) + + this.icon.value = this.fileTypeIcon + } + + public async saveAs(savePath: string) { + this.modified.value = false + this.icon.value = 'loading' + + await fileSystem.writeFile(savePath, JSON.stringify(this.tree.value.toJson(), null, 2)) + } + + public select(tree: TreeElements) { + this.selectedTree.value = { type: 'value', tree } + } + + public selectProperty(tree: TreeElements) { + this.selectedTree.value = { type: 'property', tree } + } + + public drag(tree: TreeElements) { + this.draggedTree.value = { type: 'property', tree } + } + + public cancelDrag() { + this.draggedTree.value = null + } + + public edit(edit: TreeEdit) { + this.modified.value = true + + if (this.currentEditIndex !== this.history.length - 1) this.history = this.history.slice(0, this.currentEditIndex + 1) + + this.history.push(edit) + this.currentEditIndex++ + + this.selectedTree.value = edit.apply() + + this.validate() + this.updateCompletions() + + if (Settings.get('autoSaveChanges')) { + this.interruptAutoSave.invoke() + } + } + + public undo() { + if (this.currentEditIndex < 0) return + + this.modified.value = true + + this.selectedTree.value = this.history[this.currentEditIndex].undo() + + this.currentEditIndex-- + + this.validate() + this.updateCompletions() + + if (Settings.get('autoSaveChanges')) { + this.interruptAutoSave.invoke() + } + } + + public redo() { + if (this.currentEditIndex >= this.history.length - 1) return + + this.modified.value = true + + this.currentEditIndex++ + + this.selectedTree.value = this.history[this.currentEditIndex].apply() + + this.validate() + this.updateCompletions() + + if (Settings.get('autoSaveChanges')) { + this.interruptAutoSave.invoke() + } + } + + public getTreeSchemaPath(tree: TreeElements): string { + let path = '/' + + let parents = [] + let currentElement: ParentElements | TreeElements = tree + + while (currentElement?.parent) { + parents.push(currentElement) + + currentElement = currentElement.parent + } + + parents.reverse() + + for (const element of parents) { + if (typeof element.key !== 'string') { + path += 'any_index/' + + continue + } + + path += element.key?.toString() + '/' + } + + return path + } + + public getTreeParentSchemaPath(tree: TreeElements): string { + let path = '/' + + let parents = [] + let currentElement: ParentElements | TreeElements = tree + + while (currentElement?.parent) { + parents.push(currentElement) + + currentElement = currentElement.parent + } + + parents.reverse() + + for (const element of parents.slice(0, -1)) { + if (typeof element.key !== 'string') { + path += 'any_index/' + + continue + } + + path += element.key?.toString() + '/' + } + + return path + } + + public getTypes(path: string): string[] { + if (!ProjectManager.currentProject) return [] + if (!(ProjectManager.currentProject instanceof BedrockProject)) return [] + + const schemaData = ProjectManager.currentProject.schemaData + + const schemas = schemaData.getSchemasForFile(this.path) + + if (!schemas) return [] + + const schema = schemas.localSchemas[schemas.main] + + const filePath = this.path + + console.time('Get Types') + + const valueSchema = createSchema(schema, (path: string) => schemaData.getSchemaForFile(filePath, path)) + + const types = valueSchema.getTypes(this.tree.value.toJson(), path) + + console.timeEnd('Get Types') + + return types + } + + private validate() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + if (!Settings.get('inlineDiagnostics')) { + return (this.diagnostics.value = []) + } + + const schemaData = ProjectManager.currentProject.schemaData + + const schemas = schemaData.getSchemasForFile(this.path) + + if (!schemas) return + + const schema = schemas.localSchemas[schemas.main] + + const filePath = this.path + + console.time('Validate') + + const valueSchema = createSchema(schema, (path: string) => schemaData.getSchemaForFile(filePath, path)) + + this.diagnostics.value = valueSchema.validate(this.tree.value.toJson()) + + console.timeEnd('Validate') + } + + private updateCompletions() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + if (!this.selectedTree.value) { + this.completions.value = [] + + return + } + + const schemaData = ProjectManager.currentProject.schemaData + + const schemas = schemaData.getSchemasForFile(this.path) + + if (!schemas) return + + const schema = schemas.localSchemas[schemas.main] + + const filePath = this.path + + console.time('Completions') + + const valueSchema = createSchema(schema, (path: string) => schemaData.getSchemaForFile(filePath, path)) + + let path = this.getTreeSchemaPath(this.selectedTree.value.tree) + let parentPath = this.getTreeParentSchemaPath(this.selectedTree.value.tree) + + this.completions.value = valueSchema.getCompletionItems(this.tree.value.toJson(), path) + this.parentCompletions.value = valueSchema.getCompletionItems(this.tree.value.toJson(), parentPath) + + console.timeEnd('Completions') + } + + private interruptAutoSave = interupt(() => { + this.save() + }, 1000) +} diff --git a/src/components/Tabs/TreeEditor/TreeEditorTab.vue b/src/components/Tabs/TreeEditor/TreeEditorTab.vue new file mode 100644 index 000000000..0b772c4ff --- /dev/null +++ b/src/components/Tabs/TreeEditor/TreeEditorTab.vue @@ -0,0 +1,336 @@ + + + diff --git a/src/components/TaskManager/SimpleWorkerTask.ts b/src/components/TaskManager/SimpleWorkerTask.ts deleted file mode 100644 index d82ae2330..000000000 --- a/src/components/TaskManager/SimpleWorkerTask.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Progress } from '../Common/Progress' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' - -export abstract class SimpleTaskService extends EventDispatcher< - [number, number] -> { - protected lastDispatch = 0 - public progress = new Progress(0, 100, 100) - - constructor() { - super() - this.progress.on(this.dispatch.bind(this)) - } - - dispatch(data: [number, number]) { - // Always send last data batch - if (data[0] === data[1]) super.dispatch(data) - - // Otherwise, first check that we don't send too many messages to the main thread - if (this.lastDispatch + 200 > Date.now()) return - - super.dispatch(data) - this.lastDispatch = Date.now() - } -} diff --git a/src/components/TaskManager/Task.ts b/src/components/TaskManager/Task.ts deleted file mode 100644 index 42c80e167..000000000 --- a/src/components/TaskManager/Task.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { InformationWindow } from '../Windows/Common/Information/InformationWindow' -import type { TaskManager } from './TaskManager' - -export interface ITaskDetails { - icon: string - name: string - description: string - totalTaskSteps?: number - indeterminate?: boolean -} - -export class Task { - public currentStepCount: number = 0 - protected window: any - constructor( - protected taskManager: TaskManager, - protected taskDetails: ITaskDetails - ) {} - - update(newStepCount?: number, newTotalSteps?: number) { - if (newStepCount !== undefined) this.currentStepCount = newStepCount - if (newTotalSteps !== undefined) - this.taskDetails.totalTaskSteps = newTotalSteps - } - - createWindow() { - this.window = new InformationWindow({ - name: this.name, - description: this.description, - }) - } - - complete() { - if (this.window) this.window.dispose() - this.dispose() - } - - protected dispose() { - this.taskManager?.delete(this) - } - - get name() { - return this.taskDetails.name - } - get description() { - return this.taskDetails.description - } - get totalStepCount() { - return this.taskDetails.totalTaskSteps ?? 100 - } - get icon() { - return this.taskDetails.icon - } - get progress() { - if (this.taskDetails.indeterminate) return undefined - - return Math.round((this.currentStepCount / this.totalStepCount) * 100) - } -} diff --git a/src/components/TaskManager/TaskManager.ts b/src/components/TaskManager/TaskManager.ts deleted file mode 100644 index 9165cb04c..000000000 --- a/src/components/TaskManager/TaskManager.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ITaskDetails, Task } from './Task' -import { ref } from 'vue' - -export const tasks = ref([]) - -export class TaskManager { - create(taskDetails: ITaskDetails) { - const task = new Task(this, taskDetails) - tasks.value.push(task) - return task - } - delete(task: Task) { - tasks.value = tasks.value.filter((t) => t !== task) - } - - get hasRunningTasks() { - return tasks.value.length > 0 - } -} diff --git a/src/components/TaskManager/WorkerTask.ts b/src/components/TaskManager/WorkerTask.ts deleted file mode 100644 index 44c839e98..000000000 --- a/src/components/TaskManager/WorkerTask.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' - -class LegacyProgress { - constructor( - protected taskService: TaskService, - protected current: number, - protected total: number - ) {} - - addToCurrent(value?: number) { - this.current += value ?? 1 - this.taskService.dispatch([this.getCurrent(), this.getTotal()]) - } - addToTotal(value?: number) { - this.total += value ?? 1 - this.taskService.dispatch([this.getCurrent(), this.getTotal()]) - } - - getTotal() { - return this.total - } - getCurrent() { - return this.current - } - - setTotal(val: number) { - this.total = val - } -} - -export abstract class TaskService extends EventDispatcher< - [number, number] -> { - protected lastDispatch = 0 - public progress!: LegacyProgress - - protected abstract onStart(data: K): Promise | T - - async start(data: K) { - this.progress = new LegacyProgress(this, 0, 0) - - const result = await this.onStart(data) - - this.dispatch([this.progress.getCurrent(), this.progress.getCurrent()]) - - return result - } - - dispatch(data: [number, number]) { - // Otherwise, first check that we don't send too many messages to the main thread - if (this.lastDispatch + 200 > Date.now()) return - - super.dispatch(data) - this.lastDispatch = Date.now() - } -} diff --git a/src/components/Toolbar/Category/download.ts b/src/components/Toolbar/Category/download.ts deleted file mode 100644 index ab6b8bd09..000000000 --- a/src/components/Toolbar/Category/download.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { vuetify } from '../../App/Vuetify' -import { ToolbarButton } from '../ToolbarButton' -import { App } from '/@/App' - -export function setupDownloadButton(app: App) { - App.toolbar.add( - new ToolbarButton( - 'mdi-download', - 'toolbar.download.name', - () => { - App.openUrl( - 'https://bridge-core.app/guide/download/', - undefined, - true - ) - }, - { value: !import.meta.env.VITE_IS_TAURI_APP } - ) - ) -} diff --git a/src/components/Toolbar/Category/file.ts b/src/components/Toolbar/Category/file.ts deleted file mode 100644 index aa23c23dd..000000000 --- a/src/components/Toolbar/Category/file.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { App } from '/@/App' -import { ToolbarCategory } from '../ToolbarCategory' -import { Divider } from '../Divider' -import { platform } from '/@/utils/os' -import { - AnyDirectoryHandle, - AnyFileHandle, -} from '/@/components/FileSystem/Types' -import { - isUsingFileSystemPolyfill, - isUsingOriginPrivateFs, -} from '/@/components/FileSystem/Polyfill' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { download } from '/@/components/FileSystem/saveOrDownload' -import { CommandBarState } from '/@/components/CommandBar/State' -import { showFolderPicker } from '/@/components/FileSystem/Pickers/showFolderPicker' - -export function setupFileCategory(app: App) { - const file = new ToolbarCategory('mdi-file-outline', 'toolbar.file.name') - - file.disposables.push( - App.eventSystem.on('projectChanged', () => { - file.shouldRender.value = !app.isNoProjectSelected - }) - ) - - file.shouldRender.value = !app.isNoProjectSelected - - file.addItem( - app.actionManager.create({ - id: 'bridge.action.newFile', - icon: 'mdi-file-plus-outline', - name: 'actions.newFile.name', - description: 'actions.newFile.description', - keyBinding: 'Ctrl + N', - isDisabled: () => app.isNoProjectSelected, - onTrigger: () => app.windows.createPreset.open(), - }) - ) - file.addItem( - app.actionManager.create({ - id: 'bridge.action.openFile', - icon: 'mdi-open-in-app', - name: 'actions.openFile.name', - description: 'actions.openFile.description', - keyBinding: 'Ctrl + O', - onTrigger: async () => { - let fileHandles: AnyFileHandle[] - try { - fileHandles = await window.showOpenFilePicker({ - multiple: true, - }) - } catch { - return - } - - for (const fileHandle of fileHandles) { - if (await app.fileDropper.importFile(fileHandle)) continue - - app.project.openFile(fileHandle, { isTemporary: false }) - } - }, - }) - ) - // Doesn't make sense to show this option on fs polyfill browsers - if (import.meta.env.VITE_IS_TAURI_APP || !isUsingFileSystemPolyfill.value) - file.addItem( - app.actionManager.create({ - id: 'bridge.action.openFolder', - icon: 'mdi-folder-open-outline', - name: 'actions.openFolder.name', - description: 'actions.openFolder.description', - keyBinding: 'Ctrl + Shift + O', - onTrigger: async () => { - const app = await App.getApp() - const [directoryHandle] = (await showFolderPicker()) ?? [] - if (!directoryHandle) return - - await app.fileDropper.importFolder(directoryHandle) - }, - }) - ) - file.addItem( - app.actionManager.create({ - id: 'bridge.action.searchFile', - icon: 'mdi-magnify', - name: 'actions.searchFile.name', - description: 'actions.searchFile.description', - keyBinding: 'Ctrl + P', - onTrigger: () => (CommandBarState.isWindowOpen = true), - }) - ) - file.addItem( - app.actionManager.create({ - icon: 'mdi-file-cancel-outline', - name: 'actions.closeFile.name', - description: 'actions.closeFile.description', - keyBinding: 'Ctrl + W', - onTrigger: () => App.ready.once((app) => app.tabSystem?.close()), - }) - ) - - file.addItem(new Divider()) - - file.addItem( - app.actionManager.create({ - icon: 'mdi-content-save-outline', - name: 'actions.saveFile.name', - description: 'actions.saveFile.description', - keyBinding: 'Ctrl + S', - onTrigger: () => App.ready.once((app) => app.tabSystem?.save()), - }) - ) - - if ( - !import.meta.env.VITE_IS_TAURI_APP && - (isUsingFileSystemPolyfill.value || isUsingOriginPrivateFs) - ) { - file.addItem( - app.actionManager.create({ - icon: 'mdi-file-download-outline', - name: 'actions.downloadFile.name', - description: 'actions.downloadFile.description', - keyBinding: 'Ctrl + Shift + S', - onTrigger: async () => { - const app = await App.getApp() - - const currentTab = app.project.tabSystem?.selectedTab - if (!(currentTab instanceof FileTab)) return - - const [_, compiled] = - await app.project.compilerService.compileFile( - currentTab.getPath(), - await currentTab - .getFile() - .then( - async (file) => - new Uint8Array(await file.arrayBuffer()) - ) - ) - - let uint8arr: Uint8Array - if (typeof compiled === 'string') - uint8arr = new TextEncoder().encode(compiled) - else if (compiled instanceof Blob) - uint8arr = new Uint8Array(await compiled.arrayBuffer()) - else uint8arr = new Uint8Array(compiled) - - download(currentTab.name, uint8arr) - }, - }) - ) - } else { - file.addItem( - app.actionManager.create({ - icon: 'mdi-content-save-edit-outline', - name: 'actions.saveAs.name', - description: 'actions.saveAs.description', - keyBinding: 'Ctrl + Shift + S', - onTrigger: () => - App.ready.once((app) => app.tabSystem?.saveAs()), - }) - ) - } - - file.addItem( - app.actionManager.create({ - icon: 'mdi-content-save-settings-outline', - name: 'actions.saveAll.name', - description: 'actions.saveAll.description', - keyBinding: - platform() === 'win32' ? 'Ctrl + Alt + S' : 'Ctrl + Meta + S', - onTrigger: () => App.ready.once((app) => app.tabSystem?.saveAll()), - }) - ) - - App.toolbar.addCategory(file) -} diff --git a/src/components/Toolbar/Category/help.ts b/src/components/Toolbar/Category/help.ts deleted file mode 100644 index 6eccd531b..000000000 --- a/src/components/Toolbar/Category/help.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { App } from '/@/App' -import { ToolbarCategory } from '../ToolbarCategory' -import { Divider } from '../Divider' -import { openAboutWindow } from '../../Windows/About/AboutWindow' - -export function setupHelpCategory(app: App) { - const help = new ToolbarCategory('mdi-help', 'toolbar.help.name') - help.addItem( - app.actionManager.create({ - name: 'actions.about.name', - icon: 'mdi-information-outline', - description: 'actions.about.description', - onTrigger: () => openAboutWindow(), - }) - ) - help.addItem( - app.actionManager.create({ - name: 'actions.releases.name', - icon: 'mdi-alert-decagram', - description: 'actions.releases.description', - onTrigger: () => - App.openUrl( - 'https://github.com/bridge-core/editor/releases', - undefined, - true - ), - }) - ) - help.addItem( - app.actionManager.create({ - name: 'actions.bugReports.name', - icon: 'mdi-bug-outline', - description: 'actions.bugReports.description', - onTrigger: () => - App.openUrl( - 'https://github.com/bridge-core/editor/issues/new/choose', - undefined, - true - ), - }) - ) - help.addItem( - app.actionManager.create({ - name: 'actions.twitter.name', - icon: 'mdi-twitter', - description: 'actions.twitter.description', - onTrigger: () => - App.openUrl('https://twitter.com/bridgeIDE', undefined, true), - }) - ) - - help.addItem(new Divider()) - - help.addItem( - app.actionManager.create({ - name: 'actions.extensionAPI.name', - icon: 'mdi-puzzle-outline', - description: 'actions.extensionAPI.description', - onTrigger: () => - App.openUrl( - 'https://bridge-core.github.io/extension-docs/', - undefined, - true - ), - }) - ) - help.addItem( - app.actionManager.create({ - name: 'actions.gettingStarted.name', - icon: 'mdi-help-circle-outline', - description: 'actions.gettingStarted.description', - onTrigger: () => - App.openUrl( - 'https://bridge-core.github.io/editor-docs/getting-started/', - undefined, - true - ), - }) - ) - help.addItem( - app.actionManager.create({ - name: 'actions.faq.name', - icon: 'mdi-frequently-asked-questions', - description: 'actions.faq.description', - onTrigger: () => - App.openUrl( - 'https://bridge-core.github.io/editor-docs/faq/', - undefined, - true - ), - }) - ) - - App.toolbar.addCategory(help) -} diff --git a/src/components/Toolbar/Category/project.ts b/src/components/Toolbar/Category/project.ts deleted file mode 100644 index 4cad0494e..000000000 --- a/src/components/Toolbar/Category/project.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { App } from '/@/App' -import { ToolbarCategory } from '../ToolbarCategory' -import { Divider } from '../Divider' -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' -import { createVirtualProjectWindow } from '/@/components/FileSystem/Virtual/ProjectWindow' -import { importNewProject } from '/@/components/Projects/Import/ImportNew' -import { virtualProjectName } from '/@/components/Projects/Project/Project' -import { revealInFileExplorer } from '/@/utils/revealInFileExplorer' -import { getBridgeFolderPath } from '/@/utils/getBridgeFolderPath' - -export function setupProjectCategory(app: App) { - const project = new ToolbarCategory( - 'mdi-view-dashboard-outline', - 'toolbar.project.name' - ) - - project.addItem( - app.actionManager.create({ - icon: 'mdi-home-outline', - name: 'actions.goHome.name', - description: 'actions.goHome.description', - isDisabled: () => - app.isNoProjectSelected && - !app.viewComMojangProject.hasComMojangProjectLoaded, - onTrigger: async () => { - const app = await App.getApp() - app.projectManager.selectProject(virtualProjectName) - - if (!App.sidebar.elements.packExplorer.isSelected) - App.sidebar.elements.packExplorer.click() - }, - }) - ) - project.addItem( - app.actionManager.create({ - icon: 'mdi-folder-open-outline', - name: 'windows.projectChooser.title', - description: 'windows.projectChooser.description', - isDisabled: () => app.hasNoProjects, - onTrigger: () => { - if ( - !import.meta.env.VITE_IS_TAURI_APP && - isUsingFileSystemPolyfill.value - ) { - createVirtualProjectWindow() - } else { - App.instance.windows.projectChooser.open() - } - }, - }) - ) - project.addItem( - app.actionManager.create({ - icon: 'mdi-minecraft', - name: 'actions.launchMinecraft.name', - description: 'actions.launchMinecraft.description', - keyBinding: 'F5', - onTrigger: () => { - App.openUrl('minecraft:', undefined, true) - }, - }) - ) - project.addItem(new Divider()) - - project.addItem( - app.actionManager.create({ - id: 'bridge.action.newProject', - icon: 'mdi-folder-plus-outline', - name: 'actions.newProject.name', - description: 'actions.newProject.description', - onTrigger: () => app.windows.createProject.open(), - }) - ) - project.addItem( - app.actionManager.create({ - icon: 'mdi-import', - name: 'actions.importBrproject.name', - description: 'actions.importBrproject.description', - onTrigger: () => importNewProject(), - }) - ) - if (import.meta.env.VITE_IS_TAURI_APP) { - project.addItem( - app.actionManager.create({ - icon: 'mdi-folder-search-outline', - name: 'actions.viewBridgeFolder.name', - description: 'actions.viewBridgeFolder.description', - onTrigger: async () => { - revealInFileExplorer(await getBridgeFolderPath()) - }, - }) - ) - } - project.addItem(new Divider()) - - project.addItem( - app.actionManager.create({ - icon: 'mdi-puzzle-outline', - name: 'actions.extensions.name', - description: 'actions.extensions.description', - onTrigger: () => app.windows.extensionStore.open(), - }) - ) - project.addItem( - app.actionManager.create({ - id: 'bridge.action.openSettings', - icon: 'mdi-cog-outline', - name: 'actions.settings.name', - description: 'actions.settings.description', - keyBinding: 'Ctrl + ,', - onTrigger: () => app.windows.settings.open(), - }) - ) - - App.toolbar.addCategory(project) -} diff --git a/src/components/Toolbar/Category/settings.ts b/src/components/Toolbar/Category/settings.ts deleted file mode 100644 index 5275c6b1d..000000000 --- a/src/components/Toolbar/Category/settings.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ToolbarButton } from '../ToolbarButton' -import { App } from '/@/App' - -export function setupSettingsButton(app: App) { - App.toolbar.add( - new ToolbarButton( - 'mdi-cog', - 'actions.settings.name', - () => { - app.windows.settings.open() - }, - { value: true } - ) - ) -} diff --git a/src/components/Toolbar/Category/tools.ts b/src/components/Toolbar/Category/tools.ts deleted file mode 100644 index c6302fde9..000000000 --- a/src/components/Toolbar/Category/tools.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { App } from '/@/App' -import { ToolbarCategory } from '../ToolbarCategory' -import { Divider } from '../Divider' -import { platform } from '/@/utils/os' -import { clearAllNotifications } from '/@/components/Notifications/create' -import { IframeTab } from '/@/components/Editors/IframeTab/IframeTab' -import { BlockbenchTab } from '../../Editors/Blockbench/BlockbenchTab' - -export function setupToolsCategory(app: App) { - const tools = new ToolbarCategory( - 'mdi-wrench-outline', - 'toolbar.tools.name' - ) - - tools.disposables.push( - App.eventSystem.on('projectChanged', () => { - tools.shouldRender.value = !app.isNoProjectSelected - }) - ) - - tools.shouldRender.value = !app.isNoProjectSelected - - tools.addItem( - app.actionManager.create({ - icon: 'mdi-book-open-page-variant', - name: 'actions.docs.name', - description: 'actions.docs.description', - onTrigger: async () => { - const app = await App.getApp() - const tabSystem = app.tabSystem - - if (!tabSystem) return - - const tab = new IframeTab(tabSystem, { - icon: 'mdi-book-open-page-variant', - name: 'bedrock.dev', - url: 'https://bedrock.dev', - iconColor: 'primary', - }) - tabSystem.add(tab) - }, - }) - ) - tools.addItem( - app.actionManager.create({ - icon: 'mdi-minecraft', - name: 'actions.minecraftDocs.name', - description: 'actions.minecraftDocs.description', - onTrigger: () => { - App.openUrl( - 'https://docs.microsoft.com/en-us/minecraft/creator/' - ) - }, - }) - ) - tools.addItem( - app.actionManager.create({ - icon: '$blockbench', - name: '[Open Blockbench]', - onTrigger: async () => { - const app = await App.getApp() - const tabSystem = app.tabSystem - - if (!tabSystem) return - - const tab = new BlockbenchTab(tabSystem) - tabSystem.add(tab) - }, - }) - ) - tools.addItem( - app.actionManager.create({ - icon: 'mdi-snowflake', - name: '[Open Snowstorm]', - onTrigger: async () => { - const app = await App.getApp() - const tabSystem = app.tabSystem - - if (!tabSystem) return - - const tab = new IframeTab(tabSystem, { - icon: 'mdi-snowflake', - name: 'Snowstorm', - url: 'https://snowstorm.app/', - iconColor: 'primary', - }) - tabSystem.add(tab) - }, - }) - ) - - App.toolbar.addCategory(tools) -} diff --git a/src/components/Toolbar/Divider.ts b/src/components/Toolbar/Divider.ts deleted file mode 100644 index 4c49b8a85..000000000 --- a/src/components/Toolbar/Divider.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { v4 as uuid } from 'uuid' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' - -export class Divider extends EventDispatcher { - public readonly id = uuid() - public readonly type = 'divider' -} diff --git a/src/components/Toolbar/Main.vue b/src/components/Toolbar/Main.vue deleted file mode 100644 index 4f0bed482..000000000 --- a/src/components/Toolbar/Main.vue +++ /dev/null @@ -1,202 +0,0 @@ - - - - - diff --git a/src/components/Toolbar/Menu/Activator.vue b/src/components/Toolbar/Menu/Activator.vue deleted file mode 100644 index 3e1ded395..000000000 --- a/src/components/Toolbar/Menu/Activator.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/src/components/Toolbar/Menu/Button.vue b/src/components/Toolbar/Menu/Button.vue deleted file mode 100644 index 25b27ed79..000000000 --- a/src/components/Toolbar/Menu/Button.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/src/components/Toolbar/Menu/MenuList.vue b/src/components/Toolbar/Menu/MenuList.vue deleted file mode 100644 index b1465d548..000000000 --- a/src/components/Toolbar/Menu/MenuList.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - diff --git a/src/components/Toolbar/Toolbar.ts b/src/components/Toolbar/Toolbar.ts index 17426825e..d1d71ca52 100644 --- a/src/components/Toolbar/Toolbar.ts +++ b/src/components/Toolbar/Toolbar.ts @@ -1,30 +1,130 @@ -import { ToolbarCategory } from './ToolbarCategory' -import { del, reactive, set } from 'vue' -import { showContextMenu } from '../ContextMenu/showContextMenu' +import { Ref, ref } from 'vue' + +export type ToolbarItem = Button | Dropdown + +export interface Button { + type: 'button' + action: string +} + +export interface Dropdown { + type: 'dropdown' + id: string + name: string + items: DropdownItem[] +} + +export type DropdownItem = DropdownButton | Seperator + +interface DropdownButton { + type: 'button' + action: string +} + +interface Seperator { + type: 'seperator' +} export class Toolbar { - protected state: Record = reactive({}) + public static items: Ref = ref([]) + + public static setup() { + this.items.value = [] + + this.addDropdown('project', 'toolbar.project.name', [ + { type: 'button', action: 'editor.goHome' }, + { type: 'button', action: 'editor.launchMinecraft' }, + { type: 'seperator' }, + { + type: 'button', + action: 'editor.openSettings', + }, + { + type: 'button', + action: 'editor.openExtensions', + }, + { type: 'seperator' }, + { type: 'button', action: 'editor.importProject' }, + { type: 'seperator' }, + { type: 'button', action: 'editor.openFolder' }, + { type: 'button', action: 'editor.revealBridgeFolder' }, + { type: 'button', action: 'editor.revealOutputFolder' }, + { type: 'button', action: 'editor.revealExtensionsFolder' }, + ]) + + this.addButton('openSettings') + + this.addDropdown('file', 'toolbar.file.name', [ + { type: 'button', action: 'files.createFile' }, + { type: 'button', action: 'project.importFile' }, + { type: 'seperator' }, + { type: 'button', action: 'files.save' }, + { type: 'button', action: 'files.saveAs' }, + { type: 'button', action: 'files.saveAll' }, + ]) + + this.addDropdown('tools', 'toolbar.tools.name', [{ type: 'button', action: 'editor.clearNotifications' }]) + + this.addDropdown('tools', 'toolbar.help.name', [ + { type: 'button', action: 'help.gettingStarted' }, + { type: 'button', action: 'help.faq' }, + { type: 'button', action: 'help.extensions' }, + { type: 'button', action: 'help.feedback' }, + { type: 'seperator' }, + { type: 'button', action: 'help.scriptingDocs' }, + { type: 'button', action: 'help.bedrockDevDocs' }, + { type: 'button', action: 'help.creatorDocs' }, + { type: 'seperator' }, + { type: 'button', action: 'help.openChangelog' }, + { type: 'button', action: 'help.releases' }, + ]) - addCategory(category: ToolbarCategory) { - set(this.state, category.id, category) + this.addButton('help.openDownloadGuide') } - add(item: any) { - set(this.state, item.id, item) + + public static addButton(action: string): Button { + const item: Button = { type: 'button', action } + + this.items.value.push(item) + this.items.value = [...this.items.value] + + return item + } + + public static addDropdown(id: string, name: string, items: DropdownItem[]): Dropdown { + const item: Dropdown = { type: 'dropdown', id, name, items } + + this.items.value.push(item) + this.items.value = [...this.items.value] + + return item + } + + public static addDropdownItem(dropdownId: string, item: DropdownItem) { + const dropdown = this.items.value.find((item) => item.type === 'dropdown' && item.id === dropdownId) as Dropdown | undefined + + if (!dropdown) return + + dropdown.items.push(item) + this.items.value = [...this.items.value] + } + + public static removeButton(button: Button) { + this.items.value.splice(this.items.value.indexOf(button), 1) + this.items.value = [...this.items.value] } - disposeCategory(category: ToolbarCategory) { - del(this.state, category.id) + + public static removeDropdown(dropdown: Dropdown) { + this.items.value.splice(this.items.value.indexOf(dropdown), 1) + this.items.value = [...this.items.value] } - showMobileMenu(event: MouseEvent) { - showContextMenu( - event, - Object.values(this.state) - .map((category) => [ - { type: 'divider' }, - category.toNestedMenu(), - ]) - .flat(1) - .slice(1) - ) + public static removeDropdownItem(dropdownId: string, item: DropdownItem) { + const dropdown = this.items.value.find((item) => item.type === 'dropdown' && item.id === dropdownId) as Dropdown | undefined + + if (!dropdown) return + + dropdown.items.splice(dropdown.items.indexOf(item), 1) + this.items.value = [...this.items.value] } } diff --git a/src/components/Toolbar/Toolbar.vue b/src/components/Toolbar/Toolbar.vue new file mode 100644 index 000000000..999089bf7 --- /dev/null +++ b/src/components/Toolbar/Toolbar.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/components/Toolbar/ToolbarButton.ts b/src/components/Toolbar/ToolbarButton.ts deleted file mode 100644 index eecee6684..000000000 --- a/src/components/Toolbar/ToolbarButton.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { v4 as uuid } from 'uuid' -import { EventDispatcher } from '../Common/Event/EventDispatcher' -import { UnwrapNestedRefs } from 'vue' - -export class ToolbarButton extends EventDispatcher { - public readonly id = uuid() - protected type = 'button' - constructor( - protected icon: string, - protected name: string, - protected callback: () => void, - protected shouldRender: UnwrapNestedRefs<{ value: boolean }> - ) { - super() - } - - toNestedMenu() { - return { - type: 'button', - icon: this.icon, - name: this.name, - onTrigger: () => this.trigger(), - } - } - - trigger() { - this.callback() - this.dispatch() - } - - dispose() {} -} diff --git a/src/components/Toolbar/ToolbarCategory.ts b/src/components/Toolbar/ToolbarCategory.ts deleted file mode 100644 index 54f1ec5ff..000000000 --- a/src/components/Toolbar/ToolbarCategory.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { App } from '/@/App' -import { Action } from '/@/components/Actions/Action' -import { IDisposable } from '/@/types/disposable' -import { v4 as uuid } from 'uuid' -import { EventDispatcher } from '../Common/Event/EventDispatcher' -import { del, reactive, set } from 'vue' -import { Divider } from './Divider' - -export class ToolbarCategory extends EventDispatcher { - public readonly id = uuid() - protected type = 'category' - public shouldRender = reactive({ value: true }) - - protected state: Record = - reactive({}) - protected disposableItems: Record = {} - public disposables: IDisposable[] = [] - - constructor(protected icon: string, protected name: string) { - super() - } - - addItem(item: Action | ToolbarCategory | Divider) { - set(this.state, item.id, item) - this.disposableItems[item.id] = item.on(() => this.trigger()) - return this - } - disposeItem(item: Action | ToolbarCategory | Divider) { - del(this.state, item.id) - this.disposableItems[item.id]?.dispose() - - this.disposableItems[item.id] = undefined - } - - toNestedMenu() { - return { - type: 'submenu', - icon: this.icon, - name: this.name, - actions: Object.values(this.state).map((item) => { - if (item instanceof Action) { - return { - ...item.getConfig(), - description: undefined, - keyBinding: undefined, - } - } else if (item instanceof ToolbarCategory) { - return null - } else if (item instanceof Divider) { - return { type: 'divider' } - } - - throw new Error(`Unknown toolbar item type: ${item}`) - }), - } - } - - trigger() { - this.dispatch() - } - - dispose() { - for (const disposable of this.disposables) { - disposable.dispose() - } - - App.toolbar.disposeCategory(this) - } -} diff --git a/src/components/Toolbar/WindowAction.vue b/src/components/Toolbar/WindowAction.vue deleted file mode 100644 index 9f7a4a552..000000000 --- a/src/components/Toolbar/WindowAction.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - - - diff --git a/src/components/Toolbar/WindowControls.vue b/src/components/Toolbar/WindowControls.vue deleted file mode 100644 index 5f710f994..000000000 --- a/src/components/Toolbar/WindowControls.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/src/components/Toolbar/setupDefaults.ts b/src/components/Toolbar/setupDefaults.ts deleted file mode 100644 index d43306b2c..000000000 --- a/src/components/Toolbar/setupDefaults.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { App } from '/@/App' -import { setupFileCategory } from './Category/file' -import { setupHelpCategory } from './Category/help' -import { setupToolsCategory } from './Category/tools' -import { setupProjectCategory } from './Category/project' -import { setupSettingsButton } from './Category/settings' -import { setupDownloadButton } from './Category/download' - -export async function setupDefaultMenus(app: App) { - setupProjectCategory(app) - setupSettingsButton(app) - setupFileCategory(app) - setupToolsCategory(app) - setupHelpCategory(app) - setupDownloadButton(app) -} diff --git a/src/components/UIElements/DirectoryViewer/Common/BaseWrapper.ts b/src/components/UIElements/DirectoryViewer/Common/BaseWrapper.ts deleted file mode 100644 index 38430a4a8..000000000 --- a/src/components/UIElements/DirectoryViewer/Common/BaseWrapper.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { DirectoryWrapper } from '../DirectoryView/DirectoryWrapper' -import { VirtualHandle } from '/@/components/FileSystem/Virtual/Handle' -import { App } from '/@/App' -import type { IDirectoryViewerOptions } from '../DirectoryStore' -import { moveHandle } from '/@/utils/file/moveHandle' -import { ref } from 'vue' -import type { FileWrapper } from '../FileView/FileWrapper' -import { platform } from '/@/utils/os' -import { renameHandle } from '/@/utils/file/renameHandle' -import { isSameEntry } from '/@/utils/file/isSameEntry' - -export abstract class BaseWrapper { - public abstract readonly kind: 'file' | 'directory' - public readonly isSelected = ref(false) - public readonly isEditingName = ref(false) - - constructor( - protected parent: DirectoryWrapper | null, - public readonly handle: T, - public readonly options: IDirectoryViewerOptions - ) {} - - get name() { - return this.handle.name === '' - ? this.options.startPath ?? '' - : this.handle.name - } - get path(): string | null { - if (!this.parent) return this.options.startPath ?? null - - const parentPath = this.parent.path - - if (!parentPath) return this.name - else return `${parentPath}/${this.name}` - } - get color() { - const path = this.path - if (!path) return this.options.defaultIconColor ?? 'accent' - - return ( - App.packType.get(path)?.color ?? - this.options.defaultIconColor ?? - 'accent' - ) - } - getParent() { - return this.parent - } - - async isSame(child: BaseWrapper) { - return await isSameEntry(child.handle, this.handle) - } - - abstract readonly icon: string - // abstract useIcon(): string - abstract unselectAll(): void - abstract _onRightClick(event: MouseEvent): void - abstract _onClick(event: MouseEvent, forceClick: boolean): void - - onRightClick(event: MouseEvent) { - this._onRightClick(event) - - this.unselectAll() - this.isSelected.value = true - } - onClick(event: MouseEvent, forceClick: boolean = false) { - event.preventDefault() - this._onClick(event, forceClick) - - // Unselect other wrappers if multiselect key is not pressed - if ( - (platform() === 'darwin' && !event.metaKey) || - (platform() !== 'darwin' && !event.ctrlKey) - ) - this.unselectAll() - - this.isSelected.value = true - - // Find first wrapper name component and focus it - const nameElement = ( - event - .composedPath() - .find((el) => - (el).classList.contains( - 'directory-viewer-name' - ) - ) - ) - if (nameElement) nameElement.focus() - } - - async move(directoryWrapper: DirectoryWrapper) { - // No move actually happened - if (this.parent === directoryWrapper) return - - // Store old path and old parent handle - const fromPath = this.path! - const fromParent = this.parent! - - // Set the new parent - this.parent = directoryWrapper - - // Move the file handle - const { type, handle: newHandle } = await moveHandle({ - fromHandle: fromParent.handle, - toHandle: this.parent.handle, - moveHandle: this.handle, - }) - - if (newHandle) { - // If necessary, update the handle reference - // @ts-ignore This works because newHandle always has the same type as this.handle - this.handle = newHandle - } - - if (type === 'cancel') { - // Move was cancelled - this.parent = fromParent - directoryWrapper.children.value = - directoryWrapper.children.value!.filter( - (child) => - child !== - (this) - ) - fromParent.children.value!.push( - (this) - ) - fromParent.sort() - } else if (type === 'overwrite') { - // We need to remove the duplicate FileWrapper - directoryWrapper.children.value = - directoryWrapper.children.value!.filter( - (child) => - child === - (this) || - !child.isSame( - (this) - ) - ) - } - - // Call onHandleMoved - this.options.onHandleMoved?.({ - movedHandle: this.handle, - fromHandle: this.parent.handle, - fromPath, - toPath: this.path!, - toHandle: this.parent.handle, - }) - - // Sort parent's children - directoryWrapper.sort() - } - async rename(newName: string) { - // No rename actually happened - if (this.name === newName) return - - // Store old path and old parent handle - const fromPath = this.path! - const fromParent = this.parent! - - // Rename the file handle - const { type, handle: newHandle } = await renameHandle({ - parentHandle: fromParent.handle, - renameHandle: this.handle, - newName, - }) - - if (newHandle) { - // If necessary, update the handle reference - // @ts-ignore This works because newHandle always has the same type as this.handle - this.handle = newHandle - } - - if (type === 'cancel') return - - if (type === 'overwrite') { - // We need to remove the duplicate FileWrapper - this.getParent()!.children.value = - this.getParent()!.children.value!.filter( - (child) => - child === - (this) || - !child.isSame( - (this) - ) - ) - } - - // Call onHandleMoved - this.options.onHandleMoved?.({ - movedHandle: this.handle, - fromHandle: fromParent.handle, - fromPath, - toPath: this.path!, - toHandle: this.parent!.handle, - }) - - await this.parent!.refresh() - } - - startRename() { - if (this.options.isReadOnly || this.parent === null) return - - this.isEditingName.value = true - } - async confirmRename(newName: string) { - if (!newName) return - - this.isEditingName.value = false - - await this.rename(newName) - } - - async onFilesAdded(filePaths: string[]) { - await this.options.onFilesAdded?.(filePaths) - } -} diff --git a/src/components/UIElements/DirectoryViewer/Common/BasicIconName.vue b/src/components/UIElements/DirectoryViewer/Common/BasicIconName.vue deleted file mode 100644 index c6a689180..000000000 --- a/src/components/UIElements/DirectoryViewer/Common/BasicIconName.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/src/components/UIElements/DirectoryViewer/Common/DraggingWrapper.ts b/src/components/UIElements/DirectoryViewer/Common/DraggingWrapper.ts deleted file mode 100644 index 5a6c5406f..000000000 --- a/src/components/UIElements/DirectoryViewer/Common/DraggingWrapper.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ref } from 'vue' - -export const isDraggingWrapper = ref(false) diff --git a/src/components/UIElements/DirectoryViewer/Common/Name.vue b/src/components/UIElements/DirectoryViewer/Common/Name.vue deleted file mode 100644 index 25ccd3134..000000000 --- a/src/components/UIElements/DirectoryViewer/Common/Name.vue +++ /dev/null @@ -1,274 +0,0 @@ - - - - - diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/ConnectedFiles.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/ConnectedFiles.ts deleted file mode 100644 index ee39b4b76..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/ConnectedFiles.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { FileWrapper } from '../../FileView/FileWrapper' -import { ViewCompilerOutput } from './ViewCompilerOutput' -import { App } from '/@/App' -import { ISubmenuConfig } from '/@/components/ContextMenu/showContextMenu' -import { basename } from '/@/utils/path' - -export const ViewConnectedFiles = async (fileWrapper: FileWrapper) => { - if (!fileWrapper.path) return null - const app = await App.getApp() - const packIndexer = app.project.packIndexer - - const connectedFiles = createSimpleActions( - packIndexer.hasFired - ? await packIndexer.service.getConnectedFiles(fileWrapper.path) - : [] - ) - - const compilerFiles = app.project.compilerReady.hasFired - ? await app.project.compilerService.getFileDependencies( - fileWrapper.path - ) - : [] - const compilerFileActions = [ - ViewCompilerOutput(fileWrapper.path, false, false), - ].concat(createSimpleActions(compilerFiles)) - - return connectedFiles.length === 0 && compilerFileActions.length === 1 - ? ViewCompilerOutput(fileWrapper.path) - : { - type: 'submenu', - icon: 'mdi-spider-web', - name: 'actions.viewConnectedFiles.name', - description: 'actions.viewConnectedFiles.description', - - actions: [ - ...compilerFileActions, - connectedFiles.length > 0 ? { type: 'divider' } : null, - ...connectedFiles, - ], - } -} - -function createSimpleActions(filePaths: string[]) { - return filePaths.map((filePath) => { - const fileType = App.fileType.get(filePath) - const packType = App.packType.get(filePath) - - return { - icon: fileType?.icon ?? 'mdi-file-outline', - color: packType?.color, - name: `[${basename(filePath)}]`, - description: fileType ? `fileType.${fileType.id}` : undefined, - onTrigger: async () => { - const app = await App.getApp() - - await app.project.openFile( - await app.fileSystem.getFileHandle(filePath) - ) - }, - } - }) -} diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Download.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Download.ts deleted file mode 100644 index 5a1448342..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Download.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BaseWrapper } from '../../Common/BaseWrapper' -import { download } from '/@/components/FileSystem/saveOrDownload' -import { ZipDirectory } from '/@/components/FileSystem/Zip/ZipDirectory' - -export const DownloadAction = (baseWrapper: BaseWrapper) => ({ - icon: - baseWrapper.kind === 'directory' - ? 'mdi-folder-download-outline' - : 'mdi-file-download-outline', - name: 'actions.download.name', - onTrigger: async () => { - if (baseWrapper.kind === 'file') { - const file: File = await baseWrapper.handle.getFile() - - download(baseWrapper.name, new Uint8Array(await file.arrayBuffer())) - } else { - const zip = new ZipDirectory(baseWrapper.handle) - download(`${baseWrapper.name}.zip`, await zip.package()) - } - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit.ts deleted file mode 100644 index 3c9815924..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ISubmenuConfig } from '/@/components/ContextMenu/showContextMenu' -import { BaseWrapper } from '../../Common/BaseWrapper' -import { CopyAction } from './Edit/Copy' -import { PasteAction } from './Edit/Paste' -import { DuplicateAction } from './Edit/Duplicate' -import { RenameAction } from './Edit/Rename' -import { DeleteAction } from './Edit/Delete' -import { DirectoryWrapper } from '../../DirectoryView/DirectoryWrapper' - -interface IEditOptions { - hideRename?: boolean - hideDelete?: boolean - hideDuplicate?: boolean -} - -export const EditAction = async ( - baseWrapper: BaseWrapper, - options: IEditOptions = {} -) => { - return [ - options.hideRename ? null : RenameAction(baseWrapper), - options.hideDelete ? null : DeleteAction(baseWrapper), - options.hideDuplicate ? null : DuplicateAction(baseWrapper), - // options.hideRename && options.hideDelete && options.hideDuplicate - // ? null - // : { type: 'divider' }, - - CopyAction(baseWrapper), - PasteAction( - baseWrapper instanceof DirectoryWrapper - ? baseWrapper - : baseWrapper.getParent()! - ), - ] -} diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Copy.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Copy.ts deleted file mode 100644 index 7816b64ae..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Copy.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { App } from '/@/App' -import { AnyHandle } from '/@/components/FileSystem/Types' -import { BaseWrapper } from '/@/components/UIElements/DirectoryViewer/Common/BaseWrapper' - -interface IClipboard { - item: AnyHandle | null -} -export const clipboard: IClipboard = { - item: null, -} - -export const CopyAction = (baseWrapper: BaseWrapper) => ({ - icon: 'mdi-content-copy', - name: 'actions.copy.name', - - onTrigger: async () => { - clipboard.item = baseWrapper.handle - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Delete.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Delete.ts deleted file mode 100644 index 7bc654b0f..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Delete.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { App } from '/@/App' -import { BaseWrapper } from '/@/components/UIElements/DirectoryViewer/Common/BaseWrapper' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { translate } from '/@/components/Locales/Manager' - -export const DeleteAction = (baseWrapper: BaseWrapper) => ({ - icon: 'mdi-delete-outline', - name: 'actions.delete.name', - - onTrigger: async () => { - const parent = baseWrapper.getParent() - if (baseWrapper.options.isReadOnly || parent === null) return - - const app = await App.getApp() - const t = (str: string) => translate(str) - - const confirmWindow = new ConfirmationWindow({ - description: `[${t('actions.delete.confirmText')} "${ - baseWrapper.path ?? baseWrapper.name - }"? ${t('actions.delete.noRestoring')}]`, - }) - - if (!(await confirmWindow.fired)) return - - const success = await app.project.unlinkHandle(baseWrapper.handle) - - // File is not part of the bridge. folder, we need to unlink it manually - if (!success) { - await parent.handle.removeEntry(baseWrapper.name, { - recursive: true, - }) - } - await baseWrapper.getParent()?.refresh() - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Duplicate.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Duplicate.ts deleted file mode 100644 index e2416d043..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Duplicate.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { clipboard } from './Copy' -import { PasteAction } from './Paste' -import { BaseWrapper } from '/@/components/UIElements/DirectoryViewer/Common/BaseWrapper' - -export const DuplicateAction = (baseWrapper: BaseWrapper) => ({ - icon: 'mdi-content-duplicate', - name: 'actions.duplicate.name', - - onTrigger: async () => { - const parent = baseWrapper.getParent() - if (!parent) return - - clipboard.item = baseWrapper.handle - - const newHandle = await PasteAction(parent).onTrigger() - if (!newHandle) return - - const newWrapper = parent.getChild(newHandle.name) - if (!newWrapper) return - - newWrapper.isEditingName.value = true - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Paste.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Paste.ts deleted file mode 100644 index 669760225..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Paste.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { DirectoryWrapper } from '../../../DirectoryView/DirectoryWrapper' -import { clipboard } from './Copy' -import { - findSuitableFileName, - findSuitableFolderName, -} from '/@/utils/directory/findSuitableName' -import { App } from '/@/App' -import { AnyHandle } from '/@/components/FileSystem/Types' - -export const PasteAction = (directoryWrapper: DirectoryWrapper) => ({ - icon: 'mdi-content-paste', - name: 'actions.paste.name', - - onTrigger: async () => { - if (directoryWrapper.options.isReadOnly) return - - if (!clipboard.item) return - const handleToPaste = clipboard.item - - const app = await App.getApp() - const project = app.project - - if (!directoryWrapper.isOpen.value) await directoryWrapper.open() - - App.eventSystem.dispatch('beforeModifiedProject', null) - - let newHandle: AnyHandle - if (handleToPaste.kind === 'file') { - const newName = await findSuitableFileName( - handleToPaste.name, - directoryWrapper.handle - ) - - newHandle = await directoryWrapper.handle.getFileHandle(newName, { - create: true, - }) - await app.fileSystem.copyFileHandle(handleToPaste, newHandle) - } else if (handleToPaste.kind === 'directory') { - app.windows.loadingWindow.open() - - const newName = await findSuitableFolderName( - handleToPaste.name, - directoryWrapper.handle - ) - - newHandle = await directoryWrapper.handle.getDirectoryHandle( - newName, - { create: true } - ) - await app.fileSystem.copyFolderByHandle(handleToPaste, newHandle) - - app.windows.loadingWindow.close() - } else { - // @ts-ignore - throw new Error('Invalid handle kind: ' + handleToPaste.kind) - } - - await directoryWrapper.refresh() - if (newHandle) await project.updateHandle(newHandle) - - App.eventSystem.dispatch('modifiedProject', null) - - return newHandle - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Rename.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Rename.ts deleted file mode 100644 index 90aadb5fb..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Edit/Rename.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BaseWrapper } from '/@/components/UIElements/DirectoryViewer/Common/BaseWrapper' - -export const RenameAction = (baseWrapper: BaseWrapper) => ({ - icon: 'mdi-pencil-outline', - name: 'actions.rename.name', - - onTrigger: () => { - baseWrapper.isEditingName.value = true - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/FindInFolder.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/FindInFolder.ts deleted file mode 100644 index 43a81db27..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/FindInFolder.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { DirectoryWrapper } from '../../DirectoryView/DirectoryWrapper' -import { App } from '/@/App' -import { searchType } from '/@/components/FindAndReplace/Controls/searchType' -import { FindAndReplaceTab } from '/@/components/FindAndReplace/Tab' - -export const FindInFolderAction = (directoryWrapper: DirectoryWrapper) => ({ - icon: 'mdi-file-search-outline', - name: 'actions.findInFolder.name', - - onTrigger: async () => { - const app = await App.getApp() - const project = app.project - - project.tabSystem?.add( - new FindAndReplaceTab( - project.tabSystem!, - [ - { - directory: directoryWrapper.handle, - path: directoryWrapper.path ?? '', - }, - ], - { - searchType: searchType.matchCase, - isReadOnly: directoryWrapper.options.isReadOnly, - } - ) - ) - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/ImportFile.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/ImportFile.ts deleted file mode 100644 index e7f147534..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/ImportFile.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { DirectoryWrapper } from '../../DirectoryView/DirectoryWrapper' -import { moveHandle } from '/@/utils/file/moveHandle' - -export const ImportFileAction = (baseWrapper: DirectoryWrapper) => ({ - icon: 'mdi-import', - name: 'actions.importFile.name', - onTrigger: async () => { - const fileHandles = await window.showOpenFilePicker({ - multiple: true, - }) - - for (const fileHandle of fileHandles) { - await moveHandle({ - moveHandle: fileHandle, - toHandle: baseWrapper.handle, - }) - } - - // Refresh pack explorer UI - await baseWrapper.refresh() - - // No path information -> no way to update files - if (baseWrapper.path === null) return - - // Update compiler output & lightning cache - const newFilePaths = fileHandles.map( - (fileHandle) => `${baseWrapper.path}/${fileHandle.name}` - ) - baseWrapper.onFilesAdded(newFilePaths) - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Open.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Open.ts deleted file mode 100644 index 6962e045e..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Open.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { FileWrapper } from '/@/components/UIElements/DirectoryViewer/FileView/FileWrapper' -import { App } from '/@/App' - -export const OpenAction = (fileWrapper: FileWrapper) => ({ - icon: 'mdi-plus', - name: 'actions.open.name', - - onTrigger: async () => { - const app = await App.getApp() - app.project.openFile(fileWrapper.handle, { - readOnlyMode: fileWrapper.options.isReadOnly ? 'forced' : 'off', - }) - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenInSplitScreen.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenInSplitScreen.ts deleted file mode 100644 index 71c156481..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenInSplitScreen.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FileWrapper } from '/@/components/UIElements/DirectoryViewer/FileView/FileWrapper' -import { App } from '/@/App' - -export const OpenInSplitScreenAction = (fileWrapper: FileWrapper) => ({ - icon: 'mdi-arrow-split-vertical', - name: 'actions.openInSplitScreen.name', - onTrigger: async () => { - const app = await App.getApp() - - app.project.openFile(fileWrapper.handle, { - openInSplitScreen: true, - readOnlyMode: fileWrapper.options.isReadOnly ? 'forced' : 'off', - }) - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith.ts deleted file mode 100644 index bc1936688..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { FileWrapper } from '/@/components/UIElements/DirectoryViewer/FileView/FileWrapper' -import { ISubmenuConfig } from '/@/components/ContextMenu/showContextMenu' -import { HTMLPreviewerAction } from './OpenWith/HTMLPreviewer' -import { TreeEditorAction } from './OpenWith/TreeEditor' -import { TextEditorAction } from './OpenWith/TextEditor' -import { SnowstormAction } from './OpenWith/Snowstorm' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { BlockbenchAction } from './OpenWith/Blockbench' - -export const pluginActionStore = new Set() - -export interface IPluginOpenWithAction { - icon: string - name: string - isAvailable?: (details: IOpenWithDetails) => Promise | boolean - onOpen: (details: IOpenWithDetails) => Promise | void -} -interface IOpenWithDetails { - isReadOnly?: boolean - fileHandle: AnyFileHandle - filePath: string | null -} - -export const OpenWithAction = async (fileWrapper: FileWrapper) => { - // Default bridge. actions - const defaultActions = [ - TextEditorAction(fileWrapper), - TreeEditorAction(fileWrapper), - HTMLPreviewerAction(fileWrapper.handle, fileWrapper.path ?? undefined), - ].filter((action) => action !== null) - - // Actions which open an external tool - const externalActions = [ - SnowstormAction(fileWrapper), - BlockbenchAction(fileWrapper), - ].filter((action) => action !== null) - - // Load actions provided by plugins - for (const action of pluginActionStore) { - const details = { - isReadOnly: fileWrapper.options.isReadOnly, - fileHandle: fileWrapper.handle, - filePath: fileWrapper.path, - } - if (!((await action.isAvailable?.(details)) ?? true)) continue - - externalActions.push({ - icon: action.icon, - name: action.name, - onTrigger: async () => { - await action.onOpen(details) - }, - }) - } - - const actions = [ - ...defaultActions, - defaultActions.length > 0 && externalActions.length > 0 - ? { type: 'divider' } - : null, - ...externalActions, - ].filter((action) => action !== null) - - if (actions.length <= 1) return null - - // Construct and return submenu - return { - type: 'submenu', - icon: 'mdi-open-in-app', - name: 'actions.openWith.name', - - actions, - } -} diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/Blockbench.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/Blockbench.ts deleted file mode 100644 index 962cce3f2..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/Blockbench.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { FileWrapper } from '../../../FileView/FileWrapper' -import { App } from '/@/App' -import { BlockbenchTab } from '/@/components/Editors/Blockbench/BlockbenchTab' -import { IframeTab } from '/@/components/Editors/IframeTab/IframeTab' - -export const BlockbenchAction = (fileWrapper: FileWrapper) => { - return fileWrapper.path?.includes('/models/') - ? { - icon: '$blockbench', - name: 'openWith.blockbench', - onTrigger: async () => { - const app = await App.getApp() - const tabSystem = app.tabSystem - - if (!tabSystem) return - - const tab = new BlockbenchTab(tabSystem, { - openWithPayload: { - fileHandle: fileWrapper.handle, - filePath: fileWrapper.path ?? undefined, - }, - }) - tabSystem.add(tab) - }, - } - : null -} diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/HTMLPreviewer.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/HTMLPreviewer.ts deleted file mode 100644 index 35fdeb9cc..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/HTMLPreviewer.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { FileWrapper } from '/@/components/UIElements/DirectoryViewer/FileView/FileWrapper' -import { App } from '/@/App' -import { HTMLPreviewTab } from '/@/components/Editors/HTMLPreview/HTMLPreview' -import { AnyFileHandle } from '/@/components/FileSystem/Types' - -export const HTMLPreviewerAction = ( - fileHandle: AnyFileHandle, - filePath?: string -) => - fileHandle.name.endsWith('.html') - ? { - icon: 'mdi-language-html5', - name: 'openWith.htmlPreviewer', - onTrigger: async () => { - const app = await App.getApp() - const tabSystem = app.tabSystem - if (!tabSystem) return - - tabSystem.add( - new HTMLPreviewTab(tabSystem, { - fileHandle: fileHandle, - filePath: filePath ?? undefined, - }) - ) - }, - } - : null diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/Snowstorm.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/Snowstorm.ts deleted file mode 100644 index 2ea5a86d1..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/Snowstorm.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { FileWrapper } from '/@/components/UIElements/DirectoryViewer/FileView/FileWrapper' -import { App } from '/@/App' -import json5 from 'json5' -import { strFromU8, zlibSync } from 'fflate' -import { IframeTab } from '/@/components/Editors/IframeTab/IframeTab' -import { findFileExtension } from '/@/components/FileSystem/FindFile' -import { FileSystem } from '/@/components/FileSystem/FileSystem' - -export const SnowstormAction = (fileWrapper: FileWrapper) => { - if (!fileWrapper.name.endsWith('.json')) return null - const path = fileWrapper.path - if (!path) return null - - const fileType = App.fileType.getId(path) - - if ( - fileType === 'particle' || - (fileType === 'unknown' && path.includes('particles/')) - ) - return { - icon: 'mdi-snowflake', - name: 'openWith.snowstorm', - onTrigger: async () => { - const app = await App.getApp() - const tabSystem = app.tabSystem - if (!tabSystem) return - - const file = await fileWrapper.handle.getFile() - const particleJson = json5.parse(await file.text()) - const rawParticle = new Uint8Array(await file.arrayBuffer()) - - // Get texture path from particle json - const texturePath: string | undefined = - particleJson?.particle_effect?.description - ?.basic_render_parameters?.texture - const fullTexturePath = texturePath - ? await findFileExtension( - app.fileSystem, - app.projectConfig.resolvePackPath( - 'resourcePack', - texturePath - ), - ['.tga', '.png', '.jpg', '.jpeg'] - ) - : undefined - let rawTextureData: Uint8Array | null = null - if (fullTexturePath) { - rawTextureData = await app.fileSystem - .getFileHandle(fullTexturePath) - .then((fileHandle) => fileHandle.getFile()) - .then((file) => file.arrayBuffer()) - .then((buffer) => new Uint8Array(buffer)) - } - - const base64 = btoa( - strFromU8(zlibSync(rawParticle, { level: 9 }), true) - ) - const url = new URL('https://snowstorm.app/') - url.searchParams.set('loadParticle', base64) - if (rawTextureData) { - const base64Texture = btoa( - strFromU8(zlibSync(rawTextureData, { level: 9 }), true) - ) - url.searchParams.set('loadParticleTexture', base64Texture) - } - - tabSystem.add( - new IframeTab(tabSystem, { - icon: 'mdi-snowflake', - name: `Snowstorm: ${fileWrapper.name}`, - url: url.href, - iconColor: 'primary', - }) - ) - }, - } - - return null -} diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/TextEditor.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/TextEditor.ts deleted file mode 100644 index 33f895434..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/TextEditor.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { FileWrapper } from '/@/components/UIElements/DirectoryViewer/FileView/FileWrapper' -import { App } from '/@/App' -import { extname } from '/@/utils/path' -import { TextTab } from '/@/components/Editors/Text/TextTab' - -const textEditorNotAllowed = [ - '.png', - '.jpg', - '.jpeg', - '.tga', - '.fsb', - '.wav', - '.ogg', -] - -export const TextEditorAction = (fileWrapper: FileWrapper) => { - const ext = extname(fileWrapper.name) - if (textEditorNotAllowed.includes(ext)) return null - - return { - icon: 'mdi-pencil-outline', - name: 'openWith.textEditor', - onTrigger: async () => { - const app = await App.getApp() - const tabSystem = app.tabSystem - if (!tabSystem) return - - tabSystem.add( - new TextTab( - tabSystem, - fileWrapper.handle, - fileWrapper.options.isReadOnly ? 'forced' : 'off' - ) - ) - }, - } -} diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/TreeEditor.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/TreeEditor.ts deleted file mode 100644 index 961c949d6..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/OpenWith/TreeEditor.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { FileWrapper } from '/@/components/UIElements/DirectoryViewer/FileView/FileWrapper' -import { App } from '/@/App' -import { TreeTab } from '/@/components/Editors/TreeEditor/Tab' - -export const TreeEditorAction = (fileWrapper: FileWrapper) => - fileWrapper.name.endsWith('.json') - ? { - icon: 'mdi-file-tree-outline', - name: 'openWith.treeEditor', - onTrigger: async () => { - const app = await App.getApp() - const tabSystem = app.tabSystem - if (!tabSystem) return - - tabSystem.add( - new TreeTab( - tabSystem, - fileWrapper.handle, - fileWrapper.options.isReadOnly ? 'forced' : 'off' - ) - ) - }, - } - : null diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Refresh.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Refresh.ts deleted file mode 100644 index 1cd7b0526..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/Refresh.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DirectoryWrapper } from '/@/components/UIElements/DirectoryViewer/DirectoryView/DirectoryWrapper' - -export const RefreshAction = (directoryWrapper: DirectoryWrapper) => ({ - icon: 'mdi-refresh', - name: 'general.refresh', - - onTrigger: () => { - directoryWrapper.refresh() - }, -}) diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/RevealInFileExplorer.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/RevealInFileExplorer.ts deleted file mode 100644 index 58f8411bd..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/RevealInFileExplorer.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DirectoryWrapper } from '../../DirectoryView/DirectoryWrapper' -import { FileWrapper } from '../../FileView/FileWrapper' -import { BaseVirtualHandle } from '/@/components/FileSystem/Virtual/Handle' -import { TauriFsStore } from '/@/components/FileSystem/Virtual/Stores/TauriFs' -import { revealInFileExplorer } from '/@/utils/revealInFileExplorer' - -export const RevealInFileExplorer = ( - baseWrapper: FileWrapper | DirectoryWrapper -) => { - if (!import.meta.env.VITE_IS_TAURI_APP) return null - - return { - icon: - baseWrapper.kind === 'directory' - ? 'mdi-folder-marker-outline' - : 'mdi-file-marker-outline', - name: 'actions.revealInFileExplorer.name', - onTrigger: async () => { - const handle = baseWrapper.handle - if (!(handle instanceof BaseVirtualHandle)) return - - let path = baseWrapper.path - const baseStore = handle.getBaseStore() - if (!(baseStore instanceof TauriFsStore) || !path) return - - revealInFileExplorer(await baseStore.resolvePath(path)) - }, - } -} diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/RevealPath.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/RevealPath.ts deleted file mode 100644 index daa0e21c9..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/RevealPath.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isUsingFileSystemPolyfill } from '/@/components/FileSystem/Polyfill' -import { BaseWrapper } from '/@/components/UIElements/DirectoryViewer/Common/BaseWrapper' -import { InformationWindow } from '/@/components/Windows/Common/Information/InformationWindow' - -export const RevealFilePathAction = (baseWrapper: BaseWrapper) => - isUsingFileSystemPolyfill.value || import.meta.env.VITE_IS_TAURI_APP - ? null - : { - icon: - baseWrapper.kind === 'directory' - ? 'mdi-folder-marker-outline' - : 'mdi-file-marker-outline', - name: 'actions.revealPath.name', - onTrigger: async () => { - new InformationWindow({ - name: 'actions.revealPath.name', - description: `[${baseWrapper.path}]`, - isPersistent: false, - }).open() - }, - } diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/ViewCompilerOutput.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/ViewCompilerOutput.ts deleted file mode 100644 index 946e8dd4c..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Actions/ViewCompilerOutput.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { App } from '/@/App' -import { IActionConfig } from '/@/components/Actions/SimpleAction' -import { FileTab } from '/@/components/TabSystem/FileTab' -import { InformationWindow } from '/@/components/Windows/Common/Information/InformationWindow' - -export const ViewCompilerOutput = ( - filePath?: string | null, - addKeyBinding = false, - addViewBeforeName = true -) => - { - icon: 'mdi-file-cog-outline', - name: `actions.viewCompilerOutput.${ - addViewBeforeName ? 'view' : 'name' - }`, - keyBinding: addKeyBinding ? 'Ctrl + Shift + D' : undefined, - - onTrigger: async () => { - const app = await App.getApp() - const project = app.project - - let fileToView = filePath - if (fileToView === undefined) { - const currentTab = app.project.tabSystem?.selectedTab - if (!(currentTab instanceof FileTab)) return - - fileToView = currentTab.getPath() - } - if (!fileToView) return - - const transformedPath = await project.compilerService.getCompilerOutputPath( - fileToView - ) - const fileSystem = app.comMojang.hasComMojang - ? app.comMojang.fileSystem - : app.fileSystem - - // Information when file does not exist - if ( - !transformedPath || - !(await fileSystem.fileExists(transformedPath)) - ) { - new InformationWindow({ - description: 'actions.viewCompilerOutput.fileMissing', - }) - return - } - - const fileHandle = await fileSystem.getFileHandle(transformedPath) - await project?.openFile(fileHandle, { - selectTab: true, - readOnlyMode: 'forced', - }) - }, - } diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/File.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/File.ts deleted file mode 100644 index d81d8ea8f..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/File.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { FileWrapper } from '../FileView/FileWrapper' -import { CopyAction } from './Actions/Edit/Copy' -import { OpenInSplitScreenAction } from './Actions/OpenInSplitScreen' -import { OpenWithAction } from './Actions/OpenWith' -import { RevealFilePathAction } from './Actions/RevealPath' -import { - showContextMenu, - TActionConfig, -} from '/@/components/ContextMenu/showContextMenu' -import { shareFile } from '/@/components/StartParams/Action/openRawFile' -import { EditAction } from './Actions/Edit' -import { DownloadAction } from './Actions/Download' - -export async function showFileContextMenu( - event: MouseEvent, - fileWrapper: FileWrapper -) { - const additionalActions = - await fileWrapper.options.provideFileContextMenu?.(fileWrapper) - - let revealAction: TActionConfig | null = null - if (import.meta.env.VITE_IS_TAURI_APP) { - const { RevealInFileExplorer } = await import( - '/@/components/UIElements/DirectoryViewer/ContextMenu/Actions/RevealInFileExplorer' - ) - - revealAction = RevealInFileExplorer(fileWrapper) - } else { - revealAction = RevealFilePathAction(fileWrapper) - } - - showContextMenu(event, [ - ...(fileWrapper.options.isReadOnly - ? [CopyAction(fileWrapper)] - : await EditAction(fileWrapper)), - { type: 'divider' }, - - OpenInSplitScreenAction(fileWrapper), - await OpenWithAction(fileWrapper), - - { type: 'divider' }, - - { - type: 'submenu', - icon: 'mdi-dots-horizontal', - name: 'actions.more.name', - - actions: [ - { - icon: 'mdi-share', - name: 'general.shareFile', - onTrigger: async () => { - await shareFile(fileWrapper.handle) - }, - }, - DownloadAction(fileWrapper), - revealAction, - ], - }, - - ...(additionalActions ?? []), - ]) -} diff --git a/src/components/UIElements/DirectoryViewer/ContextMenu/Folder.ts b/src/components/UIElements/DirectoryViewer/ContextMenu/Folder.ts deleted file mode 100644 index addf040ce..000000000 --- a/src/components/UIElements/DirectoryViewer/ContextMenu/Folder.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { DirectoryWrapper } from '../DirectoryView/DirectoryWrapper' -import { DownloadAction } from './Actions/Download' -import { EditAction } from './Actions/Edit' -import { CopyAction } from './Actions/Edit/Copy' -import { FindInFolderAction } from './Actions/FindInFolder' -import { ImportFileAction } from './Actions/ImportFile' -import { RefreshAction } from './Actions/Refresh' -import { RevealFilePathAction } from './Actions/RevealPath' -import { App } from '/@/App' -import { - showContextMenu, - TActionConfig, -} from '/@/components/ContextMenu/showContextMenu' -import { InputWindow } from '/@/components/Windows/Common/Input/InputWindow' -import { tryCreateFile } from '/@/utils/file/tryCreateFile' -import { tryCreateFolder } from '/@/utils/file/tryCreateFolder' - -interface IFolderOptions { - hideDelete?: boolean - hideRename?: boolean - hideDuplicate?: boolean -} -export async function showFolderContextMenu( - event: MouseEvent, - directoryWrapper: DirectoryWrapper, - options: IFolderOptions = {} -) { - const app = await App.getApp() - const path = directoryWrapper.path - - const mutatingActions = ([ - { - icon: 'mdi-file-plus-outline', - name: 'actions.createFile.name', - - onTrigger: async () => { - const inputWindow = new InputWindow({ - name: 'actions.createFile.name', - label: 'general.fileName', - default: '', - }) - const name = await inputWindow.fired - if (!name) return - - const { type, handle } = await tryCreateFile({ - directoryHandle: directoryWrapper.handle, - name, - }) - - if (type === 'cancel') return - - if (handle) { - app.project.updateHandle(handle) - // Open file in new tab - await app.project.openFile(handle, { - selectTab: true, - }) - } - - directoryWrapper.refresh() - }, - }, - { - icon: 'mdi-folder-plus-outline', - name: 'actions.createFolder.name', - - onTrigger: async () => { - const inputWindow = new InputWindow({ - name: 'actions.createFolder.name', - label: 'general.folderName', - default: '', - }) - const name = await inputWindow.fired - if (!name) return - - const { type } = await tryCreateFolder({ - directoryHandle: directoryWrapper.handle, - name: name, - }) - if (type === 'cancel') return - - // Refresh pack explorer - directoryWrapper.refresh() - }, - }, - ...(await EditAction(directoryWrapper, options)), - { type: 'divider' }, - ]).filter((action) => action !== null) - - const additionalActions = - await directoryWrapper.options.provideDirectoryContextMenu?.( - directoryWrapper - ) - - let revealAction: TActionConfig | null = null - if (import.meta.env.VITE_IS_TAURI_APP) { - const { RevealInFileExplorer } = await import( - '/@/components/UIElements/DirectoryViewer/ContextMenu/Actions/RevealInFileExplorer' - ) - - revealAction = RevealInFileExplorer(directoryWrapper) - } else { - revealAction = RevealFilePathAction(directoryWrapper) - } - - showContextMenu(event, [ - ...(directoryWrapper.options.isReadOnly - ? [ - FindInFolderAction(directoryWrapper), - CopyAction(directoryWrapper), - ] - : mutatingActions), - - { - type: 'submenu', - icon: 'mdi-dots-horizontal', - name: 'actions.more.name', - - actions: [ - ...(directoryWrapper.options.isReadOnly - ? [] - : [ - ImportFileAction(directoryWrapper), - FindInFolderAction(directoryWrapper), - ]), - - RefreshAction(directoryWrapper), - DownloadAction(directoryWrapper), - revealAction, - ], - }, - - ...(additionalActions ?? []), - ]) -} diff --git a/src/components/UIElements/DirectoryViewer/DirectoryStore.ts b/src/components/UIElements/DirectoryViewer/DirectoryStore.ts deleted file mode 100644 index 5450d5273..000000000 --- a/src/components/UIElements/DirectoryViewer/DirectoryStore.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { DirectoryWrapper } from './DirectoryView/DirectoryWrapper' -import { markRaw } from 'vue' -import type { FileWrapper } from './FileView/FileWrapper' -import type { IFileDiagnostic } from '/@/components/PackIndexer/Worker/PackSpider/PackSpider' -import { VirtualHandle } from '/@/components/FileSystem/Virtual/Handle' -import { TActionConfig } from '../../ContextMenu/showContextMenu' -import { isSameEntry } from '/@/utils/file/isSameEntry' - -export interface IDirectoryViewerOptions { - startPath?: string - isReadOnly?: boolean - defaultIconColor?: string - - // TODO: mode for selecting files/folders - mode?: 'view-directory' // | 'select-file' | 'select-folder' - // multiple?: boolean - - onFileRightClick?: ( - event: MouseEvent, - fileWrapper: FileWrapper - ) => Promise | void - onDirectoryRightClick?: ( - event: MouseEvent, - directoryWrapper: DirectoryWrapper - ) => Promise | void - onHandleMoved?: (opts: IHandleMovedOptions) => Promise | void - onFilesAdded?: (filePaths: string[]) => Promise | void - - /** - * Add new items to the bottom of the file context menu - */ - provideFileContextMenu?: ( - fileWrapper: FileWrapper - ) => Promise | TActionConfig[] - /** - * Add new items to the bottom of the directory context menu - */ - provideDirectoryContextMenu?: ( - directoryWrapper: DirectoryWrapper - ) => Promise | TActionConfig[] - - /** - * Show file diagnostics within the directory viewer - */ - provideFileDiagnostics?: ( - fileWrapper: FileWrapper - ) => Promise | IFileDiagnostic[] -} - -export interface IHandleMovedOptions { - fromPath: string - toPath: string - movedHandle: FileSystemHandle | VirtualHandle - fromHandle: AnyDirectoryHandle - toHandle: AnyDirectoryHandle -} - -export class DirectoryStore { - protected static cache = new Map() - - protected static async getCachedDirectoryHandle( - directoryHandle: AnyDirectoryHandle - ) { - for (const [currDirhandle, currWrapper] of this.cache.entries()) { - if (await isSameEntry(currDirhandle, directoryHandle)) { - await currWrapper.refresh() - return currWrapper - } - } - - return null - } - - static async getDirectory( - directoryHandle: AnyDirectoryHandle, - options: IDirectoryViewerOptions = {} - ) { - const fromCache = await this.getCachedDirectoryHandle(directoryHandle) - if (fromCache) return fromCache - - const wrapper = new DirectoryWrapper(null, directoryHandle, options) - await wrapper.open() - this.cache.set(directoryHandle, markRaw(wrapper)) - - return wrapper - } - - static async disposeDirectory(directoryHandle: AnyDirectoryHandle) { - this.cache.delete(directoryHandle) - } -} diff --git a/src/components/UIElements/DirectoryViewer/DirectoryView/DirectoryView.vue b/src/components/UIElements/DirectoryViewer/DirectoryView/DirectoryView.vue deleted file mode 100644 index ee166eb5a..000000000 --- a/src/components/UIElements/DirectoryViewer/DirectoryView/DirectoryView.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - - - diff --git a/src/components/UIElements/DirectoryViewer/DirectoryView/DirectoryWrapper.ts b/src/components/UIElements/DirectoryViewer/DirectoryView/DirectoryWrapper.ts deleted file mode 100644 index 89a7c92b3..000000000 --- a/src/components/UIElements/DirectoryViewer/DirectoryView/DirectoryWrapper.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { markRaw, Ref, ref } from 'vue' -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { BaseWrapper } from '../Common/BaseWrapper' -import { IDirectoryViewerOptions } from '../DirectoryStore' -import { FileWrapper } from '../FileView/FileWrapper' -import { showFolderContextMenu } from '../ContextMenu/Folder' -// import { toSignal } from '/@/components/Solid/toSignal' - -const ignoreFiles = ['.DS_Store'] - -export class DirectoryWrapper extends BaseWrapper { - public readonly kind = 'directory' - public isOpen = ref(false) - public isLoading = ref(false) - public children = >ref(null) - - constructor( - parent: DirectoryWrapper | null, - directoryHandle: AnyDirectoryHandle, - options: IDirectoryViewerOptions - ) { - super(parent, directoryHandle, options) - } - - get icon() { - return this.isOpen.value ? 'mdi-folder-open' : 'mdi-folder' - } - // useIcon() { - // const [isOpen] = toSignal(this.isOpen) - - // return isOpen() ? 'mdi-folder-open' : 'mdi-folder' - // } - - protected async getChildren() { - const children: (DirectoryWrapper | FileWrapper)[] = [] - - try { - for await (const entry of this.handle.values()) { - if (entry.kind === 'directory') - children.push( - markRaw(new DirectoryWrapper(this, entry, this.options)) - ) - else if ( - entry.kind === 'file' && - !ignoreFiles.includes(entry.name) - ) - children.push( - markRaw(new FileWrapper(this, entry, this.options)) - ) - } - } catch (err) { - console.error('Trying to access non-existent directory', this.path) - } - - this.sortChildren(children) - - return children - } - protected sortChildren(children: (DirectoryWrapper | FileWrapper)[]) { - return children.sort((a, b) => { - if (a.kind === b.kind) return a.name.localeCompare(b.name) - else if (a.kind === 'directory') return -1 - else if (b.kind === 'directory') return 1 - - return 0 - }) - } - - /** - * Load directory contents - */ - async load() { - if (this.children.value) { - console.warn(`Children are already loaded`) - return - } - - this.children.value = await this.getChildren() - } - protected getOpenFolders(currentPath: string[] = []) { - const openFolders: string[][] = [] - - if (this.isOpen.value && currentPath.length !== 0) { - openFolders.push(currentPath) - } - - if (this.children.value) { - this.children.value.forEach((child) => { - if (child.kind === 'directory') { - openFolders.push( - ...child.getOpenFolders( - currentPath - ? [...currentPath, child.name] - : [child.name] - ) - ) - } - }) - } - - return openFolders - } - protected async openFolderPaths( - paths: string[][], - children: (DirectoryWrapper | FileWrapper)[] - ) { - if (paths.length === 0) return - - for (const path of paths) { - const folder = ( - children.find( - (child) => - child.name === path[0] && child.kind === 'directory' - ) - ) - - if (folder) { - await folder.open() - if (folder.children.value) - await folder.openFolderPaths( - [path.slice(1)], - folder.children.value - ) - } - } - } - - async refresh() { - const openPaths = this.getOpenFolders([]) - - const newChildren = await this.getChildren() - - await this.openFolderPaths(openPaths, newChildren) - - this.children.value = newChildren - } - sort() { - if (this.children.value) this.sortChildren(this.children.value) - } - - async toggleOpen(deep = false) { - // Folder is open, close it - if (this.isOpen.value) this.close(deep) - else await this.open(deep) - } - - async close(deep = false) { - this.isOpen.value = false - - if (deep) { - this.children.value?.forEach((child) => { - if (child instanceof DirectoryWrapper) child.close(true) - }) - } - } - async open(deep = false) { - if (this.isOpen.value && !deep) return - - // Folder is closed, did we load folder contents already? - if (this.children.value) { - // Yes, open folder - this.isOpen.value = true - } else { - // No, load folder contents - this.isLoading.value = true - - await this.load() - this.isOpen.value = true - this.isLoading.value = false - } - - if (deep) { - this.children.value?.forEach((child) => { - if (child instanceof DirectoryWrapper) child.open(true) - }) - } - } - - getChild(name: string) { - if (!this.children.value) - throw new Error( - 'Cannot use directoryWrapper.getChild(..) because children are not loaded yet' - ) - return this.children.value?.find((child) => child.name === name) - } - - protected _unselectAll() { - this.isSelected.value = false - this.children.value?.forEach((child) => { - child.isSelected.value = false - if (child.kind === 'directory') child._unselectAll() - }) - } - override unselectAll() { - if (this.parent === null) return this._unselectAll() - this.parent.unselectAll() - } - - override _onRightClick(event: MouseEvent) { - showFolderContextMenu(event, this, { - hideDelete: this.parent === null, - hideRename: this.parent === null, - hideDuplicate: this.parent === null, - }) - this.options.onDirectoryRightClick?.(event, this) - } - override _onClick(_: MouseEvent, forceClick: boolean): void { - // Click is a double click so we want to deep open/close the folder - if (forceClick) { - if (this.isOpen.value) this.open(true) - else this.close(true) - } else { - // Normal click; toggle folder open/close - - this.toggleOpen(forceClick) - } - } -} diff --git a/src/components/UIElements/DirectoryViewer/DirectoryViewer.vue b/src/components/UIElements/DirectoryViewer/DirectoryViewer.vue deleted file mode 100644 index a22a15eb8..000000000 --- a/src/components/UIElements/DirectoryViewer/DirectoryViewer.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/src/components/UIElements/DirectoryViewer/FileView/FileView.vue b/src/components/UIElements/DirectoryViewer/FileView/FileView.vue deleted file mode 100644 index 2f71cc1ac..000000000 --- a/src/components/UIElements/DirectoryViewer/FileView/FileView.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/src/components/UIElements/DirectoryViewer/FileView/FileWrapper.ts b/src/components/UIElements/DirectoryViewer/FileView/FileWrapper.ts deleted file mode 100644 index d64d8705c..000000000 --- a/src/components/UIElements/DirectoryViewer/FileView/FileWrapper.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { BaseWrapper } from '../Common/BaseWrapper' -import type { IDirectoryViewerOptions } from '../DirectoryStore' -import type { DirectoryWrapper } from '../DirectoryView/DirectoryWrapper' -import { App } from '/@/App' -import { showFileContextMenu } from '../ContextMenu/File' -import { getDefaultFileIcon } from '/@/utils/file/getIcon' - -export class FileWrapper extends BaseWrapper { - public readonly kind = 'file' - - constructor( - parent: DirectoryWrapper, - fileHandle: AnyFileHandle, - options: IDirectoryViewerOptions - ) { - super(parent, fileHandle, options) - } - - get icon() { - const path = this.path - if (!path) return getDefaultFileIcon(this.handle.name) - - return ( - App.fileType.get(path)?.icon ?? getDefaultFileIcon(this.handle.name) - ) - } - // useIcon() { - // return this.icon - // } - - async getFirstDiagnostic() { - // TODO: Disabled until we find time to polish the feature - return null - // const diagnostics = await this.options.provideFileDiagnostics?.(this) - - // return diagnostics?.[0] - } - - async openFile(persistFile = false) { - const app = await App.getApp() - - await app.project.openFile(this.handle, { - selectTab: true, - readOnlyMode: this.options.isReadOnly ? 'forced' : 'off', - isTemporary: !persistFile, - }) - } - - override _onRightClick(event: MouseEvent) { - showFileContextMenu(event, this) - - this.options.onFileRightClick?.(event, this) - } - override async _onClick(event: MouseEvent, forceClick: boolean) { - if (forceClick) { - const app = await App.getApp() - - const currentTab = app.project.tabSystem?.selectedTab - if (currentTab && currentTab.isTemporary) - currentTab.isTemporary = false - } - this.openFile() - } - override unselectAll(): void { - this.parent?.unselectAll() - } -} diff --git a/src/components/UIElements/Logo.vue b/src/components/UIElements/Logo.vue deleted file mode 100644 index 62079f793..000000000 --- a/src/components/UIElements/Logo.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/components/UIElements/ProjectDisplay.vue b/src/components/UIElements/ProjectDisplay.vue deleted file mode 100644 index 9c815c0a0..000000000 --- a/src/components/UIElements/ProjectDisplay.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - diff --git a/src/components/UIElements/SelectedStatus.vue b/src/components/UIElements/SelectedStatus.vue deleted file mode 100644 index 435bedd0c..000000000 --- a/src/components/UIElements/SelectedStatus.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/src/components/UIElements/Sheet.vue b/src/components/UIElements/Sheet.vue deleted file mode 100644 index edcd4bbc3..000000000 --- a/src/components/UIElements/Sheet.vue +++ /dev/null @@ -1,47 +0,0 @@ - - - - - diff --git a/src/components/UIElements/ToggleSheet.vue b/src/components/UIElements/ToggleSheet.vue deleted file mode 100644 index e310c6d71..000000000 --- a/src/components/UIElements/ToggleSheet.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - diff --git a/src/components/ViewFolders/ViewFolders.ts b/src/components/ViewFolders/ViewFolders.ts deleted file mode 100644 index 557ec4559..000000000 --- a/src/components/ViewFolders/ViewFolders.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { markRaw } from 'vue' -import { addFilesToCommandBar } from '../CommandBar/AddFiles' -import { AnyDirectoryHandle } from '../FileSystem/Types' -import { InfoPanel } from '../InfoPanel/InfoPanel' -import { SidebarAction } from '../Sidebar/Content/SidebarAction' -import { SidebarContent } from '../Sidebar/Content/SidebarContent' -import { SidebarElement } from '../Sidebar/SidebarElement' -import { IDirectoryViewerOptions } from '../UIElements/DirectoryViewer/DirectoryStore' -import ViewFolderComponent from './ViewFolders.vue' -import { App } from '/@/App' -import { IDisposable } from '/@/types/disposable' -import { isSameEntry } from '/@/utils/file/isSameEntry' - -export interface IViewHandleOptions extends IDirectoryViewerOptions { - directoryHandle: AnyDirectoryHandle - isDisposed?: boolean - disposable?: IDisposable | null - onDispose?: () => void -} -export class ViewFolders extends SidebarContent { - component = ViewFolderComponent - actions: SidebarAction[] = [] - topPanel: InfoPanel | undefined = undefined - - protected directoryHandles: IViewHandleOptions[] = [] - protected sidebarElement: SidebarElement - protected closeAction = new SidebarAction({ - icon: 'mdi-close', - name: 'general.close', - color: 'error', - onTrigger: async () => { - this.directoryHandles.forEach((handle) => handle.onDispose?.()) - this.directoryHandles = [] - - this.updateVisibility() - }, - }) - - constructor() { - super() - - this.actions = [this.closeAction] - - this.sidebarElement = markRaw( - new SidebarElement({ - id: 'viewOpenedFolders', - group: 'packExplorer', - sidebarContent: this, - displayName: 'sidebar.openedFolders.name', - icon: 'mdi-folder-open-outline', - isVisible: () => this.directoryHandles.length > 0, - }) - ) - } - - async updateVisibility() { - if (this.directoryHandles.length > 0) return - - const app = await App.getApp() - // Unselect ViewFolders tab by selecting packExplorer/viewComMojangProject instead - if (app.viewComMojangProject.hasComMojangProjectLoaded) - App.sidebar.elements.viewComMojangProject.select() - else App.sidebar.elements.packExplorer.select() - } - - async addDirectoryHandle({ - directoryHandle, - ...other - }: IViewHandleOptions) { - if (await this.hasDirectoryHandle(directoryHandle)) return - - const viewHandle: IViewHandleOptions = { - directoryHandle, - ...other, - provideDirectoryContextMenu: (directoryWrapper) => { - return [ - directoryWrapper.getParent() === null - ? { - name: 'sidebar.openedFolders.removeFolder', - icon: 'mdi-eye-off-outline', - onTrigger: async () => { - await this.removeDirectoryHandle( - directoryWrapper.handle - ) - await this.updateVisibility() - }, - } - : null, - ] - }, - isDisposed: false, - disposable: null, - onDispose() { - // When folder gets removed from ViewFolders, remove its actions from CommandBar - this.disposable?.dispose() - this.isDisposed = true - }, - } - - // Add files from this folder to CommandBar - addFilesToCommandBar(viewHandle.directoryHandle).then((disposable) => { - // Folder was already removed, so immediately dispose actions added to CommandBar again - if (viewHandle.isDisposed) disposable.dispose() - // Else, store disposable to remove actions later - else viewHandle.disposable = disposable - }) - this.directoryHandles.push(viewHandle) - - if (!this.sidebarElement.isSelected) this.sidebarElement.select() - } - async removeDirectoryHandle(directoryHandle: AnyDirectoryHandle) { - for (let i = 0; i < this.directoryHandles.length; i++) { - const curr = this.directoryHandles[i] - if (await curr.directoryHandle.isSameEntry(directoryHandle)) { - this.directoryHandles.splice(i, 1) - curr.onDispose?.() - return - } - } - - throw new Error('directoryHandle to remove not found') - } - async hasDirectoryHandle(directoryHandle: AnyDirectoryHandle) { - for (const { directoryHandle: currHandle } of this.directoryHandles) { - if (await isSameEntry(currHandle, directoryHandle)) return true - } - - return false - } -} diff --git a/src/components/ViewFolders/ViewFolders.vue b/src/components/ViewFolders/ViewFolders.vue deleted file mode 100644 index 58447bfbd..000000000 --- a/src/components/ViewFolders/ViewFolders.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/components/WelcomeAlert/Alert.vue b/src/components/WelcomeAlert/Alert.vue deleted file mode 100644 index 7f82c5631..000000000 --- a/src/components/WelcomeAlert/Alert.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - - - diff --git a/src/components/Windows/About/About.vue b/src/components/Windows/About/About.vue new file mode 100644 index 000000000..680c85c6c --- /dev/null +++ b/src/components/Windows/About/About.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/components/Windows/About/AboutWindow.ts b/src/components/Windows/About/AboutWindow.ts new file mode 100644 index 000000000..9cc2ad864 --- /dev/null +++ b/src/components/Windows/About/AboutWindow.ts @@ -0,0 +1,7 @@ +import { Window } from '../Window' +import About from './About.vue' + +export class AboutWindow extends Window { + public static id = 'createProject' + public static component = About +} diff --git a/src/components/Windows/About/AboutWindow.tsx b/src/components/Windows/About/AboutWindow.tsx deleted file mode 100644 index c43e7858f..000000000 --- a/src/components/Windows/About/AboutWindow.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { For } from 'solid-js' -import { SolidIconButton } from '../../Solid/Inputs/IconButton/IconButton' -import { SolidBridgeLogo } from '../../Solid/Logo' -import { SolidWindow } from '../../Solid/Window/Window' -import { App } from '/@/App' -import { version } from '/@/utils/app/version' - -const AboutWindow = () => { - const socialLinks = [ - { - icon: 'mdi-github', - link: 'https://github.com/bridge-core/', - }, - { - icon: 'mdi-twitter', - link: 'https://twitter.com/bridgeIde', - }, - { - icon: 'mdi-discord', - link: 'https://discord.gg/jj2PmqU', - }, - ] - - return ( -
- - bridge. v{version} - -
- - {({ icon, link }) => ( - App.openUrl(link)} - icon={icon} - /> - )} - -
-
- ) -} - -export function openAboutWindow() { - new SolidWindow(AboutWindow) -} diff --git a/src/components/Windows/Alert/Alert.vue b/src/components/Windows/Alert/Alert.vue new file mode 100644 index 000000000..c63cf06e5 --- /dev/null +++ b/src/components/Windows/Alert/Alert.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/Windows/Alert/AlertWindow.ts b/src/components/Windows/Alert/AlertWindow.ts new file mode 100644 index 000000000..640ec5d7e --- /dev/null +++ b/src/components/Windows/Alert/AlertWindow.ts @@ -0,0 +1,10 @@ +import { Window } from '../Window' +import Confirm from './Alert.vue' + +export class AlertWindow extends Window { + public component = Confirm + + constructor(public text: string) { + super() + } +} diff --git a/src/components/Windows/BrowserUnsupported/BrowserUnsupported.ts b/src/components/Windows/BrowserUnsupported/BrowserUnsupported.ts deleted file mode 100644 index 7cd89ae41..000000000 --- a/src/components/Windows/BrowserUnsupported/BrowserUnsupported.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NewBaseWindow } from '../NewBaseWindow' -import BrowserUnsupportedComponent from './BrowserUnsupported.vue' -import { App } from '/@/App' - -export class BrowserUnsupportedWindow extends NewBaseWindow { - constructor() { - super(BrowserUnsupportedComponent) - this.defineWindow() - } -} diff --git a/src/components/Windows/BrowserUnsupported/BrowserUnsupported.vue b/src/components/Windows/BrowserUnsupported/BrowserUnsupported.vue deleted file mode 100644 index fd7d4f297..000000000 --- a/src/components/Windows/BrowserUnsupported/BrowserUnsupported.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - diff --git a/src/components/Windows/Changelog/Changelog.ts b/src/components/Windows/Changelog/Changelog.ts deleted file mode 100644 index 84e890c6e..000000000 --- a/src/components/Windows/Changelog/Changelog.ts +++ /dev/null @@ -1,30 +0,0 @@ -import ChangelogComponent from './Changelog.vue' -import { App } from '/@/App' -import { baseUrl } from '/@/utils/baseUrl' -import { version } from '/@/utils/app/version' -import { NewBaseWindow } from '../NewBaseWindow' - -export class ChangelogWindow extends NewBaseWindow { - changelog: string | undefined - version: string | undefined - - constructor() { - super(ChangelogComponent) - this.defineWindow() - } - - async open() { - const app = await App.getApp() - app.windows.loadingWindow.open() - - await fetch(baseUrl + 'changelog.html') - .then((response) => response.text()) - .then((html) => { - this.changelog = html - this.version = version - }) - - app.windows.loadingWindow.close() - super.open() - } -} diff --git a/src/components/Windows/Changelog/Changelog.vue b/src/components/Windows/Changelog/Changelog.vue index 688e49498..c118b0433 100644 --- a/src/components/Windows/Changelog/Changelog.vue +++ b/src/components/Windows/Changelog/Changelog.vue @@ -1,34 +1,31 @@ diff --git a/src/components/Windows/Changelog/ChangelogWindow.ts b/src/components/Windows/Changelog/ChangelogWindow.ts new file mode 100644 index 000000000..58af6091a --- /dev/null +++ b/src/components/Windows/Changelog/ChangelogWindow.ts @@ -0,0 +1,7 @@ +import { Window } from '../Window' +import Changelog from './Changelog.vue' + +export class ChangelogWindow extends Window { + public static id = 'createProject' + public static component = Changelog +} diff --git a/src/components/Windows/Collect.vue b/src/components/Windows/Collect.vue deleted file mode 100644 index 6e223d4d2..000000000 --- a/src/components/Windows/Collect.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/src/components/Windows/Common/Confirm/ConfirmWindow.ts b/src/components/Windows/Common/Confirm/ConfirmWindow.ts deleted file mode 100644 index 81f02e2e6..000000000 --- a/src/components/Windows/Common/Confirm/ConfirmWindow.ts +++ /dev/null @@ -1,44 +0,0 @@ -import ConfirmWindowComponent from './ConfirmWindow.vue' -import { NewBaseWindow } from '../../NewBaseWindow' - -export interface IConfirmWindowOpts { - title?: string - description: string - confirmText?: string - cancelText?: string - onConfirm?: () => void - onCancel?: () => void - height?: number -} - -export class ConfirmationWindow extends NewBaseWindow { - constructor(protected opts: IConfirmWindowOpts) { - super(ConfirmWindowComponent, true, false) - this.defineWindow() - this.open() - } - - get confirmText() { - return this.opts.confirmText ?? 'general.okay' - } - get cancelText() { - return this.opts.cancelText ?? 'general.cancel' - } - get description() { - return this.opts.description - } - get title() { - return this.opts.title ?? 'general.confirm' - } - get height() { - return this.opts.height ?? 130 - } - onConfirm() { - if (typeof this.opts.onConfirm === 'function') this.opts.onConfirm() - this.close(true) - } - onCancel() { - if (typeof this.opts.onCancel === 'function') this.opts.onCancel() - this.close(false) - } -} diff --git a/src/components/Windows/Common/Confirm/ConfirmWindow.vue b/src/components/Windows/Common/Confirm/ConfirmWindow.vue deleted file mode 100644 index 040897b29..000000000 --- a/src/components/Windows/Common/Confirm/ConfirmWindow.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - diff --git a/src/components/Windows/Common/Dropdown/Dropdown.vue b/src/components/Windows/Common/Dropdown/Dropdown.vue deleted file mode 100644 index c939a2695..000000000 --- a/src/components/Windows/Common/Dropdown/Dropdown.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - diff --git a/src/components/Windows/Common/Dropdown/DropdownWindow.ts b/src/components/Windows/Common/Dropdown/DropdownWindow.ts deleted file mode 100644 index cf7fbe6aa..000000000 --- a/src/components/Windows/Common/Dropdown/DropdownWindow.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { reactive } from 'vue' -import { NewBaseWindow } from '../../NewBaseWindow' -import DropdownWindowComponent from './Dropdown.vue' - -export interface IDropdownWindowOpts { - name: string - isClosable?: boolean - placeholder?: string - options: string[] | { text: string; value: string }[] - default?: string - onConfirm?: (selection: string) => Promise | void -} - -export class DropdownWindow extends NewBaseWindow { - protected state = reactive({ - ...super.state, - currentSelection: undefined, - }) - - constructor(protected opts: IDropdownWindowOpts) { - super(DropdownWindowComponent, true, false) - this.state.currentSelection = opts.default ?? opts.options[0] - this.defineWindow() - this.open() - } - - get title() { - return this.opts.name - } - get options() { - return this.opts.options - } - get placeholder() { - return this.opts.placeholder - } - get isClosable() { - return this.opts.isClosable - } - - async confirm() { - const selection = - typeof this.state.currentSelection === 'object' - ? this.state.currentSelection.value - : this.state.currentSelection - - if (typeof this.opts.onConfirm === 'function') - await this.opts.onConfirm(selection) - super.close(selection ?? null) - } -} diff --git a/src/components/Windows/Common/FilePath/Window.ts b/src/components/Windows/Common/FilePath/Window.ts deleted file mode 100644 index 0a29d6c45..000000000 --- a/src/components/Windows/Common/FilePath/Window.ts +++ /dev/null @@ -1,58 +0,0 @@ -import FilePathWindowComponent from './Window.vue' -import { basename, extname } from '/@/utils/path' -import { NewBaseWindow } from '../../NewBaseWindow' -import { reactive } from 'vue' - -export interface IFilePathWinConfig { - fileName?: string - startPath?: string - isPersistent?: boolean -} -export interface IChangedFileData { - filePath: string - fileName?: string -} - -export class FilePathWindow extends NewBaseWindow { - protected fileExt?: string - protected isPersistent = false - protected hasFilePath = false - - protected state = reactive({ - ...super.state, - fileName: '', - currentFilePath: '', - }) - - constructor({ - fileName, - startPath = '', - isPersistent = false, - }: IFilePathWinConfig) { - super(FilePathWindowComponent, true, false) - this.state.currentFilePath = startPath - this.isPersistent = isPersistent - - if (fileName) { - this.hasFilePath = true - this.fileExt = extname(fileName) - this.state.fileName = basename(fileName, this.fileExt) - } - - this.defineWindow() - this.open() - } - - startCloseWindow(skippedDialog: boolean) { - return this.close( - skippedDialog - ? null - : { - filePath: this.state.currentFilePath, - fileName: this.hasFilePath - ? `${this.state.fileName}${this.fileExt}` - : undefined, - } - ) - } -} diff --git a/src/components/Windows/Common/FilePath/Window.vue b/src/components/Windows/Common/FilePath/Window.vue deleted file mode 100644 index 6577445c2..000000000 --- a/src/components/Windows/Common/FilePath/Window.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/src/components/Windows/Common/Information/Information.vue b/src/components/Windows/Common/Information/Information.vue deleted file mode 100644 index e2ac71666..000000000 --- a/src/components/Windows/Common/Information/Information.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/src/components/Windows/Common/Information/InformationWindow.ts b/src/components/Windows/Common/Information/InformationWindow.ts deleted file mode 100644 index 51c38e461..000000000 --- a/src/components/Windows/Common/Information/InformationWindow.ts +++ /dev/null @@ -1,35 +0,0 @@ -import InformationWindowComponent from './Information.vue' -import { NewBaseWindow } from '../../NewBaseWindow' - -export interface IConfirmWindowOpts { - title?: string - /** @deprecated Use "title" instead */ - name?: string - description: string - isPersistent?: boolean - onClose?: () => Promise | void -} - -export class InformationWindow extends NewBaseWindow { - constructor(protected opts: IConfirmWindowOpts) { - super(InformationWindowComponent, true, false) - this.defineWindow() - this.open() - } - - get description() { - return this.opts.description - } - get title() { - return this.opts.title ?? this.opts.name ?? 'general.information' - } - get isPersistent() { - return this.opts.isPersistent ?? true - } - - async close() { - super.close(null) - if (typeof this.opts.onClose === 'function') await this.opts.onClose() - this.dispatch() - } -} diff --git a/src/components/Windows/Common/Input/Input.vue b/src/components/Windows/Common/Input/Input.vue deleted file mode 100644 index 95de63797..000000000 --- a/src/components/Windows/Common/Input/Input.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/src/components/Windows/Common/Input/InputWindow.ts b/src/components/Windows/Common/Input/InputWindow.ts deleted file mode 100644 index 362eb9a37..000000000 --- a/src/components/Windows/Common/Input/InputWindow.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { reactive } from 'vue' -import { NewBaseWindow } from '../../NewBaseWindow' -import InputWindowComponent from './Input.vue' -import { App } from '/@/App' - -export interface IInputWindowOpts { - name: string - label: string - default?: string - expandText?: string - onConfirm?: (input: string) => Promise | void -} - -export class InputWindow extends NewBaseWindow { - protected state = reactive({ - ...super.getState(), - inputValue: '', - }) - - constructor(protected opts: IInputWindowOpts) { - super(InputWindowComponent, true, false) - this.state.inputValue = opts.default ?? '' - super.defineWindow() - super.open() - } - - get title() { - return this.opts.name - } - get label() { - return this.opts.label - } - get expandText() { - return this.opts.expandText - } - - async confirm() { - const finalInput = this.state.inputValue + (this.expandText ?? '') - - if (typeof this.opts.onConfirm === 'function') - await this.opts.onConfirm(finalInput) - - super.close(finalInput) - } -} diff --git a/src/components/Windows/Common/MultiOptions/Window.ts b/src/components/Windows/Common/MultiOptions/Window.ts deleted file mode 100644 index 52bf421d5..000000000 --- a/src/components/Windows/Common/MultiOptions/Window.ts +++ /dev/null @@ -1,31 +0,0 @@ -import MultiWindowComponent from './Window.vue' -import { NewBaseWindow } from '../../NewBaseWindow' - -export interface IOption { - name: string - isSelected: boolean -} -export interface IMultiOptionsWindowConfig { - name: string - options: IOption[] - isClosable?: boolean -} - -export class MultiOptionsWindow extends NewBaseWindow { - constructor(protected config: IMultiOptionsWindowConfig) { - super(MultiWindowComponent, true, false) - - this.defineWindow() - this.open() - } - - get name() { - return this.config.name - } - get options() { - return this.config.options - } - get isClosable() { - return this.config.isClosable - } -} diff --git a/src/components/Windows/Common/MultiOptions/Window.vue b/src/components/Windows/Common/MultiOptions/Window.vue deleted file mode 100644 index 36922dd59..000000000 --- a/src/components/Windows/Common/MultiOptions/Window.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/src/components/Windows/Compiler/Compiler.vue b/src/components/Windows/Compiler/Compiler.vue new file mode 100644 index 000000000..9c5454d57 --- /dev/null +++ b/src/components/Windows/Compiler/Compiler.vue @@ -0,0 +1,173 @@ + + + diff --git a/src/components/Windows/Compiler/CompilerWindow.ts b/src/components/Windows/Compiler/CompilerWindow.ts new file mode 100644 index 000000000..ab8455050 --- /dev/null +++ b/src/components/Windows/Compiler/CompilerWindow.ts @@ -0,0 +1,7 @@ +import { Window } from '../Window' +import Compiler from './Compiler.vue' + +export class CompilerWindow extends Window { + public static id = 'compiler' + public static component = Compiler +} diff --git a/src/components/Windows/Confirm/Confirm.vue b/src/components/Windows/Confirm/Confirm.vue new file mode 100644 index 000000000..60528d142 --- /dev/null +++ b/src/components/Windows/Confirm/Confirm.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/Windows/Confirm/ConfirmWindow.ts b/src/components/Windows/Confirm/ConfirmWindow.ts new file mode 100644 index 000000000..0dd396e1b --- /dev/null +++ b/src/components/Windows/Confirm/ConfirmWindow.ts @@ -0,0 +1,18 @@ +import { Window } from '../Window' +import Confirm from './Confirm.vue' + +export class ConfirmWindow extends Window { + public component = Confirm + + constructor(public text: string, public confirmCallback?: () => void, public cancelCallback?: () => void) { + super() + } + + public confirm() { + if (this.confirmCallback) this.confirmCallback() + } + + public cancel() { + if (this.cancelCallback) this.cancelCallback() + } +} diff --git a/src/components/Windows/CreateProject/CreateProject.vue b/src/components/Windows/CreateProject/CreateProject.vue new file mode 100644 index 000000000..778d71ceb --- /dev/null +++ b/src/components/Windows/CreateProject/CreateProject.vue @@ -0,0 +1,410 @@ + + + + + diff --git a/src/components/Windows/CreateProject/CreateProjectWindow.ts b/src/components/Windows/CreateProject/CreateProjectWindow.ts new file mode 100644 index 000000000..21921e30f --- /dev/null +++ b/src/components/Windows/CreateProject/CreateProjectWindow.ts @@ -0,0 +1,18 @@ +import { Settings } from '@/libs/settings/Settings' +import { Window } from '../Window' +import CreateProject from './CreateProject.vue' + +export class CreateProjectWindow extends Window { + public static id = 'createProject' + public static component = CreateProject + + public static setup() { + Settings.addSetting('defaultAuthor', { + default: '', + }) + + Settings.addSetting('defaultNamespace', { + default: 'bridge', + }) + } +} diff --git a/src/components/Windows/Dropdown/Dropdown.vue b/src/components/Windows/Dropdown/Dropdown.vue new file mode 100644 index 000000000..63423f497 --- /dev/null +++ b/src/components/Windows/Dropdown/Dropdown.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/Windows/Dropdown/DropdownWindow.ts b/src/components/Windows/Dropdown/DropdownWindow.ts new file mode 100644 index 000000000..8639255e7 --- /dev/null +++ b/src/components/Windows/Dropdown/DropdownWindow.ts @@ -0,0 +1,31 @@ +import { Window } from '../Window' +import { Ref, ref } from 'vue' +import Dropdown from './Dropdown.vue' + +export class DropdownWindow extends Window { + public id = 'dropwdownWindow' + public component = Dropdown + + public name: Ref = ref('?') + + constructor( + name: string, + public label: string, + public options: string[], + public confirmCallback: (input: string) => void, + public cancelCallback: () => void = () => {}, + public defaultValue?: string + ) { + super() + + this.name.value = name + } + + public confirm(input: string) { + this.confirmCallback(input) + } + + public cancel() { + this.cancelCallback() + } +} diff --git a/src/components/Windows/Error/ErrorWindow.tsx b/src/components/Windows/Error/ErrorWindow.tsx deleted file mode 100644 index 96d0596a0..000000000 --- a/src/components/Windows/Error/ErrorWindow.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Component, For } from 'solid-js' -import { useTranslations } from '../../Composables/useTranslations' -import { SolidIcon } from '../../Solid/Icon/SolidIcon' -import { SolidButton } from '../../Solid/Inputs/Button/SolidButton' -import { SolidSpacer } from '../../Solid/SolidSpacer' -import { SolidWindow } from '../../Solid/Window/Window' -import { App } from '/@/App' - -const ErrorWindow: Component<{ - error: Error -}> = (props) => { - const { t } = useTranslations() - - const prettyError = () => - `Error: ${props.error.message}\n${props.error.stack ?? ''}` - - const onCopyError = () => { - navigator.clipboard.writeText(prettyError()) - } - const onReportBug = () => { - App.openUrl( - `https://github.com/bridge-core/editor/issues/new?assignees=&labels=bug&template=bug_report.md&title=${prettyError()}` - ) - } - - return ( -
-
- -

{t('general.error')}

-
- -

- {t('windows.error.explanation')} -

- -
- {props.error.message} -
- - - -
- - - {/* Report bug on GitHub */} - - - {t('general.reportBug')} - - - {/* Copy error message button */} - - - - {t('actions.copy.name')} {t('general.error')} - - -
-
- ) -} - -interface IErrorWindowOpts { - error: Error -} - -export function openErrorWindow({ error }: IErrorWindowOpts) { - new SolidWindow(ErrorWindow, { error }) -} diff --git a/src/components/Windows/ExtensionLibrary/ExtensionLibrary.ts b/src/components/Windows/ExtensionLibrary/ExtensionLibrary.ts new file mode 100644 index 000000000..4700177dc --- /dev/null +++ b/src/components/Windows/ExtensionLibrary/ExtensionLibrary.ts @@ -0,0 +1,72 @@ +import { Windows } from '@/components/Windows/Windows' +import { Ref, ref } from 'vue' +import { ExtensionManifest } from '@/libs/extensions/Extension' +import { Data } from '@/libs/data/Data' +import { Extensions } from '@/libs/extensions/Extensions' +import { Window } from '@/components/Windows/Window' +import { InformedChoiceWindow } from '@/components/Windows/InformedChoice/InformedChoiceWindow' +import ExtensionLibrary from './ExtensionLibrary.vue' + +export class ExtensionLibraryWindow extends Window { + public static tags: Record = {} + public static selectedTag: Ref = ref('All') + public static extensions: ExtensionManifest[] = [] + + private static extensionToInstall?: ExtensionManifest + + public static id = 'extensionLibrary' + public static component = ExtensionLibrary + + public static async load() { + ExtensionLibraryWindow.tags = await Data.get('packages/common/extensionTags.json') + + ExtensionLibraryWindow.selectedTag.value = Object.keys(ExtensionLibraryWindow.tags)[0] + + ExtensionLibraryWindow.extensions = await ( + await fetch('https://raw.githubusercontent.com/bridge-core/plugins/master/extensions.json') + ).json() + } + + public static async open() { + await ExtensionLibraryWindow.load() + + Windows.open(ExtensionLibraryWindow) + } + + public static requestInstall(extension: ExtensionManifest) { + ExtensionLibraryWindow.extensionToInstall = extension + + Windows.open( + new InformedChoiceWindow('Extension Install Location', [ + { + icon: 'public', + name: 'Install Globally', + description: 'Global extensions are accessible to all of your projects', + choose: () => { + ExtensionLibraryWindow.confirmInstallGlobal() + }, + }, + { + icon: 'folder', + name: 'Install Locally', + description: 'Local extensions are accessible to only the projects you install them to', + choose: () => { + ExtensionLibraryWindow.confirmInstallProject() + }, + }, + ]) + ) + } + + public static confirmInstallGlobal() { + if (ExtensionLibraryWindow.extensionToInstall === undefined) return + + Extensions.installGlobal(ExtensionLibraryWindow.extensionToInstall) + } + + public static confirmInstallProject() { + if (ExtensionLibraryWindow.extensionToInstall === undefined) return + + Extensions.installProject(ExtensionLibraryWindow.extensionToInstall) + } +} diff --git a/src/components/Windows/ExtensionLibrary/ExtensionLibrary.vue b/src/components/Windows/ExtensionLibrary/ExtensionLibrary.vue new file mode 100644 index 000000000..9e68e97b0 --- /dev/null +++ b/src/components/Windows/ExtensionLibrary/ExtensionLibrary.vue @@ -0,0 +1,191 @@ + + + diff --git a/src/components/Windows/ExtensionStore/ExtensionActions.ts b/src/components/Windows/ExtensionStore/ExtensionActions.ts deleted file mode 100644 index 757956466..000000000 --- a/src/components/Windows/ExtensionStore/ExtensionActions.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SimpleAction } from '/@/components/Actions/SimpleAction' -import { ExtensionViewer } from './ExtensionViewer' -import { App } from '/@/App' - -export const extensionActions = (extension: ExtensionViewer) => [ - new SimpleAction({ - name: `windows.extensionStore.deleteExtension`, - icon: 'mdi-delete', - onTrigger: () => { - extension.delete() - extension.closeActionMenu() - }, - }), - new SimpleAction({ - name: `windows.extensionStore.${ - extension.isActive ? 'deactivateExtension' : 'activateExtension' - }`, - icon: extension.isActive ? 'mdi-close' : 'mdi-check', - onTrigger: () => { - extension.setActive(!extension.isActive) - extension.closeActionMenu() - }, - }), - // Only show "Install Local"/"Install Global" action if the extension isn't installed locally and globally yet - // ...and if the user is not currently on the "Home View" - !extension.isInstalledLocallyAndGlobally && - !App.instance.isNoProjectSelected - ? new SimpleAction({ - name: `windows.extensionStore.${ - extension.isGlobal ? 'installLocal' : 'installGlobal' - }`, - icon: 'mdi-download', - onTrigger: () => { - extension.download(!extension.isGlobal) - extension.closeActionMenu() - }, - }) - : null, -] diff --git a/src/components/Windows/ExtensionStore/ExtensionCard.vue b/src/components/Windows/ExtensionStore/ExtensionCard.vue deleted file mode 100644 index 11a01e45f..000000000 --- a/src/components/Windows/ExtensionStore/ExtensionCard.vue +++ /dev/null @@ -1,214 +0,0 @@ - - - - - diff --git a/src/components/Windows/ExtensionStore/ExtensionStore.ts b/src/components/Windows/ExtensionStore/ExtensionStore.ts deleted file mode 100644 index 5b1df56a7..000000000 --- a/src/components/Windows/ExtensionStore/ExtensionStore.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { Sidebar, SidebarItem } from '/@/components/Windows/Layout/Sidebar' -import ExtensionStoreComponent from './ExtensionStore.vue' -import { App } from '/@/App' -import { compareVersions } from 'bridge-common-utils' -import { ExtensionTag } from './ExtensionTag' -import { ExtensionViewer } from './ExtensionViewer' -import { IExtensionManifest } from '/@/components/Extensions/ExtensionLoader' -import { Notification } from '/@/components/Notifications/Notification' -import { InformationWindow } from '/@/components/Windows/Common/Information/InformationWindow' -import { NewBaseWindow } from '../NewBaseWindow' - -let updateNotification: Notification | undefined = undefined - -export class ExtensionStoreWindow extends NewBaseWindow { - protected baseUrl = - 'https://raw.githubusercontent.com/bridge-core/plugins/master' - protected sidebar = new Sidebar([]) - protected extensionTags!: Record - protected installedExtensions: ExtensionViewer[] = [] - public readonly tags: Record = {} - protected updates = new Set() - protected extensions: ExtensionViewer[] = [] - - constructor() { - super(ExtensionStoreComponent) - - App.eventSystem.on('projectChanged', () => { - updateNotification?.dispose() - updateNotification = undefined - }) - - this.defineWindow() - } - - async open(filter?: string) { - const app = await App.getApp() - app.windows.loadingWindow.open() - this.sidebar.removeElements() - this.installedExtensions = [] - - await app.dataLoader.fired - this.extensionTags = await app.dataLoader.readJSON( - 'data/packages/common/extensionTags.json' - ) - - const installedExtensions = - await app.extensionLoader.getInstalledExtensions() - - let extensions: IExtensionManifest[] - try { - extensions = navigator.onLine - ? await fetch(`${this.baseUrl}/extensions.json`).then((resp) => - resp.json() - ) - : [...installedExtensions.values()].map((ext) => ext.manifest) - } catch { - return this.createOfflineWindow() - } - - // User doesn't have local extensions and is offline - if (extensions.length === 0 && !navigator.onLine) - return this.createOfflineWindow() - - this.extensions = extensions.map( - (extension) => new ExtensionViewer(this, extension) - ) - - this.updates.clear() - - installedExtensions.forEach((installedExtension) => { - const extension = this.extensions.find( - (ext) => ext.id === installedExtension.id - ) - - if (extension) { - extension.setInstalled() - extension.setConnected(installedExtension) - - // Update for extension is available - if ( - compareVersions( - installedExtension.version, - extension.onlineVersion, - '<' - ) - ) { - extension.setIsUpdateAvailable() - this.updates.add(extension) - } - } else { - this.extensions.push(installedExtension.forStore(this)) - } - }) - - updateNotification?.dispose() - - this.setupSidebar() - - if (filter) this.sidebar.setFilter(filter) - - app.windows.loadingWindow.close() - super.open() - } - - async createOfflineWindow() { - const app = await App.getApp() - - app.windows.loadingWindow.close() - new InformationWindow({ - description: 'windows.extensionStore.offlineError', - }) - } - - close() { - super.close() - - if (this.updates.size > 0) { - updateNotification = new Notification({ - icon: 'mdi-sync', - color: 'primary', - message: 'sidebar.notifications.updateExtensions', - onClick: () => { - this.updates.forEach((extension) => extension.update(false)) - updateNotification?.dispose() - }, - }) - } - } - - updateInstalled(extension: ExtensionViewer) { - this.updates.delete(extension) - } - delete(extension: ExtensionViewer) { - const index = this.installedExtensions.findIndex( - (e) => e.id === extension.id - ) - if (index === -1) return - - this.installedExtensions.splice(index, 1) - - if (extension.isLocalOnly) - this.extensions = this.extensions.filter( - (e) => e.id !== extension.id - ) - } - - setupSidebar() { - this.sidebar.addElement( - new SidebarItem({ - id: 'all', - text: 'All', - icon: 'mdi-format-list-bulleted-square', - color: 'primary', - }), - this.getExtensions() - ) - this.sidebar.addElement( - new SidebarItem({ - id: 'installed', - text: 'Installed', - icon: 'mdi-download-circle-outline', - color: 'primary', - }), - this.installedExtensions - ) - Object.values(this.tags).forEach((tag) => - this.sidebar.addElement( - tag.asSidebarElement(), - this.getExtensionsByTag(tag) - ) - ) - this.sidebar.setDefaultSelected() - } - - protected getExtensions(findTag?: ExtensionTag) { - return [ - ...new Set([...this.extensions, ...this.installedExtensions]), - ].filter((ext) => !ext.isLocalOnly && (!findTag || ext.hasTag(findTag))) - } - protected getExtensionsByTag(findTag: ExtensionTag) { - return this.getExtensions(findTag) - } - getTagIcon(tagName: string) { - return this.extensionTags[tagName]?.icon - } - getTagColor(tagName: string) { - return this.extensionTags[tagName]?.color - } - getExtensionById(id: string) { - return this.getExtensions().find((extension) => extension.id === id) - } - - get selectedSidebar() { - return this.sidebar.selected - } - set selectedSidebar(val) { - this.sidebar.clearFilter() - this.sidebar.selected = val - } - - getBaseUrl() { - return this.baseUrl - } - addInstalledExtension(extension: ExtensionViewer) { - this.installedExtensions.push(extension) - } -} diff --git a/src/components/Windows/ExtensionStore/ExtensionStore.vue b/src/components/Windows/ExtensionStore/ExtensionStore.vue deleted file mode 100644 index 5c2246fe9..000000000 --- a/src/components/Windows/ExtensionStore/ExtensionStore.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - diff --git a/src/components/Windows/ExtensionStore/ExtensionTag.ts b/src/components/Windows/ExtensionStore/ExtensionTag.ts deleted file mode 100644 index b1ac16c7c..000000000 --- a/src/components/Windows/ExtensionStore/ExtensionTag.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { SidebarItem } from '/@/components/Windows/Layout/Sidebar' -import { ExtensionStoreWindow } from './ExtensionStore' - -export class ExtensionTag { - protected icon: string - protected text: string - protected color?: string - - constructor(protected parent: ExtensionStoreWindow, tagName: string) { - this.icon = this.parent.getTagIcon(tagName) - this.text = tagName - this.color = this.parent.getTagColor(tagName) - } - - getText() { - return this.text - } - - asSidebarElement() { - return new SidebarItem({ - id: this.text.toLowerCase(), - text: this.text, - color: this.color, - icon: this.icon, - }) - } -} diff --git a/src/components/Windows/ExtensionStore/ExtensionViewer.ts b/src/components/Windows/ExtensionStore/ExtensionViewer.ts deleted file mode 100644 index 067db3790..000000000 --- a/src/components/Windows/ExtensionStore/ExtensionViewer.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { App } from '/@/App' -import { Extension } from '/@/components/Extensions/Extension' -import { IExtensionManifest } from '/@/components/Extensions/ExtensionLoader' -import { InformedChoiceWindow } from '/@/components/Windows/InformedChoice/InformedChoice' -import { ExtensionStoreWindow } from './ExtensionStore' -import { ExtensionTag } from './ExtensionTag' -import { extensionActions } from './ExtensionActions' -import { ConfirmationWindow } from '../Common/Confirm/ConfirmWindow' -import { compareVersions } from 'bridge-common-utils' -import { version as appVersion } from '/@/utils/app/version' - -export class ExtensionViewer { - protected tags: ExtensionTag[] - protected isLoading = false - protected _isInstalled = false - protected isUpdateAvailable = false - protected connected: Extension[] = [] - protected showMenu = false - public isActive = true - - constructor( - protected parent: ExtensionStoreWindow, - protected config: IExtensionManifest, - public readonly isLocalOnly: boolean = false - ) { - this.tags = this.config.tags - .map((tag) => { - if (!this.parent.tags[tag]) - this.parent.tags[tag] = new ExtensionTag(this.parent, tag) - return this.parent.tags[tag] - }) - .sort((a, b) => a.getText().localeCompare(b.getText())) - } - - //#region Config getters - get author() { - return this.manifest.author - } - get name() { - return this.manifest.name - } - get version() { - return this.manifest.version - } - get description() { - return this.manifest.description - } - get icon() { - return this.manifest.icon - } - get id() { - return this.manifest.id - } - get releaseTimestamp() { - return this.config.releaseTimestamp ?? Date.now() - } - get readme() { - return this.manifest.readme - } - get manifest() { - if (this.isUpdateAvailable) return this.config ?? {} - - return this.extension?.manifest ?? this.config ?? {} - } - //#endregion - - get compilerPlugins() { - const ext = this.extension - if (ext) return Object.keys(ext.compilerPlugins ?? {}) - - return Object.keys(this.config?.compiler?.plugins ?? {}) - } - get isInstalled() { - return this._isInstalled - } - get actions() { - return extensionActions(this).filter((action) => action !== null) - } - - get isGlobal() { - return this.extension?.isGlobal ?? false - } - get onlineVersion() { - return this.config.version - } - get isInstalledLocallyAndGlobally() { - return this.connected.length === 2 - } - get extension() { - if (this.connected.length === 0) return null - const localExtension = this.connected.find((ext) => !ext.isGlobal) - if (localExtension) return localExtension - - return this.connected[0] - } - - hasTag(tag: ExtensionTag) { - return this.tags.includes(tag) - } - - isCompatibleVersion() { - return ( - !this.config.compatibleAppVersions || - (((this.config.compatibleAppVersions.min && - compareVersions( - appVersion, - this.config.compatibleAppVersions.min, - '>=' - )) || - !this.config.compatibleAppVersions.min) && - ((this.config.compatibleAppVersions.max && - compareVersions( - appVersion, - this.config.compatibleAppVersions.max, - '<' - )) || - !this.config.compatibleAppVersions.max)) - ) - } - - async download(isGlobalInstall?: boolean) { - const app = await App.getApp() - if (isGlobalInstall !== undefined) - return this.downloadExtension(isGlobalInstall) - - // If the user is on the HomeView, only allow global extension installations - if (app.isNoProjectSelected) return this.downloadExtension(true) - - const installLocationChoiceWindow = new InformedChoiceWindow( - 'windows.pluginInstallLocation.title' - ) - const actionManager = await installLocationChoiceWindow.actionManager - actionManager.create({ - icon: 'mdi-folder-multiple-outline', - name: 'actions.pluginInstallLocation.global.name', - description: 'actions.pluginInstallLocation.global.description', - onTrigger: () => { - this.downloadExtension(true) - }, - }) - actionManager.create({ - icon: 'mdi-folder-outline', - name: 'actions.pluginInstallLocation.local.name', - description: 'actions.pluginInstallLocation.local.description', - onTrigger: () => { - this.downloadExtension(false) - }, - }) - } - - protected async downloadExtension( - isGlobalInstall: boolean, - isUpdateDownload = false, - shouldActivateExtension = true - ) { - this.isLoading = true - - const app = await App.getApp() - const zip = await fetch( - this.parent.getBaseUrl() + this.config.link - ).then((response) => response.arrayBuffer()) - - const basePath = !isGlobalInstall - ? `${app.project.projectPath}/.bridge/extensions` - : '~local/extensions' - const extensionLoader = isGlobalInstall - ? app.extensionLoader - : app.project.extensionLoader - const zipPath = basePath + `/${this.name.replace(/\s+/g, '')}.zip` - await app.fileSystem.writeFile(zipPath, zip) - - // Delete old folder - try { - await app.fileSystem.unlink(zipPath.replace('.zip', '')) - } catch {} - - // Install dependencies - for (const dependency of this.config.dependencies ?? []) { - const extension = this.parent.getExtensionById(dependency) - if (extension) { - if (!extension.isInstalled) - await extension.downloadExtension(isGlobalInstall) - else if (!extension.isActive && extension.connected) - extension.connected.forEach((ext) => ext.activate()) - } - } - - // Unzip & activate extension - const extension = await extensionLoader.loadExtension( - await app.fileSystem.getDirectoryHandle(basePath), - await app.fileSystem.getFileHandle(zipPath), - // Only activate extension if we're supposed to and the currently connected extension is global - shouldActivateExtension && - (this.connected.length === 0 || this.extension?.isGlobal) - ) - - if (extension) this.setConnected(extension) - - this.setInstalled() - this.isUpdateAvailable = false - this.isLoading = false - - if (!isUpdateDownload) { - if (extension?.contributesCompilerPlugins) { - new ConfirmationWindow({ - title: 'windows.extensionStore.compilerPluginDownload.title', - description: - 'windows.extensionStore.compilerPluginDownload.description', - cancelText: 'general.later', - confirmText: - 'windows.extensionStore.compilerPluginDownload.openConfig', - onConfirm: async () => { - this.parent.close() - - const app = await App.getApp() - const project = app.project - - const config = project.config.get() - - if (config.compiler) { - await project.openFile( - await project.fileSystem.getFileHandle( - 'config.json' - ) - ) - } else { - await project.openFile( - await project.fileSystem.getFileHandle( - '.bridge/compiler/default.json' - ) - ) - } - }, - }) - } - } - } - - async update(notifyParent = true) { - if (this.connected.length === 0) return - - if (notifyParent) this.parent.updateInstalled(this) - - const wasExtensionActive = this.connected.map((ext) => ext.isActive) - - this.connected.forEach((ext) => ext.deactivate()) - await Promise.all( - this.connected.map(async (ext, i) => { - await ext.resetInstalled() - - await this.downloadExtension( - ext.isGlobal, - true, - wasExtensionActive[i] - ) - }) - ) - } - delete() { - if (this.connected.length === 0) return - - this.extension?.delete() - - this.connected = this.connected.filter((ext) => ext !== this.extension) - - if (this.connected.length === 0) { - this.parent.delete(this) - this._isInstalled = false - } - } - - setInstalled() { - this._isInstalled = true - this.parent.addInstalledExtension(this) - } - setIsUpdateAvailable() { - this.isUpdateAvailable = true - } - setConnected(ext: Extension) { - this.connected.push(ext) - - this.isActive = this.extension?.isActive ?? false - } - - setActive(value: boolean) { - if (!this.connected) - throw new Error(`No extension connected to ExtensionViewer`) - - // Deactivate all connected extensions - if (!value) this.connected.forEach((ext) => ext.setActive(value)) - // But only activate the current extension - else this.extension?.setActive(value) - - this.isActive = value - } - closeActionMenu() { - this.showMenu = false - } - - get canShare() { - return typeof navigator.share === 'function' - } - async share() { - if (!this.canShare) return - - const url = new URL(window.location.href) - url.searchParams.set('viewExtension', encodeURIComponent(this.id)) - - await navigator - .share({ - title: `Extension: ${this.name}`, - text: `View the extension "${this.name}" within bridge.!`, - url: url.href, - }) - .catch(() => {}) - } -} diff --git a/src/components/Windows/InformedChoice/InformedChoice.ts b/src/components/Windows/InformedChoice/InformedChoice.ts deleted file mode 100644 index 5a0820ff1..000000000 --- a/src/components/Windows/InformedChoice/InformedChoice.ts +++ /dev/null @@ -1,43 +0,0 @@ -import InformedChoiceComponent from './InformedChoice.vue' -import { ActionManager } from '/@/components/Actions/ActionManager' -import { Signal } from '/@/components/Common/Event/Signal' -import { InfoPanel } from '/@/components/InfoPanel/InfoPanel' -import { NewBaseWindow } from '../NewBaseWindow' -import { reactive } from 'vue' - -interface IInformedChoiceWindowOpts { - isPersistent?: boolean -} - -export class InformedChoiceWindow extends NewBaseWindow { - protected _ready = new Signal() - protected topPanel?: InfoPanel - - protected state = reactive({ - ...super.state, - actionManager: new ActionManager(), - }) - - get actionManager() { - return this.state.actionManager - } - - constructor( - protected title: string, - protected opts: IInformedChoiceWindowOpts = {} - ) { - super(InformedChoiceComponent, true) - - this.defineWindow() - this.open() - } - - async open() { - super.open() - } - - dispose() { - super.dispose() - this.actionManager.dispose() - } -} diff --git a/src/components/Windows/InformedChoice/InformedChoice.vue b/src/components/Windows/InformedChoice/InformedChoice.vue index ce29adc33..47742804b 100644 --- a/src/components/Windows/InformedChoice/InformedChoice.vue +++ b/src/components/Windows/InformedChoice/InformedChoice.vue @@ -1,49 +1,40 @@ - - + + diff --git a/src/components/Windows/InformedChoice/InformedChoiceWindow.ts b/src/components/Windows/InformedChoice/InformedChoiceWindow.ts new file mode 100644 index 000000000..92576c784 --- /dev/null +++ b/src/components/Windows/InformedChoice/InformedChoiceWindow.ts @@ -0,0 +1,25 @@ +import { Window } from '../Window' +import Confirm from './InformedChoice.vue' + +export type InformedChoice = { + icon: string + name: string + description: string + choose: () => void +} + +export class InformedChoiceWindow extends Window { + public component = Confirm + + constructor(public name: string, public choices: InformedChoice[], public cancelCallback?: () => void) { + super() + } + + public choose(choice: InformedChoice) { + choice.choose() + } + + public cancel() { + if (this.cancelCallback) this.cancelCallback() + } +} diff --git a/src/components/Windows/Layout/BaseWindow.vue b/src/components/Windows/Layout/BaseWindow.vue deleted file mode 100644 index 401fa626d..000000000 --- a/src/components/Windows/Layout/BaseWindow.vue +++ /dev/null @@ -1,257 +0,0 @@ - - - - - diff --git a/src/components/Windows/Layout/Sidebar.ts b/src/components/Windows/Layout/Sidebar.ts deleted file mode 100644 index b8c1d2a52..000000000 --- a/src/components/Windows/Layout/Sidebar.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { del, reactive, Ref, ref, set } from 'vue' -import { v4 as uuid } from 'uuid' -import { EventDispatcher } from '/@/components/Common/Event/EventDispatcher' - -export type TSidebarElement = SidebarCategory | SidebarItem -export interface ISidebarCategoryConfig { - text: string - items: SidebarItem[] - isOpen?: boolean - shouldSort?: boolean -} -export class SidebarCategory { - public readonly type = 'category' - public readonly id = uuid() - protected text: string - protected items: SidebarItem[] - protected isOpen: boolean - protected shouldSort: boolean - public showDisabled = false - - get isDisabled() { - return this.items.every((i) => i.isDisabled) - } - - constructor({ items, text, isOpen, shouldSort }: ISidebarCategoryConfig) { - this.text = text - this.items = items - this.isOpen = isOpen ?? true - this.shouldSort = shouldSort ?? true - if (this.shouldSort) this.sortCategory() - } - - addItem(item: SidebarItem) { - this.items.push(item) - if (this.shouldSort) this.sortCategory() - } - removeItems() { - this.items = [] - } - - getText() { - return this.text - } - getSearchText() { - return this.text.toLowerCase() - } - getItems(showDisabled = this.showDisabled) { - return showDisabled - ? this.items - : this.items.filter((item) => !item.isDisabled) - } - - setOpen(val: boolean) { - this.isOpen = val - } - - getCurrentElement(selected: string) { - return this.items.find(({ id }) => id === selected) - } - - sortCategory() { - this.items = this.items.sort((a, b) => - a.getSearchText().localeCompare(b.getSearchText()) - ) - } - hasFilterMatches(filter: string) { - if (filter === '') return this.items.length > 0 - - return ( - this.items.find((item) => item.getSearchText().includes(filter)) !== - undefined - ) - } - filtered(filter: string) { - if (filter.length === 0) return this - - const category = new SidebarCategory({ - items: this.getItems().filter((item) => - item.getSearchText().includes(filter) - ), - text: this.text, - shouldSort: this.shouldSort, - isOpen: true, - }) - category.showDisabled = this.showDisabled - - return category - } -} - -export interface ISidebarItemConfig { - id: string - text: string - icon?: string - color?: string - isDisabled?: boolean - disabledText?: string -} -export class SidebarItem { - readonly type = 'item' - public readonly id: string - protected text: string - protected icon?: string - protected color?: string - public isDisabled: boolean - public disabledText?: string - - constructor({ - id, - text, - icon, - color, - isDisabled, - disabledText, - }: ISidebarItemConfig) { - this.id = id - this.text = text - this.icon = icon - this.color = color - this.isDisabled = isDisabled ?? false - this.disabledText = disabledText - } - - getText() { - return this.text - } - getSearchText() { - return this.text.toLowerCase() - } -} - -export class Sidebar extends EventDispatcher { - public readonly _selected = ref('') - protected _filter = ref('') - /** - * Stores the last _filter value that we have already selected a new element for - */ - protected reselectedForFilter = '' - public readonly state: Record = reactive({}) - protected _showDisabled = ref(false) - protected _elements = >ref([]) - - constructor( - _elements: TSidebarElement[], - protected readonly shouldSortSidebar = true - ) { - super() - this.selected = this.findDefaultSelected() - } - - get showDisabled() { - return this._showDisabled.value - } - set showDisabled(val: boolean) { - this._showDisabled.value = val - - this.rawElements.forEach((e) => { - if (e.type === 'category') e.showDisabled = val - }) - } - - addElement(element: TSidebarElement, additionalData?: unknown) { - if (element.type === 'item' && additionalData) - set(this.state, element.id, additionalData) - - if (this.has(element)) this.replace(element) - else this._elements.value.push(element) - } - has(element: TSidebarElement) { - return ( - this._elements.value.find((e) => e.id === element.id) !== undefined - ) - } - replace(element: TSidebarElement) { - this._elements.value = this._elements.value.map((e) => { - if (e.id === element.id) return element - - return e - }) - } - removeElements() { - this._elements.value = [] - - for (const key in this.state) { - del(this.state, key) - } - } - - get filter() { - return this._filter.value.toLowerCase() - } - set filter(val) { - this._filter.value = val.toLowerCase() - } - setFilter(filter: string) { - this._filter.value = filter.toLowerCase() - } - - get elements() { - const elements = this.sortSidebar( - this._elements.value - .filter((e) => { - if (!this.showDisabled && e.isDisabled) return false - - if (e.type === 'item') - return e.getSearchText().includes(this.filter) - else return e.hasFilterMatches(this.filter) - }) - .map((e) => (e.type === 'item' ? e : e.filtered(this.filter))) - ) - - if (this.filter !== this.reselectedForFilter && elements.length > 0) { - const e = elements.find((e) => !e.isDisabled) - if (!e) return elements - - if (e.type === 'category' && e.getItems(false).length > 0) { - e.setOpen(true) - - const item = e.getItems(false)[0] - if (item) this.setDefaultSelected(item.id) - } else if (e.type === 'item') { - this.setDefaultSelected(e.id) - } - - this.reselectedForFilter = this.filter - } - - return elements - } - get rawElements() { - return this._elements.value - } - - get currentElement() { - if (!this.selected) return - - for (const element of this._elements.value) { - if (element.type === 'item') { - if (element.id === this.selected) return element - else continue - } - - const item = element.getCurrentElement(this.selected) - if (item) return item - } - - return - } - get currentState() { - if (!this.selected) return {} - return this.state[this.selected] ?? {} - } - getState(id: string) { - return this.state[id] ?? {} - } - setState(id: string, data: any) { - set(this.state, id, data) - } - - protected sortSidebar(elements: TSidebarElement[]) { - if (!this.shouldSortSidebar) return elements - - return elements.sort((a, b) => { - if (a.type !== b.type) return a.type.localeCompare(b.type) - return a.getSearchText().localeCompare(b.getSearchText()) - }) - } - protected findDefaultSelected() { - for (const element of this.sortSidebar(this.elements)) { - if (element.isDisabled) continue - - if ( - element.type === 'category' && - element.getItems(false).length > 0 - ) { - const item = element.getItems(false)[0] - if (item) return item.id - } else if (element.type === 'item') return element.id - } - } - setDefaultSelected(value?: string) { - if (value) this.selected = value - else if (!this.selected) this.selected = this.findDefaultSelected() - } - resetSelected() { - this.selected = undefined - } - - clearFilter() { - this._filter.value = '' - } - get selected() { - return this._selected.value - } - set selected(val) { - if (this._selected.value !== val) { - this.dispatch(val) - this._selected.value = val - } - } -} diff --git a/src/components/Windows/Layout/Sidebar/Group.vue b/src/components/Windows/Layout/Sidebar/Group.vue deleted file mode 100644 index c3d7b8fcb..000000000 --- a/src/components/Windows/Layout/Sidebar/Group.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - - diff --git a/src/components/Windows/Layout/Sidebar/Item.vue b/src/components/Windows/Layout/Sidebar/Item.vue deleted file mode 100644 index 2dacb6814..000000000 --- a/src/components/Windows/Layout/Sidebar/Item.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - - - diff --git a/src/components/Windows/Layout/SidebarWindow.vue b/src/components/Windows/Layout/SidebarWindow.vue deleted file mode 100644 index b0537d43a..000000000 --- a/src/components/Windows/Layout/SidebarWindow.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - diff --git a/src/components/Windows/Layout/Toolbar/Button.vue b/src/components/Windows/Layout/Toolbar/Button.vue deleted file mode 100644 index 177516e1a..000000000 --- a/src/components/Windows/Layout/Toolbar/Button.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/src/components/Windows/Layout/Toolbar/DisplayAction.vue b/src/components/Windows/Layout/Toolbar/DisplayAction.vue deleted file mode 100644 index e9dafd828..000000000 --- a/src/components/Windows/Layout/Toolbar/DisplayAction.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - diff --git a/src/components/Windows/Layout/Toolbar/Mac.vue b/src/components/Windows/Layout/Toolbar/Mac.vue deleted file mode 100644 index a3e887b34..000000000 --- a/src/components/Windows/Layout/Toolbar/Mac.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - - - diff --git a/src/components/Windows/Layout/Toolbar/Mac/Button.vue b/src/components/Windows/Layout/Toolbar/Mac/Button.vue deleted file mode 100644 index e23c8bcf7..000000000 --- a/src/components/Windows/Layout/Toolbar/Mac/Button.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - - - diff --git a/src/components/Windows/Layout/Toolbar/Mac/WindowControls.vue b/src/components/Windows/Layout/Toolbar/Mac/WindowControls.vue deleted file mode 100644 index 6cc675f25..000000000 --- a/src/components/Windows/Layout/Toolbar/Mac/WindowControls.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - diff --git a/src/components/Windows/Layout/Toolbar/Windows.vue b/src/components/Windows/Layout/Toolbar/Windows.vue deleted file mode 100644 index 0187b59e6..000000000 --- a/src/components/Windows/Layout/Toolbar/Windows.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - diff --git a/src/components/Windows/LoadingWindow/LoadingWindow.ts b/src/components/Windows/LoadingWindow/LoadingWindow.ts deleted file mode 100644 index 4ab86abac..000000000 --- a/src/components/Windows/LoadingWindow/LoadingWindow.ts +++ /dev/null @@ -1,37 +0,0 @@ -import LoadingWindowComponent from './LoadingWindow.vue' -import { NewBaseWindow } from '../NewBaseWindow' - -export class LoadingWindow extends NewBaseWindow { - protected virtualWindows = 0 - protected loadingMessages: (string | undefined)[] = [] - - constructor() { - super(LoadingWindowComponent) - this.defineWindow() - } - - get message() { - return this.loadingMessages[this.loadingMessages.length - 1] - } - - open(message?: string) { - this.virtualWindows++ - this.loadingMessages.push(message) - if (!this.state.isVisible) super.open() - } - close() { - this.virtualWindows-- - this.loadingMessages.pop() - - if (this.virtualWindows === 0) { - super.close() - } else if (this.virtualWindows < 0) { - this.virtualWindows = 0 - } - } - closeAll() { - this.virtualWindows = 0 - this.loadingMessages = [] - super.close() - } -} diff --git a/src/components/Windows/LoadingWindow/LoadingWindow.vue b/src/components/Windows/LoadingWindow/LoadingWindow.vue deleted file mode 100644 index cd72ddc90..000000000 --- a/src/components/Windows/LoadingWindow/LoadingWindow.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/src/components/Windows/NewBaseWindow.ts b/src/components/Windows/NewBaseWindow.ts deleted file mode 100644 index aee19cee0..000000000 --- a/src/components/Windows/NewBaseWindow.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Component as VueComponent } from 'vue' -import { v4 as uuid } from 'uuid' -import { Signal } from '/@/components/Common/Event/Signal' -import { SimpleAction } from '/@/components/Actions/SimpleAction' -import { App } from '/@/App' -import { markRaw, reactive } from 'vue' -import { Sidebar } from './Layout/Sidebar' - -export interface IWindowState { - isVisible: boolean - isLoading: boolean - shouldRender: boolean - actions: SimpleAction[] -} -export abstract class NewBaseWindow extends Signal { - protected windowUUID = uuid() - protected component: VueComponent - protected closeTimeout: number | null = null - protected state: IWindowState = reactive({ - isVisible: false, - shouldRender: false, - isLoading: true, - actions: [], - }) - protected sidebar?: Sidebar - - getState() { - return this.state - } - - constructor( - component: VueComponent, - protected disposeOnClose = false, - protected keepAlive = false - ) { - super() - - this.component = markRaw(component) - } - - defineWindow() { - App.windowState.addWindow(this.windowUUID, this) - } - addAction(action: SimpleAction) { - this.state.actions.push(action) - } - - close(data: T | null) { - this.sidebar?.setFilter('') - - this.state.isVisible = false - if (data !== null) this.dispatch(data) - - if (!this.keepAlive) { - this.closeTimeout = window.setTimeout(() => { - this.state.shouldRender = false - this.closeTimeout = null - if (this.disposeOnClose) this.dispose() - }, 600) - } - } - open() { - this.state.shouldRender = true - this.state.isVisible = true - this.resetSignal() - if (this.closeTimeout !== null) { - window.clearTimeout(this.closeTimeout) - this.closeTimeout = null - } - } - dispose() { - App.windowState.deleteWindow(this.windowUUID) - } -} diff --git a/src/components/Windows/Presets/Presets.vue b/src/components/Windows/Presets/Presets.vue new file mode 100644 index 000000000..a7b380826 --- /dev/null +++ b/src/components/Windows/Presets/Presets.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/src/components/Windows/Presets/PresetsWindow.ts b/src/components/Windows/Presets/PresetsWindow.ts new file mode 100644 index 000000000..d414cfc40 --- /dev/null +++ b/src/components/Windows/Presets/PresetsWindow.ts @@ -0,0 +1,14 @@ +import { Window } from '../Window' +import Presets from './Presets.vue' + +export class PresetsWindow extends Window { + public static id = 'presets' + public static component = Presets + + public static validationRules: Record string | null> = { + alphanumeric: (value) => (value.match(/^[a-zA-Z0-9_\.]*$/) !== null ? null : 'validation.invalidLetters'), + lowercase: (value: string) => (value.toLowerCase() === value ? null : 'validation.mustBeLowercase'), + required: (value: string) => (!!value ? null : 'validation.mustNotBeEmpty'), + numeric: (value: string) => (!isNaN(Number(value)) ? null : 'validation.mustBeNumeric'), + } +} diff --git a/src/components/Windows/Progress/Progress.vue b/src/components/Windows/Progress/Progress.vue new file mode 100644 index 000000000..b8e0e41b2 --- /dev/null +++ b/src/components/Windows/Progress/Progress.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/src/components/Windows/Progress/ProgressWindow.ts b/src/components/Windows/Progress/ProgressWindow.ts new file mode 100644 index 000000000..3b632666d --- /dev/null +++ b/src/components/Windows/Progress/ProgressWindow.ts @@ -0,0 +1,10 @@ +import { Window } from '../Window' +import WindowComponent from './Progress.vue' + +export class ProgressWindow extends Window { + public component = WindowComponent + + constructor(public text: string) { + super() + } +} diff --git a/src/components/Windows/Project/CreatePreset/CreateFile.ts b/src/components/Windows/Project/CreatePreset/CreateFile.ts deleted file mode 100644 index b9490a887..000000000 --- a/src/components/Windows/Project/CreatePreset/CreateFile.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { IPresetFileOpts } from './PresetWindow' -import { transformString } from './TransformString' -import { App } from '/@/App' -import { CombinedFileSystem } from '/@/components/FileSystem/CombinedFs' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { extname, dirname } from '/@/utils/path' - -export type TCreateFile = [string, string, IPresetFileOpts?] - -const textTransformFiles = [ - '.mcfunction', - '.json', - '.js', - '.ts', - '.txt', - '.molang', -] - -export async function createFile( - presetPath: string, - [originPath, destPath, opts]: TCreateFile, - models: Record -) { - const app = await App.getApp() - const fs = new CombinedFileSystem( - app.fileSystem.baseDirectory, - app.dataLoader - ) - - const inject = opts?.inject ?? [] - const fullOriginPath = `${presetPath}/${originPath}` - const fullDestPath = transformString( - app.projectConfig.resolvePackPath(opts?.packPath, destPath), - inject, - models - ) - const ext = extname(fullDestPath) - await fs.mkdir(dirname(fullDestPath), { recursive: true }) - - let fileHandle: AnyFileHandle - if (inject.length === 0 || !textTransformFiles.includes(ext)) { - fileHandle = await fs.copyFile(fullOriginPath, fullDestPath) - } else { - const file = await fs.readFile(fullOriginPath) - const fileText = await file.text() - - fileHandle = await fs.writeFile( - fullDestPath, - transformString(fileText, inject, models) - ) - } - - return fileHandle -} diff --git a/src/components/Windows/Project/CreatePreset/ExpandFile.ts b/src/components/Windows/Project/CreatePreset/ExpandFile.ts deleted file mode 100644 index 35ab01c69..000000000 --- a/src/components/Windows/Project/CreatePreset/ExpandFile.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { IPresetFileOpts } from './PresetWindow' -import { transformString } from './TransformString' -import { App } from '/@/App' -import { CombinedFileSystem } from '/@/components/FileSystem/CombinedFs' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { deepMerge } from 'bridge-common-utils' -import { extname, dirname } from '/@/utils/path' - -export type TExpandFile = [string, string, IPresetFileOpts?] - -export async function expandFile( - presetPath: string, - [originPath, destPath, opts]: TExpandFile, - models: Record -) { - const app = await App.getApp() - const fs = new CombinedFileSystem( - app.fileSystem.baseDirectory, - app.dataLoader - ) - const inject = opts?.inject ?? [] - const fullOriginPath = `${presetPath}/${originPath}` - const fullDestPath = transformString( - app.projectConfig.resolvePackPath(opts?.packPath, destPath), - inject, - models - ) - const ext = extname(fullDestPath) - await fs.mkdir(dirname(fullDestPath), { recursive: true }) - - let fileHandle: AnyFileHandle - if (ext === '.json') { - const originFile = await fs.readFile(fullOriginPath) - const jsonStr = transformString(await originFile.text(), inject, models) - - let destJson: any - try { - destJson = await fs.readJSON(fullDestPath) - } catch { - destJson = {} - } - - fileHandle = await fs.writeFile( - fullDestPath, - JSON.stringify(deepMerge(destJson, JSON.parse(jsonStr)), null, '\t') - ) - } else { - const file = await fs.readFile(fullOriginPath) - const fileText = await file.text() - - let destFileText: string - try { - destFileText = await (await fs.readFile(fullDestPath)).text() - } catch { - destFileText = '' - } - - const outputFileText = transformString(fileText, inject, models) - - fileHandle = await fs.writeFile( - fullDestPath, - `${destFileText}${destFileText !== '' ? '\n' : ''}${outputFileText}` - ) - } - - return fileHandle -} diff --git a/src/components/Windows/Project/CreatePreset/PresetItem.ts b/src/components/Windows/Project/CreatePreset/PresetItem.ts deleted file mode 100644 index b633a2c1c..000000000 --- a/src/components/Windows/Project/CreatePreset/PresetItem.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - ISidebarItemConfig, - SidebarItem, -} from '/@/components/Windows/Layout/Sidebar' - -export class PresetItem extends SidebarItem { - public readonly resetState: () => void - - constructor(config: ISidebarItemConfig & { resetState: () => void }) { - super(config) - - this.resetState = config.resetState - } -} diff --git a/src/components/Windows/Project/CreatePreset/PresetPath.vue b/src/components/Windows/Project/CreatePreset/PresetPath.vue deleted file mode 100644 index 6b385bc94..000000000 --- a/src/components/Windows/Project/CreatePreset/PresetPath.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - - - diff --git a/src/components/Windows/Project/CreatePreset/PresetScript.ts b/src/components/Windows/Project/CreatePreset/PresetScript.ts deleted file mode 100644 index 56fac6365..000000000 --- a/src/components/Windows/Project/CreatePreset/PresetScript.ts +++ /dev/null @@ -1,210 +0,0 @@ -import json5 from 'json5' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { IPermissions, IPresetFileOpts } from './PresetWindow' -import { transformString } from './TransformString' -import { App } from '/@/App' -import { run } from '/@/components/Extensions/Scripts/run' -import { deepMerge } from 'bridge-common-utils' -import { AnyFileHandle } from '/@/components/FileSystem/Types' -import { CombinedFileSystem } from '/@/components/FileSystem/CombinedFs' - -export async function runPresetScript( - presetPath: string, - presetScript: string, - models: Record, - permissions: IPermissions -) { - const app = await App.getApp() - const fs = app.fileSystem - const globalFs = new CombinedFileSystem( - app.fileSystem.baseDirectory, - app.dataLoader - ) - - let loadFilePath: string - if (presetScript.startsWith('./')) { - loadFilePath = presetScript = presetScript.replaceAll( - './', - presetPath + '/' - ) - } else { - loadFilePath = `data/packages/minecraftBedrock/${presetScript}` - } - - await app.dataLoader.fired - const script = await globalFs.readFile(loadFilePath) - const scriptSrc = await script.text() - - const module: any = { exports: undefined } - try { - run({ - script: scriptSrc, - env: { - module, - }, - }) - } catch (err) { - throw new Error( - `Failed to execute PresetScript "${presetScript}": ${err}` - ) - } - - if (typeof module.exports !== 'function') - throw new Error( - `Failed to execute PresetScript "${presetScript}": module.exports is of type ${typeof module.exports}` - ) - - const createdFiles: AnyFileHandle[] = [] - const openFiles: AnyFileHandle[] = [] - const createJSONFile = ( - filePath: string, - data: any, - opts: IPresetFileOpts = { - inject: [], - openFile: false, - packPath: undefined, - } - ) => { - if (typeof data !== 'string') data = JSON.stringify(data, null, '\t') - return createFile(filePath, data, opts) - } - const createFile = async ( - filePath: string, - data: FileSystemWriteChunkType, - { - inject = [], - openFile = false, - packPath = undefined, - }: IPresetFileOpts = { - inject: [], - openFile: false, - packPath: undefined, - } - ) => { - const resolvedFilePath = app.projectConfig.resolvePackPath( - packPath, - filePath - ) - // Permission not set yet, prompt user if necessary - if ( - permissions.mayOverwriteFiles === undefined && - (await fs.fileExists(resolvedFilePath)) - ) { - const confirmWindow = new ConfirmationWindow({ - description: 'windows.createPreset.overwriteFiles', - confirmText: 'windows.createPreset.overwriteFilesConfirm', - }) - - const overwriteFiles = await confirmWindow.fired - if (overwriteFiles) { - // Stop file collision checks & continue creating preset - permissions.mayOverwriteFiles = true - } else { - // Don't create file - permissions.mayOverwriteFiles = false - return - } - } else if (permissions.mayOverwriteFiles === false) { - // Permission set, abort write - return - } - - const fileHandle = await fs.getFileHandle(resolvedFilePath, true) - createdFiles.push(fileHandle) - if (openFile) openFiles.push(fileHandle) - - await fs.write( - fileHandle, - typeof data === 'string' - ? transformString(data, inject, models) - : data - ) - } - const expandFile = async ( - filePath: string, - data: any, - { - inject = [], - openFile = false, - packPath = undefined, - }: IPresetFileOpts = { - inject: [], - openFile: false, - packPath: undefined, - } - ) => { - const resolvedFilePath = app.projectConfig.resolvePackPath( - packPath, - filePath - ) - const fileHandle = await fs.getFileHandle(resolvedFilePath, true) - - // Permission for overwriting unsaved changes not set yet, request it - if (permissions.mayOverwriteUnsavedChanges === undefined) { - const tab = await app.project.getFileTab(fileHandle) - if (tab !== undefined && tab.isUnsaved) { - const confirmWindow = new ConfirmationWindow({ - description: 'windows.createPreset.overwriteUnsavedChanges', - confirmText: - 'windows.createPreset.overwriteUnsavedChangesConfirm', - }) - - const overwriteUnsaved = await confirmWindow.fired - if (overwriteUnsaved) { - // Stop file collision checks & continue creating preset - permissions.mayOverwriteUnsavedChanges = true - - tab.close() - } else { - // Don't expand file - permissions.mayOverwriteUnsavedChanges = false - return - } - } - } else if (permissions.mayOverwriteUnsavedChanges === false) { - // Permission set to false, abort expanding file - return - } - - let current: string | null = null - try { - current = await (await fs.readFile(resolvedFilePath)).text() - } catch {} - - createdFiles.push(fileHandle) - if (openFile) openFiles.push(fileHandle) - - if (typeof data === 'string') { - data = transformString(data, inject, models) - await fs.writeFile( - resolvedFilePath, - current ? `${current}\n${data}` : data - ) - } else { - data = transformString(json5.stringify(data), inject, models) - await fs.writeJSON( - resolvedFilePath, - deepMerge( - current ? json5.parse(current) : {}, - json5.parse(data) - ), - true - ) - } - } - const loadPresetFile = (filePath: string) => - globalFs.readFile(`${presetPath}/${filePath}`) - - await module.exports({ - models, - createFile, - expandFile, - loadPresetFile, - createJSONFile, - }) - - return { - createdFiles: createdFiles, - openFile: openFiles, - } -} diff --git a/src/components/Windows/Project/CreatePreset/PresetWindow.ts b/src/components/Windows/Project/CreatePreset/PresetWindow.ts deleted file mode 100644 index 9583a8aaf..000000000 --- a/src/components/Windows/Project/CreatePreset/PresetWindow.ts +++ /dev/null @@ -1,478 +0,0 @@ -import { Sidebar, SidebarCategory } from '/@/components/Windows/Layout/Sidebar' -import PresetWindowComponent from './PresetWindow.vue' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { App } from '/@/App' -import { v4 as uuid } from 'uuid' -import { dirname } from '/@/utils/path' -import { runPresetScript } from './PresetScript' -import { expandFile, TExpandFile } from './ExpandFile' -import { createFile, TCreateFile } from './CreateFile' -import { TPackTypeId } from '/@/components/Data/PackType' -import { transformString } from './TransformString' -import { ConfirmationWindow } from '../../Common/Confirm/ConfirmWindow' -import { PresetItem } from './PresetItem' -import { DataLoader } from '/@/components/Data/DataLoader' -import { AnyFileHandle, AnyHandle } from '/@/components/FileSystem/Types' -import { - IRequirements, - RequiresMatcher, -} from '/@/components/Data/RequiresMatcher/RequiresMatcher' -import { createFailureMessage } from '/@/components/Data/RequiresMatcher/FailureMessage' -import json5 from 'json5' -import { markRaw, reactive, Ref, ref } from 'vue' -import { translate } from '/@/components/Locales/Manager' -import { NewBaseWindow } from '../../NewBaseWindow' - -export interface IPresetManifest { - name: string - icon: string - requiredModules?: string[] - category: string - description?: string - presetPath?: string - additionalModels?: Record - fields: [string, string, IPresetFieldOpts][] - createFiles?: (string | TCreateFile)[] - expandFiles?: TExpandFile[] - requires: IRequirements - showIfDisabled?: boolean -} -export interface IPresetFieldOpts { - // All types - type?: 'fileInput' | 'numberInput' | 'textInput' | 'switch' | 'selectInput' - default?: string - optional?: boolean - // Type = 'numberInput' - min?: number - max?: number - step?: number - // type = 'fileInput' - accept?: string - icon?: string - multiple?: boolean - // type = 'selectInput' - options?: string[] | { fileType: string; cacheKey: string } - isLoading?: boolean -} - -export interface IPresetFileOpts { - inject: string[] - openFile?: boolean - packPath?: TPackTypeId -} - -export interface IPermissions { - mayOverwriteFiles?: boolean - mayOverwriteUnsavedChanges?: boolean -} - -export class CreatePresetWindow extends NewBaseWindow { - protected loadPresetPaths = new Set() - public sidebar = new Sidebar([]) - protected shouldReloadPresets = true - protected modelResetters: (() => void)[] = [] - - /** - * Add new validation strategies to this object ([key]: [validationFunction]) - * to make them available as the [key] inside of presets. - */ - protected _validationRules: Record boolean> = { - alphanumeric: (value: string) => - value.match(/^[a-zA-Z0-9_\.]*$/) !== null, - lowercase: (value: string) => value.toLowerCase() === value, - required: (value: string) => !!value, - numeric: (value: string) => !isNaN(Number(value)), - } - - /** - * This getter returns the validationRules object for usage inside of the PresetWindow. - * It wraps the _validationRules (see above) functions inside of another function call to return the proper - * error message: "windows.createPreset.validationRule.[key]" - */ - get validationRules() { - return Object.fromEntries( - Object.entries(this._validationRules).map(([key, func]) => [ - key, - (value: string) => - func(value) || - translate(`windows.createPreset.validationRule.${key}`), - ]) - ) - } - - constructor() { - super(PresetWindowComponent) - this.defineWindow() - - const reloadEvents = ['presetsChanged', 'projectChanged'] - - reloadEvents.forEach((eventName) => - App.eventSystem.on(eventName, () => this.onPresetsChanged()) - ) - } - - onPresetsChanged() { - this.shouldReloadPresets = true - } - - protected requiresMatcher = markRaw(new RequiresMatcher()) - protected async addPreset(manifestPath: string, manifest: IPresetManifest) { - const app = await App.getApp() - - // Presets need a category, presets without category are most likely incompatible v1 presets - if (!manifest.category) - throw new Error( - `Error loading ${manifestPath}: Missing preset category` - ) - - const mayUsePreset = this.requiresMatcher.isValid(manifest.requires) - if (!mayUsePreset && manifest.showIfDisabled === false) return - - let category = ( - this.sidebar.rawElements.find( - (element) => element.getText() === manifest.category - ) - ) - if (!category) { - category = new SidebarCategory({ - isOpen: false, - text: manifest.category, - items: [], - }) - this.sidebar.addElement(category) - } - - const id = uuid() - - const resetState = () => { - manifest.fields?.forEach( - ([fieldName, fieldModel, fieldOpts = {}]) => { - if ( - fieldOpts.type !== 'selectInput' || - Array.isArray(fieldOpts.options) - ) - return - - const { fileType, cacheKey } = fieldOpts.options ?? {} - if (!fileType || !cacheKey) return - - fieldOpts.options = [] - fieldOpts.isLoading = true - app.project.packIndexer.once(async () => { - const options = - await app.project.packIndexer.service.getCacheDataFor( - fileType, - undefined, - cacheKey - ) - - fieldOpts.options = options ?? [] - fieldOpts.isLoading = false - }) - } - ) - - this.sidebar.setState(id, { - ...manifest, - presetPath: dirname(manifestPath), - models: { - PROJECT_PREFIX: - app.projectConfig.get().namespace ?? 'bridge', - ...(manifest.additionalModels ?? {}), - ...Object.fromEntries( - manifest.fields.map(([_, id, opts = {}]: any) => [ - id, - opts.default ?? - (!opts.type || opts.type === 'textInput' - ? '' - : null), - ]) - ), - }, - }) - } - - const iconColor = - manifest.category === 'fileType.simpleFile' && - manifest.requires?.packTypes && - manifest.requires.packTypes.length > 0 - ? App.packType.getFromId(manifest.requires.packTypes[0]) - ?.color ?? 'primary' - : 'primary' - - let failureMessage: string | undefined - if (this.requiresMatcher.failures.length > 0) { - failureMessage = await createFailureMessage( - this.requiresMatcher.failures[0], - manifest.requires - ) - } - - category.addItem( - new PresetItem({ - id, - text: manifest.name, - icon: manifest.icon, - color: iconColor, - isDisabled: !mayUsePreset, - disabledText: failureMessage, - resetState, - }) - ) - - resetState() - this.modelResetters.push(resetState) - } - - protected async loadPresets( - fs: FileSystem | DataLoader, - dirPath = 'data/packages/minecraftBedrock/preset' - ) { - await fs.fired - - // Use shortcut presets.json file if available - if ( - dirPath.endsWith('presets.json') && - (await fs.fileExists(dirPath)) - ) { - const presets = await fs.readJSON(dirPath) - - await Promise.all( - Object.entries(presets).map(([presetPath, manifest]) => - this.addPreset( - `${dirname(dirPath)}/${presetPath}`, - manifest - ) - ) - ) - return - } - - let dirents: AnyHandle[] = [] - try { - dirents = await fs.readdir(dirPath, { withFileTypes: true }) - } catch {} - - const promises = [] - for (const dirent of dirents) { - if (dirent.kind === 'directory') - promises.push(this.loadPresets(fs, `${dirPath}/${dirent.name}`)) - else if (dirent.name === 'manifest.json') { - let manifest - try { - manifest = json5.parse( - await dirent.getFile().then((file) => file.text()) - ) - } catch (originalError: any) { - const error = new Error( - `Failed to load JSON file "${dirPath}/${dirent.name}".` - ) - // @ts-ignore TypeScript doesn't know about error.cause yet - error.cause = originalError - - console.error(error) - continue - } - - promises.push( - await this.addPreset(`${dirPath}/${dirent.name}`, manifest) - ) - } - } - - await Promise.all(promises) - } - - async loadDefaultPresets(dataLoader: DataLoader) { - const presets = await dataLoader.readJSON( - 'data/packages/minecraftBedrock/presets.json' - ) - - await Promise.all( - Object.entries(presets).map(([presetPath, manifest]) => - this.addPreset(presetPath, manifest) - ) - ) - } - - async open() { - const app = await App.getApp() - const fs = app.fileSystem - app.windows.loadingWindow.open() - - if (this.shouldReloadPresets) { - this.sidebar.removeElements() - - // Reset requires matcher - this.requiresMatcher = markRaw(new RequiresMatcher()) - await this.requiresMatcher.setup() - - await Promise.all([ - this.loadDefaultPresets(app.dataLoader), - ...[...this.loadPresetPaths].map((loadPresetPath) => - this.loadPresets(fs, loadPresetPath) - ), - ]) - - this.sidebar.setDefaultSelected() - this.shouldReloadPresets = false - } else { - this.modelResetters.forEach((reset) => reset()) - } - - app.windows.loadingWindow.close() - super.open() - } - addPresets(folderPath: string) { - this.loadPresetPaths.add(folderPath) - - return { - dispose: () => this.loadPresetPaths.delete(folderPath), - } - } - - async createPreset({ - presetPath, - createFiles = [], - expandFiles = [], - }: IPresetManifest) { - if (!presetPath) return - - const app = await App.getApp() - app.windows.loadingWindow.open() - const projectFs = app.project!.fileSystem - - const createdFiles: AnyFileHandle[] = [] - const permissions: IPermissions = { - mayOverwriteFiles: undefined, - mayOverwriteUnsavedChanges: undefined, - } - - // Check that we don't overwrite files - for (const createFile of createFiles) { - if (typeof createFile === 'string') continue - - const filePath = transformString( - createFile[1], - createFile[2]?.inject ?? [], - this.sidebar.currentState.models - ) - if (await projectFs.fileExists(filePath)) { - const confirmWindow = new ConfirmationWindow({ - description: 'windows.createPreset.overwriteFiles', - confirmText: 'windows.createPreset.overwriteFilesConfirm', - }) - - const overwriteFiles = await confirmWindow.fired - if (overwriteFiles) { - // Stop file collision checks & continue creating preset - permissions.mayOverwriteFiles = true - break - } else { - // Close loading window & early return - app.windows.loadingWindow.close() - return - } - } - } - - const project = app.project! - // Request permission for overwriting unsaved changes - for (const expandFile of expandFiles) { - if (typeof expandFile === 'string') continue - - let filePath = transformString( - expandFile[1], - expandFile[2]?.inject ?? [], - this.sidebar.currentState.models - ) - // This filePath is relative to the project root - // The project.hasFile/project.closeFile methods expect a fileHandle - let fileHandle: AnyFileHandle | null = null - try { - fileHandle = await project.fileSystem.getFileHandle(filePath) - } catch { - continue - } - - const tab = await project.getFileTab(fileHandle) - if (tab !== undefined && tab.isUnsaved) { - const confirmWindow = new ConfirmationWindow({ - description: 'windows.createPreset.overwriteUnsavedChanges', - confirmText: - 'windows.createPreset.overwriteUnsavedChangesConfirm', - }) - - const overwriteUnsaved = await confirmWindow.fired - if (overwriteUnsaved) { - // Stop file collision checks & continue creating preset - permissions.mayOverwriteUnsavedChanges = true - - project.closeFile(fileHandle) - } else { - // Close loading window & early return - app.windows.loadingWindow.close() - return - } - } - } - - const openFiles: AnyFileHandle[] = [] - for (const createFileOpts of createFiles) { - if (typeof createFileOpts === 'string') { - const scriptResult = await runPresetScript( - presetPath, - createFileOpts, - this.sidebar.currentState.models, - permissions - ) - createdFiles.push(...scriptResult.createdFiles) - openFiles.push(...scriptResult.openFile) - } else { - const fileHandle = await createFile( - presetPath, - createFileOpts, - this.sidebar.currentState.models - ) - createdFiles.push(fileHandle) - if (createFileOpts[2]?.openFile) openFiles.push(fileHandle) - } - } - for (const expandFileOpts of expandFiles) { - const fileHandle = await expandFile( - presetPath, - expandFileOpts, - this.sidebar.currentState.models - ) - createdFiles.push(fileHandle) - if (expandFileOpts[2]?.openFile) openFiles.push(fileHandle) - } - - // await Promise.all(promises) - App.eventSystem.dispatch('fileAdded', undefined) - - // Close window - if (permissions.mayOverwriteFiles !== false) this.close() - - const filePaths: string[] = [] - for (const fileHandle of createdFiles) { - const filePath = await app.fileSystem.pathTo(fileHandle) - if (!filePath) continue - - filePaths.push(filePath) - if (openFiles.includes(fileHandle)) - await app.project.openFile(fileHandle, { - selectTab: fileHandle === openFiles[openFiles.length - 1], - isTemporary: false, - }) - } - - app.project.updateFiles(filePaths) - - // Reset preset inputs - if (this.sidebar.currentElement instanceof PresetItem) - this.sidebar.currentElement.resetState() - - app.windows.loadingWindow.close() - } -} diff --git a/src/components/Windows/Project/CreatePreset/PresetWindow.vue b/src/components/Windows/Project/CreatePreset/PresetWindow.vue deleted file mode 100644 index 5caa15b77..000000000 --- a/src/components/Windows/Project/CreatePreset/PresetWindow.vue +++ /dev/null @@ -1,211 +0,0 @@ - - - diff --git a/src/components/Windows/Project/CreatePreset/TransformString.ts b/src/components/Windows/Project/CreatePreset/TransformString.ts deleted file mode 100644 index b5372168c..000000000 --- a/src/components/Windows/Project/CreatePreset/TransformString.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function transformString( - str: string, - inject: string[], - models: Record -) { - inject.forEach((val) => (str = str.replaceAll(`{{${val}}}`, models[val]))) - return str -} diff --git a/src/components/Windows/Prompt/Prompt.vue b/src/components/Windows/Prompt/Prompt.vue new file mode 100644 index 000000000..ba9fbfe71 --- /dev/null +++ b/src/components/Windows/Prompt/Prompt.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/components/Windows/Prompt/PromptWindow.ts b/src/components/Windows/Prompt/PromptWindow.ts new file mode 100644 index 000000000..2d989ebdd --- /dev/null +++ b/src/components/Windows/Prompt/PromptWindow.ts @@ -0,0 +1,31 @@ +import { Window } from '../Window' +import { Ref, ref } from 'vue' +import Prompt from './Prompt.vue' + +export class PromptWindow extends Window { + public id = 'promptWindow' + public component = Prompt + + public name: Ref = ref('?') + + constructor( + name: string, + public label: string, + public placeholder: string, + public confirmCallback: (input: string) => void, + public cancelCallback: () => void = () => {}, + public defaultValue?: string + ) { + super() + + this.name.value = name + } + + public confirm(input: string) { + this.confirmCallback(input) + } + + public cancel() { + this.cancelCallback() + } +} diff --git a/src/components/Windows/ReportError/ReportError.vue b/src/components/Windows/ReportError/ReportError.vue new file mode 100644 index 000000000..25a81521d --- /dev/null +++ b/src/components/Windows/ReportError/ReportError.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/Windows/ReportError/ReportErrorWindow.ts b/src/components/Windows/ReportError/ReportErrorWindow.ts new file mode 100644 index 000000000..0c5792a90 --- /dev/null +++ b/src/components/Windows/ReportError/ReportErrorWindow.ts @@ -0,0 +1,32 @@ +import { NotificationSystem } from '@/components/Notifications/NotificationSystem' +import { Window } from '../Window' +import { Windows } from '../Windows' +import Component from './ReportError.vue' + +export class ReportErrorWindow extends Window { + public component = Component + + constructor(public message: string) { + super() + } + + public static setup() { + window.addEventListener('error', (event) => { + this.openErrorWindow(event.error) + }) + + window.onunhandledrejection = (event) => { + this.openErrorWindow(event.reason) + } + } + + public static openErrorWindow(error: Error) { + NotificationSystem.addNotification( + 'report', + () => { + Windows.open(new ReportErrorWindow(error.message)) + }, + 'error' + ) + } +} diff --git a/src/components/Windows/Settings/Actions/ActionsList.vue b/src/components/Windows/Settings/Actions/ActionsList.vue new file mode 100644 index 000000000..5a0c975ab --- /dev/null +++ b/src/components/Windows/Settings/Actions/ActionsList.vue @@ -0,0 +1,120 @@ + + diff --git a/src/components/Windows/Settings/Appearance/SidebarElementVisibility.vue b/src/components/Windows/Settings/Appearance/SidebarElementVisibility.vue new file mode 100644 index 000000000..7ac1204dc --- /dev/null +++ b/src/components/Windows/Settings/Appearance/SidebarElementVisibility.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/components/Windows/Settings/Controls/ActionViewer/ActionViewer.ts b/src/components/Windows/Settings/Controls/ActionViewer/ActionViewer.ts deleted file mode 100644 index 749e1afdd..000000000 --- a/src/components/Windows/Settings/Controls/ActionViewer/ActionViewer.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Control } from '../Control' -import ActionViewerComponent from './ActionViewer.vue' -import { shallowReactive } from 'vue' -import { SimpleAction } from '/@/components/Actions/SimpleAction' - -export class ActionViewer extends Control { - config: any = shallowReactive({ category: 'actions', action: {} }) - - constructor(action: SimpleAction, category = 'actions') { - super( - ActionViewerComponent, - { - category, - action: {}, - description: action.description ?? 'No description provided', - key: action.id, - name: action.name ?? 'general.unknown', - }, - undefined - ) - this.config.category = category - this.config.action = action - } - - matches(filter: string) { - return ( - this.config.action.name.includes(filter) || - this.config.action.description?.includes(filter) - ) - } - onChange = async () => {} -} diff --git a/src/components/Windows/Settings/Controls/ActionViewer/ActionViewer.vue b/src/components/Windows/Settings/Controls/ActionViewer/ActionViewer.vue deleted file mode 100644 index 374cf9ac3..000000000 --- a/src/components/Windows/Settings/Controls/ActionViewer/ActionViewer.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/src/components/Windows/Settings/Controls/Button/Button.ts b/src/components/Windows/Settings/Controls/Button/Button.ts deleted file mode 100644 index b4334bfce..000000000 --- a/src/components/Windows/Settings/Controls/Button/Button.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Control, IControl } from '../Control' -import ButtonComponent from './Button.vue' -import Vue from 'vue' - -export class Button extends Control { - constructor(config: { - name: string - category: string - description: string - onClick: () => void - }) { - super(ButtonComponent, { ...config, key: 'N/A' }) - } - - matches(filter: string) { - return ( - this.config.name.includes(filter) || - this.config.description.includes(filter) - ) - } - onChange = async () => {} -} diff --git a/src/components/Windows/Settings/Controls/Button/Button.vue b/src/components/Windows/Settings/Controls/Button/Button.vue deleted file mode 100644 index b176138da..000000000 --- a/src/components/Windows/Settings/Controls/Button/Button.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/src/components/Windows/Settings/Controls/ButtonToggle/ButtonToggle.ts b/src/components/Windows/Settings/Controls/ButtonToggle/ButtonToggle.ts deleted file mode 100644 index 1e89ff4eb..000000000 --- a/src/components/Windows/Settings/Controls/ButtonToggle/ButtonToggle.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Control, IControl } from '../Control' -import ButtonToggleComponent from './ButtonToggle.vue' - -export interface IButtonToggle extends IControl { - options: string[] -} - -export class ButtonToggle extends Control { - constructor(config: IButtonToggle) { - super(ButtonToggleComponent, config) - } - - matches(filter: string) { - return ( - this.config.name.toLowerCase().includes(filter) || - this.config.description.toLowerCase().includes(filter) || - this.config.options.some((option) => - option.toLowerCase().includes(filter) - ) - ) - } -} diff --git a/src/components/Windows/Settings/Controls/ButtonToggle/ButtonToggle.vue b/src/components/Windows/Settings/Controls/ButtonToggle/ButtonToggle.vue deleted file mode 100644 index 122c1a75d..000000000 --- a/src/components/Windows/Settings/Controls/ButtonToggle/ButtonToggle.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/src/components/Windows/Settings/Controls/Control.ts b/src/components/Windows/Settings/Controls/Control.ts deleted file mode 100644 index a3207801a..000000000 --- a/src/components/Windows/Settings/Controls/Control.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { settingsState } from '../SettingsState' -import { set } from 'vue' - -export interface IControl { - omitFromSaveFile?: boolean - category: string - name: string - description: string - key: string - default?: T - onChange?: (value: T) => void | Promise - - [key: string]: any -} - -export abstract class Control = IControl> { - readonly component!: Vue.Component - readonly config!: K - - abstract matches(filter: string): void - protected rawValue?: T = undefined - - constructor( - component: Vue.Component, - control: K, - protected state = settingsState - ) { - set(this, 'config', control) - set(this, 'component', component) - - if (this.value === undefined && control.default !== undefined) - this.value = control.default - } - set value(value: T | undefined) { - if (this.config.omitFromSaveFile) { - this.rawValue = value - return - } - - if (this.state[this.config.category] === undefined) - set(this.state, this.config.category, {}) - set(this.state[this.config.category], this.config.key, value) - } - get value() { - if (this.config.omitFromSaveFile) return this.rawValue - return this.state[this.config.category]?.[this.config.key] - } - - onChange = async (value: T) => { - if (this.value === value) return - - await (this.value = value) - - if (typeof this.config.onChange === 'function') - await this.config.onChange(value) - } -} diff --git a/src/components/Windows/Settings/Controls/FontSelection.ts b/src/components/Windows/Settings/Controls/FontSelection.ts deleted file mode 100644 index 63650678b..000000000 --- a/src/components/Windows/Settings/Controls/FontSelection.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ISelectionControl, Selection } from './Selection/Selection' - -export class FontSelection extends Selection { - protected didLoadFonts = false - constructor(config: ISelectionControl) { - super(config) - - config.onClick = () => this.onClick() - - if (typeof navigator?.permissions?.query === 'function') - // Try to load fonts if permission was already granted - navigator.permissions - // @ts-ignore - .query({ name: 'local-fonts' }) - .then(({ state }) => { - if (state === 'granted') this.onClick() - }) - .catch(() => {}) - } - - async onClick() { - if (!window.queryLocalFonts || this.didLoadFonts) return - - await window - .queryLocalFonts() - .then((fonts) => { - this.didLoadFonts = true - - this.config.options.push( - ...fonts - .filter((font) => font.style === 'Regular') - .map((font) => ({ - text: font.fullName, - value: font.family, - })) - ) - }) - .catch((err) => console.error(err)) - } -} diff --git a/src/components/Windows/Settings/Controls/Selection/BridgeConfigSelection.ts b/src/components/Windows/Settings/Controls/Selection/BridgeConfigSelection.ts deleted file mode 100644 index d01e40b7e..000000000 --- a/src/components/Windows/Settings/Controls/Selection/BridgeConfigSelection.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { App } from '/@/App' -import { Selection } from './Selection' - -export class BridgeConfigSelection extends Selection | string> { - get value() { - return App.getApp().then( - (app) => (app.projectConfig.get().bridge)?.[this.config.key] - ) - } - set value(val) { - App.getApp().then(async (app) => { - if (!app.projectConfig.get().bridge) - app.projectConfig.get().bridge = {} - ;(app.projectConfig.get().bridge)[this.config.key] = val - await app.projectConfig.save() - }) - } -} diff --git a/src/components/Windows/Settings/Controls/Selection/Selection.ts b/src/components/Windows/Settings/Controls/Selection/Selection.ts deleted file mode 100644 index c8725e046..000000000 --- a/src/components/Windows/Settings/Controls/Selection/Selection.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Control, IControl } from '../Control' -import SelectionComponent from './Selection.vue' - -export interface ISelectionControl extends IControl { - options: (string | { text: string; value: string })[] - onClick?: () => Promise | void -} - -export class Selection extends Control> { - constructor(config: ISelectionControl) { - super(SelectionComponent, config) - } - - matches(filter: string) { - return ( - this.config.name.toLowerCase().includes(filter) || - this.config.description.toLowerCase().includes(filter) || - this.config.options.some((option) => - typeof option === 'string' - ? option.toLowerCase().includes(filter) - : option.text.toLowerCase().includes(filter) - ) - ) - } -} diff --git a/src/components/Windows/Settings/Controls/Selection/Selection.vue b/src/components/Windows/Settings/Controls/Selection/Selection.vue deleted file mode 100644 index b69f60bb9..000000000 --- a/src/components/Windows/Settings/Controls/Selection/Selection.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - diff --git a/src/components/Windows/Settings/Controls/Sidebar/Sidebar.ts b/src/components/Windows/Settings/Controls/Sidebar/Sidebar.ts deleted file mode 100644 index 04cb96c52..000000000 --- a/src/components/Windows/Settings/Controls/Sidebar/Sidebar.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Control, IControl } from '../Control' -import SidebarComponent from './Sidebar.vue' -import { App } from '/@/App' -import { translate } from '/@/components/Locales/Manager' - -export class Sidebar extends Control { - constructor(config: IControl) { - super(SidebarComponent, config) - } - - matches(filter: string) { - return Object.values(App.sidebar.elements).some((sidebar) => - translate(sidebar.displayName).includes(filter) - ) - } -} diff --git a/src/components/Windows/Settings/Controls/Sidebar/Sidebar.vue b/src/components/Windows/Settings/Controls/Sidebar/Sidebar.vue deleted file mode 100644 index 323cf9e03..000000000 --- a/src/components/Windows/Settings/Controls/Sidebar/Sidebar.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/src/components/Windows/Settings/Controls/TextField/TextField.ts b/src/components/Windows/Settings/Controls/TextField/TextField.ts deleted file mode 100644 index dce891416..000000000 --- a/src/components/Windows/Settings/Controls/TextField/TextField.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Control, IControl } from '../Control' -import TextFieldComponent from './TextField.vue' - -export class TextField extends Control> { - constructor(config: IControl) { - super(TextFieldComponent, config) - } - - matches(filter: string) { - return ( - this.config.name.toLowerCase().includes(filter) || - this.config.description.toLowerCase().includes(filter) - ) - } -} diff --git a/src/components/Windows/Settings/Controls/TextField/TextField.vue b/src/components/Windows/Settings/Controls/TextField/TextField.vue deleted file mode 100644 index f0cb7939c..000000000 --- a/src/components/Windows/Settings/Controls/TextField/TextField.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - diff --git a/src/components/Windows/Settings/Controls/Toggle/Toggle.ts b/src/components/Windows/Settings/Controls/Toggle/Toggle.ts deleted file mode 100644 index 6ee0ff9df..000000000 --- a/src/components/Windows/Settings/Controls/Toggle/Toggle.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Control, IControl } from '../Control' -import ToggleComponent from './Toggle.vue' - -export class Toggle extends Control { - constructor(config: IControl) { - super(ToggleComponent, config) - } - - matches(filter: string) { - return ( - this.config.name.toLowerCase().includes(filter) || - this.config.description.toLowerCase().includes(filter) - ) - } -} diff --git a/src/components/Windows/Settings/Controls/Toggle/Toggle.vue b/src/components/Windows/Settings/Controls/Toggle/Toggle.vue deleted file mode 100644 index fbf504419..000000000 --- a/src/components/Windows/Settings/Controls/Toggle/Toggle.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/src/components/Windows/Settings/Projects/OutputFolder.vue b/src/components/Windows/Settings/Projects/OutputFolder.vue new file mode 100644 index 000000000..460fc22e8 --- /dev/null +++ b/src/components/Windows/Settings/Projects/OutputFolder.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/components/Windows/Settings/Settings.vue b/src/components/Windows/Settings/Settings.vue new file mode 100644 index 000000000..bd0e29757 --- /dev/null +++ b/src/components/Windows/Settings/Settings.vue @@ -0,0 +1,209 @@ + + + diff --git a/src/components/Windows/Settings/SettingsSidebar.ts b/src/components/Windows/Settings/SettingsSidebar.ts deleted file mode 100644 index 58ef955d1..000000000 --- a/src/components/Windows/Settings/SettingsSidebar.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Sidebar } from '/@/components/Windows/Layout/Sidebar' -import { Control } from './Controls/Control' - -export class SettingsSidebar extends Sidebar { - protected lastFilter!: string - - get elements() { - let selectSidebar: string | undefined = undefined - - const elements = this._elements.value.filter((element) => { - if (element.type === 'category') return true - - const controls = []>this.getState(element.id) - const hasControl = controls.some((control) => - control.matches(this.filter) - ) - if (!selectSidebar && hasControl) selectSidebar = element.id - return hasControl - }) - - if ( - selectSidebar && - this.lastFilter !== this.filter && - this.currentState.length === 0 - ) - this.setDefaultSelected(selectSidebar) - this.lastFilter = this.filter - return this.sortSidebar(elements) - } - - get currentState() { - if (!this.selected) return [] - return ([]>this.state[this.selected] ?? []).filter( - (control) => control.matches(this.filter) - ) - } -} diff --git a/src/components/Windows/Settings/SettingsState.ts b/src/components/Windows/Settings/SettingsState.ts deleted file mode 100644 index 41d458b82..000000000 --- a/src/components/Windows/Settings/SettingsState.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { reactive, set } from 'vue' - -export let settingsState: Record> = reactive({ - sidebar: {}, -}) - -export function setSettingsState( - state: Record> -) { - for (const key in state) { - set(settingsState, key, state[key]) - } -} diff --git a/src/components/Windows/Settings/SettingsWindow.ts b/src/components/Windows/Settings/SettingsWindow.ts index 0ce86905d..e1dce5d80 100644 --- a/src/components/Windows/Settings/SettingsWindow.ts +++ b/src/components/Windows/Settings/SettingsWindow.ts @@ -1,139 +1,466 @@ -import { SidebarCategory, SidebarItem } from '../Layout/Sidebar' -import { Control } from './Controls/Control' -import SettingsWindowComponent from './SettingsWindow.vue' -import { setupSettings } from './setupSettings' -import { App } from '/@/App' -import { SettingsSidebar } from './SettingsSidebar' -import { setSettingsState, settingsState } from './SettingsState' -import { Signal } from '/@/components/Common/Event/Signal' -import { translate } from '../../Locales/Manager' -import { NewBaseWindow } from '../NewBaseWindow' -import { reactive } from 'vue' - -export class SettingsWindow extends NewBaseWindow { - public static readonly loadedSettings = new Signal() - - protected sidebar = new SettingsSidebar([]) - protected bridgeCategory = new SidebarCategory({ - items: [], - text: 'bridge.', - }) - - protected state = reactive({ - ...super.getState(), - reloadRequired: false, - }) - - constructor(public parent: App) { - super(SettingsWindowComponent, false, true) - this.defineWindow() - } +import OutputFolder from './Projects/OutputFolder.vue' +import ActionsList from './Actions/ActionsList.vue' +import Settings from './Settings.vue' +import SidebarElementVisibility from './Appearance/SidebarElementVisibility.vue' - async setup() { - // this.sidebar.addElement(this.bridgeCategory) - - this.addCategory( - 'general', - translate('windows.settings.general.name'), - 'mdi-circle-outline' - ) - this.addCategory( - 'appearance', - translate('windows.settings.appearance.name'), - 'mdi-palette-outline' - ) - this.addCategory('editor', 'Editor', 'mdi-pencil-outline') - this.addCategory( - 'actions', - translate('windows.settings.actions.name'), - 'mdi-keyboard-outline' - ) - // this.addCategory('extensions', 'Extensions', 'mdi-puzzle-outline') - this.addCategory( - 'sidebar', - translate('windows.settings.sidebar.name'), - 'mdi-table-column' - ) - this.addCategory( - 'developers', - translate('windows.settings.developer.name'), - 'mdi-wrench-outline' - ) - this.addCategory( - 'projects', - translate('windows.settings.projects.name'), - 'mdi-folder-open-outline' - ) - - await setupSettings(this) - this.sidebar.setDefaultSelected() - } +import { ComputedRef, Ref, computed, ref } from 'vue' +import { Windows } from '@/components/Windows/Windows' +import { Window } from '@/components/Windows/Window' +import { ThemeManager } from '@/libs/theme/ThemeManager' +import { LocaleManager } from '@/libs/locales/Locales' +import { CompletionItem } from '@/libs/jsonSchema/Schema' - addCategory(id: string, name: string, icon: string) { - if (settingsState[id] === undefined) settingsState[id] = {} - - this.sidebar.addElement( - new SidebarItem({ - color: 'primary', - text: name, - icon, - id, - }), - [] - ) - } +interface Category { + label: string + icon: string +} - addControl(control: Control) { - const category = []>( - this.sidebar.state[control.config.category] - ) - if (!category) - throw new Error( - `Undefined settings category: ${control.config.category}` - ) +type Item = CustomItem | DropdownItem | AutocompleteItem | ToggleItem | TabItem | TextItem | LabelItem - category.push(control) +export interface CustomItem { + type: 'custom' + label?: string + component: any +} + +export interface DropdownItem { + type: 'dropdown' + label: string + description: string + values: ComputedRef + labels: ComputedRef +} + +export interface AutocompleteItem { + type: 'autocomplete' + label: string + description: string + completions: ComputedRef +} + +export interface TextItem { + type: 'text' + label: string + description: string +} + +export interface ToggleItem { + type: 'toggle' + label: string + description: string +} + +export interface TabItem { + type: 'tab' + label: string + description: string + values: string[] + labels: string[] +} + +export interface LabelItem { + type: 'label' + label: string + description?: string +} + +export class SettingsWindow extends Window { + public static id = 'settings' + public static component = Settings + + public static categories: Record = {} + public static items: Record> = {} + + public static selectedCategory: Ref = ref('projects') + + public static setup() { + setupProjectsCategory() + setupGeneralCategory() + setupActionsCategory() + setupAppearanceCategory() + setupEditorCategory() + setupDeveloperCategory() } - static async saveSettings(app?: App) { - if (!app) app = await App.getApp() + public static open(categoryId?: string) { + Windows.open(SettingsWindow) - await app.fileSystem.writeJSON( - '~local/data/settings.json', - settingsState - ) + if (!categoryId) return + + SettingsWindow.selectedCategory.value = categoryId } - static async loadSettings(app: App) { - try { - setSettingsState( - await app.fileSystem.readJSON('~local/data/settings.json') - ) - } catch { - } finally { - this.loadedSettings.dispatch(settingsState) - } + + public static addCategory(id: string, category: Category) { + SettingsWindow.categories[id] = category + + SettingsWindow.items[id] = {} } - addReloadHint() { - this.state.reloadRequired = true + public static addItem(category: string, id: string, item: T) { + SettingsWindow.items[category][id] = item } +} - async open() { - if (this.state.isVisible) return +function setupProjectsCategory() { + SettingsWindow.addCategory('projects', { + label: 'windows.settings.projects.name', + icon: 'folder', + }) - this.sidebar.removeElements() - await this.setup() + SettingsWindow.addItem('projects', 'outputFolderLabel', { + type: 'label', + label: 'Output Folder', + }) - super.open() - } + SettingsWindow.addItem('projects', 'outputFolder', { + type: 'custom', + component: OutputFolder, + }) - async close() { - super.close() + SettingsWindow.addItem('projects', 'defaultLabel', { + type: 'label', + label: 'Project Defaults', + }) - const app = await App.getApp() + SettingsWindow.addItem('projects', 'defaultAuthor', { + type: 'text', + label: 'windows.settings.projects.defaultAuthor.name', + description: 'windows.settings.projects.defaultAuthor.description', + }) - app.windows.loadingWindow.open() - await SettingsWindow.saveSettings() - app.windows.loadingWindow.close() - } + SettingsWindow.addItem('projects', 'defaultNamespace', { + type: 'text', + label: 'windows.settings.projects.defaultNamespace.name', + description: 'windows.settings.projects.defaultNamespace.description', + }) + + SettingsWindow.addItem('projects', 'exportLabel', { + type: 'label', + label: 'Export Settings', + }) + + SettingsWindow.addItem('projects', 'incrementVersionOnExport', { + type: 'toggle', + label: 'windows.settings.projects.incrementVersionOnExport.name', + description: 'windows.settings.projects.incrementVersionOnExport.description', + }) + + SettingsWindow.addItem('projects', 'addGeneratedWith', { + type: 'toggle', + label: 'windows.settings.projects.addGeneratedWith.name', + description: 'windows.settings.projects.addGeneratedWith.description', + }) +} + +function setupGeneralCategory() { + SettingsWindow.addCategory('general', { + label: 'windows.settings.general.name', + icon: 'circle', + }) + + SettingsWindow.addItem('general', 'languageLabel', { + type: 'label', + label: 'Language Settings', + }) + + SettingsWindow.addItem('general', 'language', { + type: 'dropdown', + label: 'windows.settings.general.language.name', + description: 'windows.settings.general.language.description', + values: computed(() => LocaleManager.getAvailableLanguages().map((language) => language.text)), + labels: computed(() => LocaleManager.getAvailableLanguages().map((language) => language.text)), + }) + + SettingsWindow.addItem('general', 'tabLabel', { + type: 'label', + label: 'Tab Settings', + }) + + SettingsWindow.addItem('general', 'restoreTabs', { + type: 'toggle', + label: 'windows.settings.general.restoreTabs.name', + description: 'windows.settings.general.restoreTabs.description', + }) + + SettingsWindow.addItem('general', 'keepTabsOpen', { + type: 'toggle', + label: 'windows.settings.general.keepTabsOpen.name', + description: 'windows.settings.general.keepTabsOpen.description', + }) +} + +function setupActionsCategory() { + SettingsWindow.addCategory('actions', { + label: 'windows.settings.actions.name', + icon: 'keyboard', + }) + + SettingsWindow.addItem('actions', 'actionsList', { + type: 'custom', + component: ActionsList, + }) +} + +function setupAppearanceCategory() { + SettingsWindow.addCategory('appearance', { + label: 'windows.settings.appearance.name', + icon: 'palette', + }) + + SettingsWindow.addItem('appearance', 'themeLabel', { + type: 'label', + label: 'Theme Settings', + }) + + SettingsWindow.addItem('appearance', 'colorScheme', { + type: 'tab', + label: 'windows.settings.appearance.colorScheme.name', + description: 'windows.settings.appearance.colorScheme.description', + labels: ['Auto', 'Dark', 'Light'], + values: ['auto', 'dark', 'light'], + }) + + const themes = ThemeManager.useThemesImmediate() + + const darkThemes = computed(() => themes.value.filter((theme) => theme.colorScheme === 'dark')) + const lightThemes = computed(() => themes.value.filter((theme) => theme.colorScheme === 'light')) + + SettingsWindow.addItem('appearance', 'darkTheme', { + type: 'dropdown', + label: 'windows.settings.appearance.darkTheme.name', + description: 'windows.settings.appearance.darkTheme.description', + values: computed(() => darkThemes.value.map((theme) => theme.id)), + labels: computed(() => darkThemes.value.map((theme) => theme.name)), + }) + + SettingsWindow.addItem('appearance', 'lightTheme', { + type: 'dropdown', + label: 'windows.settings.appearance.lightTheme.name', + description: 'windows.settings.appearance.lightTheme.description', + values: computed(() => lightThemes.value.map((theme) => theme.id)), + labels: computed(() => lightThemes.value.map((theme) => theme.name)), + }) + + SettingsWindow.addItem('appearance', 'fontLabel', { + type: 'label', + label: 'Font Settings', + }) + + SettingsWindow.addItem('appearance', 'font', { + type: 'dropdown', + label: 'windows.settings.appearance.font.name', + description: 'windows.settings.appearance.font.description', + values: computed(() => [ + 'Inter', + 'Roboto', + 'Arial', + 'Verdana', + 'Helvetica', + 'Tahome', + 'Trebuchet MS', + 'Menlo', + 'Monaco', + 'Courier New', + 'monospace', + ]), + labels: computed(() => [ + 'Inter', + 'Roboto', + 'Arial', + 'Verdana', + 'Helvetica', + 'Tahome', + 'Trebuchet MS', + 'Menlo', + 'Monaco', + 'Courier New', + 'Monospace', + ]), + }) + + SettingsWindow.addItem('appearance', 'editorFont', { + type: 'dropdown', + label: 'windows.settings.appearance.editorFont.name', + description: 'windows.settings.appearance.editorFont.description', + values: computed(() => ['Roboto', 'Arial', 'Consolas', 'Menlo', 'Monaco', '"Courier New"', 'monospace']), + labels: computed(() => ['Roboto', 'Arial', 'Consolas', 'Menlo', 'Monaco', '"Courier New"', 'Monospace']), + }) + + SettingsWindow.addItem('appearance', 'editorFontSize', { + type: 'autocomplete', + label: 'windows.settings.appearance.editorFontSize.name', + description: 'windows.settings.appearance.editorFontSize.description', + completions: computed(() => + [8, 10, 12, 14, 16, 18, 20].map((value) => ({ + type: 'value', + label: value.toString(), + value, + })) + ), + }) + + SettingsWindow.addItem('appearance', 'compactTabDesign', { + type: 'toggle', + label: 'windows.settings.editor.compactTabDesign.name', + description: 'windows.settings.editor.compactTabDesign.description', + }) + + SettingsWindow.addItem('appearance', 'sideBarLabel', { + type: 'label', + label: 'Sidebar Settings', + }) + + SettingsWindow.addItem('appearance', 'sidebarRight', { + type: 'toggle', + label: 'windows.settings.sidebar.sidebarRight.name', + description: 'windows.settings.sidebar.sidebarRight.description', + }) + + SettingsWindow.addItem('appearance', 'sidebarSize', { + type: 'tab', + label: 'windows.settings.sidebar.sidebarSize.name', + description: 'windows.settings.sidebar.sidebarSize.description', + labels: ['Small', 'Normal', 'Large', 'X-Large'], + values: ['small', 'normal', 'large', 'x-large'], + }) + + SettingsWindow.addItem('appearance', 'sidebarItemVisibility', { + type: 'custom', + label: 'windows.settings.appearance.sidebarElementVisibility.name', + component: SidebarElementVisibility, + }) + + SettingsWindow.addItem('appearance', 'otherAppearanceLabel', { + type: 'label', + label: 'Other Settings', + }) + + SettingsWindow.addItem('appearance', 'fileExplorerIndentation', { + type: 'tab', + label: 'windows.settings.appearance.fileExplorerIndentation.name', + description: 'windows.settings.appearance.fileExplorerIndentation.description', + labels: ['Small', 'Normal', 'Large', 'X-Large'], + values: ['small', 'normal', 'large', 'x-large'], + }) +} + +function setupEditorCategory() { + SettingsWindow.addCategory('editor', { + label: 'windows.settings.editor.name', + icon: 'edit', + }) + + SettingsWindow.addItem('editor', 'generalEditorLabel', { + type: 'label', + label: 'General Editor Settings', + }) + + SettingsWindow.addItem('editor', 'jsonEditor', { + type: 'dropdown', + label: 'windows.settings.editor.jsonEditor.name', + description: 'windows.settings.editor.jsonEditor.description', + labels: computed(() => ['Raw Text Editor', 'Tree Editor']), + values: computed(() => ['text', 'tree']), + }) + + SettingsWindow.addItem('editor', 'autoSaveChanges', { + type: 'toggle', + label: 'windows.settings.editor.autoSaveChanges.name', + description: 'windows.settings.editor.autoSaveChanges.description', + }) + + SettingsWindow.addItem('editor', 'textEditorLabel', { + type: 'label', + label: 'Text Editor Settings', + }) + + SettingsWindow.addItem('editor', 'formatOnSave', { + type: 'toggle', + label: 'windows.settings.editor.formatOnSave.name', + description: 'windows.settings.editor.formatOnSave.description', + }) + + SettingsWindow.addItem('editor', 'bracketPairColorization', { + type: 'toggle', + label: 'windows.settings.editor.bracketPairColorization.name', + description: 'windows.settings.editor.bracketPairColorization.description', + }) + + SettingsWindow.addItem('editor', 'wordWrap', { + type: 'toggle', + label: 'windows.settings.editor.wordWrap.name', + description: 'windows.settings.editor.wordWrap.description', + }) + + SettingsWindow.addItem('editor', 'wordWrapColumns', { + type: 'autocomplete', + label: 'windows.settings.editor.wordWrapColumns.name', + description: 'windows.settings.editor.wordWrapColumns.description', + completions: computed(() => + [40, 60, 80, 100, 120, 160].map((value) => ({ + type: 'value', + label: value.toString(), + value, + })) + ), + }) + + SettingsWindow.addItem('editor', 'treeLabel', { + type: 'label', + label: 'Tree Editor Settings', + }) + + SettingsWindow.addItem('editor', 'bridgePredictions', { + type: 'toggle', + label: 'windows.settings.editor.bridgePredictions.name', + description: 'windows.settings.editor.bridgePredictions.description', + }) + + SettingsWindow.addItem('editor', 'inlineDiagnostics', { + type: 'toggle', + label: 'windows.settings.editor.inlineTreeEditorDiagnostics.name', + description: 'windows.settings.editor.inlineTreeEditorDiagnostics.description', + }) + + SettingsWindow.addItem('editor', 'showArrayIndices', { + type: 'toggle', + label: 'windows.settings.editor.showArrayIndices.name', + description: 'windows.settings.editor.showArrayIndices.description', + }) + + SettingsWindow.addItem('editor', 'hideBrackets', { + type: 'toggle', + label: 'windows.settings.editor.hideBrackets.name', + description: 'windows.settings.editor.hideBrackets.description', + }) + + SettingsWindow.addItem('editor', 'automaticallyOpenTreeNodes', { + type: 'toggle', + label: 'windows.settings.editor.automaticallyOpenTreeNodes.name', + description: 'windows.settings.editor.automaticallyOpenTreeNodes.description', + }) + + SettingsWindow.addItem('editor', 'dragAndDropTreeNodes', { + type: 'toggle', + label: 'windows.settings.editor.dragAndDropTreeNodes.name', + description: 'windows.settings.editor.dragAndDropTreeNodes.description', + }) +} + +function setupDeveloperCategory() { + SettingsWindow.addCategory('developer', { + label: 'windows.settings.developer.name', + icon: 'code', + }) + + SettingsWindow.addItem('developer', 'developerLabel', { + type: 'label', + label: 'Developer Settings', + }) + + SettingsWindow.addItem('developer', 'dataDeveloperMode', { + type: 'toggle', + label: 'windows.settings.developer.dataDeveloperMode.name', + description: 'windows.settings.developer.dataDeveloperMode.description', + }) } diff --git a/src/components/Windows/Settings/SettingsWindow.vue b/src/components/Windows/Settings/SettingsWindow.vue deleted file mode 100644 index 2b742e51c..000000000 --- a/src/components/Windows/Settings/SettingsWindow.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - diff --git a/src/components/Windows/Settings/setupSettings.ts b/src/components/Windows/Settings/setupSettings.ts deleted file mode 100644 index 006a21266..000000000 --- a/src/components/Windows/Settings/setupSettings.ts +++ /dev/null @@ -1,601 +0,0 @@ -import { App } from '/@/App' -import { ButtonToggle } from './Controls/ButtonToggle/ButtonToggle' -import { Toggle } from './Controls/Toggle/Toggle' -import { SettingsWindow } from './SettingsWindow' -import { ActionViewer } from './Controls/ActionViewer/ActionViewer' -import { Selection } from './Controls/Selection/Selection' -import { BridgeConfigSelection } from './Controls/Selection/BridgeConfigSelection' -import { Button } from './Controls/Button/Button' -import { del, get, set } from 'idb-keyval' -import { comMojangKey } from '/@/components/OutputFolders/ComMojang/ComMojang' -import { Sidebar } from './Controls/Sidebar/Sidebar' -import { - isUsingFileSystemPolyfill, - isUsingOriginPrivateFs, -} from '/@/components/FileSystem/Polyfill' -import { platform } from '/@/utils/os' -import { TextField } from './Controls/TextField/TextField' -import { devActions } from '/@/components/Developer/Actions' -import { FontSelection } from './Controls/FontSelection' -import { LocaleManager } from '../../Locales/Manager' -import { showFolderPicker } from '../../FileSystem/Pickers/showFolderPicker' -import { pathFromHandle } from '../../FileSystem/Virtual/pathFromHandle' - -export async function setupSettings(settings: SettingsWindow) { - const app = await App.getApp() - - settings.addControl( - new ButtonToggle({ - category: 'appearance', - name: 'windows.settings.appearance.colorScheme.name', - description: 'windows.settings.appearance.colorScheme.description', - key: 'colorScheme', - options: ['auto', 'dark', 'light'], - default: 'auto', - onChange: () => { - app.themeManager.updateTheme() - }, - }) - ) - settings.addControl( - new Toggle({ - category: 'appearance', - name: 'windows.settings.appearance.highContrast.name', - description: 'windows.settings.appearance.highContrast.description', - key: 'highContrast', - default: false, - }) - ) - settings.addControl( - new Selection({ - category: 'appearance', - name: 'windows.settings.appearance.darkTheme.name', - description: 'windows.settings.appearance.darkTheme.description', - key: 'darkTheme', - get options() { - return settings.parent.themeManager - .getThemes('dark') - .map((theme) => ({ text: theme.name, value: theme.id })) - }, - default: 'bridge.default.dark', - onChange: () => { - app.themeManager.updateTheme() - }, - }) - ) - settings.addControl( - new Selection({ - category: 'appearance', - name: 'windows.settings.appearance.lightTheme.name', - description: 'windows.settings.appearance.lightTheme.description', - key: 'lightTheme', - get options() { - return settings.parent.themeManager - .getThemes('light') - .map((theme) => ({ text: theme.name, value: theme.id })) - }, - default: 'bridge.default.light', - onChange: () => { - app.themeManager.updateTheme() - }, - }) - ) - - if (!app.isNoProjectSelected) { - settings.addControl( - new BridgeConfigSelection({ - category: 'appearance', - name: 'windows.settings.appearance.localDarkTheme.name', - description: - 'windows.settings.appearance.localDarkTheme.description', - key: 'darkTheme', - get options() { - return settings.parent.themeManager - .getThemes('dark', false) - .map((theme) => ({ text: theme.name, value: theme.id })) - .concat([{ text: 'None', value: 'bridge.noSelection' }]) - }, - default: 'bridge.noSelection', - onChange: () => { - app.themeManager.updateTheme() - }, - }) - ) - settings.addControl( - new BridgeConfigSelection({ - category: 'appearance', - name: 'windows.settings.appearance.localLightTheme.name', - description: - 'windows.settings.appearance.localLightTheme.description', - key: 'lightTheme', - get options() { - return settings.parent.themeManager - .getThemes('light', false) - .map((theme) => ({ text: theme.name, value: theme.id })) - .concat([{ text: 'None', value: 'bridge.noSelection' }]) - }, - default: 'bridge.noSelection', - onChange: () => { - app.themeManager.updateTheme() - }, - }) - ) - } - - settings.addControl( - new FontSelection({ - category: 'appearance', - name: 'windows.settings.appearance.font.name', - description: 'windows.settings.appearance.font.description', - key: 'font', - default: 'Roboto', - options: [ - 'Roboto', - 'Arial', - 'Verdana', - 'Helvetica', - 'Tahome', - 'Trebuchet MS', - 'Menlo', - 'Monaco', - 'Courier New', - 'monospace', - ], - }) - ) - settings.addControl( - new FontSelection({ - category: 'appearance', - name: 'windows.settings.appearance.editorFont.name', - description: 'windows.settings.appearance.editorFont.description', - key: 'editorFont', - default: platform() === 'darwin' ? 'Menlo' : 'Consolas', - options: [ - 'Roboto', - 'Arial', - 'Consolas', - 'Menlo', - 'Monaco', - '"Courier New"', - 'monospace', - ], - onChange: async (val) => { - app.projectManager.updateAllEditorOptions({ - fontFamily: val, - }) - }, - }) - ) - settings.addControl( - new Selection({ - category: 'appearance', - name: 'windows.settings.appearance.editorFontSize.name', - description: - 'windows.settings.appearance.editorFontSize.description', - key: 'editorFontSize', - default: '14px', - options: ['8px', '10px', '12px', '14px', '16px', '18px', '20px'], - onChange: async (val) => { - app.projectManager.updateAllEditorOptions({ - fontSize: Number(val.replace('px', '')), - }) - }, - }) - ) - settings.addControl( - new Toggle({ - category: 'appearance', - name: 'windows.settings.appearance.hideToolbarItems.name', - description: - 'windows.settings.appearance.hideToolbarItems.description', - key: 'hideToolbarItems', - default: false, - }) - ) - settings.addControl( - new Toggle({ - category: 'sidebar', - name: 'windows.settings.sidebar.sidebarRight.name', - description: 'windows.settings.sidebar.sidebarRight.description', - key: 'isSidebarRight', - default: false, - }) - ) - settings.addControl( - new Toggle({ - category: 'sidebar', - name: 'windows.settings.sidebar.shrinkSidebarElements.name', - description: - 'windows.settings.sidebar.shrinkSidebarElements.description', - key: 'smallerSidebarElements', - default: false, - }) - ) - settings.addControl( - new ButtonToggle({ - category: 'sidebar', - name: 'windows.settings.sidebar.sidebarSize.name', - description: 'windows.settings.sidebar.sidebarSize.description', - key: 'sidebarSize', - options: ['tiny', 'small', 'normal', 'large'], - default: 'normal', - onChange: () => { - app.windowResize.dispatch() - }, - }) - ) - settings.addControl( - new ButtonToggle({ - category: 'sidebar', - name: 'windows.settings.sidebar.packExplorerFolderIndentation.name', - description: - 'windows.settings.sidebar.packExplorerFolderIndentation.description', - key: 'packExplorerFolderIndentation', - options: ['small', 'normal', 'large', 'x-large'], - default: 'normal', - }) - ) - settings.addControl( - new Sidebar({ - category: 'sidebar', - name: 'windows.settings.sidebar.shrinkSidebarElements.name', - description: - 'windows.settings.sidebar.shrinkSidebarElements.description', - key: 'hideElements', - }) - ) - settings.addControl( - new Selection({ - omitFromSaveFile: true, - category: 'general', - name: 'windows.settings.general.language.name', - description: 'windows.settings.general.language.description', - key: 'locale', - options: LocaleManager.getAvailableLanguages(), - default: LocaleManager.getCurrentLanguageId(), - onChange: (val) => { - settings.addReloadHint() - set('language', val) - }, - }) - ) - - settings.addControl( - new Toggle({ - category: 'general', - name: 'windows.settings.general.collaborativeMode.name', - description: - 'windows.settings.general.collaborativeMode.description', - key: 'fullLightningCacheRefresh', - default: true, - }) - ) - // TODO(Dash): Re-enable pack spider - // settings.addControl( - // new Toggle({ - // category: 'general', - // name: 'windows.settings.general.packSpider.name', - // description: 'windows.settings.general.packSpider.description', - // key: 'enablePackSpider', - // default: false, - // }) - // ) - - settings.addControl( - new Toggle({ - category: 'general', - name: 'windows.settings.general.formatOnSave.name', - description: 'windows.settings.general.formatOnSave.description', - key: 'formatOnSave', - default: true, - }) - ) - - settings.addControl( - new Toggle({ - category: 'general', - name: 'windows.settings.general.openLinksInBrowser.name', - description: - 'windows.settings.general.openLinksInBrowser.description', - key: 'openLinksInBrowser', - default: false, - }) - ) - settings.addControl( - new Toggle({ - category: 'general', - name: 'windows.settings.general.restoreTabs.name', - description: 'windows.settings.general.restoreTabs.description', - key: 'restoreTabs', - default: true, - }) - ) - if (import.meta.env.VITE_IS_TAURI_APP || !isUsingFileSystemPolyfill.value) { - settings.addControl( - new Button({ - category: 'general', - name: 'windows.settings.general.selectBridgeFolder.name', - description: - 'windows.settings.general.selectBridgeFolder.description', - onClick: async () => { - if (import.meta.env.VITE_IS_TAURI_APP) { - // Native app - const [folderHandle] = - (await showFolderPicker({ multiple: false })) ?? [] - if (!folderHandle) return - - const folderPath = await pathFromHandle(folderHandle) - set('bridgeFolderPath', folderPath) - - settings.addReloadHint() - } else { - // PWA - const app = await App.getApp() - - await app.setupBridgeFolder(true) - } - }, - }) - ) - } - // Only show reset bridge folder on native app if a different bridge folder is set - if ( - import.meta.env.VITE_IS_TAURI_APP && - (await get('bridgeFolderPath')) !== undefined - ) { - settings.addControl( - new Button({ - category: 'general', - name: 'windows.settings.general.resetBridgeFolder.name', - description: - 'windows.settings.general.resetBridgeFolder.description', - onClick: async () => { - set('bridgeFolderPath', undefined) - settings.addReloadHint() - }, - }) - ) - } - - // Editor - settings.addControl( - new Selection({ - category: 'editor', - name: 'windows.settings.editor.jsonEditor.name', - description: 'windows.settings.editor.jsonEditor.description', - key: 'jsonEditor', - options: [ - { text: 'Tree Editor', value: 'treeEditor' }, - { text: 'Raw Text', value: 'rawText' }, - ], - default: 'rawText', - }) - ) - - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.bracketPairColorization.name', - description: - 'windows.settings.editor.bracketPairColorization.description', - key: 'bracketPairColorization', - default: false, - onChange: async (val) => { - app.projectManager.updateAllEditorOptions({ - // @ts-expect-error The monaco team did not update the types yet - 'bracketPairColorization.enabled': val, - }) - }, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.wordWrap.name', - description: 'windows.settings.editor.wordWrap.description', - key: 'wordWrap', - default: false, - onChange: async (val) => { - app.projectManager.updateAllEditorOptions({ - wordWrap: val ? 'bounded' : 'off', - }) - }, - }) - ) - settings.addControl( - new Selection({ - category: 'editor', - name: 'windows.settings.editor.wordWrapColumns.name', - description: 'windows.settings.editor.wordWrapColumns.description', - key: 'wordWrapColumns', - default: '80', - options: ['40', '60', '80', '100', '120', '140', '160'], - onChange: async (val) => { - app.projectManager.updateAllEditorOptions({ - wordWrapColumn: Number(val), - }) - }, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.compactTabDesign.name', - description: 'windows.settings.editor.compactTabDesign.description', - key: 'compactTabDesign', - default: true, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.keepTabsOpen.name', - description: 'windows.settings.editor.keepTabsOpen.description', - key: 'keepTabsOpen', - default: false, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.autoSaveChanges.name', - description: 'windows.settings.editor.autoSaveChanges.description', - key: 'autoSaveChanges', - default: app.mobile.isCurrentDevice(), // Auto save should be on by default on mobile - }) - ) - - // Tree Editor specific settings - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.showTreeEditorLocationBar.name', - description: - 'windows.settings.editor.showTreeEditorLocationBar.description', - key: 'showTreeEditorLocationBar', - default: true, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.bridgePredictions.name', - description: - 'windows.settings.editor.bridgePredictions.description', - key: 'bridgePredictions', - default: true, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.inlineTreeEditorDiagnostics.name', - description: - 'windows.settings.editor.inlineTreeEditorDiagnostics.description', - key: 'inlineTreeEditorDiagnostics', - default: true, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.automaticallyOpenTreeNodes.name', - description: - 'windows.settings.editor.automaticallyOpenTreeNodes.description', - key: 'automaticallyOpenTreeNodes', - default: true, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.dragAndDropTreeNodes.name', - description: - 'windows.settings.editor.dragAndDropTreeNodes.description', - key: 'dragAndDropTreeNodes', - default: true, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.showArrayIndices.name', - description: 'windows.settings.editor.showArrayIndices.description', - key: 'showArrayIndices', - default: false, - }) - ) - settings.addControl( - new Toggle({ - category: 'editor', - name: 'windows.settings.editor.hideBracketsWithinTreeEditor.name', - description: - 'windows.settings.editor.hideBracketsWithinTreeEditor.description', - key: 'hideBracketsWithinTreeEditor', - default: false, - }) - ) - - // Projects - settings.addControl( - new TextField({ - category: 'projects', - name: 'windows.settings.projects.defaultAuthor.name', - description: 'windows.settings.projects.defaultAuthor.description', - key: 'defaultAuthor', - default: '', - }) - ) - settings.addControl( - new Toggle({ - category: 'projects', - name: 'windows.settings.projects.loadComMojangProjects.name', - description: - 'windows.settings.projects.loadComMojangProjects.description', - key: 'loadComMojangProjects', - default: true, - }) - ) - settings.addControl( - new Toggle({ - category: 'projects', - name: 'windows.settings.projects.incrementVersionOnExport.name', - description: - 'windows.settings.projects.incrementVersionOnExport.description', - key: 'incrementVersionOnExport', - default: isUsingOriginPrivateFs || isUsingFileSystemPolyfill.value, - }) - ) - settings.addControl( - new Toggle({ - category: 'projects', - name: 'windows.settings.projects.addGeneratedWith.name', - description: - 'windows.settings.projects.addGeneratedWith.description', - key: 'addGeneratedWith', - default: true, - }) - ) - - // Actions - Object.values(app.actionManager.state).forEach((action) => { - if (action.type === 'action') - settings.addControl(new ActionViewer(action)) - }) - - if (import.meta.env.DEV) { - settings.addControl( - new ButtonToggle({ - category: 'developers', - name: 'windows.settings.developer.simulateOS.name', - description: - 'windows.settings.developer.simulateOS.description', - key: 'simulateOS', - options: ['auto', 'win32', 'darwin', 'linux'], - default: 'auto', - }) - ) - settings.addControl( - new Toggle({ - category: 'developers', - name: 'windows.settings.developer.devMode.name', - description: 'windows.settings.developer.devMode.description', - key: 'isDevMode', - }) - ) - settings.addControl( - new Toggle({ - category: 'developers', - name: 'windows.settings.developer.forceDataDownload.name', - description: - 'windows.settings.developer.forceDataDownload.description', - key: 'forceDataDownload', - default: false, - }) - ) - - devActions.forEach((action) => { - settings.addControl(new ActionViewer(action, 'developers')) - }) - } -} diff --git a/src/components/Windows/SidebarWindow.vue b/src/components/Windows/SidebarWindow.vue new file mode 100644 index 000000000..69cbd5c19 --- /dev/null +++ b/src/components/Windows/SidebarWindow.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/components/Windows/Socials/Main.vue b/src/components/Windows/Socials/Main.vue deleted file mode 100644 index 26406f8e7..000000000 --- a/src/components/Windows/Socials/Main.vue +++ /dev/null @@ -1,62 +0,0 @@ - - - diff --git a/src/components/Windows/Socials/Socials.vue b/src/components/Windows/Socials/Socials.vue new file mode 100644 index 000000000..742550a85 --- /dev/null +++ b/src/components/Windows/Socials/Socials.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/Windows/Socials/SocialsWindow.ts b/src/components/Windows/Socials/SocialsWindow.ts index 3a2eef205..fb7f37cd7 100644 --- a/src/components/Windows/Socials/SocialsWindow.ts +++ b/src/components/Windows/Socials/SocialsWindow.ts @@ -1,9 +1,7 @@ -import { NewBaseWindow } from '../NewBaseWindow' -import SocialsComponent from './Main.vue' +import { Window } from '../Window' +import Socials from './Socials.vue' -export class SocialsWindow extends NewBaseWindow { - constructor() { - super(SocialsComponent) - this.defineWindow() - } +export class SocialsWindow extends Window { + public static id = 'socials' + public static component = Socials } diff --git a/src/components/Windows/UnsavedFile/UnsavedFile.ts b/src/components/Windows/UnsavedFile/UnsavedFile.ts deleted file mode 100644 index 150160563..000000000 --- a/src/components/Windows/UnsavedFile/UnsavedFile.ts +++ /dev/null @@ -1,43 +0,0 @@ -import UnsavedFileComponent from './UnsavedFile.vue' -import { App } from '/@/App' -import { Tab } from '/@/components/TabSystem/CommonTab' -import { NewBaseWindow } from '../NewBaseWindow' -import { FileTab } from '../../TabSystem/FileTab' - -const tabs = new WeakMap() - -export class UnsavedFileWindow extends NewBaseWindow< - 'cancel' | 'close' | 'save' -> { - protected canSaveTab = false - - constructor(tab: Tab) { - super(UnsavedFileComponent, true, false) - tabs.set(this, tab) - this.canSaveTab = tab instanceof FileTab - - this.defineWindow() - this.open() - } - - get tab() { - return tabs.get(this) - } - - async noSave() { - this.close('close') - - const app = await App.getApp() - await app.tabSystem?.close(this.tab, false) - } - async save() { - this.close('save') - - const app = await App.getApp() - await app.tabSystem?.save(this.tab) - await app.tabSystem?.close(this.tab, false) - } - async cancel() { - this.close('cancel') - } -} diff --git a/src/components/Windows/UnsavedFile/UnsavedFile.vue b/src/components/Windows/UnsavedFile/UnsavedFile.vue deleted file mode 100644 index dc0339bc5..000000000 --- a/src/components/Windows/UnsavedFile/UnsavedFile.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - diff --git a/src/components/Windows/Update/UpdateWindow.tsx b/src/components/Windows/Update/UpdateWindow.tsx deleted file mode 100644 index 64039f388..000000000 --- a/src/components/Windows/Update/UpdateWindow.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Component } from 'solid-js' -import { useTranslations } from '../../Composables/useTranslations' -import { SolidIcon } from '../../Solid/Icon/SolidIcon' -import { SolidButton } from '../../Solid/Inputs/Button/SolidButton' -import { SolidBridgeLogo } from '../../Solid/Logo' -import { SolidSpacer } from '../../Solid/SolidSpacer' -import { SolidWindow } from '../../Solid/Window/Window' - -interface IProps { - version: string - onClick: () => void -} -const UpdateWindow: Component = (props) => { - const { t } = useTranslations() - - return ( -
- -

- bridge. v{props.version} -

-

Update Available!

- - - -
- - - - {t('general.installNow')} - -
-
- ) -} - -export function openUpdateWindow(props: IProps) { - const window = new SolidWindow(UpdateWindow, { - ...props, - onClick: () => { - window.close() - props.onClick() - }, - }) -} diff --git a/src/components/Windows/Window.ts b/src/components/Windows/Window.ts new file mode 100644 index 000000000..c46f36209 --- /dev/null +++ b/src/components/Windows/Window.ts @@ -0,0 +1,10 @@ +import { v4 as uuid } from 'uuid' + +export class Window { + public component: any + public id: string = 'none' + + constructor() { + this.id = uuid() + } +} diff --git a/src/components/Windows/Window.vue b/src/components/Windows/Window.vue new file mode 100644 index 000000000..797252ed9 --- /dev/null +++ b/src/components/Windows/Window.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/components/Windows/WindowState.ts b/src/components/Windows/WindowState.ts deleted file mode 100644 index 49a95be7f..000000000 --- a/src/components/Windows/WindowState.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { del, markRaw, ref, set, shallowReactive, watch } from 'vue' -import { NewBaseWindow } from './NewBaseWindow' - -export class WindowState { - public state = ref>>({}) - public isAnyWindowVisible = ref(true) - protected watchStop = new Map void>() - - constructor() {} - - addWindow(uuid: string, window: NewBaseWindow) { - set(this.state.value, uuid, markRaw(window)) - - this.watchStop.set( - uuid, - watch(window.getState(), () => this.onWindowsChanged()) - ) - - this.onWindowsChanged() - } - deleteWindow(uuid: string) { - del(this.state.value, uuid) - - const watchStop = this.watchStop.get(uuid) - if (watchStop) watchStop() - this.watchStop.delete(uuid) - - this.onWindowsChanged() - } - - onWindowsChanged() { - for (const window of Object.values(this.state.value)) { - if (window.getState().isVisible) { - this.isAnyWindowVisible.value = true - return - } - } - - this.isAnyWindowVisible.value = false - } -} diff --git a/src/components/Windows/Windows.ts b/src/components/Windows/Windows.ts index 3dd353676..bc225aec5 100644 --- a/src/components/Windows/Windows.ts +++ b/src/components/Windows/Windows.ts @@ -1,29 +1,28 @@ -import { App } from '/@/App' -import { BrowserUnsupportedWindow } from './BrowserUnsupported/BrowserUnsupported' -import { ExtensionStoreWindow } from './ExtensionStore/ExtensionStore' -import { LoadingWindow } from './LoadingWindow/LoadingWindow' -import { CreatePresetWindow } from './Project/CreatePreset/PresetWindow' -import { CreateProjectWindow } from '/@/components/Projects/CreateProject/CreateProject' -import { ProjectChooserWindow } from '/@/components/Projects/ProjectChooser/ProjectChooser' -import { SettingsWindow } from './Settings/SettingsWindow' -import { ChangelogWindow } from '/@/components/Windows/Changelog/Changelog' -import { SocialsWindow } from './Socials/SocialsWindow' -import { CompilerWindow } from '../Compiler/Window/Window' +import { ShallowRef, shallowRef } from 'vue' + +interface WindowProvider { + component: any + id: string +} export class Windows { - settings: SettingsWindow - socialsWindow = new SocialsWindow() - projectChooser: ProjectChooserWindow - createProject = new CreateProjectWindow() - loadingWindow = new LoadingWindow() - createPreset = new CreatePresetWindow() - extensionStore = new ExtensionStoreWindow() - browserUnsupported = new BrowserUnsupportedWindow() - changelogWindow = new ChangelogWindow() - compilerWindow = new CompilerWindow() + public static openWindows: ShallowRef = shallowRef([]) + + public static open(window: WindowProvider) { + if (Windows.openWindows.value.includes(window)) return + + Windows.openWindows.value.push(window) + Windows.openWindows.value = [...Windows.openWindows.value] + } + + public static close(window: WindowProvider) { + if (!Windows.openWindows.value.includes(window)) return + + Windows.openWindows.value.splice(Windows.openWindows.value.indexOf(window), 1) + Windows.openWindows.value = [...Windows.openWindows.value] + } - constructor(protected app: App) { - this.settings = new SettingsWindow(app) - this.projectChooser = new ProjectChooserWindow(app) + public static isOpen(window: WindowProvider) { + return Windows.openWindows.value.includes(window) } } diff --git a/src/components/Windows/Windows.vue b/src/components/Windows/Windows.vue new file mode 100644 index 000000000..6a76e318b --- /dev/null +++ b/src/components/Windows/Windows.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/components/Windows/create.ts b/src/components/Windows/create.ts deleted file mode 100644 index bb398d06d..000000000 --- a/src/components/Windows/create.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Component as VueComponent } from 'vue' -import { v4 as uuid } from 'uuid' -import { App } from '/@/App' -import { shallowReactive, del, set } from 'vue' - -export function createWindow( - vueComponent: VueComponent, - state: Record = {}, - disposeOnClose = true, - onClose = () => {} -) { - // It might make sense for some windows to be "await"-able. This is a helper for that - const status: { setDone?: () => void; done?: Promise } = {} - status.done = new Promise((resolve) => { - status.setDone = resolve - }) - - const windowUUID = uuid() - const localState: typeof state = shallowReactive( - Object.assign(state, { - isVisible: false, - shouldRender: false, - }) - ) - - const windowApi = { - component: vueComponent, - getState: () => localState, - close: () => { - onClose() - - localState.isVisible = false - setTimeout(() => { - localState.shouldRender = false - if (disposeOnClose) windowApi.dispose() - }, 600) - }, - open: () => { - localState.shouldRender = true - localState.isVisible = true - }, - dispose: () => del(App.windowState.state, windowUUID), - status, - get isVisible() { - return localState.isVisible - }, - } - - set(App.windowState.state, windowUUID, windowApi) - - return windowApi -} - -export type TWindow = ReturnType diff --git a/src/libs/Clipboard.ts b/src/libs/Clipboard.ts new file mode 100644 index 000000000..cb20d81de --- /dev/null +++ b/src/libs/Clipboard.ts @@ -0,0 +1,17 @@ +let clipboard: any = undefined + +/** + * Sets a virtual clipboard + * @param value + */ +export function setClipboard(value: any) { + clipboard = value +} + +/** + * Gets the virtual clipboard value + * @param value + */ +export function getClipboard(): any { + return clipboard +} diff --git a/src/libs/Debounce.ts b/src/libs/Debounce.ts new file mode 100644 index 000000000..c14f1466e --- /dev/null +++ b/src/libs/Debounce.ts @@ -0,0 +1,46 @@ +import { Disposable } from '@/libs/disposeable/Disposeable' + +/** + * Limit an action from being triggered too often + * @param action The function to limit + * @param delay A delay in seconds between calls + * @returns A disposable that disposes the timeout + */ +export function debounce(action: () => void, delay: number): Disposable & { invoke: () => void } { + let ready = true + let inFlight = false + + let timeout: number | null = null + + const invoke = () => { + if (!ready) { + inFlight = true + + return + } + + inFlight = false + ready = false + + timeout = setTimeout(() => { + ready = true + + timeout = null + + if (inFlight) invoke() + }, delay) + + action() + } + + const dispose = () => { + if (inFlight) action() + + if (timeout !== null) clearTimeout(timeout) + } + + return { + invoke, + dispose, + } +} diff --git a/src/libs/Download.ts b/src/libs/Download.ts new file mode 100644 index 000000000..e8cdba272 --- /dev/null +++ b/src/libs/Download.ts @@ -0,0 +1,10 @@ +export async function download(name: string, data: Uint8Array) { + const url = URL.createObjectURL(new Blob([data], { type: 'application/file-export' })) + const element = document.createElement('a') + element.download = name + element.href = url + + element.click() + + URL.revokeObjectURL(url) +} diff --git a/src/libs/Interupt.ts b/src/libs/Interupt.ts new file mode 100644 index 000000000..c082b67fd --- /dev/null +++ b/src/libs/Interupt.ts @@ -0,0 +1,40 @@ +import { Disposable } from '@/libs/disposeable/Disposeable' + +/** + * Only trigger an action after not receiving any additional invokes for a specified amount of time + * @param action The function to limit + * @param delay A delay in seconds + * @returns A disposable that disposes the timeout + */ +export function interupt(action: () => void, delay: number): Disposable & { invoke: () => void } { + let inFlight = false + + let timeout: number | null = null + + const invoke = () => { + inFlight = true + + if (timeout !== null) { + clearTimeout(timeout) + } + + timeout = setTimeout(() => { + timeout = null + + inFlight = false + + action() + }, delay) + } + + const dispose = () => { + if (inFlight) action() + + if (timeout !== null) clearTimeout(timeout) + } + + return { + invoke, + dispose, + } +} diff --git a/src/libs/Mobile.ts b/src/libs/Mobile.ts new file mode 100644 index 000000000..52af43ef6 --- /dev/null +++ b/src/libs/Mobile.ts @@ -0,0 +1,24 @@ +import { onMounted, onUnmounted, Ref, ref } from 'vue' + +/** + * Reactive value if the user has a mobile sized screen + * @returns A ref of wether the screen is a mobile size + */ +export function useIsMobile(): Ref { + const mobile = ref(false) + update() + + function update() { + mobile.value = document.body.clientWidth < 960 + } + + onMounted(() => { + window.addEventListener('resize', update) + }) + + onUnmounted(() => { + window.removeEventListener('resize', update) + }) + + return mobile +} diff --git a/src/libs/OpenUrl.ts b/src/libs/OpenUrl.ts new file mode 100644 index 000000000..8395acae3 --- /dev/null +++ b/src/libs/OpenUrl.ts @@ -0,0 +1,7 @@ +/** + * Opens a url in a new tab + * @param url A string of the url to open + */ +export function openUrl(url: string) { + window.open(url) +} diff --git a/src/libs/actions/Action.ts b/src/libs/actions/Action.ts new file mode 100644 index 000000000..be502d7b3 --- /dev/null +++ b/src/libs/actions/Action.ts @@ -0,0 +1,117 @@ +import { Event } from '@/libs/event/Event' +import { get, set } from 'idb-keyval' +import { Settings } from '@/libs/settings/Settings' + +interface ActionConfig { + id: string + trigger: (data?: unknown) => void + keyBinding?: string + icon?: string + name?: string + description?: string + requiresContext?: boolean + visible?: boolean + category?: string +} + +export class Action { + public static allDisabled: boolean = false + + public id: string + public keyBinding?: string + public icon?: string + public name?: string + public description?: string + public requiresContext: boolean + public category: string + + public visible: boolean = true + + public updated: Event = new Event() + + public trigger: (data?: unknown) => void + + private ctrlModifier: boolean = false + private shiftModifier: boolean = false + private altModifier: boolean = false + private key: string = '' + + public constructor(config: ActionConfig) { + this.id = config.id + this.trigger = config.trigger + + Settings.addSetting(`actionKeybind-${this.id}`, { + default: undefined, + }) + + this.keyBinding = Settings.get(`actionKeybind-${this.id}`) ?? config.keyBinding + + this.icon = config.icon + this.name = config.name + this.description = config.description + this.requiresContext = config.requiresContext ?? false + this.category = config.category ?? 'actions.misc.name' + this.visible = config.visible ?? true + + if (this.keyBinding) { + this.ctrlModifier = this.keyBinding.includes('Ctrl') + this.shiftModifier = this.keyBinding.includes('Shift') + this.altModifier = this.keyBinding.includes('Alt') + + this.key = this.keyBinding.split(' + ').filter((key) => key !== 'Ctrl' && key !== 'Shift' && key !== 'Alt')[0] + } + + window.addEventListener('keydown', (event) => { + if (Action.allDisabled) return + + if (!this.keyBinding) return + + if (this.ctrlModifier !== event.ctrlKey) return + if (this.shiftModifier !== event.shiftKey) return + if (this.altModifier !== event.altKey) return + + if (event.key.toUpperCase() !== this.key && event.key !== this.key) return + + event.preventDefault() + + this.trigger(undefined) + }) + } + + public static disabledAll() { + Action.allDisabled = true + } + + public static enableAll() { + Action.allDisabled = false + } + + public setVisible(visible: boolean) { + if (this.visible === visible) return + + this.visible = visible + + this.updated.dispatch() + } + + public rebind(key: string, ctrlModifier: boolean, shiftModifier: boolean, altModifier: boolean) { + this.key = key + this.ctrlModifier = ctrlModifier + this.shiftModifier = shiftModifier + this.altModifier = altModifier + + this.keyBinding = [ctrlModifier ? 'Ctrl' : undefined, altModifier ? 'Alt' : undefined, shiftModifier ? 'Shift' : undefined, key.toUpperCase()] + .filter((item) => item !== undefined) + .join(' + ') + + Settings.set(`actionKeybind-${this.id}`, this.keyBinding) + + this.updated.dispatch() + } + + public unbind() { + this.keyBinding = undefined + + this.updated.dispatch() + } +} diff --git a/src/libs/actions/ActionManager.ts b/src/libs/actions/ActionManager.ts new file mode 100644 index 000000000..c5084b2d8 --- /dev/null +++ b/src/libs/actions/ActionManager.ts @@ -0,0 +1,76 @@ +import { onMounted, onUnmounted, shallowRef, ShallowRef } from 'vue' +import { Action } from './Action' +import { Event } from '@/libs/event/Event' +import { Disposable } from '@/libs/disposeable/Disposeable' + +export class ActionManager { + public static actions: Record = {} + + public static actionsUpdated: Event = new Event() + + public static addAction(action: Action): Action { + this.actions[action.id] = action + + action.updated.on(() => ActionManager.actionsUpdated.dispatch()) + + this.actionsUpdated.dispatch() + + return action + } + + public static removeAction(action: Action) { + delete ActionManager.actions[action.id] + + this.actionsUpdated.dispatch() + } + + public static trigger(id: string, data?: any) { + if (this.actions[id] === undefined) return + + this.actions[id].trigger(data) + } +} + +export function useActions(): ShallowRef> { + const current: ShallowRef> = shallowRef(ActionManager.actions) + + function update() { + //@ts-ignore this value in't acutally read by any code, it just triggers an update + current.value = null + current.value = ActionManager.actions + } + + let disposable: Disposable + + onMounted(() => { + disposable = ActionManager.actionsUpdated.on(update) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return current +} + +export function useAction(action: string): ShallowRef { + const current: ShallowRef = shallowRef(ActionManager.actions[action]) + + function update() { + //@ts-ignore this value in't acutally read by any code, it just triggers an update + current.value = null + current.value = ActionManager.actions[action] + } + + let disposable: Disposable + + onMounted(() => { + disposable = ActionManager.actionsUpdated.on(update) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return current +} diff --git a/src/libs/actions/Actions.ts b/src/libs/actions/Actions.ts new file mode 100644 index 000000000..a4ddf3823 --- /dev/null +++ b/src/libs/actions/Actions.ts @@ -0,0 +1,1791 @@ +import { PromptWindow } from '@/components/Windows/Prompt/PromptWindow' +import { TabManager } from '@/components/TabSystem/TabManager' +import { TextTab } from '@/components/Tabs/Text/TextTab' +import { basename, dirname, join, parse } from 'pathe' +import { getClipboard, setClipboard } from '@/libs/Clipboard' +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' +import { ActionManager } from './ActionManager' +import { Action } from './Action' +import { fileSystem, pickDirectory, pickFile, pickFiles } from '@/libs/fileSystem/FileSystem' +import { Windows } from '@/components/Windows/Windows' +import { NotificationSystem } from '@/components/Notifications/NotificationSystem' +import { TreeEditorTab } from '@/components/Tabs/TreeEditor/TreeEditorTab' +import { SettingsWindow } from '@/components/Windows/Settings/SettingsWindow' +import { ExtensionLibraryWindow } from '@/components/Windows/ExtensionLibrary/ExtensionLibrary' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { ArrayElement, DeleteElementEdit, ObjectElement, ReplaceEdit, TreeElements, ValueElement } from '@/components/Tabs/TreeEditor/Tree' +import { exportAsBrProject } from '@/libs/export/exporters/BrProject' +import { exportAsMcAddon } from '@/libs/export/exporters/McAddon' +import { exportAsTemplate } from '@/libs/export/exporters/McTemplate' +import { importFromBrProject } from '@/libs/import/BrProject' +import { importFromMcAddon } from '@/libs/import/McAddon' +import { importFromMcPack } from '@/libs/import/McPack' +import { openUrl } from '@/libs/OpenUrl' +import { FileTab } from '@/components/TabSystem/FileTab' +import { Extensions } from '@/libs/extensions/Extensions' +import { FileExplorer } from '@/components/FileExplorer/FileExplorer' +import { CreateProjectWindow } from '@/components/Windows/CreateProject/CreateProjectWindow' +import { Tab } from '@/components/TabSystem/Tab' +import { appVersion } from '@/libs/app/AppEnv' +import { ImporterManager } from '@/libs/import/ImporterManager' +import { tauriBuild } from '@/libs/tauri/Tauri' +import { TauriFileSystem } from '@/libs/fileSystem/TauriFileSystem' +import { LocaleManager } from '@/libs/locales/Locales' + +export function setupActions() { + setupFileTabActions() + + setupEditorActions() + + setupProjectActions() + + setupTextEditorActions() + + setupFileSystemActions() + + setupTextEditorActions() + + setupExportActions() + + setupJsonTreeActions() + + setupHelpActions() + + setupTabActions() +} + +function setupFileTabActions() { + const save = ActionManager.addAction( + new Action({ + id: 'files.save', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + focusedTab.temporary.value = false + + if (focusedTab instanceof FileTab) focusedTab.save() + }, + keyBinding: 'Ctrl + S', + name: 'actions.files.save.name', + description: 'actions.files.save.description', + icon: 'save', + visible: false, + category: 'actions.files.name', + }) + ) + + const saveAs = ActionManager.addAction( + new Action({ + id: 'files.saveAs', + trigger: async () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof FileTab)) return + + const path = focusedTab.path + + if (!(await fileSystem.exists(path))) return + + Windows.open( + new PromptWindow( + 'Save As', + 'Name', + 'Name', + async (newName) => { + const newPath = join(dirname(path), newName) + + await focusedTab.saveAs(newPath) + + await TabManager.removeTab(focusedTab) + await TabManager.openFile(newPath) + }, + () => {}, + basename(path) + ) + ) + }, + keyBinding: 'Ctrl + Shift + S', + name: 'actions.files.saveAs.name', + description: 'actions.files.saveAs.description', + icon: 'save_as', + visible: false, + category: 'actions.files.name', + }) + ) + + const saveAll = ActionManager.addAction( + new Action({ + id: 'files.saveAll', + trigger: () => { + for (const tabSystem of TabManager.tabSystems.value) { + for (const tab of tabSystem.tabs.value) { + tab.temporary.value = false + + if (tab instanceof FileTab) tab.save() + } + } + }, + keyBinding: 'Ctrl + Alt + S', + name: 'actions.files.saveAll.name', + description: 'actions.files.saveAll.description', + icon: 'save', + visible: false, + category: 'actions.files.name', + }) + ) + + for (const action of [save, saveAll, saveAs]) { + TabManager.focusedTabSystemChanged.on(() => { + action.setVisible( + TabManager.focusedTabSystem.value !== null && + TabManager.focusedTabSystem.value.selectedTab.value !== null && + TabManager.focusedTabSystem.value.selectedTab.value instanceof FileTab && + TabManager.focusedTabSystem.value.selectedTab.value.canSave + ) + }) + } +} + +function setupEditorActions() { + const goHome = ActionManager.addAction( + new Action({ + id: 'editor.goHome', + trigger: () => { + ProjectManager.closeProject() + }, + name: 'actions.editor.goHome.name', + description: 'actions.editor.goHome.description', + icon: 'home', + visible: false, + category: 'actions.editor.name', + }) + ) + + ProjectManager.updatedCurrentProject.on(() => { + goHome.setVisible(ProjectManager.currentProject !== null) + }) + + ActionManager.addAction( + new Action({ + id: 'editor.clearNotifications', + trigger: () => { + NotificationSystem.clearNotifications() + }, + name: 'actions.editor.clearNotifications.name', + description: 'actions.editor.clearNotifications.description', + icon: 'delete_forever', + category: 'actions.editor.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'editor.openSettings', + trigger: () => { + SettingsWindow.open() + }, + keyBinding: 'Ctrl + ,', + name: 'actions.editor.settings.name', + description: 'actions.editor.settings.description', + icon: 'settings', + category: 'actions.editor.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'editor.openExtensions', + trigger: () => { + ExtensionLibraryWindow.open() + }, + name: 'actions.editor.extensions.name', + description: 'actions.editor.extensions.description', + icon: 'extension', + category: 'actions.editor.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'editor.importProject', + trigger: async () => { + // TODO: Translate + const file = await pickFile(LocaleManager.translate('Choose a Project'), { + 'application/zip': ['.brproject', '.mcaddon', '.mcpack'], + }) + + if (!file) return + + if (file.path.endsWith('.mcaddon')) { + await importFromMcAddon(file) + } else if (file.path.endsWith('.mcpack')) { + await importFromMcPack(file) + } else { + await importFromBrProject(file) + } + }, + name: 'actions.editor.importProject.name', + description: 'actions.editor.importProject.description', + icon: 'package', + category: 'actions.editor.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'editor.openFolder', + trigger: async () => { + const directory = await pickDirectory() + + if (!directory) return + + await ImporterManager.importDirectory(directory) + }, + name: 'actions.editor.openFolder.name', + description: 'actions.editor.openFolder.description', + icon: 'drive_folder_upload', + category: 'actions.editor.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'editor.reloadEditor', + trigger() { + location.reload() + }, + keyBinding: 'Ctrl + R', + name: 'actions.editor.reloadEditor.name', + description: 'actions.editor.reloadEditor.description', + icon: 'refresh', + category: 'actions.editor.name', + }) + ) + + const nextTabAction = ActionManager.addAction( + new Action({ + id: 'editor.nextTab', + trigger() { + const tabSystem = TabManager.focusedTabSystem.value + + if (tabSystem === null) return + + tabSystem.nextTab() + }, + name: 'actions.editor.nextTab.name', + description: 'actions.editor.nextTab.description', + icon: 'arrow_right', + category: 'actions.editor.name', + visible: false, + }) + ) + + const previousTabAction = ActionManager.addAction( + new Action({ + id: 'editor.previousTab', + trigger() { + const tabSystem = TabManager.focusedTabSystem.value + + if (tabSystem === null) return + + tabSystem.previousTab() + }, + name: 'actions.editor.previousTab.name', + description: 'actions.editor.previousTab.description', + icon: 'arrow_left', + category: 'actions.editor.name', + visible: false, + }) + ) + + const closeTabAction = ActionManager.addAction( + new Action({ + id: 'editor.closeTab', + trigger() { + const tabSystem = TabManager.focusedTabSystem.value + + if (tabSystem === null) return + + const tab = tabSystem.selectedTab.value + + if (!tab) return + + tabSystem.removeTabSafe(tab) + }, + name: 'actions.editor.closeTab.name', + description: 'actions.editor.closeTab.description', + icon: 'close', + category: 'actions.editor.name', + visible: false, + }) + ) + + ActionManager.addAction( + new Action({ + id: 'editor.launchMinecraft', + trigger() { + openUrl('minecraft:') + }, + keyBinding: 'F5', + name: 'actions.editor.launchMinecraft.name', + description: 'actions.editor.launchMinecraft.description', + icon: 'play_arrow', + category: 'actions.editor.name', + }) + ) + + if (tauriBuild) + ActionManager.addAction( + new Action({ + id: 'editor.revealBridgeFolder', + trigger: () => { + if (!tauriBuild) return + + if (!(fileSystem instanceof TauriFileSystem)) return + + fileSystem.revealInFileExplorer('/') + }, + name: 'actions.editor.revealBridgeFolder.name', + description: 'actions.editor.revealBridgeFolder.description', + icon: 'folder_open', + category: 'actions.editor.name', + }) + ) + + // TODO: Renable once tauri build acceleration is implemented + // ActionManager.addAction( + // new Action({ + // id: 'editor.revealOutputFolder', + // trigger: () => { + // if (!tauriBuild) return + + // if (!ProjectManager.currentProject) return + + // let outputFileSystem = null + // outputFileSystem = ProjectManager.currentProject.outputFileSystem + + // console.log(outputFileSystem) + + // if (!(outputFileSystem instanceof TauriFileSystem)) return + + // outputFileSystem.revealInFileExplorer('/') + // }, + // name: 'actions.editor.revealOutputFolder.name', + // description: 'actions.editor.revealOutputFolder.description', + // icon: 'manufacturing', + // category: 'actions.editor.name', + // }) + // ) + + if (tauriBuild) + ActionManager.addAction( + new Action({ + id: 'editor.revealExtensionsFolder', + trigger: () => { + if (!tauriBuild) return + + if (!(fileSystem instanceof TauriFileSystem)) return + + fileSystem.revealInFileExplorer('/extensions') + }, + name: 'actions.editor.revealExtensionsFolder.name', + description: 'actions.editor.revealExtensionsFolder.description', + icon: 'extension', + category: 'actions.editor.name', + }) + ) + + ProjectManager.updatedCurrentProject.on(() => { + for (const action of [nextTabAction, previousTabAction, closeTabAction]) { + action.setVisible(ProjectManager.currentProject !== null) + } + }) +} + +function setupProjectActions() { + const projectActions: Action[] = [] + + projectActions.push( + ActionManager.addAction( + new Action({ + id: 'project.reload', + async trigger() { + if (!ProjectManager.currentProject) return + + const currentProjectName = ProjectManager.currentProject.name + + await ProjectManager.closeProject() + + await ProjectManager.loadProject(currentProjectName) + }, + name: 'actions.project.reload.name', + description: 'actions.project.reload.description', + icon: 'refresh', + category: 'actions.project.name', + }) + ) + ) + + projectActions.push( + ActionManager.addAction( + new Action({ + id: 'project.reloadExtensions', + async trigger() { + await Extensions.reload() + }, + name: 'actions.project.reloadExtensions.name', + description: 'actions.project.reloadExtensions.description', + icon: 'frame_reload', + category: 'actions.project.name', + }) + ) + ) + + projectActions.push( + ActionManager.addAction( + new Action({ + id: 'project.toggleFileExplorer', + async trigger() { + FileExplorer.toggle() + }, + name: 'actions.project.toggleFileExplorer.name', + description: 'actions.project.toggleFileExplorer.description', + icon: 'folder_open', + category: 'actions.project.name', + }) + ) + ) + + ActionManager.addAction( + new Action({ + id: 'project.newProject', + trigger() { + Windows.open(CreateProjectWindow) + }, + name: 'actions.project.newProject.name', + description: 'actions.project.newProject.description', + icon: 'add', + category: 'actions.project.name', + }) + ) + + projectActions.push( + ActionManager.addAction( + new Action({ + id: 'project.importFile', + trigger: async () => { + const files = await pickFiles(LocaleManager.translate('Choose a File')) + + if (!files) return + + for (const file of files) { + await ImporterManager.importFile(file) + } + }, + name: 'actions.project.importFile.name', + description: 'actions.project.importFile.description', + icon: 'file_open', + category: 'actions.project.name', + }) + ) + ) + + if (tauriBuild) + projectActions.push( + ActionManager.addAction( + new Action({ + id: 'project.revealInFileExplorer', + trigger: () => { + if (!tauriBuild) return + + if (!(fileSystem instanceof TauriFileSystem)) return + + if (!ProjectManager.currentProject) return + + fileSystem.revealInFileExplorer(ProjectManager.currentProject.path) + }, + name: 'actions.project.revealInFileExplorer.name', + description: 'actions.project.revealInFileExplorer.description', + icon: 'folder_open', + visible: false, + category: 'actions.project.name', + }) + ) + ) + + ProjectManager.updatedCurrentProject.on(() => { + for (const action of projectActions) { + action.setVisible(ProjectManager.currentProject !== null) + } + }) +} + +function setupTextEditorActions() { + const copy = ActionManager.addAction( + new Action({ + id: 'textEditor.copy', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TextTab)) return + + focusedTab.copy() + }, + keyBinding: 'Ctrl + C', + name: 'actions.textEditor.copy.name', + description: 'actions.textEditor.copy.description', + icon: 'content_copy', + visible: false, + category: 'actions.textEditor.name', + }) + ) + + const paste = ActionManager.addAction( + new Action({ + id: 'textEditor.paste', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TextTab)) return + + focusedTab.paste() + }, + keyBinding: 'Ctrl + V', + name: 'actions.textEditor.paste.name', + description: 'actions.textEditor.paste.description', + icon: 'content_paste', + visible: false, + category: 'actions.textEditor.name', + }) + ) + + const cut = ActionManager.addAction( + new Action({ + id: 'textEditor.cut', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TextTab)) return + + focusedTab.cut() + }, + keyBinding: 'Ctrl + X', + name: 'actions.textEditor.cut.name', + description: 'actions.textEditor.cut.description', + icon: 'content_cut', + visible: false, + category: 'actions.textEditor.name', + }) + ) + + const format = ActionManager.addAction( + new Action({ + id: 'textEditor.format', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TextTab)) return + + focusedTab.format() + }, + name: 'actions.textEditor.formatDocument.name', + description: 'actions.textEditor.formatDocument.description', + icon: 'edit_document', + category: 'actions.textEditor.name', + }) + ) + + const goToSymbol = ActionManager.addAction( + new Action({ + id: 'textEditor.goToSymbol', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TextTab)) return + + focusedTab.goToSymbol() + }, + name: 'actions.textEditor.goToSymbol.name', + description: 'actions.textEditor.goToSymbol.description', + icon: 'arrow_forward', + category: 'actions.textEditor.name', + }) + ) + + const changeAllOccurrences = ActionManager.addAction( + new Action({ + id: 'textEditor.changeAllOccurrences', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TextTab)) return + + focusedTab.changeAllOccurrences() + }, + name: 'actions.textEditor.changeAllOccurrences.name', + description: 'actions.textEditor.changeAllOccurrences.description', + icon: 'edit_note', + category: 'actions.textEditor.name', + }) + ) + + const goToDefinition = ActionManager.addAction( + new Action({ + id: 'textEditor.goToDefinition', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TextTab)) return + + focusedTab.goToDefinition() + }, + name: 'actions.textEditor.goToDefinition.name', + description: 'actions.textEditor.goToDefinition.description', + icon: 'search', + category: 'actions.textEditor.name', + }) + ) + + const viewDocumentation = ActionManager.addAction( + new Action({ + id: 'textEditor.viewDocumentation', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TextTab)) return + + focusedTab.viewDocumentation() + }, + name: 'actions.textEditor.documentationLookup.name', + description: 'actions.textEditor.documentationLookup.description', + icon: 'menu_book', + category: 'actions.textEditor.name', + }) + ) + + for (const action of [copy, paste, cut, format, goToSymbol, changeAllOccurrences, goToDefinition, viewDocumentation]) { + TabManager.focusedTabSystemChanged.on(() => { + TabManager.focusedTabSystemChanged.on(() => { + action.setVisible( + TabManager.focusedTabSystem.value !== null && + TabManager.focusedTabSystem.value.selectedTab.value !== null && + TabManager.focusedTabSystem.value.selectedTab.value instanceof TextTab + ) + }) + }) + } +} + +function setupFileSystemActions() { + const deleteFileSystemEntry = ActionManager.addAction( + new Action({ + id: 'files.deleteFileSystemEntry', + trigger: async (path: unknown) => { + if (typeof path !== 'string') return + + if (!(await fileSystem.exists(path))) return + + const entry = await fileSystem.getEntry(path) + + if (entry.kind === 'directory') { + await fileSystem.removeDirectory(path) + } + + if (entry.kind === 'file') { + await fileSystem.removeFile(path) + } + }, + keyBinding: 'Delete', + name: 'actions.files.delete.name', + description: 'actions.files.delete.description', + icon: 'delete', + requiresContext: true, + visible: false, + category: 'actions.files.name', + }) + ) + + const createFile = ActionManager.addAction( + new Action({ + id: 'files.createFile', + trigger: async (path: unknown) => { + if (typeof path !== 'string' && path !== undefined) return + + if (path === undefined) { + const currentPackPath = FileExplorer.selectedPackPath.value + + Windows.open( + new PromptWindow('Create File', 'File Name', 'File Name', (name) => { + fileSystem.writeFile(join(currentPackPath, name), '') + }) + ) + } else { + Windows.open( + new PromptWindow('Create File', 'File Name', 'File Name', (name) => { + fileSystem.writeFile(join(path, name), '') + }) + ) + } + }, + name: 'actions.files.createFile.name', + description: 'actions.files.createFile.description', + icon: 'note_add', + requiresContext: true, + visible: false, + category: 'actions.files.name', + }) + ) + + const createFolder = ActionManager.addAction( + new Action({ + id: 'files.createFolder', + trigger: async (path: unknown) => { + if (typeof path !== 'string') return + + Windows.open( + new PromptWindow('Create Folder', 'Folder Name', 'Folder Name', (name) => { + fileSystem.makeDirectory(join(path, name)) + }) + ) + }, + name: 'actions.files.createFolder.name', + description: 'actions.files.createFolder.description', + icon: 'create_new_folder', + requiresContext: true, + visible: false, + category: 'actions.files.name', + }) + ) + + const renameFileSystemEntry = ActionManager.addAction( + new Action({ + id: 'files.renameFileSystemEntry', + trigger: async (path: unknown) => { + if (typeof path !== 'string') return + + if (!(await fileSystem.exists(path))) return + const fileName = basename((await fileSystem.getEntry(path)).path) + + Windows.open( + new PromptWindow( + 'Rename', + 'Name', + 'Name', + async (newPath) => { + if (!(await fileSystem.exists(path))) return + + const entry = await fileSystem.getEntry(path) + + if (entry.kind === 'directory') { + await fileSystem.copyDirectory(path, join(dirname(path), newPath)) + await fileSystem.removeDirectory(path) + } + + if (entry.kind === 'file') { + await fileSystem.copyFile(path, join(dirname(path), newPath)) + await fileSystem.removeFile(path) + } + }, + () => {}, + fileName + ) + ) + }, + name: 'actions.files.rename.name', + description: 'actions.files.rename.description', + icon: 'text_fields_alt', + requiresContext: true, + visible: false, + category: 'actions.files.name', + }) + ) + + const duplicateFileSystemEntry = ActionManager.addAction( + new Action({ + id: 'files.duplicateFileSystemEntry', + trigger: async (path: unknown) => { + if (typeof path !== 'string') return + + if (!(await fileSystem.exists(path))) return + + const entry = await fileSystem.getEntry(path) + + const parsedPath = parse(path) + const newPathBase = path.substring(0, path.length - parsedPath.ext.length) + + let additionalName = ' copy' + let newPath = newPathBase + additionalName + parsedPath.ext + + while (await fileSystem.exists(newPath)) { + additionalName += ' copy' + + newPath = newPathBase + additionalName + parsedPath.ext + } + + if (entry.kind === 'directory') { + await fileSystem.copyDirectory(path, newPath) + } + + if (entry.kind === 'file') { + await fileSystem.copyFile(path, newPath) + } + }, + name: 'actions.files.duplicate.name', + description: 'actions.files.duplicate.description', + icon: 'file_copy', + requiresContext: true, + visible: false, + category: 'actions.files.name', + }) + ) + + const copyFileSystemEntry = ActionManager.addAction( + new Action({ + id: 'files.copyFileSystemEntry', + trigger: async (path: unknown) => { + if (typeof path !== 'string') return + + if (!(await fileSystem.exists(path))) return + + setClipboard(await fileSystem.getEntry(path)) + }, + keyBinding: 'Ctrl + C', + name: 'actions.files.copy.name', + description: 'actions.files.copy.description', + icon: 'file_copy', + requiresContext: true, + visible: false, + category: 'actions.files.name', + }) + ) + + const pasteFileSystemEntry = ActionManager.addAction( + new Action({ + id: 'files.pasteFileSystemEntry', + trigger: async (path: unknown) => { + if (typeof path !== 'string') return + + const clipboardEntry = getClipboard() + + if (!(clipboardEntry instanceof BaseEntry)) return + + const sourceEntry = await fileSystem.getEntry(path) + + const parsedPath = parse(clipboardEntry.path) + const newPathBase = join(sourceEntry.kind === 'directory' ? path : dirname(path), parsedPath.name) + + let additionalName = ' copy' + let newPath = newPathBase + additionalName + parsedPath.ext + + while (await fileSystem.exists(newPath)) { + additionalName += ' copy' + + newPath = newPathBase + additionalName + parsedPath.ext + } + + if (clipboardEntry.kind === 'directory') { + await fileSystem.copyDirectory(clipboardEntry.path, newPath) + } + + if (clipboardEntry.kind === 'file') { + await fileSystem.copyFile(clipboardEntry.path, newPath) + } + }, + keyBinding: 'Ctrl + V', + name: 'actions.files.paste.name', + description: 'actions.files.paste.description', + icon: 'content_paste', + requiresContext: true, + visible: false, + category: 'actions.files.name', + }) + ) + + const openToSide = ActionManager.addAction( + new Action({ + id: 'files.openToSide', + trigger: async (path: unknown) => { + if (typeof path !== 'string') return + + if (TabManager.isFileOpen(path)) return + + if (TabManager.tabSystems.value.length < 2) { + const tabSystem = await TabManager.addTabSystem() + + TabManager.focusTabSystem(tabSystem) + } else { + const otherTabSystem = TabManager.tabSystems.value.find( + (tabSystem) => TabManager.focusedTabSystem.value?.id !== tabSystem.id + ) + + if (!otherTabSystem) return + + TabManager.focusTabSystem(otherTabSystem) + } + + await TabManager.openFile(path) + }, + name: 'actions.files.openToSide.name', + description: 'actions.files.openToSide.description', + icon: 'splitscreen_right', + requiresContext: true, + visible: false, + category: 'actions.files.name', + }) + ) + + if (tauriBuild) { + const revealInFileExplorer = ActionManager.addAction( + new Action({ + id: 'files.revealInFileExplorer', + trigger: (path: unknown) => { + if (!(typeof path === 'string')) return + + if (!tauriBuild) return + + if (!(fileSystem instanceof TauriFileSystem)) return + + fileSystem.revealInFileExplorer(path) + }, + name: 'actions.files.revealInFileExplorer.name', + description: 'actions.files.revealInFileExplorer.description', + icon: 'folder_open', + visible: false, + requiresContext: true, + category: 'actions.files.name', + }) + ) + + ProjectManager.updatedCurrentProject.on(() => { + revealInFileExplorer.setVisible(ProjectManager.currentProject !== null) + }) + } + + for (const action of [ + deleteFileSystemEntry, + createFile, + createFolder, + renameFileSystemEntry, + duplicateFileSystemEntry, + copyFileSystemEntry, + pasteFileSystemEntry, + openToSide, + ]) { + ProjectManager.updatedCurrentProject.on(() => { + action.setVisible(ProjectManager.currentProject !== null) + }) + } +} + +function setupExportActions() { + const exportBrProject = ActionManager.addAction( + new Action({ + id: 'export.exportBrProject', + trigger: () => { + exportAsBrProject() + }, + name: 'actions.export.brproject.name', + description: 'actions.export.brproject.description', + icon: 'folder_zip', + visible: false, + category: 'actions.export.name', + }) + ) + + const exportMcAddon = ActionManager.addAction( + new Action({ + id: 'export.exportMcAddon', + trigger: () => { + exportAsMcAddon() + }, + name: 'actions.export.mcaddon.name', + description: 'actions.export.mcaddon.description', + icon: 'deployed_code', + visible: false, + category: 'actions.export.name', + }) + ) + + const exportMcWorld = ActionManager.addAction( + new Action({ + id: 'export.exportMcWorld', + trigger: () => { + exportAsTemplate(true) + }, + name: 'actions.export.mcworld.name', + description: 'actions.export.mcworld.description', + icon: 'globe', + visible: false, + category: 'actions.export.name', + }) + ) + + const exportMcTemplate = ActionManager.addAction( + new Action({ + id: 'export.exportMcTemplate', + trigger: () => { + exportAsTemplate() + }, + name: 'actions.export.mctemplate.name', + description: 'actions.export.mctemplate.description', + icon: 'package', + visible: false, + category: 'actions.export.name', + }) + ) + + for (const action of [exportBrProject, exportMcAddon, exportMcWorld, exportMcTemplate]) { + ProjectManager.updatedCurrentProject.on(() => { + action.setVisible(ProjectManager.currentProject !== null) + }) + } +} + +function setupJsonTreeActions() { + const undo = ActionManager.addAction( + new Action({ + id: 'treeEditor.undo', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TreeEditorTab)) return + + focusedTab.undo() + }, + keyBinding: 'Ctrl + Z', + name: 'actions.treeEditor.undo.name', + description: 'actions.treeEditor.undo.description', + icon: 'undo', + visible: false, + category: 'actions.treeEditor.name', + }) + ) + + const redo = ActionManager.addAction( + new Action({ + id: 'treeEditor.redo', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TreeEditorTab)) return + + focusedTab.redo() + }, + keyBinding: 'Ctrl + Y', + name: 'actions.treeEditor.redo.name', + description: 'actions.treeEditor.redo.description', + icon: 'redo', + visible: false, + category: 'actions.treeEditor.name', + }) + ) + + // const copy = ActionManager.addAction( + // new Action({ + // id: 'treeEditor.copy', + // trigger: () => { + // const focusedTab = TabManager.getFocusedTab() + + // if (focusedTab === null) return + + // if (!(focusedTab instanceof TreeEditorTab)) return + + // focusedTab.copy() + // }, + // keyBinding: 'Ctrl + C', + // name: 'actions.treeEditor.copy.name', + // description: 'actions.treeEditor.copy.description', + // icon: 'content_copy', + // visible: false, + // category: 'actions.treeEditor.name', + // }) + // ) + + // const paste = ActionManager.addAction( + // new Action({ + // id: 'treeEditor.paste', + // trigger: () => { + // const focusedTab = TabManager.getFocusedTab() + + // if (focusedTab === null) return + + // if (!(focusedTab instanceof TreeEditorTab)) return + + // focusedTab.paste() + // }, + // keyBinding: 'Ctrl + V', + // name: 'actions.treeEditor.paste.name', + // description: 'actions.treeEditor.paste.description', + // icon: 'content_paste', + // visible: false, + // category: 'actions.treeEditor.name', + // }) + // ) + + // const cut = ActionManager.addAction( + // new Action({ + // id: 'treeEditor.cut', + // trigger: () => { + // const focusedTab = TabManager.getFocusedTab() + + // if (focusedTab === null) return + + // if (!(focusedTab instanceof TreeEditorTab)) return + + // focusedTab.cut() + // }, + // keyBinding: 'Ctrl + X', + // name: 'actions.treeEditor.cut.name', + // description: 'actions.treeEditor.cut.description', + // icon: 'content_cut', + // visible: false, + // category: 'actions.treeEditor.name', + // }) + // ) + + const deleteAction = ActionManager.addAction( + new Action({ + id: 'treeEditor.delete', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TreeEditorTab)) return + + if (focusedTab.contextTree.value) { + focusedTab.edit(new DeleteElementEdit(focusedTab.contextTree.value.tree)) + } else if (focusedTab.selectedTree.value) { + focusedTab.edit(new DeleteElementEdit(focusedTab.selectedTree.value.tree)) + } + }, + keyBinding: 'Delete', + name: 'actions.treeEditor.delete.name', + description: 'actions.treeEditor.delete.description', + icon: 'delete', + visible: false, + category: 'actions.treeEditor.name', + }) + ) + + // const viewDocumentation = ActionManager.addAction( + // new Action({ + // id: 'treeEditor.viewDocumentation', + // trigger: () => { + // const focusedTab = TabManager.getFocusedTab() + + // if (focusedTab === null) return + + // if (!(focusedTab instanceof TextTab)) return + + // focusedTab.viewDocumentation() + // }, + // name: 'actions.treeEditor.documentationLookup.name', + // description: 'actions.treeEditor.documentationLookup.description', + // icon: 'menu_book', + // category: 'actions.treeEditor.name', + // }) + // ) + + const convertToObject = ActionManager.addAction( + new Action({ + id: 'treeEditor.convertToObject', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TreeEditorTab)) return + + let element: TreeElements = null + + if (focusedTab.contextTree.value) { + element = focusedTab.contextTree.value.tree + } else if (focusedTab.selectedTree.value) { + element = focusedTab.selectedTree.value.tree + } + + let newElement = new ObjectElement() + + if (element instanceof ArrayElement) { + for (const child of element.children) { + const newChild = child.clone() + newChild.key = child.key!.toString() + newChild.parent = newElement + + newElement.children[child.key!.toString()] = newChild + } + } + + focusedTab.edit( + new ReplaceEdit(element, newElement, (element) => { + focusedTab.tree.value = element + }) + ) + }, + name: 'actions.treeEditor.convertToObject.name', + description: 'actions.treeEditor.convertToObject.description', + icon: 'swap_horiz', + visible: false, + category: 'actions.treeEditor.name', + }) + ) + + const convertToArray = ActionManager.addAction( + new Action({ + id: 'treeEditor.convertToArray', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TreeEditorTab)) return + + let element: TreeElements = null + + if (focusedTab.contextTree.value) { + element = focusedTab.contextTree.value.tree + } else if (focusedTab.selectedTree.value) { + element = focusedTab.selectedTree.value.tree + } + + let newElement = new ArrayElement() + + if (element instanceof ObjectElement) { + let index = 0 + for (const child of Object.values(element.children)) { + const newChild = child.clone() + newChild.key = index + newChild.parent = newElement + + newElement.children[index] = newChild + + index++ + } + } + + focusedTab.edit( + new ReplaceEdit(element, newElement, (element) => { + focusedTab.tree.value = element + }) + ) + }, + name: 'actions.treeEditor.convertToArray.name', + description: 'actions.treeEditor.convertToArray.description', + icon: 'swap_horiz', + visible: false, + category: 'actions.treeEditor.name', + }) + ) + + const convertToNull = ActionManager.addAction( + new Action({ + id: 'treeEditor.convertToNull', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TreeEditorTab)) return + + let element: TreeElements = null + + if (focusedTab.contextTree.value) { + element = focusedTab.contextTree.value.tree + } else if (focusedTab.selectedTree.value) { + element = focusedTab.selectedTree.value.tree + } + + let newElement = new ValueElement(element.parent, element.key, null) + + focusedTab.edit( + new ReplaceEdit(element, newElement, (element) => { + focusedTab.tree.value = element + }) + ) + }, + name: 'actions.treeEditor.convertToNull.name', + description: 'actions.treeEditor.convertToNull.description', + icon: 'swap_horiz', + visible: false, + category: 'actions.treeEditor.name', + }) + ) + + const convertToNumber = ActionManager.addAction( + new Action({ + id: 'treeEditor.convertToNumber', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TreeEditorTab)) return + + let element: TreeElements = null + + if (focusedTab.contextTree.value) { + element = focusedTab.contextTree.value.tree + } else if (focusedTab.selectedTree.value) { + element = focusedTab.selectedTree.value.tree + } + + let newElement = new ValueElement(element.parent, element.key, 0) + + if (element instanceof ValueElement && typeof element.value === 'string') { + try { + newElement.value = parseFloat(element.value) + + if (isNaN(newElement.value)) newElement.value = 0 + } catch {} + } + + focusedTab.edit( + new ReplaceEdit(element, newElement, (element) => { + focusedTab.tree.value = element + }) + ) + }, + name: 'actions.treeEditor.convertToNumber.name', + description: 'actions.treeEditor.convertToNumber.description', + icon: 'swap_horiz', + visible: false, + category: 'actions.treeEditor.name', + }) + ) + + const convertToString = ActionManager.addAction( + new Action({ + id: 'treeEditor.convertToString', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TreeEditorTab)) return + + let element: TreeElements = null + + if (focusedTab.contextTree.value) { + element = focusedTab.contextTree.value.tree + } else if (focusedTab.selectedTree.value) { + element = focusedTab.selectedTree.value.tree + } + + let newElement = new ValueElement(element.parent, element.key, '') + + if (element instanceof ValueElement) { + newElement.value = element.value?.toString() ?? 'null' + } + + focusedTab.edit( + new ReplaceEdit(element, newElement, (element) => { + focusedTab.tree.value = element + }) + ) + }, + name: 'actions.treeEditor.convertToString.name', + description: 'actions.treeEditor.convertToString.description', + icon: 'swap_horiz', + visible: false, + category: 'actions.treeEditor.name', + }) + ) + + const convertToBoolean = ActionManager.addAction( + new Action({ + id: 'treeEditor.convertToBoolean', + trigger: () => { + const focusedTab = TabManager.getFocusedTab() + + if (focusedTab === null) return + + if (!(focusedTab instanceof TreeEditorTab)) return + + let element: TreeElements = null + + if (focusedTab.contextTree.value) { + element = focusedTab.contextTree.value.tree + } else if (focusedTab.selectedTree.value) { + element = focusedTab.selectedTree.value.tree + } + + let newElement = new ValueElement(element.parent, element.key, true) + + if (element instanceof ValueElement) { + if (element.value === 'false') newElement.value = false + } + + focusedTab.edit( + new ReplaceEdit(element, newElement, (element) => { + focusedTab.tree.value = element + }) + ) + }, + name: 'actions.treeEditor.convertToBoolean.name', + description: 'actions.treeEditor.convertToBoolean.description', + icon: 'swap_horiz', + visible: false, + category: 'actions.treeEditor.name', + }) + ) + + for (const action of [ + undo, + redo, + deleteAction, + convertToObject, + convertToArray, + convertToNull, + convertToNumber, + convertToString, + convertToBoolean, + ]) { + TabManager.focusedTabSystemChanged.on(() => { + action.setVisible( + TabManager.focusedTabSystem.value !== null && + TabManager.focusedTabSystem.value.selectedTab.value !== null && + TabManager.focusedTabSystem.value.selectedTab.value instanceof TreeEditorTab + ) + }) + } +} + +function setupHelpActions() { + ActionManager.addAction( + new Action({ + id: 'help.gettingStarted', + trigger() { + openUrl('https://bridge-core.app/guide/') + }, + name: 'actions.help.gettingStarted.name', + description: 'actions.help.gettingStarted.description', + icon: 'flag', + category: 'actions.help.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'help.faq', + trigger() { + openUrl('https://bridge-core.app/guide/faq/') + }, + name: 'actions.help.faq.name', + description: 'actions.help.faq.description', + icon: 'info', + category: 'actions.help.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'help.openDownloadGuide', + trigger() { + openUrl('https://bridge-core.app/guide/download/') + }, + name: 'actions.help.download.name', + description: 'actions.help.download.description', + icon: 'download', + category: 'actions.help.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'help.extensions', + trigger() { + openUrl('https://bridge-core.app/extensions/') + }, + name: 'actions.help.extensions.name', + description: 'actions.help.extensions.description', + icon: 'code', + category: 'actions.help.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'help.scriptingDocs', + trigger() { + openUrl('https://learn.microsoft.com/en-us/minecraft/creator/scriptapi/') + }, + name: 'actions.help.scriptingDocs.name', + description: 'actions.help.scriptingDocs.description', + icon: 'data_array', + category: 'actions.help.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'help.bedrockDevDocs', + trigger() { + openUrl('https://bedrock.dev/') + }, + name: 'actions.help.bedrockDevDocs.name', + description: 'actions.help.bedrockDevDocs.description', + icon: 'info', + category: 'actions.help.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'help.creatorDocs', + trigger() { + openUrl('https://learn.microsoft.com/en-us/minecraft/creator/') + }, + name: 'actions.help.creatorDocs.name', + description: 'actions.help.creatorDocs.description', + icon: 'help', + category: 'actions.help.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'help.feedback', + trigger() { + openUrl('https://github.com/bridge-core/editor/issues') + }, + name: 'actions.help.feedback.name', + description: 'actions.help.feedback.description', + icon: 'chat_bubble', + category: 'actions.help.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'help.releases', + trigger() { + openUrl('https://github.com/bridge-core/editor/releases') + }, + name: 'actions.help.releases.name', + description: 'actions.help.releases.description', + icon: 'publish', + category: 'actions.help.name', + }) + ) + + ActionManager.addAction( + new Action({ + id: 'help.openChangelog', + trigger() { + openUrl(`https://github.com/bridge-core/editor/releases/tag/v${appVersion}`) + }, + name: 'actions.help.openChangelog.name', + description: 'actions.help.openChangelog.description', + icon: 'deployed_code_history', + category: 'actions.help.name', + }) + ) +} + +function setupTabActions() { + const close = ActionManager.addAction( + new Action({ + id: 'tabs.close', + trigger: (data: unknown) => { + if (!(data instanceof Tab)) return + + TabManager.removeTabSafe(data) + }, + name: 'actions.tabs.close.name', + description: 'actions.tabs.close.description', + icon: 'close', + visible: false, + requiresContext: true, + category: 'actions.tabs.name', + }) + ) + + const closeAll = ActionManager.addAction( + new Action({ + id: 'tabs.closeAll', + trigger: async () => { + const tabSystems = [...TabManager.tabSystems.value] + + for (const tabSystem of tabSystems) { + const tabs = [...tabSystem.tabs.value] + + for (const tab of tabs) { + await TabManager.removeTabSafe(tab) + } + } + }, + name: 'actions.tabs.closeAll.name', + description: 'actions.tabs.closeAll.description', + icon: 'tab_close', + visible: false, + category: 'actions.tabs.name', + }) + ) + + const closeToRight = ActionManager.addAction( + new Action({ + id: 'tabs.closeToRight', + trigger: async (data: unknown) => { + if (!(data instanceof Tab)) return + + for (const tabSystem of TabManager.tabSystems.value) { + if (tabSystem.hasTab(data)) { + const tabs = [...tabSystem.tabs.value] + + let active = false + + for (const tab of tabs) { + if (tab.id === data.id) { + active = true + + continue + } + + if (!active) continue + + await TabManager.removeTabSafe(tab) + } + + break + } + } + }, + name: 'actions.tabs.closeToRight.name', + description: 'actions.tabs.closeToRight.description', + icon: 'tab_close_right', + visible: false, + requiresContext: true, + category: 'actions.tabs.name', + }) + ) + + const closeSaved = ActionManager.addAction( + new Action({ + id: 'tabs.closeSaved', + trigger: async () => { + const tabSystems = [...TabManager.tabSystems.value] + + for (const tabSystem of tabSystems) { + const tabs = [...tabSystem.tabs.value] + + for (const tab of tabs) { + if (tab instanceof FileTab && tab.modified.value) continue + + await TabManager.removeTab(tab) + } + } + }, + name: 'actions.tabs.closeSaved.name', + description: 'actions.tabs.closeSaved.description', + icon: 'save', + visible: false, + category: 'actions.tabs.name', + }) + ) + + const closeOther = ActionManager.addAction( + new Action({ + id: 'tabs.closeOther', + trigger: async (data: unknown) => { + if (!(data instanceof Tab)) return + + for (const tabSystem of TabManager.tabSystems.value) { + const tabs = [...tabSystem.tabs.value] + + for (const tab of tabs) { + if (tab.id === data.id) continue + + await TabManager.removeTabSafe(tab) + } + } + }, + name: 'actions.tabs.closeOther.name', + description: 'actions.tabs.closeOther.description', + icon: 'tab_close_inactive', + visible: false, + requiresContext: true, + category: 'actions.tabs.name', + }) + ) + + const splitscreen = ActionManager.addAction( + new Action({ + id: 'tabs.splitscreen', + trigger: async (tab: unknown) => { + if (!(tab instanceof Tab)) return + + if (TabManager.tabSystems.value.length < 2) await TabManager.addTabSystem() + + const otherTabSystem = TabManager.tabSystems.value.find( + (tabSystem) => TabManager.focusedTabSystem.value?.id !== tabSystem.id + ) + + if (!otherTabSystem) return + + await TabManager.removeTabSafe(tab) + + TabManager.focusTabSystem(otherTabSystem) + + await TabManager.openTab(tab) + }, + name: 'actions.tabs.splitscreen.name', + description: 'actions.tabs.splitscreen.description', + icon: 'splitscreen_right', + requiresContext: true, + visible: false, + category: 'actions.tabs.name', + }) + ) + + const keepOpen = ActionManager.addAction( + new Action({ + id: 'tabs.keepOpen', + trigger: async (tab: unknown) => { + if (!(tab instanceof Tab)) return + + tab.temporary.value = false + }, + name: 'actions.tabs.keepOpen.name', + description: 'actions.tabs.keepOpen.description', + icon: 'keep', + requiresContext: true, + visible: false, + category: 'actions.tabs.name', + }) + ) + + for (const action of [close, closeAll, closeToRight, closeSaved, closeOther, splitscreen, keepOpen]) { + TabManager.focusedTabSystemChanged.on(() => { + action.setVisible( + TabManager.focusedTabSystem.value !== null && + TabManager.focusedTabSystem.value.selectedTab.value !== null && + TabManager.focusedTabSystem.value.selectedTab.value instanceof FileTab + ) + }) + } +} diff --git a/src/libs/actions/export/ExportActionManager.ts b/src/libs/actions/export/ExportActionManager.ts new file mode 100644 index 000000000..1103ec058 --- /dev/null +++ b/src/libs/actions/export/ExportActionManager.ts @@ -0,0 +1,43 @@ +import { Event } from '@/libs/event/Event' +import { onMounted, onUnmounted, shallowRef, ShallowRef } from 'vue' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { ActionManager } from '../ActionManager' + +export class ExportActionManager { + public static actions: string[] = [] + + public static updated: Event = new Event() + + public static addAction(action: string) { + this.actions.push(action) + + this.updated.dispatch() + } + + public static removeAction(action: string) { + this.actions.splice(this.actions.indexOf(action), 1) + + this.updated.dispatch() + } +} + +export function useExportActions(): ShallowRef { + const current: ShallowRef = shallowRef(ExportActionManager.actions) + + function update() { + current.value = [...ExportActionManager.actions.filter((action) => ActionManager.actions[action]?.visible)] + } + + const disposables: Disposable[] = [] + + onMounted(() => { + disposables.push(ExportActionManager.updated.on(update)) + disposables.push(ActionManager.actionsUpdated.on(update)) + }) + + onUnmounted(() => { + disposeAll(disposables) + }) + + return current +} diff --git a/src/libs/actions/export/ExportActions.ts b/src/libs/actions/export/ExportActions.ts new file mode 100644 index 000000000..74baddcbc --- /dev/null +++ b/src/libs/actions/export/ExportActions.ts @@ -0,0 +1,8 @@ +import { ExportActionManager } from './ExportActionManager' + +export function setupExportActions() { + ExportActionManager.addAction('export.exportBrProject') + ExportActionManager.addAction('export.exportMcAddon') + ExportActionManager.addAction('export.exportMcWorld') + ExportActionManager.addAction('export.exportMcTemplate') +} diff --git a/src/libs/actions/file/FileActionManager.ts b/src/libs/actions/file/FileActionManager.ts new file mode 100644 index 000000000..cee527cba --- /dev/null +++ b/src/libs/actions/file/FileActionManager.ts @@ -0,0 +1,54 @@ +import { Event } from '@/libs/event/Event' +import { onMounted, onUnmounted, shallowRef, ShallowRef } from 'vue' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { ActionManager } from '../ActionManager' + +export class FileActionManager { + public static actions: { action: string; fileTypes: string[] }[] = [] + + public static updated: Event = new Event() + + public static addAction(action: string, fileTypes: string[]) { + this.actions.push({ action, fileTypes }) + + this.updated.dispatch() + } + + public static removeAction(action: string) { + this.actions.splice( + this.actions.findIndex((item) => item.action === action), + 1 + ) + + this.updated.dispatch() + } +} + +export function useFileActions(fileType: string): ShallowRef { + const current: ShallowRef = shallowRef( + FileActionManager.actions + .filter((item) => item.fileTypes.includes(fileType) && ActionManager.actions[item.action]?.visible) + .map((item) => item.action) + ) + + function update() { + current.value = [ + ...FileActionManager.actions + .filter((item) => item.fileTypes.includes(fileType) && ActionManager.actions[item.action]?.visible) + .map((item) => item.action), + ] + } + + const disposables: Disposable[] = [] + + onMounted(() => { + disposables.push(FileActionManager.updated.on(update)) + disposables.push(ActionManager.actionsUpdated.on(update)) + }) + + onUnmounted(() => { + disposeAll(disposables) + }) + + return current +} diff --git a/src/libs/actions/file/FileActions.ts b/src/libs/actions/file/FileActions.ts new file mode 100644 index 000000000..ec67b9e19 --- /dev/null +++ b/src/libs/actions/file/FileActions.ts @@ -0,0 +1,3 @@ +import { FileActionManager } from './FileActionManager' + +export function setupFileActions() {} diff --git a/src/libs/actions/tab/TabActionManager.ts b/src/libs/actions/tab/TabActionManager.ts new file mode 100644 index 000000000..ceab3446d --- /dev/null +++ b/src/libs/actions/tab/TabActionManager.ts @@ -0,0 +1,77 @@ +import { Event } from '@/libs/event/Event' +import { onMounted, onUnmounted, shallowRef, ShallowRef } from 'vue' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { ActionManager } from '../ActionManager' + +export class TabActionManager { + public static actions: { action: string; fileTypes: string[] }[] = [] + + public static updated: Event = new Event() + + public static addAction(action: string, fileTypes: string[]) { + this.actions.push({ action, fileTypes }) + + this.updated.dispatch() + } + + public static removeAction(action: string) { + this.actions.splice( + this.actions.findIndex((item) => item.action === action), + 1 + ) + + this.updated.dispatch() + } +} + +export function useTabActions(): ShallowRef<{ action: string; fileTypes: string[] }[]> { + const current: ShallowRef<{ action: string; fileTypes: string[] }[]> = shallowRef( + TabActionManager.actions.filter((item) => ActionManager.actions[item.action]?.visible) + ) + + function update() { + current.value = [...TabActionManager.actions.filter((item) => ActionManager.actions[item.action]?.visible)] + } + + const disposables: Disposable[] = [] + + onMounted(() => { + disposables.push(TabActionManager.updated.on(update)) + disposables.push(ActionManager.actionsUpdated.on(update)) + }) + + onUnmounted(() => { + disposeAll(disposables) + }) + + return current +} + +export function useTabActionsForFileType(fileType: string): ShallowRef { + const current: ShallowRef = shallowRef( + TabActionManager.actions + .filter((item) => item.fileTypes.includes(fileType) && ActionManager.actions[item.action]?.visible) + .map((item) => item.action) + ) + + function update() { + current.value = [ + ...TabActionManager.actions + .filter((item) => item.fileTypes.includes(fileType) && ActionManager.actions[item.action]?.visible) + .map((item) => item.action), + ] + } + + const disposables: Disposable[] = [] + + onMounted(() => { + disposables.push(TabActionManager.updated.on(update)) + disposables.push(ActionManager.actionsUpdated.on(update)) + }) + + onUnmounted(() => { + disposeAll(disposables) + }) + + return current +} diff --git a/src/libs/actions/tab/TabActions.ts b/src/libs/actions/tab/TabActions.ts new file mode 100644 index 000000000..13768641d --- /dev/null +++ b/src/libs/actions/tab/TabActions.ts @@ -0,0 +1,3 @@ +import { TabActionManager } from './TabActionManager' + +export function setupTabActions() {} diff --git a/src/libs/app/AppEnv.ts b/src/libs/app/AppEnv.ts new file mode 100644 index 000000000..02f3b486f --- /dev/null +++ b/src/libs/app/AppEnv.ts @@ -0,0 +1,13 @@ +import packageConfig from '../../../package.json' + +export const appVersion = packageConfig.version + +export const baseUrl = import.meta.env.BASE_URL + +let dashVersionTemp = packageConfig.dependencies['@bridge-editor/dash-compiler'] + +if (dashVersionTemp.startsWith('^') || dashVersionTemp.startsWith('~') || dashVersionTemp.startsWith('>') || dashVersionTemp.startsWith('<')) + dashVersionTemp = dashVersionTemp.substring(1) +else if (dashVersionTemp.startsWith('>=') || dashVersionTemp.startsWith('<=')) dashVersionTemp = dashVersionTemp.substring(2) + +export const dashVersion = dashVersionTemp diff --git a/src/components/App/ServiceWorker.ts b/src/libs/app/PWAServiceWorker.ts similarity index 55% rename from src/components/App/ServiceWorker.ts rename to src/libs/app/PWAServiceWorker.ts index 45b7aa0f2..cb56d7d3a 100644 --- a/src/components/App/ServiceWorker.ts +++ b/src/libs/app/PWAServiceWorker.ts @@ -1,8 +1,6 @@ -/* eslint-disable no-console */ - import { registerSW } from 'virtual:pwa-register' -import { createNotification } from '/@/components/Notifications/create' import { set } from 'idb-keyval' +import { NotificationSystem } from '@/components/Notifications/NotificationSystem' const updateSW = registerSW({ async onNeedRefresh() { @@ -10,13 +8,7 @@ const updateSW = registerSW({ await set('firstStartAfterUpdate', true) - createNotification({ - icon: 'mdi-update', - color: 'primary', - message: 'sidebar.notifications.updateAvailable.message', - textColor: 'white', - onClick: () => updateSW(), - }) + NotificationSystem.addNotification('upgrade', () => updateSW()) }, onOfflineReady() { // bridge. is ready to work offline diff --git a/src/libs/compiler/DashService.ts b/src/libs/compiler/DashService.ts new file mode 100644 index 000000000..7113c5dc8 --- /dev/null +++ b/src/libs/compiler/DashService.ts @@ -0,0 +1,243 @@ +import { basename, join } from 'pathe' +import DashWorker from './DashWorker?worker' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { WorkerFileSystemEntryPoint } from '@/libs/fileSystem/WorkerFileSystem' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { sendAndWait } from '@/libs/worker/Communication' +import { v4 as uuid } from 'uuid' +import { Data } from '@/libs/data/Data' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { Disposable, AsyncDisposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { NotificationSystem, Notification } from '@/components/Notifications/NotificationSystem' +import { ActionManager } from '@/libs/actions/ActionManager' +import { Action } from '@/libs/actions/Action' + +/** + * This is the front facing interface for the Dash Compiler. + * It also handles automatic recompilation when a file change has been detected. + * The Dash Service interacts with the Dash Worker to offload compilation tasks onto a new thread. + */ +export class DashService implements AsyncDisposable { + public logs: string[] = [] + public isSetup: boolean = false + public profiles: Action[] = [] + + private worker = new DashWorker() + private inputFileSystem = new WorkerFileSystemEntryPoint(this.worker, fileSystem, 'inputFileSystem') + private outputFileSystem: WorkerFileSystemEntryPoint + private progressNotification: Notification | null = null + private inFlightBuildRequest: boolean = false + private building: boolean = false + private watchRebuildRequestId: string | null = null + private lastProfile: string | null = null + private mode: 'development' | 'production' = 'development' + private disposables: Disposable[] = [] + + constructor(public project: BedrockProject, fileSystem?: BaseFileSystem) { + this.worker.onmessage = this.onWorkerMessage.bind(this) + + this.outputFileSystem = new WorkerFileSystemEntryPoint(this.worker, fileSystem ?? project.outputFileSystem, 'outputFileSystem') + } + + private async onWorkerMessage(event: MessageEvent) { + if (!event.data) return + + if (event.data.action === 'getJsonData') { + this.worker.postMessage({ + id: event.data.id, + data: await Data.get(event.data.path), + }) + } + + if (event.data.action === 'log') { + this.logs.push(event.data.message) + } + + if (event.data.action === 'progress') { + if (this.progressNotification === null) return + + NotificationSystem.setProgress(this.progressNotification, event.data.progress) + } + } + + /** + * Sets up the dash compiler to be configured in a specific build mode and option alcompiler config. This should be called before attempting to build. + * @param mode + * @param compilerConfigPath + */ + public async setup(mode: 'development' | 'production', compilerConfigPath?: string) { + this.mode = mode + + await sendAndWait( + { + action: 'setup', + config: this.project.config, + mode, + configPath: join(this.project.path, 'config.json'), + compilerConfigPath, + }, + this.worker + ) + + this.isSetup = true + } + + /** + * Initially sets up Dash for devlopment projects and creates the compiler profile actions. + */ + public async setupForDevelopmentProject() { + await this.setupCompileActions() + + await this.setup('development') + + this.disposables.push(fileSystem.pathUpdated.on(this.pathUpdated.bind(this))) + } + + public async dispose() { + this.worker.terminate() + + this.inputFileSystem.dispose() + this.outputFileSystem.dispose() + + this.removeCompileActions() + + disposeAll(this.disposables) + } + + public setOutputFileSystem(fileSystem: BaseFileSystem) { + this.outputFileSystem.dispose() + + this.outputFileSystem = new WorkerFileSystemEntryPoint(this.worker, fileSystem, 'outputFileSystem') + } + + /** + * Triggers a compilation. If a compilation is already in progress it will be queued up and triggered once the current compilation is completed. + * @returns + */ + public async build() { + if (this.building) { + this.inFlightBuildRequest = true + + return + } + + this.building = true + + this.progressNotification = NotificationSystem.addProgressNotification('manufacturing', 0, 1, undefined, undefined) + + await sendAndWait( + { + action: 'build', + }, + this.worker + ) + + NotificationSystem.clearNotification(this.progressNotification) + + this.building = false + + if (this.inFlightBuildRequest) { + this.inFlightBuildRequest = false + + this.build() + } + } + + public async compileFiles(paths: string[]) { + this.progressNotification = NotificationSystem.addProgressNotification('manufacturing', 0, 1, undefined, undefined) + + await sendAndWait( + { + action: 'compileFiles', + paths, + }, + this.worker + ) + + NotificationSystem.clearNotification(this.progressNotification) + } + + private async setupCompileActions() { + this.profiles.push( + ActionManager.addAction( + new Action({ + id: 'dash.compileDefault', + trigger: async () => { + if (this.lastProfile !== null) await this.setup(this.mode) + + this.lastProfile = null + + this.build() + }, + keyBinding: 'Ctrl + B', + name: 'actions.dash.compileDefault.name', + description: 'actions.dash.compileDefault.description', + icon: 'manufacturing', + category: 'actions.dash.name', + }) + ) + ) + + if (!(await fileSystem.exists(join(this.project.path, '.bridge/compiler')))) return + + const profileEntries = await fileSystem.readDirectoryEntries(join(this.project.path, '.bridge/compiler')) + + for (const entry of profileEntries) { + let profile: null | any = null + + try { + profile = await fileSystem.readFileJson(entry.path) + } catch {} + + if (profile === null) continue + + if (!profile.name) continue + + this.profiles.push( + ActionManager.addAction( + new Action({ + id: 'dash.compileProfile-' + basename(entry.path), + trigger: async () => { + if (this.lastProfile !== entry.path) await this.setup(this.mode, entry.path) + + this.lastProfile = entry.path + + this.build() + }, + name: profile.name, + description: profile.description ?? undefined, + icon: profile.icon ?? 'manufacturing', + category: 'actions.dash.name', + }) + ) + ) + } + } + + private removeCompileActions() { + for (const profile of this.profiles) { + ActionManager.removeAction(profile) + } + } + + private pathUpdated(path: unknown) { + if (typeof path !== 'string') return + + if (!path.startsWith(this.project.path)) return + + if (path.startsWith(join(this.project.path, '.bridge'))) return + if (path.startsWith(join(this.project.path, '.git'))) return + + if (fileSystem.modificationsAnnounced()) return + + const requestId = uuid() + + this.watchRebuildRequestId = requestId + + setTimeout(() => { + if (this.watchRebuildRequestId !== requestId) return + + this.build() + }, 10) + } +} diff --git a/src/libs/compiler/DashWorker.ts b/src/libs/compiler/DashWorker.ts new file mode 100644 index 000000000..ec84dc52e --- /dev/null +++ b/src/libs/compiler/DashWorker.ts @@ -0,0 +1,129 @@ +import { Dash, initRuntimes } from '@bridge-editor/dash-compiler' +import { CompatabilityFileSystem } from '@/libs/fileSystem/CompatabilityFileSystem' +import { WorkerFileSystemEndPoint } from '@/libs/fileSystem/WorkerFileSystem' +import { CompatabilityFileType } from '@/libs/data/compatability/FileType' +import { CompatabilityPackType } from '../data/compatability/PackType' +import { sendAndWait } from '../worker/Communication' +import wasmUrl from '@swc/wasm-web/wasm-web_bg.wasm?url' +import { initRuntimes as initJsRuntimes } from '@bridge-editor/js-runtime' + +initJsRuntimes(wasmUrl) +initRuntimes(wasmUrl) + +//@ts-ignore make path browserify work in web worker +globalThis.process = { + cwd: () => '', + env: {}, + release: { + name: 'browser', + }, +} + +const inputFileSystem = new WorkerFileSystemEndPoint('inputFileSystem') +const compatabilityInputFileSystem = new CompatabilityFileSystem(inputFileSystem) +const outputFileSystem = new WorkerFileSystemEndPoint('outputFileSystem') +const compatabilityOutputFileSystem = new CompatabilityFileSystem(outputFileSystem) + +let dash: null | Dash<{ fileTypes: any; packTypes: any }> = null + +async function getJsonData(path: string): Promise { + if (path.startsWith('data/')) path = path.slice('path/'.length) + + return ( + await sendAndWait({ + action: 'getJsonData', + path, + }) + ).data +} + +async function setup(config: any, mode: 'development' | 'production', configPath: string, compilerConfigPath: string | undefined, actionId: string) { + const packType = new CompatabilityPackType(config) + const fileType = new CompatabilityFileType(config, () => false) + + dash = new Dash<{ fileTypes: any; packTypes: any }>(compatabilityInputFileSystem, compatabilityOutputFileSystem, { + config: configPath, + compilerConfig: compilerConfigPath, + packType, + fileType, + requestJsonData: getJsonData, + console: { + log(...args: any[]) { + postMessage({ + action: 'log', + message: args.join(' '), + }) + + console.log(...args) + }, + error(...args: any[]) { + postMessage({ + action: 'log', + message: args.join(' '), + }) + + console.error(...args) + }, + time: console.time, + timeEnd: console.timeEnd, + }, + mode, + }) + + dash.progress.onChange((progress: { percentage: number }) => { + postMessage({ + action: 'progress', + progress: progress.percentage, + }) + }) + + await dash.setup({ + fileTypes: await getJsonData('packages/minecraftBedrock/fileDefinitions.json'), + packTypes: await getJsonData('packages/minecraftBedrock/packDefinitions.json'), + }) + + postMessage({ + action: 'setupComplete', + id: actionId, + }) +} + +async function build(actionId: string) { + if (!dash) { + console.warn('Tried building but Dash is not setup yet!') + + return + } + + await dash.build() + + postMessage({ + action: 'buildComplete', + id: actionId, + }) +} + +async function compileFiles(actionId: string, paths: string[]) { + if (!dash) { + console.warn('Tried compiling files but Dash is not setup yet!') + + return + } + + await dash.updateFiles(paths) + + postMessage({ + action: 'compileFilesComplete', + id: actionId, + }) +} + +onmessage = (event: any) => { + if (!event.data) return + + if (event.data.action === 'setup') setup(event.data.config, event.data.mode, event.data.configPath, event.data.compilerConfigPath, event.data.id) + + if (event.data.action === 'build') build(event.data.id) + + if (event.data.action === 'compileFiles') compileFiles(event.data.id, event.data.paths) +} diff --git a/src/libs/data/Data.ts b/src/libs/data/Data.ts new file mode 100644 index 000000000..9bce1eab3 --- /dev/null +++ b/src/libs/data/Data.ts @@ -0,0 +1,168 @@ +import { baseUrl } from '@/libs/app/AppEnv' +import { unzip, Unzipped } from 'fflate' +import { LocalFileSystem } from '@/libs/fileSystem/LocalFileSystem' +import { onMounted, onUnmounted, ref, Ref } from 'vue' +import { Event } from '@/libs/event/Event' +import { Disposable } from '@/libs/disposeable/Disposeable' +import { Windows } from '@/components/Windows/Windows' +import { AlertWindow } from '@/components/Windows/Alert/AlertWindow' +import { Settings } from '@/libs/settings/Settings' +import { NotificationSystem } from '@/components/Notifications/NotificationSystem' + +export interface FormatVersionDefinitions { + currentStable: string + formatVersions: string[] +} + +export interface ExperimentalToggle { + name: string + id: string + description: string + icon: string +} + +/** + * Handles loading the dynamic data. + * Will attempt to check for updated data on the remote repository and update the data if necessary, otherwise the built in fallback data will attempt to be loaded. + * Hashes are used to track wether the remote data is updated. If the current hash doesn't match the remote data hash, we assume we need to update the data. + */ +export class Data { + public static loaded: Event = new Event() + + private static fileSystem = new LocalFileSystem() + + public static async load() { + await Settings.addSetting('dataDeveloperMode', { + default: false, + }) + + Data.fileSystem.setRootName('data') + + let hash: string | undefined = undefined + + try { + hash = await fetch('https://raw.githubusercontent.com/bridge-core/editor-packages/release/hash', { + cache: 'no-cache', + }).then((response) => response.text()) + } catch {} + + let packagesUrl = 'https://raw.githubusercontent.com/bridge-core/editor-packages/release/packages.zip' + + if (Settings.get('dataDeveloperMode')) { + if (await Data.fileSystem.exists('hash')) await Data.fileSystem.removeFile('hash') + + hash = undefined + } + + if (hash === undefined) { + if (await Data.fileSystem.exists('hash')) { + console.log('[Data] Failed to fetch hash but cache exists') + + Data.loaded.dispatch() + + return + } else { + console.log('[Data] Failed to fetch hash, falling back to built in data') + + packagesUrl = baseUrl + 'packages.zip' + + try { + hash = await fetch(baseUrl + 'hash', { + cache: 'no-cache', + }).then((response) => response.text()) + } catch {} + + if (hash === undefined) throw new Error('Failed to load fallback data!') + } + } + + if ((await Data.fileSystem.exists('hash')) && (await Data.fileSystem.readFileText('hash')) === hash) { + console.log('[Data] Skipped fetching data because hash matches') + + Data.loaded.dispatch() + + return + } + + console.log('[Data] Fetching data') + + NotificationSystem.addNotification( + 'package_2', + () => { + Windows.open(new AlertWindow('A new data update has been installed.')) + }, + 'primary' + ) + + const rawData = await fetch(packagesUrl, { + cache: 'no-cache', + }).then((response) => response.arrayBuffer()) + + const unzipped = await new Promise((resolve, reject) => + unzip(new Uint8Array(rawData), async (error, zip) => { + if (error) return reject(error) + + resolve(zip) + }) + ) + + for (const path in unzipped) { + if (path.endsWith('/')) { + Data.fileSystem.makeDirectory(path) + } else { + Data.fileSystem.writeFile(path, unzipped[path]) + } + } + + await Data.fileSystem.writeFile('hash', hash) + + Data.loaded.dispatch() + } + + /** + * Gets JSON formatted data from the data path + * @param path + * @returns JSON object data + */ + public static async get(path: string): Promise { + return await Data.fileSystem.readFileJson(path) + } + + /** + * Gets string data from the data path + * @param path + * @returns + */ + public static async getText(path: string): Promise { + return await Data.fileSystem.readFileText(path) + } + + /** + * Gets raw array buffer data from the data path + * @param path + * @returns + */ + public static async getRaw(path: string): Promise { + return await Data.fileSystem.readFile(path) + } +} + +export function useGetData(): Ref<(path: string) => undefined | any> { + const get = ref((path: string) => Data.get(path)) + + function updateGet() { + get.value = (path: string) => Data.get(path) + } + + let disposable: Disposable + + onMounted(() => { + disposable = Data.loaded.on(updateGet) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return get +} diff --git a/src/libs/data/bedrock/CommandData.ts b/src/libs/data/bedrock/CommandData.ts new file mode 100644 index 000000000..2ce02bbb3 --- /dev/null +++ b/src/libs/data/bedrock/CommandData.ts @@ -0,0 +1,110 @@ +import { BedrockProject } from '@/libs/project/BedrockProject' +import { Data } from '../Data' + +export interface Argument { + type: + | 'string' + | 'boolean' + | 'selector' + | '$coordinates' + | 'number' + | 'coordinate' + | 'blockState' + | 'subcommand' + | 'command' + argumentName: string + additionalData?: { + values?: string[] + schemaReference?: string + } + isOptional?: boolean + allowMultiple?: boolean +} + +export interface SelectorArgument { + type: 'string' | 'boolean' | 'scoreData' | 'number' // seperate string and identifier. name = "works" but type = "type" doesn't + argumentName: string + additionalData?: { + values?: string[] + schemaReference?: string + multipleInstancesAllowed?: 'never' | 'whenNegated' | 'always' + supportsNegation?: boolean + } +} + +export interface Command { + commandName: string + description: string + arguments: Argument[] +} + +export interface Subommand { + commandName: string + commands: Command[] +} + +export class CommandData { + private data: any + + constructor(public project: BedrockProject) {} + + public async setup() { + this.data = await Data.get(`packages/minecraftBedrock/language/mcfunction/main.json`) + } + + public getCommands(): Command[] { + let commands: Command[] = [] + + for (const entry of this.data.vanilla) { + if (entry.requires !== undefined && !this.project.requirementsMatcher.matches(entry.requires)) continue + + commands = commands.concat(entry.commands) + } + + commands = commands.concat( + Object.entries(this.project.schemaData.dashComponentsData.get('command')).flatMap(([name, schemas]) => { + return schemas.map((schema: any) => ({ + commandName: name, + description: schema.description, + arguments: schema.arguments, + })) + }) + ) + + commands = commands.filter((command) => command) + + return commands + } + + public getSubcommands(): Subommand[] { + let commands: Subommand[] = [] + + for (const entry of this.data.vanilla) { + if (entry.requires !== undefined && !this.project.requirementsMatcher.matches(entry.requires)) continue + + commands = commands.concat(entry.subcommands) + } + + commands = commands.filter((command) => command) + + return commands + } + + public getSelectorArguments(): SelectorArgument[] { + let selectorArguments: SelectorArgument[] = [] + + for (const entry of this.data.vanilla) { + if (entry.requires !== undefined && !this.project.requirementsMatcher.matches(entry.requires)) continue + + selectorArguments = selectorArguments.concat(entry.selectorArguments) + } + + selectorArguments = selectorArguments.filter((selectorArguments) => selectorArguments) + + return selectorArguments + } + + public getCustomTypes(): Record { + return this.data.$customTypes + } +} diff --git a/src/libs/data/bedrock/DashData.ts b/src/libs/data/bedrock/DashData.ts new file mode 100644 index 000000000..e6ef22567 --- /dev/null +++ b/src/libs/data/bedrock/DashData.ts @@ -0,0 +1,170 @@ +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { Runtime } from '@/libs/runtime/Runtime' +import { Command, Component, DefaultConsole } from '@bridge-editor/dash-compiler' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' + +export class DashData implements Disposable { + private schemas: Record> = { + block: {}, + item: {}, + entity: {}, + command: {}, + } + + private components: Record = {} + private commands: Record = {} + + private disposables: Disposable[] = [] + + private componentsPath: string = '' + private commandsPath: string = '' + + public constructor(public project: BedrockProject) {} + + public async setup() { + this.componentsPath = this.project.resolvePackPath('behaviorPack', 'components') + this.commandsPath = this.project.resolvePackPath('behaviorPack', 'commands') + + this.disposables.push( + fileSystem.pathUpdated.on((path: unknown) => { + this.pathUpdated(path as string) + }) + ) + + if (await fileSystem.exists(this.componentsPath)) await this.generateSchemasInPath(this.componentsPath) + if (await fileSystem.exists(this.commandsPath)) await this.generateSchemasInPath(this.commandsPath) + } + + public dispose() { + disposeAll(this.disposables) + } + + public get(type: string): Record { + return this.schemas[type] + } + + private async pathUpdated(path: string) { + if (!path.startsWith(this.componentsPath) && !path.startsWith(this.commandsPath)) return + + if (!(await fileSystem.exists(path))) { + const componentsToRemove = Object.keys(this.components).filter((path) => path.startsWith(path)) + + for (const path of componentsToRemove) { + this.removeComponent(path) + } + + return + } + + const entry = await fileSystem.getEntry(path) + + if (entry.kind === 'directory') { + this.generateSchemasInPath(path) + } else { + if (path.startsWith(this.componentsPath)) { + await this.generateComponentSchema(entry.path) + } else { + await this.generateCommandSchema(entry.path) + } + } + } + + private async generateSchemasInPath(path: string) { + for (const entry of await fileSystem.readDirectoryEntries(path)) { + if (entry.kind === 'directory') { + await this.generateSchemasInPath(entry.path) + } else { + if (path.startsWith(this.componentsPath)) { + await this.generateComponentSchema(entry.path) + } else { + await this.generateCommandSchema(entry.path) + } + } + } + } + + private async generateCommandSchema(path: string) { + try { + const script = await fileSystem.readFileText(path) + + const command = new Command(new DefaultConsole(), script, 'development', false) + + const jsRuntime = new Runtime(fileSystem) + + const loadedResult = await command.load(jsRuntime, path, 'client') + // NOTE: Dash compiler doesn't return a truthy value when the command loads so doing this a temporary fix untill I update the dash compiler again + const loadedCorrectly = loadedResult !== null && loadedResult !== false + + if (!loadedCorrectly || !command.name || !command.getSchema()) { + console.warn('Failed to load dash command', path) + + this.removeCommand(path) + + return + } + + if (this.components[path]) delete this.schemas.command[this.components[path].name!] + + this.schemas.command[command.name] = command.getSchema() + + this.commands[path] = command + } catch { + console.warn('Failed to load dash command', path) + + this.removeCommand(path) + } + } + + private async generateComponentSchema(path: string) { + const fileType = path.replace(this.componentsPath + '/', '').split('/')[0] + + try { + const script = await fileSystem.readFileText(path) + + const component = new Component(new DefaultConsole(), fileType, script, 'development', false) + + const jsRuntime = new Runtime(fileSystem) + + const loadedCorrectly = await component.load(jsRuntime, path, 'client') + + if (!loadedCorrectly || !component.name || !component.getSchema()) { + console.warn('Failed to load dash component', path) + + this.removeComponent(path) + + return + } + + if (this.components[path]) delete this.schemas[fileType][this.components[path].name!] + + this.schemas[fileType][component.name] = component.getSchema() + + this.components[path] = component + } catch { + console.warn('Failed to load dash component', path) + + this.removeComponent(path) + } + } + + private removeComponent(path: string) { + if (!this.components[path]) return + + const fileType = path.replace(this.componentsPath + '/', '').split('/')[0] + + const name = this.components[path].name! + + delete this.schemas[fileType][name] + delete this.components[path] + } + + private removeCommand(path: string) { + if (!this.commands[path]) return + + const name = this.commands[path].name! + + delete this.schemas.command[name] + delete this.commands[path] + } +} diff --git a/src/libs/data/bedrock/FileTypeData.ts b/src/libs/data/bedrock/FileTypeData.ts new file mode 100644 index 000000000..39cf048b8 --- /dev/null +++ b/src/libs/data/bedrock/FileTypeData.ts @@ -0,0 +1,141 @@ +import { basename, extname, join, sep } from 'pathe' +import { hasAnyPath, isMatch } from 'bridge-common-utils' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { Data } from '@/libs/data/Data' +import { TPackTypeId } from 'mc-project-core' +import * as JSONC from 'jsonc-parser' +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' + +/** + * Handles determining the file types of a files and providing the file definition data associated with those file types + */ +export class FileTypeData { + public fileTypes: any[] = [] + + public async load() { + this.fileTypes = await Data.get('packages/minecraftBedrock/fileDefinitions.json') + } + + private generateMatchers(packTypes: string[], matchers: string[]) { + if (ProjectManager.currentProject === null) return [] + + if (packTypes.length === 0) return matchers.map((matcher) => ProjectManager.currentProject!.resolvePackPath(undefined, matcher)) + + const paths: string[] = [] + + for (const packType of packTypes) { + for (const matcher of matchers) { + paths.push(ProjectManager.currentProject!.resolvePackPath(packType, matcher)) + } + } + + return paths + } + + public get(path: string): null | any { + if (!ProjectManager.currentProject) return null + if (!ProjectManager.currentProject.config) return null + + const projectRelativePath = path.split(sep).slice(3).join(sep) + + for (const fileType of this.fileTypes) { + const hasExtensionMatch = fileType.detect !== undefined && fileType.detect.fileExtensions !== undefined + + if (hasExtensionMatch) { + if (!fileType.detect.fileExtensions.includes(extname(projectRelativePath))) continue + } + + const packTypes = ( + fileType.detect.packType === undefined + ? [] + : Array.isArray(fileType.detect.packType) + ? fileType.detect.packType + : [fileType.detect.packType] + ).filter((packType: string) => (ProjectManager.currentProject!.config?.packs)[packType] !== undefined) + + const hasScopeMatch = fileType.detect !== undefined && fileType.detect.scope !== undefined + + if (hasScopeMatch) { + const scopes = Array.isArray(fileType.detect.scope) ? fileType.detect.scope : [fileType.detect.scope] + + if (!this.generateMatchers(packTypes, scopes).some((scope) => path.startsWith(scope))) continue + } + + const hasPathMatchers = fileType.detect !== undefined && fileType.detect.matcher !== undefined + + if (hasPathMatchers) { + const pathMatchers = Array.isArray(fileType.detect.matcher) ? fileType.detect.matcher : [fileType.detect.matcher] + + const mustNotMatch = this.generateMatchers( + packTypes, + pathMatchers.filter((match: string) => match.startsWith('!')).map((match: string) => match.slice(1)) + ) + + const anyMatch = this.generateMatchers( + packTypes, + pathMatchers.filter((match: string) => !match.startsWith('!')) + ) + + if (!isMatch(path, anyMatch)) continue + if (isMatch(path, mustNotMatch)) continue + } + + return fileType + } + + return null + } + + public async guessFolder(entry: BaseEntry): Promise { + // Helper function + const getStartPath = (scope: string | string[], packId: TPackTypeId) => { + let startPath = Array.isArray(scope) ? scope[0] : scope + if (!startPath.endsWith('/')) startPath += '/' + + const packPath = ProjectManager.currentProject?.resolvePackPath(packId) ?? './unknown' + + return join(packPath, startPath) + } + + const extension = `.${basename(entry.path).split('.').pop()!}` + // 1. Guess based on file extension + const validTypes = this.fileTypes.filter(({ detect }) => { + if (!detect || !detect.scope) return false + + return detect.fileExtensions?.includes(extension) + }) + + const onlyOneExtensionMatch = validTypes.length === 1 + const notAJsonFileButMatch = extension !== '.json' && validTypes.length > 0 + + if (onlyOneExtensionMatch || notAJsonFileButMatch) { + const { detect } = validTypes[0] + + return getStartPath(detect!.scope!, Array.isArray(detect!.packType) ? detect!.packType[0] : detect!.packType ?? 'behaviorPack') + } + + if (extension !== '.json') return null + + // 2. Guess based on json file content + let json: any + try { + json = JSONC.parse(await entry.readText()) + } catch { + return null + } + + for (const { type, detect } of validTypes) { + if (typeof type === 'string' && type !== 'json') continue + + const { scope, fileContent, packType = 'behaviorPack' } = detect ?? {} + + if (!scope || !fileContent) continue + + if (!hasAnyPath(json, fileContent)) continue + + return getStartPath(scope, Array.isArray(packType) ? packType[0] : packType) + } + + return null + } +} diff --git a/src/libs/data/bedrock/FormatVersion.ts b/src/libs/data/bedrock/FormatVersion.ts new file mode 100644 index 000000000..06f0d663d --- /dev/null +++ b/src/libs/data/bedrock/FormatVersion.ts @@ -0,0 +1,7 @@ +import { Data } from '../Data' + +export async function getLatestStableFormatVersion(): Promise { + const formatVersions = await Data.get('/packages/minecraftBedrock/formatVersions.json') + + return formatVersions.currentStable +} diff --git a/src/libs/data/bedrock/LangData.ts b/src/libs/data/bedrock/LangData.ts new file mode 100644 index 000000000..e0a53f780 --- /dev/null +++ b/src/libs/data/bedrock/LangData.ts @@ -0,0 +1,47 @@ +import { BedrockProject } from '@/libs/project/BedrockProject' +import { Data } from '../Data' + +interface Key { + formats: string[] + inject: { + name: string + fileType: string + cacheKey: string + }[] +} + +export class LangData { + private data: any + + constructor(public project: BedrockProject) {} + + public async setup() { + this.data = await Data.get('packages/minecraftBedrock/language/lang/main.json') + } + + public async getKeys() { + let keys: string[] = [] + + for (const key of this.data.keys as Key[]) { + keys = keys.concat(await this.generateKeys(key)) + } + + return keys + } + + private async generateKeys(key: Key) { + let keys: string[] = [] + + for (const fromCache of key.inject) { + const fetchedData = + (await this.project.indexerService.getCachedData(fromCache.fileType, undefined, fromCache.cacheKey)) ?? + [] + + for (const format of key.formats) { + keys = keys.concat(fetchedData.map((data: string) => format.replace(`{{${fromCache.name}}}`, data))) + } + } + + return keys + } +} diff --git a/src/libs/data/bedrock/PresetData.ts b/src/libs/data/bedrock/PresetData.ts new file mode 100644 index 000000000..64afc6314 --- /dev/null +++ b/src/libs/data/bedrock/PresetData.ts @@ -0,0 +1,461 @@ +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { basename, dirname, extname, join } from 'pathe' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { Runtime } from '@/libs/runtime/Runtime' +import { compareVersions, deepMerge } from 'bridge-common-utils' +import { Ref, ref } from 'vue' +import { Data } from '@/libs/data/Data' +import { Extensions } from '@/libs/extensions/Extensions' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' + +export class PresetData implements Disposable { + public presets: { [key: string]: any } = {} + public extensionPresets: { [key: string]: any } = {} + public categories: { [key: string]: string[] } = {} + + private runtime = new Runtime(fileSystem) + + private disposables: Disposable[] = [] + + public async load() { + this.presets = await Data.get('packages/minecraftBedrock/presets.json') + + await this.reloadPresets() + + this.disposables.push(Extensions.updated.on(this.reloadPresets.bind(this))) + } + + public dispose() { + disposeAll(this.disposables) + } + + public getDefaultPresetOptions(presetPath: string) { + const preset = this.presets[presetPath] ?? this.extensionPresets[presetPath] + + const options: any = {} + + if (preset.additionalModels) { + for (const [key, value] of Object.entries(preset.additionalModels)) { + options[key] = value + } + } + + for (const [name, key, fieldOptions] of preset.fields) { + if (!fieldOptions) continue + if (!fieldOptions.default) continue + + options[key] = fieldOptions.default + } + + return options + } + + public async createPreset(presetPath: string, presetOptions: any) { + if (this.extensionPresets[presetPath]) { + this.createExtensionPreset(presetPath, presetOptions) + + return + } + + const project = ProjectManager.currentProject + + if (!project) return + + if (presetOptions.PRESET_PATH && !presetOptions.PRESET_PATH.endsWith('/') && presetOptions.PRESET_PATH !== '') + presetOptions.PRESET_PATH += '/' + + presetOptions.PROJECT_PREFIX = project.config?.namespace + + const preset = this.presets[presetPath] + + const createFiles = preset.createFiles ?? [] + + for (let createFileOptions of createFiles) { + const isScript = typeof createFileOptions === 'string' + + if (isScript) { + let templatePath: string = createFileOptions + + if (templatePath.startsWith('presetScript/')) { + templatePath = join(presetPath.substring('file:///data/'.length).split('/preset/')[0], templatePath) + } else { + templatePath = join(dirname(presetPath.substring('file:///data/'.length)), templatePath) + } + + const script = await Data.getText(templatePath) + + const module: any = {} + + // For some reason if this script runs twice no module exports will exist unless we clear the cache + this.runtime.clearCache() + + const result = await this.runtime.run( + templatePath, + { + module, + }, + script + ) + + module.exports({ + // We are just faking filehandles here since the file system doesn't necesarily use file handles + createFile: async (path: string, handle: any | string, options: any) => { + const packPath = project.packs[options.packPath] ?? project.path + const filePath = join(packPath, path) + + await fileSystem.ensureDirectory(filePath) + await fileSystem.writeFile(filePath, typeof handle === 'string' ? handle : handle.content) + }, + createJSONFile: async (path: string, data: any, options: any) => { + const packPath = project.packs[options.packPath] ?? project.path + const filePath = join(packPath, path) + + await fileSystem.ensureDirectory(filePath) + await fileSystem.writeFileJson(filePath, data, true) + }, + expandFile: async (path: string, data: any, options: any) => { + const packPath = project.packs[options.packPath] ?? project.path + const filePath = join(packPath, path) + + await fileSystem.ensureDirectory(filePath) + + if (await fileSystem.exists(filePath)) { + let existingContent = await fileSystem.readFileText(filePath) + + if (typeof data !== 'string') { + await fileSystem.writeFile( + filePath, + JSON.stringify(deepMerge(JSON.parse(existingContent), data), null, '\t') + ) + } else { + await fileSystem.writeFile(filePath, `${existingContent}\n${data}`) + } + } else { + await fileSystem.writeFile(filePath, typeof data === 'string' ? data : JSON.stringify(data, null, '\t')) + } + }, + loadPresetFile: async (path: string) => { + return { + name: basename(path), + content: await Data.getRaw(join(dirname(presetPath.substring('file:///data/'.length)), path)), + async text() { + return await Data.getText(join(dirname(presetPath.substring('file:///data/'.length)), path)) + }, + } + }, + models: presetOptions, + }) + + continue + } + + let [templatePath, targetPath, templateOptions] = createFileOptions + + templatePath = join(dirname(presetPath.substring('file:///data/'.length)), templatePath) + + let templateContent = null + + if (templatePath.endsWith('.png')) { + templateContent = await Data.getRaw(templatePath) + } else { + templateContent = await Data.getText(templatePath) + } + + if (templateOptions.inject) { + for (const inject of templateOptions.inject) { + targetPath = targetPath.replaceAll('{{' + inject + '}}', presetOptions[inject]) + + if (typeof templateContent === 'string') + templateContent = templateContent.replaceAll('{{' + inject + '}}', presetOptions[inject]) + } + } + + if (templateOptions.packPath) { + targetPath = join(project.packs[templateOptions.packPath], targetPath) + } else { + targetPath = join(project.path, targetPath) + } + + await fileSystem.ensureDirectory(targetPath) + + await fileSystem.writeFile(targetPath, templateContent) + } + + const expandFiles = preset.expandFiles ?? [] + + for (const expandFileOptions of expandFiles) { + let [templatePath, targetPath, templateOptions] = expandFileOptions + + templatePath = join(dirname(presetPath.substring('file:///data/'.length)), templatePath) + + let templateContent = await Data.getText(templatePath) + + if (templateOptions.inject) { + for (const inject of templateOptions.inject) { + targetPath = targetPath.replaceAll('{{' + inject + '}}', presetOptions[inject]) + + templateContent = templateContent.replaceAll('{{' + inject + '}}', presetOptions[inject]) + } + } + + if (templateOptions.packPath) { + targetPath = join(project.packs[templateOptions.packPath], targetPath) + } else { + targetPath = join(project.path, targetPath) + } + + await fileSystem.ensureDirectory(targetPath) + + if (await fileSystem.exists(targetPath)) { + let existingContent = await fileSystem.readFileText(targetPath) + + if (extname(templatePath) === '.json') { + await fileSystem.writeFile( + targetPath, + JSON.stringify(deepMerge(JSON.parse(existingContent), JSON.parse(templateContent)), null, '\t') + ) + } else { + await fileSystem.writeFile(targetPath, `${existingContent}\n${templateContent}`) + } + } else { + await fileSystem.writeFile(targetPath, templateContent) + } + } + } + + private async createExtensionPreset(presetPath: string, presetOptions: any) { + const project = ProjectManager.currentProject + + if (!project) return + + if (presetOptions.PRESET_PATH && !presetOptions.PRESET_PATH.endsWith('/') && presetOptions.PRESET_PATH !== '') + presetOptions.PRESET_PATH += '/' + + presetOptions.PROJECT_PREFIX = project.config?.namespace + + const preset = this.extensionPresets[presetPath] + + const createFiles = preset.createFiles ?? [] + + for (let createFileOptions of createFiles) { + const isScript = typeof createFileOptions === 'string' + + if (isScript) { + let templatePath: string = join(dirname(presetPath), createFileOptions) + + const script = await Data.getText(templatePath) + + const module: any = {} + + // For some reason if this script runs twice no module exports will exist unless we clear the cache + this.runtime.clearCache() + + const result = await this.runtime.run( + templatePath, + { + module, + }, + script + ) + + module.exports({ + // We are just faking filehandles here since the file system doesn't necesarily use file handles + createFile: async (path: string, handle: any | string, options: any) => { + const packPath = project.packs[options.packPath] ?? project.path + const filePath = join(packPath, path) + + await fileSystem.ensureDirectory(filePath) + await fileSystem.writeFile(filePath, typeof handle === 'string' ? handle : handle.content) + }, + createJSONFile: async (path: string, data: any, options: any) => { + const packPath = project.packs[options.packPath] ?? project.path + const filePath = join(packPath, path) + + await fileSystem.ensureDirectory(filePath) + await fileSystem.writeFileJson(filePath, data, true) + }, + expandFile: async (path: string, data: any, options: any) => { + const packPath = project.packs[options.packPath] ?? project.path + const filePath = join(packPath, path) + + await fileSystem.ensureDirectory(filePath) + + if (await fileSystem.exists(filePath)) { + let existingContent = await fileSystem.readFileText(filePath) + + if (typeof data !== 'string') { + await fileSystem.writeFile( + filePath, + JSON.stringify(deepMerge(JSON.parse(existingContent), data), null, '\t') + ) + } else { + await fileSystem.writeFile(filePath, `${existingContent}\n${data}`) + } + } else { + await fileSystem.writeFile(filePath, typeof data === 'string' ? data : JSON.stringify(data, null, '\t')) + } + }, + loadPresetFile: async (path: string) => { + return { + name: basename(path), + content: await Data.getRaw(join(dirname(presetPath), path)), + async text() { + return await Data.getText(join(dirname(presetPath), path)) + }, + } + }, + models: presetOptions, + }) + + continue + } + + let [templatePath, targetPath, templateOptions] = createFileOptions + + templatePath = join(dirname(presetPath), templatePath) + + let templateContent = null + + if (templatePath.endsWith('.png')) { + templateContent = await fileSystem.readFile(templatePath) + } else { + templateContent = await fileSystem.readFileText(templatePath) + } + + if (templateOptions.inject) { + for (const inject of templateOptions.inject) { + targetPath = targetPath.replaceAll('{{' + inject + '}}', presetOptions[inject]) + + if (typeof templateContent === 'string') + templateContent = templateContent.replaceAll('{{' + inject + '}}', presetOptions[inject]) + } + } + + if (templateOptions.packPath) { + targetPath = join(project.packs[templateOptions.packPath], targetPath) + } else { + targetPath = join(project.path, targetPath) + } + + await fileSystem.ensureDirectory(targetPath) + + await fileSystem.writeFile(targetPath, templateContent) + } + + const expandFiles = preset.expandFiles ?? [] + + for (const expandFileOptions of expandFiles) { + let [templatePath, targetPath, templateOptions] = expandFileOptions + + templatePath = join(dirname(presetPath), templatePath) + + let templateContent = await fileSystem.readFileText(templatePath) + + if (templateOptions.inject) { + for (const inject of templateOptions.inject) { + targetPath = targetPath.replaceAll('{{' + inject + '}}', presetOptions[inject]) + + templateContent = templateContent.replaceAll('{{' + inject + '}}', presetOptions[inject]) + } + } + + if (templateOptions.packPath) { + targetPath = join(project.packs[templateOptions.packPath], targetPath) + } else { + targetPath = join(project.path, targetPath) + } + + await fileSystem.ensureDirectory(targetPath) + + if (await fileSystem.exists(targetPath)) { + let existingContent = await fileSystem.readFileText(targetPath) + + if (extname(templatePath) === '.json') { + await fileSystem.writeFile( + targetPath, + JSON.stringify(deepMerge(JSON.parse(existingContent), JSON.parse(templateContent)), null, '\t') + ) + } else { + await fileSystem.writeFile(targetPath, `${existingContent}\n${templateContent}`) + } + } else { + await fileSystem.writeFile(targetPath, templateContent) + } + } + } + + public getAvailablePresets() { + return Object.fromEntries( + Object.entries({ ...this.presets, ...this.extensionPresets }).filter(([presetPath, preset]) => { + if (!preset.requires) return true + + if (!ProjectManager.currentProject) return true + + if (preset.requires.packTypes) { + for (const pack of preset.requires.packTypes) { + if (!ProjectManager.currentProject.packs[pack]) return false + } + } + + if (preset.requires.targetVersion) { + if (Array.isArray(preset.requires.targetVersion)) { + if ( + !compareVersions( + ProjectManager.currentProject.config?.targetVersion ?? '', + preset.requires.targetVersion[1], + preset.requires.targetVersion[0] + ) + ) + return false + } else { + if ( + preset.requires.targetVersion.min && + !compareVersions( + ProjectManager.currentProject.config?.targetVersion ?? '', + preset.requires.targetVersion.min ?? '', + '>=' + ) + ) + return false + + if ( + preset.requires.targetVersion.max && + !compareVersions( + ProjectManager.currentProject.config?.targetVersion ?? '', + preset.requires.targetVersion.max ?? '', + '<=' + ) + ) + return false + } + } + + return true + }) + ) + } + + private async reloadPresets() { + this.extensionPresets = Extensions.presets + + this.categories = {} + + for (const [presetPath, preset] of Object.entries(this.presets)) { + if (!this.categories[preset.category]) this.categories[preset.category] = [] + + this.categories[preset.category].push(presetPath) + } + + for (const [presetPath, preset] of Object.entries(this.extensionPresets)) { + if (!this.categories[preset.category]) this.categories[preset.category] = [] + + this.categories[preset.category].push(presetPath) + } + } + + public useAvailablePresets(): Ref<{ [key: string]: any }> { + const availablePresets = ref(this.getAvailablePresets()) + + return availablePresets + } +} diff --git a/src/libs/data/bedrock/RequirementsMatcher.ts b/src/libs/data/bedrock/RequirementsMatcher.ts new file mode 100644 index 000000000..972a0415e --- /dev/null +++ b/src/libs/data/bedrock/RequirementsMatcher.ts @@ -0,0 +1,160 @@ +import { BedrockProject } from '@/libs/project/BedrockProject' +import { TCompareOperator, compareVersions } from 'bridge-common-utils' +import { TPackTypeId } from 'mc-project-core' +import { Data } from '@/libs/data/Data' + +export interface Requirements { + /** + * Compare a version with the project's target version. + */ + targetVersion?: [TCompareOperator, string] | { min: string; max: string } + /** + * Check for the status of experimental gameplay toggles in the project. + */ + experimentalGameplay?: string[] + /** + * Check whether pack types are present in the project. + */ + packTypes?: TPackTypeId[] + /** + * Check for manifest dependencies to be present in the pack. + */ + dependencies?: { module_name: string; version?: string }[] + /** + * Whether all conditions must be met. If set to false, any condition met makes the matcher valid. + */ + matchAll?: boolean +} + +export class RequirementsMatcher { + private latestFormatVersion: string = '' + + constructor(public project: BedrockProject) {} + + public async setup() { + this.latestFormatVersion = (await Data.get('packages/minecraftBedrock/formatVersions.json'))[0] + } + + public matches(requirements: Requirements, behaviourManifest?: any): boolean { + for (const pack of requirements.packTypes ?? []) { + if (this.project.packs[pack] === undefined) return false + } + + if (requirements.targetVersion !== undefined) { + const formatVersion = this.project.config?.targetVersion ?? this.latestFormatVersion + + if (Array.isArray(requirements.targetVersion)) { + if (!compareVersions(formatVersion, requirements.targetVersion[1], requirements.targetVersion[0])) + return false + } else { + if ( + !( + compareVersions(formatVersion, requirements.targetVersion?.min ?? '1.8.0', '>=') && + compareVersions(formatVersion, requirements.targetVersion?.max ?? '1.18.0', '<=') + ) + ) + return false + } + } + + for (const experiment of requirements.experimentalGameplay ?? []) { + if (experiment.startsWith('!')) { + if ((this.project.config?.experimentalGameplay ?? {})[experiment.substring(1)]) return false + } else { + if (!(this.project.config?.experimentalGameplay ?? {})[experiment]) return false + } + } + + const dependencies = this.getDependencies(behaviourManifest) + + for (const dependency of requirements.dependencies ?? []) { + if ( + !dependencies.find( + (behaviorDependency) => + behaviorDependency.moduleName === dependency.module_name && + (dependency.version === undefined || behaviorDependency.version === dependency.version) + ) + ) + return false + } + + return true + } + + private getDependencies(behaviourManifest: any): { moduleName: string; version: string }[] { + if (!behaviourManifest) return [] + + if (behaviourManifest.dependencies === undefined) return [] + + return behaviourManifest.dependencies.map((dependency: any) => { + if (dependency.module_name) { + // Convert old module names to new naming convention + switch (dependency.module_name) { + case 'mojang-minecraft': + return { + moduleName: '@minecraft/server', + version: dependency.version, + } + case 'mojang-gametest': + return { + moduleName: '@minecraft/server-gametest', + version: dependency.version, + } + case 'mojang-minecraft-server-ui': + return { + moduleName: '@minecraft/server-ui', + version: dependency.version, + } + case 'mojang-minecraft-server-admin': + return { + moduleName: '@minecraft/server-admin', + version: dependency.version, + } + case 'mojang-net': + return { + moduleName: '@minecraft/server-net', + version: dependency.version, + } + default: + return { + moduleName: dependency.module_name, + version: dependency.version, + } + } + } else { + switch (dependency.uuid ?? '') { + case 'b26a4d4c-afdf-4690-88f8-931846312678': + return { + moduleName: '@minecraft/server', + version: dependency.version, + } + case '6f4b6893-1bb6-42fd-b458-7fa3d0c89616': + return { + moduleName: '@minecraft/server-gametest', + version: dependency.version, + } + case '2bd50a27-ab5f-4f40-a596-3641627c635e': + return { + moduleName: '@minecraft/server-ui', + version: dependency.version, + } + case '53d7f2bf-bf9c-49c4-ad1f-7c803d947920': + return { + moduleName: '@minecraft/server-admin', + version: dependency.version, + } + case '777b1798-13a6-401c-9cba-0cf17e31a81b': + return { + moduleName: '@minecraft/server-net', + version: dependency.version, + } + default: + return { + moduleName: dependency.uuid ?? '', + version: dependency.version, + } + } + } + }) + } +} diff --git a/src/libs/data/bedrock/SchemaData.ts b/src/libs/data/bedrock/SchemaData.ts new file mode 100644 index 000000000..34be225c6 --- /dev/null +++ b/src/libs/data/bedrock/SchemaData.ts @@ -0,0 +1,613 @@ +import { setSchemas } from '@/libs/monaco/Json' +import { Runtime } from '@/libs/runtime/Runtime' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { CompatabilityFileSystem } from '@/libs/fileSystem/CompatabilityFileSystem' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { v4 as uuid } from 'uuid' +import { walkObject } from 'bridge-common-utils' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { Data } from '@/libs/data/Data' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { join, basename, dirname, resolve } from 'pathe' +import { DashData } from './DashData' +import { Event } from '@/libs/event/Event' + +interface SchemaScriptResult { + data: any + result: any +} + +/** + * This handles loading and updating JSON schemas for different file types. Schemas may update as files are updated. Dynamic schema scripts may have to be run to generate updated schemas. + * + * Building the schema for a file is a little complicated. + * We can't use simple references because different files need to "reuse" generated schemas with different values. + * We need some way to swap out the references to these dynamically generated schemas + * + * The idea here is to walk the dependency tree of the schema and replace all of the references with a modified path relative to a custom folder based on the path of the file + * and then supply the referenced schema under the new path. + * Before compiling we'll also generate the dynamic schemas which will be supplied when referenced durring the compile step. + * + * Potential Future Optimizations: + * - pregenerate generated schemas for all the non dynamic schema scripts when the project loads to save time. + */ +export class SchemaData implements Disposable { + public updated: Event = new Event() + + private schemas: any = {} + + private schemaScripts: any = {} + private globalSchemaScriptResults: Record = {} + private localSchemaScripts: string[] = [] + private indexerSchemaScripts: string[] = [] + private fileSystemSchemaScripts: string[] = [] + + private lightningCacheSchemas: Record = {} + + private fileSchemas: Record< + string, + { + main: string + localSchemas: Record + } + > = {} + + private filesToUpdate: { path: string; fileType?: string; schemaUri?: string }[] = [] + + private runtime = new Runtime(fileSystem) + public dashComponentsData: DashData + + private disposables: Disposable[] = [] + + constructor(public project: BedrockProject) { + this.dashComponentsData = new DashData(this.project) + } + + private fixPaths(schemas: { [key: string]: any }) { + return Object.fromEntries(Object.entries(schemas).map(([path, schema]) => [path.substring('file://'.length), schema])) + } + + /** + * Loads all the required data and runs schema scripts + */ + public async load() { + await this.dashComponentsData.setup() + + this.disposables.push(this.project.indexerService.updated.on(this.indexerUpdated.bind(this))) + this.disposables.push(fileSystem.pathUpdated.on(this.pathUpdated.bind(this))) + + this.indexerUpdated() + + this.schemas = { + ...this.fixPaths(await Data.get('/packages/common/schemas.json')), + ...this.fixPaths(await Data.get('/packages/minecraftBedrock/schemas.json')), + } + + this.schemaScripts = this.fixPaths(await Data.get('/packages/minecraftBedrock/schemaScripts.json')) + + await this.runAllSchemaScripts() + } + + public dispose() { + this.dashComponentsData.dispose() + + disposeAll(this.disposables) + } + + private async indexerUpdated() { + for (const fileType of this.project.fileTypeData.fileTypes.map((fileType) => fileType.id)) { + const chachedData = this.project.indexerService.getCachedData(fileType) + + if (chachedData === null) continue + + const collectedData = chachedData.reduce((accumulator: any, currentObject: any) => { + for (const [key, value] of Object.entries(currentObject.data)) { + accumulator[key] = (accumulator[key] ?? []).concat(value) + } + + return accumulator + }, {}) + + const baseUrl = `/data/packages/minecraftBedrock/schema/${fileType}/dynamic` + + for (const key in collectedData) { + this.lightningCacheSchemas[join(baseUrl, `${key}Enum.json`)] = { + type: 'string', + enum: collectedData[key], + } + + this.lightningCacheSchemas[join(baseUrl, `${key}Property.json`)] = { + properties: Object.fromEntries(collectedData[key].map((d: any) => [d, {}])), + } + } + } + + for (const [path, scriptData] of Object.entries(this.schemaScripts)) { + if (!this.indexerSchemaScripts.includes(path)) continue + + const result = await this.runScript(path, scriptData) + + if (this.localSchemaScripts.includes(path)) continue + + if (result === null) continue + + this.globalSchemaScriptResults[path] = result + } + + for (const file of this.filesToUpdate) { + this.updateSchemaForFile(file.path, file.fileType, file.schemaUri) + } + } + + private async pathUpdated() { + for (const [path, scriptData] of Object.entries(this.schemaScripts)) { + if (!this.fileSystemSchemaScripts.includes(path)) continue + + const result = await this.runScript(path, scriptData) + + if (this.localSchemaScripts.includes(path)) continue + + if (result === null) continue + + this.globalSchemaScriptResults[path] = result + } + + for (const file of this.filesToUpdate) { + this.updateSchemaForFile(file.path, file.fileType, file.schemaUri) + } + } + + private rebaseReferences(schemaPart: any, schemaPath: string, basePath: string): { references: string[]; rebasedSchemaPart: any } { + let references: string[] = [] + + if (Array.isArray(schemaPart)) { + for (let index = 0; index < schemaPart.length; index++) { + const result = this.rebaseReferences(schemaPart[index], schemaPath, basePath) + + schemaPart[index] = result.rebasedSchemaPart + references = references.concat(result.references) + } + } else if (typeof schemaPart === 'object') { + for (const key of Object.keys(schemaPart)) { + if (key === '$ref') { + let reference = schemaPart[key] + + if (reference.startsWith('#')) continue + + references.push(this.resolveSchemaPath(schemaPath, reference).split('#')[0]) + + reference = join(resolve('/', basePath), this.resolveSchemaPath(schemaPath, reference.split('#')[0])) + + schemaPart[key] = reference + + continue + } + + const result = this.rebaseReferences(schemaPart[key], schemaPath, basePath) + + schemaPart[key] = result.rebasedSchemaPart + references = references.concat(result.references) + } + } + + return { + references, + rebasedSchemaPart: schemaPart, + } + } + + /** + * Updates the schema for a file at a schema path. Should be used when a file is opened as this generates all the necessary schema. + * @param path Path of the file + * @param fileType + * @param schemaUri Path of the schema + */ + public async updateSchemaForFile(path: string, fileType?: string, schemaUri?: string) { + if (!(await fileSystem.exists(path))) { + if (this.fileSchemas[path]) delete this.fileSchemas[path] + + return + } + + if (schemaUri === undefined) { + if (this.fileSchemas[path] !== undefined) delete this.fileSchemas[path] + + this.updateDefaults() + + this.updated.dispatch(path) + + return + } + + if (schemaUri.startsWith('file://')) schemaUri = schemaUri.substring('file://'.length) + + const generatedGlobalSchemas: Record = {} + + for (const [scriptPath, result] of Object.entries(this.globalSchemaScriptResults)) { + generatedGlobalSchemas[join('/data/packages/minecraftBedrock/schema/', result.data.generateFile)] = result.result + } + + const generatedDynamicSchemas: Record = {} + + for (const scriptPath of this.localSchemaScripts) { + const result = await this.runScript(scriptPath, this.schemaScripts[scriptPath], path) + + if (result === null) continue + + generatedDynamicSchemas[join('/data/packages/minecraftBedrock/schema/', result.data.generateFile)] = result.result + } + + const contextLightningCacheSchemas: Record = {} + + if (fileType) { + const collectedData = this.project.indexerService.getCachedData(fileType, path) + const baseUrl = `/data/packages/minecraftBedrock/schema/${fileType}/dynamic` + + for (const key in collectedData) { + contextLightningCacheSchemas[join(baseUrl, 'currentContext', `${key}Enum.json`)] = { + type: 'string', + enum: collectedData[key], + } + + contextLightningCacheSchemas[join(baseUrl, 'currentContext', `${key}Property.json`)] = { + properties: Object.fromEntries(collectedData[key].map((d: any) => [d, {}])), + } + } + } + + const localSchemas: Record = {} + + let rebasedSchemas = [] + let schemasToRebaseQueue = [schemaUri] + + while (schemasToRebaseQueue.length > 0) { + const schemaPathToRebase = schemasToRebaseQueue.shift()! + rebasedSchemas.push(schemaPathToRebase) + + let schema = + generatedDynamicSchemas[schemaPathToRebase] ?? + generatedGlobalSchemas[schemaPathToRebase] ?? + contextLightningCacheSchemas[schemaPathToRebase] ?? + this.lightningCacheSchemas[schemaPathToRebase] ?? + this.schemas[schemaPathToRebase] + + if (schema === undefined) { + console.warn('Failed to load schema reference', schemaPathToRebase) + + schema = {} + } + + const result = this.rebaseReferences(JSON.parse(JSON.stringify(schema)), schemaPathToRebase, path) + + for (let reference of result.references) { + if (rebasedSchemas.includes(reference)) continue + + schemasToRebaseQueue.push(reference) + rebasedSchemas.push(reference) + } + + localSchemas[join(resolve('/', path), schemaPathToRebase)] = result.rebasedSchemaPart + } + + this.fileSchemas[path] = { + main: join(resolve('/', path), schemaUri), + localSchemas, + } + + this.updateDefaults() + + this.updated.dispatch(path) + } + + /** + * Allows changes that the schema depends on to be detected and reacted to by this system + * @param path Path of the file + * @param fileType + * @param schemaUri Path of the schema + */ + public addFileForUpdate(path: string, fileType?: string, schemaUri?: string) { + this.filesToUpdate.push({ path, fileType, schemaUri }) + } + + public removeFileForUpdate(path: string) { + this.filesToUpdate.splice( + this.filesToUpdate.findIndex((file) => file.path === path), + 1 + ) + } + + /** + * Gets a fully resolved schema from the schema path + * @param path + * @param schemaBase The original schema used to support definition references + * @returns The resolved schema + */ + public getAndResolve(path: string, schemaBase?: any): any { + if (path.startsWith('#')) { + const objectPath = path.substring(1) + + let subSchema = null + walkObject(objectPath, schemaBase, (foundData) => (subSchema = foundData)) + + if (subSchema === null) { + console.error(`Failed to find schema '${path}'`) + + return {} + } + + return subSchema + } else if (path.includes('#')) { + const schemaPath = resolve('/', path.split('#')[0]) + const objectPath = path.split('#')[1].substring(1) + + let data = this.lightningCacheSchemas[schemaPath] ?? this.schemas[schemaPath] + + if (!data) { + console.error(`Failed to find schema '${path}'`) + + return {} + } + + data = JSON.parse(JSON.stringify(data)) + + data = this.resolveReferences(schemaPath, data, data) + + let subSchema = null + walkObject(objectPath, data, (foundData) => (subSchema = foundData)) + + if (subSchema === null) { + console.error(`Failed to find schema '${path}'`) + + return {} + } + + return subSchema + } + + path = resolve('/', path) + + let data = this.lightningCacheSchemas[path] ?? this.schemas[path] + + if (!data) { + console.error(`Failed to find schema '${path}'`) + + return {} + } + + data = JSON.parse(JSON.stringify(data)) + + return this.resolveReferences(path, data, data) + } + + /** + * Generate simple value completions from a schema value + * @param schema + * @returns The list of string completions + */ + public getAutocompletions(schema: any): string[] { + if (typeof schema !== 'object') return [] + + let completions: string[] = [] + + for (const key of Object.keys(schema)) { + if (key === 'enum') { + return schema[key] + } + + completions = completions.concat(this.getAutocompletions(schema[key])) + } + + return completions + } + + /** + * Get all the schema data and locally related schemas for a file + * @param path The file path + * @returns The main schema and any locally related schemas + */ + public getSchemasForFile(path: string): { main: string; localSchemas: Record } { + return this.fileSchemas[path] + } + + /** + * Gets the specified schema for the file + * @param filePath + * @param schemaPath + * @returns The schema JSON object + */ + public getSchemaForFile(filePath: string, schemaPath: string): any { + return this.fileSchemas[filePath].localSchemas[schemaPath] + } + + private resolveSchemaPath(source: string, path: string): string { + if (path.startsWith('#')) return source + path + + return resolve(dirname(source), path) + } + + private resolveReferences(path: string, schemaPart: any, schemaBase: any): any { + for (const key of Object.keys(schemaPart)) { + if (key === '$ref') { + let reference = this.resolveSchemaPath(path, schemaPart[key]) + + const data = this.getAndResolve(reference, schemaBase) + + for (const otherKey of Object.keys(data)) { + schemaPart[otherKey] = data[otherKey] + } + + delete schemaPart['$ref'] + + continue + } + + if (typeof schemaPart[key] === 'object') schemaPart[key] = this.resolveReferences(path, schemaPart[key], schemaBase) + } + + return schemaPart + } + + private updateDefaults() { + setSchemas( + Object.entries(this.fileSchemas) + .map(([filePath, schemaInfo]) => { + return Object.entries(schemaInfo.localSchemas).map(([schemaPath, schema]) => ({ + uri: schemaPath, + fileMatch: schemaPath === schemaInfo.main ? [filePath] : undefined, + schema, + })) + }) + .flat() + ) + } + + private async runAllSchemaScripts() { + for (const [path, scriptData] of Object.entries(this.schemaScripts)) { + const result = await this.runScript(path, scriptData) + + if (this.localSchemaScripts.includes(path)) continue + + if (result === null) continue + + this.globalSchemaScriptResults[path] = result + } + } + + private async runScript(scriptPath: string, scriptData: any, filePath?: string): Promise { + const script: string = typeof scriptData === 'string' ? scriptData : scriptData.script + + const compatabilityFileSystem = new CompatabilityFileSystem(fileSystem) + + const formatVersions = (await Data.get('/packages/minecraftBedrock/formatVersions.json')).formatVersions + + let fileJson: any = undefined + + if (filePath !== undefined) { + try { + fileJson = await fileSystem.readFileJson(filePath) + } catch {} + } + + const me = this + + try { + this.runtime.clearCache() + + const runResult = await ( + await this.runtime.run( + scriptPath, + { + readdir: (path: string) => { + if (!me.fileSystemSchemaScripts.includes(scriptPath)) me.fileSystemSchemaScripts.push(scriptPath) + + return compatabilityFileSystem.readdir.call(compatabilityFileSystem, path) + }, + resolvePackPath(packId: string, path?: string) { + if (!ProjectManager.currentProject) return '' + + return ProjectManager.currentProject.resolvePackPath(packId, path) + }, + getFormatVersions() { + return formatVersions + }, + getCacheDataFor(fileType: string, filePath?: string, cacheKey?: string) { + if (!me.indexerSchemaScripts.includes(scriptPath)) me.indexerSchemaScripts.push(scriptPath) + + return Promise.resolve( + (ProjectManager.currentProject as BedrockProject).indexerService.getCachedData(fileType, filePath, cacheKey) + ) + }, + getProjectConfig() { + return ProjectManager.currentProject?.config + }, + getProjectPrefix() { + return ProjectManager.currentProject?.config?.namespace + }, + getFileName() { + if (!me.localSchemaScripts.includes(scriptPath)) me.localSchemaScripts.push(scriptPath) + + if (filePath === undefined) return undefined + + return basename(filePath) + }, + uuid, + get(path: string) { + if (!me.fileSystemSchemaScripts.includes(scriptPath)) me.fileSystemSchemaScripts.push(scriptPath) + + if (!me.localSchemaScripts.includes(scriptPath)) me.localSchemaScripts.push(scriptPath) + + if (fileJson === undefined) return [] + + let data: string[] = [] + + walkObject(path, fileJson, (value) => { + data.push(value) + }) + + return data + }, + customComponents(fileType: any) { + return me.dashComponentsData.get(fileType) + }, + getIndexedPaths(fileType: string, sort: boolean) { + if (!me.indexerSchemaScripts.includes(scriptPath)) me.indexerSchemaScripts.push(scriptPath) + + if (!(ProjectManager.currentProject instanceof BedrockProject)) return Promise.resolve([]) + + return Promise.resolve(ProjectManager.currentProject.indexerService.getIndexedFiles()) + }, + failedCurrentFileLoad: undefined, + }, + ` + ___module.execute = async function(){ + ${script} + } + ` + ) + ).execute() + + if (typeof scriptData === 'string') { + scriptData = runResult + } else { + scriptData.data = runResult + } + + return { + data: scriptData, + result: this.processScriptData(scriptData), + } + } catch (err) { + console.error('Error running schema script ', scriptPath) + console.error(err) + + return null + } + } + + private processScriptData(scriptData: any): any { + if (scriptData === undefined) return undefined + + if (scriptData.type === 'enum') { + return { + type: 'string', + enum: scriptData.data, + } + } else if (scriptData.type === 'properties') { + return { + type: 'object', + properties: Object.fromEntries(scriptData.data.map((res: string) => [res, {}])), + } + } else if (scriptData.type === 'object') { + return { + type: 'object', + properties: scriptData.data, + } + } else if (scriptData.type === 'custom') { + return scriptData.data + } else { + console.warn('Unexpected script data type:', scriptData) + } + + return undefined + } +} diff --git a/src/libs/data/bedrock/ScriptTypeData.ts b/src/libs/data/bedrock/ScriptTypeData.ts new file mode 100644 index 000000000..ee52a2bc2 --- /dev/null +++ b/src/libs/data/bedrock/ScriptTypeData.ts @@ -0,0 +1,94 @@ +import { BedrockProject } from '@/libs/project/BedrockProject' +import { Uri, languages } from 'monaco-editor' +import { Data } from '@/libs/data/Data' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' + +/** + * Attempts to detect the valid scripting types for the project and apply them to the monaco completions. + */ +export class ScriptTypeData implements Disposable { + constructor(public project: BedrockProject) {} + + private typeDisposables: any[] = [] + + private appliedTypes: any[] = [] + + private disposables: Disposable[] = [] + + public async setup() { + this.disposables.push(fileSystem.pathUpdated.on(this.pathUpdated.bind(this))) + } + + public async dispose() { + disposeAll(this.disposables) + } + + public async applyTypes(types: any[]) { + for (const type of this.typeDisposables) { + type.dispose() + } + + this.appliedTypes = types + + const behaviorPackPath = this.project.resolvePackPath('behaviorPack', 'manifest.json') + let behaviourManifest = {} + + if (this.project.packs['behaviorPack'] && (await fileSystem.exists(behaviorPackPath))) { + try { + behaviourManifest = await fileSystem.readFileJson(behaviorPackPath) + } catch {} + } + + const validTypes = types.filter((type) => { + if (!type.requires) return true + + return this.project.requirementsMatcher.matches(type.requires, behaviourManifest) + }) + + const builtTypes = [] + + for (const type of validTypes) { + let location = typeof type === 'string' ? type : type.definition + + let content = null + + if (location.startsWith('types/')) { + content = await Data.getText(`packages/minecraftBedrock/${location}`) + } else { + const result = await fetch(location).catch(() => null) + + if (!result) continue + + content = await result.text() + + location = location.substring('https://'.length) + } + + if (type.moduleName) content = `declare module '${type.moduleName}' {\n${content}\n}` + + builtTypes.push({ + location, + content, + }) + } + + for (const builtType of builtTypes) { + const uri = Uri.file(builtType.location) + + this.typeDisposables.push(languages.typescript.javascriptDefaults.addExtraLib(builtType.content, uri.toString())) + + this.typeDisposables.push(languages.typescript.typescriptDefaults.addExtraLib(builtType.content, uri.toString())) + } + } + + private async pathUpdated(path: unknown) { + if (typeof path !== 'string') return + + const behaviorPackPath = this.project.resolvePackPath('behaviorPack', 'manifest.json') + + if (path !== behaviorPackPath) return + + this.applyTypes(this.appliedTypes) + } +} diff --git a/src/libs/data/compatability/FileType.ts b/src/libs/data/compatability/FileType.ts new file mode 100644 index 000000000..7a2fc1e4f --- /dev/null +++ b/src/libs/data/compatability/FileType.ts @@ -0,0 +1,10 @@ +import { FileType } from 'mc-project-core' + +export class CompatabilityFileType extends FileType<{ + fileTypes: any + packTypes: any +}> { + async setup(arg: { fileTypes: any; packTypes: any }) { + this.fileTypes = arg.fileTypes + } +} diff --git a/src/libs/data/compatability/PackType.ts b/src/libs/data/compatability/PackType.ts new file mode 100644 index 000000000..1d10f4f4b --- /dev/null +++ b/src/libs/data/compatability/PackType.ts @@ -0,0 +1,10 @@ +import { PackType } from 'mc-project-core' + +export class CompatabilityPackType extends PackType<{ + fileTypes: any + packTypes: any +}> { + async setup(arg: { fileTypes: any; packTypes: any }) { + this.packTypes = arg.packTypes + } +} diff --git a/src/libs/disposeable/Disposeable.ts b/src/libs/disposeable/Disposeable.ts new file mode 100644 index 000000000..96d47a3f3 --- /dev/null +++ b/src/libs/disposeable/Disposeable.ts @@ -0,0 +1,13 @@ +export interface Disposable { + dispose: () => void +} + +export interface AsyncDisposable { + dispose: () => Promise +} + +export function disposeAll(disposables: Disposable[]) { + for (const disposable of disposables) { + disposable.dispose() + } +} diff --git a/src/libs/element/Element.ts b/src/libs/element/Element.ts new file mode 100644 index 000000000..3ee41c289 --- /dev/null +++ b/src/libs/element/Element.ts @@ -0,0 +1,12 @@ +export function isElementOrChild( + element: HTMLElement | null, + target: HTMLElement +) { + while (element) { + if (element === target) return true + + element = element.parentElement + } + + return false +} diff --git a/src/libs/event/Event.ts b/src/libs/event/Event.ts new file mode 100644 index 000000000..4b1c6f8fb --- /dev/null +++ b/src/libs/event/Event.ts @@ -0,0 +1,62 @@ +import { Disposable } from '@/libs/disposeable/Disposeable' + +type Listener = (value?: T) => void | Promise + +/** + * An event which can have listeners registered to it that will respond to dispatches that can contain arbitrary data + */ +export class Event { + private listeners: Listener[] = [] + + public on(listener: Listener): Disposable { + this.listeners.push(listener) + + const event = this + + let disposed = false + + return { + dispose() { + if (disposed) return + + disposed = true + + event.listeners.splice(event.listeners.indexOf(listener), 1) + }, + } + } + + public once(listener: Listener): Disposable { + const event = this + + let disposed = false + + const onceListener: Listener = (value?: T) => { + disposed = true + + event.listeners.splice(event.listeners.indexOf(onceListener), 1) + + listener(value) + } + + this.listeners.push(onceListener) + + return { + dispose() { + if (disposed) return + + disposed = true + + event.listeners.splice(event.listeners.indexOf(onceListener), 1) + }, + } + } + + public dispatch(value?: T) { + const listeners = [...this.listeners] + + for (const listener of listeners) { + listener(value) + } + } +} diff --git a/src/libs/event/React.ts b/src/libs/event/React.ts new file mode 100644 index 000000000..0406ba4c3 --- /dev/null +++ b/src/libs/event/React.ts @@ -0,0 +1,39 @@ +import { onMounted, onUnmounted, shallowRef, ShallowRef } from 'vue' +import { Disposable } from '@/libs/disposeable/Disposeable' +import { Event } from '@/libs/event/Event' + +export function createReactable(event: Event, provider: () => T): () => ShallowRef { + return () => { + const valueRef: ShallowRef = shallowRef(provider()) + + function update() { + valueRef.value = provider() + } + + let disposable: Disposable + + onMounted(() => { + disposable = event.on(update) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return valueRef + } +} + +export function createHeadlessReactable(event: Event, provider: () => T): () => ShallowRef { + return () => { + const valueRef: ShallowRef = shallowRef(provider()) + + function update() { + valueRef.value = provider() + } + + event.on(update) + + return valueRef + } +} diff --git a/src/libs/export/Export.ts b/src/libs/export/Export.ts new file mode 100644 index 000000000..5f5e982e9 --- /dev/null +++ b/src/libs/export/Export.ts @@ -0,0 +1,135 @@ +import { NotificationSystem } from '@/components/Notifications/NotificationSystem' +import { AlertWindow } from '@/components/Windows/Alert/AlertWindow' +import { Windows } from '@/components/Windows/Windows' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { LocaleManager } from '@/libs/locales/Locales' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { appVersion, dashVersion } from '@/libs/app/AppEnv' +import { LocalFileSystem } from '@/libs/fileSystem/LocalFileSystem' +import { download } from '@/libs/Download' +import { basename } from 'pathe' +import { TauriFileSystem } from '@/libs/fileSystem/TauriFileSystem' + +export async function saveOrDownload(path: string, data: Uint8Array, fileSystem: BaseFileSystem) { + await fileSystem.writeFile(path, data) + + NotificationSystem.addNotification( + 'download', + async () => { + if (fileSystem instanceof LocalFileSystem) { + download(basename(path), data) + } else if (fileSystem instanceof TauriFileSystem) { + fileSystem.revealInFileExplorer(path) + } else { + Windows.open(new AlertWindow(`[${LocaleManager.translate('general.successfulExport.description')}: "${path}"]`)) + } + }, + 'success' + ) +} + +export async function incrementManifestVersions() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + let manifests: Record = {} + + for (const pack of Object.keys(ProjectManager.currentProject.packs)) { + const manifestPath = ProjectManager.currentProject.resolvePackPath(pack, 'manifest.json') + + if (await fileSystem.exists(manifestPath)) { + let manifest + + try { + manifest = await fileSystem.readFileJson(manifestPath) + } catch { + continue + } + + const [major, minor, patch] = <[number, number, number]>manifest.header?.version ?? [0, 0, 0] + + const newVersion = [major, minor, patch + 1] + + manifests[manifestPath] = { + ...manifest, + header: { + ...(manifest.header ?? {}), + version: newVersion, + }, + } + } + } + + const allManifests = Object.values(manifests) + + for (const manifest of allManifests) { + if (!Array.isArray(manifest.dependencies)) continue + + manifest.dependencies.forEach((dep: any) => { + const depManifest = allManifests.find((manifest) => manifest.header.uuid === dep.uuid) + + if (!depManifest) return + + dep.version = depManifest.header.version + }) + } + + const announcement = fileSystem.announceFileModifications() + + try { + for (const [path, manifest] of Object.entries(manifests)) { + await fileSystem.writeFileJson(path, manifest, true) + } + } catch {} + + announcement.dispose() +} + +export async function addGeneratedWith() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + let manifests: Record = {} + + for (const pack of Object.keys(ProjectManager.currentProject.packs)) { + const manifestPath = ProjectManager.currentProject.resolvePackPath(pack, 'manifest.json') + + if (await fileSystem.exists(manifestPath)) { + let manifest + + try { + manifest = await fileSystem.readFileJson(manifestPath) + } catch { + continue + } + + const [major, minor, patch] = <[number, number, number]>manifest.header?.version ?? [0, 0, 0] + + const newVersion = [major, minor, patch + 1] + + manifests[manifestPath] = { + ...manifest, + metadata: { + ...(manifest.metadata ?? {}), + generated_with: { + ...(manifest.metadata?.generated_with ?? {}), + bridge: [appVersion], + dash: [dashVersion], + }, + }, + } + } + } + + const announcement = fileSystem.announceFileModifications() + + try { + for (const [path, manifest] of Object.entries(manifests)) { + await fileSystem.writeFileJson(path, manifest, true) + } + } catch {} + + announcement.dispose() +} diff --git a/src/libs/export/exporters/BrProject.ts b/src/libs/export/exporters/BrProject.ts new file mode 100644 index 000000000..a85bf26d8 --- /dev/null +++ b/src/libs/export/exporters/BrProject.ts @@ -0,0 +1,19 @@ +import { join } from 'pathe' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { zipDirectory } from '@/libs/zip/ZipDirectory' +import { saveOrDownload } from '../Export' + +export async function exportAsBrProject() { + if (!ProjectManager.currentProject) return + + const savePath = join(ProjectManager.currentProject.path, 'builds/', ProjectManager.currentProject.name) + '.brproject' + + const zipFile = await zipDirectory(fileSystem, ProjectManager.currentProject.path, new Set(['builds'])) + + try { + await saveOrDownload(savePath, zipFile, fileSystem) + } catch (err) { + console.error(err) + } +} diff --git a/src/libs/export/exporters/McAddon.ts b/src/libs/export/exporters/McAddon.ts new file mode 100644 index 000000000..1f89eec5a --- /dev/null +++ b/src/libs/export/exporters/McAddon.ts @@ -0,0 +1,30 @@ +import { ProjectManager } from '@/libs/project/ProjectManager' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { zipDirectory } from '@/libs/zip/ZipDirectory' +import { join } from 'pathe' +import { addGeneratedWith, incrementManifestVersions, saveOrDownload } from '../Export' +import { DashService } from '@/libs/compiler/DashService' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { Settings } from '@/libs/settings/Settings' + +export async function exportAsMcAddon() { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + if (Settings.get('incrementVersionOnExport')) await incrementManifestVersions() + if (Settings.get('addGeneratedWith')) await addGeneratedWith() + + const dash = new DashService(ProjectManager.currentProject, fileSystem) + await dash.setup('production') + await dash.build() + await dash.dispose() + + const zipFile = await zipDirectory(fileSystem, join(ProjectManager.currentProject.path, 'builds/dist')) + const savePath = join(ProjectManager.currentProject.path, 'builds/', ProjectManager.currentProject.name) + '.mcaddon' + + try { + await saveOrDownload(savePath, zipFile, fileSystem) + } catch (err) { + console.error(err) + } +} diff --git a/src/libs/export/exporters/McTemplate.ts b/src/libs/export/exporters/McTemplate.ts new file mode 100644 index 000000000..7cb51f758 --- /dev/null +++ b/src/libs/export/exporters/McTemplate.ts @@ -0,0 +1,187 @@ +import { ProjectManager } from '@/libs/project/ProjectManager' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { zipDirectory } from '@/libs/zip/ZipDirectory' +import { join } from 'pathe' +import { addGeneratedWith, incrementManifestVersions, saveOrDownload } from '../Export' +import { DashService } from '@/libs/compiler/DashService' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { v4 as uuid } from 'uuid' +import { Data } from '@/libs/data/Data' +import { Windows } from '@/components/Windows/Windows' +import { DropdownWindow } from '@/components/Windows/Dropdown/DropdownWindow' +import { Settings } from '@/libs/settings/Settings' + +export async function exportAsTemplate(asMcworld = false) { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + if (Settings.get('incrementVersionOnExport')) await incrementManifestVersions() + if (Settings.get('addGeneratedWith')) await addGeneratedWith() + + const projectPath = ProjectManager.currentProject.path + + const dash = new DashService(ProjectManager.currentProject, fileSystem) + await dash.setup('production') + await dash.build() + await dash.dispose() + + let baseWorlds: string[] = [] + + if (ProjectManager.currentProject.packs['worldTemplate']) baseWorlds.push('WT') + + if (await fileSystem.exists(join(projectPath, 'worlds'))) + baseWorlds.push(...(await fileSystem.readDirectoryEntries(join(projectPath, 'worlds'))).map((entry) => entry.path)) + + let exportWorldFolder: string | null + + // No world to package + if (baseWorlds.length === 0) { + console.warn('No worlds to package!') + + return + } else if (baseWorlds.length === 1) { + exportWorldFolder = baseWorlds[0] + } else { + exportWorldFolder = await new Promise((res) => { + Windows.open( + new DropdownWindow( + 'packExplorer.exportAsMctemplate.chooseWorld', + '', + baseWorlds, + (value) => { + res(value) + }, + () => { + res(null) + }, + baseWorlds[0] + ) + ) + }) + } + + if (exportWorldFolder === null) return + + await fileSystem.ensureDirectory(join(projectPath, `builds/mctemplate/behavior_packs`)) + await fileSystem.ensureDirectory(join(projectPath, `builds/mctemplate/resource_packs`)) + + // Find out BP, RP & WT folders + const packs = (await fileSystem.readDirectoryEntries(join(projectPath, `builds/dist`))).map((entry) => entry.path) + + const packLocations = <{ [pack in 'WT' | 'BP' | 'RP']: string | undefined }>{ + BP: packs.find((pack) => pack.endsWith('BP')), + RP: packs.find((pack) => pack.endsWith('RP')), + WT: packs.find((pack) => pack.endsWith('WT')), + } + + // Copy world folder into builds/mctemplate + if (exportWorldFolder === 'WT') { + await fileSystem.move(packLocations.WT!, join(ProjectManager.currentProject!.path, `builds/mctemplate`)) + } else { + await fileSystem.copyDirectory(exportWorldFolder, join(ProjectManager.currentProject!.path, `builds/mctemplate`)) + } + + // Generate world_behavior_packs.json + if (packLocations.BP) { + const bpManifest = await fileSystem.readFileJson(`${packLocations.BP}/manifest.json`).catch(() => null) + + if (bpManifest !== null && bpManifest?.header?.uuid && bpManifest?.header?.version) { + await fileSystem.writeFileJson( + join(projectPath, 'builds/mctemplate/world_behavior_packs.json'), + [ + { + pack_id: bpManifest.header.uuid, + version: bpManifest.header.version, + }, + ], + true + ) + } + } + + // Generate world_resource_packs.json + if (packLocations.RP) { + const rpManifest = await fileSystem.readFileJson(`${packLocations.RP}/manifest.json`).catch(() => null) + + if (rpManifest !== null && rpManifest?.header?.uuid && rpManifest?.header?.version) { + await fileSystem.writeFileJson( + join(projectPath, 'builds/mctemplate/world_resource_packs.json'), + [ + { + pack_id: rpManifest.header.uuid, + version: rpManifest.header.version, + }, + ], + true + ) + } + } + + // Move BP & RP into behavior_packs/resource_packs + if (packLocations.BP) { + await fileSystem.ensureDirectory(join(projectPath, `builds/mctemplate/behavior_packs/BP_${ProjectManager.currentProject.name}`)) + + await fileSystem.move( + packLocations.BP, + join(projectPath, `builds/mctemplate/behavior_packs/BP_${ProjectManager.currentProject.name}`) + ) + } + + if (packLocations.RP) { + await fileSystem.ensureDirectory(join(projectPath, `builds/mctemplate/resource_packs/RP_${ProjectManager.currentProject.name}`)) + + await fileSystem.move( + packLocations.RP, + join(projectPath, `builds/mctemplate/resource_packs/RP_${ProjectManager.currentProject.name}`) + ) + } + + // Generate world template manifest if file doesn't exist yet + if (!(await fileSystem.exists(join(projectPath, 'builds/mctemplate/manifest.json'))) && !asMcworld) { + await fileSystem.writeFileJson( + join(projectPath, 'builds/mctemplate/manifest.json'), + { + format_version: 2, + header: { + name: 'pack.name', + description: 'pack.description', + version: [1, 0, 0], + uuid: uuid(), + lock_template_options: true, + base_game_version: ( + ProjectManager.currentProject.config!.targetVersion ?? + ( + await Data.get('packages/minecraftBedrock/formatVersions.json') + )[0] + ) + .split('.') + .map((str) => Number(str)), + }, + modules: [ + { + type: 'world_template', + version: [1, 0, 0], + uuid: uuid(), + }, + ], + }, + true + ) + } else if (asMcworld && exportWorldFolder === 'WT') { + await fileSystem.removeFile(join(projectPath, 'builds/mctemplate/manifest.json')) + } + + // ZIP builds/mctemplate folder + const zipFile = await zipDirectory(fileSystem, join(ProjectManager.currentProject.path, 'builds/mctemplate')) + const savePath = + join(ProjectManager.currentProject.path, 'builds/', ProjectManager.currentProject.name) + (asMcworld ? '.mcworld' : '.mctemplate') + + try { + await saveOrDownload(savePath, zipFile, fileSystem) + } catch (err) { + console.error(err) + } + + // Delete builds/mctemplate folder + await fileSystem.removeDirectory(join(projectPath, `builds/mctemplate`)) +} diff --git a/src/libs/extensions/CompileSFC.ts b/src/libs/extensions/CompileSFC.ts new file mode 100644 index 000000000..61dc1b025 --- /dev/null +++ b/src/libs/extensions/CompileSFC.ts @@ -0,0 +1,49 @@ +import { Runtime } from '@bridge-editor/js-runtime' +import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc' +import { Component, defineComponent } from 'vue' +import { fileSystem } from '@/libs/fileSystem/FileSystem' + +export async function compileSFC(path: string, runtime: Runtime): Promise { + const source = await fileSystem.readFileText(path) + + const parseResult = parse(source) + + const compiledScript: any = compileScript(parseResult.descriptor, { + id: path, + isProd: true, + templateOptions: { + compilerOptions: { + hmr: false, + mode: 'module', + inline: true, + }, + }, + inlineTemplate: true, + }) + + const templateResult = compileTemplate({ + source, + filename: path, + id: path, + isProd: true, + compilerOptions: { + hmr: false, + mode: 'module', + inline: true, + }, + }) + + runtime.clearCache() + const setupModule = await runtime.run(path, {}, compiledScript.content) + + runtime.clearCache() + const renderModule = await runtime.run(path, {}, templateResult.code) + + const componentOptions = setupModule.__default__ + + componentOptions.render = renderModule.render + + const component = defineComponent(componentOptions) + + return component +} diff --git a/src/libs/extensions/Extension.ts b/src/libs/extensions/Extension.ts new file mode 100644 index 000000000..877669190 --- /dev/null +++ b/src/libs/extensions/Extension.ts @@ -0,0 +1,172 @@ +import { fileSystem, iterateDirectory } from '@/libs/fileSystem/FileSystem' +import { basename, join } from 'pathe' +import { dark, light } from '@/libs/theme/DefaultThemes' +import { Theme } from '@/libs/theme/Theme' +import { Snippet, SnippetData } from '@/libs/snippets/Snippet' +import { TBaseModule } from '@bridge-editor/js-runtime/dist/Runtime' +import { Event } from '@/libs/event/Event' +import { Runtime as BridgeRuntime, initRuntimes, Module } from '@bridge-editor/js-runtime' +import wasmUrl from '@swc/wasm-web/wasm-web_bg.wasm?url' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { compileSFC } from './CompileSFC' +import * as vue from 'vue' + +export type ExtensionModuleBuilder = (extension: Extension) => TBaseModule + +class ExtensionRuntime extends BridgeRuntime { + constructor(public fileSystem: BaseFileSystem, modules?: [string, TBaseModule][]) { + initRuntimes(wasmUrl) + + super(modules ? [...modules, ['vue', vue]] : undefined) + + this.moduleLoaders.set('.vue', async (path: string) => { + return new Module(await compileSFC(path, this), {}) + }) + + this.moduleLoaders.set('.png', async (path: string) => { + return new Module(await fileSystem.readFileDataUrl(path), {}) + }) + + this.moduleLoaders.set('.jpg', async (path: string) => { + return new Module(await fileSystem.readFileDataUrl(path), {}) + }) + + this.moduleLoaders.set('.gif', async (path: string) => { + return new Module(await fileSystem.readFileDataUrl(path), {}) + }) + } + + async readFile(filePath: string): Promise { + // @ts-ignore + if (!(await this.fileSystem.exists(filePath))) return undefined + + const file = await this.fileSystem.readFile(filePath) + + // @ts-ignore + return { + name: basename(filePath), + type: 'unkown', + size: file.byteLength, + lastModified: Date.now(), + webkitRelativePath: filePath, + + arrayBuffer: () => Promise.resolve(file), + slice: () => new Blob(), + stream: () => new ReadableStream(), + text: async () => await new TextDecoder().decode(file), + } + } +} + +export interface ExtensionManifest { + author: string + description: string + icon: string + id: string + link: string + name: string + tags: string[] + target: 'v1' | 'v2' | 'both' | 'v2.1' + version: string + releaseTimestamp: number + contributeFiles: Record +} + +export class Extension { + public id: string = 'unloaded' + + private manifest: ExtensionManifest | null = null + public themes: Theme[] = [] + public presets: any = {} + public snippets: Snippet[] = [] + public ui: Record = {} + public modules: [string, TBaseModule][] = [] + public deactivated: Event = new Event() + + private static moduleBuilders: Record = {} + + constructor(public path: string) {} + + public async load() { + const manifest: ExtensionManifest = await fileSystem.readFileJson(join(this.path, 'manifest.json')) + + this.id = manifest.id + this.manifest = manifest + + const themesPath = join(this.path, 'themes') + if (await fileSystem.exists(themesPath)) { + for (const entry of await fileSystem.readDirectoryEntries(themesPath)) { + const theme: Theme = await fileSystem.readFileJson(entry.path) + + const base = theme.colorScheme === 'dark' ? dark : light + + if (manifest.target !== 'v2.1') { + theme.colors.menuAlternate = theme.colors.sidebarNavigation + theme.colors.accent = base.colors.text + } + + theme.colors = { + ...base.colors, + ...theme.colors, + } + + this.themes.push(theme) + } + } + + const presetPath = join(this.path, 'presets.json') + if (await fileSystem.exists(presetPath)) { + const presets = await fileSystem.readFileJson(presetPath) + + this.presets = Object.fromEntries(Object.entries(presets).map(([path, value]) => [join(this.path, path), value])) + } + + const snippetsPath = join(this.path, 'snippets') + if (await fileSystem.exists(snippetsPath)) { + for (const entry of await fileSystem.readDirectoryEntries(snippetsPath)) { + const snippet: SnippetData = await fileSystem.readFileJson(entry.path) + + this.snippets.push(new Snippet(snippet)) + } + } + + console.log('[Extension] Loaded:', this.manifest.name) + } + + public async activate() { + this.buildModules() + + await this.runScripts() + + console.log('[Extension] Activated:', this.manifest?.name ?? `Invalid (${this.id})`) + } + + public async deactivate() { + this.deactivated.dispatch() + + console.log('[Extension] Deactivated:', this.manifest?.name ?? `Invalid (${this.id})`) + } + + public static registerModule(name: string, module: ExtensionModuleBuilder) { + Extension.moduleBuilders[name] = module + } + + public buildModules() { + this.modules = Object.entries(Extension.moduleBuilders).map(([id, moduleBuilder]) => [id, moduleBuilder(this)]) + } + + private async runScripts() { + const promises: Promise[] = [] + + const scriptsPath = join(this.path, 'scripts') + if (await fileSystem.exists(scriptsPath)) { + iterateDirectory(fileSystem, scriptsPath, (entry) => { + const runtime = new ExtensionRuntime(fileSystem, this.modules) + + promises.push(runtime.run(entry.path)) + }) + } + + await Promise.all(promises) + } +} diff --git a/src/libs/extensions/Extensions.ts b/src/libs/extensions/Extensions.ts new file mode 100644 index 000000000..98518c09b --- /dev/null +++ b/src/libs/extensions/Extensions.ts @@ -0,0 +1,288 @@ +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { extname, join } from 'pathe' +import { Unzipped, unzip } from 'fflate' +import { Extension, ExtensionManifest } from './Extension' +import { PWAFileSystem } from '@/libs/fileSystem/PWAFileSystem' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { Ref, onMounted, onUnmounted, ref } from 'vue' +import { Event } from '@/libs/event/Event' +import { Disposable } from '@/libs/disposeable/Disposeable' +import { Theme } from '@/libs/theme/Theme' +import { Snippet } from '@/libs/snippets/Snippet' + +export class Extensions { + public static globalExtensions: Record = {} + public static projectExtensions: Record = {} + public static activeExtensions: Record = {} + + public static updated: Event = new Event() + + public static themes: Theme[] = [] + public static snippets: Snippet[] = [] + public static presets: Record = {} + + public static loaded: boolean = false + + public static setup() { + if (fileSystem instanceof PWAFileSystem) fileSystem.reloaded.on(this.fileSystemReloaded.bind(this)) + } + + public static async load() { + if (fileSystem instanceof PWAFileSystem && !fileSystem.setup) return + + await this.loadGlobalExtensions() + + this.loaded = true + } + + public static async loadGlobalExtensions() { + if (!(await fileSystem.exists('extensions'))) await fileSystem.makeDirectory('extensions') + + for (const entry of await fileSystem.readDirectoryEntries('extensions')) { + const extension = await this.loadExtension(entry.path) + + this.globalExtensions[extension.id] = extension + } + + await this.updateExtensions() + } + + public static async loadProjectExtensions() { + if (ProjectManager.currentProject === null) return + + const path = join(ProjectManager.currentProject.path, '.bridge/extensions') + + if (!(await fileSystem.exists(path))) await fileSystem.makeDirectory(path) + + for (const entry of await fileSystem.readDirectoryEntries(path)) { + const extension = await this.loadExtension(entry.path) + + this.projectExtensions[extension.id] = extension + } + + await this.updateExtensions() + } + + public static async unloadProjectExtensions() { + this.projectExtensions = {} + + await this.updateExtensions() + } + + public static async reload() { + for (const extension of Object.values(this.activeExtensions)) { + await extension.deactivate() + } + + for (const extension of Object.values(this.activeExtensions)) { + await extension.activate() + } + } + + public static async installGlobal(extension: ExtensionManifest) { + const path = join('extensions', extension.name.replace(/\s+/g, '')) + + const loadedExtension = await this.installExtension(extension, path) + + this.globalExtensions[loadedExtension.id] = loadedExtension + + await this.updateExtensions() + } + + public static async installProject(extension: ExtensionManifest) { + if (ProjectManager.currentProject === null) return + + const path = join(ProjectManager.currentProject.path, '.bridge/extensions', extension.name.replace(/\s+/g, '')) + + const loadedExtension = await this.installExtension(extension, path) + + this.projectExtensions[loadedExtension.id] = loadedExtension + + await this.updateExtensions() + } + + public static async uninstall(id: string) { + const projectExtension = this.projectExtensions[id] + + const extension = projectExtension ?? this.globalExtensions[id] + + if (extension === undefined) return + + await fileSystem.removeDirectory(extension.path) + + if (projectExtension !== undefined) { + delete this.projectExtensions[id] + } else { + delete this.globalExtensions[id] + } + + await this.updateExtensions() + } + + private static async updateExtensions() { + const oldActiveExtensions = Object.values(this.activeExtensions) + + this.activeExtensions = { ...this.globalExtensions, ...this.projectExtensions } + + this.themes = Object.values(this.activeExtensions) + .filter((extension) => this.isInstalledGlobal(extension.id)) + .flatMap((extension) => extension.themes) + + this.snippets = Object.values(this.activeExtensions).flatMap((extension) => extension.snippets) + + this.presets = {} + for (const extension of Object.values(this.activeExtensions)) { + this.presets = { ...this.presets, ...extension.presets } + } + + const newActiveExtensions = Object.values(this.activeExtensions) + + for (const extension of oldActiveExtensions) { + if (newActiveExtensions.includes(extension)) continue + + await extension.deactivate() + } + + for (const extension of newActiveExtensions) { + if (oldActiveExtensions.includes(extension)) continue + + await extension.activate() + } + + this.updated.dispatch() + } + + private static async installExtension(extension: ExtensionManifest, path: string): Promise { + const unzippedExtension = await this.downloadExtension(extension) + + for (const filePath in unzippedExtension) { + if (filePath.startsWith('.')) continue + + await fileSystem.ensureDirectory(join(path, filePath)) + + if (filePath.endsWith('/')) { + await fileSystem.makeDirectory(join(path, filePath)) + + continue + } + + await fileSystem.writeFile(join(path, filePath), unzippedExtension[filePath]) + } + + if (ProjectManager.currentProject !== null) { + const contributeFiles = extension.contributeFiles + + if (contributeFiles !== undefined) { + for (const [entryPath, { pack, path: projectPath }] of Object.entries(contributeFiles)) { + const resultPath = ProjectManager.currentProject.resolvePackPath(pack, projectPath) + + await fileSystem.ensureDirectory(resultPath) + + if (extname(entryPath) === '') { + await fileSystem.copyDirectory(join(path, entryPath), resultPath) + } else { + await fileSystem.copyFile(join(path, entryPath), resultPath) + } + } + } + } + + return await this.loadExtension(path) + } + + private static async downloadExtension(extension: ExtensionManifest): Promise { + const arrayBuffer = await ( + await fetch('https://raw.githubusercontent.com/bridge-core/plugins/master' + extension.link) + ).arrayBuffer() + + return await new Promise(async (resolve, reject) => + unzip(new Uint8Array(arrayBuffer), (err, data) => { + if (err) reject(err) + else resolve(data) + }) + ) + } + + private static async fileSystemReloaded() { + await this.load() + } + + private static async loadExtension(path: string): Promise { + const extension = new Extension(path) + await extension.load() + + return extension + } + + public static isInstalledGlobal(id: string): boolean { + return this.globalExtensions[id] !== undefined + } + + public static isInstalledProject(id: string): boolean { + return this.projectExtensions[id] !== undefined + } + + public static isInstalled(id: string): boolean { + return this.isInstalledGlobal(id) || this.isInstalledProject(id) + } + + public static useIsInstalledGlobal(): Ref<(id: string) => boolean> { + const isInstalledGlobal = ref((id: string) => this.isInstalledGlobal(id)) + + const update = () => { + isInstalledGlobal.value = (id: string) => this.isInstalledGlobal(id) + } + + let disposable: Disposable + + onMounted(() => { + disposable = this.updated.on(update) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return isInstalledGlobal + } + + public static useIsInstalledProject(): Ref<(id: string) => boolean> { + const isInstalledProject = ref((id: string) => this.isInstalledProject(id)) + + const update = () => { + isInstalledProject.value = (id: string) => this.isInstalledProject(id) + } + + let disposable: Disposable + + onMounted(() => { + disposable = this.updated.on(update) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return isInstalledProject + } + + public static useIsInstalled(): Ref<(id: string) => boolean> { + const isInstalled = ref((id: string) => this.isInstalled(id)) + + const update = () => { + isInstalled.value = (id: string) => this.isInstalled(id) + } + + let disposable: Disposable + + onMounted(() => { + disposable = this.updated.on(update) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return isInstalled + } +} diff --git a/src/libs/extensions/Modules.ts b/src/libs/extensions/Modules.ts new file mode 100644 index 000000000..9338e2565 --- /dev/null +++ b/src/libs/extensions/Modules.ts @@ -0,0 +1,654 @@ +import TextButton from '@/components/Common/TextButton.vue' +import LabeledInput from '@/components/Common/LabeledInput.vue' +import LabeledTextInput from '@/components/Common/LabeledTextInput.vue' +import LabeledDropdown from '@/components/Common/LabeledDropdown.vue' +import LabeledAutocompleteInput from '@/components/Common/LabeledAutocompleteInput.vue' +import InformativeToggle from '@/components/Common/InformativeToggle.vue' +import Icon from '@/components/Common/Icon.vue' +import IconButton from '@/components/Common/IconButton.vue' +import FreeContextMenu from '@/components/Common/FreeContextMenu.vue' +import Expandable from '@/components/Common/Expandable.vue' +import DropdownComponent from '@/components/Common/Dropdown.vue' +import ContextMenuItem from '@/components/Common/ContextMenuItem.vue' +import ContextMenu from '@/components/Common/ContextMenu.vue' +import Button from '@/components/Common/Button.vue' +import ActionContextMenuItem from '@/components/Common/ActionContextMenuItem.vue' +import ActionComponent from '@/components/Common/Action.vue' +import Info from '@/components/Common/Info.vue' +import Warning from '@/components/Common/Warning.vue' +import SubMenu from '@/components/Common/SubMenu.vue' +import Switch from '@/components/Common/Switch.vue' + +import { Button as SidebarButton, Divider as SidebarDivider, Sidebar } from '@/components/Sidebar/Sidebar' +import { TabManager } from '@/components/TabSystem/TabManager' +import { Tab } from '@/components/TabSystem/Tab' +import { appVersion } from '@/libs/app/AppEnv' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { BaseEntry, StreamableLike } from '@/libs/fileSystem/BaseFileSystem' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { NotificationSystem } from '@/components/Notifications/NotificationSystem' +import { dirname, join, extname, basename, resolve, relative } from 'pathe' +import { del, get, set, keys } from 'idb-keyval' +import { FileTab } from '@/components/TabSystem/FileTab' +import { ThemeManager, ThemeSettings } from '@/libs/theme/ThemeManager' +import { Settings } from '@/libs/settings/Settings' +import { openUrl } from '@/libs/OpenUrl' +import { Windows } from '@/components/Windows/Windows' +import { AlertWindow } from '@/components/Windows/Alert/AlertWindow' +import { ConfirmWindow } from '@/components/Windows/Confirm/ConfirmWindow' +import { DropdownWindow } from '@/components/Windows/Dropdown/DropdownWindow' +import { InformedChoiceWindow } from '@/components/Windows/InformedChoice/InformedChoiceWindow' +import { PromptWindow } from '@/components/Windows/Prompt/PromptWindow' +import { ProgressWindow } from '@/components/Windows/Progress/ProgressWindow' +import { disposeAll, Disposable } from '@/libs/disposeable/Disposeable' +import { Extension } from './Extension' +import { Action } from '@/libs/actions/Action' +import { ActionManager } from '@/libs/actions/ActionManager' +import { + Dropdown as ToolbarDropdown, + DropdownItem as ToolbarDropdownItem, + Button as ToolbarButton, + Toolbar, +} from '@/components/Toolbar/Toolbar' +import { FileImporter } from '@/libs/import/FileImporter' +import { DirectoryImporter } from '@/libs/import/DirectoryImporter' +import { ImporterManager } from '@/libs/import/ImporterManager' +import { ExportActionManager } from '@/libs/actions/export/ExportActionManager' +import { TabTypes } from '@/components/TabSystem/TabTypes' +import { FileActionManager } from '@/libs/actions/file/FileActionManager' +import { TabActionManager } from '@/libs/actions/tab/TabActionManager' + +import json5 from 'json5' +import * as fflate from 'fflate' +import * as three from 'three' +import * as vue from 'vue' + +export function setupModules() { + Extension.registerModule('@bridge/sidebar', (extension) => { + let sidebarItems: (SidebarButton | SidebarDivider)[] = [] + + extension.deactivated.once(() => { + for (const item of sidebarItems) { + Sidebar.remove(item) + } + + sidebarItems = [] + }) + + return { + addSidebarButton(id: string, displayName: string, icon: string, action: string) { + sidebarItems.push( + Sidebar.addButton(id, displayName, icon, () => { + ActionManager.trigger(action) + }) + ) + }, + addSidebarDivider() { + sidebarItems.push(Sidebar.addDivider()) + }, + addSidebarTabButton(id: string, displayName: string, icon: string, tab: Tab) { + sidebarItems.push( + Sidebar.addButton(id, displayName, icon, () => { + TabManager.openTab(tab) + }) + ) + }, + } + }) + + Extension.registerModule('@bridge/ui', () => ({ + TextButton, + LabeledInput, + LabeledTextInput, + LabeledDropdown, + LabeledAutocompleteInput, + InformativeToggle, + Icon, + IconButton, + FreeContextMenu, + Expandable, + Dropdown: DropdownComponent, + ContextMenuItem, + ContextMenu, + Button, + ActionContextMenuItem, + Action: ActionComponent, + Info, + Warning, + SubMenu, + Switch, + })) + + Extension.registerModule('@bridge/env', () => ({ + appVersion, + })) + + Extension.registerModule('@bridge/project', (extension) => { + let disposables: Disposable[] = [] + + extension.deactivated.once(() => { + disposeAll(disposables) + + disposables = [] + }) + + return { + getCurrentProject() { + return ProjectManager.currentProject + }, + getBPPath() { + return ProjectManager.currentProject?.resolvePackPath('behaviorPack') ?? null + }, + getRPPath() { + return ProjectManager.currentProject?.resolvePackPath('resourcePack') ?? null + }, + getNamespace() { + return ProjectManager.currentProject?.config?.namespace ?? null + }, + getTargetVersion() { + return ProjectManager.currentProject?.config?.targetVersion ?? null + }, + getAuthors() { + return ProjectManager.currentProject?.config?.authors ?? null + }, + resolvePackPath(packId: string, filePath: string) { + return ProjectManager.currentProject?.resolvePackPath(packId, filePath) ?? null + }, + hasPacks(packs: string[]) { + if (!ProjectManager.currentProject) return false + + for (const pack of packs) { + if (ProjectManager.currentProject.packs[pack] === undefined) return false + } + + return true + }, + onProjectChanged(callback: (projectName: string | null) => void) { + disposables.push( + ProjectManager.updatedCurrentProject.on(() => { + callback(ProjectManager.currentProject?.name ?? null) + }) + ) + }, + } + }) + + Extension.registerModule('@bridge/import', (extension) => { + let registeredFileImporters: FileImporter[] = [] + let registeredDirectoryImporters: DirectoryImporter[] = [] + + extension.deactivated.once(() => { + for (const importer of Object.values(registeredFileImporters)) { + ImporterManager.removeFileImporter(importer) + } + + for (const importer of Object.values(registeredDirectoryImporters)) { + ImporterManager.removeDirectoryImporter(importer) + } + + registeredFileImporters = [] + registeredDirectoryImporters = [] + }) + + return { + FileImporter, + DirectoryImporter, + addFileImporter(importer: FileImporter) { + registeredFileImporters.push(importer) + + ImporterManager.addFileImporter(importer) + }, + addDirectoryImporter(importer: DirectoryImporter) { + registeredDirectoryImporters.push(importer) + + ImporterManager.addDirectoryImporter(importer) + }, + } + }) + + Extension.registerModule('@bridge/export', (extension) => { + let registeredExportActions: string[] = [] + + extension.deactivated.once(() => { + for (const exportAction of registeredExportActions) { + ExportActionManager.removeAction(exportAction) + } + + registeredExportActions = [] + }) + + return { + addExportAction(action: string) { + registeredExportActions.push(action) + + ExportActionManager.addAction(action) + }, + removeExportAction(action: string) { + registeredExportActions.splice(registeredExportActions.indexOf(action)) + + ExportActionManager.removeAction(action) + }, + } + }) + + Extension.registerModule('@bridge/fileAction', (extension) => { + let registeredFileActions: string[] = [] + + extension.deactivated.once(() => { + for (const action of registeredFileActions) { + FileActionManager.removeAction(action) + } + + registeredFileActions = [] + }) + + return { + addFileAction(action: string, fileTypes: string[]) { + registeredFileActions.push(action) + + FileActionManager.addAction(action, fileTypes) + }, + removeFileAction(action: string, fileTypes: string[]) { + registeredFileActions.splice(registeredFileActions.indexOf(action)) + + FileActionManager.removeAction(action) + }, + } + }) + + Extension.registerModule('@bridge/tabAction', (extension) => { + let registeredTabActions: string[] = [] + + extension.deactivated.once(() => { + for (const action of registeredTabActions) { + TabActionManager.removeAction(action) + } + + registeredTabActions = [] + }) + + return { + addTabAction(action: string, fileTypes: string[]) { + registeredTabActions.push(action) + + TabActionManager.addAction(action, fileTypes) + }, + removeTabAction(action: string, fileTypes: string[]) { + registeredTabActions.splice(registeredTabActions.indexOf(action)) + + TabActionManager.removeAction(action) + }, + } + }) + + Extension.registerModule('@bridge/compiler', () => ({ + async compile() { + if (ProjectManager.currentProject instanceof BedrockProject) await ProjectManager.currentProject.build() + }, + async compileFiles(paths: string[]) { + if (ProjectManager.currentProject instanceof BedrockProject) await ProjectManager.currentProject.dashService.compileFiles(paths) + }, + })) + + Extension.registerModule('@bridge/com-mojang', () => ({ + async readFile(path: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + return await ProjectManager.currentProject.outputFileSystem.readFile(path) + }, + async readFileText(path: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + return await ProjectManager.currentProject.outputFileSystem.readFileText(path) + }, + async readFileDataUrl(path: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + return await ProjectManager.currentProject.outputFileSystem.readFileDataUrl(path) + }, + async readFileJson(path: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + return await ProjectManager.currentProject.outputFileSystem.readFileJson(path) + }, + async writeFile(path: string, content: FileSystemWriteChunkType) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + return await ProjectManager.currentProject.outputFileSystem.writeFile(path, content) + }, + async writeFileJson(path: string, content: object, prettify: boolean) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + await ProjectManager.currentProject.outputFileSystem.writeFileJson(path, content, prettify) + }, + async writeFileStreaming(path: string, stream: StreamableLike) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + await ProjectManager.currentProject.outputFileSystem.writeFileStreaming(path, stream) + }, + async removeFile(path: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + await ProjectManager.currentProject.outputFileSystem.removeFile(path) + }, + async copyFile(path: string, newPath: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + await ProjectManager.currentProject.outputFileSystem.copyFile(path, newPath) + }, + async readDirectoryEntries(path: string): Promise { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + return await ProjectManager.currentProject.outputFileSystem.readDirectoryEntries(path) + }, + async getEntry(path: string): Promise { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + return await ProjectManager.currentProject.outputFileSystem.getEntry(path) + }, + async ensureDirectory(path: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + await ProjectManager.currentProject.outputFileSystem.ensureDirectory(path) + }, + async makeDirectory(path: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + await ProjectManager.currentProject.outputFileSystem.makeDirectory(path) + }, + async removeDirectory(path: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + await ProjectManager.currentProject.outputFileSystem.removeDirectory(path) + }, + async move(path: string, newPath: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + await ProjectManager.currentProject.outputFileSystem.move(path, newPath) + }, + async copyDirectory(path: string, newPath: string) { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + await ProjectManager.currentProject.outputFileSystem.move(path, newPath) + }, + async exists(path: string): Promise { + if (!ProjectManager.currentProject) throw new Error('Can not access output filesystem. No project has been loaded yet!') + + return await ProjectManager.currentProject.outputFileSystem.exists(path) + }, + })) + + Extension.registerModule('@bridge/fs', (extension) => { + let disposables: Disposable[] = [] + + extension.deactivated.once(() => { + disposeAll(disposables) + + disposables = [] + }) + + return { + readFile: fileSystem.readFile.bind(fileSystem), + readFileText: fileSystem.readFileText.bind(fileSystem), + readFileDataUrl: fileSystem.readFileDataUrl.bind(fileSystem), + readFileJson: fileSystem.readFileJson.bind(fileSystem), + writeFile: fileSystem.writeFile.bind(fileSystem), + writeFileJson: fileSystem.writeFileJson.bind(fileSystem), + writeFileStreaming: fileSystem.writeFileStreaming.bind(fileSystem), + removeFile: fileSystem.removeFile.bind(fileSystem), + copyFile: fileSystem.copyFile.bind(fileSystem), + readDirectoryEntries: fileSystem.readDirectoryEntries.bind(fileSystem), + getEntry: fileSystem.getEntry.bind(fileSystem), + ensureDirectory: fileSystem.ensureDirectory.bind(fileSystem), + makeDirectory: fileSystem.makeDirectory.bind(fileSystem), + removeDirectory: fileSystem.removeDirectory.bind(fileSystem), + move: fileSystem.move.bind(fileSystem), + copyDirectory: fileSystem.copyDirectory.bind(fileSystem), + exists: fileSystem.exists.bind(fileSystem), + onPathUpdated(callback: (path: string) => void) { + disposables.push( + fileSystem.pathUpdated.on((path) => { + callback(path!) + }) + ) + }, + } + }) + + Extension.registerModule('@bridge/indexer', () => ({ + getCachedData(fileType: string, filePath?: string, cacheKey?: string) { + if (!ProjectManager.currentProject) return null + if (!(ProjectManager.currentProject instanceof BedrockProject)) return null + + ProjectManager.currentProject.indexerService.getCachedData(fileType, filePath, cacheKey) + }, + getIndexedFiles() { + if (!ProjectManager.currentProject) return null + if (!(ProjectManager.currentProject instanceof BedrockProject)) return null + + ProjectManager.currentProject.indexerService.getIndexedFiles() + }, + })) + + Extension.registerModule('@bridge/json5', () => ({ + parse: (str: string) => json5.parse(str), + stringify: (obj: any, replacer?: ((this: any, key: string, value: any) => any) | undefined, space?: string | number | undefined) => + JSON.stringify(obj, replacer, space), + })) + + Extension.registerModule('@bridge/notification', () => ({ + addNotification: NotificationSystem.addNotification, + addProgressNotification: NotificationSystem.addProgressNotification, + clearNotifications: NotificationSystem.clearNotification, + setProgress: NotificationSystem.setProgress, + activateNotification: NotificationSystem.activateNotification, + })) + + Extension.registerModule('@bridge/path', () => ({ + dirname, + join, + extname, + basename, + resolve, + relative, + })) + + Extension.registerModule('@bridge/persistent-storage', () => ({ + save: set, + load: get, + delete: del, + has: async (key: string) => { + return (await keys()).includes(key) + }, + })) + + Extension.registerModule('@bridge/tab', (extension) => { + let registeredTabTypes: (typeof Tab | typeof FileTab)[] = [] + let registeredFileTabTypes: (typeof FileTab)[] = [] + + extension.deactivated.once(() => { + for (const tabType of registeredTabTypes) { + TabTypes.removeTabType(tabType) + } + + for (const tabType of registeredFileTabTypes) { + TabTypes.removeFileTabType(tabType) + } + + registeredTabTypes = [] + registeredFileTabTypes = [] + }) + + return { + Tab, + FileTab, + openFile: TabManager.openFile, + openTab: TabManager.openTab, + registerTabType(tabType: typeof Tab | typeof FileTab) { + registeredTabTypes.push(tabType) + + TabTypes.addTabType(tabType) + }, + registerFileTabType(tabType: typeof FileTab) { + registeredFileTabTypes.push(tabType) + + TabTypes.addFileTabType(tabType) + }, + getFocusedTabSystem() { + return TabManager.focusedTabSystem.value + }, + getTabSystems() { + return TabManager.tabSystems.value + }, + } + }) + + Extension.registerModule('@bridge/theme', (extension) => { + let disposables: Disposable[] = [] + + extension.deactivated.once(() => { + disposeAll(disposables) + + disposables = [] + }) + + return { + getColor(id: string) { + return ThemeManager.get(ThemeManager.currentTheme).colors[id] + }, + getCurrentTheme() { + return ThemeManager.get(ThemeManager.currentTheme) + }, + getCurrentMode() { + const colorScheme = Settings.get(ThemeSettings.ColorScheme) + + if (colorScheme === 'light' || (colorScheme === 'auto' && !ThemeManager.prefersDarkMode())) return 'light' + + return 'dark' + }, + onThemeChanged(callback: () => void) { + disposables.push( + ThemeManager.themeChanged.on(() => { + callback() + }) + ) + }, + } + }) + + Extension.registerModule('@bridge/action', (extension) => { + let registeredActions: Record = {} + + extension.deactivated.once(() => { + for (const action of Object.values(registeredActions)) { + ActionManager.removeAction(action) + + delete registeredActions[action.id] + } + + registeredActions = {} + }) + + return { + Action, + actions: ActionManager.actions, + addAction(action: Action) { + ActionManager.addAction(action) + + registeredActions[action.id] = action + }, + removeAction(action: Action) { + if (!registeredActions[action.id]) throw new Error('You can not remove an acton you have not registered!') + + ActionManager.removeAction(action) + + delete registeredActions[action.id] + }, + trigger(action: string, data: any) { + ActionManager.trigger(action, data) + }, + } + }) + + Extension.registerModule('@bridge/toolbar', (extension) => { + let addedDropdowns: ToolbarDropdown[] = [] + let addedButtons: ToolbarButton[] = [] + let addedDropdownItems: { dropdown: string; item: ToolbarDropdownItem }[] = [] + + extension.deactivated.once(() => { + for (const dropdown of addedDropdowns) { + Toolbar.removeDropdown(dropdown) + } + + addedDropdowns = [] + + for (const button of addedButtons) { + Toolbar.removeButton(button) + } + + addedButtons = [] + + for (const dropdownItem of addedDropdownItems) { + Toolbar.removeDropdownItem(dropdownItem.dropdown, dropdownItem.item) + } + + addedDropdownItems = [] + }) + + return { + addToolbarDropdown(id: string, name: string, items: ToolbarDropdownItem[]) { + const dropdown = Toolbar.addDropdown(id, name, items) + + addedDropdowns.push(dropdown) + }, + addToolbarDropdownItem(dropdown: string, item: ToolbarDropdownItem) { + Toolbar.addDropdownItem(dropdown, item) + + addedDropdownItems.push({ dropdown, item }) + }, + addToolbarButton(action: string) { + const button = Toolbar.addButton(action) + + addedButtons.push(button) + }, + } + }) + + Extension.registerModule('@bridge/utils', () => ({ + openUrl, + })) + + Extension.registerModule('@bridge/windows', () => ({ + AlertWindow, + ConfirmWindow, + DropdownWindow, + InformedChoiceWindow, + PromptWindow, + ProgressWindow, + open: Windows.open, + close: Windows.close, + isOpen: Windows.isOpen, + })) + + Extension.registerModule('@bridge/globals', () => ({ + getGlobals() { + if (!ProjectManager.currentProject) return + + return ProjectManager.currentProject.globals + }, + getGlobalValue(item: string) { + if (!ProjectManager.currentProject) return + + if (!ProjectManager.currentProject.globals) return + + return ProjectManager.currentProject.globals[item] + }, + })) + + Extension.registerModule('@bridge/fflate', () => fflate) + + Extension.registerModule('@bridge/three', () => three) + + Extension.registerModule('@bridge/vue', () => vue) +} diff --git a/src/libs/fileSystem/BaseFileSystem.ts b/src/libs/fileSystem/BaseFileSystem.ts new file mode 100644 index 000000000..d80c351b5 --- /dev/null +++ b/src/libs/fileSystem/BaseFileSystem.ts @@ -0,0 +1,258 @@ +import { basename, dirname, extname, join } from 'pathe' +import { Event } from '@/libs/event/Event' +import { v4 as uuid } from 'uuid' +import { Disposable } from '../disposeable/Disposeable' + +/** + * The Base File Systems acts as a fascade covering the platform specific implementations of the different file systems. New file systems methods should extends BaseFileSystem. + * + * The file system works in terms of paths, relative to a root folder, usually the bridge folder. + * + * Get read entry in order to determine wether a path is a file or directory. + * + * All of these functions may throw errors! + */ +export class BaseFileSystem { + public pathUpdated: Event = new Event() + + protected watchPathsToIgnore: string[] = [] + protected modificationAnnouncements: string[] = [] + + public async readFile(path: string): Promise { + throw new Error('Not implemented!') + } + + public async readFileText(path: string): Promise { + throw new Error('Not implemented!') + } + + public async readFileDataUrl(path: string): Promise { + throw new Error('Not implemented!') + } + + public async readFileJson(path: string): Promise { + throw new Error('Not implemented!') + } + + public async writeFile(path: string, content: FileSystemWriteChunkType) { + throw new Error('Not implemented!') + } + + public async writeFileJson(path: string, content: object, prettify: boolean) { + if (prettify) { + await this.writeFile(path, JSON.stringify(content, null, '\t')) + } else { + await this.writeFile(path, JSON.stringify(content)) + } + } + + public async writeFileStreaming(path: string, stream: StreamableLike) { + throw new Error('Not implemented!') + } + + public async removeFile(path: string) { + throw new Error('Not implemented!') + } + + public async copyFile(path: string, newPath: string) { + const contents = await this.readFile(path) + + await this.writeFile(newPath, contents) + } + + public async readDirectoryEntries(path: string): Promise { + throw new Error('Not implemented!') + } + + public async getEntry(path: string): Promise { + path = this.resolvePath(path) + + try { + const entries = await this.readDirectoryEntries(dirname(path)) + + const entry = entries.find((entry) => entry.path === path) + + if (!entry) throw new Error('Entry does not exist') + + return entry + } catch (error) { + console.error(`Failed to get entry "${path}"`) + + throw error + } + } + + public async ensureDirectory(path: string) { + throw new Error('Not implemented!') + } + + public async makeDirectory(path: string) { + throw new Error('Not implemented!') + } + + public async removeDirectory(path: string) { + throw new Error('Not implemented!') + } + + public async copyDirectoryHandle(path: string, handle: FileSystemDirectoryHandle) { + await this.makeDirectory(path) + + for await (const value of handle.values()) { + if (value.kind === 'file') { + await this.writeFile(join(path, value.name), await value.getFile()) + } else { + await this.copyDirectoryHandle(join(path, value.name), value) + } + } + } + + public async move(path: string, newPath: string) { + if (path === newPath) return + + const entry = await this.getEntry(path) + + if (entry.kind === 'directory') { + await this.copyDirectory(path, newPath) + await this.removeDirectory(path) + } else { + await this.copyFile(path, newPath) + await this.removeFile(path) + } + } + + public async copyDirectory(path: string, newPath: string) { + await this.makeDirectory(newPath) + + const promises: Promise[] = [] + + for (const entry of await this.readDirectoryEntries(path)) { + if (entry.kind === 'file') { + promises.push(this.copyFile(entry.path, join(newPath, basename(entry.path)))) + } + + if (entry.kind === 'directory') { + promises.push(this.copyDirectory(entry.path, join(newPath, basename(entry.path)))) + } + } + + await Promise.all(promises) + } + + public async copyFileFromFileSystem(path: string, fileSystem: BaseFileSystem, newPath: string) { + const contents = await fileSystem.readFile(path) + + await this.writeFile(newPath, contents) + } + + public async copyDirectoryFromFileSystem(path: string, fileSystem: BaseFileSystem, newPath: string) { + await this.makeDirectory(newPath) + + const promises: Promise[] = [] + + for (const entry of await fileSystem.readDirectoryEntries(path)) { + if (entry.kind === 'file') { + promises.push(this.copyFileFromFileSystem(entry.path, fileSystem, join(newPath, basename(entry.path)))) + } + + if (entry.kind === 'directory') { + promises.push(this.copyDirectoryFromFileSystem(entry.path, fileSystem, join(newPath, basename(entry.path)))) + } + } + + await Promise.all(promises) + } + + public async exists(path: string): Promise { + throw new Error('Not implemented!') + } + + public async watch(path: string) { + throw new Error('Not implemented!') + } + + public async ingorePath(path: string) { + this.watchPathsToIgnore.push(path) + } + + public async unwatch(path: string) { + throw new Error('Not implemented!') + } + + public async findSuitableFileName(targetPath: string) { + const entries = await this.readDirectoryEntries(dirname(targetPath)) + const fileExt = extname(targetPath) + let newPath = join(dirname(targetPath), basename(targetPath, fileExt)) + + while (entries.find((entry) => entry.path === newPath + fileExt)) { + if (!newPath.includes(' copy')) { + // 1. Add "copy" to the end of the name + newPath = `${newPath} copy` + } else { + // 2. Add a number to the end of the name + const number = parseInt(newPath.match(/copy (\d+)/)?.[1] ?? '1') + newPath = newPath.replace(/ \d+$/, '') + ` ${number + 1}` + } + } + + return newPath + fileExt + } + + public async findSuitableFolderName(targetPath: string) { + const entries = await this.readDirectoryEntries(dirname(targetPath)) + let newPath = targetPath + + while (entries.find((entry) => entry.path === newPath)) { + if (!newPath.includes(' copy')) { + // 1. Add "copy" to the end of the name + newPath = `${newPath} copy` + } else { + // 2. Add a number to the end of the name + const number = parseInt(newPath.match(/copy (\d+)/)?.[1] ?? '1') + newPath = newPath.replace(/ \d+$/, '') + ` ${number + 1}` + } + } + + return newPath + } + + protected resolvePath(path: string): string { + return path + } + + public announceFileModifications(): Disposable { + const id = uuid() + + this.modificationAnnouncements.push(id) + + return { + dispose: () => { + this.modificationAnnouncements.splice(this.modificationAnnouncements.indexOf(id), 1) + }, + } + } + + public modificationsAnnounced(): boolean { + return this.modificationAnnouncements.length > 0 + } +} + +export type StreamableLike = { + ondata: (err: any | null, data: Uint8Array, final: boolean) => void + start: () => void +} + +export class BaseEntry { + constructor(public path: string, public kind: 'file' | 'directory') {} + + public async read(): Promise { + throw new Error('readFile is not implemented on this entry!') + } + + public async readText(): Promise { + throw new Error('readFileText is not implemented on this entry!') + } + + public async getFileSystem(): Promise { + throw new Error('getFileSystem is not implemented on this entry!') + } +} diff --git a/src/libs/fileSystem/CompatabilityFileSystem.ts b/src/libs/fileSystem/CompatabilityFileSystem.ts new file mode 100644 index 000000000..394b154e6 --- /dev/null +++ b/src/libs/fileSystem/CompatabilityFileSystem.ts @@ -0,0 +1,47 @@ +import { FileSystem } from '@bridge-editor/dash-compiler' +import { BaseFileSystem } from './BaseFileSystem' +import { basename, join, sep } from 'pathe' + +export class CompatabilityFileSystem extends FileSystem { + constructor(public fileSystem: BaseFileSystem) { + super() + } + + async readFile(path: string): Promise { + const content = await this.fileSystem.readFile(path) + + const file = new File([new Blob([content])], basename(path)) + + return file + } + async writeFile(path: string, content: string | Uint8Array): Promise { + await this.fileSystem.ensureDirectory(path) + + await this.fileSystem.writeFile(path, content) + } + async unlink(path: string): Promise { + throw new Error('Method not implemented.') + } + async readdir(path: string): Promise< + { + name: string + kind: 'file' | 'directory' + }[] + > { + if (!(await this.fileSystem.exists(path))) return [] + + return (await this.fileSystem.readDirectoryEntries(path)).map((entry) => { + return { + name: basename(entry.path), + kind: entry.kind, + path: entry.path, + } + }) + } + async mkdir(path: string): Promise { + throw new Error('Method not implemented.') + } + async lastModified(filePath: string): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/src/libs/fileSystem/FileSystem.ts b/src/libs/fileSystem/FileSystem.ts new file mode 100644 index 000000000..a55b89278 --- /dev/null +++ b/src/libs/fileSystem/FileSystem.ts @@ -0,0 +1,513 @@ +import { tauriBuild } from '@/libs/tauri/Tauri' +import { BaseEntry, BaseFileSystem } from './BaseFileSystem' +import { PWAFileSystem } from './PWAFileSystem' +import { TauriFileSystem } from './TauriFileSystem' +import { get, set } from 'idb-keyval' +import { LocalFileSystem } from './LocalFileSystem' +import { onMounted, onUnmounted, shallowRef, ShallowRef } from 'vue' +import { Disposable } from '@/libs/disposeable/Disposeable' +import { open } from '@tauri-apps/api/dialog' +import { readBinaryFile } from '@tauri-apps/api/fs' +import { basename, resolve } from 'pathe' +import { MemoryFileSystem } from './MemoryFileSystem' + +export function getFileSystem(): BaseFileSystem { + if (tauriBuild) return new TauriFileSystem() + + if (!supportsFileSystemApi()) return new LocalFileSystem() + + return new PWAFileSystem(true) +} + +export const fileSystem = getFileSystem() + +export async function loadBridgeFolder() { + if (!(fileSystem instanceof PWAFileSystem)) return + + const savedHandle: undefined | FileSystemDirectoryHandle = await get('bridgeFolderHandle') + + if (!fileSystem.baseHandle && savedHandle && (await fileSystem.ensurePermissions(savedHandle))) { + fileSystem.setBaseHandle(savedHandle) + + return + } +} + +export async function selectBridgeFolder() { + if (!(fileSystem instanceof PWAFileSystem)) return + + try { + fileSystem.setBaseHandle( + (await window.showDirectoryPicker({ + mode: 'readwrite', + })) ?? null + ) + + await set('bridgeFolderHandle', fileSystem.baseHandle) + } catch {} +} + +export async function selectOrLoadBridgeFolder() { + if (!(fileSystem instanceof PWAFileSystem)) return + + const savedHandle: undefined | FileSystemDirectoryHandle = await get('bridgeFolderHandle') + + if (!fileSystem.baseHandle && savedHandle && (await fileSystem.ensurePermissions(savedHandle))) { + fileSystem.setBaseHandle(savedHandle) + + return + } + + try { + fileSystem.setBaseHandle( + (await window.showDirectoryPicker({ + mode: 'readwrite', + })) ?? null + ) + + await set('bridgeFolderHandle', fileSystem.baseHandle) + } catch {} +} + +export async function iterateDirectory(fileSystem: BaseFileSystem, path: string, callback: (entry: BaseEntry) => void | Promise) { + for (const entry of await fileSystem.readDirectoryEntries(path)) { + if (entry.kind === 'directory') { + await iterateDirectory(fileSystem, entry.path, callback) + } else { + await callback(entry) + } + } +} + +export async function iterateDirectoryParrallel( + fileSystem: BaseFileSystem, + path: string, + callback: (entry: BaseEntry) => void | Promise, + ignoreFolders: Set = new Set() +) { + const promises = [] + + for (const entry of await fileSystem.readDirectoryEntries(path)) { + if (entry.kind === 'directory') { + if (!ignoreFolders.has(entry.path)) promises.push(iterateDirectory(fileSystem, entry.path, callback)) + } else { + promises.push(callback(entry)) + } + } + + await Promise.all(promises) +} + +/** + * Chrome 93 and 94 crash when we try to call createWritable on a file handle inside of a web worker + * We therefore enable this polyfill to work around the bug + * + * Additionally, Brave, Opera and similar browsers do not support the FileSystem API so we enable + * the polyfill for all browsers which are not Chrome or Edge + * (Brave and Opera still have the API methods but they're NOOPs so our detection doesn't work) + */ +function supportsFileSystemApi() { + const unsupportedChromeVersions = ['93', '94'] + + // @ts-ignore: TypeScript doesn't know about userAgentData yet + const userAgentData: any = navigator.userAgentData + if (!userAgentData) return false + + if (typeof window.showDirectoryPicker !== 'function') return false + + const chromeBrand = userAgentData.brands.find(({ brand }: any) => brand === 'Google Chrome') + + if (chromeBrand) return !unsupportedChromeVersions.includes(chromeBrand.version) + + const edgeBrand = userAgentData.brands.find(({ brand }: any) => brand === 'Microsoft Edge') + + if (edgeBrand) return true + + return false +} + +export function useBridgeFolderUnloaded(): ShallowRef { + if (!(fileSystem instanceof PWAFileSystem)) return shallowRef(false) + + const valueRef: ShallowRef = shallowRef(!fileSystem.baseHandle) + + function update() { + if (!(fileSystem instanceof PWAFileSystem)) return + + valueRef.value = !fileSystem.baseHandle + } + + let disposable: Disposable + + onMounted(() => { + disposable = fileSystem.reloaded.on(update) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return valueRef +} + +export async function pickFile( + description?: string, + accept?: Record | undefined +): Promise { + if (tauriBuild) { + const extensions = [] + + if (accept) { + for (const acceptedExtensions of Object.values(accept)) { + for (const extension of Array.isArray(acceptedExtensions) ? acceptedExtensions : [acceptedExtensions]) { + extensions.push(extension) + } + } + } + + const files = await open({ + directory: false, + multiple: false, + filters: + accept === undefined + ? undefined + : [ + { + name: description ?? 'File', + extensions: extensions.map((extension) => extension.slice(1)), + }, + ], + }) + + if (!files) return null + + const file = Array.isArray(files) ? files[0] : files + + return new ImportedFileEntry(file, (await readBinaryFile(file)).buffer as ArrayBuffer) + } else if (window.showOpenFilePicker) { + let handles = null + + try { + handles = await window.showOpenFilePicker({ + types: [ + { + description, + accept, + }, + ], + }) + } catch {} + + if (!handles) return null + + const handle = handles[0] + + if (!handle) return null + + return new ImportedFileEntry('/' + handle.name, await (await handle.getFile()).arrayBuffer()) + } else { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'file' + + if (accept) { + const acceptedTypes = [] + + for (const [mimeType, extensions] of Object.entries(accept)) { + acceptedTypes.push(mimeType) + + for (const extension of Array.isArray(extensions) ? extensions : [extensions]) { + acceptedTypes.push(extension) + } + } + + input.accept = acceptedTypes.join(',') + } + + input.onchange = async () => { + const file = input.files?.[0] + + if (!file) { + resolve(null) + + return + } + + const reader = new FileReader() + + reader.onload = () => { + resolve(new ImportedFileEntry('/' + file.name, reader.result as ArrayBuffer)) + } + + reader.onerror = () => { + resolve(null) + } + + reader.readAsArrayBuffer(file) + } + + input.oncancel = () => { + resolve(null) + } + + input.click() + }) + } +} + +export async function pickFiles( + description?: string, + accept?: Record | undefined +): Promise { + if (tauriBuild) { + const extensions = [] + + if (accept) { + for (const acceptedExtensions of Object.values(accept)) { + for (const extension of Array.isArray(acceptedExtensions) ? acceptedExtensions : [acceptedExtensions]) { + extensions.push(extension) + } + } + } + + const files = await open({ + directory: false, + multiple: true, + filters: + accept === undefined + ? undefined + : [ + { + name: description ?? 'File', + extensions: extensions.map((extension) => extension.slice(1)), + }, + ], + }) + + if (!files) return null + + const fileArray = Array.isArray(files) ? files : [files] + + // @ts-ignore TS being weird about buffers + return await Promise.all( + fileArray.map(async (file) => new ImportedFileEntry(file, (await readBinaryFile(file)).buffer as ArrayBuffer)) + ) + } else if (window.showOpenFilePicker) { + let handles = null + + try { + handles = await window.showOpenFilePicker({ + types: [ + { + description, + accept, + }, + ], + }) + } catch {} + + if (!handles) return null + + return await Promise.all( + handles.map(async (handle) => new ImportedFileEntry('/' + handle.name, await (await handle.getFile()).arrayBuffer())) + ) + } else { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'file' + input.multiple = true + + if (accept) { + const acceptedTypes = [] + + for (const [mimeType, extensions] of Object.entries(accept)) { + acceptedTypes.push(mimeType) + + for (const extension of Array.isArray(extensions) ? extensions : [extensions]) { + acceptedTypes.push(extension) + } + } + + input.accept = acceptedTypes.join(',') + } + + input.onchange = async () => { + const items = input.files + + if (!items) { + resolve(null) + + return + } + + const files: File[] = [] + + for (const item of items) { + files.push(item) + } + + const result: (BaseEntry | null)[] = await Promise.all( + files.map( + async (file) => + await new Promise((resolveBuffer) => { + const reader = new FileReader() + + reader.onload = () => { + resolveBuffer(new ImportedFileEntry('/' + file.name, reader.result as ArrayBuffer)) + } + + reader.onerror = () => { + resolveBuffer(null) + } + + reader.readAsArrayBuffer(file) + }) + ) + ) + + if (result.includes(null)) { + resolve(null) + } else { + resolve(result) + } + } + + input.oncancel = () => { + resolve(null) + } + + input.click() + }) + } +} + +export async function pickDirectory(): Promise { + if (tauriBuild) { + const directory = await open({ + directory: true, + multiple: false, + }) + + if (!directory) return null + if (typeof directory !== 'string') return null + + const fileSystem = new TauriFileSystem() + fileSystem.setBasePath(directory) + + return new ImportedDirectoryEntry(directory, fileSystem) + } else if (fileSystem instanceof PWAFileSystem) { + let handle = null + + try { + handle = await window.showDirectoryPicker({ + mode: 'readwrite', + }) + } catch {} + + if (!handle) return null + + const fileSystem = new PWAFileSystem(false) + if (await fileSystem.ensurePermissions(handle)) { + fileSystem.setBaseHandle(handle) + + return new ImportedDirectoryEntry('/' + handle.name, fileSystem) + } + + return null + } else { + const fileSystem = new MemoryFileSystem() + let directoryName = null + + const succeeded = await new Promise((resolveReads) => { + const input = document.createElement('input') + input.type = 'file' + input.webkitdirectory = true + + input.onchange = async () => { + const files = input.files + + if (!files) { + resolveReads(false) + + return + } + + for (const file of files) { + const data = await new Promise((resolveData) => { + const reader = new FileReader() + + reader.onload = () => { + resolveData(reader.result as ArrayBuffer) + } + + reader.onerror = () => { + resolveData(null) + } + + reader.readAsArrayBuffer(file) + }) + + if (!data) { + resolveReads(false) + + return + } + + directoryName = file.webkitRelativePath.split('/')[0] + + const path = file.webkitRelativePath.substring(directoryName.length) + + await fileSystem.ensureDirectory(path) + await fileSystem.writeFile(path, data) + } + + resolveReads(true) + } + + input.oncancel = () => { + resolveReads(false) + } + + input.click() + }) + + if (!succeeded) return null + if (!directoryName) return null + + return new ImportedDirectoryEntry('/' + directoryName, fileSystem) + } +} + +export class ImportedFileEntry extends BaseEntry { + private data: ArrayBuffer + + constructor(path: string, data: ArrayBuffer) { + super(path, 'file') + + this.data = data + } + + public async read(): Promise { + return this.data + } + + public async readText(): Promise { + const decoder = new TextDecoder() + + return decoder.decode(this.data) + } +} + +export class ImportedDirectoryEntry extends BaseEntry { + private fileSystem: BaseFileSystem + + constructor(path: string, fileSystem: BaseFileSystem) { + super(path, 'directory') + + this.fileSystem = fileSystem + } + + public async getFileSystem(): Promise { + return this.fileSystem + } +} diff --git a/src/libs/fileSystem/LocalFileSystem.ts b/src/libs/fileSystem/LocalFileSystem.ts new file mode 100644 index 000000000..2480023b2 --- /dev/null +++ b/src/libs/fileSystem/LocalFileSystem.ts @@ -0,0 +1,250 @@ +import { basename, parse, resolve, sep } from 'pathe' +import { BaseEntry, BaseFileSystem, StreamableLike } from './BaseFileSystem' +import { del, get, keys, set } from 'idb-keyval' +import * as JSONC from 'jsonc-parser' + +export class LocalFileSystem extends BaseFileSystem { + private textEncoder = new TextEncoder() + private textDecoder = new TextDecoder() + + private rootName: string | null = null + + private pathsToWatch: string[] = [] + + public setRootName(name: string) { + this.rootName = name + } + + public async readFile(path: string): Promise { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + const data = (await get(`localFileSystem/${this.rootName}${path}`)).content + + // @ts-ignore TS being weird about errors + if (data instanceof Uint8Array) return data.buffer + + return this.textEncoder.encode(data).buffer + } + + public async readFileText(path: string): Promise { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + try { + const content = (await get(`localFileSystem/${this.rootName}${path}`)).content + + if (typeof content === 'string') return content + + return this.textDecoder.decode(new Uint8Array(content)) + } catch (error) { + console.error(`Failed to read file text "${path}"`) + + throw error + } + } + + public async readFileJson(path: string): Promise { + path = resolve('/', path) + + return JSONC.parse(await this.readFileText(path)) + } + + public async readFileDataUrl(path: string): Promise { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + try { + const content = (await get(`localFileSystem/${this.rootName}${path}`)).content + + if (typeof content === 'string') throw new Error('Reading string as Data Url is not supported yet!') + + const file = new File([new Blob([new Uint8Array(content)])], basename(path)) + + const reader = new FileReader() + + return new Promise((resolve) => { + reader.onload = () => { + resolve(reader.result as string) + } + + reader.readAsDataURL(file) + }) + } catch (error) { + console.error(`Failed to read file as data Url "${path}"`) + + throw error + } + } + + public async writeFile(path: string, content: FileSystemWriteChunkType) { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + await set(`localFileSystem/${this.rootName}${path}`, { + kind: 'file', + content, + }) + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async writeFileStreaming(path: string, stream: StreamableLike) { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + const chunks: Uint8Array[] = [] + let totalLength = 0 + + await new Promise((resolve) => { + stream.ondata = (error, data, final) => { + chunks.push(data) + totalLength += data.length + + if (final) resolve() + } + + stream.start() + }) + + const content = new Uint8Array(totalLength) + let writePosition = 0 + + for (const chunk of chunks) { + content.set(chunk, writePosition) + + writePosition += chunk.length + } + + await set(`localFileSystem/${this.rootName}${path}`, { + kind: 'file', + content, + }) + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async removeFile(path: string) { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + await del(`localFileSystem/${this.rootName}${path}`) + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async makeDirectory(path: string) { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + await set(`localFileSystem/${this.rootName}${path}`, { + kind: 'directory', + }) + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async removeDirectory(path: string) { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + await del(`localFileSystem/${this.rootName}${path}`) + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async ensureDirectory(path: string): Promise { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + const directoryNames = parse(path).dir.split(sep) + + if (directoryNames[0] === '' || directoryNames[0] === '.') directoryNames.shift() + + let currentPath = '' + for (const directoryName of directoryNames) { + currentPath += '/' + directoryName + + if (!(await this.exists(currentPath))) await this.makeDirectory(currentPath) + } + } + + public async exists(path: string): Promise { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + return (await get(`localFileSystem/${this.rootName}${path}`)) !== undefined + } + + public async allEntries(): Promise { + if (this.rootName === null) throw new Error('Root name not set') + + const allKeys = await keys() + const localFSKeys = allKeys + .map((key) => key.toString()) + .filter((key) => key.startsWith(`localFileSystem/${this.rootName}/`)) + .map((key) => key.substring(`localFileSystem/${this.rootName}`.length)) + + return localFSKeys + } + + public async readDirectoryEntries(path: string): Promise { + if (this.rootName === null) throw new Error('Root name not set') + + path = resolve('/', path) + + const allEntries = await this.allEntries() + + const entries = allEntries.filter((entry) => parse(entry).dir === path) + + return Promise.all( + entries.map(async (entryPath) => { + const entry = await get(`localFileSystem/${this.rootName}${entryPath}`) + + return new BaseEntry(entryPath, entry.kind) + }) + ) + } + + public async watch(path: string) { + path = resolve('/', path) + + this.pathsToWatch.push(path) + } + + public async unwatch(path: string) { + path = resolve('/', path) + + this.pathsToWatch.splice(this.pathsToWatch.indexOf(path), 1) + } +} diff --git a/src/libs/fileSystem/MemoryFileSystem.ts b/src/libs/fileSystem/MemoryFileSystem.ts new file mode 100644 index 000000000..6baabc2c3 --- /dev/null +++ b/src/libs/fileSystem/MemoryFileSystem.ts @@ -0,0 +1,229 @@ +import { basename, parse, resolve, sep } from 'pathe' +import { BaseEntry, BaseFileSystem, StreamableLike } from './BaseFileSystem' +import * as JSONC from 'jsonc-parser' + +export class MemoryFileSystem extends BaseFileSystem { + private textEncoder = new TextEncoder() + private textDecoder = new TextDecoder() + + private pathsToWatch: string[] = [] + + private data: Record = {} + + public async readFile(path: string): Promise { + path = resolve('/', path) + + const entry = this.data[path] + + if (entry.kind === 'directory') throw new Error(`Can not call read on a directory! ${path}`) + + const data = entry.content + + // @ts-ignore TS being weird about errors + if (data instanceof Uint8Array) return data.buffer + + if (data instanceof ArrayBuffer) return data + + return this.textEncoder.encode(data as string).buffer + } + + public async readFileText(path: string): Promise { + path = resolve('/', path) + + const entry = this.data[path] + + if (entry.kind === 'directory') throw new Error(`Can not call read on a directory! ${path}`) + + try { + const content = entry.content + + if (typeof content === 'string') return content + + return this.textDecoder.decode(new Uint8Array(content as ArrayBuffer)) + } catch (error) { + console.error(`Failed to read file text "${path}"`) + + throw error + } + } + + public async readFileJson(path: string): Promise { + path = resolve('/', path) + + return JSONC.parse(await this.readFileText(path)) + } + + public async readFileDataUrl(path: string): Promise { + path = resolve('/', path) + + const entry = this.data[path] + + if (entry.kind === 'directory') throw new Error(`Can not call read on a directory! ${path}`) + + try { + const content = entry.content + + if (typeof content === 'string') throw new Error('Reading string as Data Url is not supported yet!') + + const file = new File([new Blob([new Uint8Array(content as ArrayBuffer)])], basename(path)) + + const reader = new FileReader() + + return new Promise((resolve) => { + reader.onload = () => { + resolve(reader.result as string) + } + + reader.readAsDataURL(file) + }) + } catch (error) { + console.error(`Failed to read file as data Url "${path}"`) + + throw error + } + } + + public async writeFile(path: string, content: FileSystemWriteChunkType) { + path = resolve('/', path) + + this.data[path] = { + kind: 'file', + content, + } + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async writeFileStreaming(path: string, stream: StreamableLike) { + path = resolve('/', path) + + const chunks: Uint8Array[] = [] + let totalLength = 0 + + await new Promise((resolve) => { + stream.ondata = (error, data, final) => { + chunks.push(data) + totalLength += data.length + + if (final) resolve() + } + + stream.start() + }) + + const content = new Uint8Array(totalLength) + let writePosition = 0 + + for (const chunk of chunks) { + content.set(chunk, writePosition) + + writePosition += chunk.length + } + + this.data[path] = { + kind: 'file', + content, + } + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async removeFile(path: string) { + path = resolve('/', path) + + delete this.data[path] + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async makeDirectory(path: string) { + path = resolve('/', path) + + this.data[path] = { + kind: 'directory', + } + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async removeDirectory(path: string) { + path = resolve('/', path) + + delete this.data[path] + + if ( + this.pathsToWatch.find((watchPath) => path.startsWith(watchPath)) !== undefined && + this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) === undefined + ) + this.pathUpdated.dispatch(path) + } + + public async ensureDirectory(path: string): Promise { + path = resolve('/', path) + + if (parse(path).dir === '/') return + + const directoryNames = parse(path).dir.split(sep).slice(1) + + let currentPath = '' + for (const directoryName of directoryNames) { + currentPath += '/' + directoryName + + if (!(await this.exists(currentPath))) await this.makeDirectory(currentPath) + } + } + + public async exists(path: string): Promise { + path = resolve('/', path) + + return this.data[path] !== undefined + } + + public async allEntries(): Promise { + return Object.keys(this.data) + } + + public async readDirectoryEntries(path: string): Promise { + path = resolve('/', path) + + const allEntries = await this.allEntries() + + const entries = allEntries.filter((entry) => parse(entry).dir === path && entry !== path) + + return Promise.all( + entries.map(async (entryPath) => { + const entry = this.data[entryPath] + + return new BaseEntry(entryPath, entry.kind) + }) + ) + } + + public async watch(path: string) { + path = resolve('/', path) + + this.pathsToWatch.push(path) + } + + public async unwatch(path: string) { + path = resolve('/', path) + + this.pathsToWatch.splice(this.pathsToWatch.indexOf(path), 1) + } +} diff --git a/src/libs/fileSystem/PWAFileSystem.ts b/src/libs/fileSystem/PWAFileSystem.ts new file mode 100644 index 000000000..7d464b9d5 --- /dev/null +++ b/src/libs/fileSystem/PWAFileSystem.ts @@ -0,0 +1,509 @@ +import { sep, parse, basename, join, resolve } from 'pathe' +import { BaseEntry, BaseFileSystem, StreamableLike } from './BaseFileSystem' +import { Ref, onMounted, onUnmounted, ref } from 'vue' +import { md5 } from 'js-md5' +import { Event } from '@/libs/event/Event' +import { Disposable } from '@/libs/disposeable/Disposeable' +import * as JSONC from 'jsonc-parser' + +export class PWAFileSystem extends BaseFileSystem implements Disposable { + public baseHandle: FileSystemDirectoryHandle | null = null + public reloaded: Event = new Event() + + private cache: { [key: string]: string } = {} + + private pathsToWatch: string[] = [] + + private checkForUpdateTimeout: number | null = null + + constructor(watchEnabled: boolean) { + super() + + if (watchEnabled) this.checkForUpdates() + } + + public get setup(): boolean { + return this.baseHandle !== null + } + + public dispose() { + if (this.checkForUpdateTimeout !== null) clearTimeout(this.checkForUpdateTimeout) + + this.checkForUpdateTimeout = null + } + + public setBaseHandle(handle: FileSystemDirectoryHandle) { + this.baseHandle = handle + + this.reloaded.dispatch() + } + + protected async traverse(path: string): Promise { + if (!this.baseHandle) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + const directoryNames = parse(path).dir.split(sep) + + if (directoryNames[0] === '' || directoryNames[0] === '.') directoryNames.shift() + + let currentHandle = this.baseHandle + + for (const directoryName of directoryNames) { + if (directoryName === '') continue + + currentHandle = await currentHandle.getDirectoryHandle(directoryName) + } + + return currentHandle + } + + public async readFile(path: string): Promise { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const handle = await (await this.traverse(path)).getFileHandle(basename(path)) + + const file = await handle.getFile() + + const reader = new FileReader() + + return new Promise((resolve) => { + reader.onload = () => { + resolve(reader.result as ArrayBuffer) + } + + reader.readAsArrayBuffer(file) + }) + } catch (error) { + console.error(`Failed to read "${path}"`) + + throw error + } + } + + public async readFileJson(path: string): Promise { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const handle = await (await this.traverse(path)).getFileHandle(basename(path)) + + const file = await handle.getFile() + + const reader = new FileReader() + + const result = new Promise((resolve) => { + reader.onload = () => { + try { + resolve(JSONC.parse(reader.result as string)) + } catch { + resolve(undefined) + } + } + + reader.readAsText(file) + }) + + if (result === undefined) throw new Error(`Failed to read "${path}" as JSON`) + + return result + } catch (error) { + console.error(`Failed to read "${path}"`) + + throw error + } + } + + public async readFileText(path: string): Promise { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const handle = await (await this.traverse(path)).getFileHandle(basename(path)) + + const file = await handle.getFile() + + const reader = new FileReader() + + return new Promise((resolve) => { + reader.onload = () => { + resolve(reader.result as string) + } + + reader.readAsText(file) + }) + } catch (error) { + console.error(`Failed to read "${path}"`) + + throw error + } + } + + public async readFileDataUrl(path: string): Promise { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const handle = await (await this.traverse(path)).getFileHandle(basename(path)) + + const file = await handle.getFile() + + const reader = new FileReader() + + return new Promise((resolve) => { + reader.onload = () => { + resolve(reader.result as string) + } + + reader.readAsDataURL(file) + }) + } catch (error) { + console.error(`Failed to read "${path}"`) + + throw error + } + } + + public async writeFile(path: string, content: FileSystemWriteChunkType) { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const handle = await ( + await this.traverse(path) + ).getFileHandle(basename(path), { + create: true, + }) + + const writable: FileSystemWritableFileStream = await handle.createWritable() + + await writable.write(content) + await writable.close() + } catch (error) { + console.error(`Failed to write "${path}"`, error) + } + } + + public async writeFileStreaming(path: string, stream: StreamableLike) { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const handle = await ( + await this.traverse(path) + ).getFileHandle(basename(path), { + create: true, + }) + + const writable: FileSystemWritableFileStream = await handle.createWritable() + let writeIndex = 0 + const writePromises: Promise[] = [] + + await new Promise((resolve, reject) => { + stream.ondata = (err, chunk, final) => { + if (err) return reject(err) + + if (chunk) { + writePromises.push( + // @ts-ignore Weird TS giving errors about buffers + writable.write({ + type: 'write', + data: chunk, + position: writeIndex, + }) + ) + + writeIndex += chunk.length + } + + if (final) resolve() + } + + if (stream.start) stream.start() + }) + + await Promise.all(writePromises) + await writable.close() + } catch (error) { + console.error(`Failed to write "${path}"`, error) + } + } + + public async removeFile(path: string) { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const baseHandle = await await this.traverse(path) + + await baseHandle.removeEntry(basename(path)) + } catch (error) { + console.error(`Failed to remove "${path}"`, error) + } + } + + public async ensureDirectory(path: string) { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const directoryNames = parse(path).dir.split(sep) + + if (directoryNames[0] === '' || directoryNames[0] === '.') directoryNames.shift() + + let currentHandle = this.baseHandle + + for (const directoryName of directoryNames) { + if (directoryName === '') continue + + currentHandle = await currentHandle.getDirectoryHandle(directoryName, { + create: true, + }) + } + } catch (error) { + console.error(`Failed to ensure directory "${path}"`) + + throw error + } + } + + public async readDirectoryEntries(path: string): Promise { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const handle = path === '/' ? this.baseHandle : await (await this.traverse(path)).getDirectoryHandle(basename(path)) + const handleEntries = handle.entries() + + const entries = [] + + for await (const handleEntry of handleEntries) { + if (handleEntry[0].endsWith('.crswap')) continue + + entries.push(new BaseEntry(join(path, handleEntry[0]), handleEntry[1].kind)) + } + + return entries + } catch (error) { + console.error(`Failed to read directory entries of "${path}"`) + + throw error + } + } + + public async makeDirectory(path: string) { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const rootHandle = await await this.traverse(path) + + await rootHandle.getDirectoryHandle(basename(path), { + create: true, + }) + } catch (error) { + console.error(`Failed to make directory "${path}"`) + + throw error + } + } + + public async removeDirectory(path: string) { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + try { + const rootHandle = await await this.traverse(path) + + rootHandle.removeEntry(basename(path), { + recursive: true, + }) + } catch (error) { + console.error(`Failed to remove directory "${path}"`) + + throw error + } + } + + public async exists(path: string): Promise { + if (this.baseHandle === null) throw new Error('Base handle not set!') + + path = this.resolvePath(path) + + const itemNames = path.split(sep) + if (itemNames[0] === '') itemNames.shift() + + let currentHandle = this.baseHandle + + for (let nameIndex = 0; nameIndex < itemNames.length; nameIndex++) { + const name = itemNames[nameIndex] + + const entries = await currentHandle.entries() + let newHandle: FileSystemDirectoryHandle | FileSystemFileHandle | null = null + + for await (const entry of entries) { + if (entry[0] !== name) continue + + newHandle = entry[1] + + break + } + + if (newHandle === null) return false + + if (nameIndex < itemNames.length - 1 && newHandle.kind !== 'directory') return false + + if (nameIndex === itemNames.length - 1) return true + + currentHandle = newHandle + } + + return true + } + + public async hasPermissions(handle: FileSystemDirectoryHandle | FileSystemFileHandle): Promise { + if ((await handle.queryPermission({ mode: 'readwrite' })) === 'granted') return true + + return false + } + + public async ensurePermissions(handle: FileSystemDirectoryHandle | FileSystemFileHandle): Promise { + if ((await handle.queryPermission({ mode: 'readwrite' })) === 'granted') return true + + try { + if ((await handle.requestPermission({ mode: 'readwrite' })) === 'granted') return true + } catch {} + + return false + } + + public async watch(path: string) { + path = this.resolvePath(path) + + await this.indexPath(path) + + this.pathsToWatch.push(path) + } + + public async unwatch(path: string) { + path = this.resolvePath(path) + + this.pathsToWatch.splice(this.pathsToWatch.indexOf(path), 1) + } + + public useSetup(): Ref { + const setup = ref(this.setup) + + const me = this + + function updatedFileSystem() { + setup.value = me.setup + } + + let disposable: Disposable + + onMounted(() => (disposable = me.reloaded.on(updatedFileSystem))) + onUnmounted(() => disposable.dispose()) + + return setup + } + + private async checkForUpdates() { + for (const path of this.pathsToWatch) { + await this.checkForUpdate(path) + } + + this.checkForUpdateTimeout = setTimeout(() => { + this.checkForUpdates() + }, 1000) + } + + private async generateFileHash(path: string): Promise { + const hash = md5.create() + hash.update(await this.readFile(path)) + + return hash.hex() + } + + private async indexPath(path: string) { + const entries = await this.readDirectoryEntries(path) + entries.sort((a, b) => a.path.localeCompare(b.path)) + + let hash = '' + + for (const entry of entries) { + if (this.watchPathsToIgnore.includes(entry.path)) continue + + hash += entry.path + '\n' + + if (entry.kind === 'file') this.cache[entry.path] = await this.generateFileHash(entry.path) + + if (entry.kind === 'directory') await this.indexPath(entry.path) + } + + this.cache[path] = hash + } + + private async checkForUpdate(path: string) { + const entries = await this.readDirectoryEntries(path) + entries.sort((a, b) => a.path.localeCompare(b.path)) + + let hash = '' + + for (const entry of entries) { + if (this.watchPathsToIgnore.includes(entry.path)) continue + + hash += entry.path + '\n' + + if (entry.kind === 'file') { + let fileHash = await this.generateFileHash(entry.path) + + if (this.cache[entry.path] !== fileHash) { + this.pathUpdated.dispatch(this.resolvePath(entry.path)) + } + + this.cache[entry.path] = fileHash + } + + if (entry.kind === 'directory') await this.checkForUpdate(entry.path) + } + + if (this.cache[path] !== hash) { + const previousPaths = (this.cache[path] ?? '').split('\n').filter((path) => path.length > 0) + const newPaths = hash.split('\n').filter((path) => path.length > 0) + + for (const previousPath of previousPaths) { + if (!newPaths.includes(previousPath)) { + this.pathUpdated.dispatch(this.resolvePath(previousPath)) + } + } + + for (const newPath of newPaths) { + if (!previousPaths.includes(newPath)) { + this.pathUpdated.dispatch(this.resolvePath(newPath)) + } + } + + this.pathUpdated.dispatch(path) + } + + this.cache[path] = hash + } + + protected resolvePath(path: string) { + return resolve('/', path) + } +} diff --git a/src/libs/fileSystem/TauriFileSystem.ts b/src/libs/fileSystem/TauriFileSystem.ts new file mode 100644 index 000000000..6cc6ff868 --- /dev/null +++ b/src/libs/fileSystem/TauriFileSystem.ts @@ -0,0 +1,260 @@ +import { createDir, exists, readBinaryFile, readDir, readTextFile, removeDir, removeFile, writeBinaryFile } from '@tauri-apps/api/fs' +import { BaseEntry, BaseFileSystem, StreamableLike } from './BaseFileSystem' +import { dirname, join, resolve } from 'pathe' +import { sep } from '@tauri-apps/api/path' +import { listen } from '@tauri-apps/api/event' +import { invoke } from '@tauri-apps/api' +import * as JSONC from 'jsonc-parser' + +export class TauriFileSystem extends BaseFileSystem { + public basePath: string | null = null + + private textEncoder = new TextEncoder() + + public setBasePath(newPath: string) { + this.basePath = newPath + } + + public startFileWatching() { + listen('watch_event', (event) => { + if (this.basePath === null) throw new Error('Base path not set!') + + const paths = (event.payload as string[]).map((path) => resolve('/', path.substring(this.basePath!.length))) + + for (const path of paths) { + if (this.watchPathsToIgnore.find((watchPath) => path.startsWith(watchPath)) !== undefined) continue + + this.pathUpdated.dispatch(path) + } + }) + } + + public async readFile(path: string): Promise { + if (this.basePath === null) throw new Error('Base path not set!') + + try { + //@ts-ignore + return (await readBinaryFile(join(this.basePath, path))).buffer + } catch (error) { + console.error(`Failed to read "${path}"`) + + throw error + } + } + + public async readFileText(path: string): Promise { + if (this.basePath === null) throw new Error('Base path not set!') + + try { + return await readTextFile(join(this.basePath, path)) + } catch (error) { + console.error(`Failed to read "${path}"`) + + throw error + } + } + + public async readFileDataUrl(path: string): Promise { + if (this.basePath === null) throw new Error('Base path not set!') + + try { + const content = await readBinaryFile(join(this.basePath, path)) + + const reader = new FileReader() + + return new Promise((resolve) => { + reader.onload = () => { + resolve(reader.result as string) + } + + //@ts-ignore + reader.readAsDataURL(new Blob([content.buffer])) + }) + } catch (error) { + console.error(`Failed to read "${path}"`) + + throw error + } + } + + public async readFileJson(path: string): Promise { + if (this.basePath === null) throw new Error('Base path not set!') + + try { + const content = await readTextFile(join(this.basePath, path)) + + return JSONC.parse(content) + } catch (error) { + console.error(`Failed to read "${path}"`) + + throw error + } + } + + public async writeFile(path: string, content: FileSystemWriteChunkType) { + if (this.basePath === null) throw new Error('Base path not set!') + + let writeableContent: ArrayBuffer | null = null + + if (typeof content === 'string') { + //@ts-ignore + writeableContent = this.textEncoder.encode(content) + } + + if (content instanceof ArrayBuffer) { + //@ts-ignore + writeableContent = content + } + + if ((content as ArrayBufferView).buffer) { + //@ts-ignore + writeableContent = (content as ArrayBufferView).buffer + } + + if (content instanceof Blob) { + writeableContent = await content.arrayBuffer() + } + + if (!writeableContent) throw new Error('Can not convert content to writeable content!') + + try { + await writeBinaryFile(join(this.basePath, path), writeableContent) + } catch (error) { + console.error(`Failed to write "${path}"`) + + throw error + } + } + + // TODO: Optimize this later + public async writeFileStreaming(path: string, stream: StreamableLike) { + if (this.basePath === null) throw new Error('Base path not set!') + + path = resolve('/', path) + + const chunks: Uint8Array[] = [] + let totalLength = 0 + + await new Promise((resolve) => { + stream.ondata = (error, data, final) => { + chunks.push(data) + totalLength += data.length + + if (final) resolve() + } + + stream.start() + }) + + const content = new Uint8Array(totalLength) + let writePosition = 0 + + for (const chunk of chunks) { + content.set(chunk, writePosition) + + writePosition += chunk.length + } + + try { + await writeBinaryFile(join(this.basePath, path), content) + } catch (error) { + console.error(`Failed to write "${path}"`) + + throw error + } + } + + public async removeFile(path: string) { + if (this.basePath === null) throw new Error('Base path not set!') + + try { + await removeFile(join(this.basePath, path)) + } catch (error) { + console.error(`Failed to remove "${path}"`) + + throw error + } + } + + public async exists(path: string): Promise { + if (this.basePath === null) throw new Error('Base path not set!') + + return await exists(join(this.basePath, path)) + } + + public async makeDirectory(path: string) { + if (this.basePath === null) throw new Error('Base path not set!') + + try { + if (await this.exists(path)) return + + await createDir(join(this.basePath, path)) + } catch (error) { + console.error(`Failed to make directory "${path}"`) + + throw error + } + } + + public async ensureDirectory(path: string) { + if (this.basePath === null) throw new Error('Base path not set!') + + const directoryPath = dirname(path) + + if (await exists(join(this.basePath, directoryPath))) return + + try { + await createDir(join(this.basePath, directoryPath), { recursive: true }) + } catch (error) { + console.error(`Failed to ensure directory "${path}"`) + + throw error + } + } + + public async removeDirectory(path: string) { + if (this.basePath === null) throw new Error('Base path not set!') + + try { + if (!(await this.exists(path))) return + + await removeDir(join(this.basePath, path)) + } catch (error) { + console.error(`Failed to remove directory "${path}"`) + + throw error + } + } + + public async readDirectoryEntries(path: string): Promise { + if (this.basePath === null) throw new Error('Base path not set!') + + try { + return (await readDir(join(this.basePath, path))).map( + (entry) => + new BaseEntry( + resolve('/', entry.path.substring(this.basePath?.length ?? 0).replaceAll(sep, '/')), + (entry.children !== undefined ? 'directory' : 'file') as 'directory' | 'file' + ) + ) + } catch (error) { + console.error(`Failed to read directory "${path}"`) + + throw error + } + } + + public async watch(path: string) { + invoke('watch', { path: join(this.basePath, path) }) + } + + public async unwatch(path: string) { + invoke('unwatch', { path: join(this.basePath, path) }) + } + + public async revealInFileExplorer(path: string) { + await invoke('reveal_in_file_explorer', { + path: join(this.basePath, path), + }) + } +} diff --git a/src/libs/fileSystem/WorkerFileSystem.ts b/src/libs/fileSystem/WorkerFileSystem.ts new file mode 100644 index 000000000..ce50b8dd3 --- /dev/null +++ b/src/libs/fileSystem/WorkerFileSystem.ts @@ -0,0 +1,149 @@ +import { sendAndWait } from '@/libs/worker/Communication' +import { BaseEntry, BaseFileSystem } from './BaseFileSystem' +import { Disposable } from '@/libs/disposeable/Disposeable' + +export class WorkerFileSystemEntryPoint implements Disposable { + public boundOnWorkerMessage: (event: MessageEvent) => void + + constructor(public worker: Worker, private fileSystem: BaseFileSystem, private name: string = 'fileSystem') { + this.boundOnWorkerMessage = this.onWorkerMessage.bind(this) + + worker.addEventListener('message', this.boundOnWorkerMessage) + } + + private async onWorkerMessage(event: MessageEvent) { + if (!event.data) return + if (event.data.fileSystemName !== this.name) return + + if (event.data.action === 'readFile') { + const data = await this.fileSystem.readFile(event.data.path) + + this.worker.postMessage( + { + arrayBuffer: data, + id: event.data.id, + fileSystemName: this.name, + }, + [data] + ) + } + + if (event.data.action === 'writeFile') { + await this.fileSystem.writeFile(event.data.path, event.data.content) + + this.worker.postMessage({ + id: event.data.id, + fileSystemName: this.name, + }) + } + + if (event.data.action === 'readDirectoryEntries') { + this.worker.postMessage({ + entries: await this.fileSystem.readDirectoryEntries(event.data.path), + id: event.data.id, + fileSystemName: this.name, + }) + } + + if (event.data.action === 'makeDirectory') { + await this.fileSystem.makeDirectory(event.data.path) + + this.worker.postMessage({ + id: event.data.id, + fileSystemName: this.name, + }) + } + + if (event.data.action === 'ensureDirectory') { + await this.fileSystem.ensureDirectory(event.data.path) + + this.worker.postMessage({ + id: event.data.id, + fileSystemName: this.name, + }) + } + + if (event.data.action === 'exists') { + this.worker.postMessage({ + exists: await this.fileSystem.exists(event.data.path), + id: event.data.id, + fileSystemName: this.name, + }) + } + } + + public dispose() { + this.worker.removeEventListener('message', this.boundOnWorkerMessage) + } +} + +export class WorkerFileSystemEndPoint extends BaseFileSystem { + constructor(private name: string = 'fileSystem') { + super() + } + + public async readFile(path: string): Promise { + return ( + await sendAndWait({ + action: 'readFile', + path, + fileSystemName: this.name, + }) + ).arrayBuffer + } + + public async writeFile(path: string, content: string) { + // console.log('Worker write file', path) + + await sendAndWait({ + action: 'writeFile', + path, + content, + fileSystemName: this.name, + }) + } + + public async readDirectoryEntries(path: string): Promise { + // console.log('Worker read directory entries', path) + + return ( + await sendAndWait({ + action: 'readDirectoryEntries', + path, + fileSystemName: this.name, + }) + ).entries + } + + public async makeDirectory(path: string) { + // console.log('Worker make directory', path) + + await sendAndWait({ + action: 'makeDirectory', + path, + fileSystemName: this.name, + }) + } + + public async ensureDirectory(path: string) { + // console.log('Worker ensure directory', path) + + await sendAndWait({ + action: 'ensureDirectory', + path, + fileSystemName: this.name, + }) + } + + public async exists(path: string): Promise { + // console.log('Worker exists', path) + + return ( + await sendAndWait({ + action: 'exists', + path, + fileSystemName: this.name, + }) + ).exists + } +} diff --git a/src/libs/import/BasicFileImporter.ts b/src/libs/import/BasicFileImporter.ts new file mode 100644 index 000000000..00904290f --- /dev/null +++ b/src/libs/import/BasicFileImporter.ts @@ -0,0 +1,51 @@ +import { basename, join } from 'pathe' +import { FileImporter } from './FileImporter' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { TabManager } from '@/components/TabSystem/TabManager' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' + +export class BasicFileImporter extends FileImporter { + public constructor() { + super([ + '.mcfunction', + '.mcstructure', + '.json', + '.molang', + '.js', + '.ts', + '.lang', + '.tga', + '.png', + '.jpg', + '.jpeg', + '.wav', + '.ogg', + '.mp3', + '.fsb', + ]) + } + + public async onImport(entry: BaseEntry, basePath: string) { + if (basePath === '/') return + + if ( + ProjectManager.currentProject && + ProjectManager.currentProject instanceof BedrockProject && + basePath === ProjectManager.currentProject.path + ) { + basePath = (await ProjectManager.currentProject.fileTypeData.guessFolder(entry)) ?? basePath + } + + const targetPath = join(basePath, basename(entry.path)) + + await fileSystem.ensureDirectory(targetPath) + + const suitablePath = await fileSystem.findSuitableFileName(targetPath) + + await fileSystem.writeFile(suitablePath, await entry.read()) + + await TabManager.openFile(suitablePath) + } +} diff --git a/src/libs/import/BrProject.ts b/src/libs/import/BrProject.ts new file mode 100644 index 000000000..67d443d5e --- /dev/null +++ b/src/libs/import/BrProject.ts @@ -0,0 +1,62 @@ +import { fileSystem, selectOrLoadBridgeFolder } from '@/libs/fileSystem/FileSystem' +import { PWAFileSystem } from '@/libs/fileSystem/PWAFileSystem' +import { streamingUnzip } from '@/libs/zip/StreamingUnzipper' +import { basename, join } from 'pathe' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { FileImporter } from './FileImporter' +import { DirectoryImporter } from './DirectoryImporter' +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' + +export async function importFromBrProject(entry: BaseEntry) { + if (fileSystem instanceof PWAFileSystem && !fileSystem.setup) await selectOrLoadBridgeFolder() + + console.time('[Import] .brproject') + + const buffer = new Uint8Array(await entry.read()) + + const targetPath = join('/projects', basename(entry.path, '.brproject')) + const projectPath = await fileSystem.findSuitableFolderName(targetPath) + const projectName = basename(projectPath) + + await streamingUnzip(buffer, async (file) => { + const path = join(projectPath, file.name) + + await fileSystem.ensureDirectory(path) + + await fileSystem.writeFileStreaming(path, file) + }) + + await ProjectManager.closeProject() + await ProjectManager.loadProjects() + await ProjectManager.loadProject(projectName) + + console.timeEnd('[Import] .brproject') +} + +export class BrProjectFileImporter extends FileImporter { + public constructor() { + super(['.brproject']) + } + + public async onImport(entry: BaseEntry, basePath: string) { + await importFromBrProject(entry) + } +} + +export class BrProjectDirectoryImporter extends DirectoryImporter { + public icon: string = 'folder_open' + public name: string = 'fileDropper.importMethod.folder.project.name' + public description: string = 'fileDropper.importMethod.folder.project.description' + + public async onImport(directory: BaseEntry, basePath: string) { + const targetPath = join('/projects', basename(directory.path)) + const projectPath = await fileSystem.findSuitableFolderName(targetPath) + const projectName = basename(projectPath) + + await fileSystem.copyDirectoryFromFileSystem('/', await directory.getFileSystem(), projectPath) + + await ProjectManager.closeProject() + await ProjectManager.loadProjects() + await ProjectManager.loadProject(projectName) + } +} diff --git a/src/libs/import/DirectoryImporter.ts b/src/libs/import/DirectoryImporter.ts new file mode 100644 index 000000000..3c84d0950 --- /dev/null +++ b/src/libs/import/DirectoryImporter.ts @@ -0,0 +1,9 @@ +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' + +export abstract class DirectoryImporter { + public abstract icon: string + public abstract name: string + public abstract description: string + + public abstract onImport(directory: BaseEntry, basePath: string): Promise | void +} diff --git a/src/libs/import/FileImporter.ts b/src/libs/import/FileImporter.ts new file mode 100644 index 000000000..e63c0b4fa --- /dev/null +++ b/src/libs/import/FileImporter.ts @@ -0,0 +1,7 @@ +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' + +export abstract class FileImporter { + constructor(public extensions: string[]) {} + + public abstract onImport(entry: BaseEntry, basePath: string): Promise | void +} diff --git a/src/libs/import/ImporterManager.ts b/src/libs/import/ImporterManager.ts new file mode 100644 index 000000000..2440f4408 --- /dev/null +++ b/src/libs/import/ImporterManager.ts @@ -0,0 +1,94 @@ +import { extname } from 'pathe' +import { FileImporter } from './FileImporter' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { DirectoryImporter } from './DirectoryImporter' +import { Windows } from '@/components/Windows/Windows' +import { InformedChoiceWindow } from '@/components/Windows/InformedChoice/InformedChoiceWindow' +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' + +export class ImporterManager { + protected static fileImporters: Record = {} + protected static defaultFileImporters: FileImporter[] = [] + + protected static directoryImporters: DirectoryImporter[] = [] + + public static addFileImporter(importer: FileImporter, defaultImporter: boolean = true) { + for (const extension of importer.extensions) { + const importers = this.fileImporters[extension] ?? [] + + importers.unshift(importer) + + this.fileImporters[extension] = importers + } + + if (defaultImporter) this.defaultFileImporters.unshift(importer) + } + + public static removeFileImporter(importer: FileImporter) { + if (!this.defaultFileImporters.includes(importer)) return + + for (const extension of importer.extensions) { + if (!this.fileImporters[extension]) continue + + this.fileImporters[extension].splice(this.fileImporters[extension].indexOf(importer), 1) + } + + this.defaultFileImporters.splice(this.defaultFileImporters.indexOf(importer), 1) + } + + public static addDirectoryImporter(importer: DirectoryImporter) { + this.directoryImporters.push(importer) + } + + public static removeDirectoryImporter(importer: DirectoryImporter) { + this.directoryImporters.splice(this.directoryImporters.indexOf(importer), 1) + } + + public static async importFile(entry: BaseEntry, basePath?: string) { + if (!basePath) { + if (ProjectManager.currentProject) { + basePath = ProjectManager.currentProject.path + } else { + basePath = '/' + } + } + + const extension = extname(entry.path) + + if (this.fileImporters[extension] && this.fileImporters[extension].length > 0) { + await this.fileImporters[extension][0].onImport(entry, basePath) + } else if (this.defaultFileImporters.length > 0) { + await this.defaultFileImporters[0].onImport(entry, basePath) + } else { + throw new Error('Could not import file. No importers added!') + } + } + + public static async importDirectory(directory: BaseEntry, basePath?: string) { + if (!basePath) { + if (ProjectManager.currentProject) { + basePath = ProjectManager.currentProject.path + } else { + basePath = '/' + } + } + + if (this.directoryImporters.length > 0) { + Windows.open( + new InformedChoiceWindow( + 'fileDropper.importMethod.name', + this.directoryImporters.map((importer) => ({ + icon: importer.icon, + name: importer.name, + description: importer.description, + choose: () => { + importer.onImport(directory, basePath) + }, + })) + ) + ) + } else { + throw new Error('Could not import directory. No importers added!') + } + } +} diff --git a/src/libs/import/Importers.ts b/src/libs/import/Importers.ts new file mode 100644 index 000000000..13d649da0 --- /dev/null +++ b/src/libs/import/Importers.ts @@ -0,0 +1,16 @@ +import { TauriFileSystem } from '../fileSystem/TauriFileSystem' +import { BasicFileImporter } from './BasicFileImporter' +import { BrProjectDirectoryImporter, BrProjectFileImporter } from './BrProject' +import { ImporterManager } from './ImporterManager' +import { AddonFileImporter as McAddonFileImporter } from './McAddon' +import { McPackFileImporter } from './McPack' +import { tauriBuild } from '@/libs/tauri/Tauri' + +export function setupImporters() { + ImporterManager.addFileImporter(new BasicFileImporter(), true) + ImporterManager.addFileImporter(new BrProjectFileImporter()) + ImporterManager.addFileImporter(new McAddonFileImporter()) + ImporterManager.addFileImporter(new McPackFileImporter()) + + ImporterManager.addDirectoryImporter(new BrProjectDirectoryImporter()) +} diff --git a/src/libs/import/McAddon.ts b/src/libs/import/McAddon.ts new file mode 100644 index 000000000..4629955b0 --- /dev/null +++ b/src/libs/import/McAddon.ts @@ -0,0 +1,107 @@ +import { fileSystem, selectOrLoadBridgeFolder } from '@/libs/fileSystem/FileSystem' +import { PWAFileSystem } from '@/libs/fileSystem/PWAFileSystem' +import { streamingUnzip } from '@/libs/zip/StreamingUnzipper' +import { basename, join } from 'pathe' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { TPackTypeId } from 'mc-project-core' +import { Windows } from '@/components/Windows/Windows' +import { AlertWindow } from '@/components/Windows/Alert/AlertWindow' +import { getPackId, IManifestModule } from '@/libs/manifest/getPackId' +import { CreateProjectConfig } from '@/libs/project/CreateProjectConfig' +import { getLatestStableFormatVersion } from '@/libs/data/bedrock/FormatVersion' +import { createConfig } from '@/libs/project/create/files/Config' +import { FileImporter } from './FileImporter' +import { Data } from '@/libs/data/Data' +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' + +export async function importFromMcAddon(entry: BaseEntry) { + if (fileSystem instanceof PWAFileSystem && !fileSystem.setup) await selectOrLoadBridgeFolder() + + console.time('[Import] .mcaddon') + + const buffer = new Uint8Array(await entry.read()) + + const targetPath = join('/projects', name) + const projectPath = await fileSystem.findSuitableFolderName(targetPath) + const projectName = basename(projectPath) + + await fileSystem.makeDirectory(`/projects/${projectName}`) + await fileSystem.makeDirectory(`/projects/${projectName}/.bridge`) + await fileSystem.makeDirectory(`projects/${projectName}/.bridge/extensions`) + await fileSystem.makeDirectory(`projects/${projectName}/.bridge/compiler`) + + if (await fileSystem.exists('/import')) await fileSystem.removeDirectory('/import') + + await fileSystem.makeDirectory('/import') + + await streamingUnzip(buffer, async (file) => { + const path = join('/import', file.name) + + await fileSystem.ensureDirectory(path) + + await fileSystem.writeFileStreaming(path, file) + }) + + const packDefinitions = await Data.get('packages/minecraftBedrock/packDefinitions.json') + + let authors: string[] | string | undefined + let description: string | undefined + const packs: (TPackTypeId | '.bridge')[] = ['.bridge'] + + for (const entry of await fileSystem.readDirectoryEntries('/import')) { + if (entry.kind === 'directory' && (await fileSystem.exists(`/import/${basename(entry.path)}/manifest.json`))) { + const manifest = await fileSystem.readFileJson(`/import/${basename(entry.path)}/manifest.json`) + const modules = manifest?.modules ?? [] + + if (!authors) authors = manifest?.metadata?.authors + if (!description) description = manifest?.header?.description + + const packId = getPackId(modules) + if (!packId) return + + packs.push(packId) + const packPath = packDefinitions.find((packDefinition: any) => packDefinition.id === packId).defaultPackPath + + // Move the pack to the correct location + await fileSystem.move(`/import/${basename(entry.path)}`, join('/projects', projectName, packPath)) + } + } + + // NOTE: For some reason this causes an error. So we'll just not remove it + // await fileSystem.removeDirectory('/import') + + if (packs.length === 1) Windows.open(new AlertWindow('fileDropper.mcaddon.missingManifests')) + + const createProjectConfig: CreateProjectConfig = { + name: projectName, + description: description ?? '', + namespace: 'bridge', + author: authors ?? ['Unknown'], + targetVersion: await getLatestStableFormatVersion(), + icon: '', + packs: packs, + configurableFiles: [], + rpAsBpDependency: false, + bpAsRpDependency: false, + uuids: {}, + experiments: {}, + } + + await createConfig(fileSystem, join(projectPath, 'config.json'), createProjectConfig) + + await ProjectManager.closeProject() + await ProjectManager.loadProjects() + await ProjectManager.loadProject(projectName) + + console.timeEnd('[Import] .mcaddon') +} + +export class AddonFileImporter extends FileImporter { + public constructor() { + super(['.mcaddon']) + } + + public async onImport(entry: BaseEntry) { + await importFromMcAddon(entry) + } +} diff --git a/src/libs/import/McPack.ts b/src/libs/import/McPack.ts new file mode 100644 index 000000000..cc4de95fe --- /dev/null +++ b/src/libs/import/McPack.ts @@ -0,0 +1,105 @@ +import { fileSystem, selectOrLoadBridgeFolder } from '@/libs/fileSystem/FileSystem' +import { PWAFileSystem } from '@/libs/fileSystem/PWAFileSystem' +import { streamingUnzip } from '@/libs/zip/StreamingUnzipper' +import { basename, join } from 'pathe' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { TPackTypeId } from 'mc-project-core' +import { Windows } from '@/components/Windows/Windows' +import { AlertWindow } from '@/components/Windows/Alert/AlertWindow' +import { getPackId, IManifestModule } from '@/libs/manifest/getPackId' +import { CreateProjectConfig } from '@/libs/project/CreateProjectConfig' +import { getLatestStableFormatVersion } from '@/libs/data/bedrock/FormatVersion' +import { createConfig } from '@/libs/project/create/files/Config' +import { FileImporter } from './FileImporter' +import { Data } from '@/libs/data/Data' +import { BaseEntry } from '@/libs/fileSystem/BaseFileSystem' + +export async function importFromMcPack(entry: BaseEntry) { + if (fileSystem instanceof PWAFileSystem && !fileSystem.setup) await selectOrLoadBridgeFolder() + + console.time('[Import] .mcpack') + + const buffer = new Uint8Array(await entry.read()) + + const targetPath = join('/projects', name) + const projectPath = await fileSystem.findSuitableFolderName(targetPath) + const projectName = basename(projectPath) + + await fileSystem.makeDirectory(`/projects/${projectName}`) + await fileSystem.makeDirectory(`/projects/${projectName}/.bridge`) + await fileSystem.makeDirectory(`projects/${projectName}/.bridge/extensions`) + await fileSystem.makeDirectory(`projects/${projectName}/.bridge/compiler`) + + if (await fileSystem.exists('/import')) await fileSystem.removeDirectory('/import') + + await fileSystem.makeDirectory('/import') + + await streamingUnzip(buffer, async (file) => { + const path = join('/import', file.name) + + await fileSystem.ensureDirectory(path) + + await fileSystem.writeFileStreaming(path, file) + }) + + const packDefinitions = await Data.get('packages/minecraftBedrock/packDefinitions.json') + + let authors: string[] | string | undefined + let description: string | undefined + const packs: (TPackTypeId | '.bridge')[] = ['.bridge'] + + if (await fileSystem.exists(`/import/manifest.json`)) { + const manifest = await fileSystem.readFileJson(`/import/manifest.json`) + const modules = manifest?.modules ?? [] + + if (!authors) authors = manifest?.metadata?.authors + if (!description) description = manifest?.header?.description + + const packId = getPackId(modules) + if (!packId) return + + packs.push(packId) + const packPath = packDefinitions.find((packDefinition: any) => packDefinition.id === packId).defaultPackPath + + // Move the pack to the correct location + await fileSystem.move(`/import`, join('/projects', projectName, packPath)) + } + + // NOTE: For some reason this causes an error. So we'll just not remove it + // await fileSystem.removeDirectory('/import') + + if (packs.length === 1) Windows.open(new AlertWindow('fileDropper.mcaddon.missingManifests')) + + const createProjectConfig: CreateProjectConfig = { + name: projectName, + description: description ?? '', + namespace: 'bridge', + author: authors ?? ['Unknown'], + targetVersion: await getLatestStableFormatVersion(), + icon: '', + packs: packs, + configurableFiles: [], + rpAsBpDependency: false, + bpAsRpDependency: false, + uuids: {}, + experiments: {}, + } + + await createConfig(fileSystem, join(projectPath, 'config.json'), createProjectConfig) + + await ProjectManager.closeProject() + await ProjectManager.loadProjects() + await ProjectManager.loadProject(projectName) + + console.timeEnd('[Import] .mcpack') +} + +export class McPackFileImporter extends FileImporter { + public constructor() { + super(['.mcpack']) + } + + public async onImport(entry: BaseEntry, basePath: string) { + await importFromMcPack(entry) + } +} diff --git a/src/libs/indexer/bedrock/IndexerService.ts b/src/libs/indexer/bedrock/IndexerService.ts new file mode 100644 index 000000000..a74038e5d --- /dev/null +++ b/src/libs/indexer/bedrock/IndexerService.ts @@ -0,0 +1,105 @@ +import { WorkerFileSystemEntryPoint } from '@/libs/fileSystem/WorkerFileSystem' +import IndexerWorker from './IndexerWorker?worker' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { sendAndWait } from '@/libs/worker/Communication' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { Data } from '@/libs/data/Data' +import { Disposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { Event } from '@/libs/event/Event' + +export class IndexerService implements Disposable { + public updated: Event = new Event() + + private index: { [key: string]: { fileType: string; data?: any } } = {} + private instructions: { [key: string]: any } = {} + private worker = new IndexerWorker() + private workerFileSystem = new WorkerFileSystemEntryPoint(this.worker, fileSystem) + + private disposables: Disposable[] = [] + + constructor(public project: BedrockProject) { + this.worker.onmessage = this.onWorkerMessage.bind(this) + } + + public async onWorkerMessage(event: MessageEvent) { + if (!event.data) return + + if (event.data.action === 'getFileType') { + this.worker.postMessage({ + id: event.data.id, + fileType: await this.project.fileTypeData.get(event.data.path), + }) + } + } + + public async setup() { + this.instructions = await Data.get('packages/minecraftBedrock/lightningCaches.json') + + this.disposables.push(fileSystem.pathUpdated.on(this.pathUpdated.bind(this))) + + this.index = ( + await sendAndWait( + { + action: 'setup', + path: this.project.path, + instructions: this.instructions, + config: this.project.config ?? undefined, + }, + this.worker + ) + ).index + + this.updated.dispatch() + } + + public getCachedData(fileType: string, filePath?: string, cacheKey?: string): null | any { + let data: string[] = [] + + if (filePath !== undefined) { + if (this.index[filePath] === undefined) return null + + if (this.index[filePath].fileType !== fileType) return null + + return cacheKey ? this.index[filePath].data[cacheKey] : this.index[filePath].data + } else { + data = Object.values(this.index) + .filter((indexedData) => { + return indexedData.fileType === fileType && indexedData.data + }) + .map((indexedData) => { + return cacheKey ? indexedData.data[cacheKey] : indexedData + }) + .flat() + } + + return data.length === 0 ? null : data + } + + public getIndexedFiles() { + return Object.keys(this.index) + } + + public dispose() { + this.worker.terminate() + + this.workerFileSystem.dispose() + + disposeAll(this.disposables) + } + + private async pathUpdated(path: unknown) { + if (typeof path !== 'string') return + + this.index = ( + await sendAndWait( + { + action: 'index', + path: this.project.path, + }, + this.worker + ) + ).index + + this.updated.dispatch() + } +} diff --git a/src/libs/indexer/bedrock/IndexerWorker.ts b/src/libs/indexer/bedrock/IndexerWorker.ts new file mode 100644 index 000000000..15b079b71 --- /dev/null +++ b/src/libs/indexer/bedrock/IndexerWorker.ts @@ -0,0 +1,266 @@ +import { WorkerFileSystemEndPoint } from '@/libs/fileSystem/WorkerFileSystem' +import { join } from 'pathe' +import { Runtime } from '@/libs/runtime/Runtime' +import { sendAndWait } from '@/libs/worker/Communication' +import { walkObject } from 'bridge-common-utils' +import { IConfigJson } from 'mc-project-core' +import wasmUrl from '@swc/wasm-web/wasm-web_bg.wasm?url' +import { initRuntimes } from '@bridge-editor/js-runtime' +import * as JSONC from 'jsonc-parser' + +initRuntimes(wasmUrl) + +interface IndexInstruction { + cacheKey: string + path: string + pathScript?: string + script?: string + filter?: string[] +} + +const fileSystem = new WorkerFileSystemEndPoint() + +const runtime = new Runtime(fileSystem) + +let instructions: { [key: string]: IndexInstruction[] | string } = {} + +let config: IConfigJson | undefined = undefined +let projectPath: string = '/' + +const textDecoder = new TextDecoder() + +async function getFileType(path: string): Promise { + return ( + await sendAndWait({ + action: 'getFileType', + path, + }) + ).fileType +} + +let index: { [key: string]: { fileType: string; data?: any } } = {} + +function resolvePackPath(packId: string, path: string) { + if (!config) return '' + if (!config.packs) return '' + + if (path === undefined) return join(projectPath ?? '', (config.packs)[packId]) + + return join(projectPath ?? '', (config.packs)[packId], path) +} + +async function runLightningCacheScript(script: string, path: string, value: string): Promise { + runtime.clearCache() + + const scriptResult = await runtime.run( + path, + { + Bridge: { + value, + async withExtension(basePath: string, extensions: string[]) { + for (const extension of extensions) { + const possiblePath = basePath + extension + + if (await fileSystem.exists(possiblePath)) return possiblePath + } + }, + resolvePackPath, + }, + }, + ` + ___module.execute = async function(){ + ${script} + } + ` + ) + + return await scriptResult.execute() +} + +async function handleJsonInstructions(filePath: string, fileType: any, json: any, fileInstructions: IndexInstruction[]) { + let data: { [key: string]: any } = {} + + for (const instruction of fileInstructions) { + const { cacheKey, path, filter, script, pathScript } = instruction + + let paths: string[] = [] + + if (typeof path === 'string') { + paths = [path] + } else { + paths = path + } + + if (pathScript !== undefined) { + runtime.clearCache() + + paths = ( + await runtime.run( + filePath, + { + Bridge: { paths }, + }, + ` + ___module.execute = async function(){ + ${pathScript} + } + ` + ) + ).execute() + } + + if (!Array.isArray(paths) || paths.length === 0) continue + + let foundData: string[] = [] + + for (const jsonPath of paths) { + let promises: Promise[] = [] + + walkObject(jsonPath, json, (data) => { + promises.push( + (async () => { + if (typeof data === 'object') data = Object.keys(data) + if (!Array.isArray(data)) data = [data] + + if (filter !== undefined) data = data.filter((value: string) => !filter.includes(value)) + + if (script !== undefined) { + data = ( + await Promise.all( + data.map(async (value: string) => { + return await runLightningCacheScript(script, filePath, value) + }) + ) + ).filter((value: unknown) => value !== undefined) + } + + foundData = foundData.concat(data) + })() + ) + }) + + await Promise.all(promises) + } + + data[cacheKey] = foundData + } + + index[filePath] = { + fileType: fileType ? fileType.id : 'unkown', + data, + } +} + +async function handleScriptInstructions(path: string, fileType: any, text: string, script: string) { + let data: { [key: string]: any } = {} + + const module: any = {} + + runtime.clearCache() + + await runtime.run( + path, + { + module, + }, + script + ) + + if (typeof module.exports === 'function') + data = module.exports(text, { + resolvePackPath, + }) + + index[path] = { + fileType: fileType ? fileType.id : 'unkown', + data, + } +} + +async function indexFile(path: string) { + let fileType = await getFileType(path) + + let fileInstructions: IndexInstruction[] | string | undefined = undefined + + if (fileType && fileType.lightningCache) + fileInstructions = instructions['file:///data/packages/minecraftBedrock/lightningCache/' + fileType.lightningCache] + + let data: { [key: string]: any } = {} + + if (fileInstructions !== undefined) { + let text = undefined + let json = undefined + + try { + const content = await fileSystem.readFile(path) + + text = textDecoder.decode(new Uint8Array(content)) + json = JSONC.parse(text) + } catch {} + + if (text !== undefined) { + if (typeof fileInstructions === 'string') { + await handleScriptInstructions(path, fileType, text, fileInstructions) + + return + } else { + await handleJsonInstructions(path, fileType, json, fileInstructions) + + return + } + } + } + + index[path] = { + fileType: fileType ? fileType.id : 'unkown', + data, + } +} + +async function indexDirectory(path: string, ignore: string[]) { + for (const entry of await fileSystem.readDirectoryEntries(path)) { + if (ignore.includes(entry.path)) return + + if (entry.kind == 'directory') { + await indexDirectory(entry.path, ignore) + } else { + await indexFile(entry.path) + } + } +} + +async function setup(newConfig: IConfigJson, newInstructions: { [key: string]: any }, actionId: string, path: string) { + index = {} + + config = newConfig + + instructions = newInstructions + + projectPath = path + + await indexDirectory(path, join(path, '.git')) + + postMessage({ + action: 'setupComplete', + index, + id: actionId, + }) +} + +async function reindex(path: string, actionId: string) { + await indexDirectory(path, join(path, '.git')) + + postMessage({ + action: 'indexComplete', + index, + id: actionId, + }) +} + +onmessage = (event: any) => { + if (!event.data) return + + if (event.data.action === 'setup') setup(event.data.config, event.data.instructions, event.data.id, event.data.path) + + if (event.data.action === 'index') reindex(event.data.path, event.data.id) +} diff --git a/src/libs/jsonSchema/Schema.ts b/src/libs/jsonSchema/Schema.ts new file mode 100644 index 000000000..9af6aeeb2 --- /dev/null +++ b/src/libs/jsonSchema/Schema.ts @@ -0,0 +1,713 @@ +type JsonObject = Record + +export interface Diagnostic { + severity: 'error' | 'warning' | 'info' + message: string + path: string +} + +export interface CompletionItem { + type: 'object' | 'array' | 'value' | 'snippet' + label: string + value: unknown +} + +export abstract class Schema { + public constructor( + public requestSchema: (path: string) => JsonObject | undefined, + public path: string + ) {} + + public abstract validate(value: unknown): Diagnostic[] + + public abstract getCompletionItems(value: unknown, path: string): CompletionItem[] + + public abstract getTypes(value: unknown, path: string): string[] + + public isValid(value: unknown) { + return this.validate(value).length === 0 + } +} + +export function getType(value: unknown) { + if (Array.isArray(value)) return 'array' + + if (value === null) return 'null' + + return typeof value +} + +function matchesType(value: unknown, type: string): boolean { + const detectedType = getType(value) + + if (type === 'integer' && detectedType === 'number') return Number.isInteger(value) + + return detectedType === type +} + +// TODO: Investigate translating errors +// TODO: Optimize get completions + +const validPartProperties = [ + 'properties', + 'patternProperties', + 'type', + 'required', + 'additionalProperties', + 'items', + 'enum', + 'const', + 'pattern', + 'default', + 'doNotSuggest', + 'minItems', + 'maxItems', + 'deprecationMessage', +] + +const ignoredProperties = [ + 'title', + 'description', + '$schema', + '$id', + 'definitions', + 'examples', + // TODO: Proper implementation of these fields + 'minimum', + 'maximum', + 'format', + 'maxLength', + 'multipleOf', + 'markdownDescription', +] + +export function createSchema(part: JsonObject, requestSchema: (path: string) => JsonObject | undefined, path: string = '/') { + if ('$ref' in part) return new RefSchema(part, requestSchema, path) + + if ('if' in part) return new IfSchema(part, requestSchema, path) + + if ('allOf' in part) return new AllOfSchema(part, requestSchema, path) + + if ('anyOf' in part) return new AnyOfSchema(part, requestSchema, path) + + return new ValueSchema(part, requestSchema, path) +} + +export class AllOfSchema extends Schema { + public constructor( + public part: JsonObject, + public requestSchema: (path: string) => JsonObject | undefined, + public path: string = '/' + ) { + super(requestSchema, path) + } + + public validate(value: unknown): Diagnostic[] { + let parts: JsonObject[] = this.part.allOf as any + + let diagnostics: Diagnostic[] = [] + + for (const part of parts) { + const schema = createSchema(part, this.requestSchema, this.path) + + diagnostics = diagnostics.concat(schema.validate(value)) + } + + return diagnostics + } + + public getCompletionItems(value: unknown, path: string): CompletionItem[] { + let parts: JsonObject[] = this.part.allOf as any + + let diagnostics: CompletionItem[] = [] + + for (const part of parts) { + const schema = createSchema(part, this.requestSchema, this.path) + + diagnostics = diagnostics.concat(schema.getCompletionItems(value, path)) + } + + return diagnostics + } + + public getTypes(value: unknown, path: string): string[] { + let parts: JsonObject[] = this.part.allOf as any + + let types: string[] = [] + + for (const part of parts) { + const schema = createSchema(part, this.requestSchema, this.path) + + types = types.concat(schema.getTypes(value, path)) + } + + return types + } +} + +export class AnyOfSchema extends Schema { + public constructor( + public part: JsonObject, + public requestSchema: (path: string) => JsonObject | undefined, + public path: string = '/' + ) { + super(requestSchema, path) + } + + public validate(value: unknown): Diagnostic[] { + let parts: JsonObject[] = this.part.anyOf as any + + let diagnostics: Diagnostic[] = [] + + for (const part of parts) { + const schema = createSchema(part, this.requestSchema, this.path) + + const result = schema.validate(value) + + if (result.length === 0) return [] + + if (diagnostics.length === 0) diagnostics = result + } + + return diagnostics + } + + public getCompletionItems(value: unknown, path: string): CompletionItem[] { + let parts: JsonObject[] = this.part.anyOf as any + + let diagnostics: CompletionItem[] = [] + + for (const part of parts) { + const schema = createSchema(part, this.requestSchema, this.path) + + diagnostics = diagnostics.concat(schema.getCompletionItems(value, path)) + } + + return diagnostics + } + + public getTypes(value: unknown, path: string): string[] { + let parts: JsonObject[] = this.part.anyOf as any + + let types: string[] = [] + + for (const part of parts) { + const schema = createSchema(part, this.requestSchema, this.path) + + types = types.concat(schema.getTypes(value, path)) + } + + return types + } +} + +export class RefSchema extends Schema { + public constructor( + public part: JsonObject, + public requestSchema: (path: string) => JsonObject | undefined, + public path: string = '/' + ) { + super(requestSchema, path) + } + + public validate(value: unknown): Diagnostic[] { + let processedPart = { ...this.part } + + delete processedPart.$ref + + processedPart = { ...processedPart, ...this.requestSchema(this.part.$ref as string) } + + return createSchema(processedPart, this.requestSchema, this.path).validate(value) + } + + public getCompletionItems(value: unknown, path: string): CompletionItem[] { + let processedPart = { ...this.part } + + delete processedPart.$ref + + processedPart = { ...processedPart, ...this.requestSchema(this.part.$ref as string) } + + return createSchema(processedPart, this.requestSchema, this.path).getCompletionItems(value, path) + } + + public getTypes(value: unknown, path: string): string[] { + let processedPart = { ...this.part } + + delete processedPart.$ref + + processedPart = { ...processedPart, ...this.requestSchema(this.part.$ref as string) } + + return createSchema(processedPart, this.requestSchema, this.path).getTypes(value, path) + } +} + +export class IfSchema extends Schema { + public constructor( + public part: JsonObject, + public requestSchema: (path: string) => JsonObject | undefined, + public path: string = '/' + ) { + super(requestSchema, path) + } + + public validate(value: unknown): Diagnostic[] { + const condition: JsonObject | boolean = this.part.if as any + const passResult: JsonObject = this.part.then as any + const failResult: JsonObject | undefined = this.part.else as any + + let processedPart = { ...this.part } + + delete processedPart.if + delete processedPart.then + + if ('else' in processedPart) delete processedPart.else + + let passed = false + + if (getType(condition) === 'boolean') { + passed = condition as boolean + } else { + const conditionSchema = createSchema(condition as JsonObject, this.requestSchema, this.path) + + passed = conditionSchema.isValid(value) + } + + if (passed) { + processedPart = { ...processedPart, ...passResult } + } else if (failResult) { + processedPart = { ...processedPart, ...failResult } + } + + return createSchema(processedPart, this.requestSchema, this.path).validate(value) + } + + public getCompletionItems(value: unknown, path: string): CompletionItem[] { + const condition: JsonObject | boolean = this.part.if as any + const passResult: JsonObject = this.part.then as any + const failResult: JsonObject | undefined = this.part.else as any + + let processedPart = { ...this.part } + + delete processedPart.if + delete processedPart.then + + if ('else' in processedPart) delete processedPart.else + + let passed = false + + if (getType(condition) === 'boolean') { + passed = condition as boolean + } else { + const conditionSchema = createSchema(condition as JsonObject, this.requestSchema, this.path) + + passed = conditionSchema.isValid(value) + } + + if (passed) { + processedPart = { ...processedPart, ...passResult } + } else if (failResult) { + processedPart = { ...processedPart, ...failResult } + } + + return createSchema(processedPart, this.requestSchema, this.path).getCompletionItems(value, path) + } + + public getTypes(value: unknown, path: string): string[] { + const condition: JsonObject | boolean = this.part.if as any + const passResult: JsonObject = this.part.then as any + const failResult: JsonObject | undefined = this.part.else as any + + let processedPart = { ...this.part } + + delete processedPart.if + delete processedPart.then + + if ('else' in processedPart) delete processedPart.else + + let passed = false + + if (getType(condition) === 'boolean') { + passed = condition as boolean + } else { + const conditionSchema = createSchema(condition as JsonObject, this.requestSchema, this.path) + + passed = conditionSchema.isValid(value) + } + + if (passed) { + processedPart = { ...processedPart, ...passResult } + } else if (failResult) { + processedPart = { ...processedPart, ...failResult } + } + + return createSchema(processedPart, this.requestSchema, this.path).getTypes(value, path) + } +} + +export class ValueSchema extends Schema { + public constructor( + public part: JsonObject, + public requestSchema: (path: string) => JsonObject | undefined, + public path: string = '/' + ) { + super(requestSchema, path) + + // To help identify unhandled cases + const partProperties = Object.keys(this.part) + + for (const property of partProperties) { + if (!validPartProperties.includes(property) && !ignoredProperties.includes(property)) + throw new Error(`Unkown schema part property "${property}"`) + } + } + + public validate(value: unknown): Diagnostic[] { + let types: undefined | string | string[] = this.part.type as any + if (types !== undefined && !Array.isArray(types)) types = [types] + + let diagnostics: Diagnostic[] = [] + + if (types !== undefined) { + let foundMatch = false + + for (const type of types) { + if (matchesType(value, type)) { + foundMatch = true + + break + } + } + + if (!foundMatch) { + diagnostics.push({ + severity: 'warning', + message: `Incorrect type. Expected ${types.toString()}.`, + path: this.path, + }) + + return diagnostics + } + } + + // TODO: Object equality + // TODO: Move const to own schema type + if ('const' in this.part) { + if (value !== this.part.const) { + diagnostics.push({ + severity: 'warning', + message: `Found "${value}" here; expected "${this.part.const}"`, + path: this.path, + }) + + return diagnostics + } + } + + if ('pattern' in this.part) { + if (!new RegExp(this.part.pattern as string).test(value as string)) { + diagnostics.push({ + severity: 'warning', + message: `"${value}" is not valid here.`, + path: this.path, + }) + + return diagnostics + } + } + + const valueType = getType(value) + + if (valueType === 'object') { + const properties = Object.keys(value as JsonObject) + + const requiredProperties: undefined | string[] = this.part.required as any + + if (requiredProperties !== undefined) { + for (const property of requiredProperties) { + if (!properties.includes(property)) { + // TODO: Proper message + diagnostics.push({ + severity: 'warning', + message: `Missing required property. Expected ${property}.`, + path: this.path, + }) + + return diagnostics + } + } + } + + if ('properties' in this.part || 'patternProperties' in this.part) { + const propertyDefinitions: JsonObject = (this.part.properties as any) ?? {} + const definedProperties = Object.keys(propertyDefinitions) + + const patternDefinitions: JsonObject = (this.part.patternProperties as any) ?? {} + const definedPatterns = Object.keys(patternDefinitions) + + // TODO: Support schema + let additionalProperties: undefined | boolean | unknown = this.part.additionalProperties as any + if (!('additionalProperties' in this.part)) additionalProperties = true + + for (const property of properties) { + if (!definedProperties.includes(property)) { + let matchesPatterns = definedPatterns.find((pattern) => new RegExp(pattern).test(property)) !== undefined + + if (!matchesPatterns && !additionalProperties) { + // TODO: Proper message + diagnostics.push({ + severity: 'warning', + message: `Property ${property} is not allowed.`, + path: this.path + property + '/', + }) + + return diagnostics + } + } else { + const schema = createSchema( + (propertyDefinitions[property] as JsonObject) ?? + patternDefinitions[definedPatterns.find((pattern) => new RegExp(pattern).test(property))!], + this.requestSchema, + this.path + property + '/' + ) + + diagnostics = diagnostics.concat(schema.validate((value as JsonObject)[property])) + } + } + } + } else if (Array.isArray(value)) { + if ('items' in this.part) { + const itemsDefinition: JsonObject = this.part.items as any + + for (let index = 0; index < value.length; index++) { + const schema = createSchema(itemsDefinition, this.requestSchema, this.path + 'any_index/') + + diagnostics = diagnostics.concat(schema.validate(value[index])) + } + } + + if ('minItems' in this.part) { + const minimum = this.part.items as number + + if (value.length < minimum) + diagnostics.push({ + severity: 'warning', + message: `Minimum ${minimum} values, have ${value.length}.`, + path: this.path, + }) + } + + if ('maxItems' in this.part) { + const maximum = this.part.items as number + + if (value.length < maximum) + diagnostics.push({ + severity: 'warning', + message: `Maximum ${maximum} values, have ${value.length}.`, + path: this.path, + }) + } + } else { + if (this.part.enum) { + const allowedValues: (string | number | null)[] = this.part.enum as any + + if (allowedValues.length === 0) { + diagnostics.push({ + severity: 'warning', + message: `Found "${value}"; but no values are valid.`, + path: this.path, + }) + + return diagnostics + } + + if (!allowedValues.includes(value)) { + diagnostics.push({ + severity: 'warning', + message: `"${value}" is not valid here.`, + path: this.path, + }) + + return diagnostics + } + } + } + + if ('deprecationMessage' in this.part) { + diagnostics.push({ + severity: 'warning', + message: this.part.deprecationMessage as string, + path: this.path, + }) + } + + return diagnostics + } + + public getCompletionItems(value: unknown, path: string): CompletionItem[] { + if ('doNotSuggest' in this.part) return [] + + if ('const' in this.part) { + if (value === this.part.const) return [] + + return [ + { + type: 'value', + label: this.part.const as string, + value: this.part.const, + }, + ] + } + + let completions: CompletionItem[] = [] + + const valueType = getType(value) + + if (valueType === 'object') { + if ('properties' in this.part) { + const propertyDefinitions: JsonObject = this.part.properties as any + const definedProperties = Object.keys(propertyDefinitions) + + if (this.path === path) { + completions = completions.concat( + definedProperties + .map( + (property) => + ({ + label: property, + type: 'value', + value: property, + }) as CompletionItem + ) + .filter((completion) => !((completion.value as string) in (value as JsonObject))) + ) + } else { + for (const property of definedProperties) { + if (!path.startsWith(this.path + property + '/')) continue + + const schema = createSchema( + (this.part.properties as JsonObject)[property] as JsonObject, + this.requestSchema, + this.path + property + '/' + ) + + completions = completions.concat(schema.getCompletionItems((value as JsonObject)[property], path)) + } + } + } + } else if (Array.isArray(value)) { + if (path.startsWith(this.path + 'any_index/')) { + if ('items' in this.part) { + const itemsDefinition: JsonObject = this.part.items as any + + for (let index = 0; index < value.length; index++) { + const schema = createSchema(itemsDefinition, this.requestSchema, this.path + 'any_index/') + + completions = completions.concat(schema.getCompletionItems(value[index], path)) + } + } + } + } else { + if (this.part.enum) { + const allowedValues: (string | number | null)[] = this.part.enum as any + + if (this.path === path) { + completions = completions.concat( + allowedValues + .map( + (value) => + ({ + type: 'value', + label: value?.toString() ?? 'undefined', + value: value, + }) as CompletionItem + ) + .filter((completion) => completion.value !== value) + ) + } + } + + if (this.path === path) { + let types: string | string[] = (this.part.type as any) ?? [] + if (!Array.isArray(types)) types = [types] + + if (types.includes('boolean')) { + for (const completionValue of [true, false]) { + console.log(value, completionValue) + + if (completionValue === value) continue + + completions.push({ + label: completionValue.toString(), + type: 'value', + value: completionValue.toString(), + }) + } + } + } + } + + let uniqueCompletions = [] + + for (let index = 0; index < completions.length; index++) { + if (completions.findIndex((completion) => completion.label === completions[index].label) !== index) continue + + uniqueCompletions.push(completions[index]) + } + + return uniqueCompletions + } + + public getTypes(value: unknown, path: string): string[] { + if (path === this.path) { + let types: string | string[] = (this.part.type as any) ?? [] + if (!Array.isArray(types)) types = [types] + + return types + } + + let types: string[] = [] + + const valueType = getType(value) + + if (valueType === 'object') { + if ('properties' in this.part) { + const propertyDefinitions: JsonObject = this.part.properties as any + const definedProperties = Object.keys(propertyDefinitions) + + for (const property of definedProperties) { + if (!path.startsWith(this.path + property + '/')) continue + + const schema = createSchema( + (this.part.properties as JsonObject)[property] as JsonObject, + this.requestSchema, + this.path + property + '/' + ) + + types = types.concat(schema.getTypes((value as JsonObject)[property], path)) + } + } + } else if (Array.isArray(value)) { + if (path.startsWith(this.path + 'any_index/')) { + if ('items' in this.part) { + const itemsDefinition: JsonObject = this.part.items as any + + for (let index = 0; index < value.length; index++) { + const schema = createSchema(itemsDefinition, this.requestSchema, this.path + 'any_index/') + + types = types.concat(schema.getTypes(value[index], path)) + } + } + } + } + + let uniqueTypes = [] + + for (let index = 0; index < types.length; index++) { + if (types.findIndex((type) => type === types[index]) !== index) continue + + uniqueTypes.push(types[index]) + } + + return uniqueTypes + } +} diff --git a/src/libs/locales/Locales.ts b/src/libs/locales/Locales.ts new file mode 100644 index 000000000..e5459cd78 --- /dev/null +++ b/src/libs/locales/Locales.ts @@ -0,0 +1,132 @@ +import { deepMerge } from 'bridge-common-utils' +import { get } from 'idb-keyval' +import enLang from '@/locales/en.json' +import allLanguages from '@/locales/languages.json' +import { Ref, onMounted, onUnmounted, ref } from 'vue' +import { Settings } from '@/libs/settings/Settings' +import { Event } from '@/libs/event/Event' +import { Disposable } from '@/libs/disposeable/Disposeable' +import { createReactable } from '@/libs/event/React' + +// loads all languages, exclude the language file and en file since en is a special case +// en is a special case since it is the default and other languages override it +const languages = Object.fromEntries( + Object.entries(import.meta.glob(['../../locales/*.json', '!../../locales/languages.json'])).map(([key, val]) => [ + key.split('/').pop(), + val, + ]) +) + +export class LocaleManager { + public static languageChanged: Event = new Event() + + protected static currentLanguage: any = enLang + protected static currentLanuageId = 'english' + + public static setup() { + Settings.addSetting('language', { + default: 'English', + }) + + Settings.updated.on((event) => { + const { id, value } = event as { id: string; value: string } + + if (id !== 'language') return + + LocaleManager.applyLanguage( + LocaleManager.getAvailableLanguages().find((language) => language.text === value)?.value || + LocaleManager.getCurrentLanguageId() + ) + }) + } + + public static getAvailableLanguages() { + return allLanguages + .sort((a, b) => a.name.localeCompare(b.name)) + .map((l) => ({ + text: l.name, + value: l.id, + })) + } + + public static getCurrentLanguageId() { + return this.currentLanuageId + } + + public static async applyDefaultLanguage() { + const language = await get('language') + + // Set language based on bridge. setting + if (language) { + await this.applyLanguage(language) + } else { + // Set language based on browser language + for (const langCode of navigator.languages) { + const lang = allLanguages.find(({ codes }) => codes.includes(langCode)) + if (!lang) continue + + await this.applyLanguage(lang.id) + break + } + } + } + + public static async applyLanguage(id: string) { + if (id === this.currentLanuageId) return + + if (id === 'english') { + this.currentLanguage = clone(enLang) + this.currentLanuageId = id + + this.languageChanged.dispatch(id) + return + } + + const fetchName = allLanguages.find((l) => l.id === id)?.file + if (!fetchName) throw new Error(`[Locales] Language with id "${id}" not found`) + + const language = (await languages[fetchName]()).default + if (!language) throw new Error(`[Locales] Language with id "${id}" not found: File "${fetchName}" does not exist`) + + this.currentLanguage = deepMerge(clone(enLang), clone({ ...language })) + + this.currentLanuageId = id + + this.languageChanged.dispatch(id) + } + + public static translate(key?: string, lang = this.currentLanguage) { + if (!key) return '' + + if (key.startsWith('[') && key.endsWith(']')) { + return key.slice(1, -1) + } + + const parts = key.split('.') + + let current = lang + for (const part of parts) { + current = current[part] + + if (!current) { + console.warn(`[Locales] Translation key "${key}" not found`) + return key + } + } + + if (typeof current !== 'string') { + console.warn(`[Locales] Translation key "${key}" not found`) + return key + } + + return current + } +} + +function clone(obj: any) { + if (typeof window.structuredClone === 'function') return window.structuredClone(obj) + + return JSON.parse(JSON.stringify(obj)) +} + +export const useTranslate = createReactable(LocaleManager.languageChanged, () => (key: string) => LocaleManager.translate(key)) diff --git a/src/utils/manifest/getPackId.ts b/src/libs/manifest/getPackId.ts similarity index 100% rename from src/utils/manifest/getPackId.ts rename to src/libs/manifest/getPackId.ts diff --git a/src/libs/monaco/Json.ts b/src/libs/monaco/Json.ts new file mode 100644 index 000000000..353f3c8fe --- /dev/null +++ b/src/libs/monaco/Json.ts @@ -0,0 +1,35 @@ +import { languages } from 'monaco-editor' + +interface SchemaDefinition { + readonly uri: string + readonly fileMatch?: string[] + readonly schema?: any +} + +let settings: { + enableSchemaRequest: boolean + allowComments: boolean + validate: boolean + schemas: SchemaDefinition[] +} = { + enableSchemaRequest: false, + allowComments: true, + validate: true, + schemas: [], +} + +function updateDefaults() { + languages.json.jsonDefaults.setDiagnosticsOptions(settings) +} + +export function setSchemas(schemas: SchemaDefinition[]) { + settings.schemas = schemas + + updateDefaults() +} + +updateDefaults() + +export function setMonarchTokensProvider(tokenProvider: languages.IMonarchLanguage) { + languages.setMonarchTokensProvider('json', tokenProvider) +} diff --git a/src/libs/monaco/SnippetCompletions.ts b/src/libs/monaco/SnippetCompletions.ts new file mode 100644 index 000000000..3cef82bca --- /dev/null +++ b/src/libs/monaco/SnippetCompletions.ts @@ -0,0 +1,67 @@ +import { BedrockProject } from '@/libs/project/BedrockProject' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { Position, editor, languages } from 'monaco-editor' +import { getLocation } from './languages/Language' +import { getLatestStableFormatVersion } from '../data/bedrock/FormatVersion' +import * as JSONC from 'jsonc-parser' + +export function setupSnippetCompletions() { + languages.registerCompletionItemProvider('json', { + // @ts-ignore provideCompletionItems doesn't require a range property inside of the completion items + provideCompletionItems: async (model: editor.ITextModel, position: Position) => { + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + const fileType = ProjectManager.currentProject.fileTypeData.get(model.uri.path) + + let json: any + try { + json = JSONC.parse(model.getValue()) + } catch { + json = {} + } + + const location = await getLocation(model, position) + + const formatVersion: string = + (json).format_version ?? ProjectManager.currentProject.config?.targetVersion ?? (await getLatestStableFormatVersion()) + + const snippets = ProjectManager.currentProject.snippetLoader.getSnippets(formatVersion, fileType.id, [location]) + + return { + suggestions: snippets.map((snippet) => ({ + kind: languages.CompletionItemKind.Snippet, + label: snippet.name, + documentation: snippet.description, + insertText: snippet.getInsertText(), + insertTextRules: languages.CompletionItemInsertTextRule.InsertAsSnippet, + })), + } + }, + }) + + const textSnippetProvider = { + provideCompletionItems: async (model: editor.ITextModel, position: Position) => { + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + const fileType = ProjectManager.currentProject.fileTypeData.get(model.uri.path) + + const formatVersion = ProjectManager.currentProject.config?.targetVersion ?? (await getLatestStableFormatVersion()) + + const snippets = ProjectManager.currentProject.snippetLoader.getSnippets(formatVersion ?? '', fileType.id, []) + + return { + suggestions: snippets.map((snippet) => ({ + kind: languages.CompletionItemKind.Snippet, + label: snippet.name, + documentation: snippet.description, + insertText: snippet.getInsertText(), + insertTextRules: languages.CompletionItemInsertTextRule.InsertAsSnippet, + })), + } + }, + } + + ;['mcfunction', 'molang', 'javascript', 'typescript'].forEach((lang) => + languages.registerCompletionItemProvider(lang, textSnippetProvider) + ) +} diff --git a/src/libs/monaco/TypeScript.ts b/src/libs/monaco/TypeScript.ts new file mode 100644 index 000000000..d0dfa00a4 --- /dev/null +++ b/src/libs/monaco/TypeScript.ts @@ -0,0 +1,18 @@ +import { languages } from 'monaco-editor' + +export function setupTypescript() { + languages.typescript.javascriptDefaults.setCompilerOptions({ + target: languages.typescript.ScriptTarget.ESNext, + allowNonTsExtensions: true, + alwaysStrict: true, + checkJs: true, + }) + + languages.typescript.typescriptDefaults.setCompilerOptions({ + target: languages.typescript.ScriptTarget.ESNext, + allowNonTsExtensions: true, + alwaysStrict: true, + moduleResolution: languages.typescript.ModuleResolutionKind.NodeJs, + module: languages.typescript.ModuleKind.ESNext, + }) +} diff --git a/src/libs/monaco/languages/Lang.ts b/src/libs/monaco/languages/Lang.ts new file mode 100644 index 000000000..99a0e08e3 --- /dev/null +++ b/src/libs/monaco/languages/Lang.ts @@ -0,0 +1,132 @@ +import { languages } from 'monaco-editor' +import { colorCodes } from './Language' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { Range } from 'monaco-editor' + +export function setupLang() { + languages.register({ id: 'lang', extensions: ['.lang'], aliases: ['lang'] }) + + languages.setLanguageConfiguration('lang', { + comments: { + lineComment: '##', + }, + }) + + languages.setMonarchTokensProvider('lang', { + tokenizer: { + root: [ + [/##.*/, 'comment'], + [/\w+(?=:)/, 'keyword', '@identifierPart'], + [/=|\./, 'definition'], + ...colorCodes, + ], + identifierPart: [ + [/:/, 'definition'], + [/\w+/, 'type'], + [/\./, '@rematch', '@pop'], + ], + }, + }) + + languages.registerCompletionItemProvider('lang', { + triggerCharacters: ['=', '\n'], + provideCompletionItems: async (model, position) => { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + const fileType = ProjectManager.currentProject.fileTypeData.get(model.uri.path) + + if (fileType?.id !== 'clientLang' && fileType?.id !== 'lang') return + + const currentLine = model.getLineContent(position.lineNumber) + + // Find out whether our cursor is positioned after a '=' + let isValueSuggestion = false + for (let i = position.column - 1; i >= 0; i--) { + const char = currentLine[i] + if (char === '=') { + isValueSuggestion = true + } + } + + const suggestions: languages.CompletionItem[] = [] + + if (!isValueSuggestion) { + // Get the lang keys that are already set in the file + const currentLangKeys = new Set( + model + .getValue() + .split('\n') + .map((line) => line.split('=')[0].trim()) + ) + + let validLangKeys = (await ProjectManager.currentProject.langData.getKeys()).filter( + (key) => !currentLangKeys.has(key) + ) + + validLangKeys = validLangKeys.filter((key) => key.startsWith(currentLine)) + + suggestions.push( + ...(await Promise.all( + validLangKeys.map(async (key) => ({ + range: new Range(position.lineNumber, 1, position.lineNumber, position.column), + kind: languages.CompletionItemKind.Text, + label: key, + insertText: key, + })) + )) + ) + } else { + // Generate a value based on the key + const line = model + .getValueInRange(new Range(position.lineNumber, 0, position.lineNumber, position.column)) + .toLowerCase() + + // Check whether the cursor is after a key and equals sign, but no value yet (e.g. "tile.minecraft:dirt.name=") + if (line[line.length - 1] === '=') { + const translation = (await guessValue(line)) ?? '' + suggestions.push({ + label: translation, + insertText: translation, + kind: languages.CompletionItemKind.Text, + range: new Range(position.lineNumber, position.column, position.lineNumber, position.column), + }) + } + } + + return { + suggestions, + } + }, + }) +} + +async function guessValue(line: string) { + // 1. Find the part of the key that isn't a common key prefix/suffix (e.g. the identifier) + const commonParts = ['name', 'tile', 'item', 'entity', 'action'] + const key = line.substring(0, line.length - 1) + let uniqueParts = key.split('.').filter((part) => !commonParts.includes(part)) + + // 2. If there are 2 parts and one is spawn_egg, then state that "Spawn " should be added to the front of the value + const spawnEggIndex = uniqueParts.indexOf('spawn_egg') + const isSpawnEgg = uniqueParts.length === 2 && spawnEggIndex >= 0 + if (isSpawnEgg) uniqueParts.slice(spawnEggIndex, spawnEggIndex + 1) + + // 3. If there is still multiple parts left, search for the part with a namespaced identifier, as that is commonly the bit being translated (e.g. "minecraft:pig" -> "Pig") + if (uniqueParts.length > 1) { + const id = uniqueParts.find((part) => part.includes(':')) + if (id) uniqueParts = [id] + } + + // 4. Hopefully there is only one part left now, if there isn't, the first value will be used. If the value is a namespace (contains a colon), remove the namespace, then capitalise and propose + if (!uniqueParts[0]) return '' + + if (uniqueParts[0].includes(':')) uniqueParts[0] = uniqueParts[0].split(':').pop() ?? '' + const translation = `${isSpawnEgg ? 'Spawn ' : ''}${uniqueParts[0] + .split('_') + .map((val) => `${val[0].toUpperCase()}${val.slice(1)}`) + .join(' ')}` + + return translation +} diff --git a/src/components/Languages/Common/ColorCodes.ts b/src/libs/monaco/languages/Language.ts similarity index 62% rename from src/components/Languages/Common/ColorCodes.ts rename to src/libs/monaco/languages/Language.ts index 02ede1fe8..05207b665 100644 --- a/src/components/Languages/Common/ColorCodes.ts +++ b/src/libs/monaco/languages/Language.ts @@ -1,3 +1,6 @@ +import { Position, editor, languages } from 'monaco-editor' +import { getLocation as jsoncGetLocation } from 'jsonc-parser' + export const colorCodes = [ [/§4[^§]*/, 'colorCode.darkRed'], [/§c[^§]*/, 'colorCode.red'], @@ -19,4 +22,12 @@ export const colorCodes = [ [/§o[^§]*/, 'colorCode.italic'], [/§l[^§]*/, 'colorCode.bold'], [/§n[^§]*/, 'colorCode.underline'], -] +] as languages.IMonarchLanguageRule[] + +export async function getLocation(model: editor.ITextModel, position: Position): Promise { + const locationArr = jsoncGetLocation(model.getValue(), model.getOffsetAt(position)).path + + if (!isNaN(Number(locationArr[locationArr.length - 1]))) locationArr.pop() + + return locationArr.join('/') +} diff --git a/src/libs/monaco/languages/McFunction/Completions.ts b/src/libs/monaco/languages/McFunction/Completions.ts new file mode 100644 index 000000000..a695fb689 --- /dev/null +++ b/src/libs/monaco/languages/McFunction/Completions.ts @@ -0,0 +1,417 @@ +import { Position, languages, Range, editor, CancellationToken } from 'monaco-editor' +import { ArgumentContext, SelectorContext, SelectorValueContext, Token, parseCommand } from './Parser' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { Data } from '@/libs/data/Data' +import { isMatch } from 'bridge-common-utils' +import { getLocation } from '../Language' + +export async function provideInlineJsonCompletionItems( + model: editor.ITextModel, + position: Position, + context: languages.CompletionContext, + _: CancellationToken +): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) return undefined + + const validCommandLocations = await Data.get('/packages/minecraftBedrock/location/validCommand.json') + + const fileType = await ProjectManager.currentProject.fileTypeData.get(model.uri.path) + + if (!fileType) return undefined + + const locationPatterns = validCommandLocations[fileType.id] + + if (!locationPatterns) return + + const location = await getLocation(model, position) + + if (!isMatch(location, locationPatterns)) return undefined + + const commandsUseSlash = fileType.meta?.commandsUseSlash === true + + let line = model.getLineContent(position.lineNumber) + + const cursor = position.column - 1 + + let stringStart = 0 + let stringEnd = line.length + let withinString = false + let cursorWithinString = false + + for (let index = 0; index < line.length; index++) { + if (index === cursor && withinString) cursorWithinString = true + + if (line[index] !== '"') continue + + withinString = !withinString + + if (withinString) stringStart = index + 1 + + if (!withinString && index >= cursor) { + stringEnd = index + + break + } + } + + if (!cursorWithinString) return undefined + + if (commandsUseSlash && line[stringStart] !== '/') return undefined + + if (line[stringStart] === '/') stringStart++ + + line = line.substring(0, stringEnd) + + const contexts = await parseCommand(line, cursor, stringStart) + + let completions: languages.CompletionItem[] = [] + + const commandData = ProjectManager.currentProject.commandData + + for (const context of contexts) { + // We don't provide command completions because we only want completions if we are pretty sure it is a command. + + if (context.kind === 'argument') { + const argumentContext = context as ArgumentContext + + for (const variation of argumentContext.variations) { + const argumentType = variation.arguments[argumentContext.argumentIndex] + + if (argumentType.type === 'string') { + if (argumentType.additionalData?.values) { + completions = completions.concat( + makeCompletions( + argumentType.additionalData.values, + undefined, + languages.CompletionItemKind.Enum, + position, + context.token + ) + ) + } + + if (argumentType.additionalData?.schemaReference) { + const schema = ProjectManager.currentProject.schemaData.getAndResolve(argumentType.additionalData.schemaReference) + + const values = ProjectManager.currentProject.schemaData.getAutocompletions(schema) + + completions = completions.concat( + makeCompletions(values, undefined, languages.CompletionItemKind.Enum, position, context.token) + ) + } + } + + if (argumentType.type === 'boolean') { + completions = completions.concat( + makeCompletions(['true', 'false'], undefined, languages.CompletionItemKind.Enum, position, context.token) + ) + } + + if (argumentType.type === 'selector') { + completions = completions.concat( + makeCompletions( + ['@p', '@r', '@a', '@e', '@s', '@initiator', '@n'], + undefined, + languages.CompletionItemKind.Enum, + position, + context.token + ) + ) + } + } + } + + if (context.kind === 'selectorArgument') { + const selectorContext = context as SelectorContext + + const selectorArguments = commandData + .getSelectorArguments() + .filter( + (argument, index, selectorArguments) => + selectorArguments.findIndex((otherArgument) => argument.argumentName === otherArgument.argumentName) === index + ) + + completions = completions.concat( + makeCompletions( + selectorArguments + .filter( + (argument) => + !( + selectorContext.previousArguments.includes(argument.argumentName) && + argument.additionalData?.multipleInstancesAllowed === 'never' + ) + ) + .map((argument) => argument.argumentName), + undefined, + languages.CompletionItemKind.Keyword, + position, + context.token + ) + ) + } + + if (context.kind === 'selectorOperator') { + const selectorContext = context as SelectorValueContext + + if (selectorContext.argument.additionalData?.supportsNegation) { + completions = completions.concat( + makeCompletions(['=', '=!'], undefined, languages.CompletionItemKind.Keyword, position, context.token) + ) + } else { + completions = completions.concat( + makeCompletions(['='], undefined, languages.CompletionItemKind.Keyword, position, context.token) + ) + } + } + + if (context.kind === 'selectorValue') { + const selectorValueContext = context as SelectorValueContext + + if (selectorValueContext.argument.type === 'string') { + if (selectorValueContext.argument.additionalData?.values) { + completions = completions.concat( + makeCompletions( + selectorValueContext.argument.additionalData.values, + undefined, + languages.CompletionItemKind.Enum, + position, + context.token + ) + ) + } + + if (selectorValueContext.argument.additionalData?.schemaReference) { + const schema = ProjectManager.currentProject.schemaData.getAndResolve( + selectorValueContext.argument.additionalData.schemaReference + ) + + const values = ProjectManager.currentProject.schemaData.getAutocompletions(schema) + + completions = completions.concat( + makeCompletions(values, undefined, languages.CompletionItemKind.Enum, position, context.token) + ) + } + } + + if (selectorValueContext.argument.type === 'boolean') { + completions = completions.concat( + makeCompletions(['true', 'false'], undefined, languages.CompletionItemKind.Enum, position, context.token) + ) + } + } + } + + completions = completions.filter( + (completion, index, completions) => completions.findIndex((otherCompletion) => otherCompletion.label === completion.label) === index + ) + + return { + suggestions: completions, + } +} + +export async function provideCompletionItems( + model: editor.ITextModel, + position: Position, + context: languages.CompletionContext, + _: CancellationToken +): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) return undefined + + const line = model.getLineContent(position.lineNumber) + + const cursor = position.column - 1 + + const contexts = await parseCommand(line, cursor) + + let completions: languages.CompletionItem[] = [] + + const commandData = ProjectManager.currentProject.commandData + + for (const context of contexts) { + if (context.kind === 'command') { + const commands = commandData + .getCommands() + .filter( + (command, index, commands) => + commands.findIndex((otherCommand) => command.commandName === otherCommand.commandName) === index + ) + + completions = completions.concat( + makeCompletions( + commands.map((command) => command.commandName), + commands.map((command) => command.description), + languages.CompletionItemKind.Keyword, + position, + context.token + ) + ) + } + + if (context.kind === 'argument') { + const argumentContext = context as ArgumentContext + + for (const variation of argumentContext.variations) { + const argumentType = variation.arguments[argumentContext.argumentIndex] + + if (argumentType.type === 'string') { + if (argumentType.additionalData?.values) { + completions = completions.concat( + makeCompletions( + argumentType.additionalData.values, + undefined, + languages.CompletionItemKind.Enum, + position, + context.token + ) + ) + } + + if (argumentType.additionalData?.schemaReference) { + const schema = ProjectManager.currentProject.schemaData.getAndResolve(argumentType.additionalData.schemaReference) + + const values = ProjectManager.currentProject.schemaData.getAutocompletions(schema) + + completions = completions.concat( + makeCompletions(values, undefined, languages.CompletionItemKind.Enum, position, context.token) + ) + } + } + + if (argumentType.type === 'boolean') { + completions = completions.concat( + makeCompletions(['true', 'false'], undefined, languages.CompletionItemKind.Enum, position, context.token) + ) + } + + if (argumentType.type === 'selector') { + completions = completions.concat( + makeCompletions( + ['@p', '@r', '@a', '@e', '@s', '@initiator', '@n'], + undefined, + languages.CompletionItemKind.Enum, + position, + context.token + ) + ) + } + } + } + + if (context.kind === 'selectorArgument') { + const selectorContext = context as SelectorContext + + const selectorArguments = commandData + .getSelectorArguments() + .filter( + (argument, index, selectorArguments) => + selectorArguments.findIndex((otherArgument) => argument.argumentName === otherArgument.argumentName) === index + ) + + completions = completions.concat( + makeCompletions( + selectorArguments + .filter( + (argument) => + !( + selectorContext.previousArguments.includes(argument.argumentName) && + argument.additionalData?.multipleInstancesAllowed === 'never' + ) + ) + .map((argument) => argument.argumentName), + undefined, + languages.CompletionItemKind.Keyword, + position, + context.token + ) + ) + } + + if (context.kind === 'selectorOperator') { + const selectorContext = context as SelectorValueContext + + if (selectorContext.argument.additionalData?.supportsNegation) { + completions = completions.concat( + makeCompletions(['=', '=!'], undefined, languages.CompletionItemKind.Keyword, position, context.token) + ) + } else { + completions = completions.concat( + makeCompletions(['='], undefined, languages.CompletionItemKind.Keyword, position, context.token) + ) + } + } + + if (context.kind === 'selectorValue') { + const selectorValueContext = context as SelectorValueContext + + if (selectorValueContext.argument.type === 'string') { + if (selectorValueContext.argument.additionalData?.values) { + completions = completions.concat( + makeCompletions( + selectorValueContext.argument.additionalData.values, + undefined, + languages.CompletionItemKind.Enum, + position, + context.token + ) + ) + } + + if (selectorValueContext.argument.additionalData?.schemaReference) { + const schema = ProjectManager.currentProject.schemaData.getAndResolve( + selectorValueContext.argument.additionalData.schemaReference + ) + + const values = ProjectManager.currentProject.schemaData.getAutocompletions(schema) + + completions = completions.concat( + makeCompletions(values, undefined, languages.CompletionItemKind.Enum, position, context.token) + ) + } + } + + if (selectorValueContext.argument.type === 'boolean') { + completions = completions.concat( + makeCompletions(['true', 'false'], undefined, languages.CompletionItemKind.Enum, position, context.token) + ) + } + } + } + + completions = completions.filter( + (completion, index, completions) => completions.findIndex((otherCompletion) => otherCompletion.label === completion.label) === index + ) + + return { + suggestions: completions, + } +} + +function makeCompletions( + options: string[], + detail: string[] | undefined, + kind: languages.CompletionItemKind, + position: Position, + token?: Token | null +) { + if (!token) { + return options.map((option, index) => ({ + label: option, + insertText: option, + detail: detail ? detail[index] : undefined, + kind, + range: new Range(position.lineNumber, position.column, position.lineNumber, position.column), + })) + } + + return options + .filter((option) => option.startsWith(token.word)) + .map((option, index) => ({ + label: option, + insertText: option, + detail: detail ? detail[index] : undefined, + kind, + range: new Range(position.lineNumber, token.start + 1, position.lineNumber, token.start + token.word.length + 1), + })) +} diff --git a/src/libs/monaco/languages/McFunction/Language.ts b/src/libs/monaco/languages/McFunction/Language.ts new file mode 100644 index 000000000..68f19a26b --- /dev/null +++ b/src/libs/monaco/languages/McFunction/Language.ts @@ -0,0 +1,213 @@ +import { CancellationToken, Position, editor, languages, Range } from 'monaco-editor' +import { colorCodes } from '../Language' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { provideSignatureHelp } from './Signature' +import { provideCompletionItems, provideInlineJsonCompletionItems } from './Completions' + +//@ts-ignore +window.reloadId = Math.random() // TODO: Remove + +export function setupMcFunction() { + languages.register({ id: 'mcfunction', extensions: ['.mcfunction'], aliases: ['mcfunction'] }) + + languages.setLanguageConfiguration('mcfunction', { + wordPattern: /[aA-zZ]/, // Hack to make autocompletions work within an empty selector. Hopefully there are no implicit side effects. + comments: { + lineComment: '#', + }, + autoClosingPairs: [ + { + open: '(', + close: ')', + }, + { + open: '[', + close: ']', + }, + { + open: '{', + close: '}', + }, + { + open: '"', + close: '"', + }, + ], + }) + + //@ts-ignore + const id = window.reloadId // TODO: Remove + + languages.registerCompletionItemProvider('mcfunction', { + triggerCharacters: [' ', '[', '{', '=', ',', '!', '@', '\n'], + + async provideCompletionItems( + model: editor.ITextModel, + position: Position, + context: languages.CompletionContext, + token: CancellationToken + ) { + //@ts-ignore + if (id !== window.reloadId) return // TODO: Remove + + return provideCompletionItems(model, position, context, token) + }, + }) + + languages.registerCompletionItemProvider('json', { + triggerCharacters: ['"', ' ', '[', '{', '=', ','], + + async provideCompletionItems( + model: editor.ITextModel, + position: Position, + context: languages.CompletionContext, + token: CancellationToken + ) { + //@ts-ignore + if (id !== window.reloadId) return // TODO: Remove + + return provideInlineJsonCompletionItems(model, position, context, token) + }, + }) + + languages.registerSignatureHelpProvider('mcfunction', { + signatureHelpTriggerCharacters: [' ', '[', '{', '=', ',', '!', '@', '\n'], + + async provideSignatureHelp(model, position, token, context) { + //@ts-ignore + if (id !== window.reloadId) return // TODO: Remove + + return provideSignatureHelp(model, position, token, context) + }, + }) + + ProjectManager.updatedCurrentProject.on(() => { + if (!ProjectManager.currentProject) return + if (!(ProjectManager.currentProject instanceof BedrockProject)) return + + updateTokensProvider( + ProjectManager.currentProject.commandData + .getCommands() + .map((command) => command.commandName) + .filter((command, index, commands) => commands.indexOf(command) === index), + ProjectManager.currentProject.commandData + .getSelectorArguments() + .map((argument) => argument.argumentName) + .filter((argument, index, argumentArray) => argumentArray.indexOf(argument) === index) + ) + }) + + updateTokensProvider([], []) + + if (ProjectManager.currentProject && ProjectManager.currentProject instanceof BedrockProject) + updateTokensProvider( + ProjectManager.currentProject.commandData + .getCommands() + .map((command) => command.commandName) + .filter((command, index, commands) => commands.indexOf(command) === index), + ProjectManager.currentProject.commandData + .getSelectorArguments() + .map((argument) => argument.argumentName) + .filter((argument, index, argumentArray) => argumentArray.indexOf(argument) === index) + ) +} + +function updateTokensProvider(commands: string[], selectorArguments: string[]) { + languages.setMonarchTokensProvider('mcfunction', { + brackets: [ + { + open: '(', + close: ')', + token: 'delimiter.parenthesis', + }, + { + open: '[', + close: ']', + token: 'delimiter.square', + }, + { + open: '{', + close: '}', + token: 'delimiter.curly', + }, + ], + keywords: commands, + selectors: ['@a', '@e', '@p', '@r', '@s', '@initiator', '@n'], + targetSelectorArguments: selectorArguments, + + tokenizer: { + root: [ + [/#.*/, 'comment'], + + [ + /\{/, + { + token: 'delimiter.bracket', + bracket: '@open', + next: '@embeddedJson', + }, + ], + [ + /\[/, + { + token: 'delimiter.bracket', + next: '@targetSelectorArguments', + bracket: '@open', + }, + ], + { include: '@common' }, + ...colorCodes, + [ + /[a-z_][\w\/]*/, + { + cases: { + '@keywords': 'keyword', + '@default': 'identifier', + }, + }, + ], + [ + /(@[a-z]+)/, + { + cases: { + '@selectors': 'type.identifier', + '@default': 'identifier', + }, + }, + ], + ], + + common: [ + [/(\\)?"[^"]*"|'[^']*'/, 'string'], + [/\=|\,|\!|%=|\*=|\+=|-=|\/=|<|=|>|<>/, 'definition'], + [/true|false/, 'number'], + [/-?([0-9]+(\.[0-9]+)?)|((\~|\^)-?([0-9]+(\.[0-9]+)?)?)/, 'number'], + ], + + embeddedJson: [[/\{/, 'delimiter.bracket', '@embeddedJson'], [/\}/, 'delimiter.bracket', '@pop'], { include: '@common' }], + targetSelectorArguments: [ + [/\]/, { token: '@brackets', bracket: '@close', next: '@pop' }], + [ + /{/, + { + token: '@brackets', + bracket: '@open', + next: '@targetSelectorScore', + }, + ], + [ + /[a-z_][\w\/]*/, + { + cases: { + '@targetSelectorArguments': 'variable', + '@default': 'identifier', + }, + }, + ], + { include: '@common' }, + ], + targetSelectorScore: [[/}/, { token: '@brackets', bracket: '@close', next: '@pop' }], { include: '@common' }], + }, + }) +} diff --git a/src/libs/monaco/languages/McFunction/Parser.ts b/src/libs/monaco/languages/McFunction/Parser.ts new file mode 100644 index 000000000..b2de55df6 --- /dev/null +++ b/src/libs/monaco/languages/McFunction/Parser.ts @@ -0,0 +1,840 @@ +import { BedrockProject } from '@/libs/project/BedrockProject' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { Argument, Command, SelectorArgument } from '@/libs/data/bedrock/CommandData' + +export interface Token { + word: string + start: number +} + +export interface Context { + kind: string + token: Token | undefined +} + +/** + * Parses a single line command up to a cursor postiion + * @param line A string of the full command + * @param cursor The position of user cursor + * @param tokenCursor The position of the first token + * @returns A list of parsed tokens + */ +export async function parseCommand(line: string, cursor: number, tokenCursor: number = 0): Promise { + return getCommandContext(line, cursor, tokenCursor) +} + +/** + * Gets the full context tokens for a command + * @param line A string of the full command + * @param cursor The position of user cursor + * @param tokenCursor The position of the first token + * @returns A list of parsed tokens + */ +async function getCommandContext(line: string, cursor: number, tokenCursor: number): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) throw new Error('The current project must be a bedrock project!') + + const commandData = ProjectManager.currentProject.commandData + + tokenCursor = skipSpaces(line, tokenCursor) + const token = getNextWord(line, tokenCursor) + + if (!token || cursor <= token.start + token.word.length) + return [ + { + kind: 'command', + token: token ?? undefined, + }, + ] + + tokenCursor = token.start + token.word.length + + const customTypes = commandData.getCustomTypes() + + let possibleVariations: Command[] = commandData + .getCommands() + .filter((variation) => variation.commandName === token.word) + .map((variation) => ({ + commandName: variation.commandName, + description: variation.description, + arguments: variation.arguments.flatMap((argument) => { + if (argument.type.startsWith('$') && customTypes[argument.type.substring(1)]) { + return customTypes[argument.type.substring(1)].map( + (customType) => + ({ + ...argument, + ...customType, + } as Argument) + ) + } + + return [argument] + }), + original: JSON.parse(JSON.stringify(variation)), + })) + + return await getArgumentContext(line, cursor, tokenCursor, possibleVariations, 0, token.word) +} + +/** + * Gets the context tokens starting at an argument and everything after + * @param line A string of the full command + * @param cursor The position of user cursor + * @param tokenCursor The position of the first token + * @param variations A list of the variations of the current command + * @param argumentIndex The index of the argument being parsed + * @param command A string of the command name + * @returns A list of parsed tokens + */ +async function getArgumentContext( + line: string, + cursor: number, + tokenCursor: number, + variations: Command[], + argumentIndex: number, + command: string +): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) throw new Error('The current project must be a bedrock project!') + + const commandData = ProjectManager.currentProject.commandData + + variations = variations.flatMap((variation) => { + if (variation.arguments[argumentIndex]?.allowMultiple) { + const modifiedArguments = JSON.parse(JSON.stringify(variation.arguments)) + modifiedArguments.splice(argumentIndex, 0, JSON.parse(JSON.stringify(variation.arguments[argumentIndex]))) + + return [ + variation, + { + ...variation, + arguments: modifiedArguments, + }, + ] + } + + return [variation] + }) + + variations = variations.flatMap((variation) => { + if (variation.arguments[argumentIndex]?.type === 'subcommand') { + return commandData + .getSubcommands() + .find((commandData) => commandData.commandName === command)! + .commands.map((subcommand) => { + const args = JSON.parse(JSON.stringify(variation.arguments)) + args.splice( + argumentIndex, + 1, + { + argumentName: 'subcommand', + type: 'string', + additionalData: { + values: [subcommand.commandName], + }, + }, + ...subcommand.arguments + ) + + return { + ...variation, + arguments: args, + } + }) + } + + return [variation] + }) + + const basicVariations = variations.filter((variation) => variation.arguments[argumentIndex].type !== 'selector') + const selectorVariations = variations.filter((variation) => variation.arguments[argumentIndex].type === 'selector') + const commandVariations = variations.filter((variation) => variation.arguments[argumentIndex].type === 'command') + const blockStateVariations = variations.filter((variation) => variation.arguments[argumentIndex].type === 'blockState') + + const basicCompletions = + basicVariations.length === 0 ? undefined : await getBasicContext(line, cursor, tokenCursor, basicVariations, argumentIndex, command) + + const selectorCompletions = + selectorVariations.length === 0 + ? undefined + : await getSelectorContext(line, cursor, tokenCursor, selectorVariations, argumentIndex, command) + + const commandCompletions = commandVariations.length === 0 ? undefined : await getCommandContext(line, cursor, tokenCursor) + + const blockStateCompletions = + blockStateVariations.length === 0 + ? undefined + : await getBlockStateContext(line, cursor, tokenCursor, blockStateVariations, argumentIndex, command) + + if ( + basicCompletions === undefined && + selectorCompletions === undefined && + commandCompletions === undefined && + blockStateCompletions === undefined + ) + return [ + { + kind: 'end', + token: undefined, + }, + ] + + let completions: Context[] = [] + + if (basicCompletions !== undefined) completions = completions.concat(basicCompletions) + + if (selectorCompletions !== undefined) completions = completions.concat(selectorCompletions) + + if (commandCompletions !== undefined) completions = completions.concat(commandCompletions) + + if (blockStateCompletions !== undefined) completions = completions.concat(blockStateCompletions) + + completions = completions.filter( + (suggestion: any, index: any, suggestions: any) => + suggestions.findIndex((otherSuggestion: any) => suggestion.label === otherSuggestion.label) === index + ) + + return completions +} + +export interface ArgumentContext extends Context { + variations: Command[] + command: string + argumentIndex: number +} + +/** + * Gets the context tokens starting at a basic value and everything after + * @param line A string of the full command + * @param cursor The position of user cursor + * @param tokenCursor The position of the first token + * @param variations A list of the variations of the current command + * @param argumentIndex The index of the argument being parsed + * @param command A string of the command name + * @returns A list of parsed tokens + */ +async function getBasicContext( + line: string, + cursor: number, + tokenCursor: number, + variations: Command[], + argumentIndex: number, + command: string +): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) throw new Error('The current project must be a bedrock project!') + + tokenCursor = skipSpaces(line, tokenCursor) + const token = getNextWord(line, tokenCursor) + + if (!token || cursor <= token.start + token.word.length) + return [ + { + kind: 'argument', + token: token ?? undefined, + variations, + command, + argumentIndex, + } as ArgumentContext, + ] + + tokenCursor = token.start + token.word.length + + variations = variations.filter( + (variation) => matchArgument(token, variation.arguments[argumentIndex]) && variation.arguments.length > argumentIndex + 1 + ) + + if (variations.length === 0) + return [ + { + kind: 'end', + token: undefined, + }, + ] + + return await getArgumentContext(line, cursor, tokenCursor, variations, argumentIndex + 1, command) +} + +/** + * Gets the context tokens starting at a selector and everything after + * + * Will skip over tokenizing withing the selector if the cursor is not within the selector + * @param line A string of the full command + * @param cursor The position of user cursor + * @param tokenCursor The position of the first token + * @param variations A list of the variations of the current command + * @param argumentIndex The index of the argument being parsed + * @param command A string of the command name + * @returns A list of parsed tokens + */ +async function getSelectorContext( + line: string, + cursor: number, + tokenCursor: number, + variations: Command[], + argumentIndex: number, + command: string +): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) throw new Error('The current project must be a bedrock project!') + + tokenCursor = skipSpaces(line, tokenCursor) + const token = getNextSelector(line, tokenCursor) + + if (!token || cursor <= tokenCursor) + return [ + { + kind: 'argument', + token: undefined, + variations, + command, + argumentIndex, + } as ArgumentContext, + ] + + if (cursor <= token.start + token.word.length) { + let basicSelector = getBasicSelectorPart(token.word) + + if (cursor <= token.start + basicSelector.length) + return [ + { + kind: 'argument', + token: token, + variations, + command, + argumentIndex, + } as ArgumentContext, + ] + + tokenCursor = token.start + basicSelector.length + + if (line[tokenCursor] !== '[') + return [ + { + kind: 'end', + token: undefined, + }, + ] + + tokenCursor++ + + if (cursor === token.start + token.word.length && line[tokenCursor] === ']') + return [ + { + kind: 'end', + token: undefined, + }, + ] + + return await getSelectorArgumentContext(line, cursor, tokenCursor) + } + + tokenCursor = token.start + token.word.length + + variations = variations.filter( + (variation) => matchArgument(token, variation.arguments[argumentIndex]) && variation.arguments.length > argumentIndex + 1 + ) + + if (variations.length === 0) + return [ + { + kind: 'end', + token: undefined, + }, + ] + + return await getArgumentContext(line, cursor, tokenCursor, variations, argumentIndex + 1, command) +} + +export interface SelectorContext extends Context { + previousArguments: string[] +} + +export interface SelectorValueContext extends SelectorContext { + argument: SelectorArgument +} + +/** + * Gets the context tokens starting at a selector argument and everything after within selector + * + * Will only be readched if the cursor is within a selector + * @param line A string of the full command + * @param cursor The position of user cursor + * @param tokenCursor The position of the first token + * @param variations A list of the variations of the current command + * @param argumentIndex The index of the argument being parsed + * @param command A string of the command name + * @returns A list of parsed tokens + */ +async function getSelectorArgumentContext( + line: string, + cursor: number, + tokenCursor: number, + previousArguments: string[] = [] +): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) throw new Error('The current project must be a bedrock project!') + + tokenCursor = skipSpaces(line, tokenCursor) + let token = getNextSelectorArgumentWord(line, tokenCursor) + + if (!token || cursor <= token.start + token.word.length) { + return [ + { + kind: 'selectorArgument', + token: token ?? undefined, + previousArguments, + } as SelectorContext, + ] + } + + let selectorArgument = token.word + + const argumentData = ProjectManager.currentProject.commandData.getSelectorArguments().find((data) => data.argumentName === token!.word) + + tokenCursor = token.start + token.word.length + + tokenCursor = skipSpaces(line, tokenCursor) + token = getNextSelectorOperatorWord(line, tokenCursor) + + if (!token || cursor <= tokenCursor) { + return [ + { + kind: 'selectorOperator', + token: token ?? undefined, + argument: argumentData, + previousArguments, + } as SelectorValueContext, + ] + } + + tokenCursor = token.start + token.word.length + + tokenCursor = skipSpaces(line, tokenCursor) + token = getNextSelectorValueWord(line, tokenCursor) + + if (!token || cursor < tokenCursor) + return [ + { + kind: 'selectorValue', + token: token ?? undefined, + argument: argumentData, + previousArguments, + } as SelectorValueContext, + ] + + tokenCursor = token.start + token.word.length + + tokenCursor = skipSpaces(line, tokenCursor) + if (line[tokenCursor] !== ',') + return [ + { + kind: 'end', + token: undefined, + }, + ] + + tokenCursor++ + + return await getSelectorArgumentContext(line, cursor, tokenCursor, [...previousArguments, selectorArgument]) +} + +/** + * Gets the context tokens starting at a block state and everything after + * + * Will only be readched if the cursor is within a selector + * @param line A string of the full command + * @param cursor The position of user cursor + * @param tokenCursor The position of the first token + * @param variations A list of the variations of the current command + * @param argumentIndex The index of the argument being parsed + * @param command A string of the command name + * @returns A list of parsed tokens + */ +async function getBlockStateContext( + line: string, + cursor: number, + tokenCursor: number, + variations: Command[], + argumentIndex: number, + command: string +): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) throw new Error('The current project must be a bedrock project!') + + tokenCursor = skipSpaces(line, tokenCursor) + const token = getNextBlockState(line, tokenCursor) + + if (!token || cursor <= token.start + token.word.length) + return [ + { + kind: 'blockState', + token: token ?? undefined, + }, + ] + + tokenCursor = token.start + token.word.length + + variations = variations.filter( + (variation) => matchArgument(token, variation.arguments[argumentIndex]) && variation.arguments.length > argumentIndex + 1 + ) + + if (variations.length === 0) + return [ + { + kind: 'end', + token: undefined, + }, + ] + + return await getArgumentContext(line, cursor, tokenCursor, variations, argumentIndex + 1, command) +} + +/** + * Checks if a token matches a argument definition + * @param argument An argument token + * @param definition A definition object + * @returns If the argument matches + */ +function matchArgument(argument: Token, definition: any): boolean { + if (definition === undefined) return false + + if (definition.type === 'string') { + if (definition.additionalData?.values && !definition.additionalData.values.includes(argument.word)) return false + + return true + } + + if (definition.type === 'boolean' && /^(true|false)$/) return true + + if (definition.type === 'selector' && /^@(a|e|r|s|p|(initiator))/.test(argument.word)) return true + + if (definition.type === 'coordinate' && /^[~^]?(-?(([0-9]*\.[0-9]+)|[0-9]+))?$/.test(argument.word)) return true + + if (definition.type === 'number' && /^-?(([0-9]*\.[0-9]+)|[0-9]+)$/.test(argument.word)) return true + + if (definition.type === 'blockState' && /^(\[|[0-9]+)/.test(argument.word)) return true + + if (definition.type === 'jsonData' && /^(\{|\[)/.test(argument.word)) return true + + console.warn('Failed to match', argument, definition) + + return false +} + +/** + * Skips the cursor passed spaces + * @param line A string of the command + * @param cursor The location of the cursor + * @returns A new location passed any spaces immedietly after the original cursor position + */ +function skipSpaces(line: string, cursor: number): number { + let startCharacter = cursor + + while (line[startCharacter] === ' ') startCharacter++ + + return startCharacter +} + +/** + * Gets the next word token. Will capture {} and "" as words. + * @param line A string of the command + * @param cursor The location of the cursor + * @returns The next token or null if at the end of the line + */ +function getNextWord(line: string, cursor: number): Token | null { + if (line[cursor] === '{') { + let endCharacter = cursor + 1 + + let openBracketCount = 1 + let withinString = false + + for (; endCharacter < line.length; endCharacter++) { + if (line[endCharacter] === '"') { + if (!withinString) { + withinString = true + } else if (line[endCharacter - 1] !== '\\') { + withinString = false + } + } + + if (withinString) continue + + if (line[endCharacter] === '{') { + openBracketCount++ + } else if (line[endCharacter] === '}') { + openBracketCount-- + + if (openBracketCount === 0) { + endCharacter++ + + break + } + } + } + + return { + word: line.substring(cursor, endCharacter), + start: cursor, + } + } + + if (line[cursor] === '"') { + let closingIndex = -1 + + for (let index = cursor + 1; index < line.length; index++) { + if (line[index] !== '"') continue + + if (line[index - 1] === '\\') continue + + closingIndex = index + 1 + } + + if (closingIndex === -1) closingIndex = line.length + + return { + word: line.substring(cursor, closingIndex), + start: cursor, + } + } + + let spaceIndex = line.substring(cursor).indexOf(' ') + cursor + if (spaceIndex === cursor - 1) spaceIndex = line.length + + if (spaceIndex <= cursor) return null + + return { + word: line.substring(cursor, spaceIndex), + start: cursor, + } +} + +/** + * Gets the next selector token + * @param line A string of the command + * @param cursor The location of the cursor + * @returns The next token or null if at the end of the line + */ +function getNextSelector(line: string, cursor: number): Token | null { + if (line[cursor] !== '@') return null + + let endCharacter = cursor + 2 + + while (/^[a-z]+$/.test(line.slice(cursor + 1, endCharacter)) && endCharacter <= line.length) { + endCharacter++ + } + + endCharacter-- + + if (endCharacter === cursor + 1) + return { + word: '@', + start: cursor, + } + + if (line[endCharacter] !== '[') + return { + word: line.substring(cursor, endCharacter), + start: cursor, + } + + endCharacter++ + + let openBracketCount = 1 + let withinString = false + + for (; endCharacter < line.length; endCharacter++) { + if (line[endCharacter] === '"') { + if (!withinString) { + withinString = true + } else if (line[endCharacter - 1] !== '\\') { + withinString = false + } + } + + if (withinString) continue + + if (line[endCharacter] === '[') { + openBracketCount++ + } else if (line[endCharacter] === ']') { + openBracketCount-- + + if (openBracketCount === 0) { + endCharacter++ + + break + } + } + } + + return { + word: line.substring(cursor, endCharacter), + start: cursor, + } +} + +function getBasicSelectorPart(word: string): string { + if (word[0] !== '@') return '' + + if (word.length === 1) return word[0] + + for (let index = 1; index < word.length; index++) { + if (!/[a-z]/.test(word[index])) return word.substring(0, index) + } + + return word +} + +function getNextSelectorArgumentWord(line: string, cursor: number): Token | null { + let endCharacter = cursor + 1 + + while (/^[a-z]+$/.test(line.slice(cursor, endCharacter)) && endCharacter <= line.length) { + endCharacter++ + } + + endCharacter-- + + if (endCharacter === cursor) return null + + return { + word: line.substring(cursor, endCharacter), + start: cursor, + } +} + +function getNextSelectorOperatorWord(line: string, cursor: number): Token | null { + if (line[cursor] + line[cursor + 1] === '=!') { + return { + word: '=!', + start: cursor, + } + } + + if (line[cursor] === '=') { + return { + word: '=', + start: cursor, + } + } + + return null +} + +function getNextSelectorValueWord(line: string, cursor: number): Token | null { + if (line[cursor] === '{') { + let endCharacter = cursor + 1 + + let openBracketCount = 1 + let withinString = false + + for (; endCharacter < line.length; endCharacter++) { + if (line[endCharacter] === '"') { + if (!withinString) { + withinString = true + } else if (line[endCharacter - 1] !== '\\') { + withinString = false + } + } + + if (withinString) continue + + if (line[endCharacter] === '{') { + openBracketCount++ + } else if (line[endCharacter] === '}') { + openBracketCount-- + + if (openBracketCount === 0) { + endCharacter++ + + break + } + } + } + + return { + word: line.substring(cursor, endCharacter), + start: cursor, + } + } + + if (line[cursor] === '"') { + let closingIndex = -1 + + for (let index = cursor + 1; index < line.length; index++) { + if (line[index] !== '"') continue + + if (line[index - 1] === '\\') continue + + closingIndex = index + 1 + } + + if (closingIndex === -1) closingIndex = line.length + + return { + word: line.substring(cursor, closingIndex), + start: cursor, + } + } + + const match = line.substring(cursor).match(/^[a-z_:A-Z0-9.]+/) + + if (match === null) return null + + return { + word: line.substring(cursor, cursor + match[0].length), + start: cursor, + } +} + +/** + * Gets the next block state token + * @param line A string of the command + * @param cursor The location of the cursor + * @returns The next token or null if at the end of the line + */ +function getNextBlockState(line: string, cursor: number): Token | null { + if (line[cursor] !== '[') { + let endCharacter = cursor + 1 + + while (/^[0-9]+$/.test(line.slice(cursor, endCharacter)) && endCharacter <= line.length) { + endCharacter++ + } + + endCharacter-- + + if (endCharacter === cursor) return null + + return { + word: line.substring(cursor, endCharacter), + start: cursor, + } + } + + let endCharacter = cursor + 1 + + let openBracketCount = 1 + let withinString = false + + for (; endCharacter < line.length; endCharacter++) { + if (line[endCharacter] === '"') { + if (!withinString) { + withinString = true + } else if (line[endCharacter - 1] !== '\\') { + withinString = false + } + } + + if (withinString) continue + + if (line[endCharacter] === '[') { + openBracketCount++ + } else if (line[endCharacter] === ']') { + openBracketCount-- + + if (openBracketCount === 0) { + endCharacter++ + + break + } + } + } + + return { + word: line.substring(cursor, endCharacter), + start: cursor, + } +} diff --git a/src/libs/monaco/languages/McFunction/Signature.ts b/src/libs/monaco/languages/McFunction/Signature.ts new file mode 100644 index 000000000..06a84a37d --- /dev/null +++ b/src/libs/monaco/languages/McFunction/Signature.ts @@ -0,0 +1,67 @@ +import { CancellationToken, Position, editor, languages } from 'monaco-editor' +import { ArgumentContext, parseCommand } from './Parser' +import { Command } from '@/libs/data/bedrock/CommandData' + +export async function provideSignatureHelp( + model: editor.ITextModel, + position: Position, + token: CancellationToken, + context: languages.SignatureHelpContext +): Promise { + const line = model.getLineContent(position.lineNumber) + + const cursor = position.column - 1 + + const contexts = await parseCommand(line, cursor) + + let signatures: languages.SignatureInformation[] = [] + + for (const context of contexts) { + if (context.kind === 'argument') { + const argumentContext = context + + signatures = signatures.concat( + argumentContext.variations.map((variation) => ({ + label: buildSignature((variation as any).original), + parameters: [], + documentation: variation.description, + })) + ) + } + } + + return { + value: { + activeParameter: 0, + activeSignature: 0, + signatures, + }, + dispose() {}, + } +} + +/** + * Creates the signature string for a given command variation + * @example fill [tileData: number] [oldBlockHandling: destroy | hollow | keep | outline | replace] + * @param command + * @returns the signature as a string + */ +function buildSignature(command: Command): string { + let signature = command.commandName + + for (const argument of command.arguments) { + let modifier = '<>' + + if (argument.isOptional) modifier = '[]' + + signature += ` ${modifier[0]}${argument.argumentName ? argument.argumentName + ': ' : ''}${ + argument.additionalData?.values + ? argument.additionalData.values.join(' | ') + : argument.type.startsWith('$') + ? argument.type.substring(1) + : argument.type + }${modifier[1]}${argument.allowMultiple ? '...' : ''}` + } + + return signature +} diff --git a/src/libs/monaco/languages/Molang.ts b/src/libs/monaco/languages/Molang.ts new file mode 100644 index 000000000..d2b353680 --- /dev/null +++ b/src/libs/monaco/languages/Molang.ts @@ -0,0 +1,91 @@ +import { MarkerSeverity, editor, languages } from 'monaco-editor' +import { CustomMolang } from '@bridge-editor/molang' + +export function setupMolang() { + languages.register({ id: 'molang', extensions: ['.molang'], aliases: ['molang'] }) + + languages.setLanguageConfiguration('lang', { + comments: { + lineComment: '#', + }, + brackets: [ + ['(', ')'], + ['[', ']'], + ['{', '}'], + ], + autoClosingPairs: [ + { + open: '(', + close: ')', + }, + { + open: '[', + close: ']', + }, + { + open: '{', + close: '}', + }, + { + open: "'", + close: "'", + }, + ], + }) + + languages.setMonarchTokensProvider('lang', { + ignoreCase: true, + brackets: [ + { open: '(', close: ')', token: 'delimiter.parenthesis' }, + { open: '[', close: ']', token: 'delimiter.square' }, + { open: '{', close: '}', token: 'delimiter.curly' }, + ], + keywords: ['return', 'loop', 'for_each', 'break', 'continue', 'this', 'function'], + identifiers: ['v', 't', 'c', 'q', 'f', 'a', 'arg', 'variable', 'temp', 'context', 'query'], + tokenizer: { + root: [ + [/#.*/, 'comment'], + [/'[^']'/, 'string'], + [/[0-9]+(\.[0-9]+)?/, 'number'], + [/true|false/, 'number'], + [/\=|\,|\!|%=|\*=|\+=|-=|\/=|<|=|>|<>/, 'definition'], + [ + /[a-z_$][\w$]*/, + { + cases: { + '@keywords': 'keyword', + '@identifiers': 'type.identifier', + '@default': 'identifier', + }, + }, + ], + ], + }, + }) + + const molang = new CustomMolang({}) + + editor.onDidCreateModel((model) => { + if (model.getLanguageId() !== 'molang') return + + model.onDidChangeContent(() => { + try { + molang.parse(model.getValue()) + editor.setModelMarkers(model, 'molang', []) + } catch (err: any) { + let { startColumn = 0, endColumn = Infinity, startLineNumber = 0, endLineNumber = Infinity } = {} + + editor.setModelMarkers(model, 'molang', [ + { + startColumn: startColumn + 1, + endColumn: endColumn + 1, + startLineNumber: startLineNumber + 1, + endLineNumber: endLineNumber + 1, + message: err.message, + severity: MarkerSeverity.Error, + }, + ]) + } + }) + }) +} diff --git a/src/libs/project/BedrockProject.ts b/src/libs/project/BedrockProject.ts new file mode 100644 index 000000000..8cb269239 --- /dev/null +++ b/src/libs/project/BedrockProject.ts @@ -0,0 +1,85 @@ +import { DashService } from '@/libs/compiler/DashService' +import { Project } from './Project' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { IPackType } from 'mc-project-core' +import { FileTypeData } from '@/libs/data/bedrock/FileTypeData' +import { SchemaData } from '@/libs/data/bedrock/SchemaData' +import { PresetData } from '@/libs/data/bedrock/PresetData' +import { ScriptTypeData } from '@/libs/data/bedrock/ScriptTypeData' +import { IndexerService } from '@/libs/indexer/bedrock/IndexerService' +import { RequirementsMatcher } from '@/libs/data/bedrock/RequirementsMatcher' +import { Data } from '@/libs/data/Data' +import { LangData } from '@/libs/data/bedrock/LangData' +import { CommandData } from '@/libs/data/bedrock/CommandData' +import { SnippetManager } from '@/libs/snippets/SnippetManager' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { join } from 'pathe' + +export class BedrockProject extends Project { + public packDefinitions: IPackType[] = [] + public fileTypeData = new FileTypeData() + public schemaData = new SchemaData(this) + public presetData = new PresetData() + public scriptTypeData = new ScriptTypeData(this) + public langData = new LangData(this) + public commandData = new CommandData(this) + public indexerService = new IndexerService(this) + public dashService = new DashService(this) + public requirementsMatcher = new RequirementsMatcher(this) + public snippetLoader = new SnippetManager() + + constructor(name: string) { + super(name) + + fileSystem.ingorePath(join(this.path, '.bridge/.dash.development.json')) + fileSystem.ingorePath(join(this.path, 'builds/')) + } + + public async load() { + await super.load() + + this.packDefinitions = await Data.get('packages/minecraftBedrock/packDefinitions.json') + + await this.fileTypeData.load() + await this.indexerService.setup() + await this.presetData.load() + await this.scriptTypeData.setup() + + await this.langData.setup() + await this.commandData.setup() + + await this.dashService.setupForDevelopmentProject() + + await this.schemaData.load() + + await this.requirementsMatcher.setup() + + this.dashService.build() + } + + public async dispose() { + await super.dispose() + + this.indexerService.dispose() + this.schemaData.dispose() + this.presetData.dispose() + + await this.dashService.dispose() + + await this.scriptTypeData.dispose() + } + + public async setOutputFileSystem(fileSystem: BaseFileSystem) { + await super.setOutputFileSystem(fileSystem) + + await this.dashService.setOutputFileSystem(fileSystem) + + if (!this.dashService.isSetup) return + + this.dashService.build() + } + + public async build() { + await this.dashService.build() + } +} diff --git a/src/libs/project/ConvertComMojangProject.ts b/src/libs/project/ConvertComMojangProject.ts new file mode 100644 index 000000000..b17ff30f4 --- /dev/null +++ b/src/libs/project/ConvertComMojangProject.ts @@ -0,0 +1,203 @@ +import { ConfirmWindow } from '@/components/Windows/Confirm/ConfirmWindow' +import { Windows } from '@/components/Windows/Windows' +import { TPackTypeId } from 'mc-project-core' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { PWAFileSystem } from '@/libs/fileSystem/PWAFileSystem' +import { Settings } from '@/libs/settings/Settings' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { Data } from '@/libs/data/Data' +import { join } from 'pathe' +import { packs } from './Packs' +import { ProjectManager } from './ProjectManager' +import { getLatestStableFormatVersion } from '@/libs/data/bedrock/FormatVersion' +import { v4 as uuid } from 'uuid' + +export interface ConvertableComMojangProjectInfo { + type: 'com.mojang' + packs: { type: TPackTypeId; uuid: string; path: string; relatedPacks: string[] }[] + name: string + icon: string +} + +export interface ConvertableV1ProjectInfo { + type: 'v1' + packs: { type: TPackTypeId; uuid: string; path: string; relatedPacks: string[] }[] + name: string + icon: string +} + +export type ConvertableProjectInfo = ConvertableComMojangProjectInfo | ConvertableV1ProjectInfo + +export function convertProject(projectInfo: ConvertableProjectInfo) { + Windows.open( + new ConfirmWindow(`convert.confirmationMessage.${projectInfo.type}`, async () => { + if (fileSystem instanceof PWAFileSystem) { + const outputFolder: FileSystemDirectoryHandle | undefined = Settings.get('outputFolder') + + if (!outputFolder) return + + const comMojangFileSystem = new PWAFileSystem(false) + comMojangFileSystem.setBaseHandle(outputFolder) + + if (outputFolder && (await comMojangFileSystem.ensurePermissions(outputFolder))) { + if (projectInfo.type === 'v1') convertV1Project(projectInfo, comMojangFileSystem) + if (projectInfo.type === 'com.mojang') convertComMojangProject(projectInfo, comMojangFileSystem) + } + } + }) + ) +} + +async function convertComMojangProject(convertableProjectInfo: ConvertableComMojangProjectInfo, comMojangFileSystem: BaseFileSystem) { + const packDefinitions: { id: string; defaultPackPath: string }[] = await Data.get('packages/minecraftBedrock/packDefinitions.json') + packDefinitions.push({ + id: 'bridge', + defaultPackPath: '.bridge', + }) + + const projectPath = join('projects', convertableProjectInfo.name) + + await fileSystem.makeDirectory(projectPath) + + let manifest = null + + for (const pack of convertableProjectInfo.packs) { + try { + manifest = await comMojangFileSystem.readFileJson(join(pack.path, 'manifest.json')) + } catch {} + } + + const experimentalToggles = await Data.get('packages/minecraftBedrock/experimentalGameplay.json') + + packs['bridge']!.create( + fileSystem, + projectPath, + { + author: (manifest?.metadata?.authors as string[] | undefined) ?? ['bridge'], + bpAsRpDependency: + convertableProjectInfo.packs.some((pack) => pack.type === 'behaviorPack') && + convertableProjectInfo.packs.some((pack) => pack.type === 'resourcePack'), + rpAsBpDependency: + convertableProjectInfo.packs.some((pack) => pack.type === 'behaviorPack') && + convertableProjectInfo.packs.some((pack) => pack.type === 'resourcePack'), + configurableFiles: [], + description: manifest?.header?.description ?? '', + icon: convertableProjectInfo.icon, + name: convertableProjectInfo.name, + namespace: 'bridge', + targetVersion: await getLatestStableFormatVersion(), + packs: ['bridge', ...convertableProjectInfo.packs.map((pack) => pack.type)], + uuids: Object.fromEntries(convertableProjectInfo.packs.map((packType) => [packType.type, uuid()])), + experiments: Object.fromEntries(experimentalToggles.map((toggle: any) => [toggle.id, false])), + }, + join(projectPath, packDefinitions.find((pack) => pack.id === 'bridge')!.defaultPackPath) + ) + + for (const pack of convertableProjectInfo.packs) { + const packDefinition = packDefinitions.find((packDefinition) => packDefinition.id === pack.type) + + if (!packDefinition) { + console.warn(`Failed to convert pack of type ${pack.type}. Could not find matching pack definition.`) + + continue + } + + await fileSystem.copyDirectoryFromFileSystem(pack.path, comMojangFileSystem, join(projectPath, packDefinition.defaultPackPath)) + } + + const projectInfo = await ProjectManager.getProjectInfo(projectPath) + + if (!projectInfo) throw new Error('Failed to create project!') + + for (const pack of convertableProjectInfo.packs) { + await comMojangFileSystem.removeDirectory(pack.path) + } + + ProjectManager.convertableProjects.splice( + ProjectManager.convertableProjects.findIndex((project) => project.packs[0].uuid === convertableProjectInfo.packs[0].uuid) + ) + ProjectManager.updatedConvertableProjects.dispatch() + + ProjectManager.addProject(projectInfo) +} + +async function convertV1Project(convertableProjectInfo: ConvertableV1ProjectInfo, comMojangFileSystem: BaseFileSystem) { + const packDefinitions: { id: string; defaultPackPath: string }[] = await Data.get('packages/minecraftBedrock/packDefinitions.json') + packDefinitions.push({ + id: 'bridge', + defaultPackPath: '.bridge', + }) + + const projectPath = join('projects', convertableProjectInfo.name) + + await fileSystem.makeDirectory(projectPath) + + let manifest = null + + for (const pack of convertableProjectInfo.packs) { + try { + manifest = await comMojangFileSystem.readFileJson(join(pack.path, 'manifest.json')) + } catch {} + } + + let config = null + + for (const pack of convertableProjectInfo.packs) { + try { + config = await comMojangFileSystem.readFileJson(join(pack.path, 'bridge', 'config.json')) + } catch {} + } + + const experimentalToggles = await Data.get('packages/minecraftBedrock/experimentalGameplay.json') + + packs['bridge']!.create( + fileSystem, + projectPath, + { + author: (manifest?.metadata?.authors as string[] | undefined) ?? ['bridge'], + bpAsRpDependency: + convertableProjectInfo.packs.some((pack) => pack.type === 'behaviorPack') && + convertableProjectInfo.packs.some((pack) => pack.type === 'resourcePack'), + rpAsBpDependency: + convertableProjectInfo.packs.some((pack) => pack.type === 'behaviorPack') && + convertableProjectInfo.packs.some((pack) => pack.type === 'resourcePack'), + configurableFiles: [], + description: manifest?.header?.description ?? '', + icon: convertableProjectInfo.icon, + name: convertableProjectInfo.name, + namespace: config?.prefix ?? 'bridge', + targetVersion: config?.formatVersion ?? (await getLatestStableFormatVersion()), + packs: ['bridge', ...convertableProjectInfo.packs.map((pack) => pack.type)], + uuids: Object.fromEntries(convertableProjectInfo.packs.map((packType) => [packType.type, uuid()])), + experiments: Object.fromEntries(experimentalToggles.map((toggle: any) => [toggle.id, false])), + }, + join(projectPath, packDefinitions.find((pack) => pack.id === 'bridge')!.defaultPackPath) + ) + + for (const pack of convertableProjectInfo.packs) { + const packDefinition = packDefinitions.find((packDefinition) => packDefinition.id === pack.type) + + if (!packDefinition) { + console.warn(`Failed to convert pack of type ${pack.type}. Could not find matching pack definition.`) + + continue + } + + await fileSystem.copyDirectoryFromFileSystem(pack.path, comMojangFileSystem, join(projectPath, packDefinition.defaultPackPath)) + } + + const projectInfo = await ProjectManager.getProjectInfo(projectPath) + + if (!projectInfo) throw new Error('Failed to create project!') + + for (const pack of convertableProjectInfo.packs) { + await comMojangFileSystem.removeDirectory(pack.path) + } + + ProjectManager.convertableProjects.splice( + ProjectManager.convertableProjects.findIndex((project) => project.packs[0].uuid === convertableProjectInfo.packs[0].uuid) + ) + ProjectManager.updatedConvertableProjects.dispatch() + + ProjectManager.addProject(projectInfo) +} diff --git a/src/libs/project/CreateProjectConfig.ts b/src/libs/project/CreateProjectConfig.ts new file mode 100644 index 000000000..7c19702f6 --- /dev/null +++ b/src/libs/project/CreateProjectConfig.ts @@ -0,0 +1,14 @@ +export interface CreateProjectConfig { + name: string + description: string + namespace: string + author: string | string[] + targetVersion: string + icon: FileSystemWriteChunkType + packs: string[] + configurableFiles: string[] + rpAsBpDependency: boolean + bpAsRpDependency: boolean + uuids: Record + experiments: Record +} diff --git a/src/libs/project/Packs.ts b/src/libs/project/Packs.ts new file mode 100644 index 000000000..665686fdd --- /dev/null +++ b/src/libs/project/Packs.ts @@ -0,0 +1,14 @@ +import { BehaviourPack } from './create/packs/BehaviorPack' +import { BridgePack } from './create/packs/Bridge' +import { Pack } from './create/packs/Pack' +import { ResourcePack } from './create/packs/ResourcePack' +import { SkinPack } from './create/packs/SkinPack' + +export const packs: { + [key: string]: Pack | undefined +} = { + bridge: new BridgePack(), + behaviorPack: new BehaviourPack(), + resourcePack: new ResourcePack(), + skinPack: new SkinPack(), +} diff --git a/src/libs/project/Project.ts b/src/libs/project/Project.ts new file mode 100644 index 000000000..7ed7bf8d5 --- /dev/null +++ b/src/libs/project/Project.ts @@ -0,0 +1,157 @@ +import { Extensions } from '@/libs/extensions/Extensions' +import { basename, join } from 'pathe' +import { ConfirmWindow } from '@/components/Windows/Confirm/ConfirmWindow' +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { PWAFileSystem } from '@/libs/fileSystem/PWAFileSystem' +import { get, set } from 'idb-keyval' +import { IConfigJson } from 'mc-project-core' +import { LocalFileSystem } from '@/libs/fileSystem/LocalFileSystem' +import { Settings } from '@/libs/settings/Settings' +import { SettingsWindow } from '@/components/Windows/Settings/SettingsWindow' +import { Windows } from '@/components/Windows/Windows' +import { AsyncDisposable, Disposable, disposeAll } from '@/libs/disposeable/Disposeable' +import { Event } from '@/libs/event/Event' +import { NotificationSystem } from '@/components/Notifications/NotificationSystem' +import { tauriBuild } from '@/libs/tauri/Tauri' +import { TauriFileSystem } from '@/libs/fileSystem/TauriFileSystem' + +export class Project implements AsyncDisposable { + public path: string + public icon: string | null = null + public config: IConfigJson | null = null + public globals: any = null + + public outputFileSystem: BaseFileSystem = new LocalFileSystem() + public usingProjectOutputFolder: boolean = false + + public packs: { [key: string]: string } = {} + + public usingProjectOutputFolderChanged: Event = new Event() + + private projectOutputFolderDataKey: string = '' + private usingProjectOutputFolderKey: string = '' + + private disposables: Disposable[] = [] + + constructor(public name: string) { + this.path = join('/projects', this.name) + + this.projectOutputFolderDataKey = `projectOutputFolderHandle-${this.name}` + this.usingProjectOutputFolderKey = `usingProjectFolder-${this.name}` + + fileSystem.watch(this.path) + fileSystem.ingorePath(join(this.path, '.git')) + + if (this.outputFileSystem instanceof LocalFileSystem) this.outputFileSystem.setRootName(this.name) + } + + public async load() { + this.config = await fileSystem.readFileJson(join(this.path, 'config.json')) + + if (!this.config) throw new Error('Failed to load project config!') + + for (const [packId, packPath] of Object.entries(this.config.packs)) { + this.packs[packId] = join(this.path, packPath) + + if (await fileSystem.exists(join(this.packs[packId], 'pack_icon.png'))) + this.icon = await fileSystem.readFileDataUrl(join(this.packs[packId], 'pack_icon.png')) + } + + if (await fileSystem.exists(join(this.path, 'globals.json'))) { + try { + this.globals = await fileSystem.readFileJson(join(this.path, 'globals.json')) + } catch { + throw new Error('Failed to loadp roject globals!') + } + } + + this.disposables.push(Settings.updated.on(this.settingsChanged.bind(this))) + + await this.setupOutputFileSystem() + + await Extensions.loadProjectExtensions() + } + + public async dispose() { + fileSystem.unwatch(this.path) + + disposeAll(this.disposables) + + await Extensions.unloadProjectExtensions() + } + + public resolvePackPath(packId?: string, path?: string) { + if (!this.config) return '' + if (!this.config.packs) return '' + + if (packId === undefined && path === undefined) return this.path + + if (packId === undefined) return join(this.path ?? '', path!) + + if (path === undefined) return join(this.path ?? '', (this.config.packs)[packId]) + + return join(this.path ?? '', (this.config.packs)[packId], path) + } + + public async saveTabManagerState(state: any) { + await set(`tabManagerState-${this.name}`, JSON.stringify(state)) + } + + public async getTabManagerState() { + const stateString = await get(`tabManagerState-${this.name}`) + + if (!stateString) return null + + try { + return JSON.parse(stateString) ?? null + } catch {} + + return null + } + + protected async setupOutputFileSystem() { + if (!tauriBuild) return + + this.usingProjectOutputFolder = (await get(this.usingProjectOutputFolderKey)) ?? false + this.usingProjectOutputFolderChanged.dispatch() + + let outputFolderData = Settings.get('outputFolder') + + if (this.usingProjectOutputFolder) outputFolderData = (await get(this.projectOutputFolderDataKey)) ?? outputFolderData + + if (!outputFolderData) return + if ((outputFolderData as { type: string }).type !== 'tauri') return + + const fileSystem = new TauriFileSystem() + fileSystem.setBasePath((outputFolderData as { type: string; path: string }).path) + + await this.setOutputFileSystem(fileSystem) + } + + protected async setOutputFileSystem(fileSystem: BaseFileSystem) { + this.outputFileSystem = fileSystem + } + + public async setLocalOutputFolderData(data: unknown) { + await set(this.projectOutputFolderDataKey, data) + + await set(this.usingProjectOutputFolderKey, true) + + await this.setupOutputFileSystem() + } + + public async clearLocalProjectFolder() { + if (!this.usingProjectOutputFolder) return + + await set(this.usingProjectOutputFolderKey, false) + + await this.setupOutputFileSystem() + } + + protected async settingsChanged(event: unknown) { + if ((event as { id: string; value: unknown }).id !== 'outputFolder') return + + await this.setupOutputFileSystem() + } +} diff --git a/src/libs/project/ProjectManager.ts b/src/libs/project/ProjectManager.ts new file mode 100644 index 000000000..b43583c35 --- /dev/null +++ b/src/libs/project/ProjectManager.ts @@ -0,0 +1,438 @@ +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { Project } from './Project' +import { basename, join } from 'pathe' +import { PWAFileSystem } from '@/libs/fileSystem/PWAFileSystem' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { CreateProjectConfig } from './CreateProjectConfig' +import { Ref, onMounted, onUnmounted, ref, watch } from 'vue' +import { BedrockProject } from './BedrockProject' +import { IConfigJson, TPackTypeId } from 'mc-project-core' +import { LocalFileSystem } from '@/libs/fileSystem/LocalFileSystem' +import { Data } from '@/libs/data/Data' +import { Settings } from '@/libs/settings/Settings' +import { get, set } from 'idb-keyval' +import { Event } from '@/libs/event/Event' +import { Disposable } from '@/libs/disposeable/Disposeable' +import { ConvertableProjectInfo } from './ConvertComMojangProject' +import { packs } from './Packs' +import { ProgressWindow } from '@/components/Windows/Progress/ProgressWindow' +import { Windows } from '@/components/Windows/Windows' +import { createHeadlessReactable, createReactable } from '@/libs/event/React' +import { TabManager } from '@/components/TabSystem/TabManager' +import { FileTab } from '@/components/TabSystem/FileTab' +import { ConfirmWindow } from '@/components/Windows/Confirm/ConfirmWindow' +import { tauriBuild } from '@/libs/tauri/Tauri' +import { TauriFileSystem } from '../fileSystem/TauriFileSystem' + +export interface ProjectInfo { + name: string + icon: string + config: IConfigJson + favorite: boolean + packs: { type: TPackTypeId; uuid: string; path: string }[] +} + +export class ProjectManager { + public static projects: ProjectInfo[] = [] + public static convertableProjects: ConvertableProjectInfo[] = [] + + public static currentProject: Project | null = null + + public static updatedProjects: Event = new Event() + public static updatedConvertableProjects: Event = new Event() + public static updatedCurrentProject: Event = new Event() + + private static cacheFileSystem = new LocalFileSystem() + + public static setup() { + this.cacheFileSystem.setRootName('projectCache') + + Settings.addSetting('outputFolder', { + default: null, + async save(value) { + await set('defaultOutputFolder', value) + + return 'set' + }, + async load(value) { + if (value === 'set') return await get('defaultOutputFolder') + }, + }) + } + + public static async loadProjects() { + if (fileSystem instanceof PWAFileSystem && !fileSystem.setup) { + this.projects = [] + + if (await this.cacheFileSystem.exists('projects.json')) this.projects = await this.cacheFileSystem.readFileJson('projects.json') + + this.updatedProjects.dispatch() + + return + } + + if (!(await fileSystem.exists('projects'))) await fileSystem.makeDirectory('projects') + + let items = await fileSystem.readDirectoryEntries('projects') + + const foldersToLoad = items.filter((item) => item.kind === 'directory').map((item) => item.path) + + this.projects = [] + + for (const path of foldersToLoad) { + const projectInfo = await this.getProjectInfo(path) + + if (projectInfo) this.projects.push(projectInfo) + } + + this.updateProjectCache() + + this.updatedProjects.dispatch() + + await ProjectManager.loadConvertableProjects() + } + + public static addProject(project: ProjectInfo) { + this.projects.push(project) + + this.updateProjectCache() + + this.updatedProjects.dispatch() + } + + public static async createProject(config: CreateProjectConfig, fileSystem: BaseFileSystem) { + const packDefinitions: { id: string; defaultPackPath: string }[] = await Data.get('packages/minecraftBedrock/packDefinitions.json') + packDefinitions.push({ + id: 'bridge', + defaultPackPath: '.bridge', + }) + + const projectPath = join('projects', config.name) + + await fileSystem.makeDirectory(projectPath) + + await Promise.all( + config.packs.map(async (packId: string) => { + const pack = packs[packId] + const packDefinition = packDefinitions.find((pack) => pack.id === packId) + + if (pack === undefined || packDefinition === undefined) return + + await pack.create(fileSystem, projectPath, config, join(projectPath, packDefinition.defaultPackPath)) + }) + ) + + const projectInfo = await this.getProjectInfo(projectPath) + + if (!projectInfo) throw new Error('Failed to create project!') + + this.addProject(projectInfo) + } + + public static async loadProject(name: string) { + console.time('[App] Load Project') + + const progressWindow = new ProgressWindow('projects.loading') + Windows.open(progressWindow) + + this.currentProject = new BedrockProject(name) + + await this.currentProject.load() + + this.updatedCurrentProject.dispatch() + + Windows.close(progressWindow) + + console.timeEnd('[App] Load Project') + } + + public static async closeProject() { + if (!this.currentProject) return + + for (const tabSystem of TabManager.tabSystems.value) { + for (const tab of tabSystem.tabs.value) { + if (tab instanceof FileTab && tab.modified.value) { + if ( + !(await new Promise((resolve) => { + Windows.open( + new ConfirmWindow( + `windows.unsavedFile.closeFile`, + () => resolve(true), + () => resolve(false) + ) + ) + })) + ) + return + } + } + } + + await this.currentProject.dispose() + + this.currentProject = null + + this.updatedCurrentProject.dispatch() + } + + public static async getProjectInfo(path: string): Promise { + if (basename(path) === 'bridge-temp-project') return undefined + + if (!(await fileSystem.exists(join(path, 'config.json')))) return undefined + + let config: undefined | any = undefined + + try { + config = await fileSystem.readFileJson(join(path, 'config.json')) + } catch {} + + if (config === undefined) return undefined + + if (!config.packs) return undefined + + let iconDataUrl = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' + + for (const [packId, packPath] of Object.entries(config.packs)) { + const projectPackPath = join(path, packPath as string) + + if (await fileSystem.exists(join(projectPackPath, 'pack_icon.png'))) { + iconDataUrl = await fileSystem.readFileDataUrl(join(projectPackPath, 'pack_icon.png')) + } + } + + let favorites: string[] = [] + + try { + favorites = JSON.parse((await get('favoriteProjects')) as string) + } catch {} + + const packs: { type: TPackTypeId; uuid: string; path: string }[] = ( + await Promise.all( + Object.entries(config.packs as Record).map(async ([id, packPath]) => { + let manifest: any | null = null + + if (await fileSystem.exists(join(path, packPath, 'manifest.json'))) { + try { + manifest = await fileSystem.readFileJson(join(path, packPath, 'manifest.json')) + } catch {} + } + + if (!manifest) return null + + const uuid = manifest.header?.uuid + + if (!uuid) return null + + return { + type: id as TPackTypeId, + uuid, + path: packPath, + } + }) + ) + ).filter((pack) => pack !== null) + + return { + name: basename(path), + icon: iconDataUrl, + config: await fileSystem.readFileJson(join(path, 'config.json')), + favorite: favorites.includes(basename(path)), + packs, + } + } + + public static async toggleFavoriteProject(name: string) { + let favorites: string[] = [] + + try { + favorites = JSON.parse((await get('favoriteProjects')) as string) + } catch {} + + if (favorites.includes(name)) { + favorites.splice(favorites.indexOf(name), 1) + } else { + favorites.push(name) + } + + const project = ProjectManager.projects.find((project) => project.name === name) + + if (project) { + project.favorite = favorites.includes(name) + + ProjectManager.updateProjectCache() + + ProjectManager.updatedProjects.dispatch() + } + + await set('favoriteProjects', JSON.stringify(favorites)) + } + + private static async updateProjectCache() { + await this.cacheFileSystem.writeFileJson('projects.json', this.projects, false) + } + + private static async loadConvertableProjects() { + ProjectManager.convertableProjects = [] + + if (!tauriBuild) return + + const outputFolder = Settings.get('outputFolder') + + if (!outputFolder) return + + if ((outputFolder as { type: string }).type !== 'tauri') return + + const comMojangFileSystem = new TauriFileSystem() + comMojangFileSystem.setBasePath((outputFolder as { type: 'tauri'; path: string }).path) + + await ProjectManager.loadConvertableProjectsFromComMojang(comMojangFileSystem) + + ProjectManager.updatedConvertableProjects.dispatch() + } + + private static async loadConvertableProjectsFromComMojang(comMojangFileSystem: BaseFileSystem) { + if (await comMojangFileSystem.exists('/development_behavior_packs/')) { + const packsEntries = await comMojangFileSystem.readDirectoryEntries('/development_behavior_packs/') + + for (const entry of packsEntries) { + await ProjectManager.loadConvertablePackFromComMojang(comMojangFileSystem, entry.path, 'behaviorPack') + } + } + + if (await comMojangFileSystem.exists('/development_resource_packs/')) { + const packsEntries = await comMojangFileSystem.readDirectoryEntries('/development_resource_packs/') + + for (const entry of packsEntries) { + await ProjectManager.loadConvertablePackFromComMojang(comMojangFileSystem, entry.path, 'resourcePack') + } + } + } + + public static async loadConvertablePackFromComMojang(comMojangFileSystem: BaseFileSystem, path: string, type: TPackTypeId) { + let iconDataUrl = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' + + if (await comMojangFileSystem.exists(join(path, 'pack_icon.png'))) { + iconDataUrl = await comMojangFileSystem.readFileDataUrl(join(path, 'pack_icon.png')) + } + + if (!(await comMojangFileSystem.exists(join(path, 'manifest.json')))) return + + let manifest: any | null = null + + try { + manifest = await comMojangFileSystem.readFileJson(join(path, 'manifest.json')) + } catch {} + + if (!manifest) return + + let name = manifest.header?.name + + if (!name) return + + if (name === 'pack.name') { + let languages: string[] | null = null + + try { + languages = await comMojangFileSystem.readFileJson(join(path, 'texts', 'languages.json')) + } catch {} + + if (!languages || languages.length === 0) return + + const language = languages[0] + + if (!language) return + + if (!(await comMojangFileSystem.exists(join(path, 'texts', language + '.lang')))) return + + const langFile = await comMojangFileSystem.readFileText(join(path, 'texts', language + '.lang')) + + const nameLine = langFile.split(/\n|\r/).find((line) => line.startsWith('pack.name=')) + + if (!nameLine) return + + name = nameLine.substring('pack.name='.length) + } + + const uuid = manifest.header?.uuid + + if (!uuid) return + + if (ProjectManager.projects.some((project) => project.packs.some((pack) => pack.uuid === uuid))) return + + const relatedPackUuids: string[] = [] + + if (manifest.dependencies) { + const dependencies: undefined | any[] = manifest.dependencies + + for (const dependency of dependencies!) { + if (dependency.uuid) relatedPackUuids.push(dependency.uuid) + } + } + + const relatedPack = ProjectManager.convertableProjects.find((project) => + project.packs.some((pack) => relatedPackUuids.includes(pack.uuid) || pack.relatedPacks.includes(uuid)) + ) + + if (relatedPack) { + relatedPack.packs.push({ + type, + uuid, + path, + relatedPacks: relatedPackUuids, + }) + } else { + ProjectManager.convertableProjects.push({ + icon: iconDataUrl, + name, + packs: [{ type, uuid, path, relatedPacks: relatedPackUuids }], + type: (await comMojangFileSystem.exists(join(path, 'bridge'))) ? 'v1' : 'com.mojang', + }) + } + } +} + +export const useProjects = createReactable(ProjectManager.updatedProjects, () => [...ProjectManager.projects]) + +export const useConvertableProjects = createReactable(ProjectManager.updatedConvertableProjects, () => [ + ...ProjectManager.convertableProjects, +]) + +export const useCurrentProject = createReactable(ProjectManager.updatedCurrentProject, () => ProjectManager.currentProject) +export const useCurrentProjectHeadless = createHeadlessReactable(ProjectManager.updatedCurrentProject, () => ProjectManager.currentProject) + +export function useUsingProjectOutputFolder(): Ref { + const usingProjectOutputFolder: Ref = ref(false) + + function update() { + if (!ProjectManager.currentProject) { + usingProjectOutputFolder.value = false + + return + } + + usingProjectOutputFolder.value = ProjectManager.currentProject.usingProjectOutputFolder + } + + let disposable: Disposable + + watch(useCurrentProject(), (newProject, oldProject) => { + if (oldProject) disposable.dispose() + + if (newProject) { + disposable = newProject.usingProjectOutputFolderChanged.on(update) + + update() + } + }) + + onMounted(() => { + if (ProjectManager.currentProject) disposable = ProjectManager.currentProject.usingProjectOutputFolderChanged.on(update) + + update() + }) + onUnmounted(() => { + if (ProjectManager.currentProject) disposable.dispose() + }) + + return usingProjectOutputFolder +} diff --git a/src/libs/project/create/files/Config.ts b/src/libs/project/create/files/Config.ts new file mode 100644 index 000000000..3766be71b --- /dev/null +++ b/src/libs/project/create/files/Config.ts @@ -0,0 +1,39 @@ +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { CreateProjectConfig } from '../../CreateProjectConfig' +import { defaultPackPaths } from 'mc-project-core' + +export async function createConfig(fileSystem: BaseFileSystem, path: string, config: CreateProjectConfig) { + const configOutput = { + type: 'minecraftBedrock', + name: config.name, + authors: [config.author], + targetVersion: config.targetVersion, + experimentalGameplay: config.experiments, + namespace: config.namespace, + packs: Object.fromEntries(config.packs.map((packId) => [packId, defaultPackPaths[packId as keyof typeof defaultPackPaths]])), + worlds: ['./worlds/*'], + packDefinitions: {}, + compiler: { + plugins: [ + 'generatorScripts', + 'typeScript', + 'entityIdentifierAlias', + 'customEntityComponents', + 'customItemComponents', + 'customBlockComponents', + 'customCommands', + 'moLang', + 'formatVersionCorrection', + 'floatPropertyTruncationFix', + [ + 'simpleRewrite', + { + packName: config.name, + }, + ], + ], + }, + } + + await fileSystem.writeFile(path, JSON.stringify(configOutput, null, 2)) +} diff --git a/src/libs/project/create/files/DenoConfig.ts b/src/libs/project/create/files/DenoConfig.ts new file mode 100644 index 000000000..f7ae7ea7e --- /dev/null +++ b/src/libs/project/create/files/DenoConfig.ts @@ -0,0 +1,19 @@ +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' + +export async function createDenoConfig( + fileSystem: BaseFileSystem, + path: string +) { + await fileSystem.writeFileJson( + path, + { + tasks: { + install_dash: + 'deno install -A --reload -f -n dash_compiler https://raw.githubusercontent.com/bridge-core/deno-dash-compiler/main/mod.ts', + watch: 'dash_compiler build --mode development && dash_compiler watch', + build: 'dash_compiler build', + }, + }, + true + ) +} diff --git a/src/libs/project/create/files/GitIgnore.ts b/src/libs/project/create/files/GitIgnore.ts new file mode 100644 index 000000000..20f2fbcbb --- /dev/null +++ b/src/libs/project/create/files/GitIgnore.ts @@ -0,0 +1,18 @@ +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' + +export async function createGitIgnore( + fileSystem: BaseFileSystem, + path: string +) { + await fileSystem.writeFile( + path, + `Desktop.ini +.DS_Store +!.bridge/ +.bridge/* +!.bridge/compiler/ +!.bridge/extensions +builds +` + ) +} diff --git a/src/libs/project/create/files/Icon.ts b/src/libs/project/create/files/Icon.ts new file mode 100644 index 000000000..aa80efb25 --- /dev/null +++ b/src/libs/project/create/files/Icon.ts @@ -0,0 +1,9 @@ +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' + +export async function createIcon( + fileSystem: BaseFileSystem, + path: string, + icon: FileSystemWriteChunkType +) { + await fileSystem.writeFile(path, icon) +} diff --git a/src/libs/project/create/files/Lang.ts b/src/libs/project/create/files/Lang.ts new file mode 100644 index 000000000..59c5eed84 --- /dev/null +++ b/src/libs/project/create/files/Lang.ts @@ -0,0 +1,12 @@ +import { v4 as uuid } from 'uuid' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' +import { CreateProjectConfig } from '../../CreateProjectConfig' + +export async function createLang(fileSystem: BaseFileSystem, path: string, config: CreateProjectConfig) { + await fileSystem.makeDirectory(join(path, 'texts')) + + await fileSystem.writeFile(join(path, 'texts/en_US.lang'), `skinpack.${config.namespace}=${config.name}`) + + await fileSystem.writeFileJson(join(path, 'texts/languages.json'), ['en_US'], true) +} diff --git a/src/libs/project/create/files/Manifest.ts b/src/libs/project/create/files/Manifest.ts new file mode 100644 index 000000000..ecba14d6d --- /dev/null +++ b/src/libs/project/create/files/Manifest.ts @@ -0,0 +1,81 @@ +import { v4 as uuid } from 'uuid' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { CreateProjectConfig } from '../../CreateProjectConfig' +import { appVersion, dashVersion } from '@/libs/app/AppEnv' +import { Data } from '@/libs/data/Data' + +async function targetVersionToMinEngineVersion(targetVersion: string) { + const mineEngineVersions: Record = await Data.get( + 'packages/minecraftBedrock/minEngineVersionMap.json' + ) + + return mineEngineVersions[targetVersion] ?? targetVersion +} + +function packTypeToModuleType(packType: string) { + switch (packType) { + case 'behaviorPack': + return 'data' + case 'resourcePack': + return 'resources' + case 'skinPack': + return 'skin_pack' + default: + throw new Error('Invalid pack type ' + packType) + } +} + +export async function createManifest( + fileSystem: BaseFileSystem, + path: string, + config: CreateProjectConfig, + packType: string +) { + const manifest: { [key: string]: any } = { + format_version: 2, + metadata: { + authors: Array.isArray(config.author) ? config.author : [config.author], + generated_with: { + bridge: [appVersion], + dash: [dashVersion], + }, + }, + header: { + name: 'pack.name', + description: 'pack.description', + min_engine_version: + packType === 'behaviorPack' || packType === 'resourcePack' + ? (await targetVersionToMinEngineVersion(config.targetVersion)).split('.').map((str) => Number(str)) + : undefined, + uuid: config.uuids[packType], + version: [1, 0, 0], + }, + modules: [ + { + type: packTypeToModuleType(packType), + uuid: uuid(), + version: [1, 0, 0], + }, + ], + } + + if (config.rpAsBpDependency && packType === 'behaviorPack') { + manifest.dependencies = [ + { + uuid: config.uuids.resourcePack, + version: [1, 0, 0], + }, + ] + } + + if (config.bpAsRpDependency && packType === 'resourcePack') { + manifest.dependencies = [ + { + uuid: config.uuids.behaviorPack, + version: [1, 0, 0], + }, + ] + } + + await fileSystem.writeFile(path, JSON.stringify(manifest, null, 2)) +} diff --git a/src/libs/project/create/files/configurable/ConfigurableFile.ts b/src/libs/project/create/files/configurable/ConfigurableFile.ts new file mode 100644 index 000000000..be76a17c5 --- /dev/null +++ b/src/libs/project/create/files/configurable/ConfigurableFile.ts @@ -0,0 +1,13 @@ +import { CreateProjectConfig } from '../../../CreateProjectConfig' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' + +export class ConfigurableFile { + public readonly id: string = 'none' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) {} +} diff --git a/src/libs/project/create/files/configurable/behaviorPack/Player.ts b/src/libs/project/create/files/configurable/behaviorPack/Player.ts new file mode 100644 index 000000000..c3bc97589 --- /dev/null +++ b/src/libs/project/create/files/configurable/behaviorPack/Player.ts @@ -0,0 +1,28 @@ +import { Data } from '@/libs/data/Data' +import { CreateProjectConfig } from '@/libs/project/CreateProjectConfig' +import { ConfigurableFile } from '../ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class PlayerFile extends ConfigurableFile { + public readonly id: string = 'player' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + const defaultPlayer = await Data.get('packages/minecraftBedrock/vanilla/player.json') + + if (!(await fileSystem.exists(join(packPath, 'entities')))) + await fileSystem.makeDirectory(join(packPath, 'entities')) + + await fileSystem.writeFileJson(join(packPath, 'entities/player.json'), defaultPlayer, true) + + if (!(await fileSystem.exists(join(packPath, 'loot_tables')))) + await fileSystem.makeDirectory(join(packPath, 'loot_tables')) + + await fileSystem.writeFile(join(packPath, 'loot_tables/empty.json'), '{}') + } +} diff --git a/src/libs/project/create/files/configurable/behaviorPack/Tick.ts b/src/libs/project/create/files/configurable/behaviorPack/Tick.ts new file mode 100644 index 000000000..1b108267e --- /dev/null +++ b/src/libs/project/create/files/configurable/behaviorPack/Tick.ts @@ -0,0 +1,20 @@ +import { CreateProjectConfig } from '../../../../CreateProjectConfig' +import { ConfigurableFile } from '../ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class TickFile extends ConfigurableFile { + public readonly id: string = 'tick' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + if (!(await fileSystem.exists(join(packPath, 'functions')))) + await fileSystem.makeDirectory(join(packPath, 'functions')) + + await fileSystem.writeFileJson(join(packPath, 'functions/tick.json'), { values: [] }, true) + } +} diff --git a/src/libs/project/create/files/configurable/resourcePack/BiomesClient.ts b/src/libs/project/create/files/configurable/resourcePack/BiomesClient.ts new file mode 100644 index 000000000..26b55b54e --- /dev/null +++ b/src/libs/project/create/files/configurable/resourcePack/BiomesClient.ts @@ -0,0 +1,23 @@ +import { CreateProjectConfig } from '../../../../CreateProjectConfig' +import { ConfigurableFile } from '../ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class BiomesClientFile extends ConfigurableFile { + public readonly id: string = 'biomesClient' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + await fileSystem.writeFileJson( + join(packPath, `biomes_client.json`), + { + biomes: {}, + }, + true + ) + } +} diff --git a/src/libs/project/create/files/configurable/resourcePack/Blocks.ts b/src/libs/project/create/files/configurable/resourcePack/Blocks.ts new file mode 100644 index 000000000..f1843b949 --- /dev/null +++ b/src/libs/project/create/files/configurable/resourcePack/Blocks.ts @@ -0,0 +1,23 @@ +import { CreateProjectConfig } from '../../../../CreateProjectConfig' +import { ConfigurableFile } from '../ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class BlocksFile extends ConfigurableFile { + public readonly id: string = 'blocks' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + await fileSystem.writeFileJson( + join(packPath, `blocks.json`), + { + format_version: [1, 1, 0], + }, + true + ) + } +} diff --git a/src/libs/project/create/files/configurable/resourcePack/FlipbookTextures.ts b/src/libs/project/create/files/configurable/resourcePack/FlipbookTextures.ts new file mode 100644 index 000000000..3a45c40ce --- /dev/null +++ b/src/libs/project/create/files/configurable/resourcePack/FlipbookTextures.ts @@ -0,0 +1,20 @@ +import { CreateProjectConfig } from '../../../../CreateProjectConfig' +import { ConfigurableFile } from '../ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class FlipbookTexturesFile extends ConfigurableFile { + public readonly id: string = 'flipbookTextures' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + if (!(await fileSystem.exists(join(packPath, 'textures')))) + await fileSystem.makeDirectory(join(packPath, 'textures')) + + await fileSystem.writeFileJson(join(packPath, 'textures/flipbook_textures.json'), [], true) + } +} diff --git a/src/libs/project/create/files/configurable/resourcePack/ItemTexture.ts b/src/libs/project/create/files/configurable/resourcePack/ItemTexture.ts new file mode 100644 index 000000000..65b88cc7d --- /dev/null +++ b/src/libs/project/create/files/configurable/resourcePack/ItemTexture.ts @@ -0,0 +1,28 @@ +import { CreateProjectConfig } from '../../../../CreateProjectConfig' +import { ConfigurableFile } from '../ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class ItemTextureFile extends ConfigurableFile { + public readonly id: string = 'itemTexture' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + if (!(await fileSystem.exists(join(packPath, 'textures/textures')))) + await fileSystem.makeDirectory(join(packPath, 'textures/textures')) + + await fileSystem.writeFileJson( + join(packPath, 'textures/item_texture.json'), + { + resource_pack_name: config.name, + texture_name: 'atlas.items', + texture_data: {}, + }, + true + ) + } +} diff --git a/src/libs/project/create/files/configurable/resourcePack/SoundDefinitions.ts b/src/libs/project/create/files/configurable/resourcePack/SoundDefinitions.ts new file mode 100644 index 000000000..58efab9e2 --- /dev/null +++ b/src/libs/project/create/files/configurable/resourcePack/SoundDefinitions.ts @@ -0,0 +1,27 @@ +import { CreateProjectConfig } from '../../../../CreateProjectConfig' +import { ConfigurableFile } from '../ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class SoundDefinitionsFile extends ConfigurableFile { + public readonly id: string = 'soundDefinitions' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + if (!(await fileSystem.exists(join(packPath, 'sounds')))) + await fileSystem.makeDirectory(join(packPath, 'sounds')) + + await fileSystem.writeFileJson( + join(packPath, 'sounds/sound_definitions.json'), + { + format_version: '1.14.0', + sound_definitions: {}, + }, + true + ) + } +} diff --git a/src/libs/project/create/files/configurable/resourcePack/Sounds.ts b/src/libs/project/create/files/configurable/resourcePack/Sounds.ts new file mode 100644 index 000000000..9595f6629 --- /dev/null +++ b/src/libs/project/create/files/configurable/resourcePack/Sounds.ts @@ -0,0 +1,17 @@ +import { CreateProjectConfig } from '../../../../CreateProjectConfig' +import { ConfigurableFile } from '../ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class SoundsFile extends ConfigurableFile { + public readonly id: string = 'sounds' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + await fileSystem.writeFileJson(join(packPath, 'sounds.json'), {}, true) + } +} diff --git a/src/libs/project/create/files/configurable/resourcePack/TerrainTexture.ts b/src/libs/project/create/files/configurable/resourcePack/TerrainTexture.ts new file mode 100644 index 000000000..0352d4a15 --- /dev/null +++ b/src/libs/project/create/files/configurable/resourcePack/TerrainTexture.ts @@ -0,0 +1,30 @@ +import { CreateProjectConfig } from '../../../../CreateProjectConfig' +import { ConfigurableFile } from '../ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class TerrainTextureFile extends ConfigurableFile { + public readonly id: string = 'terrainTexture' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + if (!(await fileSystem.exists(join(packPath, 'textures')))) + await fileSystem.makeDirectory(join(packPath, 'textures')) + + await fileSystem.writeFileJson( + join(packPath, 'textures/terrain_texture.json'), + { + num_mip_levels: 4, + padding: 8, + resource_pack_name: config.name, + texture_name: 'atlas.terrain', + texture_data: {}, + }, + true + ) + } +} diff --git a/src/libs/project/create/files/configurable/skinPack/Skins.ts b/src/libs/project/create/files/configurable/skinPack/Skins.ts new file mode 100644 index 000000000..4275b1ed7 --- /dev/null +++ b/src/libs/project/create/files/configurable/skinPack/Skins.ts @@ -0,0 +1,25 @@ +import { CreateProjectConfig } from '../../../../CreateProjectConfig' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { join } from 'pathe' + +export class SkinsFile { + public readonly id: string = 'skins' + + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + await fileSystem.writeFileJson( + join(packPath, 'skins.json'), + { + geometry: 'skinpacks/skins.json', + skins: [], + serialize_name: config.namespace, + localization_name: config.namespace, + }, + true + ) + } +} diff --git a/src/libs/project/create/packs/BehaviorPack.ts b/src/libs/project/create/packs/BehaviorPack.ts new file mode 100644 index 000000000..76dbbb4da --- /dev/null +++ b/src/libs/project/create/packs/BehaviorPack.ts @@ -0,0 +1,33 @@ +import { join } from 'pathe' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { createManifest } from '../files/Manifest' +import { createIcon } from '../files/Icon' +import { CreateProjectConfig } from '../../CreateProjectConfig' +import { Pack } from './Pack' +import { PlayerFile } from '../files/configurable/behaviorPack/Player' +import { TickFile } from '../files/configurable/behaviorPack/Tick' +import { createLang } from '../files/Lang' + +export class BehaviourPack extends Pack { + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + await fileSystem.makeDirectory(packPath) + + await createManifest(fileSystem, join(projectPath, 'BP/manifest.json'), config, 'behaviorPack') + await createIcon(fileSystem, join(projectPath, 'BP/pack_icon.png'), config.icon) + + await createLang(fileSystem, packPath, config) + + for (const file of this.configurableFiles) { + if (!config.configurableFiles.includes(file.id)) continue + + await file.create(fileSystem, projectPath, config, packPath) + } + } + + public readonly configurableFiles = [new PlayerFile(), new TickFile()] +} diff --git a/src/libs/project/create/packs/Bridge.ts b/src/libs/project/create/packs/Bridge.ts new file mode 100644 index 000000000..46c275818 --- /dev/null +++ b/src/libs/project/create/packs/Bridge.ts @@ -0,0 +1,28 @@ +import { join } from 'pathe' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { Pack } from './Pack' +import { CreateProjectConfig } from '../../CreateProjectConfig' +import { createConfig } from '../files/Config' +import { createDenoConfig } from '../files/DenoConfig' +import { createGitIgnore } from '../files/GitIgnore' + +export class BridgePack extends Pack { + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) { + await fileSystem.makeDirectory(packPath) + + await fileSystem.makeDirectory(join(packPath, 'compiler')) + + await fileSystem.makeDirectory(join(packPath, 'extensions')) + + await createConfig(fileSystem, join(projectPath, 'config.json'), config) + + await createDenoConfig(fileSystem, join(projectPath, 'deno.json')) + + await createGitIgnore(fileSystem, join(projectPath, '.gitignore')) + } +} diff --git a/src/libs/project/create/packs/Pack.ts b/src/libs/project/create/packs/Pack.ts new file mode 100644 index 000000000..12eb3282c --- /dev/null +++ b/src/libs/project/create/packs/Pack.ts @@ -0,0 +1,14 @@ +import { CreateProjectConfig } from '../../CreateProjectConfig' +import { ConfigurableFile } from '../files/configurable/ConfigurableFile' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' + +export class Pack { + public async create( + fileSystem: BaseFileSystem, + projectPath: string, + config: CreateProjectConfig, + packPath: string + ) {} + + public readonly configurableFiles: ConfigurableFile[] = [] +} diff --git a/src/libs/project/create/packs/ResourcePack.ts b/src/libs/project/create/packs/ResourcePack.ts new file mode 100644 index 000000000..18c2f4709 --- /dev/null +++ b/src/libs/project/create/packs/ResourcePack.ts @@ -0,0 +1,42 @@ +import { join } from 'pathe' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { createManifest } from '../files/Manifest' +import { createIcon } from '../files/Icon' +import { CreateProjectConfig } from '../../CreateProjectConfig' +import { Pack } from './Pack' +import { BiomesClientFile } from '../files/configurable/resourcePack/BiomesClient' +import { BlocksFile } from '../files/configurable/resourcePack/Blocks' +import { FlipbookTexturesFile } from '../files/configurable/resourcePack/FlipbookTextures' +import { ItemTextureFile } from '../files/configurable/resourcePack/ItemTexture' +import { SoundDefinitionsFile } from '../files/configurable/resourcePack/SoundDefinitions' +import { SoundsFile } from '../files/configurable/resourcePack/Sounds' +import { TerrainTextureFile } from '../files/configurable/resourcePack/TerrainTexture' +import { createLang } from '../files/Lang' + +export class ResourcePack extends Pack { + async create(fileSystem: BaseFileSystem, projectPath: string, config: CreateProjectConfig, packPath: string) { + await fileSystem.makeDirectory(packPath) + + await createManifest(fileSystem, join(projectPath, 'RP/manifest.json'), config, 'resourcePack') + + await createIcon(fileSystem, join(projectPath, 'RP/pack_icon.png'), config.icon) + + await createLang(fileSystem, packPath, config) + + for (const file of this.configurableFiles) { + if (!config.configurableFiles.includes(file.id)) continue + + await file.create(fileSystem, projectPath, config, packPath) + } + } + + public readonly configurableFiles = [ + new BiomesClientFile(), + new BlocksFile(), + new FlipbookTexturesFile(), + new ItemTextureFile(), + new SoundDefinitionsFile(), + new SoundsFile(), + new TerrainTextureFile(), + ] +} diff --git a/src/libs/project/create/packs/SkinPack.ts b/src/libs/project/create/packs/SkinPack.ts new file mode 100644 index 000000000..7fd9a86f8 --- /dev/null +++ b/src/libs/project/create/packs/SkinPack.ts @@ -0,0 +1,28 @@ +import { join } from 'pathe' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import { createManifest } from '../files/Manifest' +import { createIcon } from '../files/Icon' +import { CreateProjectConfig } from '../../CreateProjectConfig' +import { Pack } from './Pack' +import { SkinsFile } from '../files/configurable/skinPack/Skins' +import { createLang } from '../files/Lang' + +export class SkinPack extends Pack { + async create(fileSystem: BaseFileSystem, projectPath: string, config: CreateProjectConfig, pathPack: string) { + await fileSystem.makeDirectory(pathPack) + + await createManifest(fileSystem, join(projectPath, 'SP/manifest.json'), config, 'skinPack') + + await createIcon(fileSystem, join(projectPath, 'SP/pack_icon.png'), config.icon) + + await createLang(fileSystem, pathPack, config) + + for (const file of this.configurableFiles) { + if (!config.configurableFiles.includes(file.id)) continue + + await file.create(fileSystem, projectPath, config, pathPack) + } + } + + public readonly configurableFiles = [new SkinsFile()] +} diff --git a/src/libs/runtime/Runtime.ts b/src/libs/runtime/Runtime.ts new file mode 100644 index 000000000..e30c7a27b --- /dev/null +++ b/src/libs/runtime/Runtime.ts @@ -0,0 +1,31 @@ +import { Runtime as BridgeRuntime, initRuntimes } from '@bridge-editor/js-runtime' +import { basename } from 'pathe' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' +import wasmUrl from '@swc/wasm-web/wasm-web_bg.wasm?url' +import { TBaseModule } from '@bridge-editor/js-runtime/dist/Runtime' + +export class Runtime extends BridgeRuntime { + constructor(public fileSystem: BaseFileSystem, modules?: [string, TBaseModule][]) { + initRuntimes(wasmUrl) + + super(modules) + } + + async readFile(filePath: string): Promise { + const file = await this.fileSystem.readFile(filePath) + + // @ts-ignore + return { + name: basename(filePath), + type: 'unkown', + size: file.byteLength, + lastModified: Date.now(), + webkitRelativePath: filePath, + + arrayBuffer: () => Promise.resolve(file), + slice: () => new Blob(), + stream: () => new ReadableStream(), + text: async () => await new TextDecoder().decode(file), + } + } +} diff --git a/src/libs/settings/Settings.ts b/src/libs/settings/Settings.ts new file mode 100644 index 000000000..0e790be39 --- /dev/null +++ b/src/libs/settings/Settings.ts @@ -0,0 +1,134 @@ +import { Ref, ShallowRef, onMounted, onUnmounted, ref, shallowRef } from 'vue' +import { get, set } from 'idb-keyval' +import { Event } from '@/libs/event/Event' +import { Disposable } from '@/libs/disposeable/Disposeable' + +interface Setting { + default: T + load?: (value: any) => Promise + save?: (value: T) => Promise +} + +export class Settings { + public static settings: Record = {} + public static updated: Event<{ id: string; value: any }> = new Event() + public static definitions: Record> = {} + + public static loadedSettings: Record = {} + + public static async load() { + try { + Settings.loadedSettings = JSON.parse((await get('settings')) as string) + } catch {} + + for (const id of Object.keys(Settings.definitions)) { + await Settings.updateSetting(id) + } + + for (const [id, value] of Object.entries(Settings.settings)) { + Settings.updated.dispatch({ id, value }) + } + } + + public static async addSetting(id: string, setting: Setting) { + Settings.definitions[id] = setting + + await this.updateSetting(id) + } + + public static removeDefinition(id: string) { + delete Settings.definitions[id] + + if (Settings.settings[id] !== undefined) delete Settings.settings[id] + } + + public static get(id: string): T { + return Settings.settings[id] + } + + public static async set(id: string, value: any) { + Settings.settings[id] = value + + const saveSettings: Record = {} + + for (const id of Object.keys(Settings.definitions)) { + const definition = Settings.definitions[id] + + if (!definition) return + + if (definition.save) { + saveSettings[id] = await definition.save(Settings.settings[id]) + + continue + } + + saveSettings[id] = Settings.settings[id] + } + + await set('settings', JSON.stringify(saveSettings)) + + Settings.updated.dispatch({ id, value }) + } + + private static async updateSetting(id: string) { + const definition = Settings.definitions[id] + + if (definition.load) { + Settings.settings[id] = await definition.load(Settings.loadedSettings[id]) + + return + } + + if (Settings.loadedSettings[id] === undefined) { + Settings.settings[id] = definition.default + + return + } + + Settings.settings[id] = Settings.loadedSettings[id] + } + + public static useGet(): Ref<(id: string) => any> { + const get: Ref<(id: string) => any> = ref(Settings.get) + + function updateSettings() { + //@ts-ignore this value in't acutally read by any code, it just triggers an update + get.value = null + get.value = Settings.get + } + + let disposable: Disposable + + onMounted(() => { + disposable = Settings.updated.on(updateSettings) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return get + } +} + +export function useSettings(): Ref { + const currentSettings: ShallowRef = shallowRef(Settings) + + function updateSettings() { + //@ts-ignore this value in't acutally read by any code, it just triggers an update + currentSettings.value = null + currentSettings.value = Settings + } + + let disposable: Disposable + + onMounted(() => { + disposable = Settings.updated.on(updateSettings) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return currentSettings +} diff --git a/src/libs/settings/SetupSettings.ts b/src/libs/settings/SetupSettings.ts new file mode 100644 index 000000000..cb8c76bb9 --- /dev/null +++ b/src/libs/settings/SetupSettings.ts @@ -0,0 +1,37 @@ +import { fileSystem } from '@/libs/fileSystem/FileSystem' +import { LocalFileSystem } from '@/libs/fileSystem/LocalFileSystem' +import { Settings } from './Settings' + +export function setupGeneralSettings() { + Settings.addSetting('restoreTabs', { + default: true, + }) +} + +export function setupProjectsSettings() { + Settings.addSetting('incrementVersionOnExport', { + default: false || fileSystem instanceof LocalFileSystem, + }) + + Settings.addSetting('addGeneratedWith', { + default: true, + }) +} + +export function setupEditorSettings() { + Settings.addSetting('jsonEditor', { + default: 'text', + }) + + Settings.addSetting('formatOnSave', { + default: true, + }) + + Settings.addSetting('keepTabsOpen', { + default: false, + }) + + Settings.addSetting('autoSaveChanges', { + default: false, + }) +} diff --git a/src/libs/snippets/Snippet.ts b/src/libs/snippets/Snippet.ts new file mode 100644 index 000000000..7ebb300d9 --- /dev/null +++ b/src/libs/snippets/Snippet.ts @@ -0,0 +1,61 @@ +import { isMatch, compareVersions } from 'bridge-common-utils' + +export interface SnippetData { + name: string + targetFormatVersion?: { + min?: string + max?: string + } + description?: string + fileTypes: string[] + locations?: string[] + data: unknown +} + +export class Snippet { + public name: string + public description: string | undefined + + protected fileTypes: Set + protected locations: string[] + protected data: unknown + protected minTargetFormatVersion?: string + protected maxTargetFormatVersion?: string + + constructor({ name, description, fileTypes, locations, data, targetFormatVersion }: SnippetData) { + this.name = name + this.description = description + this.fileTypes = new Set(fileTypes) + this.locations = locations ?? [] + this.data = data + this.minTargetFormatVersion = targetFormatVersion?.min + this.maxTargetFormatVersion = targetFormatVersion?.max + } + + public getInsertText() { + if (typeof this.data === 'string') return this.data + else if (Array.isArray(this.data)) return this.data.join('\n') + + return JSON.stringify(this.data, null, '\t').slice(1, -1).replaceAll('\n\t', '\n').trim() + } + + public isValid(formatVersion: unknown, fileType: string, locations: string[]) { + const formatVersionValid = + typeof formatVersion !== 'string' || + ((!this.minTargetFormatVersion || compareVersions(formatVersion, this.minTargetFormatVersion, '>=')) && + (!this.maxTargetFormatVersion || compareVersions(formatVersion, this.maxTargetFormatVersion, '<='))) + + return ( + formatVersionValid && + this.fileTypes.has(fileType) && + (this.locations.length === 0 || + this.locations.some((locPattern) => + locations.some( + (locationInFile) => + locPattern === locationInFile || + (locPattern !== '' ? isMatch(locationInFile, locPattern) : false) + ) + )) + ) + } +} diff --git a/src/libs/snippets/SnippetManager.ts b/src/libs/snippets/SnippetManager.ts new file mode 100644 index 000000000..5bc7cf669 --- /dev/null +++ b/src/libs/snippets/SnippetManager.ts @@ -0,0 +1,8 @@ +import { Extensions } from '@/libs/extensions/Extensions' +import { Snippet } from './Snippet' + +export class SnippetManager { + public getSnippets(formatVersion: string, fileType: string, locations: string[]): Snippet[] { + return Extensions.snippets.filter((snippet) => snippet.isValid(formatVersion, fileType, locations)) + } +} diff --git a/src/libs/tauri/Tauri.ts b/src/libs/tauri/Tauri.ts new file mode 100644 index 000000000..e01d5fcc4 --- /dev/null +++ b/src/libs/tauri/Tauri.ts @@ -0,0 +1 @@ +export const tauriBuild: boolean = (window as any).__TAURI__ !== undefined diff --git a/src/libs/tauri/Updater.ts b/src/libs/tauri/Updater.ts new file mode 100644 index 000000000..ad8300259 --- /dev/null +++ b/src/libs/tauri/Updater.ts @@ -0,0 +1,25 @@ +import { checkUpdate, installUpdate } from '@tauri-apps/api/updater' +import { NotificationSystem } from '@/components/Notifications/NotificationSystem' + +async function installTauriUpdate() { + // Task to indicate background progress + // TODO: Make tasks with undetermined time + NotificationSystem.addProgressNotification('upgrade', 0, 100, undefined) + + // Install the update. This relaunches the application + await installUpdate() +} + +checkUpdate() + .then(async (update) => { + if (!update.shouldUpdate) return + + NotificationSystem.addNotification('upgrade', async () => { + await installTauriUpdate() + }) + }) + .catch((err: any) => { + console.error(`[TauriUpdater] ${err}`) + + return null + }) diff --git a/src/components/Extensions/Themes/DefaultTheme/ColorCodes.ts b/src/libs/theme/ColorCodes.ts similarity index 100% rename from src/components/Extensions/Themes/DefaultTheme/ColorCodes.ts rename to src/libs/theme/ColorCodes.ts diff --git a/src/components/Extensions/Themes/Default.ts b/src/libs/theme/DefaultThemes.ts similarity index 60% rename from src/components/Extensions/Themes/Default.ts rename to src/libs/theme/DefaultThemes.ts index 9805a61ab..bf279f569 100644 --- a/src/components/Extensions/Themes/Default.ts +++ b/src/libs/theme/DefaultThemes.ts @@ -1,40 +1,38 @@ -import { colorCodes } from './DefaultTheme/ColorCodes' -import { isNightly } from '/@/utils/app/isNightly' +import { colorCodes } from './ColorCodes' -export const bridgeDark = { +export const dark = { id: 'bridge.default.dark', name: 'Default Dark', colorScheme: 'dark', colors: { - text: '#ffffff', + primary: '#0073FF', + accent: '#ffffff', + accentSecondary: '#121212', - primary: isNightly ? '#3bb6a3' : '#0073FF', - secondary: isNightly ? '#3bb6a3' : '#0073FF', - accent: isNightly ? '#3bb6a3' : '#0073FF', error: '#ff5252', info: '#2196f3', warning: '#fb8c00', success: '#4caf50', + text: '#ffffff', + textSecondary: '#f2f2f2AA', + background: '#121212', - sidebarNavigation: '#1F1F1F', - expandedSidebar: '#1F1F1F', - sidebarSelection: '#151515', - menu: '#252525', - footer: '#111111', - tooltip: '#1F1F1F', - toolbar: '#1F1F1F', - tabActive: '#121212', - tabInactive: '#1F1F1F', - lineHighlightBackground: '#292929', - scrollbarThumb: '#000000', + backgroundSecondary: '#1F1F1F', + backgroundTertiary: '#373737', behaviorPack: '#ff5252', resourcePack: '#0073FF', skinPack: '#fb8c00', worldTemplate: '#4caf50', + + toolbar: '#1F1F1F', + lineHighlightBackground: '#222222', }, highlighter: { + invalid: { + color: '#ff00ff', + }, type: { color: '#a6e22e', }, @@ -67,40 +65,34 @@ export const bridgeDark = { }, ...colorCodes('#fff'), }, -} +} as const -export const bridgeLight = { +export const light = { id: 'bridge.default.light', name: 'Default Light', colorScheme: 'light', colors: { - text: '#000000', - - primary: isNightly ? '#3bb6a3' : '#0073FF', - secondary: isNightly ? '#3bb6a3' : '#0073FF', - accent: isNightly ? '#3bb6a3' : '#0073FF', + primary: '#0073FF', + secondary: '#0073FF', error: '#ff5252', info: '#2196f3', warning: '#fb8c00', success: '#4caf50', + accent: '#ffffff', + text: '#000000', + textSecondary: '#000000AA', background: '#fafafa', - sidebarNavigation: '#e8e8e8', - expandedSidebar: '#e8e8e8', - sidebarSelection: '#FFFFFF', - menu: '#FFFFFF', - tooltip: '#424242', - toolbar: '#e8e8e8', - footer: '#f5f5f5', - tabActive: '#fafafa', - tabInactive: '#e0e0e0', - lineHighlightBackground: '#e0e0e0', - scrollbarThumb: '#c8c8c8', + backgroundSecondary: '#e8e8e8', + backgroundTertiary: '#e0e0e0', behaviorPack: '#ff5252', resourcePack: '#0073FF', skinPack: '#fb8c00', worldTemplate: '#4caf50', + + toolbar: '#fafafa', + lineHighlightBackground: '#e0e0e0', }, highlighter: { type: { @@ -133,6 +125,6 @@ export const bridgeLight = { comment: { color: '#0080FF', }, - ...colorCodes('#000'), + ...colorCodes('#fff'), }, -} +} as const diff --git a/src/libs/theme/Theme.ts b/src/libs/theme/Theme.ts new file mode 100644 index 000000000..0c33cd312 --- /dev/null +++ b/src/libs/theme/Theme.ts @@ -0,0 +1,43 @@ +export const colorNames = [ + 'primary', + + 'accent', + 'accentSecondary', + + 'error', + 'info', + 'warning', + 'success', + + 'text', + 'textSecondary', + + 'background', + 'backgroundSecondary', + 'backgroundTertiary', + + 'behaviorPack', + 'resourcePack', + 'worldTemplate', + 'skinPack', + + 'toolbar', + 'lineHighlightBackground', +] + +export interface Theme { + id: string + name: string + colorScheme?: 'dark' | 'light' + colors: Record<(typeof colorNames)[number], string> + highlighter?: Record< + string, + { + color?: string + background?: string + textDecoration?: string + isItalic?: boolean + } + > + monaco?: Record +} diff --git a/src/libs/theme/ThemeManager.ts b/src/libs/theme/ThemeManager.ts new file mode 100644 index 000000000..e555d17df --- /dev/null +++ b/src/libs/theme/ThemeManager.ts @@ -0,0 +1,204 @@ +import { Theme, colorNames } from './Theme' +import { dark, light } from './DefaultThemes' +import { onMounted, onUnmounted, ref } from 'vue' +import { Settings } from '@/libs/settings/Settings' +import { get, set } from 'idb-keyval' +import { Event } from '@/libs/event/Event' +import { Disposable } from '@/libs/disposeable/Disposeable' +import { Extensions } from '@/libs/extensions/Extensions' + +export enum ThemeSettings { + ColorScheme = 'colorScheme', + DarkTheme = 'darkTheme', + LightTheme = 'lightTheme', + Font = 'font', + EditorFont = 'editorFont', + EditorFontSize = 'editorFontSize', +} + +export class ThemeManager { + public static themes: Theme[] = [] + public static currentTheme: string = dark.id + public static themesUpdated: Event = new Event() + public static themeChanged: Event = new Event() + + private static previouslyUsedTheme: Theme = this.prefersDarkMode() ? dark : light + private static lastTheme: Theme | null = null + + public static setup() { + Settings.addSetting(ThemeSettings.ColorScheme, { + default: 'auto', + }) + + Settings.addSetting(ThemeSettings.DarkTheme, { + default: 'bridge.default.dark', + }) + + Settings.addSetting(ThemeSettings.LightTheme, { + default: 'bridge.default.light', + }) + + Settings.addSetting(ThemeSettings.Font, { + default: 'Inter', + }) + + Settings.addSetting(ThemeSettings.EditorFont, { + default: 'Consolas', + }) + + Settings.addSetting(ThemeSettings.EditorFontSize, { + default: 14, + }) + + Settings.updated.on((event) => { + const { id, value } = event as { id: string; value: any } + + if ( + !([ + ThemeSettings.ColorScheme, + ThemeSettings.DarkTheme, + ThemeSettings.LightTheme, + ThemeSettings.Font, + ThemeSettings.EditorFont, + ThemeSettings.EditorFontSize, + ]).includes(id) + ) + return + + const colorScheme = Settings.get(ThemeSettings.ColorScheme) + + let themeId = Settings.get(ThemeSettings.DarkTheme) + + if (colorScheme === 'light' || (colorScheme === 'auto' && !ThemeManager.prefersDarkMode())) themeId = Settings.get(ThemeSettings.LightTheme) + + ThemeManager.applyTheme(themeId as string) + }) + + Extensions.updated.on((event) => { + this.reloadThemes() + }) + } + + public static async load() { + try { + this.previouslyUsedTheme = JSON.parse((await get('lastUsedTheme')) as string) + } catch {} + + this.reloadThemes() + this.applyTheme(this.previouslyUsedTheme.id) + } + + private static addTheme(theme: Theme) { + const duplicateIndex = this.themes.findIndex((otherTheme) => otherTheme.id === theme.id) + + if (duplicateIndex !== -1) { + this.themes[duplicateIndex] = theme + } else { + this.themes.push(theme) + } + + this.themesUpdated.dispatch() + } + + private static reloadThemes() { + this.themes = [] + + this.addTheme(dark) + this.addTheme(light) + + for (const theme of Extensions.themes) { + this.addTheme(theme) + } + + if (Extensions.loaded) { + if (!this.hasTheme(Settings.get('darkTheme'))) Settings.set('darkTheme', dark.id) + if (!this.hasTheme(Settings.get('lightTheme'))) Settings.set('lightTheme', light.id) + } else { + this.addTheme(this.previouslyUsedTheme) + } + + if (this.hasTheme(this.currentTheme)) { + this.applyTheme(this.currentTheme) + } else { + this.applyTheme(this.prefersDarkMode() ? dark.id : light.id) + } + } + + private static applyTheme(themeId: string) { + const theme = this.themes.find((theme) => theme.id === themeId) + + if (!theme) return + + if (this.lastTheme === theme) return + + this.currentTheme = themeId + + const root = document.querySelector(':root') + + for (const name of colorNames) { + root.style.setProperty(`--theme-color-${name}`, theme.colors[name]) + } + + root.style.setProperty('--theme-font', Settings.get(ThemeSettings.Font)) + root.style.setProperty('--theme-font-editor', Settings.get(ThemeSettings.EditorFont)) + root.style.setProperty('--theme-font-size-editor', Settings.get(ThemeSettings.EditorFontSize) + 'px') + + set('lastUsedTheme', JSON.stringify(theme)) + this.previouslyUsedTheme = theme + + this.lastTheme = theme + + this.themeChanged.dispatch() + } + + public static get(themeId: string): Theme { + return { + ...dark, + ...this.themes.find((theme) => theme.id === themeId), + } + } + + public static hasTheme(themeId: string): boolean { + return this.themes.find((theme) => theme.id === themeId) !== undefined + } + + public static useThemes() { + const themes = ref(this.themes) + + const me = this + + function update() { + themes.value = [...me.themes] + } + + let disposable: Disposable + + onMounted(() => { + disposable = ThemeManager.themesUpdated.on(update) + }) + + onUnmounted(() => { + disposable.dispose() + }) + + return themes + } + + public static useThemesImmediate() { + const themes = ref(this.themes) + + const me = this + + function update() { + themes.value = [...me.themes] + } + + ThemeManager.themesUpdated.on(update) + + return themes + } + + public static prefersDarkMode(): boolean { + return window.matchMedia('(prefers-color-scheme: dark)').matches + } +} diff --git a/src/libs/worker/Communication.ts b/src/libs/worker/Communication.ts new file mode 100644 index 000000000..785294542 --- /dev/null +++ b/src/libs/worker/Communication.ts @@ -0,0 +1,78 @@ +import { v4 as uuid } from 'uuid' + +/** + * Sends a message to a web worker and waits untill the web worker responds with the same message id + * @param message An object of message data + * @param worker The web worker to send the message to + * @param transfer An optional list of items to be transfered to the web worker + * @returns The response data of the worker + */ +export async function sendAndWait( + message: { + [key: string]: any + }, + worker?: Worker, + transfer?: Transferable[] +): Promise { + let functionToUnbind: any = null + + const response = await new Promise((resolve) => { + let messageId = uuid() + + function recieveMessage(event: MessageEvent) { + if (!event.data) return + if (event.data.id !== messageId) return + + resolve(event.data) + } + + functionToUnbind = recieveMessage + + if (worker) { + worker.addEventListener('message', functionToUnbind) + } else { + addEventListener('message', functionToUnbind) + } + + if (worker) { + if (transfer) { + worker.postMessage( + { + ...message, + id: messageId, + }, + transfer + ) + } else { + worker.postMessage({ + ...message, + id: messageId, + }) + } + } else { + if (transfer) { + postMessage( + { + ...message, + id: messageId, + }, + '/', + transfer + ) + } else { + postMessage({ + ...message, + id: messageId, + }) + } + } + }) + + if (worker) { + worker.removeEventListener('message', functionToUnbind) + } else { + removeEventListener('message', functionToUnbind) + } + + return response +} diff --git a/src/libs/zip/StreamingUnzipper.ts b/src/libs/zip/StreamingUnzipper.ts new file mode 100644 index 000000000..93de6310f --- /dev/null +++ b/src/libs/zip/StreamingUnzipper.ts @@ -0,0 +1,59 @@ +import { NotificationSystem, Notification } from '@/components/Notifications/NotificationSystem' +import { AsyncUnzipInflate, Unzip, UnzipFile } from 'fflate' + +export async function streamingUnzip( + data: Uint8Array, + callback: (file: UnzipFile) => Promise, + task?: Notification +) { + let unzippedBytes = 0 + let totalFiles = 0 + let currentFiles = 0 + + await new Promise(async (resolve) => { + const unzip = new Unzip(async (file) => { + totalFiles++ + + if (!file.name.endsWith('/')) await callback(file) + + unzippedBytes += file.size ?? 0 + + if (task) NotificationSystem.setProgress(task, unzippedBytes) + + currentFiles++ + + // I think that this is not compeltely safe, but it is the ONLY way I can find to test if the streaming unzipper is finished. + if (currentFiles === totalFiles) { + if (task) NotificationSystem.clearNotification(task) + + resolve() + } + }) + + unzip.register(AsyncUnzipInflate) + + const chunks = getChunks(data) + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + unzip.push(chunks[chunkIndex], chunkIndex == chunks.length - 1) + + // We throttle about how many files should be unzipped / handled at a time because on some computers it creates too many threads which really slows things down + while (totalFiles - currentFiles >= 15) { + await new Promise((resolve) => { + setTimeout(resolve, 10) + }) + } + } + }) +} + +function getChunks(data: Uint8Array): Uint8Array[] { + const chunks: Uint8Array[] = [] + + const chunkSize = 64 * 1000 // 64kb + + for (let startByte = 0; startByte < data.length; startByte += chunkSize) { + chunks.push(data.slice(startByte, Math.min(data.length, startByte + chunkSize))) + } + + return chunks +} diff --git a/src/libs/zip/ZipDirectory.ts b/src/libs/zip/ZipDirectory.ts new file mode 100644 index 000000000..8a8bc6315 --- /dev/null +++ b/src/libs/zip/ZipDirectory.ts @@ -0,0 +1,48 @@ +import { zip, Zippable } from 'fflate' +import { iterateDirectoryParrallel } from '@/libs/fileSystem/FileSystem' +import { BaseFileSystem } from '@/libs/fileSystem/BaseFileSystem' + +export async function zipDirectory( + fileSystem: BaseFileSystem, + path: string, + ignoreFolders?: Set +): Promise { + // if (import.meta.env.VITE_IS_TAURI_APP) { + // const files: { [key: string]: number[] } = {} + + // await iterateDirParallel( + // this.handle, + // async (fileHandle, filePath) => { + // const file = await fileHandle.getFile() + // files[filePath] = Array.from( + // new Uint8Array(await file.arrayBuffer()) + // ) + // }, + // ignoreFolders + // ) + + // return new Uint8Array(await invoke('zip_command', { files })) + // } + + let directoryContents: Zippable = {} + await iterateDirectoryParrallel( + fileSystem, + path, + async (entry) => { + // remove the trailing slash + directoryContents[entry.path.slice(path.length + 1)] = new Uint8Array(await fileSystem.readFile(entry.path)) + }, + ignoreFolders + ) + + return new Promise((resolve, reject) => + zip(directoryContents, { level: 6 }, (error, data) => { + if (error) { + reject(error) + return + } + + resolve(data) + }) + ) +} diff --git a/src/locales/de.json b/src/locales/de.json index 232989131..863301b77 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -294,7 +294,7 @@ "title": "Projekte Ordner", "content": "bridge. benötigt Zugriff auf deinen Projekteordner." }, - "extensionStore": { + "extensionLibrary": { "title": "Erweiterungen", "searchExtensions": "Suche Erweiterungen..." }, diff --git a/src/locales/en.json b/src/locales/en.json index 2083bb6dd..62fec4ae9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -103,8 +103,8 @@ "clientManifest": "Client Manifest", "skinManifest": "Skin Manifest", "geometry": "Geometry", - "customCommand": "Command", - "customComponent": "Component", + "customCommand": "Dash Command", + "customComponent": "Dash Component", "clientAnimation": "Client Animation", "clientAnimationController": "Client Animation Controller", "attachable": "Attachable", @@ -137,291 +137,351 @@ }, "actions": { "name": "Actions", - "viewBridgeFolder": { - "name": "View bridge. Folder", - "description": "View the folder where bridge. stores your projects" - }, - "about": { - "name": "About", - "description": "About bridge." - }, - "launchMinecraft": { - "name": "Launch Minecraft", - "description": "Launch Minecraft to test your projects" - }, - "download": { - "name": "Download", - "description": "Download this file or folder" - }, - "fullscreen": { - "name": "Fullscreen", - "description": "Toggle fullscreen mode" - }, - "viewConnectedFiles": { - "name": "View Connected Files", - "description": "View all files connected to this file" - }, - "viewExtensionsFolder": { - "name": "View Extensions Folder", - "description": "View the folder where bridge. stores global extensions" - }, - "open": { - "name": "Open", - "description": "Open the file in the editor" - }, - "openWith": { - "name": "Open With", - "description": "Open the file in the editor" - }, - "openInSplitScreen": { - "name": "Open in Split Screen", - "description": "Open the file in split screen mode" - }, - "edit": { - "name": "Edit", - "description": "Edit the file or folder" - }, - "delete": { - "name": "Delete", - "description": "Delete a file or folder", - "confirmText": "Are you sure that you want to delete ", - "noRestoring": "You won't be able to restore it later!" - }, - "rename": { - "name": "Rename", - "description": "Rename a file", - "sameName": "Your new file name only differs in capitalization. This is not allowed on Windows." - }, - "duplicate": { - "name": "Duplicate", - "description": "Duplicate a file" - }, - "viewCompilerOutput": { - "name": "Compiler Output", - "view": "View Compiler Output", - "description": "View the current compiler output for this file", - "fileMissing": "It does not look like this file was compiled yet." - }, - "revealPath": { - "name": "Reveal Path", - "description": "Reveals the location of a file or folder" - }, - "revealInFileExplorer": { - "name": "Reveal in File Explorer", - "description": "Reveals the location of a file or folder in the file explorer" - }, - "createFile": { - "name": "Create File", - "description": "Create a new file" - }, - "importFile": { - "name": "Import File" - }, - "createFolder": { - "name": "Create Folder", - "description": "Create a new folder" - }, - "findInFolder": { - "name": "Find in Folder", - "description": "Search the contents of a folder" - }, - "goHome": { - "name": "Go Home", - "description": "Go back to the home screen" - }, - "newProject": { - "name": "New Project", - "description": "Create a new bridge. project" - }, - "newFile": { - "name": "New File", - "description": "Create a new Add-On feature" - }, - "openFile": { - "name": "Open File", - "description": "Open a file to edit it with bridge." - }, - "openFolder": { - "name": "Open Folder", - "description": "Open a folder to edit it, set it as an output folder or trigger other actions" - }, - "searchFile": { - "name": "Search File", - "description": "Search and open a file from the current project" - }, - "saveFile": { - "name": "Save File", - "description": "Save the currently opened file" - }, - "saveAs": { - "name": "Save As", - "description": "Save the currently opened file under a different name" - }, - "saveAll": { - "name": "Save All", - "description": "Save all currently opened files" - }, - "closeFile": { - "name": "Close File", - "description": "Close the currently opened file" - }, - "settings": { - "name": "Settings", - "description": "Open bridge.'s app settings" - }, - "extensions": { - "name": "Extensions", - "description": "Install and manage your installed extensions" - }, - "copy": { - "name": "Copy", - "description": "Copy selected text to the clipboard" - }, - "cut": { - "name": "Cut", - "description": "Copy selected text to the clipboard and remove it from the original context" - }, - "paste": { - "name": "Paste", - "description": "Paste clipboard content" - }, - "docs": { - "name": "Open bedrock.dev", - "description": "Opens the Minecraft Add-On documentation" - }, - "minecraftDocs": { - "name": "Open Minecraft Documentation", - "description": "Opens the Minecraft Bedrock Add-On documentation" - }, - "releases": { - "name": "Releases", - "description": "View the latest bridge. releases" - }, - "bugReports": { - "name": "Bug Reports", - "description": "Report an issue with bridge." - }, - "twitter": { - "name": "Twitter", - "description": "Follow bridge. on Twitter" - }, - "extensionAPI": { - "name": "Extension API", - "description": "Read more about bridge.'s extension API" - }, - "gettingStarted": { - "name": "Getting Started", - "description": "Read our guide on how to get started with bridge." - }, - "faq": { - "name": "FAQ", - "description": "Read through frequently asked questions about developing Add-Ons with bridge." - }, - "reloadAutoCompletions": { - "name": "Reload Auto-Completions", - "description": "Reloads all auto-completion data" - }, - "reloadExtensions": { - "name": "Reload Extensions", - "description": "Reloads all extensions" - }, - "moveToSplitScreen": { - "name": "Move to Split Screen", - "description": "Opens a split screen view and moves this tab to it" - }, - "closeTab": { - "name": "Close Tab", - "description": "Close this tab" - }, - "closeAll": { - "name": "Close All", - "description": "Close all tabs" - }, - "closeTabsToRight": { - "name": "Close Tabs to the Right", - "description": "Close all tabs to the right-hand side of this tab" - }, - "closeAllSaved": { - "name": "Close All Saved", - "description": "Close all saved tabs" - }, - "closeOtherTabs": { - "name": "Close other Tabs", - "description": "Close all tabs except this tab" - }, - "clearAllNotifications": { - "name": "Clear All Notifications", - "description": "Clears all current notifications" - }, - "pluginInstallLocation": { - "global": { - "name": "Install Globally", - "description": "Global extensions are accessible in all of your projects" + "rebinding": "Rebinding", + "unbound": "Unbound", + "more": "More", + "tabActions": "Tab Actions", + "unknown": { + "name": "Unkown Action", + "description": "This action is missing!" + }, + "misc": { + "name": "Misc" + }, + "editor": { + "name": "Editor", + "goHome": { + "name": "Go Home", + "description": "Close the active project and return home" + }, + "clearNotifications": { + "name": "Clear Notifications", + "description": "Clears all notifications in the sidebar" + }, + "settings": { + "name": "Settings", + "description": "Opens the settings window" + }, + "extensions": { + "name": "Extension Library", + "description": "Opens the extension library window" + }, + "importProject": { + "name": "Import Project", + "description": "Import a new project" + }, + "openFolder": { + "name": "Open Folder", + "description": "Open a folder" + }, + "reloadEditor": { + "name": "Reload Editor", + "description": "Completely reloads the editor" + }, + "nextTab": { + "name": "Next Tab", + "description": "Switch to the next tab" + }, + "previousTab": { + "name": "Previous Tab", + "description": "Switch to the previous tab" + }, + "closeTab": { + "name": "Close Tab", + "description": "Close the currently selected tab" + }, + "launchMinecraft": { + "name": "Launch Minecraft", + "description": "Launch Minecraft to test your projects" + }, + "revealBridgeFolder": { + "name": "Open bridge. Folder", + "description": "Opens the bridge. folder in your systems file explorer" + }, + "revealOutputFolder": { + "name": "Open Output Folder", + "description": "Opens the current output folder in your systems file explorer" }, - "local": { - "name": "Install Locally", - "description": "Local extensions are only accessible inside of the projects you add them to" + "revealExtensionsFolder": { + "name": "Open Extensions Folder", + "description": "Opens the bridge. extensions folder in your systems file explorer" } }, - "toObject": { - "name": "Transform to Object" - }, - "toArray": { - "name": "Transform to Array" - }, - "documentationLookup": { - "name": "View Documentation", - "noDocumentation": "No documentation available for" - }, - "toggleReadOnly": { - "name": "Toggle Read-Only Mode", - "description": "Toggle read-only mode for the currently opened file" - }, - "keepInTabSystem": { - "name": "Keep in Tab System", - "description": "Converts this tab to a permanent tab" - }, - "importBrproject": { - "name": "Import Project", - "description": "Import a project from a .brproject file" - }, - "downloadFile": { - "name": "Download File", - "description": "Download the currently opened file" - }, - "undo": { - "name": "Undo", - "description": "Undo the last action" - }, - "redo": { - "name": "Redo", - "description": "Redo the last action" + "files": { + "name": "Files", + "save": { + "name": "Save", + "description": "Save the current file" + }, + "saveAs": { + "name": "Save As", + "description": "Save the current file with a new name" + }, + "saveAll": { + "name": "Save All", + "description": "Save all unsaved files" + }, + "createFile": { + "name": "Create File", + "description": "Create a new file" + }, + "createFolder": { + "name": "Create Folder", + "description": "Create a new folder" + }, + "delete": { + "name": "Delete", + "description": "Delete a file or folder" + }, + "rename": { + "name": "Rename", + "description": "Rename a file or folder" + }, + "duplicate": { + "name": "Duplicate", + "description": "Duplicate a file or folder" + }, + "copy": { + "name": "Copy", + "description": "Copy a file or folder" + }, + "paste": { + "name": "Paste", + "description": "Paste a file or folder" + }, + "openToSide": { + "name": "Open To Side", + "description": "Opens the file in a side window" + }, + "revealInFileExplorer": { + "name": "Reveal in File Explorer", + "description": "Opens the file or folder in your systems file explorer" + } }, - "goToDefinition": { - "name": "Go to Definition", - "description": "Go to the definition of the selected symbol" + "textEditor": { + "name": "Text Editor", + "undo": { + "name": "Undo", + "description": "Undo the last action" + }, + "redo": { + "name": "Redo", + "description": "Redo the last action" + }, + "copy": { + "name": "Copy", + "description": "Copy selected text to the clipboard" + }, + "cut": { + "name": "Cut", + "description": "Copy selected text to the clipboard and delete it" + }, + "paste": { + "name": "Paste", + "description": "Paste clipboard content" + }, + "goToDefinition": { + "name": "Go to Definition", + "description": "Go to the definition of the selected symbol" + }, + "goToSymbol": { + "name": "Go to Symbol", + "description": "Opens a dialog to select a symbol to go to" + }, + "formatDocument": { + "name": "Format Document", + "description": "Format the currently opened document" + }, + "changeAllOccurrences": { + "name": "Change All Occurrences", + "description": "Change all occurrences of the selected text" + }, + "documentationLookup": { + "name": "View Documentation", + "description": "Opens relevant documentation" + } }, - "goToSymbol": { - "name": "Go to Symbol", - "description": "Opens a dialog to select a symbol to go to" + "treeEditor": { + "name": "Tree Editor", + "undo": { + "name": "Undo", + "description": "Undo the last action" + }, + "redo": { + "name": "Redo", + "description": "Redo the last action" + }, + "copy": { + "name": "Copy", + "description": "Copy selected text to the clipboard" + }, + "cut": { + "name": "Cut", + "description": "Copy selected text to the clipboard and delete it" + }, + "paste": { + "name": "Paste", + "description": "Paste clipboard content" + }, + "delete": { + "name": "Delete", + "description": "Delete selected text" + }, + "convertToObject": { + "name": "Convert To Object", + "description": "Convert the selected value to an object" + }, + "convertToArray": { + "name": "Convert To Array", + "description": "Convert the selected value to an array" + }, + "convertToNull": { + "name": "Convert To Null", + "description": "Convert the selected value to null" + }, + "convertToNumber": { + "name": "Convert To Number", + "description": "Convert the selected value to a number" + }, + "convertToString": { + "name": "Convert To String", + "description": "Convert the selected value to a string" + }, + "convertToBoolean": { + "name": "Convert To Boolean", + "description": "Convert the selected value to a boolean" + }, + "documentationLookup": { + "name": "View Documentation", + "description": "Opens relevant documentation" + } }, - "formatDocument": { - "name": "Format Document", - "description": "Format the currently opened document" + "export": { + "name": "Export", + "brproject": { + "name": "Export As bridge. Project", + "description": "Export the current project as a .brproject" + }, + "mcaddon": { + "name": "Export As Add-On", + "description": "Export the current project as a .mcaddon" + }, + "mcworld": { + "name": "Export As World", + "description": "Export the current project as a .mcworld" + }, + "mctemplate": { + "name": "Export As Template", + "description": "Export the current project as a .mctemplate" + } }, - "changeAllOccurrences": { - "name": "Change All Occurrences", - "description": "Change all occurrences of the selected text" + "dash": { + "name": "Dash", + "compileDefault": { + "name": "Default Config", + "description": "Run bridge.'s compiler with the default compiler configuration that is part of your project's \"config.json\" file." + } }, - "tgaMaskToggle": { - "name": "Show/Hide Alpha Mask" + "project": { + "name": "Project", + "reload": { + "name": "Reload Project", + "description": "Reloads the currently loaded project" + }, + "reloadExtensions": { + "name": "Reload Extensions", + "description": "Reloads all extensions" + }, + "toggleFileExplorer": { + "name": "Toggle File Explorer", + "description": "Toggles the file explorer open or closed" + }, + "newProject": { + "name": "New Project", + "description": "Opens the create project dialogue" + }, + "importFile": { + "name": "Import File", + "description": "Import a file in the project" + }, + "revealInFileExplorer": { + "name": "Open Project Folder", + "description": "Opens the project folder in your systems file explorer" + } }, - "recompileChanges": { - "name": "Compile Changes", - "description": "Compile all files that were edited without bridge. This will not compile any changes made in the editor itself after disabling watch mode" + "help": { + "name": "Help", + "download": { + "name": "Download", + "description": "Opens the guide to download bridge. native" + }, + "bedrockDevDocs": { + "name": "bedrock.dev Docs", + "description": "Opens the bedrock.dev docs in a new browser tab" + }, + "creatorDocs": { + "name": "Minecraft Creator Docs", + "description": "Opens the Minecraft creator docs in a new browser tab" + }, + "scriptingDocs": { + "name": "Scripting API Docs", + "description": "Opens the Minecraft creator docs for the scripting API in a new browser tab" + }, + "gettingStarted": { + "name": "Getting Started", + "description": "Opens the bridge. getting started guide in a new browser tab" + }, + "extensions": { + "name": "Extensions", + "description": "Opens the bridge. extensions guide in a new browser tab" + }, + "faq": { + "name": "FAQ", + "description": "Opens the bridge. FAQ in a new browser tab" + }, + "feedback": { + "name": "Feedback and Bug Reports", + "description": "Opens the editor issues on github" + }, + "releases": { + "name": "Releases", + "description": "Opens the editor releases page on github" + }, + "openChangelog": { + "name": "Open Changelog", + "description": "Opens the changelog for the current version of bridge." + } }, - "more": { - "name": "More" + "tabs": { + "name": "Tabs", + "close": { + "name": "Close Tab", + "description": "Close the currently selected tab" + }, + "closeAll": { + "name": "Close All Tabs", + "description": "Close all open tabs" + }, + "closeToRight": { + "name": "Close to the Right", + "description": "Close all open tabs to the right of the currently selected tab" + }, + "closeSaved": { + "name": "Close Saved", + "description": "Close all open unmodified tabs" + }, + "closeOther": { + "name": "Close Other", + "description": "Close all other open tabs than the currently selected tab" + }, + "splitscreen": { + "name": "Splitscreen Tab", + "description": "Moves tab into another tab system to the side" + }, + "keepOpen": { + "name": "Keep Open", + "description": "Keep tab open" + } } }, "toolbar": { @@ -440,6 +500,9 @@ "edit": { "name": "Edit" }, + "settings": { + "name": "Settings" + }, "view": { "name": "View", "togglePackExplorer": { @@ -523,6 +586,12 @@ } }, "sidebar": { + "fileExplorer": { + "name": "File Explorer" + }, + "findAndReplace": { + "name": "Find and Replace" + }, "quickExport": { "name": "Quick Export" }, @@ -559,6 +628,10 @@ }, "actions": { "runLastProfile": "Run Last Profile" + }, + "build": { + "name": "Build", + "description": "Run the default build profile" } }, "extensions": { @@ -629,11 +702,15 @@ "sidebar": { "disabledItem": "This item is disabled" }, - "error": { - "explanation": "bridge. encountered the following error:" + "reportError": { + "title": "Report Error", + "explanation": "bridge. encountered the following error:", + "discord": "Discord", + "github": "GitHub", + "copy": "Copy" }, - "changelogWindow": { - "title": "What's new?" + "about": { + "title": "About" }, "openFile": { "title": "Open", @@ -652,17 +729,34 @@ "omitPack": "Omit", "selectedPack": "Selected", "title": "Create Project", - "packIcon": "Project Icon (optional)", - "projectName": { - "name": "Project Name", + "icon": { + "label": "Icon", + "placeholder": "Project Icon (optional)" + }, + "name": { + "label": "Name", + "placeholder": "Project Name", "invalidLetters": "Project name must not contain the following characters: \" \\ / : | < > * ? ~", "mustNotBeEmpty": "You must enter a project name", "endsInPeriod": "Project name cannot end with a period" }, - "projectDescription": "Project Description (optional)", - "projectPrefix": "Project Prefix", - "projectAuthor": "Project Author", - "projectTargetVersion": "Project Target Version", + "description": { + "label": "Description", + "placeholder": "Project Description (optional)" + }, + "namespace": { + "label": "Namespace", + "placeholder": "Project Namespace", + "invalidCharacters": "Project namespace must only contain alphanumeric characters and underscores", + "mustNotBeEmpty": "You must enter a project namespace" + }, + "author": { + "label": "Author", + "placeholder": "Project Author (optional)" + }, + "targetVersion": { + "label": "Target Version" + }, "rpAsBpDependency": "Register resource pack as behavior pack dependency", "bpAsRpDependency": "Register behavior pack as a resource pack dependency", "useLangForManifest": "Add pack name/description directly to the manifest", @@ -713,6 +807,9 @@ "description": "Used to register ids for sound files to be used elsewhere in the project" } } + }, + "errors": { + "noPacks": "Your project must contain at least one pack" } }, "createPreset": { @@ -873,6 +970,13 @@ "hideToolbarItems": { "name": "Hide Toolbar Items", "description": "Make bridge. feel at home on MacOS: Move all toolbar items into a new menu button" + }, + "fileExplorerIndentation": { + "name": "File Explorer Indentation", + "description": "Change the size of the indentation of directory entries in the file explorer" + }, + "sidebarElementVisibility": { + "name": "Sidebar Elements" } }, "general": { @@ -889,10 +993,6 @@ "name": "Pack Spider", "description": "Pack Spider connects files inside of your projects and presents the connections to you in a virtual file system" }, - "formatOnSave": { - "name": "Format On Save", - "description": "Formats your text files upon saving them" - }, "openLinksInBrowser": { "name": "Open Links in Default Browser", "description": "Open links inside of your default browser instead of a native app window" @@ -908,6 +1008,10 @@ "resetBridgeFolder": { "name": "Reset Root Folder", "description": "Reset the app to use bridge. v2's default root folder again" + }, + "keepTabsOpen": { + "name": "Keep Tabs Open", + "description": "By default, opening a new tab closes the previously opened tab if it was not interacted with" } }, "developer": { @@ -923,6 +1027,10 @@ "forceDataDownload": { "name": "Force Data Download", "description": "Ignore the cached app data and instead download the latest data" + }, + "dataDeveloperMode": { + "name": "Data Developer Mode", + "description": "Ignore the data cache and load from the built in packages" } }, "actions": { @@ -934,6 +1042,10 @@ "name": "Default Author", "description": "The default author for new projects" }, + "defaultNamespace": { + "name": "Default Namespace", + "description": "The default namespace for new projects" + }, "loadComMojangProjects": { "name": "Load com.mojang Projects", "description": "Automatically load projects from the com.mojang folder" @@ -945,14 +1057,28 @@ "addGeneratedWith": { "name": "Add \"generated_with\"", "description": "Add the \"generated_with\" metadata to your projects' manifests" + }, + "clearOutputFolder": { + "name": "Clear Output Folder", + "description": "Forget the current default output folder." + }, + "outputFolder": { + "name": "Output Folder", + "button": "Select Output Folder", + "description": "Default output folder projects will compile to.", + "warning": { + "notSupported": "This platform does not support selecting an output folder.", + "notSet": "You have no default output folder set!", + "overwritten": "The default output folder is being overwritten by a project ouput folder." + } } }, "editor": { + "name": "Editor", "jsonEditor": { "name": "JSON Editor", "description": "Choose how you want to edit JSON files" }, - "bracketPairColorization": { "name": "Bracket Pair Colorization", "description": "Give matching brackets an unique color" @@ -969,10 +1095,6 @@ "name": "Compact Tab Design", "description": "Display tabs inside of the tab system in a more compact way" }, - "keepTabsOpen": { - "name": "Keep Tabs Open", - "description": "By default, opening a new tab closes the previously opened tab if it was not interacted with" - }, "autoSaveChanges": { "name": "Auto Save Changes", "description": "Automatically save changes to files after a short delay" @@ -1001,9 +1123,13 @@ "name": "Show Array Indices", "description": "Show indices for array elements inside of bridge.'s tree editor" }, - "hideBracketsWithinTreeEditor": { + "hideBrackets": { "name": "Hide Brackets", "description": "Hide brackets within bridge.'s tree editor" + }, + "formatOnSave": { + "name": "Format On Save", + "description": "Formats your text files upon saving them" } } }, @@ -1011,7 +1137,7 @@ "title": "Project Folder", "content": "bridge. needs access to its project folder in order to work correctly." }, - "extensionStore": { + "extensionLibrary": { "title": "Extension Store", "searchExtensions": "Search Extension...", "deleteExtension": "Delete Extension", @@ -1038,6 +1164,8 @@ }, "unsavedFile": { "description": "Do you want to save your changes to this file before closing it?", + "closeFile": "It looks like you have unsaved changes. Are you sure you want to close the window?", + "closeProject": "It looks like you have unsaved changes. Are you sure you want to close the project?", "save": "Save & Close" }, "browserUnsupported": { @@ -1057,6 +1185,12 @@ "upgradeFs": { "title": "Upgrade File System?", "description": "Your browser now supports saving files directly to your computer. Do you want to upgrade now?" + }, + "alert": { + "title": "Alert" + }, + "progress": { + "title": "Loading" } }, "taskManager": { @@ -1090,6 +1224,10 @@ "name": "Output Folder", "description": "Sets this folder as a new output folder" }, + "project": { + "name": "New Project", + "description": "Imports this folder as a new project" + }, "open": { "name": "Open Folder", "description": "Opens this folder in bridge.'s file explorer" @@ -1219,7 +1357,8 @@ "addValue": "Add Value", "forceValue": "Force Value", "edit": "Edit", - "childHasError": "One or more children have diagnostics" + "childHasError": "One or more children have diagnostics", + "convert": "Convert" }, "langValidation": { "noValue": { @@ -1268,7 +1407,11 @@ "tokens": { "selectorArgument": "selector argument" } - } + }, + "invalidLetters": "Must not contain special characters", + "mustNotBeEmpty": "Must not be empty", + "mustBeLowercase": "Must be lowercase", + "mustBeNumeric": "Must be a number" }, "bottomPanel": { "terminal": { @@ -1284,7 +1427,36 @@ } }, "greet": { + "projects": "Projects", + "noProjects": "You have no projects yet.", + "createOne": "Create one", "pin": "Pin", - "unpin": "Unpin" + "unpin": "Unpin", + "noBridgeFolderSelected": "You need to select a bridge. folder.", + "selectBridgeFolder": "Select a bridge. folder" + }, + "convert": { + "confirmationMessage": { + "v1": "This is a bridge. v1 project. If you click continue, the project will be converted to a bridge. project and stored in the bridge. projects folder.", + "com": { + "mojang": "bridge. no longer supports opening projects directly withing the com.mojang folder! If you click continue, the project will be converted to a bridge. project and stored in the bridge. projects folder." + } + } + }, + "projects": { + "loading": "Loading project...", + "clearOutputFolder": { + "name": "Clear Project Output Folder", + "description": "Forget the current project output folder." + }, + "outputFolder": { + "button": "Select Project Output Folder", + "description": "Output folder this project will compile to.", + "warning": { + "notSupported": "This platform does not support selecting an output folder.", + "using": "This project is using a local project folder.", + "notUsing": "This project is not using a local project folder." + } + } } } diff --git a/src/locales/ja.json b/src/locales/ja.json index 21472c616..fe2c203c7 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1005,7 +1005,7 @@ "title": "プロジェクトフォルダー", "content": "bridge. を正常に動作するためには、プロジェクトフォルダーにアクセスする必要があります。" }, - "extensionStore": { + "extensionLibrary": { "title": "拡張機能ストア", "searchExtensions": "拡張機能を検索...", "deleteExtension": "拡張機能を削除", diff --git a/src/locales/nl.json b/src/locales/nl.json index 1a4918499..9cf693b07 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -910,7 +910,7 @@ "title": "Project Map", "content": "bridge. heeft toegang tot zijn projectmap nodig om correct te werken." }, - "extensionStore": { + "extensionLibrary": { "title": "Uitbreiding Winkel", "searchExtensions": "Extensie zoeken...", "deleteExtension": "Extensie Verwijderen", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index e86e48f82..efa3dc1e6 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -841,7 +841,7 @@ "title": "项目文件夹", "content": "bridge.需要访问其项目文件夹才能正常工作。" }, - "extensionStore": { + "extensionLibrary": { "title": "扩展商店", "searchExtensions": "搜索扩展...", "deleteExtension": "删除扩展", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index f07ac208d..5d5e8ffd7 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -392,7 +392,7 @@ "title": "專案資料夾", "content": "bridge.必須存取專案資料夾" }, - "extensionStore": { + "extensionLibrary": { "title": "擴充元件商店", "searchExtensions": "搜尋擴充元件...", "activateExtension": "啟用擴充元件", diff --git a/src/main.css b/src/main.css deleted file mode 100644 index b5c61c956..000000000 --- a/src/main.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/src/main.ts b/src/main.ts index 8ca95538c..e8a284c72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,53 +1,27 @@ -import '/@/components/FileSystem/Polyfill' -import '/@/components/FileSystem/Virtual/Comlink' -import { App } from './App' -import { vue } from '/@/components/App/Vue' -import '@mdi/font/css/materialdesignicons.min.css' +import './style.css' -// Disable until we move back to vite +import { initRuntimes } from '@bridge-editor/dash-compiler' +import wasmUrl from '@swc/wasm-web/wasm-web_bg.wasm?url' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker' import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker' -import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker.js?worker' -import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker.js?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker.js?worker' -import { initRuntimes } from 'bridge-js-runtime' -import wasmUrl from '@swc/wasm-web/wasm-web_bg.wasm?url' -import './main.css' +import { createApp } from 'vue' +import App from '@/App.vue' + +initRuntimes(wasmUrl) -// @ts-ignore -self.MonacoEnvironment = { +//@ts-ignore +window.MonacoEnvironment = { getWorker(_: unknown, label: string) { if (label === 'json') { return new jsonWorker() } - if (label === 'css' || label === 'scss' || label === 'less') { - return new cssWorker() - } - if (label === 'html' || label === 'handlebars' || label === 'razor') { - return new htmlWorker() - } if (label === 'typescript' || label === 'javascript') { return new tsWorker() } + return new editorWorker() }, } -initRuntimes(wasmUrl) - -App.main(vue) - -if ('launchQueue' in window) { - ;(window).launchQueue.setConsumer(async (launchParams: any) => { - const app = await App.getApp() - - if (launchParams.targetURL) - await app.startParams.parse(launchParams.targetURL) - - if (!launchParams.files.length) return - - for (const fileHandle of launchParams.files) { - await app.fileDropper.importFile(fileHandle) - } - }) -} +createApp(App).mount('#app') diff --git a/src/style.css b/src/style.css new file mode 100644 index 000000000..5dda68691 --- /dev/null +++ b/src/style.css @@ -0,0 +1,55 @@ +@import url(https://fonts.bunny.net/css); + +@font-face { + font-family: Inter; + src: url(/Inter.ttf); +} + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + color: var(--theme-color-text); +} + +.material-symbols-rounded { + font-variation-settings: 'FILL' 1, 'wght' 500, 'GRAD' 0, 'opsz' 24; +} + +.material-symbols-rounded.no-fill { + font-variation-settings: 'FILL' 0, 'wght' 500, 'GRAD' 0, 'opsz' 24; +} + +*::-webkit-scrollbar { + border-radius: 24px; + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + border-radius: 24px; +} + +*::-webkit-scrollbar-thumb { + border-radius: 24px; + background-color: var(--theme-color-backgroundSecondary); +} + +.dark-scrollbar::-webkit-scrollbar-thumb { + border-radius: 24px; + background-color: var(--theme-color-background); +} + +.light-scrollbar::-webkit-scrollbar-thumb { + border-radius: 24px; + background-color: var(--theme-color-backgroundTertiary); +} + +summary::-webkit-details-marker { + display: none; +} + +::-webkit-scrollbar-corner { + background: rgba(0, 0, 0, 0); +} diff --git a/src/types/Activatable.ts b/src/types/Activatable.ts deleted file mode 100644 index a1ea522d4..000000000 --- a/src/types/Activatable.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IActivatable { - activate: () => Promise | void - deactivate: () => Promise | void -} diff --git a/src/types/LocalFontAccess.d.ts b/src/types/LocalFontAccess.d.ts deleted file mode 100644 index 91d40a302..000000000 --- a/src/types/LocalFontAccess.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare interface Window { - queryLocalFonts?(): Promise -} diff --git a/src/types/StructuredClone.d.ts b/src/types/StructuredClone.d.ts deleted file mode 100644 index b7c0d444c..000000000 --- a/src/types/StructuredClone.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare interface Window { - structuredClone?(obj: T): T -} diff --git a/src/types/Vite.d.ts b/src/types/Vite.d.ts deleted file mode 100644 index cab05ffc9..000000000 --- a/src/types/Vite.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// - -interface ImportMetaEnv { - readonly VITE_IS_TAURI_APP: string - // more env variables... -} - -interface ImportMeta { - readonly env: ImportMetaEnv -} diff --git a/src/types/disposable.ts b/src/types/disposable.ts deleted file mode 100644 index 16e109452..000000000 --- a/src/types/disposable.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface IDisposable { - dispose: () => void -} diff --git a/src/types/quick-score.d.ts b/src/types/quick-score.d.ts deleted file mode 100644 index deacfd46c..000000000 --- a/src/types/quick-score.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare module 'quick-score' { - declare class QuickScore { - constructor(words: T[]) {} - - search( - pattern: string - ): { - item: T - score: number - matches: [number, number][] - }[] - } - - declare function quickScore(word: string, pattern: string): number -} diff --git a/src/types/shims-path.ts b/src/types/shims-path.ts deleted file mode 100644 index 25c4a2de6..000000000 --- a/src/types/shims-path.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'path-browserify' { - import path from 'path' - export default path -} diff --git a/src/types/shims-tsx.d.ts b/src/types/shims-tsx.d.ts deleted file mode 100644 index 2ba2394a7..000000000 --- a/src/types/shims-tsx.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Vue, { VNode } from 'vue' - -declare global { - namespace JSX { - // tslint:disable no-empty-interface - interface Element extends VNode {} - // tslint:disable no-empty-interface - interface ElementClass extends Vue {} - interface IntrinsicElements { - [elem: string]: any - } - } -} diff --git a/src/types/shims-vue.d.ts b/src/types/shims-vue.d.ts deleted file mode 100644 index 079dded52..000000000 --- a/src/types/shims-vue.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.vue' { - import Vue from 'vue' - export default Vue -} diff --git a/src/types/tgaJS.ts b/src/types/tgaJS.ts deleted file mode 100644 index 72e33ccc6..000000000 --- a/src/types/tgaJS.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare module 'tga-js' { - export default class TGALoader { - load(uint8arr: Uint8Array): void - open(filePath: string, onLoad: () => void): void - getDataURL(mimeType: 'image/png'): string - getImageData(imageData?: ImageData): ImageData - } -} diff --git a/src/types/vite-env.d.ts b/src/types/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/src/types/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/utils/MoLangJS.ts b/src/utils/MoLangJS.ts deleted file mode 100644 index eb468894d..000000000 --- a/src/utils/MoLangJS.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { MoLang as BridgeMoLang } from 'molang' - -type TVariableHandler = - | ((variableName: string, variables: Record) => unknown) - | null -export default class MoLang { - protected moLang = new BridgeMoLang({}, { convertUndefined: true }) - protected _cacheEnabled = true - protected _globalVariables: any = {} - protected _variableHandler: TVariableHandler = null - - parse(expr: string, variables?: any) { - if (variables) this.moLang.updateExecutionEnv(variables, true) - - // console.log(expr, this.moLang.executeAndCatch(expr)) - return this.moLang.executeAndCatch(expr) - } - - set cache_enabled(val: boolean) { - this._cacheEnabled = val - this.moLang.updateConfig({ useCache: val }) - } - get cache_enabled() { - return this._cacheEnabled - } - - set global_variables(val: any) { - throw new Error(`Setting global variables this way is not supported`) - } - get global_variables() { - throw new Error(`Accessing global variables this way is not supported`) - } - - set variableHandler(handler: TVariableHandler) { - this._variableHandler = handler - this.moLang.updateConfig({ - variableHandler: handler ?? undefined, - }) - } - get variableHandler() { - return this._variableHandler - } -} diff --git a/src/utils/app/dashVersion.ts b/src/utils/app/dashVersion.ts deleted file mode 100644 index 848eb0f1c..000000000 --- a/src/utils/app/dashVersion.ts +++ /dev/null @@ -1,15 +0,0 @@ -import packageConfig from '../../../package.json' - -let version = packageConfig.dependencies['dash-compiler'] - -if ( - version.startsWith('^') || - version.startsWith('~') || - version.startsWith('>') || - version.startsWith('<') -) - version = version.substring(1) -else if (version.startsWith('>=') || version.startsWith('<=')) - version = version.substring(2) - -export const dashVersion = version diff --git a/src/utils/app/dataPackage.ts b/src/utils/app/dataPackage.ts deleted file mode 100644 index 3c7342562..000000000 --- a/src/utils/app/dataPackage.ts +++ /dev/null @@ -1 +0,0 @@ -export const zipSize = 805046 \ No newline at end of file diff --git a/src/utils/app/iframeApiVersion.ts b/src/utils/app/iframeApiVersion.ts deleted file mode 100644 index 367d7f4c2..000000000 --- a/src/utils/app/iframeApiVersion.ts +++ /dev/null @@ -1,15 +0,0 @@ -import packageConfig from '../../../package.json' - -let version = packageConfig.dependencies['bridge-iframe-api'] - -if ( - version.startsWith('^') || - version.startsWith('~') || - version.startsWith('>') || - version.startsWith('<') -) - version = version.substring(1) -else if (version.startsWith('>=') || version.startsWith('<=')) - version = version.substring(2) - -export const iframeApiVersion = version diff --git a/src/utils/app/isNightly.ts b/src/utils/app/isNightly.ts deleted file mode 100644 index 0f3b7e3ef..000000000 --- a/src/utils/app/isNightly.ts +++ /dev/null @@ -1 +0,0 @@ -export const isNightly = location.origin === 'https://nightly.bridge-core.app' diff --git a/src/utils/app/version.ts b/src/utils/app/version.ts deleted file mode 100644 index 9c480ac71..000000000 --- a/src/utils/app/version.ts +++ /dev/null @@ -1,3 +0,0 @@ -import packageConfig from '../../../package.json' - -export const version = packageConfig.version diff --git a/src/utils/array/findAsync.ts b/src/utils/array/findAsync.ts deleted file mode 100644 index 5693d87e0..000000000 --- a/src/utils/array/findAsync.ts +++ /dev/null @@ -1,5 +0,0 @@ -export async function findAsync(arr: T[], cb: (e: T) => Promise) { - const results = await Promise.all(arr.map(cb)) - const index = results.findIndex((result) => result) - return arr[index] -} diff --git a/src/utils/baseUrl.ts b/src/utils/baseUrl.ts deleted file mode 100644 index ea196600d..000000000 --- a/src/utils/baseUrl.ts +++ /dev/null @@ -1 +0,0 @@ -export const baseUrl = import.meta.env.BASE_URL diff --git a/src/utils/canvasToBlob.ts b/src/utils/canvasToBlob.ts deleted file mode 100644 index 62657448c..000000000 --- a/src/utils/canvasToBlob.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function toBlob(canvas: HTMLCanvasElement) { - return new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob) { - resolve(blob) - } else { - reject(new Error('Canvas is empty')) - } - }, 'image/png') - }) -} diff --git a/src/utils/constants.ts b/src/utils/constants.ts deleted file mode 100644 index 8cdd35944..000000000 --- a/src/utils/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { platform } from './os' - -export const platformRedoBinding = - platform() === 'darwin' ? 'Ctrl + Shift + Z' : 'Ctrl + Y' diff --git a/src/utils/directory/findSuitableName.ts b/src/utils/directory/findSuitableName.ts deleted file mode 100644 index d469d8016..000000000 --- a/src/utils/directory/findSuitableName.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { getEntries } from '/@/utils/directory/getEntries' -import { basename, extname } from '/@/utils/path' - -export async function findSuitableFileName( - name: string, - directorHandle: AnyDirectoryHandle -) { - const children = await getEntries(directorHandle) - const fileExt = extname(name) - let newName = basename(name, fileExt) - - while (children.find((child) => child.name === newName + fileExt)) { - if (!newName.includes(' copy')) { - // 1. Add "copy" to the end of the name - newName = `${newName} copy` - } else { - // 2. Add a number to the end of the name - // Get the number from the end of the name - const number = parseInt(newName.match(/copy (\d+)/)?.[1] ?? '1') - // Remove the last number and add the new one - newName = newName.replace(/ \d+$/, '') + ` ${number + 1}` - } - } - - return newName + fileExt -} -export async function findSuitableFolderName( - name: string, - directoryHandle: AnyDirectoryHandle -) { - const children = await getEntries(directoryHandle) - let newName = name - - while (children.find((child) => child.name === newName)) { - if (!newName.includes(' copy')) { - // 1. Add "copy" to the end of the name - newName = `${newName} copy` - } else { - // 2. Add a number to the end of the name - const number = parseInt(newName.match(/copy (\d+)/)?.[1] ?? '1') - newName = newName.replace(/ \d+$/, '') + ` ${number + 1}` - } - } - - return newName -} diff --git a/src/utils/directory/getEntries.ts b/src/utils/directory/getEntries.ts deleted file mode 100644 index 0692d0ea9..000000000 --- a/src/utils/directory/getEntries.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' - -export async function getEntries(directoryHandle: AnyDirectoryHandle) { - const entries = [] - - for await (const entry of directoryHandle.values()) { - entries.push(entry) - } - - return entries -} diff --git a/src/utils/disposableListener.ts b/src/utils/disposableListener.ts deleted file mode 100644 index f9f9f7379..000000000 --- a/src/utils/disposableListener.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function addDisposableEventListener( - event: string, - listener: (event: any) => void, - eventTarget: EventTarget = window -) { - eventTarget.addEventListener(event, listener) - - return { - dispose: () => { - eventTarget.removeEventListener(event, listener) - }, - } -} diff --git a/src/utils/disposableTimeout.ts b/src/utils/disposableTimeout.ts deleted file mode 100644 index e879581e0..000000000 --- a/src/utils/disposableTimeout.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * A function that scheducles a callback function after x milliseconds. - * @param callback The callback function to execute. - * @param milliseconds The number of milliseconds to wait before executing the callback. - * @returns {IDisposable} - */ -export function disposableTimeout(callback: () => void, milliseconds: number) { - let timeoutId: number | null - // setTimeout - timeoutId = (setTimeout(() => { - timeoutId = null - callback() - }, milliseconds)) - - return { - dispose: () => { - if (timeoutId) clearTimeout(timeoutId) - timeoutId = null - }, - } -} diff --git a/src/utils/file/dirExists.ts b/src/utils/file/dirExists.ts deleted file mode 100644 index 2effd5ebd..000000000 --- a/src/utils/file/dirExists.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' - -export async function dirExists( - directoryHandle: AnyDirectoryHandle, - name: string -) { - return directoryHandle - .getDirectoryHandle(name) - .then(() => true) - .catch(() => false) -} diff --git a/src/utils/file/fileExists.ts b/src/utils/file/fileExists.ts deleted file mode 100644 index 0e67d24dc..000000000 --- a/src/utils/file/fileExists.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' - -export async function fileExists( - directoryHandle: AnyDirectoryHandle, - name: string -) { - return directoryHandle - .getFileHandle(name) - .then(() => true) - .catch(() => false) -} diff --git a/src/utils/file/getIcon.ts b/src/utils/file/getIcon.ts deleted file mode 100644 index d2810f433..000000000 --- a/src/utils/file/getIcon.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { extname } from '../path' - -const extIconMap: Record = { - 'mdi-file-image-outline': [ - '.jpg', - '.jpeg', - '.png', - '.gif', - '.bmp', - '.webp', - '.tga', - ], - 'mdi-code-json': ['.json'], - 'mdi-volume-high': ['.mp3', '.wav', '.fsb', '.ogg'], - 'mdi-language-html5': ['.html'], - 'mdi-language-typescript': ['.ts', '.tsx'], - 'mdi-language-javascript': ['.js', '.jsx'], - 'mdi-web': ['.lang'], -} - -export function getDefaultFileIcon(name: string) { - const ext = extname(name) - for (const [icon, exts] of Object.entries(extIconMap)) { - if (exts.includes(ext)) return icon - } - - return 'mdi-file-outline' -} diff --git a/src/utils/file/isAccepted.ts b/src/utils/file/isAccepted.ts deleted file mode 100644 index 223ccbf02..000000000 --- a/src/utils/file/isAccepted.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Returns whether a file is accepted by the given "accept" filter - * - * @param file The file to check - * @param accept The accept filter (see HTML file input) - */ -export function isFileAccepted(file: File, accept?: string) { - if (!accept || accept === '*') { - return true - } - - const acceptedFiles = accept.split(',') - const fileName = file.name || '' - const mimeType = file.type || '' - const baseMimeType = mimeType.replace(/\/.*$/, '') - - return acceptedFiles.some((type) => { - const validType = type.trim() - if (validType.charAt(0) === '.') { - return fileName.toLowerCase().endsWith(validType.toLowerCase()) - } else if (/\/\*$/.test(validType)) { - // This is something like a image/* mime type - return baseMimeType === validType.replace(/\/.*$/, '') - } - return mimeType === validType - }) -} diff --git a/src/utils/file/isSameEntry.ts b/src/utils/file/isSameEntry.ts deleted file mode 100644 index 078c06470..000000000 --- a/src/utils/file/isSameEntry.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - BaseVirtualHandle, - VirtualHandle, -} from '/@/components/FileSystem/Virtual/Handle' - -export function isSameEntry( - entry1: FileSystemHandle | VirtualHandle, - entry2: FileSystemHandle | VirtualHandle -) { - if ( - entry1 instanceof BaseVirtualHandle && - entry2 instanceof BaseVirtualHandle - ) { - return entry1.isSameEntry(entry2) - } else if ( - entry1 instanceof BaseVirtualHandle || - entry2 instanceof BaseVirtualHandle - ) { - return false - } - - return entry1.isSameEntry(entry2) -} diff --git a/src/utils/file/loadAllFiles.ts b/src/utils/file/loadAllFiles.ts deleted file mode 100644 index dff1ee845..000000000 --- a/src/utils/file/loadAllFiles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - AnyDirectoryHandle, - AnyFileHandle, -} from '/@/components/FileSystem/Types' - -export async function loadAllFiles( - directoryHandle: AnyDirectoryHandle, - path = directoryHandle.name -) { - if (path === '') path = '~local' - - const files: { handle: AnyFileHandle; path: string }[] = [] - - for await (const handle of directoryHandle.values()) { - if (handle.kind === 'file' && handle.name !== '.DS_Store') { - files.push({ - handle, - path: `${path}/${handle.name}`, - }) - } else if (handle.kind === 'directory') { - files.push( - ...(await loadAllFiles(handle, `${path}/${handle.name}`)) - ) - } - } - - return files -} diff --git a/src/utils/file/moveHandle.ts b/src/utils/file/moveHandle.ts deleted file mode 100644 index 57f744eb3..000000000 --- a/src/utils/file/moveHandle.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { iterateDir } from '../iterateDir' -import { - AnyDirectoryHandle, - AnyFileHandle, - AnyHandle, -} from '/@/components/FileSystem/Types' -import { VirtualHandle } from '/@/components/FileSystem/Virtual/Handle' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { dirname } from '../path' -import { tryCreateFile } from './tryCreateFile' -import { tryMove } from './tryMove' -import { dirExists } from './dirExists' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' - -interface IMoveOptions { - fromHandle?: AnyDirectoryHandle - toHandle: AnyDirectoryHandle - moveHandle: T -} - -interface IMoveResult { - type: 'cancel' | 'overwrite' | 'move' - handle?: AnyHandle -} - -export async function moveHandle(opts: IMoveOptions): Promise { - if (opts.moveHandle.kind === 'file') - return await moveFileHandle(>opts) - else if (opts.moveHandle.kind === 'directory') - return await moveDirectoryHandle(>opts) - - return { - type: 'cancel', - } -} - -export async function moveFileHandle( - { fromHandle, toHandle, moveHandle }: IMoveOptions, - forceWrite = false -): Promise { - if (typeof (moveHandle).move === 'function') { - const moveStatus = await tryMove({ - toDirectory: toHandle, - moveHandle, - forceWrite, - }) - - if (moveStatus !== 'moveFailed') - return { - type: moveStatus, - handle: moveHandle, - } - } - - // Move is not available, we need to copy over the file manually - // 1. Get original file content - const file = await moveHandle.getFile() - // 2. Create new file - const { type, handle: newHandle } = await tryCreateFile({ - directoryHandle: toHandle, - name: moveHandle.name, - forceWrite, - }) - if (!newHandle) - return { - type: 'cancel', - } - - // 3. Write file content to new file - const writable = await newHandle.createWritable() - await writable.write(file.isVirtual ? await file.toBlobFile() : file) - await writable.close() - // 4. Delete old file - if (fromHandle) await fromHandle.removeEntry(moveHandle.name) - - return { - type: type === 'overwrite' ? 'overwrite' : 'move', - handle: newHandle, - } -} - -async function moveDirectoryHandle({ - fromHandle, - toHandle, - moveHandle, -}: IMoveOptions): Promise { - if (typeof (moveHandle).move === 'function') { - const moveStatus = await tryMove({ - moveHandle, - toDirectory: toHandle, - }) - - if (moveStatus !== 'moveFailed') - return { - type: moveStatus, - handle: moveHandle, - } - } - - // Native move is not availble, we need to manually move over the folder - const destFs = new FileSystem(toHandle) - - let type: 'move' | 'overwrite' = 'move' - if (await dirExists(toHandle, moveHandle.name)) { - type = 'overwrite' - const confirmWindow = new ConfirmationWindow({ - description: 'general.confirmOverwriteFolder', - }) - const choice = await confirmWindow.fired - - if (!choice) return { type: 'cancel' } - } - - const newHandle = await destFs.getDirectoryHandle(moveHandle.name, { - create: true, - }) - - await iterateDir( - moveHandle, - async (fileHandle, filePath, fromHandle) => { - await moveFileHandle( - { - moveHandle: fileHandle, - fromHandle, - toHandle: await destFs.getDirectoryHandle( - dirname(filePath), - { - create: true, - } - ), - }, - true - ) - }, - new Set(), - moveHandle.name - ) - - if (fromHandle) - await fromHandle.removeEntry(moveHandle.name, { recursive: true }) - return { - type, - handle: newHandle, - } -} diff --git a/src/utils/file/renameHandle.ts b/src/utils/file/renameHandle.ts deleted file mode 100644 index 78699c907..000000000 --- a/src/utils/file/renameHandle.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { tryCreateFile } from './tryCreateFile' -import { tryRename } from './tryRename' -import { - AnyDirectoryHandle, - AnyFileHandle, - AnyHandle, -} from '/@/components/FileSystem/Types' -import { VirtualHandle } from '/@/components/FileSystem/Virtual/Handle' -import { FileSystem } from '/@/components/FileSystem/FileSystem' -import { dirExists } from './dirExists' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' -import { iterateDir } from '../iterateDir' -import { moveFileHandle } from './moveHandle' -import { dirname } from '/@/utils/path' - -interface IRenameOptions { - newName: string - renameHandle: T - parentHandle: AnyDirectoryHandle -} - -interface IRenameResult { - type: 'cancel' | 'overwrite' | 'rename' - handle?: AnyHandle -} - -export async function renameHandle( - opts: IRenameOptions -): Promise { - if (opts.renameHandle.kind === 'file') - return await renameFileHandle(>opts) - else if (opts.renameHandle.kind === 'directory') - return await renameDirectoryHandle( - >opts - ) - - return { - type: 'cancel', - } -} - -async function renameFileHandle( - { renameHandle, newName, parentHandle }: IRenameOptions, - forceWrite = false -): Promise { - if (typeof (renameHandle).move === 'function') { - const renameStatus = await tryRename({ - parentHandle, - renameHandle, - newName, - forceWrite, - }) - - if (renameStatus !== 'renameFailed') - return { - type: renameStatus, - handle: renameHandle, - } - } - - // Move is not available, we need to copy over the file manually - // 1. Get original file content - const file = await renameHandle.getFile() - // 2. Create new file - const { type, handle: newHandle } = await tryCreateFile({ - directoryHandle: parentHandle, - name: newName, - forceWrite, - }) - if (!newHandle) return { type: 'cancel' } - - // 3. Write file content to new file - const writable = await newHandle.createWritable() - await writable.write(file.isVirtual ? await file.toBlobFile() : file) - await writable.close() - // 4. Delete old file - await parentHandle.removeEntry(renameHandle.name) - - return { - type: type === 'create' ? 'rename' : 'overwrite', - handle: newHandle, - } -} - -async function renameDirectoryHandle({ - parentHandle, - renameHandle, - newName, -}: IRenameOptions): Promise { - if (typeof (renameHandle).move === 'function') { - const result = await tryRename({ - parentHandle, - renameHandle, - newName, - }) - - if (result !== 'renameFailed') - return { - type: result, - handle: renameHandle, - } - } - - // Native move is not available, we need to manually move over the folder - const destFs = new FileSystem(parentHandle) - const oldName = renameHandle.name - - let type: 'rename' | 'overwrite' = 'rename' - if (await dirExists(parentHandle, newName)) { - type = 'overwrite' - const confirmWindow = new ConfirmationWindow({ - description: 'general.confirmOverwriteFolder', - }) - const choice = await confirmWindow.fired - - if (!choice) return { type: 'cancel' } - } - - const newHandle = await destFs.getDirectoryHandle(newName, { - create: true, - }) - - await iterateDir( - renameHandle, - async (fileHandle, filePath, fromHandle) => { - await moveFileHandle( - { - moveHandle: fileHandle, - fromHandle, - toHandle: await destFs.getDirectoryHandle( - dirname(filePath), - { - create: true, - } - ), - }, - true - ) - }, - new Set(), - newName - ) - - if (parentHandle) - await parentHandle.removeEntry(oldName, { recursive: true }) - - return { - type, - handle: newHandle, - } -} diff --git a/src/utils/file/tryCreateFile.ts b/src/utils/file/tryCreateFile.ts deleted file mode 100644 index 0c28c460f..000000000 --- a/src/utils/file/tryCreateFile.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - AnyDirectoryHandle, - AnyFileHandle, -} from '/@/components/FileSystem/Types' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' - -interface ICreateFileOptions { - directoryHandle: AnyDirectoryHandle - name: string - forceWrite?: boolean -} - -interface ICreateResult { - type: 'cancel' | 'overwrite' | 'create' - handle?: AnyFileHandle -} - -export async function tryCreateFile({ - directoryHandle, - name, - forceWrite, -}: ICreateFileOptions): Promise { - let handle: AnyFileHandle - try { - handle = await directoryHandle.getFileHandle(name) - } catch { - handle = await directoryHandle.getFileHandle(name, { create: true }) - return { - type: 'create', - handle, - } - } - - if (forceWrite) return { type: 'overwrite', handle } - - const confirmWindow = new ConfirmationWindow({ - description: 'general.confirmOverwriteFile', - }) - const choice = await confirmWindow.fired - - if (choice) - return { - type: 'overwrite', - handle, - } - return { type: 'cancel' } -} diff --git a/src/utils/file/tryCreateFolder.ts b/src/utils/file/tryCreateFolder.ts deleted file mode 100644 index 10e0fdb18..000000000 --- a/src/utils/file/tryCreateFolder.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AnyDirectoryHandle } from '/@/components/FileSystem/Types' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' - -interface ICreateFileOptions { - directoryHandle: AnyDirectoryHandle - name: string - forceWrite?: boolean -} - -interface ICreateResult { - type: 'cancel' | 'overwrite' | 'create' - handle?: AnyDirectoryHandle -} - -export async function tryCreateFolder({ - directoryHandle, - name, - forceWrite, -}: ICreateFileOptions): Promise { - let handle: AnyDirectoryHandle - try { - handle = await directoryHandle.getDirectoryHandle(name) - } catch { - handle = await directoryHandle.getDirectoryHandle(name, { - create: true, - }) - return { - type: 'create', - handle, - } - } - - if (forceWrite) return { type: 'overwrite', handle } - - const confirmWindow = new ConfirmationWindow({ - description: 'general.confirmOverwriteFolder', - }) - const choice = await confirmWindow.fired - - if (choice) - return { - type: 'overwrite', - handle, - } - return { type: 'cancel' } -} diff --git a/src/utils/file/tryMove.ts b/src/utils/file/tryMove.ts deleted file mode 100644 index 069c951cb..000000000 --- a/src/utils/file/tryMove.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { dirExists } from './dirExists' -import { fileExists } from './fileExists' -import { AnyDirectoryHandle, AnyHandle } from '/@/components/FileSystem/Types' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' - -interface IMoveFileOptions { - toDirectory: AnyDirectoryHandle - moveHandle: AnyHandle - forceWrite?: boolean -} - -export async function tryMove({ - toDirectory, - moveHandle, - forceWrite, -}: IMoveFileOptions) { - const directoryExists = await dirExists(toDirectory, moveHandle.name) - - let type: 'overwrite' | 'move' = 'move' - if ( - !forceWrite && - (directoryExists || (await fileExists(toDirectory, moveHandle.name))) - ) { - const confirmWindow = new ConfirmationWindow({ - description: directoryExists - ? 'general.confirmOverwriteFolder' - : 'general.confirmOverwriteFile', - }) - const choice = await confirmWindow.fired - - if (!choice) return 'cancel' - - type = 'overwrite' - } - - try { - await (moveHandle).move(toDirectory) - return type - } catch { - return 'moveFailed' - } -} diff --git a/src/utils/file/tryRename.ts b/src/utils/file/tryRename.ts deleted file mode 100644 index 4c8f064a7..000000000 --- a/src/utils/file/tryRename.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { dirExists } from './dirExists' -import { fileExists } from './fileExists' -import { AnyDirectoryHandle, AnyHandle } from '/@/components/FileSystem/Types' -import { ConfirmationWindow } from '/@/components/Windows/Common/Confirm/ConfirmWindow' - -interface IRenameFileOptions { - newName: string - parentHandle: AnyDirectoryHandle - renameHandle: AnyHandle - forceWrite?: boolean -} - -export async function tryRename({ - newName, - parentHandle, - renameHandle, - forceWrite, -}: IRenameFileOptions) { - const directoryExists = await dirExists(parentHandle, newName) - - let type: 'rename' | 'overwrite' = 'rename' - if ( - !forceWrite && - (directoryExists || (await fileExists(parentHandle, newName))) - ) { - const confirmWindow = new ConfirmationWindow({ - description: directoryExists - ? 'general.confirmOverwriteFolder' - : 'general.confirmOverwriteFile', - }) - const choice = await confirmWindow.fired - - if (!choice) return 'cancel' - - type = 'overwrite' - } - - try { - await (renameHandle).move(newName) - return type - } catch { - return 'renameFailed' - } -} diff --git a/src/utils/file/writableToUint8Array.ts b/src/utils/file/writableToUint8Array.ts deleted file mode 100644 index b343e0730..000000000 --- a/src/utils/file/writableToUint8Array.ts +++ /dev/null @@ -1,14 +0,0 @@ -const textEncoder = new TextEncoder() - -export async function writableToUint8Array(data: BufferSource | Blob | string) { - let rawData: Uint8Array - if (typeof data === 'string') rawData = textEncoder.encode(data) - else if (data instanceof Blob) - rawData = await data - .arrayBuffer() - .then((buffer) => new Uint8Array(buffer)) - else if (!ArrayBuffer.isView(data)) rawData = new Uint8Array(data) - else rawData = new Uint8Array(data.buffer) - - return rawData -} diff --git a/src/utils/fs.ts b/src/utils/fs.ts deleted file mode 100644 index 6eb682e53..000000000 --- a/src/utils/fs.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { App } from '/@/App' -import { FileSystem } from '/@/components/FileSystem/FileSystem' - -export function getFileSystem() { - return new Promise(resolve => { - App.ready.once(app => { - resolve(app.fileSystem) - }) - }) -} diff --git a/src/utils/getBridgeFolderPath.ts b/src/utils/getBridgeFolderPath.ts deleted file mode 100644 index 72d488e1f..000000000 --- a/src/utils/getBridgeFolderPath.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { get } from 'idb-keyval' - -let cachedPath: string | undefined = undefined - -export async function getBridgeFolderPath() { - if (!import.meta.env.VITE_IS_TAURI_APP) - throw new Error(`This function is only available in Tauri apps.`) - if (cachedPath) return cachedPath - - const { appLocalDataDir, join } = await import('@tauri-apps/api/path') - - const configuredPath = await get('bridgeFolderPath') - if (configuredPath) { - cachedPath = configuredPath - return cachedPath - } - - cachedPath = await join(await appLocalDataDir(), 'bridge') - return cachedPath -} diff --git a/src/utils/getStorageDirectory.ts b/src/utils/getStorageDirectory.ts deleted file mode 100644 index 6e2ec9b08..000000000 --- a/src/utils/getStorageDirectory.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { VirtualDirectoryHandle } from '../components/FileSystem/Virtual/DirectoryHandle' -import { IndexedDbStore } from '../components/FileSystem/Virtual/Stores/IndexedDb' - -async function doesSupportWritable() { - const dirHandle = await navigator.storage.getDirectory() - const fileHandle = await dirHandle.getFileHandle( - 'bridge-supports-writable-test', - { create: true } - ) - - const supportsWritable = typeof fileHandle.createWritable === 'function' - - // Safari throws error if we try to remove the file again -\_(-_-)_/- - // if (typeof dirHandle.removeEntry === 'function') - // await dirHandle.removeEntry('bridge-supports-writable-test') - - return supportsWritable -} - -export async function getStorageDirectory() { - if (import.meta.env.VITE_IS_TAURI_APP) { - const { TauriFsStore } = await import( - '/@/components/FileSystem/Virtual/Stores/TauriFs' - ) - const { getBridgeFolderPath } = await import( - '/@/utils/getBridgeFolderPath' - ) - - const directoryHandle = new VirtualDirectoryHandle( - new TauriFsStore(await getBridgeFolderPath()), - 'bridge', - undefined, - true - ) - - await directoryHandle.setupDone.fired - - return directoryHandle - } - - if ( - typeof navigator.storage?.getDirectory !== 'function' || - !(await doesSupportWritable()) - ) { - return new VirtualDirectoryHandle(new IndexedDbStore(), 'bridgeFolder') - } - - return await navigator.storage.getDirectory() -} diff --git a/src/utils/inferType.ts b/src/utils/inferType.ts deleted file mode 100644 index 9043d5258..000000000 --- a/src/utils/inferType.ts +++ /dev/null @@ -1,14 +0,0 @@ -const numberRegExp = /^\d+(\.\d+)?$/ - -export function inferType(value: string) { - let transformedValue: string | number | boolean | null = value - - if (typeof value === 'boolean' || value === 'true' || value === 'false') - transformedValue = typeof value === 'boolean' ? value : value === 'true' - else if (numberRegExp.test(value)) transformedValue = Number(value) - else if (value === 'null' || value === null) transformedValue = null - - numberRegExp.lastIndex = 0 - - return transformedValue -} diff --git a/src/utils/isNode.ts b/src/utils/isNode.ts deleted file mode 100644 index 4cc16f819..000000000 --- a/src/utils/isNode.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const isNode = () => { - try { - return typeof process !== 'undefined' && process.release.name === 'node' - } catch { - return false - } -} diff --git a/src/utils/isWritableData.ts b/src/utils/isWritableData.ts deleted file mode 100644 index 70be59f3c..000000000 --- a/src/utils/isWritableData.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * A function that returns whether the given data is writable using the FileSystem Access API - */ -export function isWritableData(data: any): boolean { - return ( - typeof data === 'string' || - data instanceof Blob || - data instanceof File || - data instanceof ArrayBuffer || - data?.buffer instanceof ArrayBuffer - ) -} diff --git a/src/utils/iterateDir.ts b/src/utils/iterateDir.ts deleted file mode 100644 index 9830b1631..000000000 --- a/src/utils/iterateDir.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - AnyDirectoryHandle, - AnyFileHandle, -} from '../components/FileSystem/Types' - -export async function iterateDir( - baseDirectory: AnyDirectoryHandle, - callback: ( - fileHandle: AnyFileHandle, - path: string, - fromDirectory: AnyDirectoryHandle - ) => Promise | void, - ignoreFolders: Set = new Set(), - path = '' -) { - for await (const handle of baseDirectory.values()) { - const currentPath = - path.length === 0 ? handle.name : `${path}/${handle.name}` - - if (handle.kind === 'file') { - if (handle.name[0] === '.' || handle.name.endsWith('.crswap')) - continue - - await callback(handle, currentPath, baseDirectory) - } else if ( - handle.kind === 'directory' && - !ignoreFolders.has(currentPath) - ) { - await iterateDir(handle, callback, ignoreFolders, currentPath) - } - } -} - -export async function iterateDirParallel( - baseDirectory: AnyDirectoryHandle, - callback: ( - fileHandle: AnyFileHandle, - path: string, - fromDirectory: AnyDirectoryHandle - ) => Promise | void, - ignoreFolders: Set = new Set(), - path = '' -) { - const promises = [] - - for await (const handle of baseDirectory.values()) { - const currentPath = - path.length === 0 ? handle.name : `${path}/${handle.name}` - - if (handle.kind === 'file') { - if (handle.name[0] === '.' || handle.name.endsWith('.crswap')) - continue - - promises.push(callback(handle, currentPath, baseDirectory)) - } else if ( - handle.kind === 'directory' && - !ignoreFolders.has(currentPath) - ) { - promises.push( - iterateDirParallel(handle, callback, ignoreFolders, currentPath) - ) - } - } - - await Promise.all(promises) -} diff --git a/src/utils/libs/internal/jsoncParser.ts b/src/utils/libs/internal/jsoncParser.ts deleted file mode 100644 index 64ede6c76..000000000 --- a/src/utils/libs/internal/jsoncParser.ts +++ /dev/null @@ -1 +0,0 @@ -export { getLocation, visit } from 'jsonc-parser' diff --git a/src/utils/libs/internal/quickScore.ts b/src/utils/libs/internal/quickScore.ts deleted file mode 100644 index 8ecddde81..000000000 --- a/src/utils/libs/internal/quickScore.ts +++ /dev/null @@ -1 +0,0 @@ -export { QuickScore } from 'quick-score' diff --git a/src/utils/libs/internal/vueTemplateCompiler.ts b/src/utils/libs/internal/vueTemplateCompiler.ts deleted file mode 100644 index 724c384d1..000000000 --- a/src/utils/libs/internal/vueTemplateCompiler.ts +++ /dev/null @@ -1 +0,0 @@ -export { parseComponent } from 'vue-template-compiler' diff --git a/src/utils/libs/useJsoncParser.ts b/src/utils/libs/useJsoncParser.ts deleted file mode 100644 index 288bb4968..000000000 --- a/src/utils/libs/useJsoncParser.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function useJsoncParser() { - return await import('./internal/jsoncParser') -} diff --git a/src/utils/libs/useModelViewer.ts b/src/utils/libs/useModelViewer.ts deleted file mode 100644 index b8f7f8578..000000000 --- a/src/utils/libs/useModelViewer.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function useBridgeModelViewer() { - return await import('bridge-model-viewer') -} diff --git a/src/utils/libs/useMonaco.ts b/src/utils/libs/useMonaco.ts deleted file mode 100644 index 59f598ca8..000000000 --- a/src/utils/libs/useMonaco.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Signal } from '../../components/Common/Event/Signal' - -export const loadMonaco = new Signal() - -export async function useMonaco() { - await loadMonaco.fired - return await import('monaco-editor') -} diff --git a/src/utils/libs/useQuickScore.ts b/src/utils/libs/useQuickScore.ts deleted file mode 100644 index b023c6e45..000000000 --- a/src/utils/libs/useQuickScore.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function useQuickScore() { - return await import('./internal/quickScore') -} diff --git a/src/utils/libs/useVueTemplateCompiler.ts b/src/utils/libs/useVueTemplateCompiler.ts deleted file mode 100644 index 51e180a51..000000000 --- a/src/utils/libs/useVueTemplateCompiler.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function useVueTemplateCompiler() { - return await import('./internal/vueTemplateCompiler') -} diff --git a/src/utils/libs/useWintersky.ts b/src/utils/libs/useWintersky.ts deleted file mode 100644 index aba997438..000000000 --- a/src/utils/libs/useWintersky.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function useWintersky() { - return await import('wintersky') -} diff --git a/src/utils/loadAsDataUrl.ts b/src/utils/loadAsDataUrl.ts deleted file mode 100644 index b7384739c..000000000 --- a/src/utils/loadAsDataUrl.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { FileSystem } from '../components/FileSystem/FileSystem' -import { AnyFileHandle } from '../components/FileSystem/Types' -import { VirtualFile } from '../components/FileSystem/Virtual/File' -import { App } from '/@/App' - -export async function loadAsDataURL(filePath: string, fileSystem?: FileSystem) { - if (!fileSystem) { - const app = await App.getApp() - fileSystem = app.fileSystem - } - - return new Promise(async (resolve, reject) => { - const reader = new FileReader() - - try { - const fileHandle = await fileSystem!.getFileHandle(filePath) - const file = await fileHandle.getFile() - - reader.addEventListener('load', () => { - resolve(reader.result) - }) - reader.addEventListener('error', reject) - reader.readAsDataURL( - file instanceof VirtualFile ? await file.toBlobFile() : file - ) - } catch { - reject(`File does not exist: "${filePath}"`) - } - }) -} - -export function loadHandleAsDataURL(fileHandle: AnyFileHandle) { - return new Promise(async (resolve, reject) => { - const reader = new FileReader() - - try { - const file = await fileHandle.getFile() - - reader.addEventListener('load', () => { - resolve(reader.result) - }) - reader.addEventListener('error', reject) - - reader.readAsDataURL( - file instanceof VirtualFile ? await file.toBlobFile() : file - ) - } catch { - reject(`File does not exist: "${fileHandle.name}"`) - } - }) -} diff --git a/src/utils/math/clamp.ts b/src/utils/math/clamp.ts deleted file mode 100644 index e76a58902..000000000 --- a/src/utils/math/clamp.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function clamp(x: number, min: number, max: number) { - return Math.min(Math.max(x, min), max) -} diff --git a/src/utils/math/randomInt.ts b/src/utils/math/randomInt.ts deleted file mode 100644 index 4b67571d9..000000000 --- a/src/utils/math/randomInt.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function randomInt(min: number, max: number, inclusive = true) { - if (inclusive) max++ - else min++ - return Math.floor(Math.random() * (max - min)) + min -} diff --git a/src/utils/minecraft/validPositionArray.ts b/src/utils/minecraft/validPositionArray.ts deleted file mode 100644 index 5e2cadf40..000000000 --- a/src/utils/minecraft/validPositionArray.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function isValidPositionArray( - array: unknown -): array is [number, number, number] { - return ( - Array.isArray(array) && - array.length === 3 && - typeof array[0] === 'number' && - typeof array[1] === 'number' && - typeof array[2] === 'number' - ) -} diff --git a/src/utils/monaco/getArrayValue.ts b/src/utils/monaco/getArrayValue.ts deleted file mode 100644 index 398bc6302..000000000 --- a/src/utils/monaco/getArrayValue.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { editor } from 'monaco-editor' -import { useMonaco } from '../libs/useMonaco' - -export async function getArrayValueAtOffset( - model: editor.ITextModel, - offset: number -) { - const { Range } = await useMonaco() - const content = model.getValue() - - const arrStart = model.getPositionAt( - getPreviousSquareBracket(content, offset) - ) - const arrEnd = model.getPositionAt(getNextSquareBracket(content, offset)) - const range = new Range( - arrStart.lineNumber, - arrStart.column, - arrEnd.lineNumber, - arrEnd.column + 1 - ) - - return { - word: model.getValueInRange(range), - range, - } -} - -function getNextSquareBracket(content: string, offset: number) { - for (let i = offset; i < content.length; i++) { - if (content[i] === ']') return i - } - return content.length -} -function getPreviousSquareBracket(content: string, offset: number) { - for (let i = offset; i > 0; i--) { - if (content[i] === '[') return i - } - return 0 -} diff --git a/src/utils/monaco/getJsonWord.ts b/src/utils/monaco/getJsonWord.ts deleted file mode 100644 index 195e1002d..000000000 --- a/src/utils/monaco/getJsonWord.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { editor, Position } from 'monaco-editor' -import { useMonaco } from '../libs/useMonaco' - -/** - * Gets the range and word of a json string from a position in a text model - * @param model The text model that the string is in - * @param position The position inside of the json string to get - * @returns An object with 'word' and 'range' properties, containing the word in the json string and the range, in the model, of the string. NOTE - the column is zero-based so when using this to set monaco editor markers the columns should be adjusted to represent the entire json word - */ -export async function getJsonWordAtPosition( - model: editor.ITextModel, - position: Position -) { - const { Range } = await useMonaco() - const line = model.getLineContent(position.lineNumber) - - const wordStart = getPreviousQuote(line, position.column) - const wordEnd = getNextQuote(line, position.column) - return { - word: line.substring(wordStart, wordEnd), - range: new Range( - position.lineNumber, - wordStart, - position.lineNumber, - wordEnd - ), - } -} - -function getNextQuote(line: string, startIndex: number) { - for (let i = startIndex - 1; i < line.length; i++) { - if (line[i] === '"') return i - } - return line.length -} -function getPreviousQuote(line: string, startIndex: number) { - for (let i = startIndex - 2; i > 0; i--) { - if (line[i] === '"') return i + 1 - } - return 0 -} diff --git a/src/utils/monaco/getLocation.ts b/src/utils/monaco/getLocation.ts deleted file mode 100644 index a46bd4511..000000000 --- a/src/utils/monaco/getLocation.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { editor, Position } from 'monaco-editor' -import { useJsoncParser } from '../libs/useJsoncParser' - -export async function getLocation( - model: editor.ITextModel, - position: Position, - removeFinalIndex = true -): Promise { - const { getLocation: jsoncGetLocation } = await useJsoncParser() - const locationArr = jsoncGetLocation( - model.getValue(), - model.getOffsetAt(position) - ).path - - // Lightning cache definition implicitly indexes arrays so we need to remove indexes if they are at the last path position - if ( - removeFinalIndex && - !isNaN(Number(locationArr[locationArr.length - 1])) - ) { - locationArr.pop() - } - - return locationArr.join('/') -} diff --git a/src/utils/monaco/withinQuotes.ts b/src/utils/monaco/withinQuotes.ts deleted file mode 100644 index e6963b297..000000000 --- a/src/utils/monaco/withinQuotes.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { editor, Position, Range } from 'monaco-editor' - -export function isWithinQuotes(model: editor.ITextModel, position: Position) { - let line: string - try { - line = model.getLineContent(position.lineNumber) - } catch { - return false - } - - const wordStart = getPreviousQuote(line, position.column) - const wordEnd = getNextQuote(line, position.column) - return wordStart && wordEnd -} - -function getNextQuote(line: string, startIndex: number) { - for (let i = startIndex - 1; i < line.length; i++) { - if (line[i] === '"') return true - } - return false -} -function getPreviousQuote(line: string, startIndex: number) { - for (let i = startIndex - 2; i > 0; i--) { - if (line[i] === '"') return true - } - return false -} diff --git a/src/utils/os.ts b/src/utils/os.ts deleted file mode 100644 index 4cf89c0f7..000000000 --- a/src/utils/os.ts +++ /dev/null @@ -1,22 +0,0 @@ -// import { createErrorNotification } from '/@/appCycle/Errors' - -import { settingsState } from '/@/components/Windows/Settings/SettingsState' - -export function platform() { - if ( - settingsState?.developers?.simulateOS && - settingsState.developers.simulateOS !== 'auto' - ) - return <'win32' | 'linux' | 'darwin'>settingsState.developers.simulateOS - - const platform = navigator.platform.toLowerCase() - if (platform.includes('win')) return 'win32' - else if (platform.includes('linux')) return 'linux' - else if (platform.includes('mac')) return 'darwin' - - console.error(`Unknown platform: ${platform}`) - return 'win32' - - // Breaks vue components \_o_/ - // createErrorNotification(new Error(`Unknown platform: ${platform}`)) -} diff --git a/src/utils/path.ts b/src/utils/path.ts deleted file mode 100644 index f61d6463a..000000000 --- a/src/utils/path.ts +++ /dev/null @@ -1,9 +0,0 @@ -import path from 'path-browserify' - -export const dirname = path.dirname -export const join = path.join -export const extname = path.extname -export const basename = path.basename -export const resolve = path.resolve -export const relative = path.relative -export const isAbsolute = path.isAbsolute diff --git a/src/utils/pointerDevice.ts b/src/utils/pointerDevice.ts deleted file mode 100644 index 051e9270a..000000000 --- a/src/utils/pointerDevice.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ref } from 'vue' - -type TPointerType = 'touch' | 'mouse' | 'pen' -export const pointerDevice = ref('mouse') - -window.addEventListener( - 'pointerdown', - (event) => { - pointerDevice.value = event.pointerType - }, - { passive: true } -) diff --git a/src/utils/revealInFileExplorer.ts b/src/utils/revealInFileExplorer.ts deleted file mode 100644 index 4770d5184..000000000 --- a/src/utils/revealInFileExplorer.ts +++ /dev/null @@ -1,10 +0,0 @@ -export async function revealInFileExplorer(path: string) { - if (!import.meta.env.VITE_IS_TAURI_APP) - throw new Error('This action is only available on Tauri builds') - - const { invoke } = await import('@tauri-apps/api/tauri') - - await invoke('reveal_in_file_explorer', { - path, - }) -} diff --git a/src/utils/setRichPresence.ts b/src/utils/setRichPresence.ts deleted file mode 100644 index a63baf119..000000000 --- a/src/utils/setRichPresence.ts +++ /dev/null @@ -1,18 +0,0 @@ -interface RichPresenceOpts { - details: string - state: string -} - -export async function setRichPresence(opts: RichPresenceOpts) { - if (!import.meta.env.VITE_IS_TAURI_APP) return - - const { getCurrent } = await import('@tauri-apps/api/window') - const window = await getCurrent() - - window.emit('setRichPresence', opts) -} - -setRichPresence({ - details: 'Developing add-ons...', - state: 'Idle', -}) diff --git a/src/utils/string/closestMatch.ts b/src/utils/string/closestMatch.ts deleted file mode 100644 index dc29a3c7f..000000000 --- a/src/utils/string/closestMatch.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { editDistance } from './editDistance' - -/** - * Return the closest match to a string in a list of strings. - */ -export function closestMatch( - str: string, - strings: string[], - threshold = 0.3 -): string | null { - const distances = strings.map((s) => editDistance(str, s)) - const min = Math.min(...distances) - const index = distances.findIndex((d) => d === min) - if (min / str.length > threshold) return null - - return strings[index] -} diff --git a/src/utils/string/editDistance.ts b/src/utils/string/editDistance.ts deleted file mode 100644 index d49a53447..000000000 --- a/src/utils/string/editDistance.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Return the edit distance between two strings. - */ -export function editDistance(a: string, b: string): number { - if (a.length === 0) return b.length - if (b.length === 0) return a.length - - const matrix = new Array(b.length + 1) - // Increment along the first column of each row - for (let i = 0; i <= b.length; i++) { - matrix[i] = new Array(a.length + 1) - matrix[i][0] = i - } - - // Increment each column in the first row - for (let i = 0; i <= a.length; i++) { - matrix[0][i] = i - } - - // Fill matrix - for (let i = 1; i <= b.length; i++) { - for (let j = 1; j <= a.length; j++) { - if (b.charAt(i - 1) === a.charAt(j - 1)) { - matrix[i][j] = matrix[i - 1][j - 1] - } else { - matrix[i][j] = Math.min( - matrix[i - 1][j - 1] + 1, // substitution - matrix[i][j - 1] + 1, // insertion - matrix[i - 1][j] + 1 // deletion - ) - } - } - } - - return matrix[b.length][a.length] -} diff --git a/src/utils/typeof.ts b/src/utils/typeof.ts deleted file mode 100644 index 98fd5d498..000000000 --- a/src/utils/typeof.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function getTypeOf(val: unknown) { - if (Array.isArray(val)) return 'array' - else if (val === null) return 'null' - else if (typeof val === 'number') { - if (Number.isInteger(val)) return 'integer' - else return 'number' - } - - return typeof val -} diff --git a/src/utils/wait.ts b/src/utils/wait.ts deleted file mode 100644 index 9386833e7..000000000 --- a/src/utils/wait.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function wait(timeMs: number) { - return new Promise((resolve) => setTimeout(() => resolve(), timeMs)) -} diff --git a/src/utils/whenIdle.ts b/src/utils/whenIdle.ts deleted file mode 100644 index 88ee92de2..000000000 --- a/src/utils/whenIdle.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { disposableTimeout } from './disposableTimeout' - -declare const requestIdleCallback: - | ((cb: () => Promise | void) => number) - | undefined -declare const cancelIdleCallback: ((handle: number) => void) | undefined - -export const supportsIdleCallback = typeof requestIdleCallback === 'function' - -export function whenIdle(cb: () => Promise | void) { - return new Promise((resolve) => { - if (typeof requestIdleCallback === 'function') { - requestIdleCallback(async () => { - await cb() - resolve() - }) - } else { - setTimeout(async () => { - await cb() - resolve() - }, 10) - } - }) -} - -export const whenIdleDisposable = (cb: () => Promise | void) => { - if (typeof requestIdleCallback !== 'function') { - return disposableTimeout(cb, 1) - } - - let callbackId: number | undefined = requestIdleCallback(() => { - callbackId = undefined - cb() - }) - - return { - dispose: () => { - if (callbackId && typeof cancelIdleCallback === 'function') - cancelIdleCallback(callbackId) - callbackId = undefined - }, - } -} diff --git a/src/utils/worker/inject.ts b/src/utils/worker/inject.ts deleted file mode 100644 index 65fdec1b9..000000000 --- a/src/utils/worker/inject.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @ts-ignore Make "path" work on this worker -globalThis.process = { - cwd: () => '', - env: {}, - release: { - name: 'browser', - }, -} - -// This Tauri IPC workaround is only needed for the Tauri app -if ( - import.meta.env.VITE_IS_TAURI_APP && - !(globalThis)._didSetupTauriWorkaround -) { - ;(globalThis)._didSetupTauriWorkaround = true - - // @ts-ignore - globalThis.window = globalThis - // @ts-ignore - globalThis.__TAURI_IPC__ = (ipcContext) => { - self.postMessage({ type: 'ipc', ipcContext }) - } - - globalThis.addEventListener('message', (e) => { - if (e.data.type === 'ipcCallback') { - const callbackId = `_${e.data.id}` - - if (typeof (window)[callbackId] === 'function') { - ;(window)[callbackId](e.data.data) - } else { - console.warn(`No callback found for id ${e.data.id}!`) - } - } - }) -} diff --git a/src/utils/worker/setup.ts b/src/utils/worker/setup.ts deleted file mode 100644 index e3b8d68db..000000000 --- a/src/utils/worker/setup.ts +++ /dev/null @@ -1,35 +0,0 @@ -export function setupWorker(worker: Worker) { - // This Tauri IPC workaround is only needed for the Tauri app - if (!import.meta.env.VITE_IS_TAURI_APP) return - - worker.addEventListener('message', (e) => { - if (e.data.type === 'ipc') { - // @ts-ignore - const { callback, error: errorId } = e.data.ipcContext - - // @ts-ignore - window[`_${errorId}`] = (error: any) => { - worker.postMessage({ - type: 'ipcCallback', - id: errorId, - data: error, - }) - Reflect.deleteProperty(window, `_${errorId}`) - Reflect.deleteProperty(window, `_${callback}`) - } - // @ts-ignore - window[`_${callback}`] = (data: any) => { - worker.postMessage({ - type: 'ipcCallback', - id: callback, - data, - }) - Reflect.deleteProperty(window, `_${errorId}`) - Reflect.deleteProperty(window, `_${callback}`) - } - - // @ts-ignore - window.__TAURI_IPC__(e.data.ipcContext) - } - }) -} diff --git a/tailwind.config.js b/tailwind.config.js index a0946e100..57952f04c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,50 +1,55 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ['./src/**/*.{html,tsx,vue}'], + content: ['./src/**/*.{html,vue}'], darkMode: 'class', theme: { extend: { - keyframes: { - 'fade-in': { - '0%': { opacity: '0%' }, - '100%': { opacity: '100%' }, - }, - 'scale-up': { - '0%': { transform: 'scale(0.5)' }, - '95%': { transform: 'scale(1.05)' }, - '100%': { transform: 'scale(1)' }, - }, - 'fade-out': { - '0%': { opacity: '100%' }, - '100%': { opacity: '0%' }, - }, - 'scale-down': { - '0%': { transform: 'scale(1)' }, - '100%': { transform: 'scale(0.5)' }, - }, + colors: { + primary: 'var(--theme-color-primary)', + + accent: 'var(--theme-color-accent)', + 'accent-secondary': 'var(--theme-color-accentSecondary)', + + background: 'var(--theme-color-background)', + 'background-secondary': 'var(--theme-color-backgroundSecondary)', + 'background-tertiary': 'var(--theme-color-backgroundTertiary)', + + text: 'var(--theme-color-text)', + 'text-secondary': 'var(--theme-color-textSecondary)', + + behaviorPack: 'var(--theme-color-behaviorPack)', + resourcePack: 'var(--theme-color-resourcePack)', + skinPack: 'var(--theme-color-skinPack)', + worldTemplate: 'var(--theme-color-worldTemplate)', + + warning: 'var(--theme-color-warning)', + info: 'var(--theme-color-info)', + error: 'var(--theme-color-error)', + success: 'var(--theme-color-success)', + + toolbar: 'var(--theme-color-toolbar)', }, - animation: { - 'fade-in': 'fade-in 0.15s ease-in-out', - 'scale-up': 'scale-up 0.15s ease-in-out', - 'fade-in-and-scale-up': - 'fade-in 0.15s ease-in-out, scale-up 0.15s ease-in-out', - 'fade-out': 'fade-out 0.15s ease-in-out', - 'scale-down': 'scale-down 0.15s ease-in-out', - 'fade-out-and-scale-down': - 'fade-out 0.15s ease-in-out, scale-down 0.15s ease-in-out', + height: { + toolbar: '1.5rem', + app: 'calc(100vh - 1.5rem)', }, - backdropBlur: { - xs: '2px', + maxHeight: { + toolbar: '1.5rem', + app: 'calc(100vh - 1.5rem)', }, - colors: { - 'surface-dark': 'var(--v-toolbar-base)', - surface: 'var(--v-background-base)', - primary: 'var(--v-primary-base)', - error: 'var(--v-error-base)', - warning: 'var(--v-warning-base)', - info: 'var(--v-info-base)', - text: 'var(--v-text-base)', - accent: 'var(--v-accent-base)', + spacing: { + toolbar: '1.5rem', + }, + boxShadow: { + window: '0 0 16px -2px rgb(0, 0, 0, 0.4)', + }, + fontSize: { + 'theme-editor': 'var(--theme-font-size-editor)', + }, + fontFamily: { + theme: 'var(--theme-font)', + 'theme-editor': 'var(--theme-font-editor)', + inter: ['Inter', 'ui-sans-serif', 'system-ui', 'Segoe UI', 'Roboto', 'sans-serif'], }, }, }, diff --git a/tsconfig.json b/tsconfig.json index 5c9dfd378..2a8ec3350 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,29 +1,23 @@ { "compilerOptions": { - "target": "esnext", - "module": "esnext", + "target": "ESNext", + "module": "ESNext", "strict": true, - "jsx": "preserve", - "jsxImportSource": "solid-js", "importHelpers": true, - "moduleResolution": "node", + "moduleResolution": "Node", "skipLibCheck": true, "esModuleInterop": true, "resolveJsonModule": true, "allowSyntheticDefaultImports": true, "sourceMap": true, "baseUrl": ".", - "types": [ - "@types/wicg-file-system-access", - "node", - "vite-plugin-pwa/client" - ], + "types": ["@types/wicg-file-system-access", "vite-plugin-pwa/client"], "paths": { - "/@/*": ["src/*"] + "@/*": ["src/*"] }, - "lib": ["esnext", "dom", "dom.iterable", "scripthost", "WebWorker"], + "lib": ["ESNext", "DOM", "DOM.Iterable", "WebWorker"], "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "include": ["src/**/*.ts", "src/**/*.vue"], "exclude": ["node_modules"] } diff --git a/vite.config.ts b/vite.config.ts index e0eb40621..1e2e0ebf8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,10 @@ -import { defineConfig, splitVendorChunkPlugin } from 'vite' +import { defineConfig } from 'vite' import { resolve, join } from 'path' -import Vue from '@vitejs/plugin-vue2' -import solidPlugin from 'vite-plugin-solid' +import vue from '@vitejs/plugin-vue' import { VitePWA } from 'vite-plugin-pwa' -import { ViteEjsPlugin } from 'vite-plugin-ejs' + const isNightly = process.argv[2] === '--nightly' -const iconPath = (filePath: string) => - isNightly ? `./img/icons/nightly/${filePath}` : `./img/icons/${filePath}` +const iconPath = (filePath: string) => (isNightly ? `./img/icons/nightly/${filePath}` : `./img/icons/${filePath}`) // https://vitejs.dev/config/ export default defineConfig({ @@ -14,81 +12,30 @@ export default defineConfig({ server: { strictPort: true, port: 8080, - watch: { - ignored: ['**/src-tauri/**/*'], - }, }, - envPrefix: ['VITE_', 'TAURI_'], json: { stringify: true, }, resolve: { alias: { - '/@': join(__dirname, 'src'), - vue: 'vue/dist/vue.esm.js', - molangjs: join(__dirname, './src/utils/MoLangJS.ts'), + '@': join(__dirname, 'src'), + vue: 'vue/dist/vue.esm-bundler.js', }, }, - build: { - rollupOptions: { - input: { - main: resolve(__dirname, 'index.html'), - // nested: resolve(__dirname, 'nested/index.html'), - }, - }, - }, - worker: { - format: 'es', - }, plugins: [ - splitVendorChunkPlugin(), - solidPlugin(), - Vue({}), - ViteEjsPlugin({ - isNightly, - title: isNightly ? 'bridge Nightly' : 'bridge v2', - }), - - /** - * VS Code's JSON language files has an issue with large JSON files - * This is caused by Array.prototype.push.apply(...) throws a maximum call stack size exceeded error - * with sufficiently large arrays - * - * https://github.com/bridge-core/editor/issues/447 - */ - { - name: 'fix-vscode-json-language-service-bug', - transform: (source, id) => { - if ( - id.includes('json.worker.js') && - id.includes('node_modules/monaco-editor/') - ) - return source.replace( - 'Array.prototype.push.apply(this.schemas, other.schemas);', - 'this.schemas = this.schemas.concat(other.schemas);' - ) - - return source - }, - }, - + vue(), VitePWA({ filename: 'service-worker.js', registerType: 'prompt', - includeAssets: [ - 'https://fonts.bunny.net/css?family=Roboto:100,300,400,500,700,900', - ], + includeAssets: ['https://fonts.bunny.net/css?family=Roboto:100,300,400,500,700,900'], workbox: { - globPatterns: [ - '**/*.{js,css,html,png,svg,woff2,woff,ttf,wasm,zip}', - ], + globPatterns: ['**/*.{js,css,html,png,svg,woff2,woff,ttf,wasm,zip}'], maximumFileSizeToCacheInBytes: Number.MAX_SAFE_INTEGER, }, manifest: { name: isNightly ? 'bridge Nightly' : 'bridge v2', short_name: isNightly ? 'bridge Nightly' : 'bridge v2', - description: - 'bridge. is a powerful IDE for Minecraft Bedrock Add-Ons.', + description: 'bridge. is a powerful IDE for Minecraft Bedrock Add-Ons.', categories: ['development', 'utilities', 'productivity'], theme_color: '#1778D2', icons: [ @@ -118,8 +65,8 @@ export default defineConfig({ start_url: '.', display: 'standalone', background_color: '#0F0F0F', - // @ts-ignore launch_handler: { + // @ts-ignore route_to: 'existing-client-retain', navigate_existing_client: 'never', }, @@ -127,12 +74,7 @@ export default defineConfig({ { action: '/', accept: { - 'application/zip': [ - '.mcaddon', - '.mcpack', - '.mcworld', - '.mctemplate', - ], + 'application/zip': ['.mcaddon', '.mcpack', '.mcworld', '.mctemplate'], 'application/json': ['.json', '.bbmodel'], 'application/javascript': ['.js', '.ts'], 'text/plain': ['.mcfunction', '.molang', '.lang'],