[logging] Allow for custom formatter for content in log-row

This will allow downstream users to insert custom color/text formatting
for every column.

This also adds a showControls option to show/hide controls and filter
input.

Change-Id: I019dd302aa358dadf37d957a2ba2e48fef75fd4e
Reviewed-on: https://fuchsia-review.googlesource.com/c/vscode-plugins/+/769723
Reviewed-by: Miguel Flores <miguelfrde@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
diff --git a/webviews/logging/components/log_list.ts b/webviews/logging/components/log_list.ts
index ca526ff..19904b6 100644
--- a/webviews/logging/components/log_list.ts
+++ b/webviews/logging/components/log_list.ts
@@ -11,6 +11,7 @@
 import {
   LogField,
   LogHeadersData,
+  LogViewOptions,
   HeaderWidthChangeHandler
 } from '../src/fields';
 import {FilterExpression} from '../src/filter';
@@ -123,7 +124,8 @@
     private headerFields: LogHeadersData,
     shouldWrapLogs: boolean,
     onHeaderWidthChange: HeaderWidthChangeHandler,
-    private currentFilter: FilterExpression) {
+    private currentFilter: FilterExpression,
+    private options: LogViewOptions) {
     super();
     this.maxWidths = new Array(Object.keys(headerFields).length).fill(0);
     this.logHeader = new LogHeader(headerFields, onHeaderWidthChange);
@@ -209,7 +211,8 @@
   }
 
   private appendLogElement(log: LogRowData) {
-    const element = new LogRow(log, this.headerFields);
+    const element = new LogRow(
+      log, this.headerFields, this.options.columnFormatter);
     this.logRows = [...this.logRows, element];
     element.updateComplete
       .then(() => {
diff --git a/webviews/logging/components/log_row.ts b/webviews/logging/components/log_row.ts
index fdb7c1f..1e3af09 100644
--- a/webviews/logging/components/log_row.ts
+++ b/webviews/logging/components/log_row.ts
@@ -5,7 +5,7 @@
 import {ChildPart, html, LitElement} from 'lit';
 import {Directive, directive} from 'lit/directive.js';
 import {customElement, property, state} from 'lit/decorators.js';
-import {LogHeadersData} from '../src/fields';
+import {LogColumnFormatter, LogHeadersData} from '../src/fields';
 import {LogRowData} from '../src/log_data';
 
 class AttributeSetter extends Directive {
@@ -31,7 +31,10 @@
   private fields!: Record<string, any>;
   private dataAttributes!: Record<string, any>;
 
-  constructor(log: LogRowData, private headerFields: LogHeadersData) {
+  constructor(
+    log: LogRowData,
+    private headerFields: LogHeadersData,
+    private columnFormatter: LogColumnFormatter) {
     super();
     this.log = log;
     this.formatRow(this.log);
@@ -58,7 +61,7 @@
         title="${fieldKey === 'message' ? '' : content}"
         class="log-list-cell${fieldKey === 'message' ? ' msg-cell' : ''}"
       >
-        ${content}
+        ${this.columnFormatter(fieldKey, content)}
       </div>`;
   }
 
diff --git a/webviews/logging/components/view.ts b/webviews/logging/components/view.ts
index c8cb235..bb7c29b 100644
--- a/webviews/logging/components/view.ts
+++ b/webviews/logging/components/view.ts
@@ -8,6 +8,7 @@
 import { State } from '../src/state';
 import { LogList } from './log_list';
 import {ffxLogToLogRowData} from '../src/ffxLogToLogRow';
+import {LogViewOptions} from '../src/fields';
 
 export const RESUMING_LOG_STREAMING_LOG = 'Resuming log streaming, press [pause icon] to pause.';
 export const PAUSING_LOG_STREAMING_LOG = 'Playback has been paused, press [play icon] to resume.';
@@ -19,26 +20,33 @@
 
 export class LoggingView extends EventTarget {
   private logActionContainer: HTMLDivElement;
-  private logControl: LogControl;
+  private logControl?: LogControl;
   private logList: LogList;
   private logListContainer: HTMLDivElement;
   private logViewActions: LogViewActions;
 
   public static readonly externalActionRequestEvent: string = 'external-action-request-event';
 
-  constructor(private state: State, private root: HTMLElement) {
+  constructor(
+    private state: State,
+    private root: HTMLElement,
+    private options: LogViewOptions = {
+      columnFormatter: (_fieldName, text) => text,
+      showControls: true
+    }) {
     super();
     this.logList = new LogList(state.currentFields, state.shouldWrapLogs,
-      state.setHeaderWidth.bind(state), state.currentFilter);
-
-    this.logControl = new LogControl(this.state.currentFilterText);
-    this.logControl.addEventListener(LogControl.filterChangeEvent, (e) => {
-      const event = e as CustomEvent;
-      const { filter, text } = event.detail;
-      this.state.registerFilter(filter, text);
-      this.logControl.resultsMessage = filter.isEmpty() ? '' : `${this.logList.filterResultsCount} results`;
-
-    });
+      state.setHeaderWidth.bind(state), state.currentFilter, options);
+    if (this.options.showControls) {
+      this.logControl = new LogControl(this.state.currentFilterText);
+      this.logControl.addEventListener(LogControl.filterChangeEvent, (e) => {
+        const event = e as CustomEvent;
+        const { filter, text } = event.detail;
+        this.state.registerFilter(filter, text);
+        this.logControl!.resultsMessage =
+          filter.isEmpty() ? '' : `${this.logList.filterResultsCount} results`;
+      });
+    }
 
     this.state.addEventListener('filterChange', (e) => {
       const event = e as CustomEvent;
@@ -77,7 +85,7 @@
     this.logListContainer.id = 'log-list-container';
 
     this.root.appendChild(this.logActionContainer);
-    this.logActionContainer.appendChild(this.logControl);
+    if (this.logControl) {this.logActionContainer.appendChild(this.logControl);}
     this.logActionContainer.appendChild(this.logViewActions);
 
     this.root.appendChild(this.logListContainer);
@@ -98,7 +106,7 @@
   public reset() {
     this.state.reset();
     this.logList.reset();
-    this.logControl.reset();
+    this.logControl?.reset();
   }
 
   /**
diff --git a/webviews/logging/src/fields.ts b/webviews/logging/src/fields.ts
index 08a69e7..a708c12 100644
--- a/webviews/logging/src/fields.ts
+++ b/webviews/logging/src/fields.ts
@@ -10,7 +10,15 @@
 
 export type LogFieldData = {
   displayName: string,
-  width: string,
+  width: string
+};
+
+export type LogColumnFormatter =
+  (fieldName: string, text: string) => HTMLElement | string;
+
+export type LogViewOptions = {
+  columnFormatter: LogColumnFormatter,
+  showControls: boolean
 };
 
 export type LogHeadersData = Record<LogField, LogFieldData>;
diff --git a/webviews/logging/test/filter.test.ts b/webviews/logging/test/filter.test.ts
index 3315e35..36735b2 100644
--- a/webviews/logging/test/filter.test.ts
+++ b/webviews/logging/test/filter.test.ts
@@ -8,6 +8,9 @@
 import { FilterExpression, OrExpression, parseFilter } from '../src/filter';
 import {ffxLogToLogRowData} from '../src/ffxLogToLogRow';
 import {State} from '../src/state';
+import {LogColumnFormatter} from '../src/fields';
+
+const columnFormatter: LogColumnFormatter = (_fieldName, text) => text;
 
 before(() => {
   chai.should();
@@ -55,7 +58,7 @@
             },
             timestamp: 12345,
             version: 1
-          }, false)!, state.currentFields),
+          }, false)!, state.currentFields, columnFormatter),
       new LogRow( //2
         ffxLogToLogRowData(
           {
@@ -92,7 +95,7 @@
             },
             timestamp: 12345,
             version: 1
-          }, false)!, state.currentFields),
+          }, false)!, state.currentFields, columnFormatter),
       new LogRow(
         ffxLogToLogRowData(
           {
@@ -129,7 +132,7 @@
             },
             timestamp: 12345,
             version: 1
-          }, false)!, state.currentFields),
+          }, false)!, state.currentFields, columnFormatter),
     ];
 
     for (const index in testLitElements) {
diff --git a/webviews/logging/test/log_header.test.ts b/webviews/logging/test/log_header.test.ts
index bc1f570..055d694 100644
--- a/webviews/logging/test/log_header.test.ts
+++ b/webviews/logging/test/log_header.test.ts
@@ -8,6 +8,12 @@
 import { LogField } from '../src/fields';
 import {LogHeader} from '../components/log_header';
 import {LogList} from '../components/log_list';
+import {LogViewOptions} from '../src/fields';
+
+const logOptions: LogViewOptions = {
+  columnFormatter: (_fieldName, text) => text,
+  showControls: true
+};
 
 before(() => {
   chai.should();
@@ -24,7 +30,7 @@
     document.body.appendChild(root);
     state = new State();
     logList = new LogList(state.currentFields, state.shouldWrapLogs,
-      state.setHeaderWidth.bind(state), state.currentFilter);
+      state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
     root.appendChild(logList);
     await logList.updateComplete;
     logHeader = logList.shadowRoot!.children[0] as LogHeader;
diff --git a/webviews/logging/test/log_list.test.ts b/webviews/logging/test/log_list.test.ts
index 0136a2a..b8aed93 100644
--- a/webviews/logging/test/log_list.test.ts
+++ b/webviews/logging/test/log_list.test.ts
@@ -12,6 +12,12 @@
 import {MemoryStore, State} from '../src/state';
 import {LitElement} from 'lit';
 import {ffxLogToLogRowData} from '../src/ffxLogToLogRow';
+import {LogViewOptions} from '../src/fields';
+
+const logOptions: LogViewOptions = {
+  columnFormatter: (_fieldName, text) => text,
+  showControls: true
+};
 
 before(function () {
   chai.should();
@@ -36,7 +42,7 @@
     it('crates a new empty view', async () => {
       const logsList = new LogList(
         state.currentFields, state.shouldWrapLogs,
-        state.setHeaderWidth.bind(state), state.currentFilter);
+        state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
       root.appendChild(logsList);
       await logsList.updateComplete;
       logsList.shadowRoot!.children.length.should.equal(1);
@@ -51,7 +57,7 @@
       state.shouldWrapLogs = true;
       let logList = new LogList(
         state.currentFields, state.shouldWrapLogs,
-        state.setHeaderWidth.bind(state), state.currentFilter);
+        state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
       root.appendChild(logList);
       await logList.updateComplete;
       logList.hasAttribute(WRAP_LOG_TEXT_ATTR).should.be.true;
@@ -59,7 +65,7 @@
       state.shouldWrapLogs = false;
       logList = new LogList(
         state.currentFields, state.shouldWrapLogs,
-        state.setHeaderWidth.bind(state), state.currentFilter);
+        state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
       logList.hasAttribute(WRAP_LOG_TEXT_ATTR).should.be.false;
     });
   });
@@ -68,7 +74,7 @@
     it('appends a log', async () => {
       const logsList = new LogList(
         state.currentFields, state.shouldWrapLogs,
-        state.setHeaderWidth.bind(state), state.currentFilter);
+        state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
 
       document.body.appendChild(logsList);
       await logsList.updateComplete;
@@ -106,7 +112,7 @@
       }), 'moniker:core/bar');
       const logsList = new LogList(
         state.currentFields, state.shouldWrapLogs,
-        state.setHeaderWidth.bind(state), state.currentFilter);
+        state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
       root.appendChild(logsList);
 
       logsList.addLog(ffxLogToLogRowData(logDataForTest('core/foo'))!);
@@ -123,7 +129,7 @@
     it('handles ffx events', async () => {
       const logsList = new LogList(
         state.currentFields, state.shouldWrapLogs,
-        state.setHeaderWidth.bind(state), state.currentFilter);
+        state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
       let moniker = constant.FFX_MONIKER;
       const msg = 'Logger lost connection to target. Retrying...';
       logsList.addLog(ffxLogToLogRowData(ffxEventForTest('TargetDisconnected'))!);
@@ -149,7 +155,7 @@
     it('handles malformed logs', async () => {
       const logsList = new LogList(
         state.currentFields, state.shouldWrapLogs,
-        state.setHeaderWidth.bind(state), state.currentFilter);
+        state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
       const msg = 'Malformed target log: oh no something went wrong';
       logsList.addLog(ffxLogToLogRowData(malformedLogForTest('oh no something went wrong'))!);
 
@@ -171,7 +177,7 @@
     it('detects user hovering logs', () => {
       const view = new LogList(
         state.currentFields, state.shouldWrapLogs,
-        state.setHeaderWidth.bind(state), state.currentFilter);
+        state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
       view.addLog(ffxLogToLogRowData(logDataForTest('core/foo'))!);
       const scrollArea = view.parentElement;
       scrollArea?.matches(':hover').should.equal(false);
@@ -184,7 +190,7 @@
     it('resets the log list', async () => {
       const logsList = new LogList(
         state.currentFields, state.shouldWrapLogs,
-        state.setHeaderWidth.bind(state), state.currentFilter);
+        state.setHeaderWidth.bind(state), state.currentFilter, logOptions);
       root.appendChild(logsList);
       logsList.addLog(ffxLogToLogRowData(logDataForTest('core/foo'))!);
       await logsList.updateComplete;
diff --git a/webviews/logging/test/log_row.test.ts b/webviews/logging/test/log_row.test.ts
index cb613a4..04099cd 100644
--- a/webviews/logging/test/log_row.test.ts
+++ b/webviews/logging/test/log_row.test.ts
@@ -10,6 +10,9 @@
 import { messageForEvent } from '../src/format_log_text';
 import {ffxLogToLogRowData} from '../src/ffxLogToLogRow';
 import {State} from '../src/state';
+import {LogColumnFormatter} from '../src/fields';
+
+const columnFormatter: LogColumnFormatter = (_fieldName, text) => text;
 
 before(() => {
   chai.should();
@@ -64,7 +67,7 @@
             keys: null,
             printf: null,
           }
-        }), false)!, state.currentFields);
+        }), false)!, state.currentFields, columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       const testFields = [
@@ -96,7 +99,7 @@
             keys: null,
             printf: null,
           }
-        }), false)!, state.currentFields);
+        }), false)!, state.currentFields, columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       const testFields = [
@@ -115,6 +118,31 @@
       logRow.remove();
     });
 
+    it('formats html logs', async () => {
+      const logRow = new LogRow(
+        {fields: [{key: 'message', text: 'hello vscode'}]}
+        , {
+          'timestamp': {displayName: 'timestamp', width: ''},
+          'pid': {displayName: 'pid', width: ''},
+          'tid': {displayName: 'tid', width: ''},
+          'tags': {displayName: 'tags', width: ''},
+          'moniker': {displayName: 'moniker', width: ''},
+          'severity': {displayName: 'severity', width: ''},
+          'message': {displayName: 'messagepw', width: ''},
+        }, (fieldName, text) => {
+          const el = document.createElement('span');
+          if (fieldName === 'message') {
+            el.innerHTML = text.replace('vscode', '<b>vscode</b>');
+          }
+          return el;
+        });
+      document.body.appendChild(logRow);
+      await logRow.updateComplete;
+      const msgField = logRow.children[0].children[6] as HTMLElement;
+      msgField.querySelector('b')!.innerText.should.equal('vscode');
+      logRow.remove();
+    });
+
     it('formats structured logs', async () => {
       const logRow = new LogRow(ffxLogToLogRowData(
         logDataForTest({
@@ -128,7 +156,7 @@
             },
             printf: null,
           }
-        }), false)!, state.currentFields);
+        }), false)!, state.currentFields, columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       const testFields =
@@ -153,7 +181,7 @@
               args: ['Fuchsia', 1]
             },
           }
-        }), false)!, state.currentFields);
+        }), false)!, state.currentFields, columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       const testFields =
@@ -182,7 +210,7 @@
             keys: null,
             printf: null,
           }
-        }), false)!, state.currentFields);
+        }), false)!, state.currentFields, columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       const testFields =
@@ -198,7 +226,9 @@
     it('formats logging started events', async () => {
       let logRow = new LogRow(
         ffxLogToLogRowData(
-          ffxEventForTest('LoggingStarted'), false)!, state.currentFields);
+          ffxEventForTest('LoggingStarted'), false)!,
+        state.currentFields,
+        columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       let moniker = constant.FFX_MONIKER;
@@ -212,7 +242,9 @@
 
       logRow = new LogRow(
         ffxLogToLogRowData(
-          ffxEventForTest('LoggingStarted'), true)!, state.currentFields);
+          ffxEventForTest('LoggingStarted'), true)!,
+        state.currentFields,
+        columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       msg = messageForEvent('LoggingStarted', true);
@@ -227,7 +259,9 @@
     it('formats target disconnected events', async () => {
       const logRow = new LogRow(
         ffxLogToLogRowData(
-          ffxEventForTest('TargetDisconnected'), false)!, state.currentFields);
+          ffxEventForTest('TargetDisconnected'), false)!,
+        state.currentFields,
+        columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       let moniker = constant.FFX_MONIKER;
@@ -243,7 +277,9 @@
     it('formats malformed log', async () => {
       const logRow = new LogRow(
         ffxLogToLogRowData(
-          malformedLogForTest('hello world'), false)!, state.currentFields);
+          malformedLogForTest('hello world'), false)!,
+        state.currentFields,
+        columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       let msg = 'Malformed target log: hello world';
@@ -263,7 +299,7 @@
         version: 0
       };
       const logRow = new LogRow(
-        ffxLogToLogRowData(data, false)!, state.currentFields);
+        ffxLogToLogRowData(data, false)!, state.currentFields, columnFormatter);
       document.body.appendChild(logRow);
       await logRow.updateComplete;
       const monikerEl = logRow.children[0].children[3] as HTMLElement;
diff --git a/webviews/logging/test/view.test.ts b/webviews/logging/test/view.test.ts
index 14141ce..0e5b7cc 100644
--- a/webviews/logging/test/view.test.ts
+++ b/webviews/logging/test/view.test.ts
@@ -12,9 +12,16 @@
 import { LogControl } from '../components/log_control';
 import { Filter } from '../src/filter';
 import { LogViewActions } from '../components/log_view_actions';
-import { WRAP_LOG_TEXT_ATTR } from '../components/log_list';
-import { LitElement } from 'lit';
 import { ffxLogToLogRowData } from '../src/ffxLogToLogRow';
+import {LogList, WRAP_LOG_TEXT_ATTR} from '../components/log_list';
+import {LitElement} from 'lit';
+import {LogViewOptions} from '../src/fields';
+
+const logOptions: LogViewOptions = {
+  columnFormatter: (_fieldName, text) => text,
+  showControls: false
+};
+
 before(function () {
   chai.should();
   chai.use(chaiDom);
@@ -105,6 +112,13 @@
       state.currentFilterText.should.deep.equal(filterText);
     });
 
+    it('log control and filter field can be disabled', async () => {
+      new LoggingView(state, root, logOptions);
+      let logView = root.getElementsByTagName('log-view')[0] as LogList;
+      await logView.updateComplete;
+      root.getElementsByTagName('log-control').length.should.equal(0);
+    });
+
     it('announces the number of filtered results', async () => {
       const view = new LoggingView(state, root);
       const logList = root.querySelector('log-view') as LitElement;