diff --git a/.github/old_screenshot.png b/.github/old_screenshot.png new file mode 100644 index 0000000..0fa1120 Binary files /dev/null and b/.github/old_screenshot.png differ diff --git a/.github/screenshot.png b/.github/screenshot.png new file mode 100644 index 0000000..9586aa8 Binary files /dev/null and b/.github/screenshot.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..10a85a1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + inputs: + type: + description: 'Release type (major/minor/patch)' + default: 'patch' + required: true + type: choice + options: + - major + - minor + - patch + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Exit if workflow_dispatch on other branch than main + if: (github.event_name == 'workflow_dispatch') && (github.ref_name != 'main') + run: | + echo "Release workflow should be runned from main branch, exiting." + exit 1 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Setup Git + run: | + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + - name: Install dependencies + run: npm ci + + # Only runs on release + - name: Set release version + if: github.event_name == 'workflow_dispatch' + run: | + npm version ${{ github.event.inputs.type }} + echo version=$(node -p "require('./package.json').version") >> $GITHUB_ENV + + - name: Package + run: npm run package + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: vscode-vsix + path: "*.vsix" + retention-days: 5 + + # All tasks below only run on release + - name: Push changes + uses: ad-m/github-push-action@master + if: github.event_name == 'workflow_dispatch' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + tags: true + branch: main + - name: Upload release + uses: softprops/action-gh-release@v1 + if: github.event_name == 'workflow_dispatch' + with: + tag_name: "v${{ env.version }}" + files: "*.vsix" diff --git a/docs/developer-documentation/constants/enums/SelectedRowType.md b/docs/developer-documentation/constants/enums/SelectedRowType.md new file mode 100644 index 0000000..2fab8da --- /dev/null +++ b/docs/developer-documentation/constants/enums/SelectedRowType.md @@ -0,0 +1,15 @@ + +--- +```TS +enum SelectedRowType { + None = "NONE", + UserSelect = "SELECTED", + QueryResult = "QUERY_RESULT", +} +``` + +This `enum` is used for the Structure Matching functionality to indicate if the row is selected by the user. + +`NONE` if the row is not selected; +`SELECTED` if the row is selected by the user; +`QUERY_RESULT` it is not used yet. \ No newline at end of file diff --git a/src/viewer/LogFile.tsx b/src/viewer/LogFile.tsx new file mode 100644 index 0000000..541912c --- /dev/null +++ b/src/viewer/LogFile.tsx @@ -0,0 +1,227 @@ +import Rule from "./rules/Rule"; +import { extent } from "d3-array"; +import { Header } from "./types"; +import { scaleSequential } from "d3-scale"; +import { interpolateTurbo } from "d3-scale-chromatic"; + +const DEFAULT_HEADER_TYPE = "string"; +const HEADER_TYPE_LOOKUP = { + threadID: "number", +}; + +export default class LogFile { + private readonly headerIndexLookup: { [k: string]: number }; + + readonly contentHeaders: string[]; + readonly rows: string[][]; + readonly columnsColors: string[][] = []; + headers: Header[]; + selectedColumns: boolean[]; + selectedColumnsMini: boolean[]; + + private constructor(contentHeaders: string[], headers: Header[], rows: string[][]) { + this.contentHeaders = contentHeaders; + this.headerIndexLookup = Object.fromEntries(headers.map((h, i) => [h.name, i])); + this.headers = headers; + this.rows = rows; + this.selectedColumns = new Array(headers.length).fill(true); + this.selectedColumnsMini = new Array(headers.length).fill(true); + this.selectedColumnsMini[0] = false; + } + + static create(content: { [s: string]: string }[], rules: Rule[]) { + const contentHeaders = this.getContentHeaders(content); + const headers = this.getHeaders(contentHeaders, rules); + if (!contentHeaders.includes("Line")) + for (let i = 0; i < content.length; i++) + content[i]["Line"] = (i + 1).toString(); + const rows = content.map((l) => headers.map((h) => l[h.name])); + const logFile = new LogFile(contentHeaders, headers, rows); + logFile.computeDefaultColumnColors(); + logFile.computeRulesValuesAndColors(rules); + return logFile; + } + + updateLogFile(rules: Rule[], structureMatches: number[][]): LogFile { + const [updatedSelected, updatedSelectedMini] = this.updateSelectedColumns(rules) + const headers = LogFile.getHeaders(this.contentHeaders, rules); + + let rows = this.rows; + + if (structureMatches.length > 0) { + let num = 1; + while (true) { + const name = "Structure" + num + const type = DEFAULT_HEADER_TYPE; + if (!headers.map(h => h.name).includes(name)) { + headers.push({ name, type }); + break; + } + num++; + } + // Previous structure match already exists in logfile + if (headers.length === rows[0].length) { + for (let i = 0; i < rows.length; i++) + rows[i].pop(); + } + else { + updatedSelected.push(true); + updatedSelectedMini.push(true); + } + for (let i = 0; i < rows.length; i++) + rows[i].push("0"); + + let currentStructureIndex = 0; + for (let i = 0; i < rows.length; i++) { + if (currentStructureIndex < structureMatches.length) { + if (i > structureMatches[currentStructureIndex].at(-1)!) { + currentStructureIndex++; + if (currentStructureIndex === structureMatches.length) + break; + } + + if (structureMatches[currentStructureIndex].includes(i)) { + rows[i].pop(); + rows[i].push((currentStructureIndex + 1).toString()); + } + } + } + } + + const logFile = new LogFile(this.contentHeaders, headers, rows); + logFile.copyDefaultColumnColors(this.columnsColors); + logFile.computeRulesValuesAndColors(rules); + return logFile.setSelectedColumns(updatedSelected, updatedSelectedMini); + } + + updateSelectedColumns(rules: Rule[]) { + const existingHeaders = this.headers.map(h => h.name); + const updatedSelected = this.selectedColumns.slice(0, this.contentHeaders.length); + const updatedSelectedMini = this.selectedColumnsMini.slice(0, this.contentHeaders.length); + + for (let i = 0; i < rules.length; i++) { + const existingIndex = existingHeaders.indexOf(rules[i].column); + if (existingIndex > -1) { + updatedSelected.push(this.selectedColumns[existingIndex]); + updatedSelectedMini.push(this.selectedColumnsMini[existingIndex]); + } + else { + updatedSelected.push(true); + updatedSelectedMini.push(true); + } + } + return [updatedSelected, updatedSelectedMini] + } + + setSelectedColumns(selected: boolean[], selectedMini: boolean[]) { + for (let column = 0; column < this.selectedColumns.length; column++) { + if (selected[column] !== undefined) { + this.selectedColumns[column] = selected[column]; + } + } + for (let column = 0; column < this.selectedColumnsMini.length; column++) { + if (selectedMini[column] !== undefined) { + this.selectedColumnsMini[column] = selectedMini[column]; + } + } + return this; + } + + getSelectedHeader(): Header[] { + const selectedHeaders = this.headers.filter((h, i) => this.selectedColumns[i] == true); + return selectedHeaders; + } + + getSelectedHeaderMini(): Header[] { + const selectedHeadersMini = this.headers.filter((h, i) => this.selectedColumnsMini[i] == true); + return selectedHeadersMini; + } + + private static getContentHeaders(content: { [s: string]: string }[]) { + // Headers are all keys that are present in the first object (row) + const firstRow = content[0] ?? {}; + const contentHeaders = Object.keys(firstRow); + return contentHeaders; + } + + private static getHeaders(contentHeaders: string[], rules: Rule[]) { + const allHeaders = [...contentHeaders, ...rules.map((r) => r.column)]; + let headers = allHeaders.map((name) => { + const type = HEADER_TYPE_LOOKUP[name] ?? DEFAULT_HEADER_TYPE; + return { name, type }; + }); + if (!contentHeaders.includes("Line")) { + const lineHeader = [{ name: "Line", type: DEFAULT_HEADER_TYPE }]; + headers = lineHeader.concat(headers); + } + return headers; + } + + + private computeDefaultColumnColors() { + for (let i = 0; i < this.getStaticHeadersSize(); i++) { + const values = this.rows.map((r) => r[i]); + this.columnsColors[i] = LogFile.computeColors(this.headers[i], values); + } + } + + private copyDefaultColumnColors(colours: string[][]) { + for (let i = 0; i < this.getStaticHeadersSize(); i++) { + this.columnsColors[i] = colours[i]; + } + } + + private computeRulesValuesAndColors(rules: Rule[]) { + // Compute rules values + const firstRuleIndex = this.getStaticHeadersSize(); + const rulesValues = rules.map((r) => r.computeValues(this)); + for (let row = 0; row < this.rows.length; row++) { + for (let column = 0; column < rulesValues.length; column++) { + this.rows[row][column + firstRuleIndex] = rulesValues[column][row]; + } + } + + // Compute colors + for (let i = firstRuleIndex; i < this.headers.length; i++) { + const values = this.rows.map((r) => r[i]); + this.columnsColors[i] = LogFile.computeColors(this.headers[i], values); + } + } + + private static computeColors(header: Header, values: string[]) { + let colorizer: (s: string) => string; + if (this.containsOnlyNumbers(values)) { + let uniqueValues = [...new Set(values)]; + const sortedNumbers = uniqueValues.map(Number).sort(function (a, b) { return a - b; }); + colorizer = scaleSequential().domain(extent(sortedNumbers)).interpolator(interpolateTurbo); + } else if (header.type === "string") { + const uniqueValues = [...new Set(values)].sort(); + colorizer = (v) => interpolateTurbo(uniqueValues.indexOf(v) / uniqueValues.length); + } + + return values.map((l) => colorizer(l)); + } + + private static containsOnlyNumbers(items: string[]) { + for (const i of items) { + if (!Number(+i) && (+i !== 0)) + return false; + } + return true; + } + + private getStaticHeadersSize() { + let size = this.contentHeaders.length; + if (!this.contentHeaders.includes("Line")) + size++; + return size; + } + + amountOfRows = () => this.rows.length; + amountOfColumns = () => this.headers.length; + amountOfColorColumns = () => this.columnsColors.length; + + value(column: string, row: number): string { + return this.rows[row]?.[this.headerIndexLookup[column]]; + } +} diff --git a/src/viewer/contextMenu/contextMenu.tsx b/src/viewer/contextMenu/contextMenu.tsx new file mode 100644 index 0000000..a9d9f7e --- /dev/null +++ b/src/viewer/contextMenu/contextMenu.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { ContextMenuItem } from "../types"; +import { getContextMenuItemStyle, getContextMenuStyle } from "../hooks/useStyleManager"; +import { motion } from "framer-motion"; + +interface Props { + parentDivId: string; + items: ContextMenuItem[]; +} + +interface State { + xPos: number; + yPos: number; + showMenu: boolean; + selectedItemIndex: number | null; + anchorDivId: string; +} + +export default class ContextMenu extends React.PureComponent { + parentDiv: HTMLElement | null; + + constructor(props: Props) { + super(props); + this.parentDiv = document.getElementById(this.props.parentDivId); + this.state = { + xPos: 0, + yPos: 0, + showMenu: false, + selectedItemIndex: null, + anchorDivId: "", + }; + } + + componentDidMount(): void { + if (this.parentDiv) { + this.parentDiv.addEventListener("click", this.handleClick); + this.parentDiv.addEventListener("contextmenu", this.handleContextMenu); + } + } + + componentWillUnmount(): void { + if (this.parentDiv) { + this.parentDiv.removeEventListener("click", this.handleClick); + this.parentDiv.removeEventListener("contextmenu", this.handleContextMenu); + } + } + + handleClick = () => { + if (this.state.selectedItemIndex !== null) { + this.props.items[this.state.selectedItemIndex].callback(this.state.anchorDivId); + } + + if (this.state.showMenu) this.setState({ showMenu: false, selectedItemIndex: null }); + }; + + handleContextMenu = (e) => { + e.preventDefault(); + const path = e.composedPath(); + + this.setState({ + xPos: e.pageX, + yPos: e.pageY, + showMenu: true, + anchorDivId: path[0].id, + }); + }; + + toggleSelectedOptionIndex(selectedOptionIndex: number) { + this.setState({ selectedItemIndex: selectedOptionIndex }); + } + + clearSelectedOptionIndex() { + this.setState({ selectedItemIndex: null }); + } + + renderMenuOptions() { + const { items, parentDivId } = this.props; + const result: any = []; + + for (let o = 0; o < items.length; o++) { + const isSelected = o === this.state.selectedItemIndex; + const contextMenuItemStyle = getContextMenuItemStyle(isSelected); + + result.push( +
this.toggleSelectedOptionIndex(o)} + > + {items[o].text} +
, + ); + } + + return result; + } + + render() { + const { items } = this.props; + const { showMenu, xPos, yPos } = this.state; + const contextMenuHeight = items.length * 28; + const contextMenuWidth = 120; + + const contextMenuStyle = getContextMenuStyle(contextMenuHeight, contextMenuWidth, xPos, yPos); + + if (showMenu) + return ( +
this.clearSelectedOptionIndex()}> + +
{this.renderMenuOptions()}
+
+
+ ); + else return null; + } +} diff --git a/src/viewer/log/LogView.tsx b/src/viewer/log/LogView.tsx new file mode 100644 index 0000000..8de6020 --- /dev/null +++ b/src/viewer/log/LogView.tsx @@ -0,0 +1,505 @@ +import React from "react"; +import { + LOG_HEADER_HEIGHT, + LOG_ROW_HEIGHT, + LOG_COLUMN_WIDTH_LOOKUP, + LOG_DEFAULT_COLUMN_WIDTH, + BORDER, + BORDER_SIZE, + RGB_ANNOTATION0, + RGB_ANNOTATION1, + RGB_ANNOTATION2, + RGB_ANNOTATION3, + RGB_ANNOTATION4, +} from "../constants"; +import { + getHeaderColumnInnerStyle, + getHeaderColumnStyle, + getLogViewRowSelectionStyle, + getLogViewStructureMatchStyle, + getSegmentStyle, + getSegmentRowStyle, +} from "../hooks/useStyleManager"; +import { LogViewState, RowProperty, Segment, StructureMatchId } from "../types"; +import LogFile from "../LogFile"; +import ReactResizeDetector from "react-resize-detector"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +import { getSegmentMaxLevel } from "../hooks/useRowProperty"; +import Tooltip from "@mui/material/Tooltip"; + +interface Props { + logFile: LogFile; + previousSessionLogView: LogViewState | undefined; + onLogViewStateChanged: (value: LogViewState) => void; + onSelectedRowsChanged: (index: number, event: React.MouseEvent) => void; + onRowPropsChanged: (index: number, isRendered: boolean) => void; + forwardRef: React.RefObject; + filterSearch: boolean; + coloredTable: boolean; + rowProperties: RowProperty[]; + currentSearchMatch: number | null; + currentStructureMatch: number[]; + structureMatches: number[][]; + collapsibleRows: { [key: number]: Segment }; + clearSegmentation: () => void; +} +interface State { + state: LogViewState | undefined; + logFile: LogFile; + columnWidth: { [id: string]: number }; + collapsed: { [key: number]: boolean }; + isLoadingSavedState: boolean; +} + +const HEADER_STYLE: React.CSSProperties = { + width: "100%", + height: LOG_HEADER_HEIGHT, + position: "relative", + overflow: "hidden", + borderBottom: BORDER, +}; + +const VIEWPORT_STYLE: React.CSSProperties = { position: "relative", flex: 1, overflow: "auto" }; + +export default class LogView extends React.Component { + viewport: React.RefObject; + + constructor(props: Props) { + super(props); + this.viewport = this.props.forwardRef; + this.updateState = this.updateState.bind(this); + this.state = { + state: undefined, + columnWidth: LOG_COLUMN_WIDTH_LOOKUP, + logFile: this.props.logFile, + collapsed: [], + isLoadingSavedState: false + }; + } + + componentDidMount(): void { + window.addEventListener("resize", () => this.updateState()); + // this.updateState(); + } + + componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { + if (prevProps.logFile !== this.props.logFile) { + if ( this.props.previousSessionLogView === undefined) + this.updateState(); + else + this.loadState(); + } + if (prevProps.currentStructureMatch[0] !== this.props.currentStructureMatch[0]) { + this.updateState(this.props.currentStructureMatch[0]); + } + if (prevProps.currentSearchMatch !== this.props.currentSearchMatch) { + this.updateState(this.props.currentSearchMatch); + } + if (this.viewport.current && this.props.previousSessionLogView && this.state.isLoadingSavedState) { + this.viewport.current.scrollTop = this.props.previousSessionLogView.scrollTop; + this.setState({isLoadingSavedState:false}); + } + if (prevProps.filterSearch !== this.props.filterSearch) { + const firstRow = this.state.state?.startFloor; + this.updateState(firstRow); + } + } + + renderColumn( + value: string, + columnIndex: number, + isHeader: boolean, + width: number, + colorMap: string, + ) { + const height = isHeader ? LOG_HEADER_HEIGHT : LOG_ROW_HEIGHT; + + let color = "transparent"; + let fontColor = ""; + + if (this.props.coloredTable) { + color = colorMap; + if (this.isLight(color)) { + fontColor = "#000000"; + } else { + fontColor = "#ffffff"; + } + } + const columnHeaderStyle = getHeaderColumnStyle(width, columnIndex, height); + const columnHeaderInnerStyle = getHeaderColumnInnerStyle(height, isHeader); + const colorStyle: React.CSSProperties = { backgroundColor: color, color: fontColor }; + const innerStyle = { ...columnHeaderInnerStyle, ...colorStyle }; + + return ( +
+
{value}
+
+ ); + } + + renderRows() { + // This method only renders the rows that are visible + if (!this.state.state) return; + const result: any = []; + const { + logFile, + rowProperties, + structureMatches, + currentStructureMatch, + collapsibleRows, + } = this.props; + let firstRender = this.state.state.startFloor; + let lastRender = this.state.state.endCeil; + const visibleRows = logFile.rows.filter((v, i) => rowProperties[i].isRendered); + if (lastRender > visibleRows.length) { + if (!this.viewport.current) return; + const height = this.viewport.current.clientHeight; + const maxVisibleItems = height / LOG_ROW_HEIGHT; + lastRender = visibleRows.length - 1; + firstRender = Math.max(0, Math.ceil(lastRender - maxVisibleItems) - 1); + } + + // Search does not match any row + if (visibleRows.length === 0) { + return []; + } + + let counter = firstRender; + const maxLevel = Math.min(4, getSegmentMaxLevel(collapsibleRows)); + const segmentWidth: number = (getSegmentMaxLevel(this.props.collapsibleRows) + 1) * 30 + BORDER_SIZE; + const structureMatchesLogRows = structureMatches.flat(1); + + for (let r = firstRender; counter <= lastRender; r++) { + if (rowProperties[r].isRendered) { + let rowStyle; + + if (structureMatchesLogRows.includes(r) && !this.props.filterSearch) { + rowStyle = getLogViewStructureMatchStyle( + currentStructureMatch, + structureMatches, + r, + segmentWidth, + ); + } else { + rowStyle = getLogViewRowSelectionStyle(rowProperties, r, counter, segmentWidth); + } + //add segment + let rowResult: any = []; + for (let l = 0; l <= maxLevel; l++) { + rowResult.push(this.renderSegmentForRow(r, l)); + } + const segmentStyle = getSegmentRowStyle(segmentWidth, counter * LOG_ROW_HEIGHT); + //add log rows + result.push( +
+ {Object.keys(this.props.collapsibleRows).length > 0 && +
+
+ {rowResult} +
+
} +
this.props.onSelectedRowsChanged(r, event)} + > + {logFile.headers.map( + (h, c) => + logFile.selectedColumns[c] == true && + this.renderColumn( + logFile.rows[r][c], + c, + false, + this.state.columnWidth[h.name], + logFile.columnsColors[c][r], + ), + )} +
+
, + ); + counter++; + rowResult = []; + } + } + return result; + } + + renderSegmentForRow(r: number, level: number) { + const { collapsibleRows } = this.props; + const style: React.CSSProperties = { + display: "inline-block", + position: "relative", + textAlign: "center", + alignContent: "center", + justifyContent: "center", + height: LOG_ROW_HEIGHT, + width: 30 + }; + let annotation = false; + if (collapsibleRows[r] != undefined && collapsibleRows[r].level == level) { + return ( + this.collapseRows(r)} + > + {this.state.collapsed[r] ? ( + + ) : ( + + )} + + ); + } else { + Object.keys(collapsibleRows) + .filter((key) => collapsibleRows[key].level == level) + .map((key) => { + const segment: Segment = collapsibleRows[key]; + if (segment != undefined) { + if (r <= segment.end && r > segment.start) { + annotation = true; + } + } + }); + } + if (annotation) { + return ( +
+
+
+ ); + } else { + return
; + } + } + + collapseRows(index: number) { + if (this.state.collapsed[index]) { + //expand + this.setState((prevState) => { + const collapsed = { ...prevState.collapsed }; + collapsed[index] = false; + return { collapsed }; + }); + let collapsedEnd = 0; + for (let r = index + 1; r <= this.props.collapsibleRows[index].end; r++) { + const collap = this.state.collapsed[r]; + if (collap) { + collapsedEnd = + this.props.collapsibleRows[r] == undefined + ? collapsedEnd + : this.props.collapsibleRows[r].end; + this.props.onRowPropsChanged(r, true); + } else if (r <= collapsedEnd) { + this.props.onRowPropsChanged(r, false); + } else { + this.props.onRowPropsChanged(r, true); + } + } + } else { + //collapse + for (let r = index + 1; r <= this.props.collapsibleRows[index].end; r++) { + this.props.onRowPropsChanged(r, false); + } + this.setState((prevState) => { + const collapsed = { ...prevState.collapsed }; + collapsed[index] = true; + return { collapsed }; + }); + } + } + + loadState() { + if (!this.props.previousSessionLogView) return; + this.setState({ state: this.props.previousSessionLogView, isLoadingSavedState: true }); + this.props.onLogViewStateChanged( this.props.previousSessionLogView ); + } + + updateState(currentMatchFirstRow: StructureMatchId = null) { + if (!this.viewport.current) return; + const height = this.viewport.current.clientHeight; + const maxVisibleItems = height / LOG_ROW_HEIGHT; + const visibleItems = Math.min(this.props.logFile.amountOfRows(), maxVisibleItems); + let scrollTop; + + if (currentMatchFirstRow !== null) { + if (currentMatchFirstRow + visibleItems < this.props.logFile.amountOfRows()) { + scrollTop = currentMatchFirstRow * LOG_ROW_HEIGHT; + } else { + scrollTop = + visibleItems < this.props.logFile.amountOfRows() + ? (this.props.logFile.amountOfRows() - 1 - visibleItems) * LOG_ROW_HEIGHT + : 0; + } + } else { + scrollTop = this.viewport.current.scrollTop; + } + + const scrollLeft = this.viewport.current.scrollLeft; + const start = + currentMatchFirstRow !== null && + currentMatchFirstRow + maxVisibleItems < this.props.logFile.amountOfRows() + ? currentMatchFirstRow + : scrollTop / LOG_ROW_HEIGHT; + const startFloor = Math.floor(start); + const endCeil = Math.min( + Math.ceil(start + maxVisibleItems) - 1, + this.props.logFile.amountOfRows() - 1, + ); + const state = { + height, + scrollLeft, + scrollTop, + startFloor, + start, + endCeil, + visibleItems, + rowHeight: LOG_ROW_HEIGHT, + }; + + if (currentMatchFirstRow !== null) { + this.viewport.current.scrollTop = scrollTop; + } + + this.setState({ state }); + this.props.onLogViewStateChanged(state); + } + + setColumnWidth(name: string, width: number) { + //update the state for triggering the render + this.setState((prevState) => { + const columnWidth = { ...prevState.columnWidth }; + columnWidth[name] = width; + return { columnWidth }; + }); + } + + columnWidth(name: string) { + return LOG_COLUMN_WIDTH_LOOKUP[name] ?? LOG_DEFAULT_COLUMN_WIDTH; + } + + isLight(color: string) { + const colors = JSON.parse(color.slice(3).replace("(", "[").replace(")", "]")); + const brightness = (colors[0] * 299 + colors[1] * 587 + colors[2] * 114) / 1000; + return brightness > 110; + } + + getRGB(level: number) { + switch (level) { + case 0: + return RGB_ANNOTATION0; + case 1: + return RGB_ANNOTATION1; + case 2: + return RGB_ANNOTATION2; + case 3: + return RGB_ANNOTATION3; + case 4: + return RGB_ANNOTATION4; + } + } + + getVisibleRows() { + const { logFile, rowProperties } = this.props; + const visibleRows = logFile.rows.filter((v, i) => rowProperties[i].isRendered); + return visibleRows.length; + } + + deleteSegmentAnnotations() { + this.setState({ collapsed: [] }); + this.props.clearSegmentation(); + } + + renderHeader(width: number) { + const style: React.CSSProperties = { + width, + height: "100%", + position: "absolute", + left: this.state.state ? this.state.state.scrollLeft * -1 : 0, + }; + const segmentWidth: number = (getSegmentMaxLevel(this.props.collapsibleRows) + 1) * 30 + BORDER_SIZE; + return ( +
+
+
+ {Object.keys(this.props.collapsibleRows).length > 0 && +
+ Delete all the segment annotations} + placement="bottom" + arrow + > + { this.deleteSegmentAnnotations() }} + > + + + +
} +
+ {this.props.logFile + .getSelectedHeader() + .map((h, i) => this.renderHeaderColumn(h.name, i, true, this.columnWidth(h.name)))} +
+
+ ); + } + + renderHeaderColumn(value: string, columnIndex: number, isHeader: boolean, width: number) { + const height = isHeader ? LOG_HEADER_HEIGHT : LOG_ROW_HEIGHT; + const columnHeaderStyle = getHeaderColumnStyle(width, columnIndex, height); + const columnHeaderInnerStyle = getHeaderColumnInnerStyle(height, isHeader); + + return ( + this.setColumnWidth(value, width!)} + > +
+
{value}
+
+
+ ); + } + + render() { + const selection = getSelection(); + + if (selection !== null) { + // empty unwanted text selection resulting from Shift-click + selection.empty(); + } + + const { logFile } = this.props; + const containerHeight = this.getVisibleRows() * LOG_ROW_HEIGHT; + const containerWidth = Object.keys(this.props.collapsibleRows).length > 0 ? + logFile.amountOfColumns() * BORDER_SIZE + + logFile.headers.reduce( + (partialSum: number, h) => partialSum + this.state.columnWidth[h.name], + 0, + ) + (getSegmentMaxLevel(this.props.collapsibleRows) + 1) * 30 + BORDER_SIZE : + logFile.amountOfColumns() * BORDER_SIZE + + logFile.headers.reduce( + (partialSum: number, h) => partialSum + this.state.columnWidth[h.name], + 0, + ); + return ( +
+ {this.renderHeader(containerWidth)} +
this.updateState()}> +
+ {this.renderRows()} +
+
+
+ ); + } +} diff --git a/src/viewer/log/SelectColDialog.tsx b/src/viewer/log/SelectColDialog.tsx new file mode 100644 index 0000000..c21baab --- /dev/null +++ b/src/viewer/log/SelectColDialog.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import LogFile from "../LogFile"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +interface Props { + logFile: LogFile; + onClose: (selectedCol: boolean[], selectedColMini: boolean[]) => void; +} + +interface State { + showDialog: boolean; + selectedCol: boolean[]; + selectedColMini: boolean[]; +} + +const BACKDROP_STYLE: React.CSSProperties = { + width: "100vw", + backgroundColor: "#00000030", + position: "absolute", + padding: "10px", + zIndex: 100 +}; + +const DIALOG_STYLE: React.CSSProperties = { + height: "90", + width: "70%", + padding: "10px", + display: "flex", + flexDirection: "column", + alignItems: "start", + overflow: "auto", +}; +const innerStyle: React.CSSProperties = { + display: "flex", + height: "20px", + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + paddingLeft: "2px", +}; +export default class SelectColDialog extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + showDialog: false, + selectedCol: this.props.logFile.selectedColumns, + selectedColMini: this.props.logFile.selectedColumnsMini, + }; + } + + handleCheckbox = (e, index) => { + const cols = [...this.state.selectedCol]; + if (e.target.checked) { + cols[index] = true; + this.setState({ selectedCol: cols }); + } else { + cols[index] = false; + this.setState({ selectedCol: cols }); + } + }; + + handleCheckboxMini = (e, index) => { + const cols = [...this.state.selectedColMini]; + if (e.target.checked) { + cols[index] = true; + this.setState({ selectedColMini: cols }); + } else { + cols[index] = false; + this.setState({ selectedColMini: cols }); + } + }; + + renderCheckbox(name: string, index: number) { + return ( +
+
+
{name}
+
+ this.handleCheckbox(e, index)} + key={index} + /> +
+
+ this.handleCheckboxMini(e, index)} + key={index} + /> +
+
+
+ ); + } + + onDialogClick(isClose: boolean) { + this.setState({ showDialog: false }, () => { + if (isClose === true) this.props.onClose(this.state.selectedCol, this.state.selectedColMini); + }); + } + + render() { + return ( +
+
+
+
+
+
Table
+
Minimap
+
+ this.onDialogClick(true)}> + + +
+
+
+ {this.props.logFile.headers.map((h, i) => this.renderCheckbox(h.name, i))} +
+
+ ); + } +} diff --git a/src/viewer/minimap/MinimapHeader.tsx b/src/viewer/minimap/MinimapHeader.tsx new file mode 100644 index 0000000..9294e90 --- /dev/null +++ b/src/viewer/minimap/MinimapHeader.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { MINIMAP_COLUMN_WIDTH } from "../constants"; +import LogFile from "../LogFile"; +interface Props { + logFile: LogFile; +} +export default class MinimapHeader extends React.Component { + constructor(props: Props) { + super(props); + } + + componentDidUpdate(prevProps: Readonly) { + if (prevProps.logFile !== this.props.logFile) { + this.render(); + } + } + + renderHeader(name: string, index: number) { + const style: React.CSSProperties = { + whiteSpace: "nowrap", + width: MINIMAP_COLUMN_WIDTH, + display: "inline-block", + }; + const innerStyle: React.CSSProperties = { + display: "flex", + height: "100%", + paddingLeft: "2px", + }; + return ( +
+
+ {name} +
+
+ ); + } + + render() { + return ( +
+ {this.props.logFile.getSelectedHeaderMini().map((h, i) => this.renderHeader(h.name, i))} +
+ ); + } +} diff --git a/src/viewer/minimap/MinimapView.tsx b/src/viewer/minimap/MinimapView.tsx new file mode 100644 index 0000000..fb28159 --- /dev/null +++ b/src/viewer/minimap/MinimapView.tsx @@ -0,0 +1,261 @@ +import React from "react"; +import LogFile from "../LogFile"; +import { LogViewState, RowProperty } from "../types"; +import { MINIMAP_COLUMN_WIDTH } from "../constants"; + +interface Props { + logFile: LogFile; + logViewState: LogViewState; + onLogViewStateChanged: (value: LogViewState) => void; + forwardRef: React.RefObject; + rowProperties: RowProperty[]; +} +interface State { + state: LogViewState | undefined; + scale: number; + controlDown: boolean; +} +const ROW_HEIGHT = 28; +export default class MinimapView extends React.Component { + canvas: React.RefObject; + + constructor(props: Props) { + super(props); + this.canvas = React.createRef(); + this.handleWheel = this.handleWheel.bind(this); + this.handleClick = this.handleClick.bind(this); + this.state = { scale: 1, controlDown: false, state: this.props.logViewState }; + } + + componentDidMount(): void { + window.addEventListener("resize", () => this.draw()); + window.addEventListener("keydown", (e) => this.controlDownListener(e)); + window.addEventListener("keyup", (e) => this.controlUpListener(e)); + this.draw(); + } + + componentDidUpdate(prevProps: Readonly, prevState: State): void { + if ( + prevProps.logViewState !== this.props.logViewState || + prevState.scale !== this.state.scale || + prevProps.logFile !== this.props.logFile + ) { + this.draw(); + } + } + + draw() { + // Clear and scale the canvas + const canvas = this.canvas.current; + if (!canvas || !this.props.logViewState) return; + canvas.height = canvas.clientHeight * window.devicePixelRatio; + canvas.width = canvas.clientWidth * window.devicePixelRatio; + const ctx = canvas.getContext("2d")!; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Hide Minimap if search did not return any rows + if (this.props.logFile.rows.length === 1 && this.props.logFile.rows[0][0] === "") return; + + // Compute start and end. + const { logViewState, logFile } = this.props; + const { + rowHeight: logRowHeight, + height, + visibleItems: logVisibleItems, + start: logStart, + scrollTop: logScrollTop, + } = logViewState; + const minVisibleItems = logVisibleItems; + const maxVisibleItems = logFile.amountOfRows(); + // If scale is 0.0 all log file entries are visible, if scale is 1.0 the minimap 1:1 matches the log view + const visibleItems = + (maxVisibleItems - minVisibleItems) * Math.abs(this.state.scale - 1) + minVisibleItems; + const scale = logVisibleItems / visibleItems; + + ctx.scale(1, scale); + ctx.translate(0, logScrollTop * -1); + + const minimapEnd = logScrollTop + height / scale; + const minimapCenter = logScrollTop + (minimapEnd - logScrollTop) / 2; + + const logEnd = (logStart + logVisibleItems) * logRowHeight; + const logCenter = logScrollTop + (logEnd - logScrollTop) / 2; + + // Try to center the logview port in the center of the minimap. + // This is only possible when the user scrolled enough from top. + // This code also makes sure that when scrolled to the end of the log file and zoomed out, the minimap stops at the bottom of the screen. + const logMinimapCenterDiff = minimapCenter - logCenter; + const scrollTop = Math.min(logMinimapCenterDiff, logScrollTop); + const scrollBottom = + minVisibleItems === maxVisibleItems + ? 0 + : Math.min(logFile.amountOfRows() * logRowHeight + scrollTop - minimapEnd, 0); + ctx.translate(0, scrollTop - scrollBottom); + + // Draw blocks + let index = 0; //for caculating the position + for (let columnIndex = 0; columnIndex < logFile.selectedColumnsMini.length; columnIndex++) { + if (logFile.selectedColumnsMini[columnIndex]) { + const colors = logFile.columnsColors[columnIndex]; + let counter = 0; //increase only when row is rendered + for (let i = 0; i < colors.length; i++) { + if ( + this.props.rowProperties[i].isRendered + ) { + ctx.beginPath(); + ctx.fillStyle = colors[i]; + ctx.fillRect( + index * MINIMAP_COLUMN_WIDTH, + counter * logRowHeight, + MINIMAP_COLUMN_WIDTH, + logRowHeight, + ); + ctx.stroke(); + counter++; + } + } + index++; + } + } + + // Draw the log viewport on top of the minimap (grey block) + ctx.beginPath(); + ctx.fillStyle = "#d3d3d380"; + ctx.fillRect(0, logScrollTop, canvas.width, logEnd - logScrollTop); + ctx.stroke(); + } + + handleClick(e: React.MouseEvent) { + const canvas = this.canvas?.current; + if (!canvas) return; + const bounding = canvas.getBoundingClientRect(); + let y = e.clientY; + if (bounding != undefined) { + y = e.clientY - bounding.top; + } + const { logViewState, logFile } = this.props; + const { + rowHeight: logRowHeight, + visibleItems: logVisibleItems, + height, + start: logStart, + scrollTop: logScrollTop, + } = logViewState; + const minVisibleItems = logVisibleItems; + const maxVisibleItems = logFile.amountOfRows(); + const visibleItems = + (maxVisibleItems - minVisibleItems) * Math.abs(this.state.scale - 1) + minVisibleItems; + const scaleItem = logVisibleItems / visibleItems; + + const minimapEnd = logScrollTop + height / scaleItem; + const minimapCenter = logScrollTop + (minimapEnd - logScrollTop) / 2; + const logEnd = (logStart + logVisibleItems) * logRowHeight; + const logCenter = logScrollTop + (logEnd - logScrollTop) / 2; + + const logMinimapCenterDiff = minimapCenter - logCenter; + const scrollTopBox = Math.min(logMinimapCenterDiff, logScrollTop); + const scrollBottom = + minVisibleItems === maxVisibleItems + ? 0 + : Math.min(logFile.amountOfRows() * logRowHeight + scrollTopBox - minimapEnd, 0); + let nrOfRows = 0; + let scrollTop = 0; + if (scrollBottom < 0) { + //scrollBottom becomes smaller than 0, when the log view scrolls to the last part of the log. + nrOfRows = ((scrollTopBox - scrollBottom) * scaleItem - y) / (height / visibleItems); //number of rows to move, can be positive or negative + scrollTop = (logStart - nrOfRows) * ROW_HEIGHT; + } else { + nrOfRows = (y - scrollTopBox * scaleItem) / (height / visibleItems); //number of rows to move, can be positive or negative + scrollTop = (logStart + nrOfRows) * ROW_HEIGHT; + } + //when grey box meet the bottom of the log, the scroll top will not increase. + const maximumScrollTop = (maxVisibleItems - logVisibleItems) * ROW_HEIGHT; + scrollTop = Math.min(maximumScrollTop, scrollTop); + if (!this.props.forwardRef.current) return; + const scrollLeft = this.props.forwardRef.current.scrollLeft; + const start = scrollTop / ROW_HEIGHT; + const startFloor = Math.floor(start); + const endCeil = Math.min( + Math.ceil(start + maxVisibleItems) - 1, + this.props.logFile.amountOfRows() - 1, + ); + this.props.forwardRef.current.scrollTop = scrollTop; + const state = { + height, + scrollLeft, + scrollTop, + startFloor, + start, + endCeil, + visibleItems: logVisibleItems, + rowHeight: ROW_HEIGHT, + }; + const scale = this.state.scale; + this.setState({ state, scale }); + this.draw(); + this.props.onLogViewStateChanged(state); + } + + handleWheel(e: React.WheelEvent) { + if (this.state.controlDown === true) { + const offset = Math.abs(1.02 - this.state.scale) / 5; + let scale = this.state.scale + (e.deltaY < 0 ? offset : offset * -1); + scale = Math.max(Math.min(1, scale), 0); + this.setState({ scale }); + } else { + const scale = e.deltaY; + this.updateState(scale); + } + } + + updateState(scale: number) { + if (!this.props.forwardRef.current) return; + const height = this.props.forwardRef.current.clientHeight; + this.props.forwardRef.current.scrollTop = this.props.forwardRef.current.scrollTop + scale; + const scrollTop = this.props.forwardRef.current.scrollTop; + const scrollLeft = this.props.forwardRef.current.scrollLeft; + const maxVisibleItems = height / ROW_HEIGHT; + const start = scrollTop / ROW_HEIGHT; + const startFloor = Math.floor(start); + const endCeil = Math.min( + Math.ceil(start + maxVisibleItems) - 1, + this.props.logFile.amountOfRows() - 1, + ); + const visibleItems = Math.min(this.props.logFile.amountOfRows(), maxVisibleItems); + const state = { + height, + scrollLeft, + scrollTop, + startFloor, + start, + endCeil, + visibleItems, + rowHeight: ROW_HEIGHT, + }; + this.setState({ state }); + this.props.onLogViewStateChanged(state); + } + + controlDownListener(e: any) { + if (e.key === "Control" && this.state.controlDown === false) + this.setState({ controlDown: true }); + } + + controlUpListener(e: any) { + if (e.key === "Control" && this.state.controlDown) this.setState({ controlDown: false }); + } + + render() { + return ( +
+ +
+ ); + } +} diff --git a/src/viewer/rules/Dialogs/ExportDialog.tsx b/src/viewer/rules/Dialogs/ExportDialog.tsx new file mode 100644 index 0000000..0349695 --- /dev/null +++ b/src/viewer/rules/Dialogs/ExportDialog.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; + +interface Props { + filepath: string; + onClose: () => void; +} + +interface State { +} + +const BACKDROP_STYLE: React.CSSProperties = { + height: "100vh", + width: "100vw", + backgroundColor: "#00000030", + position: "absolute", + display: "flex", + justifyContent: "center", + alignItems: "center", +}; + +const DIALOG_STYLE: React.CSSProperties = { + height: "100px", + width: "600px", + padding: "5px", + display: "flex", + overflow: "auto", + zIndex: 100 +}; + +export default class FlagsDialog extends React.Component { + constructor(props: Props) { + super(props); + } + + + render() { + return ( +
+
+
+
+

Data has been successfully exported to filepath:

+

{this.props.filepath}

+
+ + this.props.onClose()} + > + Ok + +
+
+
+ ); + } +} diff --git a/src/viewer/rules/Dialogs/FlagsDialog.tsx b/src/viewer/rules/Dialogs/FlagsDialog.tsx new file mode 100644 index 0000000..093722b --- /dev/null +++ b/src/viewer/rules/Dialogs/FlagsDialog.tsx @@ -0,0 +1,292 @@ +import React from "react"; +import Rule from "../Rule"; +import LogFile from "../../LogFile"; +import FlagRule from "../FlagRule"; +import StateBasedRule from "../StateBasedRule"; +import Table from "../Tables/Table"; +import { + VSCodeButton, + VSCodeTextField, + VSCodeDropdown, + VSCodeOption, +} from "@vscode/webview-ui-toolkit/react"; + +interface Props { + onReturn: (rules: Rule[]) => void; + onClose: (rules: Rule[]) => void; + initialRules: Rule[]; + logFile: LogFile; +} + +interface State { + showEdit: boolean; + rules: Rule[]; + selectedRule: number; +} + +const BACKDROP_STYLE: React.CSSProperties = { + height: "100vh", + width: "100vw", + backgroundColor: "#00000030", + position: "absolute", + display: "flex", + justifyContent: "center", + alignItems: "center", +}; + +const DIALOG_STYLE: React.CSSProperties = { + height: "95%", + width: "95%", + padding: "10px", + display: "flex", + flexDirection: "column", + overflow: "auto", + zIndex: 100 +}; + +export default class FlagsDialog extends React.Component { + constructor(props: Props) { + super(props); + this.state = { showEdit: false, selectedRule: -1, rules: props.initialRules }; + } + + updateRule(rule: Rule, index: number) { + const rules = [...this.state.rules]; + if (rules[index].column != rule.column) { + for (let i = 0; i < rules.length; i++) { + if (rules[i].ruleType === "Flag rule") { + const updateRule = rules[i] as FlagRule; + const updatedFlags = updateRule.flags; + for (let j = 0; j < updatedFlags.length; j++) + for (let k = 0; k < updatedFlags[j].conditions.length; k++) + for (let l = 0; l < updatedFlags[j].conditions[k].length; l++) + if (updatedFlags[j].conditions[k][l].Column === rules[index].column) + updatedFlags[j].conditions[k][l].Column = rule.column; + updateRule.setFlags(updatedFlags); + rules[i] = updateRule; + } else if (rules[i].ruleType === "State based rule") { + const updatedRule = rules[i] as StateBasedRule; + const updatedStates = updatedRule.ruleStates; + for (let j = 0; j < updatedStates.length; j++) { + for (let k = 0; k < updatedStates[j].transitions.length; k++) { + for (let l = 0; l < updatedStates[j].transitions[k].conditions.length; l++) { + for (let m = 0; m < updatedStates[j].transitions[k].conditions[l].length; m++) { + if ( + updatedStates[j].transitions[k].conditions[l][m].Column === rules[index].column + ) + updatedStates[j].transitions[k].conditions[l][m].Column = rule.column; + } + } + } + } + updatedRule.setStates(updatedStates, updatedRule.initialStateIndex); + rules[i] = updatedRule; + } + } + } + rules[index] = rule; + this.setState({ rules }); + } + + updateFlagProperty(rule: Rule, property: string, new_value: string, index: number) { + const rules = [...this.state.rules]; + const flagRule = rule as FlagRule; + if (property === "defaultValue") rules[index] = flagRule.setDefault(new_value); + else if (property === "flagType") rules[index] = flagRule.setFlagType(new_value); + this.setState({ rules }); + } + + onDialogClick(isClose: boolean) { + const ruleIndex = this.state.selectedRule; + if (ruleIndex !== -1) { + const rule = FlagRule.cleanConditions(this.state.rules[ruleIndex].reset()); + this.updateRule(rule, ruleIndex); + } + this.setState({ selectedRule: -1, showEdit: false }, () => { + if (isClose === true) this.props.onClose(this.state.rules); + else this.props.onReturn(this.state.rules); + }); + } + + renderManage() { + const onAddAction = () => { + const newRule = new FlagRule( + `FlagRule${this.state.rules.filter((r) => r.ruleType === "Flag rule").length + 1}`, + "", + "", + "User Defined", + 0, + [], + ); + this.setState({ + rules: [...this.state.rules, newRule], + selectedRule: this.state.rules.length, + showEdit: true, + }); + }; + + const onEditAction = (tableIndex: number) => { + const index = this.state.rules.findIndex( + (x) => x === this.state.rules.filter((r) => r.ruleType === "Flag rule")[tableIndex], + ); + this.setState({ showEdit: true, selectedRule: index }); + }; + + const onDeleteAction = (tableIndex: number) => { + const index = this.state.rules.findIndex( + (x) => x === this.state.rules.filter((r) => r.ruleType === "Flag rule")[tableIndex], + ); + if (this.state.selectedRule === index) this.setState({ selectedRule: -1 }); + this.setState({ rules: this.state.rules.filter((r, i) => i !== index) }); + }; + + const tableRows = this.state.rules + .filter((r) => r.ruleType === "Flag rule") + .map((rule) => { + const flagRule = rule as FlagRule; + return [rule.column, flagRule.flagType, rule.description]; + }); + + return ( +
+ + + ); + } + + renderEdit() { + if (this.state.selectedRule === -1) return; + const ruleIndex = this.state.selectedRule; + const rule = this.state.rules[ruleIndex]; + const ruleAsFlag = rule as FlagRule; + const defaultValue = ruleAsFlag.defaultValue; + const flagType = ruleAsFlag.flagType; + const userColumns = this.state.rules + .map((r, i) => r.column) + .filter((name) => name != rule.column); + const keyWidth = "100px"; + const textFieldWidth = "250px"; + const rows = [ + [ + "Name", + + this.updateRule(rule.setColumn(e.target.value), ruleIndex) + } + />, + ], + [ + "Description", + this.updateRule(rule.setDescription(e.target.value), ruleIndex)} + />, + ], + [ + "Type", + 0} + style={{ width: textFieldWidth, marginBottom: "2px" }} + value={flagType} + key="Type" + onChange={(e) => this.updateFlagProperty(rule, "flagType", e.target.value, ruleIndex)} + > + + User Defined + + + Capture Match + + , + ], + [ + "Default Value", + this.updateFlagProperty(rule, "defaultValue", e.target.value, ruleIndex)} + />, + ], + ]; + + return ( +
+
+ {rule.renderEdit( + (newRule) => this.updateRule(newRule, ruleIndex), + keyWidth, + textFieldWidth, + userColumns, + this.props.logFile, + this.state.rules + )} + + ); + } + + render() { + return ( +
+
+
+ {!this.state.showEdit &&
Flag Annotation Columns
} + {this.state.showEdit &&
Edit Flag Annotation Column
} + {this.state.showEdit && ( + this.onDialogClick(false)} + > + + + + )} + this.onDialogClick(true)} + > + + +
+
+ {!this.state.showEdit && this.renderManage()} + {this.state.showEdit && this.renderEdit()} +
+
+
+ ); + } +} diff --git a/src/viewer/rules/Dialogs/StatesDialog.tsx b/src/viewer/rules/Dialogs/StatesDialog.tsx new file mode 100644 index 0000000..8b485b6 --- /dev/null +++ b/src/viewer/rules/Dialogs/StatesDialog.tsx @@ -0,0 +1,248 @@ +import React from "react"; +import Rule from "../Rule"; +import LogFile from "../../LogFile"; +import FlagRule from "../FlagRule"; +import StateBasedRule from "../StateBasedRule"; +import Table from "../Tables/Table"; +import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"; + +interface Props { + onReturn: (rules: Rule[]) => void; + onClose: (rules: Rule[]) => void; + initialRules: Rule[]; + logFile: LogFile; +} + +interface State { + showEdit: boolean; + rules: Rule[]; + selectedRule: number; +} + +const BACKDROP_STYLE: React.CSSProperties = { + height: "100vh", + width: "100vw", + backgroundColor: "#00000030", + position: "absolute", + display: "flex", + justifyContent: "center", + alignItems: "center", +}; + +const DIALOG_STYLE: React.CSSProperties = { + height: "95%", + width: "95%", + padding: "10px", + display: "flex", + flexDirection: "column", + overflow: "auto", + zIndex: 100 +}; + +export default class StatesDialog extends React.Component { + constructor(props: Props) { + super(props); + this.state = { showEdit: false, selectedRule: -1, rules: props.initialRules }; + } + + updateRule(rule: Rule, index: number) { + const rules = [...this.state.rules]; + if (rules[index].column != rule.column) { + for (let i = 0; i < rules.length; i++) { + if (rules[i].ruleType === "Flag rule") { + const updatedRule = rules[i] as FlagRule; + const updatedFlags = updatedRule.flags; + for (let j = 0; j < updatedFlags.length; j++) + for (let k = 0; k < updatedFlags[j].conditions.length; k++) + for (let l = 0; l < updatedFlags[j].conditions[k].length; l++) + if (updatedFlags[j].conditions[k][l].Column === rules[index].column) + updatedFlags[j].conditions[k][l].Column = rule.column; + updatedRule.setFlags(updatedFlags); + rules[i] = updatedRule; + } else if (rules[i].ruleType === "State based rule") { + const updatedRule = rules[i] as StateBasedRule; + const updatedStates = updatedRule.ruleStates; + for (let j = 0; j < updatedStates.length; j++) { + for (let k = 0; k < updatedStates[j].transitions.length; k++) { + for (let l = 0; l < updatedStates[j].transitions[k].conditions.length; l++) { + for (let m = 0; m < updatedStates[j].transitions[k].conditions[l].length; m++) { + if ( + updatedStates[j].transitions[k].conditions[l][m].Column === rules[index].column + ) + updatedStates[j].transitions[k].conditions[l][m].Column = rule.column; + } + } + } + } + updatedRule.setStates(updatedStates, updatedRule.initialStateIndex); + rules[i] = updatedRule; + } + } + } + rules[index] = rule; + this.setState({ rules }); + } + + onDialogClick(isClose: boolean) { + const ruleIndex = this.state.selectedRule; + if (ruleIndex !== -1) { + const rule = StateBasedRule.cleanConditions(this.state.rules[ruleIndex].reset()); + this.updateRule(rule, ruleIndex); + } + this.setState({ selectedRule: -1, showEdit: false }, () => { + if (isClose === true) this.props.onClose(this.state.rules); + else this.props.onReturn(this.state.rules); + }); + } + + renderManage() { + const onAddAction = () => { + const newRule = new StateBasedRule( + `StateRule${this.state.rules.filter((r) => r.ruleType === "State based rule").length + 1}`, + "", + 0, + 0, + 0, + [], + ); + this.setState({ + rules: [...this.state.rules, newRule], + selectedRule: this.state.rules.length, + showEdit: true, + }); + }; + + const onEditAction = (tableIndex: number) => { + const index = this.state.rules.findIndex( + (x) => x === this.state.rules.filter((r) => r.ruleType === "State based rule")[tableIndex], + ); + this.setState({ showEdit: true, selectedRule: index }); + }; + + const onDeleteAction = (tableIndex: number) => { + const index = this.state.rules.findIndex( + (x) => x === this.state.rules.filter((r) => r.ruleType === "State based rule")[tableIndex], + ); + if (this.state.selectedRule === index) this.setState({ selectedRule: -1 }); + this.setState({ rules: this.state.rules.filter((r, i) => i !== index) }); + }; + + return ( +
+
r.ruleType === "State based rule") + .map((rule) => [rule.column, rule.ruleType, rule.description])} + noRowsText={"No rules have been defined (click + to add)"} + onAddAction={onAddAction} + onEditAction={onEditAction} + onDeleteAction={onDeleteAction} + /> + + ); + } + + renderEdit() { + if (this.state.selectedRule === -1) return; + const ruleIndex = this.state.selectedRule; + const rule = this.state.rules[ruleIndex]; + const userColumns = this.state.rules + .map((r, i) => r.column) + .filter((name) => name != rule.column); + const keyWidth = "100px"; + const textFieldWidth = "250px"; + const rows = [ + [ + "Name", + + this.updateRule(rule.setColumn(e.target.value), ruleIndex) + } + />, + ], + [ + "Description", + this.updateRule(rule.setDescription(e.target.value), ruleIndex)} + />, + ], + ]; + + return ( +
+
+ {rule.renderEdit( + (newRule) => this.updateRule(newRule, ruleIndex), + keyWidth, + textFieldWidth, + userColumns, + this.props.logFile, + this.state.rules + )} + + ); + } + + render() { + return ( +
+
+
+ {!this.state.showEdit && ( +
State-Based Annotation Columns
+ )} + {this.state.showEdit && ( +
Edit State-Based Annotation Column
+ )} + {this.state.showEdit && ( + this.onDialogClick(false)} + > + + + )} + this.onDialogClick(true)} + > + + +
+
+ {!this.state.showEdit && this.renderManage()} + {this.state.showEdit && this.renderEdit()} +
+
+
+ ); + } +} diff --git a/src/viewer/structures/StructureDialog.tsx b/src/viewer/structures/StructureDialog.tsx new file mode 100644 index 0000000..6027715 --- /dev/null +++ b/src/viewer/structures/StructureDialog.tsx @@ -0,0 +1,772 @@ +import React from "react"; +import Tooltip, { TooltipProps, tooltipClasses } from "@mui/material/Tooltip"; +import CloseIcon from "@mui/icons-material/Close"; +import IconButton from "@mui/material/IconButton"; +import StructureTable from "./StructureTable"; +import { ContextMenuItem, Header, LogEntryCharMaps, Segment, StructureDefinition, StructureEntry, Wildcard } from "../types"; +import { StructureHeaderColumnType, StructureLinkDistance } from "../constants"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +import { useStructureQueryConstructor, useStructureRegularExpressionNestedSearch } from "../hooks/useStructureRegularExpressionManager"; +import { + constructStructureEntriesArray, + appendNewStructureEntries, + removeStructureEntryFromList, + toggleCellSelection, + toggleStructureLink, + removeLastStructureLink, + addWildcardToStructureEntry, + removeWildcardFromStructureEntry, + updateStructureEntriesAfterWildcardDeletion, +} from "../hooks/useStructureEntryManager"; +import { + createWildcard, + getIndicesForWildcardFromDivId, + insertWildcardIntoCellsContents, + removeWildcardSubstitution, + removeWildcardSubstitutionsForStructureEntry, + getWildcardIndex, + removeWildcardFromCellContent, +} from "../hooks/useWildcardManager"; +import { structureDialogBackdropStyle, structureDialogDialogStyle } from "../hooks/useStyleManager"; +import isEqual from "react-fast-compare"; +import cloneDeep from "lodash/cloneDeep"; +import ContextMenu from "../contextMenu/contextMenu"; +import { styled } from "@mui/material/styles"; +import { constructNewSegment, maximumSegmentation } from "../hooks/useRowProperty"; +import { StructureSettingsDropdown } from "./StructureSettingsDropdown"; + +interface Props { + logFileAsString: string; + logEntryCharIndexMaps: LogEntryCharMaps | null; + logHeaderColumns: Header[]; + logHeaderColumnsTypes: StructureHeaderColumnType[]; + logSelectedRows: string[][]; + loadedStructureDefinition: StructureDefinition | null; + currentStructureMatchIndex: number | null; + numberOfMatches: number; + collapsibleRows: { [key: number]: Segment }; + onClose: () => void; + onStructureUpdate: () => void; + onNavigateStructureMatches: (isGoingForward: boolean) => void; + onMatchStructure: (expression: string) => void; + onStructureDefinitionSave: (structureDefinition: string) => void; + onStructureDefinitionLoad: () => void; + onExportStructureMatches: () => void; + onDefineSegment: (expression: { [key: number]: Segment }) => void; +} + +interface State { + wildcards: Wildcard[]; + structureEntries: StructureEntry[]; + isRemovingStructureEntries: boolean; + isLoadingStructureDefintion: boolean; + isStructureMatching: boolean; + structureHeaderColumns: Header[]; + structureHeaderColumnsTypes: StructureHeaderColumnType[]; +} + +export default class StructureDialog extends React.Component { + constructor(props: Props) { + super(props); + const { logHeaderColumns, logHeaderColumnsTypes, logSelectedRows } = this.props; + let structureEntries = constructStructureEntriesArray(logHeaderColumnsTypes, logSelectedRows); + structureEntries = removeLastStructureLink(structureEntries); + + this.state = { + isRemovingStructureEntries: false, + isLoadingStructureDefintion: false, + isStructureMatching: this.props.numberOfMatches > 0 ? true : false, + structureHeaderColumns: logHeaderColumns, + structureHeaderColumnsTypes: logHeaderColumnsTypes, + structureEntries: structureEntries, + wildcards: [], + }; + + //bind context for all functions used by the context and dropdown menus: + this.createWildcard = this.createWildcard.bind(this); + this.saveStructureDefinition = this.saveStructureDefinition.bind(this); + this.loadStructureDefinition = this.loadStructureDefinition.bind(this); + } + + componentDidMount(): void { + // trigger manually, as update function isn't called for initial render. + // removing the trigger to keep persistence + //this.props.onStructureUpdate(); + } + + shouldComponentUpdate( + nextProps: Readonly, + nextState: Readonly, + _nextContext: any, + ): boolean { + const isLoadingStructureDefinition = nextProps.loadedStructureDefinition !== null; + const arelogHeaderColumnsUpdating = !isEqual( + this.props.logHeaderColumns, + nextProps.logHeaderColumns, + ); + const arelogHeaderColumnTypesUpdating = !isEqual( + this.props.logHeaderColumnsTypes, + nextProps.logHeaderColumnsTypes, + ); + const arelogSelectedRowsUpdating = !isEqual( + this.props.logSelectedRows, + nextProps.logSelectedRows, + ); + const isCurrentMatchIndexUpdating = !isEqual( + this.props.currentStructureMatchIndex, + nextProps.currentStructureMatchIndex, + ); + const isNumberOfMatchesUpdating = !isEqual( + this.props.numberOfMatches, + nextProps.numberOfMatches, + ); + + const areHeaderColumnTypesUpdating = !isEqual( + this.state.structureHeaderColumnsTypes, + nextState.structureHeaderColumnsTypes, + ); + const areStateEntriesUpdating = !isEqual( + this.state.structureEntries, + nextState.structureEntries, + ); + const areWildcardsUpdating = !isEqual(this.state.wildcards, nextState.wildcards); + const isRemovingStructureEntriesUpdating = !isEqual( + this.state.isRemovingStructureEntries, + nextState.isRemovingStructureEntries, + ); + const isStructureMatchingUpdating = !isEqual( + this.state.isStructureMatching, + nextState.isStructureMatching, + ); + + if ( + isLoadingStructureDefinition || + arelogHeaderColumnsUpdating || + arelogHeaderColumnTypesUpdating || + arelogSelectedRowsUpdating || + isCurrentMatchIndexUpdating || + isNumberOfMatchesUpdating || + areHeaderColumnTypesUpdating || + areStateEntriesUpdating || + areWildcardsUpdating || + isRemovingStructureEntriesUpdating || + isStructureMatchingUpdating + ) { + return true; + } + + return false; + } + + componentDidUpdate(prevProps: Readonly, _prevState: Readonly): void { + const { loadedStructureDefinition, logSelectedRows } = this.props; + + if (this.state.isLoadingStructureDefintion && loadedStructureDefinition !== null) { + this.setState({ + isLoadingStructureDefintion: false, + isStructureMatching: false, + structureHeaderColumns: loadedStructureDefinition.headerColumns, + structureHeaderColumnsTypes: loadedStructureDefinition.headerColumnsTypes, + structureEntries: loadedStructureDefinition.entries, + wildcards: loadedStructureDefinition.wildcards, + }); + + this.props.onStructureUpdate(); + } else if (logSelectedRows !== prevProps.logSelectedRows) { + this.updateStructure(); + } + } + + updateStructure() { + const { structureHeaderColumnsTypes, structureEntries } = this.state; + const structureEntriesCopy = cloneDeep(structureEntries); + const newSelectedRows = this.props.logSelectedRows.filter( + (entry) => !structureEntriesCopy.some((value) => isEqual(value.row, entry)), + ); + + if (newSelectedRows.length !== 0) { + const newStructureEntries = constructStructureEntriesArray( + structureHeaderColumnsTypes, + newSelectedRows, + ); + const finalStructureEntries = appendNewStructureEntries( + structureEntriesCopy, + newStructureEntries, + ); + this.setState({ + structureEntries: finalStructureEntries, + isStructureMatching: false, + }); + } + + this.props.onStructureUpdate(); + } + + getContextMenuItems() { + const contextMenuItems: ContextMenuItem[] = []; + + const createWildcardItem: ContextMenuItem = { + text: "Create wildcard", + callback: () => this.createWildcard(), + }; + + contextMenuItems.push(createWildcardItem); + + if (this.state.wildcards.length > 0) { + this.state.wildcards.forEach((wc, index) => { + const useWildcardItem: ContextMenuItem = { + text: `Use wildcard ?${index + 1}`, + callback: () => this.useWildcard(index), + }; + + contextMenuItems.push(useWildcardItem); + }); + + const removeWildcardItem: ContextMenuItem = { + text: "Remove wildcard", + callback: (anchorDivId) => this.removeWildcard(anchorDivId), + }; + + contextMenuItems.push(removeWildcardItem); + } + + return contextMenuItems; + } + + removeStructureEntry(rowIndex: number) { + const { structureEntries, wildcards } = this.state; + const wildcardsCopy = cloneDeep(wildcards); + + const wildcardRemovalResults = removeWildcardSubstitutionsForStructureEntry( + wildcardsCopy, + rowIndex, + ); + const modifiedWildcards = wildcardRemovalResults.modifiedWildcards; + + const remainingEntries = removeStructureEntryFromList(structureEntries, rowIndex); + + wildcardRemovalResults.indicesOfWildcardsToBeRemoved.forEach((index) => { + updateStructureEntriesAfterWildcardDeletion(remainingEntries, modifiedWildcards, index); + }); + + if (remainingEntries.length === 0) { + this.props.onClose(); + } else { + this.props.onStructureUpdate(); + this.setState({ + structureEntries: remainingEntries, + wildcards: modifiedWildcards, + isStructureMatching: false, + }); + } + } + + toggleIsRemovingStructureEntries() { + const isRemovingStructureEntries = this.state.isRemovingStructureEntries; + this.setState({ + isRemovingStructureEntries: !isRemovingStructureEntries, + }); + } + + toggleIsCellSelected( + structureEntryIndex: number, + cellIndex: number, + isCtrlPressed: boolean, + isShiftPressed: boolean, + ) { + if (isCtrlPressed) { + const { structureHeaderColumnsTypes, structureEntries } = this.state; + let structureEntriesCopy = cloneDeep(structureEntries); + + structureEntriesCopy = toggleCellSelection( + structureHeaderColumnsTypes, + structureEntriesCopy, + structureEntryIndex, + cellIndex, + isShiftPressed, + ); + + this.setState({ structureEntries: structureEntriesCopy }); + } + } + + toggleStructureLink(structureEntryIndex: number) { + let { structureEntries } = this.state; + const structureEntriesCopy = cloneDeep(structureEntries); + + structureEntries = toggleStructureLink(structureEntriesCopy, structureEntryIndex); + + this.setState({ structureEntries: structureEntries }); + } + + matchStructure() { + // pass list of wildcards and use those in regular expression construction + const structureRegExp = useStructureQueryConstructor( + this.state.structureHeaderColumns, + this.state.structureHeaderColumnsTypes, + this.state.structureEntries, + this.state.wildcards, + ); + + this.props.onMatchStructure(structureRegExp); + this.setState({ isStructureMatching: true }); + } + + defineSegment() { + let collapsibleRows = this.props.collapsibleRows; + + const minSegmentRegExp = useStructureQueryConstructor( + this.state.structureHeaderColumns, + this.state.structureHeaderColumnsTypes, + this.state.structureEntries, + this.state.wildcards, + ); + + let maxSegmentEntries = this.state.structureEntries; + maxSegmentEntries[maxSegmentEntries.length-2].structureLink = StructureLinkDistance.Max; + + const maxSegmentRegExp = useStructureQueryConstructor( + this.state.structureHeaderColumns, + this.state.structureHeaderColumnsTypes, + maxSegmentEntries, + this.state.wildcards, + ); + + const segmentMatches = useStructureRegularExpressionNestedSearch( + minSegmentRegExp, + maxSegmentRegExp, + this.props.logFileAsString, + this.props.logEntryCharIndexMaps!, + ); + + let entryMatches: number[] = []; + let exitMatches: number[] = []; + segmentMatches.forEach((match) => { + entryMatches.push(match[0]) + exitMatches.push(match[match.length - 1]) + }); + entryMatches = [...new Set(entryMatches)].sort(function (a, b) { return a - b; }); + exitMatches = [...new Set(exitMatches)].sort(function (a, b) { return a - b; }); + + const stack: number[] = []; + let nextEntry = entryMatches.shift()!; + let nextExit = exitMatches.shift()!; + + while (nextEntry !== undefined && nextExit !== undefined) { + if (nextEntry < nextExit) { + stack.push(nextEntry); + nextEntry = entryMatches.shift()!; + } else { + const entry = stack.pop()!; + if (stack.length < maximumSegmentation) + collapsibleRows[entry] = constructNewSegment(entry, nextExit, stack.length); + // else console.log(`Maximum segment level reached: Discarding (${entry}, ${nextExit})`); + nextExit = exitMatches.shift()!; + } + } + while (nextExit !== undefined) { + const entry = stack.pop()!; + if (stack.length < maximumSegmentation) + collapsibleRows[entry] = constructNewSegment(entry, nextExit, stack.length); + nextExit = exitMatches.shift()!; + } + + this.props.onDefineSegment(collapsibleRows); + } + + createWildcard() { + const selection = getSelection(); + const range = selection!.getRangeAt(0); + const startNode = range.startContainer; + const endNode = range.endContainer; + const startOffset = range.startOffset; + const endOffset = range.endOffset; + const parentDivId = (startNode.parentNode as Element).id; + + if (startNode.textContent === endNode.textContent && startOffset !== endOffset) { + const { structureEntries, wildcards } = this.state; + const structureEntriesCopy: StructureEntry[] = cloneDeep(structureEntries); + let wildcardsCopy: Wildcard[] = cloneDeep(wildcards); + + const indicesForWildcard = getIndicesForWildcardFromDivId(parentDivId); + + const entryIndex = +indicesForWildcard[1]; + const cellIndex = +indicesForWildcard[2]; + const contentsIndex = +indicesForWildcard[3]; + + const newWildcard = createWildcard(entryIndex, cellIndex, contentsIndex); + + wildcardsCopy.push(newWildcard); + + const wildcardIndex = wildcardsCopy.length - 1; + const modifiedStructureEntries = addWildcardToStructureEntry( + structureEntriesCopy, + entryIndex, + cellIndex, + wildcardIndex, + ); + + const insertionResults = insertWildcardIntoCellsContents( + structureEntriesCopy[entryIndex].row[cellIndex], + wildcardsCopy, + entryIndex, + cellIndex, + wildcardIndex, + contentsIndex, + startOffset, + endOffset, + ); + structureEntriesCopy[entryIndex].row[cellIndex] = insertionResults.cellContents; + wildcardsCopy = insertionResults.wildcards; + + wildcardsCopy[wildcardIndex].wildcardSubstitutions[0].contentsIndex = + insertionResults.insertedWildcardContentsIndex; + + this.setState({ + structureEntries: modifiedStructureEntries, + wildcards: wildcardsCopy, + }); + } + } + + useWildcard(wildcardIndex: number) { + const selection = getSelection(); + const range = selection!.getRangeAt(0); + const startNode = range.startContainer; + const endNode = range.endContainer; + const startOffset = range.startOffset; + const endOffset = range.endOffset; + const parentDivId = (startNode.parentNode as Element).id; + + if (startNode.textContent === endNode.textContent && startOffset !== endOffset) { + const { structureEntries, wildcards } = this.state; + const structureEntriesCopy: StructureEntry[] = cloneDeep(structureEntries); + let wildcardsCopy: Wildcard[] = cloneDeep(wildcards); + + const indicesForWildcard = getIndicesForWildcardFromDivId(parentDivId); + + const entryIndex = +indicesForWildcard[1]; + const cellIndex = +indicesForWildcard[2]; + const contentsIndex = +indicesForWildcard[3]; + + const modifiedStructureEntries = addWildcardToStructureEntry( + structureEntriesCopy, + entryIndex, + cellIndex, + wildcardIndex, + ); + + const insertionResults = insertWildcardIntoCellsContents( + structureEntriesCopy[entryIndex].row[cellIndex], + wildcardsCopy, + entryIndex, + cellIndex, + wildcardIndex, + contentsIndex, + startOffset, + endOffset, + ); + structureEntriesCopy[entryIndex].row[cellIndex] = insertionResults.cellContents; + wildcardsCopy = insertionResults.wildcards; + + const newWildcardSubstitution = { + entryIndex: entryIndex, + cellIndex: cellIndex, + contentsIndex: insertionResults.insertedWildcardContentsIndex, + }; + wildcardsCopy[wildcardIndex].wildcardSubstitutions.push(newWildcardSubstitution); + + this.setState({ + structureEntries: modifiedStructureEntries, + wildcards: wildcardsCopy, + }); + } + } + + removeWildcard(anchorDivId: string) { + const isAnchorDivWildcard = anchorDivId[0] === "w"; + + if (isAnchorDivWildcard) { + const indicesForWildcard = anchorDivId.split("-"); + const entryIndex = +indicesForWildcard[1]; + const cellIndex = +indicesForWildcard[2]; + const contentsIndex = +indicesForWildcard[3]; + + const { structureEntries, wildcards } = this.state; + const structureEntriesCopy: StructureEntry[] = cloneDeep(structureEntries); + let wildcardsCopy: Wildcard[] = cloneDeep(wildcards); + + const wildcardIndex = getWildcardIndex(wildcardsCopy, entryIndex, cellIndex, contentsIndex); + + const wildcardsUpdateResult = removeWildcardSubstitution( + wildcardsCopy, + wildcardIndex, + entryIndex, + cellIndex, + contentsIndex, + ); + wildcardsCopy = wildcardsUpdateResult.wildcards; + + let modifiedStructureEntries = removeWildcardFromStructureEntry( + structureEntriesCopy, + entryIndex, + cellIndex, + wildcardIndex, + ); + + const removalResults = removeWildcardFromCellContent( + structureEntriesCopy[entryIndex].row[cellIndex], + wildcardsCopy, + entryIndex, + cellIndex, + contentsIndex, + ); + structureEntriesCopy[entryIndex].row[cellIndex] = removalResults.cellContents; + + wildcardsCopy = removalResults.wildcards; + + if (wildcardsUpdateResult.isWildcardDeleted) { + modifiedStructureEntries = updateStructureEntriesAfterWildcardDeletion( + modifiedStructureEntries, + wildcardsCopy, + wildcardIndex, + ); + } + + this.props.onStructureUpdate(); + + this.setState({ + structureEntries: modifiedStructureEntries, + wildcards: wildcardsCopy, + isStructureMatching: false, + }); + } + } + + saveStructureDefinition() { + const { structureHeaderColumns, structureHeaderColumnsTypes, structureEntries, wildcards } = + this.state; + const { onStructureDefinitionSave } = this.props; + + const structureDefiniton: StructureDefinition = { + headerColumns: structureHeaderColumns, + headerColumnsTypes: structureHeaderColumnsTypes, + entries: structureEntries, + wildcards: wildcards, + }; + const structureDefinitonJSON = JSON.stringify(structureDefiniton); + + onStructureDefinitionSave(structureDefinitonJSON); + } + + loadStructureDefinition() { + this.props.onStructureDefinitionLoad(); + this.setState({ isLoadingStructureDefintion: true }); + } + + render() { + const { structureEntries, wildcards, isRemovingStructureEntries, isStructureMatching } = + this.state; + const structureEntriesCopy = cloneDeep(structureEntries); + const wildcardsCopy = cloneDeep(wildcards); + const contextMenuItems = this.getContextMenuItems(); + + const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => ( + + ))({ + [`& .${tooltipClasses.tooltip}`]: { + maxWidth: 900, + }, + }); + + const selection = getSelection(); + + if (selection !== null) { + // empty unwanted text selection resulting from Shift-click + selection.empty(); + } + + return ( +
+
+
+
Structure Matching
+
+ +

Help

+
    +
  • + Ignoring cells: Hold CTRL and click on a cell to ignore it or + stop ignoring it. Hold SHIFT+CTRL to ignore the cell and stop + ignoring all others, or ignore all other cells instead.{" "} +
  • +
  • + Constraining distance between structure rows: Change the constraint + on the distance between two rows by clicking on the link icon between them. + This icon is three horizontal dots by default. +
  • +
  • + Creating wildcards: Selecting a part of the text in a cell, right + click and select "Create wildcard" to create a new + wildcard. A wildcard can be used to abstract away any specific data. +
  • +
  • + Using wildcards: Selecting a part of the text in a cell, right click + and select "Use wildcard wildcard id". Any value could be + abstracted by the wildcard, but the value has to be the same in all places + where this wildcard is used. +
  • +
  • + Removing wildcards: Hover over a wildcard, right click and select + "Remove wildcard". If the wildcard is used in multiple + places, only the selected one will be removed. +
  • +
  • + Removing rows: Click on the Remove rows button on the bottom + right of the dialogue. A red cross will appear to the left of every row in + the structure, by clicking on a cross, the row will be removed from the + structure. Click theDone button afterwards. +
  • +
+ + } + sx={{ m: 1 }} + placement="right" + arrow + > + +
+ + this.props.onClose()} + > + + +
+
+ + this.toggleIsCellSelected( + structureEntryIndex, + cellIndex, + isCtrlPressed, + isShiftPressed, + ) + } + onToggleStructureLink={(structureEntryIndex) => + this.toggleStructureLink(structureEntryIndex) + } + onStructureEntryRemoved={(structureEntryIndex) => + this.removeStructureEntry(structureEntryIndex) + } + /> + +
+ { + this.defineSegment(); + }} + disabled={this.state.structureEntries.length === 1} + > + Create Segment + + { + this.toggleIsRemovingStructureEntries(); + }} + > + {isRemovingStructureEntries ? "Done" : "Remove rows"} + + { + this.matchStructure(); + }} + disabled={isRemovingStructureEntries} + > + Search for Structure + + { + this.props.onExportStructureMatches(); + }} + disabled={this.props.numberOfMatches == 0} + > + Export Structures + + {isStructureMatching && ( + <> +
+ {" "} + {this.props.currentStructureMatchIndex === null + ? 0 + : this.props.currentStructureMatchIndex! + 1}{" "} + of {this.props.numberOfMatches} +
+ {this.props.numberOfMatches > 1 && ( + <> + this.props.onNavigateStructureMatches(false)} + > + + + this.props.onNavigateStructureMatches(true)} + > + + + + )} + + )} +
+
+
+ ); + } +} diff --git a/src/viewer/structures/StructureSettingsDropdown.tsx b/src/viewer/structures/StructureSettingsDropdown.tsx new file mode 100644 index 0000000..2ec93d6 --- /dev/null +++ b/src/viewer/structures/StructureSettingsDropdown.tsx @@ -0,0 +1,89 @@ +import * as React from "react"; +import IconButton from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import ListItemText from "@mui/material/ListItemText"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import SaveIcon from "@mui/icons-material/Save"; +import FileOpenIcon from "@mui/icons-material/FileOpen"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { createTheme } from "@mui/material/styles"; +import { MenuList } from "@mui/material"; + +// const theme = createTheme({ +// palette: { +// primary: { +// main: `${var(--vscode-editorGroupHeader-tabsBackground)}`, +// }, +// secondary: { +// main: '#f44336', +// }, +// }, +// }); + +interface StructureSettingsDropdownProps { + onStructureDefinitionSave: () => void; + onStructureDefinitionLoad: () => void; +} + +export const StructureSettingsDropdown: React.FunctionComponent = ({ + onStructureDefinitionSave, + onStructureDefinitionLoad, +}) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = (action: string) => { + if (action === "save") onStructureDefinitionSave(); + else if (action === "load") onStructureDefinitionLoad(); + + setAnchorEl(null); + }; + + return ( +
+ + + + handleClose("")} + MenuListProps={{ + "aria-labelledby": "basic-button", + sx: { py: 0 }, + }} + > + + handleClose("save")}> + + + + + Save Structure Definition + + + handleClose("load")}> + + + + + Load Structure Definition + + + + +
+ ); +}; diff --git a/src/viewer/structures/StructureTable.tsx b/src/viewer/structures/StructureTable.tsx new file mode 100644 index 0000000..d19e9c4 --- /dev/null +++ b/src/viewer/structures/StructureTable.tsx @@ -0,0 +1,274 @@ +import React, { ReactNode } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import Tooltip from "@mui/material/Tooltip"; +import { Header, StructureEntry, Wildcard } from "../types"; +import { + LOG_HEADER_HEIGHT, + LOG_ROW_HEIGHT, + BORDER_SIZE, + LOG_COLUMN_WIDTH_LOOKUP, + LOG_DEFAULT_COLUMN_WIDTH, + STRUCTURE_WIDTH, + STRUCTURE_LINK_HEIGHT, + StructureLinkDistance, +} from "../constants"; +import { + getStructureTableColumnStyle, + getStructureTableHeaderStyle, + getHeaderColumnStyle, + getHeaderColumnInnerStyle, + getStructureTableCellSelectionStyle, + getStructureTableEntryIconStyle, + getStructureTableRowStyle, + getStructureTableLinkStyle, +} from "../hooks/useStyleManager"; +import { getReactElementsFromCellContents } from "../hooks/useWildcardManager"; +import isEqual from "react-fast-compare"; + +interface Props { + headerColumns: Header[]; + structureEntries: StructureEntry[]; + wildcards: Wildcard[]; + isRemovingStructureEntries: boolean; + onToggleStructureLink: (structureEntryIndex: number) => void; + onStructureEntryRemoved: (structureEntryIndex: number) => void; + onToggleIsCellSelected: ( + structureEntryIndex: number, + cellIndex: number, + isCtrlPressed: boolean, + isShiftPressed: boolean, + ) => void; +} + +interface State { + columnWidth: { [id: string]: number }; +} + +export default class StructureTable extends React.Component { + constructor(props: Props) { + super(props); + this.state = { columnWidth: LOG_COLUMN_WIDTH_LOOKUP }; + } + + shouldComponentUpdate( + nextProps: Readonly, + nextState: Readonly, + _nextContext: any, + ): boolean { + const areStructureEntriesUpdating = !isEqual( + this.props.structureEntries, + nextProps.structureEntries, + ); + const areWildcardsUpdating = !isEqual(this.props.wildcards, nextProps.wildcards); + const isRemovingStructureEntriesUpdating = !isEqual( + this.props.isRemovingStructureEntries, + nextProps.isRemovingStructureEntries, + ); + const isColumnWidthUpdating = !isEqual(this.state.columnWidth, nextState.columnWidth); + + if ( + areStructureEntriesUpdating || + areWildcardsUpdating || + isRemovingStructureEntriesUpdating || + isColumnWidthUpdating + ) { + return true; + } + + return false; + } + + setColumnWidth(name: string, width: number) { + //update the state for triggering the render + this.setState((prevState) => { + const columnWidth = { ...prevState.columnWidth }; + columnWidth[name] = width; + return { columnWidth }; + }); + } + + getInitialColumnWidth(name: string) { + return LOG_COLUMN_WIDTH_LOOKUP[name.toLowerCase()] ?? LOG_DEFAULT_COLUMN_WIDTH; + } + + renderHeader(containerWidth: number) { + const style = getStructureTableHeaderStyle(containerWidth); + + return ( +
+
+ {this.props.headerColumns.filter(h => !h.name.startsWith("Structure")).map((h, i) => + this.renderHeaderColumn(h.name, i, this.getInitialColumnWidth(h.name)), + )} +
+
+ ); + } + + renderHeaderColumn(value: string, columnIndex: number, width: number) { + const height = LOG_HEADER_HEIGHT; + const headerColumnStyle = getHeaderColumnStyle(width, columnIndex, height); + const headerColumnInnerStyle = getHeaderColumnInnerStyle(height, true); + return ( + this.setColumnWidth(value, width!)} + > +
+
{value}
+
+
+ ); + } + + renderColumn(rowIndex: number, cellIndex: number, width: number) { + const { structureEntries } = this.props; + const columnStyle = getStructureTableColumnStyle(width, cellIndex); + const columnInnerStyle = getStructureTableCellSelectionStyle( + structureEntries, + rowIndex, + cellIndex, + ); + + const allCellContents: ReactNode[] = []; + + structureEntries[rowIndex].row[cellIndex].forEach((contentsPart) => { + const contentPartDiv = getReactElementsFromCellContents( + rowIndex, + cellIndex, + contentsPart.contentsIndex, + contentsPart.wildcardIndex, + contentsPart.textValue, + ); + allCellContents.push(contentPartDiv); + }); + + return ( +
+
+ this.props.onToggleIsCellSelected(rowIndex, cellIndex, event.ctrlKey, event.shiftKey) + } + > + {allCellContents.map((value) => value)} +
+
+ ); + } + + renderRows(containerWidth: number, containerHeight: number) { + const newContainerWidth = containerWidth + STRUCTURE_WIDTH; + const result: ReactNode[] = []; + const { structureEntries, isRemovingStructureEntries, onStructureEntryRemoved } = + this.props; + const headerColumns = this.props.headerColumns.filter(h => !h.name.startsWith("Structure")); + const structureEntryIconStyle = getStructureTableEntryIconStyle(isRemovingStructureEntries); + let structureLinkIndex = 0; + + for (let r = 0; r < structureEntries.length; r++) { + const rowStyle = getStructureTableRowStyle(r, structureLinkIndex); + + result.push( +
+ {!isRemovingStructureEntries && ( +
+ +
+ )} + {isRemovingStructureEntries && ( +
{ + onStructureEntryRemoved(r); + }} + > + +
+ )} + {headerColumns.map((h, c) => this.renderColumn(r, c, this.state.columnWidth[h.name]))} +
, + ); + + if (r !== structureEntries.length - 1) { + const structureLinkStyle = getStructureTableLinkStyle(r, structureLinkIndex); + + const structureLinkDistance = structureEntries[r].structureLink; + + result.push( +
this.props.onToggleStructureLink(r)} + > + {structureLinkDistance === StructureLinkDistance.Max && ( + Allow maximal number of rows in-between} + placement="right" + arrow + > + + + )} + {structureLinkDistance === StructureLinkDistance.None && ( + Disallow rows in-between} placement="right" arrow> + + + )} + {structureLinkDistance === StructureLinkDistance.Min && ( + Allow minimal number of rows in-between} + placement="right" + arrow + > + + + )} +
, + ); + structureLinkIndex++; + } + } + + return ( +
+ {result} +
+ ); + } + + render() { + const numberOfRows = this.props.structureEntries.length; + const headerColumns = this.props.headerColumns.filter(h => !h.name.startsWith("Structure")); + const containerHeight = + numberOfRows * LOG_ROW_HEIGHT + (numberOfRows - 1) * STRUCTURE_LINK_HEIGHT; + const containerWidth = + headerColumns.length * BORDER_SIZE + + headerColumns.reduce((partialSum: number, h) => partialSum + this.state.columnWidth[h.name], 0); + + return ( +
+ {this.renderHeader(containerWidth)} + {this.renderRows(containerWidth, containerHeight)} +
+ ); + } +} diff --git a/src/viewer/types.d.ts b/src/viewer/types.d.ts new file mode 100644 index 0000000..e4988b8 --- /dev/null +++ b/src/viewer/types.d.ts @@ -0,0 +1,70 @@ +import { SelectedRowType, StructureHeaderColumnType, StructureLinkDistance } from "./constants"; + +export interface LogViewState { + height: number; + start: number; + visibleItems: number; + startFloor: number; + endCeil: number; + scrollTop: number; + scrollLeft: number; + rowHeight: number; +} + +export interface Header { + name: string; + type: "string" | "number"; +} + +export interface StructureDefinition { + headerColumns: Header[], + headerColumnsTypes: StructureHeaderColumnType[], + entries: StructureEntry[], + wildcards: Wildcard[] +} + +export interface StructureEntry { + row: CellContents[][]; + cellSelection: boolean[]; + structureLink: StructureLinkDistance | undefined; + wildcardsIndices: number[][]; +} + +export interface ContextMenuItem { + text: string; + callback: (anchorDiv: string) => void; +} + +export interface Wildcard { + wildcardSubstitutions: WildcardSubstitution[]; +} + +export interface WildcardSubstitution { + entryIndex: number; + cellIndex: number; + contentsIndex: number; +} + +export interface CellContents { + contentsIndex: number; + textValue: string; + wildcardIndex: number | null; +} + +export type StructureMatchId = number | null; + +export interface RowProperty { + isRendered: boolean; + rowType: SelectedRowType; +} + +export interface Segment { + start: number; + end: number; + level: number; +} + +export interface LogEntryCharMaps { + firstCharIndexMap: Map; + lastCharIndexMap: Map; +}