Add tooltips for +Block buttons

We try to show the full syntax path from root
until the chosen block.

Change-Id: I6ad29abeae3a730e267c7bd5fe6feab97797871d
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
index 1448129..60b1a1a 100644
--- a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
@@ -22,6 +22,7 @@
 import '@polymer/paper-icon-button/paper-icon-button';
 import '@polymer/paper-item/paper-item';
 import '@polymer/paper-listbox/paper-listbox';
+import '@polymer/paper-tooltip/paper-tooltip.js';
 
 import '../../shared/gr-button/gr-button';
 import {pluralize} from '../../../utils/string-util';
@@ -43,23 +44,27 @@
 /**
  * Traverses a hierarchical structure of syntax blocks and
  * finds the most local/nested block that can be associated line.
- * It finds the closest block that contains the whole line.
+ * It finds the closest block that contains the whole line and
+ * returns the whole path from the syntax layer (blocks) sent as parameter
+ * to the most nested block - the complete path from the top to bottom layer of
+ * a syntax tree. Example: [myNamepace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
  *
  * @param lineNum line number for the targeted line.
  * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
- * @returns
  */
-function findMostNestedContainingBlock(
+function findBlockTreePathForLine(
   lineNum: number,
   blocks?: SyntaxBlock[]
-): SyntaxBlock | undefined {
+): SyntaxBlock[] {
   const containingBlock = blocks?.find(
     ({range}) => range.start_line < lineNum && range.end_line > lineNum
   );
-  const containingChildBlock = containingBlock
-    ? findMostNestedContainingBlock(lineNum, containingBlock?.children)
-    : undefined;
-  return containingChildBlock || containingBlock;
+  if (!containingBlock) return [];
+  const innerPathInChild = findBlockTreePathForLine(
+    lineNum,
+    containingBlock?.children
+  );
+  return [containingBlock].concat(innerPathInChild);
 }
 
 @customElement('gr-context-controls')
@@ -116,6 +121,9 @@
     .belowButton {
       top: calc(100% + var(--divider-border));
     }
+    .breadcrumbTooltip {
+      white-space: nowrap;
+    }
   `;
 
   // To pass CSS mixins for @apply to Polymer components, they need to be
@@ -194,7 +202,11 @@
   /**
    * Creates a specific expansion button (e.g. +X common lines, +10, +Block).
    */
-  private createContextButton(type: ContextButtonType, linesToExpand: number) {
+  private createContextButton(
+    type: ContextButtonType,
+    linesToExpand: number,
+    tooltipText?: string
+  ) {
     let text = '';
     let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
     let ariaLabel = '';
@@ -257,6 +269,11 @@
       groups
     );
 
+    const tooltip = tooltipText
+      ? html`<paper-tooltip offset="10"
+          ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
+        >`
+      : undefined;
     const button = html` <gr-button
       class="${classes}"
       link="true"
@@ -265,6 +282,7 @@
       @click="${expandHandler}"
     >
       <span class="showContext">${text}</span>
+      ${tooltip}
     </gr-button>`;
     return button;
   }
@@ -385,13 +403,19 @@
   ) {
     assertIsDefined(this.diff, 'diff');
     const syntaxTree = this.diff!.meta_b.syntax_tree;
-    const containingBlock = findMostNestedContainingBlock(
+    const outlineSyntaxPath = findBlockTreePathForLine(
       referenceLine,
       syntaxTree
     );
     let linesToExpand = numLines;
-    if (containingBlock) {
-      const {range} = containingBlock;
+    let tooltipText = `${linesToExpand} common lines`;
+    if (outlineSyntaxPath.length) {
+      const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
+      // Create breadcrumb string:
+      // myNamepace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
+      tooltipText = outlineSyntaxPath
+        .map(b => b.name || '(anonymous)')
+        .join(' > ');
       const targetLine =
         buttonType === ContextButtonType.BLOCK_ABOVE
           ? range.end_line
@@ -401,7 +425,7 @@
         linesToExpand = distanceToTargetLine;
       }
     }
-    return this.createContextButton(buttonType, linesToExpand);
+    return this.createContextButton(buttonType, linesToExpand, tooltipText);
   }
 
   private contextRange() {
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
index 37b8723..d866c1a 100644
--- a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
@@ -23,7 +23,7 @@
 
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffFileMetaInfo, DiffInfo} from '../../../api/diff';
+import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
 
 const blankFixture = fixtureFromElement('div');
 
@@ -56,6 +56,7 @@
 
   test('no +10 buttons for 10 or less lines', async () => {
     element.contextGroups = createContextGroups({count: 10});
+
     await flush();
 
     const buttons = element.shadowRoot!.querySelectorAll(
@@ -68,6 +69,7 @@
   test('context control at the top', async () => {
     element.contextGroups = createContextGroups({offset: 0, count: 20});
     element.showBelow = true;
+
     await flush();
 
     const buttons = element.shadowRoot!.querySelectorAll(
@@ -86,6 +88,7 @@
     element.contextGroups = createContextGroups({offset: 10, count: 20});
     element.showAbove = true;
     element.showBelow = true;
+
     await flush();
 
     const buttons = element.shadowRoot!.querySelectorAll(
@@ -105,6 +108,7 @@
   test('context control at the bottom', async () => {
     element.contextGroups = createContextGroups({offset: 30, count: 20});
     element.showAbove = true;
+
     await flush();
 
     const buttons = element.shadowRoot!.querySelectorAll(
@@ -119,13 +123,18 @@
     assert.include([...buttons[1].classList.values()], 'aboveButton');
   });
 
-  test('context control with block expansion at the top', async () => {
+  function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
     element.renderPreferences!.use_block_expansion = true;
     element.diff!.meta_b = ({
-      syntax_tree: [],
+      syntax_tree: syntaxTree,
     } as any) as DiffFileMetaInfo;
+  }
+
+  test('context control with block expansion at the top', async () => {
+    prepareForBlockExpansion([]);
     element.contextGroups = createContextGroups({offset: 0, count: 20});
     element.showBelow = true;
+
     await flush();
 
     const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
@@ -140,7 +149,10 @@
     assert.equal(fullExpansionButtons.length, 1);
     assert.equal(partialExpansionButtons.length, 1);
     assert.equal(blockExpansionButtons.length, 1);
-    assert.equal(blockExpansionButtons[0].textContent!.trim(), '+Block');
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
     assert.include(
       [...blockExpansionButtons[0].classList.values()],
       'belowButton'
@@ -148,14 +160,11 @@
   });
 
   test('context control with block expansion in the middle', async () => {
-    element.renderPreferences!.use_block_expansion = true;
-    element.diff!.meta_b = ({
-      syntax_tree: [],
-    } as any) as DiffFileMetaInfo;
-
+    prepareForBlockExpansion([]);
     element.contextGroups = createContextGroups({offset: 10, count: 20});
     element.showAbove = true;
     element.showBelow = true;
+
     await flush();
 
     const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
@@ -170,8 +179,14 @@
     assert.equal(fullExpansionButtons.length, 1);
     assert.equal(partialExpansionButtons.length, 2);
     assert.equal(blockExpansionButtons.length, 2);
-    assert.equal(blockExpansionButtons[0].textContent!.trim(), '+Block');
-    assert.equal(blockExpansionButtons[1].textContent!.trim(), '+Block');
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.equal(
+      blockExpansionButtons[1].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
     assert.include(
       [...blockExpansionButtons[0].classList.values()],
       'aboveButton'
@@ -183,12 +198,10 @@
   });
 
   test('context control with block expansion at the bottom', async () => {
-    element.renderPreferences!.use_block_expansion = true;
-    element.diff!.meta_b = ({
-      syntax_tree: [],
-    } as any) as DiffFileMetaInfo;
+    prepareForBlockExpansion([]);
     element.contextGroups = createContextGroups({offset: 30, count: 20});
     element.showAbove = true;
+
     await flush();
 
     const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
@@ -203,10 +216,162 @@
     assert.equal(fullExpansionButtons.length, 1);
     assert.equal(partialExpansionButtons.length, 1);
     assert.equal(blockExpansionButtons.length, 1);
-    assert.equal(blockExpansionButtons[0].textContent!.trim(), '+Block');
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
     assert.include(
       [...blockExpansionButtons[0].classList.values()],
       'aboveButton'
     );
   });
+
+  test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificFunction',
+        range: {start_line: 1, start_column: 0, end_line: 25, end_column: 0},
+        children: [],
+      },
+      {
+        name: 'anotherFunction',
+        range: {start_line: 26, start_column: 0, end_line: 50, end_column: 0},
+        children: [],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificFunction'
+    );
+    assert.equal(
+      blockExpansionButtons[1]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'anotherFunction'
+    );
+  });
+
+  test('+Block tooltip shows nested syntax blocks as breadcrumbs', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: 'MyClass',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > MyClass > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows (anonymous) for empty blocks', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: '',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > (anonymous) > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
+    prepareForBlockExpansion([]);
+
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    // querySelector('.breadcrumbTooltip')!.textContent!.trim()
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      '20 common lines'
+    );
+  });
 });