lively.ide_studio_component-browser.cp.js

import { pt, Color, rect } from 'lively.graphics';
import { TilingLayout, morph, config, easings, MorphicDB, Icon, Morph, Label, ShadowObject, ViewModel, add, part, component } from 'lively.morphic';
import { Project } from 'lively.project';
import { isModuleLoaded, module } from 'lively.modules';
import { InputLineDefault } from 'lively.components/inputs.cp.js';
import { MullerColumnView, ColumnListDark, ColumnListDefault } from 'lively.components/muller-columns.cp.js';
import { TreeData, LabeledCheckbox, LabeledCheckboxLight } from 'lively.components';
import { arr, promise, num, date, string, fun } from 'lively.lang';
import { resource } from 'lively.resources';
import { renderMorphToDataURI } from 'lively.morphic/rendering/morph-to-image.js';
import { localInterface } from 'lively-system-interface';
import { once, noUpdate } from 'lively.bindings/index.js';
import { adoptObject } from 'lively.lang/object.js';
import { DropDownList, DarkDropDownList } from 'lively.components/list.cp.js';
import { withAllViewModelsDo } from 'lively.morphic/components/policy.js';
import { ButtonDarkDefault, SystemButton } from 'lively.components/buttons.cp.js';
import { Text } from 'lively.morphic/text/morph.js';
import { COLORS } from '../js/browser/index.js';
import { Spinner, DarkPopupWindow } from './shared.cp.js';
import { InteractiveComponentDescriptor } from '../components/editor.js';
import { PopupWindow, SystemList } from '../styling/shared.cp.js';
import { joinPath } from 'lively.lang/string.js';
import { runCommand } from 'lively.shell/client-command.js';
import ShellClientResource from 'lively.shell/client-resource.js';
import { StatusMessageError, StatusMessageConfirm } from 'lively.halos/components/messages.cp.js';
import { unsubscribe, subscribe } from 'lively.notifications/index.js';

class MasterComponentTreeData extends TreeData {
  /**
   * Create a tree data object listing master component files.
   * @param { object } props
   * @property { ComponentBrowserModel } props.browser - Reference to the component browser view model.
   */
  constructor (props) {
    super(props);
    this.ensurePopularComponentsCollection();
  }

  isCollapsed ({ isCollapsed }) { return isCollapsed; }

  async collapse (node, bool) {
    if (node === this.root) {
      bool = false; // never collapse root
      node.subNodes = await this.listAllComponentCollections();
    }
    if (!bool && node.type === 'package loader') {
      // clear the selection
      this.columnView.reset();
      this.interactivelyImportProject();
      return;
    }
    node.isCollapsed = bool;
    node.isDirty = true;
    if (!bool) {
      const loadedFiles = await this.getLoadedComponentFileUrls();
      if (node.type === 'package') {
        node.subNodes = await this.listComponentFilesInPackage(node.url, loadedFiles);
      }

      if (node.children) {
        node.children.forEach(child => {
          child.isDeclaration = true;
          child.isCollapsed = true;
          child.parent = node;
        });
        node.subNodes = node.children;
      }

      if (node.type === 'directory') {
        node.subNodes = await this.listComponentFilesInDir(node.url, loadedFiles);
      }

      if (node.type === 'cp.js') {
        node.subNodes = await this.listModuleScope(node.url);
      }
    }
  }

  getChildren (parent) {
    let { subNodes } = parent;
    let result = subNodes || [];
    result && result.forEach(n => this.parentMap.set(n, parent));
    return result;
  }

  isLeaf ({ type, isDeclaration, children }) {
    if (isDeclaration) return !children;
    return !['package', 'directory', 'cp.js'].includes(type);
  }

  display (node) {
    const { type, pkg, isCollapsed, componentObject, lastModified, size, name, url } = node;
    const isSelected = this.columnView.isSelected(node);
    if (type === 'package') {
      return this.displayPackage(pkg, isSelected);
    } else if (type === 'package loader') {
      return this.renderPackageLoader();
    } else if (componentObject) {
      return this.displayComponent(componentObject, isSelected);
    } else {
      let col1Size = 19;
      let datePrinted = lastModified
        ? date.format(lastModified, 'yyyy-mm-dd HH:MM:ss')
        : ' '.repeat(col1Size);
      let sizePrinted = size ? num.humanReadableByteSize(size) : '';
      let displayedName;

      switch (type) {
        case 'cp.js':
          if (!node.isLoaded && isSelected) node.isLoaded = true;
          displayedName = this.displayComponentFile(name, isSelected, node.isLoaded, url);
          break;
        case 'directory':
          displayedName = this.displayDirectory(name, !isCollapsed);
          break;
      }

      return [
        ...displayedName,
        `\t${sizePrinted} ${datePrinted}`, {
          paddingTop: '3px',
          opacity: 0.5,
          fontSize: '70%',
          textStyleClasses: ['annotation']
        }
      ];
    }
  }

  renderPackageLoader () {
    return [...Icon.textAttribute('ti-square-rounded-arrow-right', { fontColor: Color.darkGray }), ' Import project...', {
      fontColor: Color.darkGray,
      fontWeight: 'bold',
      fontStyle: 'italic',
      nativeCursor: 'pointer'
    }];
  }

  /**
   * @returns { Morph } - The visual representation of the muller columns view presenting this data.
   */
  get columnView () { return this.root.browser.models.componentFilesView; }

  /**
   * @returns { LivelySystemInterface } - Returns the local interface to be used to resolve the modules (client only)
   */
  get systemInterface () { return localInterface; }

  /**
   * Returns the text attributes needed to display an entry of the list covering the
   * components contained in the currently openend component file.
   * @param { Object } componentDecl - The code entity representing the declaration of the component.
   */
  displayComponent (componentObj, isSelected) {
    if (isSelected && !this.root.browser._pauseUpdates) {
      this.root.browser.selectComponent(componentObj, true);
    }
    return [...Icon.textAttribute('cube'), ' ' + string.truncate(componentObj.componentName || '[PARSE_ERROR]', 18, '…'), null];
  }

  /**
   * Returns the text attributes needed to display a package that contains a collection
   * of component files.
   */
  displayPackage (pkg, isSelected) {
    if (isSelected && !this.root.browser._pauseUpdates) {
      this.root.browser.reset(false);
    }
    const isOpenedProject = pkg.url === $world.openedProject?.package.url;
    return [
      ...Icon.textAttribute('cubes'),
      ' ' + string.truncate(pkg.name, 26, '…'), {
        fontWeight: isOpenedProject ? 'bold' : 'normal',
        fontStyle: pkg.kind === 'git' && !isOpenedProject ? 'italic' : 'normal'
      },
      `\t${pkg.kind}`, {
        paddingTop: '3px',
        opacity: 0.5,
        fontSize: '70%',
        textStyleClasses: ['annotation']
      }
    ];
  }

  /**
   * Returns the text attributes needed to display a directory that contains a collection
   * of component files.
   * @param { String } dir - The name of the directory.
   * @param { Boolean } isOpen - Wether or not the directory is currently opened.
   */
  displayDirectory (dir, isOpen) {
    if (isOpen && !this.root.browser._pauseUpdates) {
      this.root.browser.reset(false);
    }
    return [
      ...Icon.textAttribute(isOpen ? 'folder-open' : 'folder', {
        fontWeight: '400'
      }),
      ' ' + dir, null
    ];
  }

  /**
   * Returns the text attributes needed to display an entry that represents a component file.
   */
  displayComponentFile (modUrl, isSelected, isLoaded, url) {
    if (isSelected && !this.root.browser._pauseUpdates) {
      const mod = module(url);
      const pkg = mod.package();
      const isOpenedProject = pkg && pkg.url === $world.openedProject?.package.url;
      modUrl = arr.last(modUrl.split('--'));
      this.getComponentsInModule(url).then(components => {
        this.root.browser.showComponentsInFile(modUrl, components, isOpenedProject);
      });
    }

    return [
      ...Icon.textAttribute('shapes', {
        fontColor: isSelected ? Color.white : COLORS.cp,
        opacity: isLoaded ? 1 : 0.5
      }),
      ' ' + string.truncate(modUrl.replace('.cp.js', ''), 24, '…'), null
    ];
  }

  async getComponentsInModule (moduleName) {
    return await this.root.browser.getComponentsInModule(moduleName);
  }

  async listModuleScope (moduleName) {
    const exportedComponents = await this.getComponentsInModule(moduleName);
    return exportedComponents.map(componentObject => {
      return {
        isCollapsed: true,
        isDeclaration: true,
        componentObject
      };
    });
  }

  async interactivelyImportProject () {
    const availableProjects = await Project.listAvailableProjects();
    const notLoaded = availableProjects
      .filter(proj => !isModuleLoaded(joinPath(proj.url, proj.main || 'index.js')))
      .map(proj => ({
        isListItem: true,
        value: proj,
        tooltip: `${proj.name} by ${proj.projectRepoOwner} at version ${proj.version}`,
        label: [
          proj.name, {},
          proj.version, {
            paddingLeft: '5px',
            fontSize: '70%',
            textStyleClasses: ['truncated-text', 'annotation']
          }
        ]
      }));
    const win = this.root.browser.view.getWindow();
    const { selected: [projectToLoad] } = await $world.filterableListPrompt('Select project to import', notLoaded, {
      requester: win,
      multiSelect: false
    });
    if (projectToLoad) {
      await $world.withLoadingIndicatorDo(async () => {
        await Project.loadProject(projectToLoad.name, true);
        this.root.subNodes = await this.listAllComponentCollections();
        this.columnView.refresh();
      }, win, 'Importing project...');
    }
  }

  /**
   * Returns all the custom project inside the lively.next projects folder
   * that may contain custom component definitions.
   * @todo Implement this feature.
   */
  async getCustomLocalProjects () {
    return (await Project.listAvailableProjects()).filter(({ main = 'index.js', url }) => {
      return isModuleLoaded(joinPath(url, main));
    });
  }

  /**
   * Initializes a local folder comprising a set of "popular" component files such as
   * buttons, input fields or lists.
   */
  async ensurePopularComponentsCollection () {
    const res = resource('local://VeryPopularComponents');
    ['buttons', 'list', 'inputs'].map(async name => {
      res.join(name + '.cp.js').write('redirect -> ' + await System.decanonicalize(`lively.components/${name}.cp.js`));
    });
    ['value-widgets', 'styling/color-picker'].map(async name => {
      res.join(name + '.cp.js').write('redirect -> ' + await System.decanonicalize(`lively.ide/${name}.cp.js`));
    });
  }

  async getComponentCollections () {
    return [
      ...await Promise.all(['lively.ide', 'lively.components'].map(pkgName => this.systemInterface.getPackage(pkgName))),
      ...await this.getCustomLocalProjects(),
      { name: 'Popular', url: 'local://VeryPopularComponents' }
    ];
  }

  async listAllComponentCollections () {
    let coll = await this.getComponentCollections();
    coll = arr.sortBy(coll.map(pkg => {
      let kind = 'git';
      if (pkg.url?.startsWith('local')) kind = 'local';
      if (pkg.name.startsWith('lively')) kind = 'core';
      pkg.kind = kind;
      return {
        url: pkg.url + (pkg.url.endsWith('/') ? '' : '/'),
        isCollapsed: true,
        type: 'package',
        name: pkg.name,
        tooltip: pkg.name,
        pkg
      };
    }), ({ pkg }) => ({ core: 2, local: 1, git: 3 }[pkg.kind]));
    if (!this.root.browser.selectionMode) {
      coll.push({
        type: 'package loader',
        isCollapsed: true,
        tooltip: 'Import additional project'
      });
    }
    return coll;
  }

  async listComponentFilesInPackage (pkg, loadedFiles) {
    return await this.listComponentFilesInDir(pkg, loadedFiles);
  }

  async getLoadedComponentFileUrls () {
    const selectedPkg = this.root.subNodes.find(pkg => !pkg.isCollapsed);
    if (!selectedPkg) return {};
    const files = await resource(selectedPkg.url).dirList('infinity', {
      exclude: (res) => {
        if (res.url.match(/\.git|\.gitignore|.github|assets|build/)) return true;
        return !(res.url.endsWith('.cp.js') || res.isDirectory() || !module(res.url).isLoaded());
      }
    });
    if (selectedPkg.name !== 'Popular') {
      // ensure the package is present in the system
      // so that we do not get any orphaned modules...
      await this.systemInterface.getPackage(selectedPkg.url);
    }
    const loadedModules = {};
    files.forEach(file => {
      loadedModules[file.url] = file;
    });
    return loadedModules;
  }

  async listComponentFilesInDir (folderLocation, loadedFiles) {
    if (!loadedFiles) loadedFiles = await this.getLoadedComponentFileUrls();
    const resources = (await resource(folderLocation).dirList(1, {
      exclude: (res) => {
        if (res.name() === 'assets' || res.name() === 'tests' || res.name() === 'node_modules') return true;
        return !((res.url.endsWith('.cp.js') || res.isDirectory()) && !res.name().startsWith('.'));
      }
    }));
    // ensure that the package is loaded at this point
    // ensure that we only list folders who will in turn have anything to show
    const files = arr.compact(await Promise.all(resources.map(async res => {
      let type;
      if (res.isDirectory()) {
        type = 'directory';
        if ((await this.listComponentFilesInDir(res.url, loadedFiles)).length === 0) return;
      } else {
        type = 'cp.js';
        if ((await res.read()).match(/['"]skip listing['"];/)) return;
        if (res.url.endsWith('.cp.js') && (await this.getComponentsInModule(res.url)).length === 0) return;
      }
      return {
        isDirty: true, // ensure proper rendering
        isCollapsed: true,
        name: res.name(),
        size: res.size,
        lastModified: res.lastModified,
        url: res.url,
        type
      };
    })));

    return files.map(file => {
      file.isLoaded = !!loadedFiles[file.url];
      return file;
    });
  }
}

export class ExportedComponent extends Morph {
  static get properties () {
    return {
      project: {},
      componentBrowser: {
        derived: true,
        get () {
          return this.ownerChain().find(m => m.isComponentBrowser);
        }
      },
      dragTriggerDistance: {
        get () { return 30; }
      },
      fetchUrl: {
        after: ['submorphs'],
        set (url) {
          this.setProperty('fetchUrl', url);
          this.updateLabel();
        }
      },
      isSelected: {},
      isInOpenedProject: {
        derived: true,
        readOnly: true,
        get () {
          return $world.openedProject?.package.url === this.package.url;
        }
      },
      package: {
        get () {
          return module(this.component[Symbol.for('lively-module-meta')].moduleId).package();
        }
      },
      preview: {
        derived: true,
        set (url) {
          this.getSubmorphNamed('preview holder').imageUrl = url;
          this.fitPreview();
        }
      },
      component: {
        set (cp) {
          this.setProperty('component', cp);
          this.generatePreview();
        }
      }
    };
  }

  generatePreview () {
    try {
      const preview = part(this.component, { defaultViewModel: null, name: this.component.componentName });
      const container = this.get('preview container');
      const maxExtent = pt(100, 70);
      const padding = -10;
      preview.scale = 1;
      // This is needed since the centering via css layouts gets currently quite
      // confused when transforms are applied (scale, rotation)
      const previewBoundsWrapper = morph({ fill: Color.transparent, reactsToPointer: false, extent: preview.bounds().extent(), submorphs: [preview] });
      preview.topLeft = pt(0, 0);
      previewBoundsWrapper.scale = Math.min(
        maxExtent.x / (previewBoundsWrapper.bounds().width - padding),
        maxExtent.y / (previewBoundsWrapper.bounds().height - padding));
      container.submorphs = [previewBoundsWrapper];
      preview.withAllSubmorphsDo(m => m.reactsToPointer = false);
    } catch (err) {
      this.displayError(err);
    }
    this.get('component name').textString = string.decamelize(this.component.componentName);
  }

  displayError (err) {
    this.get('preview container').submorphs = [
      part(ComponentError, { submorphs: [{ name: 'error message', textString: err.message }] }) // eslint-disable-line no-use-before-define
    ];
  }

  async initExportIndicatorIfNeeded () {
    if (this.fetchUrl.startsWith('part://$world/')) {
      const exportIndicator = this.addMorph(
        this.getSubmorphNamed('export indicator') ||
        await resource('part://SystemDialogs/export indicator').read()
      );
      exportIndicator.name = 'export indicator';
      exportIndicator.fetchUrl = this.fetchUrl;
      exportIndicator.isLayoutable = false;
      return exportIndicator;
    }
  }

  async fitPreview () {
    const img = this.getSubmorphNamed('preview holder');
    if (!this.world()) img.opacity = 0;
    await this.master.whenApplied();
    const naturalExtent = await img.determineNaturalExtent();
    // scale the preview down to fit into width and height;
    const maxWidth = 130;
    const maxHeight = 130;
    const scaleFactor = Math.min(maxWidth / naturalExtent.x, maxHeight / naturalExtent.y);
    img.extent = naturalExtent.scaleBy(scaleFactor);
    img.opacity = 1;
    const exportIndicator = this.getSubmorphNamed('export indicator') || await this.initExportIndicatorIfNeeded();
    if (!exportIndicator) {
      return;
    }
    // ensure the layout has applied itself already...
    exportIndicator.topRight = this.innerBounds().insetBy(2).topRight();
  }

  updateLabel () {
    const nameLabel = this.getSubmorphNamed('component name');
    nameLabel.value = resource(this.fetchUrl).url.replace(/part:\/\/[^\/]*\//, '');
    this.tooltip = this.fetchUrl;
  }

  onMouseDown (evt) {
    super.onMouseDown(evt);
    // this is pretty bad style
    if (this.project) {
      this.project.selectComponent(this);
      // notify the column view to update accordingly if active...
    }
  }

  onDrag (evt) {
    if (!this.component) return;
    const [{ scale }] = this.getSubmorphNamed('preview container').submorphs;
    const instance = part(this.component, { scale });

    if (!this.componentBrowser.importAlive) {
      // disable the behavior
      withAllViewModelsDo(instance, m => m.viewModel.detach());
    }
    // on drop scale to 1
    instance.openInHand();
    const grabShadow = new ShadowObject({ fast: false, color: Color.rgba(0, 0, 0, 0.6), blur: 40 });
    instance.animate({
      scale: 1,
      center: instance.center,
      dropShadow: grabShadow,
      duration: 300
    });
    once(instance, 'onBeingDroppedOn', async (hand) => {
      if (this.componentBrowser.globalBounds().containsPoint(hand.position)) {
        instance.openInWorld(hand.position);
        await instance.animate({ center: this.globalBounds().center(), opacity: 0, duration: 300 });
        instance.remove();
      }
    });
  }

  select (active) {
    this.isSelected = active;
    this.master.setState(active ? 'selected' : null);
  }
}

export class ProjectEntry extends Morph {
  static get properties () {
    return {
      exportedComponents: {
        derived: true,
        get () {
          return this.getSubmorphNamed('component previews').submorphs.map(m => m.component);
        }
      },
      selectedComponent: {
        derived: true,
        get () {
          const selectedPreview = this.getSubmorphNamed('component previews').submorphs.find(m => m.isSelected);
          return selectedPreview && selectedPreview.component;
        }
      },
      previewMaster: {
        isComponent: true,
        initialize () {
          this.previewMaster = ComponentPreview; // eslint-disable-line no-use-before-define
        }
      },
      selectedPreviewMaster: {
        isComponent: true,
        initialize () {
          this.selectedPreviewMaster = ComponentPreviewSelected; // eslint-disable-line no-use-before-define
        }
      },
      worldName: {
        derived: true,
        set (name) {
          this.getSubmorphNamed('project title').value = [name, {}, '  ', {}].concat(name === 'This Project' ? [] : Icon.textAttribute('external-link-square-alt', { paddingTop: '3px' }));
        },
        // this.worldName
        get () {
          return this.getSubmorphNamed('project title').value[0];
        }
      }
    };
  }

  onMouseUp (evt) {
    super.onMouseUp(evt);
    if (this._navigationDisabled) return;
    const projectTitle = this.getSubmorphNamed('project title');
    if (projectTitle.textBounds().containsPoint(evt.positionIn(projectTitle))) { this.openComponentWorld(); }
  }

  async openComponentWorld () {
    const selectedComponent = this.selectedComponent || this.exportedComponents[0];
    const { moduleId: moduleName, exportedName: name } = selectedComponent[Symbol.for('lively-module-meta')];
    await $world.execCommand('open browser', { moduleName, codeEntity: [{ name }] });
  }

  renderComponents (components) {
    const previewContainer = this.getSubmorphNamed('component previews');
    previewContainer.submorphs = components.map(cp => {
      let preview = previewContainer.submorphs.find(p => p.component === cp);
      if (!preview) {
        preview = part(this.previewMaster, {
          master: {
            states: {
              selected: this.selectedPreviewMaster
            }
          }
        });
        preview.project = this;
        preview.component = cp;
      }
      return preview;
    });
    return this;
  }

  selectComponent (component) {
    this.owner.getSubmorphsByStyleClassName('ExportedComponent').forEach(m => m.select(false));
    component.select(true);
  }

  disableNavigation () {
    this._navigationDisabled = true;
    this.getSubmorphNamed('project title').nativeCursor = 'auto';
    this.getSubmorphNamed('project title').value = [this.worldName, {}];
  }
}

export class NameSection extends ProjectEntry {
  static get properties () {
    return {
      char: {
        derived: true,
        set (name) {
          this.getSubmorphNamed('project title').value = [name, null];
        },
        get () {
          return this.getSubmorphNamed('project title').value[0];
        }
      }
    };
  }
}

export class ExportIndicator extends Morph {
  static get properties () {
    return {
      fetchUrl: {
        set (url) {
          this.setProperty('fetchUrl', url.replace('part://$world/', ''));
          this.exported = this.isExported(this.fetchUrl);
        }
      },
      exported: {
        after: ['submorphs'],
        set (isExported) {
          this.setProperty('exported', isExported);
          this.updateStyle();
        }
      },
      ui: {
        get () {
          return {
            publicIndicator: this.getSubmorphNamed('public indicator'),
            privateIndicator: this.getSubmorphNamed('private indicator')
          };
        }
      }
    };
  }

  updateStyle () {
    const { publicIndicator, privateIndicator } = this.ui;
    publicIndicator.isLayoutable = publicIndicator.visible = this.exported;
    privateIndicator.isLayoutable = privateIndicator.visible = !this.exported;
  }

  onMouseDown (evt) {
    super.onMouseDown(evt);
    this.toggleExport();
  }

  isExported (url) {
    return !$world.hiddenComponents.includes(url);
  }

  toggleExport () {
    this.exported = !this.exported;
    if (this.exported) {
      this.exportComponent();
    } else {
      this.hideComponent();
    }
  }

  exportComponent () {
    $world.hiddenComponents = arr.without($world.hiddenComponents, this.fetchUrl);
  }

  hideComponent () {
    $world.hiddenComponents = [this.fetchUrl, ...$world.hiddenComponents];
  }
}

const SearchComponentsNotice = component({
  extent: pt(663.7, 592.1),
  layout: new TilingLayout({
    axis: 'column',
    axisAlign: 'center',
    orderByIndex: true
  }),
  fill: Color.rgba(255, 255, 255, 0),
  submorphs: [
    {
      type: Text,
      name: 'component box',
      extent: pt(164, 231),
      dropShadow: new ShadowObject({ color: Color.rgba(0, 0, 0, 0.16), blur: 15, fast: false }),
      fontColor: Color.rgba(0, 0, 0, 0.25),
      fontSize: 164,
      fontWeight: 700,
      position: pt(-5, 26),
      textAndAttributes: ['', {
        fontFamily: 'Tabler Icons',
        fontWeight: '900'
      }]

    }, {
      type: 'text',
      name: 'notice',
      textAlign: 'center',
      fixedWidth: true,
      fontColor: Color.rgba(0, 0, 0, 0.25),
      dropShadow: new ShadowObject({ color: Color.rgba(0, 0, 0, 0.16), blur: 15, fast: false }),
      fontSize: 34,
      fontWeight: 700,
      lineWrapping: 'by-words',
      textAndAttributes: ['Begin searching to display components...', null],
      extent: pt(354.6, 191)
    }]
});

const SearchComponentsNoticeDark = component(SearchComponentsNotice, {
  submorphs: [{
    name: 'component box',
    fontColor: Color.rgba(255, 255, 255, 0.25)
  }, {
    name: 'notice',
    fontColor: Color.rgba(255, 255, 255, 0.25)
  }]
});

export class ComponentBrowserModel extends ViewModel {
  static get properties () {
    return {
      isComponentBrowser: {
        get () { return true; }
      },
      sectionMaster: {
        initialize () {
          this.sectionMaster = ProjectSection; // eslint-disable-line no-use-before-define
        }
      },
      SearchComponentsNotice: {
        initialize () { this.SearchComponentsNotice = SearchComponentsNotice; }
      },
      isPrompt: { get () { return true; } },
      isEpiMorph: {
        get () { return true; }
      },
      isHaloItem: { get () { return true; } },
      importAlive: {
        defaultValue: false
      },
      selectionMode: {
        defaultValue: false
      },
      groupBy: {
        type: 'Enum',
        values: ['name', 'module'],
        defaultValue: 'module'
      },
      db: {
        serialize: false,
        readOnly: true,
        get () { return MorphicDB.default; }
      },
      expose: {
        get () {
          return ['activate', 'isComponentBrowser', 'reset', 'isEpiMorph', 'close',
            'isPrompt', 'isHaloItem', 'onWindowClose', 'menuItems', 'importAlive'];
        }
      }
    };
  }

  menuItems () {
    const checked = Icon.textAttribute('check-square', { paddingRight: '3px' });
    const unchecked = Icon.textAttribute('square', { paddingRight: '3px' });
    return [
      ['Import project...', () => {
        this.ui.componentFilesView.treeData.interactivelyImportProject();
      }],
      [[...this.importAlive ? checked : unchecked, ' Enable behavior'], () => this.importAlive = !this.importAlive],
      ['Group Components by ', [
        [[...this.groupBy === 'module' ? checked : unchecked, ' Modules'], () => { this.groupBy = 'module'; }],
        [[...this.groupBy === 'name' ? checked : unchecked, ' Names'], () => { this.groupBy = 'name'; }]
      ]]
    ];
  }

  get bindings () {
    return [
      {
        signal: 'onWindowClose',
        handler: 'close'
      },
      {
        target: 'import button',
        signal: 'fire',
        handler: 'importSelectedComponent'
      },
      {
        target: 'selection button',
        signal: 'fire',
        handler: 'chooseComponent'
      },
      { target: 'edit button', signal: 'fire', handler: 'editSelectedComponent' },
      {
        target: 'search input',
        signal: 'inputChanged',
        handler: 'filterAllComponents'
      },
      {
        signal: 'onKeyDown',
        handler: 'close',
        updater: ($reject, evt) => {
          if (evt.key === 'Escape') $reject();
        }
      },
      { signal: 'onMouseDown', handler: 'focus' },
      {
        target: /component files view|master component list/,
        signal: 'onMouseUp',
        handler: 'ensureButtonControls'
      },
      {
        target: /component files view|master component list/,
        signal: 'onMouseUp',
        handler: 'ensureComponentEntitySelected'
      },
      {
        target: 'behavior toggle',
        signal: 'checked',
        handler: 'toggleBehaviorImport'
      }, {
        model: 'sorting selector',
        signal: 'selection',
        handler: 'changeComponentGrouping'
      },
      {
        target: 'search clear button',
        signal: 'onMouseDown',
        handler: 'resetSearchInput'
      }
    ];
  }

  focus () { this.view.bringToFront(); }

  ensureButtonControls () {
    const selectedComponent = this.getSelectedComponent();
    this.models.importButton.deactivated = !selectedComponent;
    this.models.editButton.deactivated = !selectedComponent || !selectedComponent.isInOpenedProject;
    if (this.models.editButton.deactivated) this.ui.editButton.tooltip = 'You can not edit this component, since it is outside of your current project.';
    else {
      this.ui.editButton.tooltip = 'Click to start editing this component.\nNote that changes to this component will\npropagate throughout your project.';
      this.models.selectionButton.deactivated = !selectedComponent;
    }
  }

  viewDidLoad () {
    if (!this.view.isComponent) {
      this.view.withMetaDo({ metaInteraction: true }, () => {
        this.ui.componentFilesView.setTreeData(new MasterComponentTreeData({ browser: this }));
      });
    }
    const openedProject = $world.openedProject;
    if (!(openedProject?.owner === 'LivelyKernel' && openedProject?.name === 'partsbin') && !$world._partsbinUpdated) {
      const li = $world.showLoadingIndicatorFor(null, 'Updating `partsbin`');
      // This relies on the assumption, that the default directory the shell command gets dropped in is `lively.server`.
      // `install.sh` ensures that the partsbin repository exists.
      // As users should fork the partsbin to contribute, no special precaution is taken here when stashing.
      const cmd = runCommand('cd ../local_projects/LivelyKernel--partsbin && git stash && git checkout main && git pull origin main', { l2lClient: ShellClientResource.defaultL2lClient });

      cmd.whenDone().then(() => {
        if (cmd.exitCode !== 0) {
          $world.setStatusMessage('`partsbin` could not be updated.', StatusMessageError);
          return;
        }
        $world.setStatusMessage('`partsbin` updated!', StatusMessageConfirm);
        $world._partsbinUpdated = true;
        li.remove();
      });
    }
  }

  async refresh () {
    const selectedModule = this.getSelectedModule();
    const { componentFilesView, searchInput } = this.ui;
    if (!selectedModule && searchInput.input) {
      await componentFilesView.setTreeData(new MasterComponentTreeData({ browser: this }));
      this.filterAllComponents();
      return;
    }
    if (selectedModule) {
      delete selectedModule.subNodes;
      componentFilesView.treeData.collapse(selectedModule, false);
      componentFilesView.treeData.display(selectedModule);
    }
  }

  onRefresh (change) {
    super.onRefresh(change);
    this.ui.behaviorToggle.checked = this.importAlive;
    this.handleColumnViewVisibility();
    this.ui.editButton.visible = !this.isPopupModel && !this.selectionMode && config.ide.studio.componentEditViaComponentBrowser;
    this.ui.behaviorToggle.visible = !this.selectionMode;
    this.ui.importButton.visible = !this.selectionMode;
    this.ui.selectionButton.visible = this.selectionMode;
    noUpdate(() => this.ui.sortingSelector.selection = this.groupBy);
  }

  handleColumnViewVisibility () {
    const { componentFilesView, searchInput } = this.ui;
    componentFilesView.visible = false;
    if (this.groupBy === 'name') {
      // do nothing really
    } else if (!searchInput.input) componentFilesView.visible = true;
  }

  get systemInterface () { return localInterface; }

  reset (resetScroll = true) {
    this.ui.masterComponentList.submorphs = [];
    if (resetScroll) this.ui.componentFilesView.scroll = pt(0, 0);
  }

  async activate (pos = false) {
    this._refreshOnLoaded = subscribe('lively.modules/moduleloaded', () => this.refresh(), System);
    this._refreshOnChanged = subscribe('lively.modules/modulechanged', () => this.refresh(), System);
    this.ui.editButton.visible = !this.isPopupModel && config.ide.studio.componentEditViaComponentBrowser;
    this._promise = promise.deferred();
    this.ui.searchInput.focus();
    this.ensureButtonControls();
    return this._promise.promise;
  }

  onWindowClose () { this.close(); }

  close () {
    unsubscribe('lively.modules/moduleloaded', this._refreshOnLoaded, System);
    unsubscribe('lively.modules/modulechanged', this._refreshOnChanged, System);
    if (this._promise) this._promise.resolve(null);
  }

  async importSelectedComponent () {
    const selectedComponent = this.getSelectedComponent();
    const importedComponent = part(selectedComponent.component);
    if (!this.importAlive) {
      // disable the behavior
      withAllViewModelsDo(importedComponent, m => m.viewModel.detach());
    }
    importedComponent.openInWorld();
    importedComponent.world().showHaloFor(importedComponent);
  }

  chooseComponent () {
    this._promise.resolve(this.getSelectedComponent().component);
    this.close();
  }

  async editSelectedComponent () {
    const selectedComponent = this.getSelectedComponent();
    const editableComponent = await selectedComponent.component.edit();
    if (editableComponent) {
      editableComponent.applyLayoutIfNeeded();
      editableComponent.openInWorld();
    }
  }

  toggleBusyState (active) {
    this.ui.spinner.visible = active;
  }

  toggleComponentList (active) {
    const { masterComponentList } = this.ui;
    if (masterComponentList.isLayoutable === active) return;
    const center = this.world().visibleBounds().center();
    this.withAnimationDo(() => {
      masterComponentList.height = active ? 300 : 0;
      masterComponentList.isLayoutable = active;
    }, {
      duration: 300,
      easing: easings.inOutExpo
    });
    this.layout.forceLayout();
    this.animate({ center, duration: 300, easing: easings.inOutExpo });
  }

  async getComponentsInModule (moduleName) {
    let source = await this.systemInterface.moduleRead(moduleName);
    if (source.startsWith('redirect -> ')) {
      moduleName = source.replace('redirect -> ', '');
    }
    if (source.includes('component-browser skip')) return [];
    const mod = localInterface.getModule(moduleName);
    if (!mod.isLoaded()) {
      await mod.load();
    }
    const exports = await mod.exports();
    const componentDescriptors = exports
      .map(m => mod.recorder[m.exported])
      .filter(c => c && c.isComponentDescriptor);
    componentDescriptors.forEach(descr => adoptObject(descr, InteractiveComponentDescriptor));
    return componentDescriptors;
  }

  async withoutUpdates (cb) {
    this._pauseUpdates = true;
    await cb();
    this._pauseUpdates = false;
  }

  async ensureComponentEntitySelected (evt) {
    if (!evt.isClickTarget(this.ui.masterComponentList) ||
        !this.ui.componentFilesView.visible) return;

    const selectedComponent = this.getSelectedComponent();
    if (selectedComponent) {
      // expand path until node selected
      this.showComponentInFilesView(selectedComponent.component);
    }
  }

  async showComponentInFilesView (aComponentDescriptor) {
    const { treeData: td, _selectedNode: n, lists } = this.models.componentFilesView;
    if (n && n.componentObject === aComponentDescriptor) return;
    const lastList = arr.last(lists);
    const itemToSelect = lastList?.items.find(item => item.value.componentObject === aComponentDescriptor);
    if (itemToSelect) {
      this.models.componentFilesView.selectNode(itemToSelect.value, false);
      return;
    }
    const meta = aComponentDescriptor[Symbol.for('lively-module-meta')];
    if (!meta) return;
    const url = System.decanonicalize(meta.moduleId);
    await this.withoutUpdates(() => this.models.componentFilesView.setExpandedPath(node => {
      if (node === td.root) return true;
      if (node.url) {
        return url.startsWith(node.url);
      }
      if (node.componentObject === aComponentDescriptor) {
        // ensure the component list to be ready
        this.models.componentFilesView._selectedNode = node;
        return true;
      }
    }, td.root, false));
  }

  getSelectedComponent () {
    return this.view.getSubmorphsByStyleClassName('ExportedComponent').find(component => component.isSelected);
  }

  getSelectedModule () {
    if (!this.ui.componentFilesView.visible) return false;
    const fileView = this.models.componentFilesView;
    return fileView.getExpandedPath().find(m => m.type === 'cp.js');
  }

  toggleBehaviorImport () {
    this.importAlive = !this.importAlive;
  }

  parseInput () {
    const filterText = this.ui.searchInput.textString;
    // parser that allows escapes
    const parsed = Array.from(filterText).reduce(
      (state, char) => {
        // filterText = "foo bar\\ x"
        if (char === '\\' && !state.escaped) {
          state.escaped = true;
          return state;
        }

        if (char === ' ' && !state.escaped) {
          if (!state.spaceSeen && state.current) {
            state.tokens.push(state.current);
            state.current = '';
          }
          state.spaceSeen = true;
        } else {
          state.spaceSeen = false;
          state.current += char;
        }
        state.escaped = false;
        return state;
      },
      { tokens: [], current: '', escaped: false, spaceSeen: false }
    );
    parsed.current && parsed.tokens.push(parsed.current);
    const lowercasedTokens = parsed.tokens.map(ea => ea.toLowerCase());
    return { tokens: parsed.tokens, lowercasedTokens };
  }

  fuzzyMatch (parsedInput, term) {
    const tokens = parsedInput.lowercasedTokens;
    if (tokens.every(token => term.toLowerCase().includes(token))) return true;
    // "fuzzy" match against item.string or another prop of item
    const fuzzyValue = String(term).toLowerCase();
    return arr.sum(parsedInput.lowercasedTokens.map(token =>
      string.levenshtein(fuzzyValue, token))) <= 3;
  }

  async filteredIndex (term) {
    const previewWidth = 120 * window.devicePixelRatio;
    const filteredIndex = {};
    const parsedInput = this.parseInput();
    let localComponents = await Promise.all($world.localComponents.map(async c => {
      // minimum width is the width of the rendered preview
      let renderedWidth = c.width;
      let renderedHeight = c.height;
      renderedHeight *= previewWidth / renderedWidth;
      renderedWidth = previewWidth;
      const preview = c._preview || (c._preview = await renderMorphToDataURI(c, {
        width: renderedWidth, height: renderedHeight, type: 'png'
      }));
      return {
        identifier: 'part://$world/' + c.name,
        preview,
        worldName: $world.name
      };
    }));
    localComponents = localComponents.filter(component =>
      this.fuzzyMatch(parsedInput, component.identifier));
    if (localComponents.length > 0) {
      filteredIndex['This Project'] = localComponents;
    }
    for (const worldName of Object.keys(this._componentIndex)) {
      if (worldName === this.world().name) continue; // skip local components
      const matches = this._componentIndex[worldName].filter(component =>
        this.fuzzyMatch(parsedInput, component.identifier));
      if (matches.length > 0) filteredIndex[worldName] = matches;
    }
    return filteredIndex;
  }

  resetSearchInput () {
    this.ui.searchInput.clear();
    this.reset();
    if (this.groupBy === 'module') {
      const fileView = this.models.componentFilesView;
      const lastSelectedModule = fileView.getExpandedPath().find(m => m.type === 'cp.js');
      if (lastSelectedModule) { fileView.treeData.display(lastSelectedModule); } else this.ui.masterComponentList.submorphs = [];
    }
    if (this.groupBy === 'name') {
      this.showSearchComponensNotice();
    }
  }

  async filterAllComponents () {
    fun.debounceNamed('filterAllComponents', 200, async () => {
      this.toggleBusyState(true);
      const { importButton, componentFilesView, searchInput, searchClearButton } = this.ui;
      const term = searchInput.input;
      const parsedInput = this.parseInput();
      const rootUrls = arr.compact(componentFilesView.treeData.root.subNodes?.map(m => m.url).slice(1)); // ignore the popular stuff

      if (!rootUrls) return setTimeout(() => this.filterAllComponents(), 200);

      // via system interface

      this.handleColumnViewVisibility();

      if (term === '') {
        searchClearButton.visible = false;
        this.toggleBusyState(false);
        this.reset();
        return;
      }

      const componentModules = Array.from(await Promise.all(rootUrls.map(url => {
        return resource(url).dirList(10, {
          exclude: (file) => {
            return file.isFile() && (!file.url.endsWith('cp.js') || file.url.includes('tests') || file.url.includes('node_modules'));
          }
        });
      }))).flat().filter(file => file.url.endsWith('cp.js'))
        .map(file => file.url);

      searchClearButton.visible = true;

      // filter the candidates and render the projects together with the matches
      let filteredIndex = {};
      await Promise.all(componentModules.map(async modUrl => {
        let components;
        try {
          components = await this.getComponentsInModule(modUrl);// retrieve the components exported in that module
        } catch (err) {
          return;
        }
        // get the matching components in the module
        components = components.filter(c => {
          return this.fuzzyMatch(parsedInput, c.componentName);
        });
        // store them in the filtered index if there is a match or more
        if (components.length > 0) filteredIndex[modUrl.replace(System.baseURL, '')] = components;
      }));

      if (this.groupBy === 'name') {
        const flattenedComponents = arr.flat(Object.values(filteredIndex));
        filteredIndex = arr.groupBy(flattenedComponents, c => c.componentName[0]);
      }
      // update the components list with the filtered projects
      this.updateList(filteredIndex, this.groupBy === 'name');
      importButton.viewModel.deactivated = !this.getSelectedComponent();
      this.toggleBusyState(false);
    })();
  }

  // componentsByWorlds = filteredIndex
  async updateList (componentsByWorlds, organizeByName) {
    const { masterComponentList } = this.ui;
    // do some smart updating of the list
    const newList = [];
    // remove all empty lists
    const orderedWorlds = arr.sortBy((componentsByWorlds['This Project'] ? ['This Project'] : []).concat(arr.without(Object.keys(componentsByWorlds), 'This Project')), m => m);

    for (const worldName of orderedWorlds) {
      const mod = module(worldName);
      const pkg = mod.package();
      const isOpenedProject = pkg && pkg.url === $world.openedProject?.package.url;
      newList.push(organizeByName
        ? this.renderComponentsByChar(worldName, componentsByWorlds[worldName])
        : this.renderComponentsInFile(
          joinPath(mod.package().name, mod.pathInPackage()),
          componentsByWorlds[worldName],
          isOpenedProject)
      );
    }

    masterComponentList.clipMode = 'auto';
    masterComponentList.submorphs = newList;
    // ensure that all of the sections are fitted
    for (let section of newList) { masterComponentList.layout.setResizePolicyFor(section, { width: 'fill', height: 'fixed' }); }
    this.view.doNotAcceptDropsForThisAndSubmorphs();
  }

  selectComponent (component, scrollIntoView = false) {
    const previewToSelect = this.view.getSubmorphsByStyleClassName('ExportedComponent').find(preview => preview.component === component);
    if (previewToSelect && !previewToSelect.isSelected) {
      previewToSelect.project.selectComponent(previewToSelect); // lol?
    }
    const { masterComponentList } = this.ui;
    if (previewToSelect && scrollIntoView) {
      const scrollY = masterComponentList.localizePointFrom(pt(0, 0), previewToSelect).y;
      masterComponentList.animate({
        scroll: pt(0, scrollY - 50),
        duration: 200
      });
    }
  }

  renderComponentsByChar (char, componentsByChar) {
    const { masterComponentList } = this.ui;
    const currentList = masterComponentList.submorphs;

    const charGroup = currentList.find(item => item.char === char) || part(this.sectionMaster, { type: NameSection });
    charGroup.char = char;
    charGroup.renderComponents(componentsByChar);

    return charGroup;
  }

  renderComponentsInFile (fileName, componentsInFile, enableNavigation) {
    const { masterComponentList } = this.ui;
    const currentList = masterComponentList.submorphs;
    const projectEntry = currentList.find(item => item.worldName === fileName) || part(this.sectionMaster);

    projectEntry.worldName = arr.last(fileName.split('--'));
    projectEntry.renderComponents(componentsInFile);

    if (!enableNavigation) projectEntry.disableNavigation();

    return projectEntry;
  }

  showSearchComponensNotice () {
    const { masterComponentList } = this.ui;
    const { SearchComponentsNotice } = this;
    const notice = part(SearchComponentsNotice);
    masterComponentList.clipMode = 'hidden';
    masterComponentList.submorphs = [notice];
    masterComponentList.layout.setResizePolicyFor(notice, {
      width: 'fill', height: 'fill'
    });
  }

  showComponentsInFile (fileName, componentsInFile, activeNavigation) {
    const { masterComponentList } = this.ui;

    if (componentsInFile.length === 0) {
      masterComponentList.submorphs = [];
      return;
    }

    const projectEntry = this.renderComponentsInFile(fileName, componentsInFile, activeNavigation);
    masterComponentList.submorphs = [projectEntry];
    masterComponentList.layout.setResizePolicyFor(projectEntry, {
      width: 'fill', height: 'fixed'
    });
  }

  changeComponentGrouping (groupBy) {
    this.groupBy = groupBy;
    this.resetSearchInput();
  }
}

class ComponentBrowserPopupModel extends ComponentBrowserModel {
  get isPopupModel () {
    return true;
  }

  get bindings () {
    return [
      ...super.bindings,
      {
        target: 'close button',
        signal: 'onMouseUp',
        handler: 'close'
      }
    ];
  }

  get expose () {
    return [...super.expose, 'browse'];
  }

  async browse (aComponent) {
    await this.showComponentInFilesView(aComponent);
    await this.refresh();
    this.selectComponent(aComponent);
  }

  async activate (pos) {
    // popup specific
    const { view } = this;
    view.doNotAcceptDropsForThisAndSubmorphs();
    view.openInWorld();
    view.clipMode = 'hidden';
    if (!pos) view.center = $world.visibleBounds().center();
    else view.position = pos;

    return await super.activate();
  }

  close () {
    super.close();
    this.view.remove();
  }
}

const ComponentPreview = component({
  type: ExportedComponent,
  name: 'component preview',
  layout: new TilingLayout({
    axis: 'column',
    axisAlign: 'center',
    hugContentsVertically: true,
    orderByIndex: true,
    padding: rect(5, 5, 0, 0),
    resizePolicies: [['component name', {
      height: 'fixed',
      width: 'fill'
    }]],
    spacing: 5
  }),
  borderColor: Color.transparent,
  borderRadius: 5,
  borderWidth: 2,
  extent: pt(130, 130),
  fill: Color.transparent,
  draggable: true,
  nativeCursor: 'grab',
  submorphs: [{
    name: 'preview container',
    layout: new TilingLayout({
      align: 'center',
      axisAlign: 'center',
      orderByIndex: true
    }),
    borderColor: Color.rgb(23, 160, 251),
    extent: pt(120, 100),
    fill: Color.rgba(0, 0, 0, 0),
    reactsToPointer: false,
    submorphs: [{
      name: 'preview holder',
      borderColor: Color.rgb(23, 160, 251),
      dropShadow: new ShadowObject({ distance: 5, rotation: 75, color: Color.rgba(0, 0, 0, 0.2), blur: 20, fast: false }),
      extent: pt(105, 45),
      naturalExtent: pt(105, 45),
      reactsToPointer: false
    }]
  }, {
    type: 'text',
    name: 'component name',
    textAlign: 'center',
    fontColor: Color.darkGray,
    fixedWidth: true,
    fixedHeight: false,
    lineWrapping: 'by-words',
    fontSize: 14,
    fontWeight: 'bold',
    clipMode: 'hidden',
    reactsToPointer: false,
    textAndAttributes: ['Button', null]
  }]
});

const ComponentPreviewDark = component(ComponentPreview, {
  submorphs: [{
    name: 'component name',
    fontColor: Color.rgb(204, 204, 204)
  }]
});

const ComponentPreviewSelected = component(ComponentPreview, {
  name: 'component preview selected',
  borderColor: Color.rgb(33, 150, 243),
  borderWidth: 2,
  fill: Color.rgba(3, 169, 244, 0.75),
  submorphs: [{
    name: 'component name',
    fontColor: Color.white
  }]
});

const ComponentPreviewSelectedDark = component(ComponentPreview, {
  borderColor: Color.rgb(52, 138, 117),
  borderWidth: 2,
  fill: Color.rgba(100, 255, 218, 0.6),
  submorphs: [{
    name: 'component name',
    fontColor: Color.rgb(255, 255, 255)
  }]
});

const ProjectSection = component({
  type: ProjectEntry,
  name: 'project section',
  layout: new TilingLayout({
    axis: 'column',
    hugContentsVertically: true,
    resizePolicies: [['project title', {
      height: 'fixed',
      width: 'fill'
    }], ['component previews', {
      height: 'fixed',
      width: 'fill'
    }]]
  }),
  borderColor: Color.rgb(23, 160, 251),
  extent: pt(488.6, 154.2),
  fill: Color.rgba(0, 0, 0, 0),
  position: pt(647.1, 628.5),
  renderOnGPU: true,
  submorphs: [{
    type: Label,
    name: 'project title',
    fixedWidth: true,
    borderColor: Color.rgb(215, 219, 221),
    fontColor: Color.rgb(66, 73, 73),
    borderWidthBottom: 2,
    fontSize: 20,
    fontWeight: 'bold',
    nativeCursor: 'pointer',
    padding: rect(10, 8, -2, 0),
    textAndAttributes: ['Project Name', {
    }, '  ', {
    }, '', {
      fontFamily: 'Font Awesome',
      fontWeight: '900',
      nativeCursor: 'pointer',
      paddingTop: '3px'
    }]
  }, {
    name: 'component previews',
    borderColor: Color.rgb(23, 160, 251),
    extent: pt(489, 101.2),
    fill: Color.rgba(0, 0, 0, 0),
    layout: new TilingLayout({
      hugContentsVertically: true,
      orderByIndex: true,
      padding: rect(10, 10, 0, 0),
      spacing: 30,
      wrapSubmorphs: true
    })
  }]
});

const ProjectSectionDark = component(ProjectSection, {
  previewMaster: ComponentPreviewDark,
  selectedPreviewMaster: ComponentPreviewSelectedDark,
  submorphs: [{
    name: 'project title',
    fontColor: Color.rgb(204, 204, 204),
    borderColor: Color.rgb(130, 130, 130)
  }]
});

const ComponentBrowser = component({
  defaultViewModel: ComponentBrowserModel,
  reactsToPointer: false,
  fill: Color.rgba(255, 255, 255, 0),
  extent: pt(515.1, 599.9),
  layout: new TilingLayout({
    axis: 'column',
    axisAlign: 'center',
    orderByIndex: true,
    padding: rect(16, 16, 0, 0),
    resizePolicies: [['search input wrapper', {
      height: 'fixed',
      width: 'fill'
    }], ['component files view', {
      height: 'fixed',
      width: 'fill'
    }], ['master component list', {
      height: 'fill',
      width: 'fill'
    }], ['button wrapper', {
      height: 'fixed',
      width: 'fill'
    }]],
    spacing: 16
  }),
  submorphs: [{
    name: 'search input wrapper',
    layout: new TilingLayout({
      axisAlign: 'center',
      orderByIndex: true,
      padding: rect(8, 0, -8, 0),
      resizePolicies: [['search input', {
        height: 'fixed',
        width: 'fill'
      }]]
    }),
    borderRadius: 3,
    borderColor: Color.rgb(23, 160, 251),
    extent: pt(388.4, 42.6),
    position: pt(120, 541),
    submorphs: [{
      type: Text,
      name: 'search icon',
      extent: pt(17.5, 18),
      fontSize: 18,
      fontColor: Color.rgba(0, 0, 0, 0.5),
      cursorWidth: 1.5,
      fixedWidth: true,
      padding: rect(1, 1, 0, 0),
      textAndAttributes: ['', {
        fontFamily: 'Font Awesome',
        fontWeight: '900',
        lineHeight: 1.2
      }]
    }, part(InputLineDefault, {
      name: 'search input',
      dropShadow: null,
      highlightWhenFocused: false,
      borderColor: Color.rgb(224, 224, 224),
      borderRadius: 2,
      extent: pt(445.3, 34.3),
      fill: Color.rgba(255, 255, 255, 0),
      padding: rect(6, 4, -4, 2),
      position: pt(11.9, 3.8),
      placeholder: 'Search for components...'
    }), part(Spinner, {
      name: 'spinner',
      opacity: .7,
      viewModel: { color: 'black' },
      visible: false
    }), {
      type: Text,
      name: 'search clear button',
      nativeCursor: 'pointer',
      visible: false,
      fontColor: Color.rgba(0, 0, 0, 0.5),
      fontSize: 25,
      lineHeight: 2,
      padding: rect(1, 1, 9, 0),
      textAndAttributes: ['', {
        fontFamily: 'Font Awesome',
        fontWeight: '900',
        lineHeight: 1
      }]
    }]
  }, part(MullerColumnView, {
    name: 'component files view',
    viewModel: { listMaster: ColumnListDefault },
    borderColor: Color.rgb(149, 165, 166),
    borderWidth: 1,
    extent: pt(483, 150),
    borderRadius: 2
  }), {
    name: 'master component list',
    borderColor: Color.rgb(149, 165, 166),
    borderWidth: 1,
    borderRadius: 2,
    fill: Color.rgb(238, 238, 238),
    clipMode: 'auto',
    extent: pt(640, 304),
    layout: new TilingLayout({
      wrapSubmorphs: false,
      axis: 'column'
    })
  }, {
    name: 'button wrapper',
    height: 33.92421875,
    clipMode: 'visible',
    fill: Color.transparent,
    layout: new TilingLayout({
      align: 'right',
      axisAlign: 'center',
      justifySubmorphs: 'spaced',
      orderByIndex: true,
      resizePolicies: [['behavior toggle', {
        height: 'fixed',
        width: 'fill'
      }]],
      spacing: 15
    }),
    submorphs: [part(DropDownList, {
      name: 'sorting selector',
      extent: pt(149.6, 25),
      viewModel: {
        openListInWorld: true,
        listMaster: SystemList,
        items: [
          {
            isListItem: true,
            label: [...Icon.textAttribute('boxes'), '  By module', null],
            tooltip: 'Group the components by the modules they are defined in.',
            value: 'module'
          },
          {
            isListItem: true,
            label: [...Icon.textAttribute('tag', { paddingLeft: '2px', paddingTop: '2px' }), '  By name', null],
            tooltip: 'Group the components by their names',
            value: 'name'
          }
        ]
      },
      submorphs: [{
        name: 'label',
        textAndAttributes: ['Arrange by name', null]
      }]

    }), part(LabeledCheckboxLight, {
      name: 'behavior toggle',
      viewModel: { label: 'Enable behavior' }
    }), part(SystemButton, {
      name: 'edit button',
      extent: pt(80, 23.8),
      submorphs: [{
        name: 'label',
        textAndAttributes: [...Icon.textAttribute('edit', { fontColor: Color.rgbHex('D32F2F') }), ' Edit', {
          fontFamily: 'IBM Plex Sans'
        }]
      }]
    }), part(SystemButton, {
      name: 'import button',
      extent: pt(80, 23.8),
      submorphs: [{
        name: 'label',
        textAndAttributes: ['', {
          fontColor: Color.rgb(74, 174, 79),
          fontFamily: 'Font Awesome',
          fontWeight: '900',
          lineHeight: 1
        }, ' Open', {
          fontFamily: 'IBM Plex Sans'
        }]

      }]
    }), add(part(SystemButton, {
      name: 'selection button',
      visible: false,
      extent: pt(80, 23.8),
      layout: new TilingLayout({
        align: 'center',
        axisAlign: 'center'
      }),
      submorphs: [{
        name: 'label',
        textAndAttributes: ['Select', null]
      }]
    }))]
  }]
});

const ComponentBrowserPopup = component(PopupWindow, {
  defaultViewModel: ComponentBrowserPopupModel,
  hasFixedPosition: false,
  extent: pt(515, 658),
  layout: new TilingLayout({
    axis: 'column',
    axisAlign: 'center',
    hugContentsHorizontally: true,
    hugContentsVertically: true,
    orderByIndex: true,
    resizePolicies: [['header menu', {
      height: 'fixed',
      width: 'fill'
    }]]
  }),
  submorphs: [
    add(part(ComponentBrowser, {
      defaultViewModel: null,
      name: 'controls',
      submorphs: [{
        name: 'search input wrapper',
        fill: Color.rgb(238, 238, 238)
      }]
    })),
    {
      name: 'header menu',
      submorphs: [{
        name: 'title',
        fontSize: 18,
        reactsToPointer: false,
        textAndAttributes: ['Browse Components', null]
      }]
    }
  ]
});

const ComponentBrowserPopupDark = component(ComponentBrowserPopup, {
  master: DarkPopupWindow,
  viewModel: {
    sectionMaster: ProjectSectionDark,
    SearchComponentsNotice: SearchComponentsNoticeDark
  },
  layout: new TilingLayout({
    axis: 'column',
    axisAlign: 'center',
    hugContentsHorizontally: true,
    hugContentsVertically: true,
    resizePolicies: [['header menu', {
      height: 'fixed',
      width: 'fill'
    }]]
  }),
  submorphs: [{
    name: 'controls',
    submorphs: [{
      name: 'search input wrapper',
      fill: Color.rgb(122, 122, 122),
      submorphs: [{
        name: 'search icon',
        fontColor: Color.rgba(255, 255, 255, 0.5)
      }, {
        name: 'search input',
        fontColor: Color.rgbHex('B2EBF2')
      }, {
        name: 'spinner',
        viewModel: { color: 'white' },
        visible: false,
        position: pt(456.6, 4.4),
        scale: 0.35
      }, {
        name: 'search clear button',
        fontColor: Color.rgba(255, 255, 255, 0.69)
      }]
    }, {
      name: 'component files view',
      fill: Color.rgba(255, 255, 255, 0.1),
      borderColor: Color.rgb(112, 123, 124),
      borderWidth: 1,
      viewModel: { listMaster: ColumnListDark }
    },
    {
      name: 'master component list',
      fill: Color.rgba(238, 238, 238, 0.1),
      borderColor: Color.rgb(112, 123, 124),
      borderWidth: 1
    },
    {
      name: 'button wrapper',
      submorphs: [
        {
          name: 'sorting selector',
          master: DarkDropDownList
        },
        {
          name: 'behavior toggle',
          visible: false,
          master: LabeledCheckbox,
          submorphs: [{
            name: 'label',
            textAndAttributes: ['Enable behavior', null],
            fontColor: Color.white
          }]
        }, {
          name: 'edit button',
          master: ButtonDarkDefault,
          visible: false,
          submorphs: [{
            name: 'label',
            fontColor: Color.rgb(255, 255, 255),
            textAndAttributes: ['', {
              fontColor: Color.rgbHex('B2EBF2'),
              fontFamily: 'Font Awesome',
              fontWeight: '900',
              lineHeight: 1
            }, ' Edit', {
              fontFamily: 'IBM Plex Sans'
            }]
          }]
        }, {
          name: 'import button',
          visible: false,
          master: ButtonDarkDefault,
          submorphs: [{
            name: 'label',
            textAndAttributes: ['', {
              fontColor: Color.rgbHex('B2EBF2'),
              fontFamily: 'Font Awesome',
              fontWeight: '900',
              lineHeight: 1
            }, ' Import', {
              fontFamily: 'IBM Plex Sans'
            }]
          }]
        }, {
          name: 'selection button', master: ButtonDarkDefault
        }]
    }]
  }]
});

const ComponentError = component({
  name: 'component error',
  nativeCursor: 'not-allowed',
  borderStyle: 'none',
  borderColor: Color.rgb(189, 189, 189),
  dropShadow: new ShadowObject({ color: Color.rgba(0, 0, 0, 0.52), blur: 15 }),
  borderWidth: 2,
  borderRadius: 10,
  extent: pt(159.8, 159),
  fill: Color.rgba(0, 0, 0, 0.6719),
  layout: new TilingLayout({
    align: 'center',
    axis: 'column',
    axisAlign: 'center',
    orderByIndex: true,
    padding: rect(10, 10, 0, 0),
    resizePolicies: [['error message', {
      height: 'fixed',
      width: 'fill'
    }]],
    spacing: 10
  }),
  position: pt(693.4, 588.2),
  submorphs: [{
    name: 'backdrop',
    borderColor: Color.rgb(23, 160, 251),
    borderWidth: 1,
    extent: pt(10.6, 31.4),
    position: pt(7, 43),
    reactsToPointer: false,
    submorphs: [{
      type: Text,
      name: 'warning sign',
      cursorWidth: 1.5,
      fill: Color.rgba(255, 255, 255, 0),
      fontColor: Color.rgb(255, 171, 64),
      fontSize: 48,
      reactsToPointer: false,
      lineWrapping: 'by-words',
      padding: rect(1, 1, 0, 0),
      position: pt(-19.7, -13.6),
      textAndAttributes: ['', {
        fontFamily: 'Font Awesome',
        fontWeight: '900',
        lineHeight: 1
      }]
    }]
  }, {
    type: Text,
    name: 'error message',
    height: 70.91015625,
    fontWeight: 700,
    cursorWidth: 1.5,
    fill: Color.rgba(255, 255, 255, 0),
    fixedHeight: true,
    fixedWidth: true,
    fontColor: Color.rgb(255, 255, 255),
    lineWrapping: 'by-words',
    reactsToPointer: false,
    padding: rect(1, 1, 0, 0),
    position: pt(10, 81),
    textAlign: 'center',
    textString: 'This is not an error I would display explaining what went wrong.'
  }]
});

export {
  ComponentBrowser,
  ComponentBrowserPopup,
  ComponentBrowserPopupDark,
  ComponentPreview,
  ComponentPreviewSelected,
  ComponentPreviewDark,
  ComponentPreviewSelectedDark,
  ProjectSection,
  ComponentError
};