From 40e0a2edb8796ad9388c103dd29731a01bc1b997 Mon Sep 17 00:00:00 2001
From: patrick <pa.trickjlp@gmail.com>
Date: Thu, 1 Jun 2023 12:05:48 +0200
Subject: [PATCH] support type to search select

---
 app/components/component-tree-item.hbs  | 11 +--
 app/controllers/component-tree.js       | 64 ++++++++++++++++-
 app/helpers/mark-match.js               | 32 +++++++++
 app/styles/component_tree.scss          | 11 +++
 app/templates/component-tree.hbs        |  6 +-
 app/utils/key-codes.js                  |  1 +
 app/utils/search-match.js               |  2 +-
 tests/acceptance/component-tree-test.js | 96 ++++++++++++++++++++++++-
 8 files changed, 211 insertions(+), 12 deletions(-)
 create mode 100644 app/helpers/mark-match.js

diff --git a/app/components/component-tree-item.hbs b/app/components/component-tree-item.hbs
index 450b7ce0b4..8ebaf663d5 100644
--- a/app/components/component-tree-item.hbs
+++ b/app/components/component-tree-item.hbs
@@ -1,6 +1,7 @@
 {{!-- template-lint-disable no-invalid-interactive --}}
 <div
   style={{@item.style}}
+  data-test-id={{@item.instance}}
   class="
     component-tree-item relative flex items-center mx-1 rounded
     {{if @item.hasInstance "cursor-pointer" "cursor-default"}}
@@ -40,7 +41,7 @@
       {{#if @item.isComponent}}
         {{#if @item.isCurlyInvocation}}
           <span class="component-name">
-            {{@item.name}}
+            {{mark-match @item.name @searchTerm}}
           </span>
 
           {{#each @item.args.positional as |value|}}
@@ -63,7 +64,7 @@
           {{/each-in}}
         {{else}}
           <span class="component-name">
-            {{classify @item.name}}
+            {{mark-match (classify @item.name) @searchTerm}}
           </span>
 
           {{#each-in @item.args.named as |name value|}}
@@ -78,11 +79,11 @@
           {{/each-in}}
         {{/if}}
       {{else if @item.isOutlet}}
-        \{{outlet "{{@item.name}}"}}
+        \{{outlet "{{mark-match @item.name @searchTerm}}"}}
       {{else if @item.isEngine}}
-        \{{mount "{{@item.name}}"}}
+        \{{mount "{{mark-match @item.name @searchTerm}}"}}
       {{else if @item.isRouteTemplate}}
-        {{@item.name}} route
+        {{mark-match @item.name @searchTerm}} route
       {{/if}}
     </code>
   </div>
diff --git a/app/controllers/component-tree.js b/app/controllers/component-tree.js
index 927beaae61..b62122f616 100644
--- a/app/controllers/component-tree.js
+++ b/app/controllers/component-tree.js
@@ -19,10 +19,15 @@ export default class ComponentTreeController extends Controller {
 
   @tracked query = '';
   @tracked isInspecting = false;
+  /**
+   *
+   * @type {RenderItem[]}
+   */
   @tracked renderItems = [];
 
   @tracked _pinned = undefined;
   @tracked _previewing = undefined;
+  @tracked searchSelect = undefined;
 
   _store = Object.create(null);
 
@@ -30,6 +35,10 @@ export default class ComponentTreeController extends Controller {
     let { _store } = this;
 
     let store = Object.create(null);
+    /**
+     *
+     * @type {RenderItem[]}
+     */
     let renderItems = [];
 
     let flatten = (parent, renderNode) => {
@@ -80,6 +89,16 @@ export default class ComponentTreeController extends Controller {
   }
 
   get nextItem() {
+    if (this.searchSelect) {
+      const items = this.matchingItems;
+      const index = items.indexOf(this.findItem(this.pinned)) + 1;
+      return (
+        items
+          .slice(index)
+          .find((i) => searchMatch(i.name, this.searchSelect)) ||
+        this.currentItem
+      );
+    }
     const items = this.visibleItems;
     return (
       items[items.indexOf(this.findItem(this.pinned)) + 1] ||
@@ -88,6 +107,17 @@ export default class ComponentTreeController extends Controller {
   }
 
   get previousItem() {
+    if (this.searchSelect) {
+      const items = this.matchingItems;
+      const index = items.indexOf(this.findItem(this.pinned));
+      return (
+        items
+          .slice(0, index)
+          .reverse()
+          .find((i) => searchMatch(i.name, this.searchSelect)) ||
+        this.currentItem
+      );
+    }
     const items = this.visibleItems;
     return items[items.indexOf(this.findItem(this.pinned)) - 1] || items[0];
   }
@@ -193,6 +223,10 @@ export default class ComponentTreeController extends Controller {
     }
   }
 
+  @action handleClick() {
+    this.searchSelect = false;
+  }
+
   @action handleKeyDown(event) {
     if (focusedInInput()) {
       return;
@@ -204,13 +238,16 @@ export default class ComponentTreeController extends Controller {
 
     switch (event.keyCode) {
       case KEYS.up:
-        this.pinned = this.previousItem.id;
+        this.pinned = this.previousItem?.id;
         break;
       case KEYS.right: {
+        if (this.searchSelect) {
+          break;
+        }
         const pinnedItem = this.findItem(this.pinned);
 
         if (pinnedItem.isExpanded) {
-          this.pinned = this.nextItem.id;
+          this.pinned = this.nextItem?.id;
         } else {
           pinnedItem.expand();
         }
@@ -218,9 +255,12 @@ export default class ComponentTreeController extends Controller {
         break;
       }
       case KEYS.down:
-        this.pinned = this.nextItem.id;
+        this.pinned = this.nextItem?.id;
         break;
       case KEYS.left: {
+        if (this.searchSelect) {
+          break;
+        }
         const pinnedItem = this.findItem(this.pinned);
 
         if (pinnedItem.isExpanded) {
@@ -231,6 +271,22 @@ export default class ComponentTreeController extends Controller {
 
         break;
       }
+      case KEYS.escape:
+        this.searchSelect = undefined;
+        break;
+      case KEYS.backspace:
+        this.searchSelect = (this.searchSelect || '').slice(0, -1);
+        break;
+      default:
+        if (event.key.length === 1) {
+          this.searchSelect = (this.searchSelect || '') + event.key;
+          if (
+            !this.currentItem ||
+            !searchMatch(this.currentItem.name, this.searchSelect)
+          ) {
+            this.pinned = this.nextItem?.id;
+          }
+        }
     }
   }
 
@@ -248,10 +304,12 @@ export default class ComponentTreeController extends Controller {
 
   @action arrowKeysSetup() {
     document.addEventListener('keydown', this.handleKeyDown);
+    document.addEventListener('click', this.handleClick);
   }
 
   @action arrowKeysTeardown() {
     document.removeEventListener('keydown', this.handleKeyDown);
+    document.removeEventListener('click', this.handleClick);
   }
 }
 
diff --git a/app/helpers/mark-match.js b/app/helpers/mark-match.js
new file mode 100644
index 0000000000..269575deb1
--- /dev/null
+++ b/app/helpers/mark-match.js
@@ -0,0 +1,32 @@
+import { helper } from '@ember/component/helper';
+import { htmlSafe } from '@ember/template';
+import searchMatch from 'ember-inspector/utils/search-match';
+
+function replaceRange(s, start, end, substitute) {
+  return s.substring(0, start) + substitute + s.substring(end);
+}
+/**
+ *
+ * @param str {string}
+ * @param regex {RegExp}
+ * @return {SafeString}
+ */
+export function markMatch([str, regex]) {
+  if (!regex) {
+    return str;
+  }
+  const match = searchMatch(str, regex);
+  if (!match) {
+    return str;
+  }
+  const matchedText = str.slice(match.index, match.index + match[0].length);
+  str = replaceRange(
+    str,
+    match.index,
+    match.index + match[0].length,
+    `<mark>${matchedText}</mark>`
+  );
+  return htmlSafe(str);
+}
+
+export default helper(markMatch);
diff --git a/app/styles/component_tree.scss b/app/styles/component_tree.scss
index bb399c8e1f..f607380f2f 100644
--- a/app/styles/component_tree.scss
+++ b/app/styles/component_tree.scss
@@ -210,3 +210,14 @@
 .component-tree-item--component {
   color: var(--base09);
 }
+
+.component-tree-search {
+  background-color: var(--base00);
+  border: 1px solid var(--base10);
+  box-shadow: 0 0 3px var(--base15);
+  padding: 3px;
+  position: absolute;
+  right: 20px;
+  top: 3px;
+  z-index: 1;
+}
diff --git a/app/templates/component-tree.hbs b/app/templates/component-tree.hbs
index a0944481d9..54cbe08194 100644
--- a/app/templates/component-tree.hbs
+++ b/app/templates/component-tree.hbs
@@ -10,6 +10,10 @@
   {{/in-element}}
 {{/if}}
 
+{{#if this.searchSelect}}
+  <div class='component-tree-search'> selecting: <strong>{{this.searchSelect}}</strong></div>
+{{/if}}
+
 <ScrollContainer
   @collection={{this.visibleItems}}
   @currentItem={{this.currentItem}}
@@ -24,6 +28,6 @@
     @estimateHeight={{this.itemHeight}}
     @items={{this.visibleItems}}
     @key="id" as |item|>
-    <ComponentTreeItem @item={{item}} />
+    <ComponentTreeItem @item={{item}} @searchTerm={{this.searchSelect}}/>
   </VerticalCollection>
 </ScrollContainer>
\ No newline at end of file
diff --git a/app/utils/key-codes.js b/app/utils/key-codes.js
index 4776758dc7..f664fd95a2 100644
--- a/app/utils/key-codes.js
+++ b/app/utils/key-codes.js
@@ -5,6 +5,7 @@ const KEYS = {
   right: 39,
   down: 40,
   left: 37,
+  backspace: 8,
 };
 
 export { KEYS };
diff --git a/app/utils/search-match.js b/app/utils/search-match.js
index d578459548..407f10614f 100644
--- a/app/utils/search-match.js
+++ b/app/utils/search-match.js
@@ -10,5 +10,5 @@ export default function (text, searchQuery) {
     return true;
   }
   let regExp = new RegExp(escapeRegExp(sanitize(searchQuery)));
-  return !!sanitize(text).match(regExp);
+  return sanitize(text).match(regExp);
 }
diff --git a/tests/acceptance/component-tree-test.js b/tests/acceptance/component-tree-test.js
index 80cbf1f3b6..66422d816c 100644
--- a/tests/acceptance/component-tree-test.js
+++ b/tests/acceptance/component-tree-test.js
@@ -13,6 +13,7 @@ import {
 import { module, test } from 'qunit';
 import { setupApplicationTest } from 'ember-qunit';
 import { setupTestAdapter, respondWith, sendMessage } from '../test-adapter';
+import { KEYS } from 'ember-inspector/utils/key-codes';
 
 function textFor(selector, context) {
   return context.querySelector(selector).textContent.trim();
@@ -108,8 +109,8 @@ function getRenderTree({ withChildren, withManyChildren } = {}) {
   const children = [];
   if (withChildren) {
     children.push(
-      Component({ id: 5, name: 'sub-task' }),
-      Component({ id: 6, name: 'sub-task' })
+      Component({ id: 5, name: 'sub-task-' + 5 }),
+      Component({ id: 6, name: 'sub-task-' + 6 })
     );
   }
   if (withManyChildren) {
@@ -354,6 +355,97 @@ module('Component Tab', function (hooks) {
     assert.strictEqual(treeNodes.length, 4, 'expected all tree nodes');
   });
 
+  test('It should search select when typing characters', async function (assert) {
+    assert.expect(26);
+    await visit('/component-tree');
+
+    respondWith('view:showInspection', false, { count: 12 });
+
+    await sendMessage({
+      type: 'view:renderTree',
+      tree: getRenderTree({ withManyChildren: true }),
+    });
+
+    let expanders = findAll('.component-tree-item__expand');
+    let expanderEl = expanders[expanders.length - 1];
+    await click(expanderEl);
+
+    async function triggerCharCodes(text) {
+      for (let t of text) {
+        await triggerKeyEvent(document, 'keydown', t.toUpperCase());
+      }
+    }
+
+    let treeNodes = findAll('.component-tree-item');
+    assert.strictEqual(treeNodes.length, 32, 'expected all tree nodes');
+
+    respondWith('view:showInspection', false);
+    respondWith('objectInspector:inspectById', ({ objectId }) => {
+      const result = findAll(
+        `[data-test-id=${objectId}] .component-tree-item__tag`
+      )[0];
+      // matching initial character s
+      assert.strictEqual(
+        result.textContent.trim(),
+        'todos route',
+        'should first select todos route'
+      );
+      return false;
+    });
+
+    await triggerCharCodes('sub-task-1');
+    await rerender();
+
+    treeNodes = findAll('mark');
+    treeNodes.forEach((node) => {
+      assert.strictEqual(
+        node.textContent.trim(),
+        'SubTask1',
+        'SubTask1 text part should be marked'
+      );
+    });
+    assert.strictEqual(
+      treeNodes.length,
+      10,
+      'expected nodes with sub-task-1 name to be marked'
+    );
+
+    treeNodes = findAll('.component-tree-item');
+    assert.strictEqual(treeNodes.length, 32, 'expected all tree nodes');
+
+    respondWith('view:showInspection', false);
+
+    let subTaskNumber = 11;
+    for (let i = 0; i < 10; i++) {
+      await triggerKeyEvent(document, 'keydown', KEYS.down);
+      const node = findAll(
+        '.component-tree-item--pinned .component-tree-item__tag'
+      )[0];
+      assert.strictEqual(
+        node.textContent.trim(),
+        'SubTask' + subTaskNumber,
+        'should include SubTask1'
+      );
+      subTaskNumber++;
+      if (subTaskNumber === 20) {
+        subTaskNumber = 100;
+      }
+    }
+
+    await triggerKeyEvent(document, 'keydown', KEYS.up);
+    const node = findAll(
+      '.component-tree-item--pinned .component-tree-item__tag'
+    )[0];
+    assert.strictEqual(
+      node.textContent.trim(),
+      'SubTask19',
+      'should include SubTask1'
+    );
+
+    treeNodes = findAll('.component-tree-item');
+    assert.strictEqual(treeNodes.length, 33, 'expected all tree nodes');
+  });
+
   test('It should update the view tree when the port triggers a change, preserving the expanded state of existing nodes', async function (assert) {
     await visit('/component-tree');