From 5632fa9eb362035de013dea3d1e9889fb6d12a43 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 3 Dec 2021 16:58:16 +0100 Subject: [PATCH 01/35] scaffolder: fix dev setup Signed-off-by: Patrik Oldsberg --- plugins/scaffolder/dev/index.tsx | 17 ++++++++++++++++- plugins/scaffolder/package.json | 1 + 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 3a749792f1..f7c6aa62fc 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -17,7 +17,11 @@ import { CatalogClient } from '@backstage/catalog-client'; import { createDevApp } from '@backstage/dev-utils'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + catalogApiRef, + starredEntitiesApiRef, + DefaultStarredEntitiesApi, +} from '@backstage/plugin-catalog-react'; import React from 'react'; import { scaffolderApiRef, ScaffolderClient } from '../src'; import { ScaffolderPage } from '../src/plugin'; @@ -25,14 +29,25 @@ import { configApiRef, discoveryApiRef, identityApiRef, + storageApiRef, } from '@backstage/core-plugin-api'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; createDevApp() + .addPage({ + path: '/catalog/:kind/:namespace/:name', + element: , + }) .registerApi({ api: catalogApiRef, deps: { discoveryApi: discoveryApiRef }, factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }), }) + .registerApi({ + api: starredEntitiesApiRef, + deps: { storageApi: storageApiRef }, + factory: ({ storageApi }) => new DefaultStarredEntitiesApi({ storageApi }), + }) .registerApi({ api: scaffolderApiRef, deps: { diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 35b14dffa1..ef2fa32974 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -67,6 +67,7 @@ "react": "^16.13.1 || ^17.0.0" }, "devDependencies": { + "@backstage/plugin-catalog": "^0.7.3", "@backstage/cli": "^0.10.0", "@backstage/core-app-api": "^0.1.24", "@backstage/dev-utils": "^0.2.13", From d94abcab7d64076941449232e22715e0d5e84d0d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 4 Dec 2021 13:48:39 +0100 Subject: [PATCH 02/35] core-components: added AnsiProcessor for LogViewer Signed-off-by: Patrik Oldsberg --- packages/core-components/package.json | 2 + .../LogViewer/AnsiProcessor.test.ts | 197 ++++++++++++++++++ .../src/components/LogViewer/AnsiProcessor.ts | 177 ++++++++++++++++ yarn.lock | 17 +- 4 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts create mode 100644 packages/core-components/src/components/LogViewer/AnsiProcessor.ts diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 1b04173c40..8d24b419d7 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -39,6 +39,7 @@ "@material-ui/lab": "4.0.0-alpha.57", "@types/react-sparklines": "^1.7.0", "@types/react-text-truncate": "^0.14.0", + "ansi-regex": "^5.0.1", "classnames": "^2.2.6", "clsx": "^1.1.0", "d3-selection": "^3.0.0", @@ -81,6 +82,7 @@ "@types/d3-selection": "^3.0.1", "@types/d3-shape": "^3.0.1", "@types/d3-zoom": "^3.0.1", + "@types/ansi-regex": "^5.0.0", "@types/dagre": "^0.7.44", "@types/google-protobuf": "^3.7.2", "@types/jest": "^26.0.7", diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts new file mode 100644 index 0000000000..b2ceee78b0 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts @@ -0,0 +1,197 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnsiProcessor } from './AnsiProcessor'; + +describe('AnsiProcessor', () => { + it('should process a single line', () => { + const processor = new AnsiProcessor(); + expect(processor.process('foo\x1b[31mbar\x1b[39mbaz')).toEqual([ + [ + { + text: 'foo', + modifiers: {}, + }, + { + text: 'bar', + modifiers: { foreground: 'red' }, + }, + { + text: 'baz', + modifiers: {}, + }, + ], + ]); + + expect(processor.process(`foo bar: baz`)).toEqual([ + [ + { + text: 'foo ', + modifiers: {}, + }, + { + text: 'bar', + modifiers: { foreground: 'green' }, + }, + { + text: ': baz', + modifiers: {}, + }, + ], + ]); + }); + + it('should process multiple lines', () => { + const processor = new AnsiProcessor(); + expect( + processor.process(` +a\x1b[34mb\x1b[39mc +x\x1b[44my\x1b[49mz +`), + ).toEqual([ + [{ text: '', modifiers: {} }], + [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { foreground: 'blue' }, + }, + { + text: 'c', + modifiers: {}, + }, + ], + [ + { + text: 'x', + modifiers: {}, + }, + { + text: 'y', + modifiers: { background: 'blue' }, + }, + { + text: 'z', + modifiers: {}, + }, + ], + [{ text: '', modifiers: {} }], + ]); + }); + + it('should carry state across lines', () => { + const processor = new AnsiProcessor(); + expect( + processor.process(` +a\x1b[45mb\x1b[35mc +x\x1b[39my\x1b[49mz`), + ).toEqual([ + [{ text: '', modifiers: {} }], + [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { background: 'magenta' }, + }, + { + text: 'c', + modifiers: { foreground: 'magenta', background: 'magenta' }, + }, + ], + [ + { + text: 'x', + modifiers: { foreground: 'magenta', background: 'magenta' }, + }, + { + text: 'y', + modifiers: { background: 'magenta' }, + }, + { + text: 'z', + modifiers: {}, + }, + ], + ]); + }); + + it('should carry forward state when appending lines', () => { + const processor = new AnsiProcessor(); + const out1 = processor.process(` +a\x1b[36mb\x1b[3mc`); + expect(out1).toEqual([ + [{ text: '', modifiers: {} }], + [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { foreground: 'cyan' }, + }, + { + text: 'c', + modifiers: { foreground: 'cyan', italic: true }, + }, + ], + ]); + + const out2 = processor.process(` +a\x1b[36mb\x1b[3mc +x\x1b[39my\x1b[23mz`); + expect(out2).toEqual([ + [{ text: '', modifiers: {} }], + [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { foreground: 'cyan' }, + }, + { + text: 'c', + modifiers: { foreground: 'cyan', italic: true }, + }, + ], + [ + { + text: 'x', + modifiers: { foreground: 'cyan', italic: true }, + }, + { + text: 'y', + modifiers: { italic: true }, + }, + { + text: 'z', + modifiers: {}, + }, + ], + ]); + + // Verifies that we appended rather than reprocessed + expect(out1[0]).toBe(out2[0]); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts new file mode 100644 index 0000000000..f148005355 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ansiRegexMaker from 'ansi-regex'; + +const ansiRegex = ansiRegexMaker(); +const newlineRegex = /\n\r?/g; + +// A mapping of how each escape code changes the modifiers +const codeModifiers = Object.fromEntries( + Object.entries({ + 1: m => ({ ...m, bold: true }), + 3: m => ({ ...m, italic: true }), + 4: m => ({ ...m, underline: true }), + 22: ({ bold: _, ...m }) => m, + 23: ({ italic: _, ...m }) => m, + 24: ({ underline: _, ...m }) => m, + 30: m => ({ ...m, foreground: 'black' }), + 31: m => ({ ...m, foreground: 'red' }), + 32: m => ({ ...m, foreground: 'green' }), + 33: m => ({ ...m, foreground: 'yellow' }), + 34: m => ({ ...m, foreground: 'blue' }), + 35: m => ({ ...m, foreground: 'magenta' }), + 36: m => ({ ...m, foreground: 'cyan' }), + 37: m => ({ ...m, foreground: 'white' }), + 39: ({ foreground: _, ...m }) => m, + 90: m => ({ ...m, foreground: 'grey' }), + 40: m => ({ ...m, background: 'black' }), + 41: m => ({ ...m, background: 'red' }), + 42: m => ({ ...m, background: 'green' }), + 43: m => ({ ...m, background: 'yellow' }), + 44: m => ({ ...m, background: 'blue' }), + 45: m => ({ ...m, background: 'magenta' }), + 46: m => ({ ...m, background: 'cyan' }), + 47: m => ({ ...m, background: 'white' }), + 49: ({ background: _, ...m }) => m, + } as Record ChunkModifiers>).map( + ([code, modifier]) => [`\x1b[${code}m`, modifier], + ), +); + +export type AnsiColor = + | 'black' + | 'red' + | 'green' + | 'yellow' + | 'blue' + | 'magenta' + | 'cyan' + | 'white' + | 'grey'; + +export interface ChunkModifiers { + foreground?: AnsiColor; + background?: AnsiColor; + bold?: boolean; + italic?: boolean; + underline?: boolean; +} + +export interface Chunk { + text: string; + modifiers: ChunkModifiers; +} + +export class AnsiProcessor { + private text: string = ''; + private lines: Chunk[][] = []; + + /** + * Processes a chunk of text while keeping internal state that optimizes + * subsequent processing that appends to the text. + */ + process(text: string): Chunk[][] { + if (this.text === text) { + return this.lines; + } + + if (text.startsWith(this.text)) { + const lastLineIndex = this.lines.length > 0 ? this.lines.length - 1 : 0; + const lastLine = this.lines[lastLineIndex] ?? []; + const lastChunk = lastLine[lastLine.length - 1] as Chunk | undefined; + const newLines = this.processLines( + (lastChunk?.text ?? '') + text.slice(this.text.length), + lastChunk?.modifiers, + ); + this.text = text; + lastLine.splice(lastLine.length - 1, 1, ...newLines[0]); + this.lines[lastLineIndex] = lastLine; + this.lines.push(...newLines.slice(1)); + } else { + this.lines = this.processLines(text); + this.text = text; + } + + return this.lines; + } + + // Split a chunk of text up into lines and process each line individually + private processLines = ( + text: string, + modifiers: ChunkModifiers = {}, + ): Chunk[][] => { + const lines: Chunk[][] = []; + + let prevIndex = 0; + let currentModifiers = modifiers; + newlineRegex.lastIndex = 0; + for (;;) { + const match = newlineRegex.exec(text); + if (!match) { + lines.push(this.processText(text.slice(prevIndex), currentModifiers)); + return lines; + } + + const line = text.slice(prevIndex, match.index); + prevIndex = match.index + match[0].length; + + const chunks = this.processText(line, currentModifiers); + lines.push(chunks); + + // Modifiers that are active in the last chunk are carried over to the next line + currentModifiers = + chunks[chunks.length - 1].modifiers ?? currentModifiers; + } + }; + + // Processing of a one individual text chunk + private processText = ( + fullText: string, + modifiers: ChunkModifiers, + ): Chunk[] => { + const chunks: Chunk[] = []; + + let prevIndex = 0; + let currentModifiers = modifiers; + ansiRegex.lastIndex = 0; + for (;;) { + const match = ansiRegex.exec(fullText); + if (!match) { + chunks.push({ + text: fullText.slice(prevIndex), + modifiers: currentModifiers, + }); + return chunks; + } + + const text = fullText.slice(prevIndex, match.index); + chunks.push({ text, modifiers: currentModifiers }); + + // For every escape code that we encounter we keep track of where the + // next chunk of text starts, and what modifiers it has + prevIndex = match.index + match[0].length; + currentModifiers = this.processCode(match[0], currentModifiers); + } + }; + + private processCode = ( + code: string, + modifiers: ChunkModifiers, + ): ChunkModifiers => { + return codeModifiers[code]?.(modifiers) ?? modifiers; + }; +} diff --git a/yarn.lock b/yarn.lock index a71567ffe8..5c43e126d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6974,6 +6974,13 @@ dependencies: "@types/node" "*" +"@types/ansi-regex@^5.0.0": + version "5.0.0" + resolved "https://registry.npmjs.org/@types/ansi-regex/-/ansi-regex-5.0.0.tgz#569a5189a92cc46d63fb2ad91e6b130f33d999c1" + integrity sha512-SQafVL3pXFh/5qq/nN6p5858g//zSVzcb8JzCLtoVxm8YNPggMQfEIm7aaTNysxpw1S+lFTaW8kv+aR0/CEhCA== + dependencies: + ansi-regex "*" + "@types/archiver@^5.1.0": version "5.3.0" resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.0.tgz#2b34ba56d4d7102d256b922c7e91e09eab79db6f" @@ -9235,6 +9242,11 @@ ansi-html@0.0.7, ansi-html@^0.0.7: resolved "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= +ansi-regex@*, ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -9255,11 +9267,6 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== - ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" From cef1f124f6e90b00b5f75f6946af678edca63863 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 4 Dec 2021 15:56:47 +0100 Subject: [PATCH 03/35] core-components: added new LogViewer component Signed-off-by: Patrik Oldsberg --- packages/core-components/package.json | 4 + .../LogViewer/LogViewer.stories.tsx | 81 ++++++++ .../src/components/LogViewer/LogViewer.tsx | 196 ++++++++++++++++++ .../src/components/LogViewer/index.ts | 18 ++ .../core-components/src/components/index.ts | 1 + yarn.lock | 17 +- 6 files changed, 305 insertions(+), 12 deletions(-) create mode 100644 packages/core-components/src/components/LogViewer/LogViewer.stories.tsx create mode 100644 packages/core-components/src/components/LogViewer/LogViewer.tsx create mode 100644 packages/core-components/src/components/LogViewer/index.ts diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 8d24b419d7..0d17d30461 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -62,6 +62,8 @@ "react-syntax-highlighter": "^15.4.3", "react-text-truncate": "^0.16.0", "react-use": "^17.2.4", + "react-virtualized-auto-sizer": "^1.0.6", + "react-window": "^1.8.6", "remark-gfm": "^2.0.0", "zen-observable": "^0.8.15" }, @@ -89,6 +91,8 @@ "@types/node": "^14.14.32", "@types/react-helmet": "^6.1.0", "@types/react-syntax-highlighter": "^13.5.2", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", "@types/zen-observable": "^0.8.0" }, "files": [ diff --git a/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx b/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx new file mode 100644 index 0000000000..b7a56e1a7b --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { LogViewer } from './LogViewer'; + +export default { + title: 'Data Display/LogViewer', + component: LogViewer, +}; + +const exampleLog = `Starting up task with 3 steps +Beginning step Fetch Skeleton + Template +info: Fetching template content from remote URL {"timestamp":"2021-12-03T15:47:11.625Z"} +info: Listing files and directories in template {"timestamp":"2021-12-03T15:47:12.797Z"} +info: Processing 33 template files/directories with input values {"component_id":"srnthsrthntrhsn","description":"rnthsrtnhssrthnrsthn","destination":{"host":"github.com","owner":"rtshnsrtmhrstmh","repo":"srtmhsrtmhrsthms"},"owner":"rstnhrstnhsrthn","timestamp":"2021-12-03T15:47:12.801Z"} +info: Writing file .editorconfig to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.816Z"} +info: Writing file .eslintignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.818Z"} +info: Writing file .eslintrc.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.820Z"} +info: Writing directory .github/ to template output path. {"timestamp":"2021-12-03T15:47:12.823Z"} +info: Writing file .gitignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.824Z"} +info: Writing file README.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.827Z"} +info: Writing file babel.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.829Z"} +info: Writing file catalog-info.yaml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.831Z"} +info: Writing directory docs/ to template output path. {"timestamp":"2021-12-03T15:47:12.834Z"} +info: Writing file jest.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.836Z"} +info: Writing file mkdocs.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.838Z"} +info: Writing file next-env.d.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.841Z"} +info: Writing file next.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.844Z"} +info: Writing file package.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.845Z"} +info: Writing file prettier.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.848Z"} +info: Writing directory public/ to template output path. {"timestamp":"2021-12-03T15:47:12.849Z"} +info: Writing directory src/ to template output path. {"timestamp":"2021-12-03T15:47:12.850Z"} +info: Writing file tsconfig.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.851Z"} +info: Writing directory .github/workflows/ to template output path. {"timestamp":"2021-12-03T15:47:12.853Z"} +info: Writing file docs/index.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.854Z"} +info: Writing directory public/static/ to template output path. {"timestamp":"2021-12-03T15:47:12.857Z"} +info: Writing directory src/__tests__/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"} +info: Writing directory src/components/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"} +info: Writing directory src/pages/ to template output path. {"timestamp":"2021-12-03T15:47:12.859Z"} +info: Copying file/directory .github/workflows/build.yml without processing. {"timestamp":"2021-12-03T15:47:12.859Z"} +info: Writing file .github/workflows/build.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.860Z"} +info: Writing file public/static/fonts.css to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.861Z"} +info: Writing file src/components/Header.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.863Z"} +info: Writing file src/__tests__/index.test.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.865Z"} +info: Writing file src/pages/_app.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.868Z"} +info: Writing file src/pages/_document.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.871Z"} +info: Writing directory src/pages/api/ to template output path. {"timestamp":"2021-12-03T15:47:12.873Z"} +info: Writing file src/pages/index.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.874Z"} +info: Writing file src/pages/api/ping.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.877Z"} +info: Template result written to /var/folders/k6/9s7hd6w17115xlgwnsp0wsbr0000gn/T/5c9f8584-fded-4741-b6ef-46d94ff2cbdb {"timestamp":"2021-12-03T15:47:12.878Z"} +Finished step Fetch Skeleton + Template +Beginning step Publish +HttpError: Not Found + at /Users/patriko/dev/backstage/node_modules/@octokit/request/dist-node/index.js:86:21 + at runMicrotasks () + at processTicksAndRejections (internal/process/task_queues.js:95:5) + at async Object.handler (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts:156:20) + at async HandlebarsWorkflowRunner.execute (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts:254:11) + at async TaskWorker.runOneTask (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:110:13) + at async eval (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:100:9) +Run completed with status: failed`; + +export const ExampleLogViewer = () => ( +
+ +
+); diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx new file mode 100644 index 0000000000..21d42a1862 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -0,0 +1,196 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { makeStyles } from '@material-ui/core/styles'; +import React, { useMemo } from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeList } from 'react-window'; +import { AnsiProcessor } from './AnsiProcessor'; +import startCase from 'lodash/startCase'; +import * as colors from '@material-ui/core/colors'; + +export interface LogViewerProps { + text: string; + noLineNumbers?: boolean; +} + +export type AnsiColor = + | 'black' + | 'red' + | 'green' + | 'yellow' + | 'blue' + | 'magenta' + | 'cyan' + | 'white' + | 'grey'; + +export interface ChunkModifiers { + foreground?: AnsiColor; + background?: AnsiColor; + bold?: boolean; + italic?: boolean; + underline?: boolean; +} + +const useStyles = makeStyles(theme => ({ + root: { + fontFamily: '"Monaco", monospace', + fontSize: theme.typography.fontSize, + background: theme.palette.background.paper, + }, + line: { + whiteSpace: 'pre', + }, + lineNumber: { + display: 'inline-block', + textAlign: 'end', + width: 60, + marginRight: theme.spacing(1), + }, + modifierBold: { + fontWeight: theme.typography.fontWeightBold, + }, + modifierItalic: { + fontStyle: 'italic', + }, + modifierUnderline: { + textDecoration: 'underline', + }, + modifierForegroundBlack: { + color: colors.common.black, + }, + modifierForegroundRed: { + color: colors.red[500], + }, + modifierForegroundGreen: { + color: colors.green[500], + }, + modifierForegroundYellow: { + color: colors.yellow[500], + }, + modifierForegroundBlue: { + color: colors.blue[500], + }, + modifierForegroundMagenta: { + color: colors.purple[500], + }, + modifierForegroundCyan: { + color: colors.cyan[500], + }, + modifierForegroundWhite: { + color: colors.common.white, + }, + modifierForegroundGrey: { + color: colors.grey[500], + }, + modifierBackgroundBlack: { + color: colors.common.black, + }, + modifierBackgroundRed: { + color: colors.red[500], + }, + modifierBackgroundGreen: { + color: colors.green[500], + }, + modifierBackgroundYellow: { + color: colors.yellow[500], + }, + modifierBackgroundBlue: { + color: colors.blue[500], + }, + modifierBackgroundMagenta: { + color: colors.purple[500], + }, + modifierBackgroundCyan: { + color: colors.cyan[500], + }, + modifierBackgroundWhite: { + color: colors.common.white, + }, + modifierBackgroundGrey: { + color: colors.grey[500], + }, +})); + +function getModifierClasses( + classes: ReturnType, + modifiers: ChunkModifiers, +) { + const classNames = new Array(); + if (modifiers.bold) { + classNames.push(classes.modifierBold); + } + if (modifiers.italic) { + classNames.push(classes.modifierItalic); + } + if (modifiers.underline) { + classNames.push(classes.modifierUnderline); + } + if (modifiers.foreground) { + const key = `modifierForeground${startCase( + modifiers.foreground, + )}` as keyof typeof classes; + classNames.push(classes[key]); + } + if (modifiers.background) { + const key = `modifierBackground${startCase( + modifiers.background, + )}` as keyof typeof classes; + classNames.push(classes[key]); + } + return classNames.join(' '); +} + +export function LogViewer(props: LogViewerProps) { + const { noLineNumbers } = props; + const classes = useStyles(); + + // The processor keeps state that optimizes appending to the text + const processor = useMemo(() => new AnsiProcessor(), []); + const lines = processor.process(props.text); + + return ( + + {({ height, width }) => ( + + {({ index, style, data }) => ( +
+ {!noLineNumbers && ( + {index + 1} + )} + {data[index].map(({ text, modifiers }, i) => ( + + {text} + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/packages/core-components/src/components/LogViewer/index.ts b/packages/core-components/src/components/LogViewer/index.ts new file mode 100644 index 0000000000..839f34f81e --- /dev/null +++ b/packages/core-components/src/components/LogViewer/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { LogViewer } from './LogViewer'; +export type { LogViewerProps } from './LogViewer'; diff --git a/packages/core-components/src/components/index.ts b/packages/core-components/src/components/index.ts index 3c9376a894..473fab8444 100644 --- a/packages/core-components/src/components/index.ts +++ b/packages/core-components/src/components/index.ts @@ -30,6 +30,7 @@ export * from './HeaderIconLinkRow'; export * from './HorizontalScrollGrid'; export * from './Lifecycle'; export * from './Link'; +export * from './LogViewer'; export * from './MarkdownContent'; export * from './OAuthRequestDialog'; export * from './OverflowTooltip'; diff --git a/yarn.lock b/yarn.lock index 5c43e126d4..a71567ffe8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6974,13 +6974,6 @@ dependencies: "@types/node" "*" -"@types/ansi-regex@^5.0.0": - version "5.0.0" - resolved "https://registry.npmjs.org/@types/ansi-regex/-/ansi-regex-5.0.0.tgz#569a5189a92cc46d63fb2ad91e6b130f33d999c1" - integrity sha512-SQafVL3pXFh/5qq/nN6p5858g//zSVzcb8JzCLtoVxm8YNPggMQfEIm7aaTNysxpw1S+lFTaW8kv+aR0/CEhCA== - dependencies: - ansi-regex "*" - "@types/archiver@^5.1.0": version "5.3.0" resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.0.tgz#2b34ba56d4d7102d256b922c7e91e09eab79db6f" @@ -9242,11 +9235,6 @@ ansi-html@0.0.7, ansi-html@^0.0.7: resolved "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= -ansi-regex@*, ansi-regex@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== - ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -9267,6 +9255,11 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" From 8af6baa2d1df591a5113f41635f78faec77dd4b5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 4 Dec 2021 17:12:38 +0100 Subject: [PATCH 04/35] core-components: refactor AnsiProcessor to keep track of line numbers Signed-off-by: Patrik Oldsberg --- .../LogViewer/AnsiProcessor.test.ts | 289 ++++++++++-------- .../src/components/LogViewer/AnsiProcessor.ts | 59 +++- .../src/components/LogViewer/LogViewer.tsx | 2 +- 3 files changed, 204 insertions(+), 146 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts index b2ceee78b0..9ef0353eca 100644 --- a/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts @@ -20,37 +20,43 @@ describe('AnsiProcessor', () => { it('should process a single line', () => { const processor = new AnsiProcessor(); expect(processor.process('foo\x1b[31mbar\x1b[39mbaz')).toEqual([ - [ - { - text: 'foo', - modifiers: {}, - }, - { - text: 'bar', - modifiers: { foreground: 'red' }, - }, - { - text: 'baz', - modifiers: {}, - }, - ], + { + chunks: [ + { + text: 'foo', + modifiers: {}, + }, + { + text: 'bar', + modifiers: { foreground: 'red' }, + }, + { + text: 'baz', + modifiers: {}, + }, + ], + lineNumber: 1, + }, ]); expect(processor.process(`foo bar: baz`)).toEqual([ - [ - { - text: 'foo ', - modifiers: {}, - }, - { - text: 'bar', - modifiers: { foreground: 'green' }, - }, - { - text: ': baz', - modifiers: {}, - }, - ], + { + chunks: [ + { + text: 'foo ', + modifiers: {}, + }, + { + text: 'bar', + modifiers: { foreground: 'green' }, + }, + { + text: ': baz', + modifiers: {}, + }, + ], + lineNumber: 1, + }, ]); }); @@ -62,36 +68,42 @@ a\x1b[34mb\x1b[39mc x\x1b[44my\x1b[49mz `), ).toEqual([ - [{ text: '', modifiers: {} }], - [ - { - text: 'a', - modifiers: {}, - }, - { - text: 'b', - modifiers: { foreground: 'blue' }, - }, - { - text: 'c', - modifiers: {}, - }, - ], - [ - { - text: 'x', - modifiers: {}, - }, - { - text: 'y', - modifiers: { background: 'blue' }, - }, - { - text: 'z', - modifiers: {}, - }, - ], - [{ text: '', modifiers: {} }], + { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { + chunks: [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { foreground: 'blue' }, + }, + { + text: 'c', + modifiers: {}, + }, + ], + lineNumber: 2, + }, + { + chunks: [ + { + text: 'x', + modifiers: {}, + }, + { + text: 'y', + modifiers: { background: 'blue' }, + }, + { + text: 'z', + modifiers: {}, + }, + ], + lineNumber: 3, + }, + { chunks: [{ text: '', modifiers: {} }], lineNumber: 4 }, ]); }); @@ -102,35 +114,41 @@ x\x1b[44my\x1b[49mz a\x1b[45mb\x1b[35mc x\x1b[39my\x1b[49mz`), ).toEqual([ - [{ text: '', modifiers: {} }], - [ - { - text: 'a', - modifiers: {}, - }, - { - text: 'b', - modifiers: { background: 'magenta' }, - }, - { - text: 'c', - modifiers: { foreground: 'magenta', background: 'magenta' }, - }, - ], - [ - { - text: 'x', - modifiers: { foreground: 'magenta', background: 'magenta' }, - }, - { - text: 'y', - modifiers: { background: 'magenta' }, - }, - { - text: 'z', - modifiers: {}, - }, - ], + { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { + chunks: [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { background: 'magenta' }, + }, + { + text: 'c', + modifiers: { foreground: 'magenta', background: 'magenta' }, + }, + ], + lineNumber: 2, + }, + { + chunks: [ + { + text: 'x', + modifiers: { foreground: 'magenta', background: 'magenta' }, + }, + { + text: 'y', + modifiers: { background: 'magenta' }, + }, + { + text: 'z', + modifiers: {}, + }, + ], + lineNumber: 3, + }, ]); }); @@ -139,56 +157,65 @@ x\x1b[39my\x1b[49mz`), const out1 = processor.process(` a\x1b[36mb\x1b[3mc`); expect(out1).toEqual([ - [{ text: '', modifiers: {} }], - [ - { - text: 'a', - modifiers: {}, - }, - { - text: 'b', - modifiers: { foreground: 'cyan' }, - }, - { - text: 'c', - modifiers: { foreground: 'cyan', italic: true }, - }, - ], + { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { + chunks: [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { foreground: 'cyan' }, + }, + { + text: 'c', + modifiers: { foreground: 'cyan', italic: true }, + }, + ], + lineNumber: 2, + }, ]); const out2 = processor.process(` a\x1b[36mb\x1b[3mc x\x1b[39my\x1b[23mz`); expect(out2).toEqual([ - [{ text: '', modifiers: {} }], - [ - { - text: 'a', - modifiers: {}, - }, - { - text: 'b', - modifiers: { foreground: 'cyan' }, - }, - { - text: 'c', - modifiers: { foreground: 'cyan', italic: true }, - }, - ], - [ - { - text: 'x', - modifiers: { foreground: 'cyan', italic: true }, - }, - { - text: 'y', - modifiers: { italic: true }, - }, - { - text: 'z', - modifiers: {}, - }, - ], + { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { + chunks: [ + { + text: 'a', + modifiers: {}, + }, + { + text: 'b', + modifiers: { foreground: 'cyan' }, + }, + { + text: 'c', + modifiers: { foreground: 'cyan', italic: true }, + }, + ], + lineNumber: 2, + }, + { + chunks: [ + { + text: 'x', + modifiers: { foreground: 'cyan', italic: true }, + }, + { + text: 'y', + modifiers: { italic: true }, + }, + { + text: 'z', + modifiers: {}, + }, + ], + lineNumber: 3, + }, ]); // Verifies that we appended rather than reprocessed diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts index f148005355..f3ea37615c 100644 --- a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts @@ -71,34 +71,56 @@ export interface ChunkModifiers { underline?: boolean; } -export interface Chunk { +// export interface AnsiLine { +// lineNumber: number; +// chunks: AnsiChunk[]; +// } + +export interface AnsiChunk { text: string; modifiers: ChunkModifiers; } +export class AnsiLine { + constructor( + readonly lineNumber: number = 1, + readonly chunks: AnsiChunk[] = [], + ) {} + + lastChunk(): AnsiChunk | undefined { + return this.chunks[this.chunks.length - 1]; + } +} + export class AnsiProcessor { private text: string = ''; - private lines: Chunk[][] = []; + private lines: AnsiLine[] = []; /** * Processes a chunk of text while keeping internal state that optimizes * subsequent processing that appends to the text. */ - process(text: string): Chunk[][] { + process(text: string): AnsiLine[] { if (this.text === text) { return this.lines; } if (text.startsWith(this.text)) { const lastLineIndex = this.lines.length > 0 ? this.lines.length - 1 : 0; - const lastLine = this.lines[lastLineIndex] ?? []; - const lastChunk = lastLine[lastLine.length - 1] as Chunk | undefined; + const lastLine = this.lines[lastLineIndex] ?? new AnsiLine(); + const lastChunk = lastLine.lastChunk(); + const newLines = this.processLines( (lastChunk?.text ?? '') + text.slice(this.text.length), lastChunk?.modifiers, + lastLine?.lineNumber, ); this.text = text; - lastLine.splice(lastLine.length - 1, 1, ...newLines[0]); + lastLine.chunks.splice( + lastLine.chunks.length - 1, + 1, + ...newLines[0]?.chunks, + ); this.lines[lastLineIndex] = lastLine; this.lines.push(...newLines.slice(1)); } else { @@ -113,16 +135,23 @@ export class AnsiProcessor { private processLines = ( text: string, modifiers: ChunkModifiers = {}, - ): Chunk[][] => { - const lines: Chunk[][] = []; + startingLineNumber: number = 1, + ): AnsiLine[] => { + const lines: AnsiLine[] = []; + + let currentModifiers = modifiers; + let currentLineNumber = startingLineNumber; let prevIndex = 0; - let currentModifiers = modifiers; newlineRegex.lastIndex = 0; for (;;) { const match = newlineRegex.exec(text); if (!match) { - lines.push(this.processText(text.slice(prevIndex), currentModifiers)); + const chunks = this.processText( + text.slice(prevIndex), + currentModifiers, + ); + lines.push(new AnsiLine(currentLineNumber, chunks)); return lines; } @@ -130,11 +159,12 @@ export class AnsiProcessor { prevIndex = match.index + match[0].length; const chunks = this.processText(line, currentModifiers); - lines.push(chunks); + lines.push(new AnsiLine(currentLineNumber, chunks)); // Modifiers that are active in the last chunk are carried over to the next line currentModifiers = chunks[chunks.length - 1].modifiers ?? currentModifiers; + currentLineNumber += 1; } }; @@ -142,11 +172,12 @@ export class AnsiProcessor { private processText = ( fullText: string, modifiers: ChunkModifiers, - ): Chunk[] => { - const chunks: Chunk[] = []; + ): AnsiChunk[] => { + const chunks: AnsiChunk[] = []; + + let currentModifiers = modifiers; let prevIndex = 0; - let currentModifiers = modifiers; ansiRegex.lastIndex = 0; for (;;) { const match = ansiRegex.exec(fullText); diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index 21d42a1862..a77990f734 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -179,7 +179,7 @@ export function LogViewer(props: LogViewerProps) { {!noLineNumbers && ( {index + 1} )} - {data[index].map(({ text, modifiers }, i) => ( + {data[index].chunks.map(({ text, modifiers }, i) => ( Date: Sat, 4 Dec 2021 17:43:17 +0100 Subject: [PATCH 05/35] core-components: basic line selection and filtering for LogViewer Signed-off-by: Patrik Oldsberg --- .../LogViewer/AnsiProcessor.test.ts | 19 ++- .../src/components/LogViewer/AnsiProcessor.ts | 23 ++-- .../src/components/LogViewer/LogViewer.tsx | 113 ++++++++++++++---- 3 files changed, 116 insertions(+), 39 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts index 9ef0353eca..e957b28b7c 100644 --- a/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts @@ -35,6 +35,7 @@ describe('AnsiProcessor', () => { modifiers: {}, }, ], + text: 'foobarbaz', lineNumber: 1, }, ]); @@ -55,6 +56,7 @@ describe('AnsiProcessor', () => { modifiers: {}, }, ], + text: 'foo bar: baz', lineNumber: 1, }, ]); @@ -68,7 +70,7 @@ a\x1b[34mb\x1b[39mc x\x1b[44my\x1b[49mz `), ).toEqual([ - { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, { chunks: [ { @@ -84,6 +86,7 @@ x\x1b[44my\x1b[49mz modifiers: {}, }, ], + text: 'abc', lineNumber: 2, }, { @@ -101,9 +104,10 @@ x\x1b[44my\x1b[49mz modifiers: {}, }, ], + text: 'xyz', lineNumber: 3, }, - { chunks: [{ text: '', modifiers: {} }], lineNumber: 4 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 4 }, ]); }); @@ -114,7 +118,7 @@ x\x1b[44my\x1b[49mz a\x1b[45mb\x1b[35mc x\x1b[39my\x1b[49mz`), ).toEqual([ - { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, { chunks: [ { @@ -130,6 +134,7 @@ x\x1b[39my\x1b[49mz`), modifiers: { foreground: 'magenta', background: 'magenta' }, }, ], + text: 'abc', lineNumber: 2, }, { @@ -147,6 +152,7 @@ x\x1b[39my\x1b[49mz`), modifiers: {}, }, ], + text: 'xyz', lineNumber: 3, }, ]); @@ -157,7 +163,7 @@ x\x1b[39my\x1b[49mz`), const out1 = processor.process(` a\x1b[36mb\x1b[3mc`); expect(out1).toEqual([ - { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, { chunks: [ { @@ -173,6 +179,7 @@ a\x1b[36mb\x1b[3mc`); modifiers: { foreground: 'cyan', italic: true }, }, ], + text: 'abc', lineNumber: 2, }, ]); @@ -181,7 +188,7 @@ a\x1b[36mb\x1b[3mc`); a\x1b[36mb\x1b[3mc x\x1b[39my\x1b[23mz`); expect(out2).toEqual([ - { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, { chunks: [ { @@ -197,6 +204,7 @@ x\x1b[39my\x1b[23mz`); modifiers: { foreground: 'cyan', italic: true }, }, ], + text: 'abc', lineNumber: 2, }, { @@ -214,6 +222,7 @@ x\x1b[39my\x1b[23mz`); modifiers: {}, }, ], + text: 'xyz', lineNumber: 3, }, ]); diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts index f3ea37615c..a33d0db450 100644 --- a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts @@ -82,14 +82,25 @@ export interface AnsiChunk { } export class AnsiLine { + text: string; + constructor( readonly lineNumber: number = 1, readonly chunks: AnsiChunk[] = [], - ) {} + ) { + this.text = chunks.map(c => c.text).join(''); + } lastChunk(): AnsiChunk | undefined { return this.chunks[this.chunks.length - 1]; } + + replaceLastChunk(newChunks?: AnsiChunk[]) { + if (newChunks) { + this.chunks.splice(this.chunks.length - 1, 1, ...newChunks); + this.text = this.chunks.map(c => c.text).join(''); + } + } } export class AnsiProcessor { @@ -115,18 +126,14 @@ export class AnsiProcessor { lastChunk?.modifiers, lastLine?.lineNumber, ); - this.text = text; - lastLine.chunks.splice( - lastLine.chunks.length - 1, - 1, - ...newLines[0]?.chunks, - ); + lastLine.replaceLastChunk(newLines[0]?.chunks); + this.lines[lastLineIndex] = lastLine; this.lines.push(...newLines.slice(1)); } else { this.lines = this.processLines(text); - this.text = text; } + this.text = text; return this.lines; } diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index a77990f734..1adcb6df7e 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -15,12 +15,16 @@ */ import { makeStyles } from '@material-ui/core/styles'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; import { AnsiProcessor } from './AnsiProcessor'; import startCase from 'lodash/startCase'; import * as colors from '@material-ui/core/colors'; +import clsx from 'clsx'; +import TextField from '@material-ui/core/TextField'; + +const HEADER_SIZE = 40; export interface LogViewerProps { text: string; @@ -48,18 +52,38 @@ export interface ChunkModifiers { const useStyles = makeStyles(theme => ({ root: { + background: theme.palette.background.paper, + }, + header: { + height: HEADER_SIZE, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + }, + log: { fontFamily: '"Monaco", monospace', fontSize: theme.typography.fontSize, - background: theme.palette.background.paper, }, line: { whiteSpace: 'pre', + + '&:hover': { + background: theme.palette.action.hover, + }, + }, + lineSelected: { + background: theme.palette.action.selected, + + '&:hover': { + background: theme.palette.action.selected, + }, }, lineNumber: { display: 'inline-block', textAlign: 'end', width: 60, marginRight: theme.spacing(1), + cursor: 'pointer', }, modifierBold: { fontWeight: theme.typography.fontWeightBold, @@ -152,44 +176,81 @@ function getModifierClasses( )}` as keyof typeof classes; classNames.push(classes[key]); } - return classNames.join(' '); + return classNames.length > 0 ? classNames.join(' ') : undefined; } export function LogViewer(props: LogViewerProps) { const { noLineNumbers } = props; const classes = useStyles(); + const [selectedLine, setSelectedLine] = useState(); + const [filter, setFilter] = useState(''); // The processor keeps state that optimizes appending to the text const processor = useMemo(() => new AnsiProcessor(), []); const lines = processor.process(props.text); + const filteredLines = useMemo(() => { + if (!filter) { + return lines; + } + return lines.filter(line => line.text.includes(filter)); + }, [lines, filter]); + return ( {({ height, width }) => ( - - {({ index, style, data }) => ( -
- {!noLineNumbers && ( - {index + 1} - )} - {data[index].chunks.map(({ text, modifiers }, i) => ( - +
+ setFilter(e.target.value)} + /> +
+ + {({ index, style, data }) => { + const { chunks, lineNumber } = data[index]; + return ( +
- {text} - - ))} -
- )} -
+ {!noLineNumbers && ( + setSelectedLine(lineNumber)} + onKeyPress={() => setSelectedLine(lineNumber)} + > + {lineNumber} + + )} + {chunks.map(({ text, modifiers }, i) => ( + + {text} + + ))} +
+ ); + }} +
+ )}
); From 8884af06e8f0b4e7b077610aef0c0ade6116b71e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 4 Dec 2021 18:58:51 +0100 Subject: [PATCH 06/35] core-components: highlight search matches in LogViewer Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/AnsiProcessor.ts | 10 +- .../src/components/LogViewer/LogViewer.tsx | 166 +++++++++++++++--- 2 files changed, 148 insertions(+), 28 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts index a33d0db450..5c4bf58865 100644 --- a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts @@ -88,7 +88,10 @@ export class AnsiLine { readonly lineNumber: number = 1, readonly chunks: AnsiChunk[] = [], ) { - this.text = chunks.map(c => c.text).join(''); + this.text = chunks + .map(c => c.text) + .join('') + .toLocaleLowerCase('en-US'); } lastChunk(): AnsiChunk | undefined { @@ -98,7 +101,10 @@ export class AnsiLine { replaceLastChunk(newChunks?: AnsiChunk[]) { if (newChunks) { this.chunks.splice(this.chunks.length - 1, 1, ...newChunks); - this.text = this.chunks.map(c => c.text).join(''); + this.text = this.chunks + .map(c => c.text) + .join('') + .toLocaleLowerCase('en-US'); } } } diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index 1adcb6df7e..c1a5d06b34 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ -import { makeStyles } from '@material-ui/core/styles'; +import { alpha, makeStyles } from '@material-ui/core/styles'; import React, { useMemo, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; -import { AnsiProcessor } from './AnsiProcessor'; +import { AnsiChunk, AnsiLine, AnsiProcessor } from './AnsiProcessor'; import startCase from 'lodash/startCase'; import * as colors from '@material-ui/core/colors'; import clsx from 'clsx'; @@ -85,6 +85,9 @@ const useStyles = makeStyles(theme => ({ marginRight: theme.spacing(1), cursor: 'pointer', }, + textHighlight: { + background: alpha(theme.palette.primary.main, 0.3), + }, modifierBold: { fontWeight: theme.typography.fontWeightBold, }, @@ -122,31 +125,31 @@ const useStyles = makeStyles(theme => ({ color: colors.grey[500], }, modifierBackgroundBlack: { - color: colors.common.black, + background: colors.common.black, }, modifierBackgroundRed: { - color: colors.red[500], + background: colors.red[500], }, modifierBackgroundGreen: { - color: colors.green[500], + background: colors.green[500], }, modifierBackgroundYellow: { - color: colors.yellow[500], + background: colors.yellow[500], }, modifierBackgroundBlue: { - color: colors.blue[500], + background: colors.blue[500], }, modifierBackgroundMagenta: { - color: colors.purple[500], + background: colors.purple[500], }, modifierBackgroundCyan: { - color: colors.cyan[500], + background: colors.cyan[500], }, modifierBackgroundWhite: { - color: colors.common.white, + background: colors.common.white, }, modifierBackgroundGrey: { - color: colors.grey[500], + background: colors.grey[500], }, })); @@ -179,22 +182,135 @@ function getModifierClasses( return classNames.length > 0 ? classNames.join(' ') : undefined; } +export function LogLine({ + line, + classes, + searchText, +}: { + line: AnsiLine; + classes: ReturnType; + searchText: string; +}) { + let searchResults: Array<{ start: number; end: number }> | undefined = + undefined; + if (searchText && line.text.includes(searchText)) { + searchResults = []; + let offset = 0; + for (;;) { + const start = line.text.indexOf(searchText, offset); + if (start === -1) { + break; + } + const end = start + searchText.length; + searchResults.push({ start, end }); + offset = end; + } + } + + const output = new Array(line.chunks.length); + + let key = 0; + let chunkOffset = 0; + let nextResult = searchResults?.shift(); + for (const { text, modifiers } of line.chunks) { + if (!nextResult || chunkOffset + text.length < nextResult.start) { + output.push( + + {text} + , + ); + chunkOffset += text.length; + continue; + } + + let localOffset = 0; + while (nextResult) { + let localStart = nextResult.start - chunkOffset; + if (localStart < 0) { + localStart = 0; + } + const localEnd = nextResult.end - chunkOffset; + const beforeMatch = text.slice(localOffset, localStart); + const match = text.slice(localStart, localEnd); + + if (beforeMatch) { + output.push( + + {beforeMatch} + , + ); + } + output.push( + + {match} + , + ); + + localOffset = localStart + match.length; + + if (match.length === searchText.length) { + nextResult = searchResults?.shift(); + } else { + break; + } + } + + if (localOffset < text.length) { + output.push( + + {text.slice(localOffset)} + , + ); + } + + chunkOffset += text.length; + } + return <>{output}; +} + export function LogViewer(props: LogViewerProps) { const { noLineNumbers } = props; const classes = useStyles(); const [selectedLine, setSelectedLine] = useState(); - const [filter, setFilter] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const searchText = searchInput.toLocaleLowerCase('en-US'); // The processor keeps state that optimizes appending to the text const processor = useMemo(() => new AnsiProcessor(), []); const lines = processor.process(props.text); const filteredLines = useMemo(() => { - if (!filter) { + if (!searchText) { return lines; } - return lines.filter(line => line.text.includes(filter)); - }, [lines, filter]); + const matchingLines = []; + const searchResults = []; + for (const line of lines) { + if (line.text.includes(searchText)) { + matchingLines.push(line); + + const lineResults = []; + let offset = 0; + for (;;) { + const start = line.text.indexOf(searchText, offset); + if (start === -1) { + break; + } + const end = start + searchText.length; + lineResults.push({ start, end }); + offset = end; + } + searchResults.push(lineResults); + } + } + return lines.filter(line => line.text.includes(searchText)); + }, [lines, searchText]); return ( @@ -205,8 +321,8 @@ export function LogViewer(props: LogViewerProps) { size="small" variant="standard" placeholder="Search" - value={filter} - onChange={e => setFilter(e.target.value)} + value={searchInput} + onChange={e => setSearchInput(e.target.value)} /> {({ index, style, data }) => { - const { chunks, lineNumber } = data[index]; + const line = data[index]; + const { lineNumber } = line; return (
)} - {chunks.map(({ text, modifiers }, i) => ( - - {text} - - ))} +
); }} From 439833d300c03dfa1e7d50ac715133e6969173db Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 11:29:09 +0100 Subject: [PATCH 07/35] core-components: split up LogViewer Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogLine.tsx | 140 +++++++++++ .../src/components/LogViewer/LogViewer.tsx | 233 +----------------- .../src/components/LogViewer/styles.ts | 123 +++++++++ 3 files changed, 266 insertions(+), 230 deletions(-) create mode 100644 packages/core-components/src/components/LogViewer/LogLine.tsx create mode 100644 packages/core-components/src/components/LogViewer/styles.ts diff --git a/packages/core-components/src/components/LogViewer/LogLine.tsx b/packages/core-components/src/components/LogViewer/LogLine.tsx new file mode 100644 index 0000000000..7e5366eb77 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogLine.tsx @@ -0,0 +1,140 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { AnsiLine, ChunkModifiers } from './AnsiProcessor'; +import startCase from 'lodash/startCase'; +import clsx from 'clsx'; +import { useStyles } from './useStyles'; + +function getModifierClasses( + classes: ReturnType, + modifiers: ChunkModifiers, +) { + const classNames = new Array(); + if (modifiers.bold) { + classNames.push(classes.modifierBold); + } + if (modifiers.italic) { + classNames.push(classes.modifierItalic); + } + if (modifiers.underline) { + classNames.push(classes.modifierUnderline); + } + if (modifiers.foreground) { + const key = `modifierForeground${startCase( + modifiers.foreground, + )}` as keyof typeof classes; + classNames.push(classes[key]); + } + if (modifiers.background) { + const key = `modifierBackground${startCase( + modifiers.background, + )}` as keyof typeof classes; + classNames.push(classes[key]); + } + return classNames.length > 0 ? classNames.join(' ') : undefined; +} + +export interface LogLineProps { + line: AnsiLine; + classes: ReturnType; + searchText: string; +} + +export function LogLine({ line, classes, searchText }: LogLineProps) { + let searchResults: Array<{ start: number; end: number }> | undefined = + undefined; + if (searchText && line.text.includes(searchText)) { + searchResults = []; + let offset = 0; + for (;;) { + const start = line.text.indexOf(searchText, offset); + if (start === -1) { + break; + } + const end = start + searchText.length; + searchResults.push({ start, end }); + offset = end; + } + } + + const output = new Array(line.chunks.length); + + let key = 0; + let chunkOffset = 0; + let nextResult = searchResults?.shift(); + for (const { text, modifiers } of line.chunks) { + if (!nextResult || chunkOffset + text.length < nextResult.start) { + output.push( + + {text} + , + ); + chunkOffset += text.length; + continue; + } + + let localOffset = 0; + while (nextResult) { + let localStart = nextResult.start - chunkOffset; + if (localStart < 0) { + localStart = 0; + } + const localEnd = nextResult.end - chunkOffset; + const beforeMatch = text.slice(localOffset, localStart); + const match = text.slice(localStart, localEnd); + + if (beforeMatch) { + output.push( + + {beforeMatch} + , + ); + } + output.push( + + {match} + , + ); + + localOffset = localStart + match.length; + + if (match.length === searchText.length) { + nextResult = searchResults?.shift(); + } else { + break; + } + } + + if (localOffset < text.length) { + output.push( + + {text.slice(localOffset)} + , + ); + } + + chunkOffset += text.length; + } + return <>{output}; +} diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index c1a5d06b34..d6722edbcd 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -14,17 +14,14 @@ * limitations under the License. */ -import { alpha, makeStyles } from '@material-ui/core/styles'; import React, { useMemo, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; -import { AnsiChunk, AnsiLine, AnsiProcessor } from './AnsiProcessor'; -import startCase from 'lodash/startCase'; -import * as colors from '@material-ui/core/colors'; +import { AnsiProcessor } from './AnsiProcessor'; +import { HEADER_SIZE, useStyles } from './styles'; import clsx from 'clsx'; import TextField from '@material-ui/core/TextField'; - -const HEADER_SIZE = 40; +import { LogLine } from './LogLine'; export interface LogViewerProps { text: string; @@ -50,230 +47,6 @@ export interface ChunkModifiers { underline?: boolean; } -const useStyles = makeStyles(theme => ({ - root: { - background: theme.palette.background.paper, - }, - header: { - height: HEADER_SIZE, - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - }, - log: { - fontFamily: '"Monaco", monospace', - fontSize: theme.typography.fontSize, - }, - line: { - whiteSpace: 'pre', - - '&:hover': { - background: theme.palette.action.hover, - }, - }, - lineSelected: { - background: theme.palette.action.selected, - - '&:hover': { - background: theme.palette.action.selected, - }, - }, - lineNumber: { - display: 'inline-block', - textAlign: 'end', - width: 60, - marginRight: theme.spacing(1), - cursor: 'pointer', - }, - textHighlight: { - background: alpha(theme.palette.primary.main, 0.3), - }, - modifierBold: { - fontWeight: theme.typography.fontWeightBold, - }, - modifierItalic: { - fontStyle: 'italic', - }, - modifierUnderline: { - textDecoration: 'underline', - }, - modifierForegroundBlack: { - color: colors.common.black, - }, - modifierForegroundRed: { - color: colors.red[500], - }, - modifierForegroundGreen: { - color: colors.green[500], - }, - modifierForegroundYellow: { - color: colors.yellow[500], - }, - modifierForegroundBlue: { - color: colors.blue[500], - }, - modifierForegroundMagenta: { - color: colors.purple[500], - }, - modifierForegroundCyan: { - color: colors.cyan[500], - }, - modifierForegroundWhite: { - color: colors.common.white, - }, - modifierForegroundGrey: { - color: colors.grey[500], - }, - modifierBackgroundBlack: { - background: colors.common.black, - }, - modifierBackgroundRed: { - background: colors.red[500], - }, - modifierBackgroundGreen: { - background: colors.green[500], - }, - modifierBackgroundYellow: { - background: colors.yellow[500], - }, - modifierBackgroundBlue: { - background: colors.blue[500], - }, - modifierBackgroundMagenta: { - background: colors.purple[500], - }, - modifierBackgroundCyan: { - background: colors.cyan[500], - }, - modifierBackgroundWhite: { - background: colors.common.white, - }, - modifierBackgroundGrey: { - background: colors.grey[500], - }, -})); - -function getModifierClasses( - classes: ReturnType, - modifiers: ChunkModifiers, -) { - const classNames = new Array(); - if (modifiers.bold) { - classNames.push(classes.modifierBold); - } - if (modifiers.italic) { - classNames.push(classes.modifierItalic); - } - if (modifiers.underline) { - classNames.push(classes.modifierUnderline); - } - if (modifiers.foreground) { - const key = `modifierForeground${startCase( - modifiers.foreground, - )}` as keyof typeof classes; - classNames.push(classes[key]); - } - if (modifiers.background) { - const key = `modifierBackground${startCase( - modifiers.background, - )}` as keyof typeof classes; - classNames.push(classes[key]); - } - return classNames.length > 0 ? classNames.join(' ') : undefined; -} - -export function LogLine({ - line, - classes, - searchText, -}: { - line: AnsiLine; - classes: ReturnType; - searchText: string; -}) { - let searchResults: Array<{ start: number; end: number }> | undefined = - undefined; - if (searchText && line.text.includes(searchText)) { - searchResults = []; - let offset = 0; - for (;;) { - const start = line.text.indexOf(searchText, offset); - if (start === -1) { - break; - } - const end = start + searchText.length; - searchResults.push({ start, end }); - offset = end; - } - } - - const output = new Array(line.chunks.length); - - let key = 0; - let chunkOffset = 0; - let nextResult = searchResults?.shift(); - for (const { text, modifiers } of line.chunks) { - if (!nextResult || chunkOffset + text.length < nextResult.start) { - output.push( - - {text} - , - ); - chunkOffset += text.length; - continue; - } - - let localOffset = 0; - while (nextResult) { - let localStart = nextResult.start - chunkOffset; - if (localStart < 0) { - localStart = 0; - } - const localEnd = nextResult.end - chunkOffset; - const beforeMatch = text.slice(localOffset, localStart); - const match = text.slice(localStart, localEnd); - - if (beforeMatch) { - output.push( - - {beforeMatch} - , - ); - } - output.push( - - {match} - , - ); - - localOffset = localStart + match.length; - - if (match.length === searchText.length) { - nextResult = searchResults?.shift(); - } else { - break; - } - } - - if (localOffset < text.length) { - output.push( - - {text.slice(localOffset)} - , - ); - } - - chunkOffset += text.length; - } - return <>{output}; -} - export function LogViewer(props: LogViewerProps) { const { noLineNumbers } = props; const classes = useStyles(); diff --git a/packages/core-components/src/components/LogViewer/styles.ts b/packages/core-components/src/components/LogViewer/styles.ts new file mode 100644 index 0000000000..fd66571915 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/styles.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { alpha, makeStyles } from '@material-ui/core/styles'; +import * as colors from '@material-ui/core/colors'; + +export const HEADER_SIZE = 40; + +export const useStyles = makeStyles(theme => ({ + root: { + background: theme.palette.background.paper, + }, + header: { + height: HEADER_SIZE, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + }, + log: { + fontFamily: '"Monaco", monospace', + fontSize: theme.typography.fontSize, + }, + line: { + whiteSpace: 'pre', + + '&:hover': { + background: theme.palette.action.hover, + }, + }, + lineSelected: { + background: theme.palette.action.selected, + + '&:hover': { + background: theme.palette.action.selected, + }, + }, + lineNumber: { + display: 'inline-block', + textAlign: 'end', + width: 60, + marginRight: theme.spacing(1), + cursor: 'pointer', + }, + textHighlight: { + background: alpha(theme.palette.primary.main, 0.3), + }, + modifierBold: { + fontWeight: theme.typography.fontWeightBold, + }, + modifierItalic: { + fontStyle: 'italic', + }, + modifierUnderline: { + textDecoration: 'underline', + }, + modifierForegroundBlack: { + color: colors.common.black, + }, + modifierForegroundRed: { + color: colors.red[500], + }, + modifierForegroundGreen: { + color: colors.green[500], + }, + modifierForegroundYellow: { + color: colors.yellow[500], + }, + modifierForegroundBlue: { + color: colors.blue[500], + }, + modifierForegroundMagenta: { + color: colors.purple[500], + }, + modifierForegroundCyan: { + color: colors.cyan[500], + }, + modifierForegroundWhite: { + color: colors.common.white, + }, + modifierForegroundGrey: { + color: colors.grey[500], + }, + modifierBackgroundBlack: { + background: colors.common.black, + }, + modifierBackgroundRed: { + background: colors.red[500], + }, + modifierBackgroundGreen: { + background: colors.green[500], + }, + modifierBackgroundYellow: { + background: colors.yellow[500], + }, + modifierBackgroundBlue: { + background: colors.blue[500], + }, + modifierBackgroundMagenta: { + background: colors.purple[500], + }, + modifierBackgroundCyan: { + background: colors.cyan[500], + }, + modifierBackgroundWhite: { + background: colors.common.white, + }, + modifierBackgroundGrey: { + background: colors.grey[500], + }, +})); From 1faae751b683aa1ea21598d4b864f749d5514443 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 12:14:42 +0100 Subject: [PATCH 08/35] core-components: refactor LogViewer LogLine into pieces of logic Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogLine.tsx | 119 ++++++++++-------- 1 file changed, 65 insertions(+), 54 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/LogLine.tsx b/packages/core-components/src/components/LogViewer/LogLine.tsx index 7e5366eb77..66197e9865 100644 --- a/packages/core-components/src/components/LogViewer/LogLine.tsx +++ b/packages/core-components/src/components/LogViewer/LogLine.tsx @@ -15,12 +15,12 @@ */ import React from 'react'; -import { AnsiLine, ChunkModifiers } from './AnsiProcessor'; +import { AnsiChunk, AnsiLine, ChunkModifiers } from './AnsiProcessor'; import startCase from 'lodash/startCase'; import clsx from 'clsx'; -import { useStyles } from './useStyles'; +import { useStyles } from './styles'; -function getModifierClasses( +export function getModifierClasses( classes: ReturnType, modifiers: ChunkModifiers, ) { @@ -49,41 +49,45 @@ function getModifierClasses( return classNames.length > 0 ? classNames.join(' ') : undefined; } -export interface LogLineProps { - line: AnsiLine; - classes: ReturnType; - searchText: string; +export function findSearchResults(text: string, searchText: string) { + if (!searchText || !text.includes(searchText)) { + return undefined; + } + const searchResults = new Array<{ start: number; end: number }>(); + let offset = 0; + for (;;) { + const start = text.indexOf(searchText, offset); + if (start === -1) { + break; + } + const end = start + searchText.length; + searchResults.push({ start, end }); + offset = end; + } + return searchResults; } -export function LogLine({ line, classes, searchText }: LogLineProps) { - let searchResults: Array<{ start: number; end: number }> | undefined = - undefined; - if (searchText && line.text.includes(searchText)) { - searchResults = []; - let offset = 0; - for (;;) { - const start = line.text.indexOf(searchText, offset); - if (start === -1) { - break; - } - const end = start + searchText.length; - searchResults.push({ start, end }); - offset = end; - } +export interface HighlightAnsiChunk extends AnsiChunk { + highlight?: boolean; +} + +export function calculateHighlightedChunks( + line: AnsiLine, + searchText: string, +): HighlightAnsiChunk[] { + const results = findSearchResults(line.text, searchText); + if (!results) { + return line.chunks; } - const output = new Array(line.chunks.length); + const chunks = new Array(); - let key = 0; let chunkOffset = 0; - let nextResult = searchResults?.shift(); - for (const { text, modifiers } of line.chunks) { + let nextResult = results.shift(); + for (const chunk of line.chunks) { + const { text, modifiers } = chunk; if (!nextResult || chunkOffset + text.length < nextResult.start) { - output.push( - - {text} - , - ); + chunks.push(chunk); chunkOffset += text.length; continue; } @@ -99,42 +103,49 @@ export function LogLine({ line, classes, searchText }: LogLineProps) { const match = text.slice(localStart, localEnd); if (beforeMatch) { - output.push( - - {beforeMatch} - , - ); + chunks.push({ text: beforeMatch, modifiers }); } - output.push( - - {match} - , - ); + chunks.push({ text: match, modifiers, highlight: true }); localOffset = localStart + match.length; if (match.length === searchText.length) { - nextResult = searchResults?.shift(); + nextResult = results.shift(); } else { break; } } if (localOffset < text.length) { - output.push( - - {text.slice(localOffset)} - , - ); + chunks.push({ text: text.slice(localOffset), modifiers }); } chunkOffset += text.length; } - return <>{output}; + + return chunks; +} + +export interface LogLineProps { + line: AnsiLine; + classes: ReturnType; + searchText: string; +} + +export function LogLine({ line, classes, searchText }: LogLineProps) { + const chunks = calculateHighlightedChunks(line, searchText); + + const elements = chunks.map(({ text, modifiers, highlight }, index) => ( + + {text} + + )); + + return <>{elements}; } From ef111abb56566f8d1d761d34f2a4935ec525bdd6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 12:32:52 +0100 Subject: [PATCH 09/35] core-components: add tests for LogLine modifiers Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogLine.test.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 packages/core-components/src/components/LogViewer/LogLine.test.tsx diff --git a/packages/core-components/src/components/LogViewer/LogLine.test.tsx b/packages/core-components/src/components/LogViewer/LogLine.test.tsx new file mode 100644 index 0000000000..2b4513221a --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogLine.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChunkModifiers } from './AnsiProcessor'; +import { getModifierClasses } from './LogLine'; + +describe('getModifierClasses', () => { + const classes = { + modifierBold: 'bold', + modifierItalic: 'italic', + modifierUnderline: 'underline', + modifierForegroundBlack: 'black', + modifierForegroundRed: 'red', + modifierForegroundGreen: 'green', + modifierForegroundYellow: 'yellow', + modifierForegroundBlue: 'blue', + modifierForegroundMagenta: 'magenta', + modifierForegroundCyan: 'cyan', + modifierForegroundWhite: 'white', + modifierForegroundGrey: 'grey', + modifierBackgroundBlack: 'bg-black', + modifierBackgroundRed: 'bg-red', + modifierBackgroundGreen: 'bg-green', + modifierBackgroundYellow: 'bg-yellow', + modifierBackgroundBlue: 'bg-blue', + modifierBackgroundMagenta: 'bg-magenta', + modifierBackgroundCyan: 'bg-cyan', + modifierBackgroundWhite: 'bg-white', + modifierBackgroundGrey: 'bg-grey', + }; + const curried = (modifiers: ChunkModifiers) => + getModifierClasses( + classes as Parameters[0], + modifiers, + ); + + it('should transform modifiers to classes', () => { + expect(curried({})).toEqual(undefined); + expect(curried({ bold: true })).toEqual('bold'); + expect(curried({ italic: true })).toEqual('italic'); + expect(curried({ underline: true })).toEqual('underline'); + expect(curried({ foreground: 'black' })).toEqual('black'); + expect(curried({ background: 'black' })).toEqual('bg-black'); + expect( + curried({ + bold: true, + italic: true, + underline: true, + foreground: 'red', + background: 'red', + }), + ).toEqual('bold italic underline red bg-red'); + }); +}); From 4dd0511074566f61ae3bc94bc5c80dd48bf87a3e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 12:42:37 +0100 Subject: [PATCH 10/35] core-components: add tests for LogLine search results Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogLine.test.tsx | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/core-components/src/components/LogViewer/LogLine.test.tsx b/packages/core-components/src/components/LogViewer/LogLine.test.tsx index 2b4513221a..a4033f9221 100644 --- a/packages/core-components/src/components/LogViewer/LogLine.test.tsx +++ b/packages/core-components/src/components/LogViewer/LogLine.test.tsx @@ -15,7 +15,7 @@ */ import { ChunkModifiers } from './AnsiProcessor'; -import { getModifierClasses } from './LogLine'; +import { findSearchResults, getModifierClasses } from './LogLine'; describe('getModifierClasses', () => { const classes = { @@ -65,3 +65,48 @@ describe('getModifierClasses', () => { ).toEqual('bold italic underline red bg-red'); }); }); + +describe('findSearchResults', () => { + it('should not return results if there is no match', () => { + expect(findSearchResults('Foo', 'Bar')).toEqual(undefined); + expect(findSearchResults('Foo Bar', 'oof')).toEqual(undefined); + expect(findSearchResults('Foo Bar', '')).toEqual(undefined); + expect(findSearchResults('', '')).toEqual(undefined); + expect(findSearchResults('', 'Foo')).toEqual(undefined); + }); + + it('should find result indices', () => { + expect(findSearchResults('Foo', 'Foo')).toEqual([{ start: 0, end: 3 }]); + expect(findSearchResults('Foo', 'o')).toEqual([ + { start: 1, end: 2 }, + { start: 2, end: 3 }, + ]); + expect(findSearchResults('FooBarBaz', 'Bar')).toEqual([ + { start: 3, end: 6 }, + ]); + expect(findSearchResults('Foo Bar Baz', ' ')).toEqual([ + { start: 3, end: 4 }, + { start: 7, end: 8 }, + ]); + expect(findSearchResults('FooBarBazBarFoo', 'Bar')).toEqual([ + { start: 3, end: 6 }, + { start: 9, end: 12 }, + ]); + expect(findSearchResults('FooBarBazBarFoo', 'Foo')).toEqual([ + { start: 0, end: 3 }, + { start: 12, end: 15 }, + ]); + }); + + it('should not overlap search results', () => { + expect(findSearchResults('aaa', 'aa')).toEqual([{ start: 0, end: 2 }]); + expect(findSearchResults('aaaa', 'aa')).toEqual([ + { start: 0, end: 2 }, + { start: 2, end: 4 }, + ]); + expect(findSearchResults('aaaaa', 'aa')).toEqual([ + { start: 0, end: 2 }, + { start: 2, end: 4 }, + ]); + }); +}); From 563635a0f8decf17427ff8d61b6adf6f6562b420 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 14:54:45 +0100 Subject: [PATCH 11/35] core-components: add tests for LogLine highlighting + fix and refactor Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogLine.test.tsx | 248 +++++++++++++++++- .../src/components/LogViewer/LogLine.tsx | 43 +-- 2 files changed, 272 insertions(+), 19 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/LogLine.test.tsx b/packages/core-components/src/components/LogViewer/LogLine.test.tsx index a4033f9221..ce98746b01 100644 --- a/packages/core-components/src/components/LogViewer/LogLine.test.tsx +++ b/packages/core-components/src/components/LogViewer/LogLine.test.tsx @@ -14,8 +14,12 @@ * limitations under the License. */ -import { ChunkModifiers } from './AnsiProcessor'; -import { findSearchResults, getModifierClasses } from './LogLine'; +import { AnsiLine, ChunkModifiers } from './AnsiProcessor'; +import { + calculateHighlightedChunks, + findSearchResults, + getModifierClasses, +} from './LogLine'; describe('getModifierClasses', () => { const classes = { @@ -110,3 +114,243 @@ describe('findSearchResults', () => { ]); }); }); + +describe('calculateHighlightedChunks', () => { + it('should pass through chunks if there are no results', () => { + const chunks = [{ text: 'Foo', modifiers: {} }]; + const line = new AnsiLine(0, chunks); + expect(calculateHighlightedChunks(line, 'bar')).toBe(chunks); + }); + + it('should highlight one result from plain text', () => { + const line = new AnsiLine(0, [{ text: 'FooBarBaz', modifiers: {} }]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Foo', + modifiers: {}, + highlight: true, + }, + { + text: 'BarBaz', + modifiers: {}, + }, + ]); + expect(calculateHighlightedChunks(line, 'bar')).toEqual([ + { + text: 'Foo', + modifiers: {}, + }, + { + text: 'Bar', + modifiers: {}, + highlight: true, + }, + { + text: 'Baz', + modifiers: {}, + }, + ]); + expect(calculateHighlightedChunks(line, 'baz')).toEqual([ + { + text: 'FooBar', + modifiers: {}, + }, + { + text: 'Baz', + modifiers: {}, + highlight: true, + }, + ]); + }); + + it('should highlight multiple results from plain text', () => { + const line = new AnsiLine(0, [ + { text: 'FooBarBazBazBarFoo', modifiers: {} }, + ]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Foo', + modifiers: {}, + highlight: true, + }, + { + text: 'BarBazBazBar', + modifiers: {}, + }, + { + text: 'Foo', + modifiers: {}, + highlight: true, + }, + ]); + expect(calculateHighlightedChunks(line, 'bar')).toEqual([ + { + text: 'Foo', + modifiers: {}, + }, + { + text: 'Bar', + modifiers: {}, + highlight: true, + }, + { + text: 'BazBaz', + modifiers: {}, + }, + { + text: 'Bar', + modifiers: {}, + highlight: true, + }, + { + text: 'Foo', + modifiers: {}, + }, + ]); + expect(calculateHighlightedChunks(line, 'baz')).toEqual([ + { + text: 'FooBar', + modifiers: {}, + }, + { + text: 'Baz', + modifiers: {}, + highlight: true, + }, + { + text: 'Baz', + modifiers: {}, + highlight: true, + }, + { + text: 'BarFoo', + modifiers: {}, + }, + ]); + }); + + it('should forward modifiers to result', () => { + const line = new AnsiLine(0, [ + { text: 'FooBarBazBazBarFoo', modifiers: { bold: true } }, + ]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Foo', + modifiers: { bold: true }, + highlight: true, + }, + { + text: 'BarBazBazBar', + modifiers: { bold: true }, + }, + { + text: 'Foo', + modifiers: { bold: true }, + highlight: true, + }, + ]); + }); + + it('should highlight full chunks', () => { + const line = new AnsiLine(0, [ + { text: 'Foo', modifiers: { bold: true } }, + { text: 'BarBaz', modifiers: { bold: true } }, + { text: 'BazBar', modifiers: { italic: true } }, + { text: 'Foo', modifiers: { italic: true } }, + ]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Foo', + modifiers: { bold: true }, + highlight: true, + }, + { + text: 'BarBaz', + modifiers: { bold: true }, + }, + { + text: 'BazBar', + modifiers: { italic: true }, + }, + { + text: 'Foo', + modifiers: { italic: true }, + highlight: true, + }, + ]); + }); + + it('should highlight partial chunks', () => { + const line = new AnsiLine(0, [ + { text: 'Fo', modifiers: { bold: true } }, + { text: 'oFooFo', modifiers: {} }, + { text: 'oBarBaz', modifiers: { italic: true } }, + { text: 'Foo', modifiers: { foreground: 'blue' } }, + { text: 'FooFoo', modifiers: { italic: true } }, + { text: 'F', modifiers: { bold: true } }, + { text: 'o', modifiers: {} }, + { text: 'o', modifiers: { bold: true } }, + ]); + expect(calculateHighlightedChunks(line, 'foo')).toEqual([ + { + text: 'Fo', + modifiers: { bold: true }, + highlight: true, + }, + { + text: 'o', + modifiers: {}, + highlight: true, + }, + { + text: 'Foo', + modifiers: {}, + highlight: true, + }, + { + text: 'Fo', + modifiers: {}, + highlight: true, + }, + { + text: 'o', + modifiers: { italic: true }, + highlight: true, + }, + { + text: 'BarBaz', + modifiers: { italic: true }, + }, + { + text: 'Foo', + modifiers: { foreground: 'blue' }, + highlight: true, + }, + { + text: 'Foo', + modifiers: { italic: true }, + highlight: true, + }, + { + text: 'Foo', + modifiers: { italic: true }, + highlight: true, + }, + { + text: 'F', + modifiers: { bold: true }, + highlight: true, + }, + { + text: 'o', + modifiers: {}, + highlight: true, + }, + { + text: 'o', + modifiers: { bold: true }, + highlight: true, + }, + ]); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/LogLine.tsx b/packages/core-components/src/components/LogViewer/LogLine.tsx index 66197e9865..315ecb0222 100644 --- a/packages/core-components/src/components/LogViewer/LogLine.tsx +++ b/packages/core-components/src/components/LogViewer/LogLine.tsx @@ -82,45 +82,54 @@ export function calculateHighlightedChunks( const chunks = new Array(); - let chunkOffset = 0; + let lineOffset = 0; let nextResult = results.shift(); for (const chunk of line.chunks) { const { text, modifiers } = chunk; - if (!nextResult || chunkOffset + text.length < nextResult.start) { + if (!nextResult || lineOffset + text.length < nextResult.start) { chunks.push(chunk); - chunkOffset += text.length; + lineOffset += text.length; continue; } let localOffset = 0; while (nextResult) { - let localStart = nextResult.start - chunkOffset; - if (localStart < 0) { - localStart = 0; + const localStart = Math.max(nextResult.start - lineOffset, 0); + if (localStart > text.length) { + break; // The next result is not in this chunk } - const localEnd = nextResult.end - chunkOffset; - const beforeMatch = text.slice(localOffset, localStart); - const match = text.slice(localStart, localEnd); - if (beforeMatch) { - chunks.push({ text: beforeMatch, modifiers }); + const localEnd = Math.min(nextResult.end - lineOffset, text.length); + + const hasTextBeforeResult = localStart > localOffset; + if (hasTextBeforeResult) { + chunks.push({ text: text.slice(localOffset, localStart), modifiers }); + } + const hasResultText = localEnd > localStart; + if (hasResultText) { + chunks.push({ + modifiers, + highlight: true, + text: text.slice(localStart, localEnd), + }); } - chunks.push({ text: match, modifiers, highlight: true }); - localOffset = localStart + match.length; + localOffset = localEnd; - if (match.length === searchText.length) { + const foundCompleteResult = nextResult.end - lineOffset === localEnd; + if (foundCompleteResult) { nextResult = results.shift(); } else { - break; + break; // The rest of the result is in the following chunks } } - if (localOffset < text.length) { + const hasTextAfterResult = localOffset < text.length; + if (hasTextAfterResult) { chunks.push({ text: text.slice(localOffset), modifiers }); } - chunkOffset += text.length; + lineOffset += text.length; } return chunks; From 155da5097840d32266ccafea00f4466b9de2923a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 15:59:12 +0100 Subject: [PATCH 12/35] core-components: make LogViewer filter scroll to match instead of filter Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogViewer.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index d6722edbcd..1b7b7c2c38 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; import { AnsiProcessor } from './AnsiProcessor'; @@ -49,6 +49,7 @@ export interface ChunkModifiers { export function LogViewer(props: LogViewerProps) { const { noLineNumbers } = props; + const listRef = useRef(null); const classes = useStyles(); const [selectedLine, setSelectedLine] = useState(); const [searchInput, setSearchInput] = useState(''); @@ -85,6 +86,15 @@ export function LogViewer(props: LogViewerProps) { return lines.filter(line => line.text.includes(searchText)); }, [lines, searchText]); + const [foundLine] = filteredLines; + const scrollToLineNumber = foundLine?.lineNumber; + + useEffect(() => { + if (scrollToLineNumber !== undefined && listRef.current) { + listRef.current.scrollToItem(scrollToLineNumber - 1, 'center'); + } + }, [scrollToLineNumber]); + return ( {({ height, width }) => ( @@ -99,12 +109,13 @@ export function LogViewer(props: LogViewerProps) { /> {({ index, style, data }) => { const line = data[index]; From 1407c0085a520eac3f68154aef81d58ccd27660f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 17:27:33 +0100 Subject: [PATCH 13/35] core-components: more advanced LogViewer filter controls Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogViewer.tsx | 199 +++++++++++++----- 1 file changed, 145 insertions(+), 54 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index 1b7b7c2c38..7ff24acda5 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -17,15 +17,20 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; -import { AnsiProcessor } from './AnsiProcessor'; +import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import FilterListIcon from '@material-ui/icons/FilterList'; +import { AnsiLine, AnsiProcessor } from './AnsiProcessor'; import { HEADER_SIZE, useStyles } from './styles'; import clsx from 'clsx'; import TextField from '@material-ui/core/TextField'; import { LogLine } from './LogLine'; +import { useToggle } from 'react-use'; export interface LogViewerProps { text: string; - noLineNumbers?: boolean; } export type AnsiColor = @@ -47,11 +52,117 @@ export interface ChunkModifiers { underline?: boolean; } +function applySearchFilter(lines: AnsiLine[], searchText: string) { + if (!searchText) { + return { lines }; + } + + const matchingLines = []; + const searchResults = []; + for (const line of lines) { + if (line.text.includes(searchText)) { + matchingLines.push(line); + + let offset = 0; + let lineResultIndex = 0; + for (;;) { + const start = line.text.indexOf(searchText, offset); + if (start === -1) { + break; + } + searchResults.push({ + lineNumber: line.lineNumber, + lineIndex: lineResultIndex++, + }); + offset = start + searchText.length; + } + } + } + + return { + lines: matchingLines, + results: searchResults, + }; +} + +function LogViewerControls(props: { + search: string; + onSearchChange: (search: string) => void; + resultIndex: number | undefined; + resultCount: number | undefined; + onResultIndexChange: (index: number) => void; + shouldFilter: boolean; + onToggleShouldFilter: () => void; +}) { + const { resultCount, onResultIndexChange, onToggleShouldFilter } = props; + const resultIndex = props.resultIndex ?? 0; + + const increment = () => { + if (resultCount !== undefined) { + const next = resultIndex + 1; + onResultIndexChange(next >= resultCount ? 0 : next); + } + }; + + const decrement = () => { + if (resultCount !== undefined) { + const next = resultIndex - 1; + onResultIndexChange(next < 0 ? resultCount - 1 : next); + } + }; + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + if (event.metaKey || event.ctrlKey || event.altKey) { + onToggleShouldFilter(); + } else if (event.shiftKey) { + decrement(); + } else { + increment(); + } + } + }; + + return ( + <> + {resultCount !== undefined && ( + <> + + + + + {Math.min(resultIndex + 1, resultCount)}/{resultCount} + + + + + + )} + props.onSearchChange(e.target.value)} + /> + + {props.shouldFilter ? ( + + ) : ( + + )} + + + ); +} + export function LogViewer(props: LogViewerProps) { - const { noLineNumbers } = props; - const listRef = useRef(null); const classes = useStyles(); + const listRef = useRef(null); const [selectedLine, setSelectedLine] = useState(); + const [resultIndex, setResultIndex] = useState(); + const [shouldFilter, toggleShouldFilter] = useToggle(false); const [searchInput, setSearchInput] = useState(''); const searchText = searchInput.toLocaleLowerCase('en-US'); @@ -59,53 +170,35 @@ export function LogViewer(props: LogViewerProps) { const processor = useMemo(() => new AnsiProcessor(), []); const lines = processor.process(props.text); - const filteredLines = useMemo(() => { - if (!searchText) { - return lines; - } - const matchingLines = []; - const searchResults = []; - for (const line of lines) { - if (line.text.includes(searchText)) { - matchingLines.push(line); + const filter = useMemo( + () => applySearchFilter(lines, searchText), + [lines, searchText], + ); - const lineResults = []; - let offset = 0; - for (;;) { - const start = line.text.indexOf(searchText, offset); - if (start === -1) { - break; - } - const end = start + searchText.length; - lineResults.push({ start, end }); - offset = end; - } - searchResults.push(lineResults); - } - } - return lines.filter(line => line.text.includes(searchText)); - }, [lines, searchText]); + const searchResult = filter.results?.[resultIndex ?? 0]; + const searchResultLine = searchResult?.lineNumber; - const [foundLine] = filteredLines; - const scrollToLineNumber = foundLine?.lineNumber; + const displayLines = shouldFilter ? filter.lines : lines; useEffect(() => { - if (scrollToLineNumber !== undefined && listRef.current) { - listRef.current.scrollToItem(scrollToLineNumber - 1, 'center'); + if (searchResultLine !== undefined && listRef.current) { + listRef.current.scrollToItem(searchResultLine - 1, 'center'); } - }, [scrollToLineNumber]); + }, [searchResultLine]); return ( {({ height, width }) => (
- setSearchInput(e.target.value)} +
{({ index, style, data }) => { const line = data[index]; @@ -127,18 +220,16 @@ export function LogViewer(props: LogViewerProps) { [classes.lineSelected]: selectedLine === lineNumber, })} > - {!noLineNumbers && ( - setSelectedLine(lineNumber)} - onKeyPress={() => setSelectedLine(lineNumber)} - > - {lineNumber} - - )} + setSelectedLine(lineNumber)} + onKeyPress={() => setSelectedLine(lineNumber)} + > + {lineNumber} + Date: Sun, 5 Dec 2021 17:45:06 +0100 Subject: [PATCH 14/35] core-components: highlight individual LogViewer search results Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogLine.test.tsx | 48 +++++++++---------- .../src/components/LogViewer/LogLine.tsx | 33 ++++++++----- .../src/components/LogViewer/LogViewer.tsx | 5 ++ .../src/components/LogViewer/styles.ts | 5 +- 4 files changed, 55 insertions(+), 36 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/LogLine.test.tsx b/packages/core-components/src/components/LogViewer/LogLine.test.tsx index ce98746b01..c5fd6e39ca 100644 --- a/packages/core-components/src/components/LogViewer/LogLine.test.tsx +++ b/packages/core-components/src/components/LogViewer/LogLine.test.tsx @@ -128,7 +128,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Foo', modifiers: {}, - highlight: true, + highlight: 0, }, { text: 'BarBaz', @@ -143,7 +143,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Bar', modifiers: {}, - highlight: true, + highlight: 0, }, { text: 'Baz', @@ -158,7 +158,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Baz', modifiers: {}, - highlight: true, + highlight: 0, }, ]); }); @@ -171,7 +171,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Foo', modifiers: {}, - highlight: true, + highlight: 0, }, { text: 'BarBazBazBar', @@ -180,7 +180,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Foo', modifiers: {}, - highlight: true, + highlight: 1, }, ]); expect(calculateHighlightedChunks(line, 'bar')).toEqual([ @@ -191,7 +191,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Bar', modifiers: {}, - highlight: true, + highlight: 0, }, { text: 'BazBaz', @@ -200,7 +200,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Bar', modifiers: {}, - highlight: true, + highlight: 1, }, { text: 'Foo', @@ -215,12 +215,12 @@ describe('calculateHighlightedChunks', () => { { text: 'Baz', modifiers: {}, - highlight: true, + highlight: 0, }, { text: 'Baz', modifiers: {}, - highlight: true, + highlight: 1, }, { text: 'BarFoo', @@ -237,7 +237,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Foo', modifiers: { bold: true }, - highlight: true, + highlight: 0, }, { text: 'BarBazBazBar', @@ -246,7 +246,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Foo', modifiers: { bold: true }, - highlight: true, + highlight: 1, }, ]); }); @@ -262,7 +262,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Foo', modifiers: { bold: true }, - highlight: true, + highlight: 0, }, { text: 'BarBaz', @@ -275,7 +275,7 @@ describe('calculateHighlightedChunks', () => { { text: 'Foo', modifiers: { italic: true }, - highlight: true, + highlight: 1, }, ]); }); @@ -295,27 +295,27 @@ describe('calculateHighlightedChunks', () => { { text: 'Fo', modifiers: { bold: true }, - highlight: true, + highlight: 0, }, { text: 'o', modifiers: {}, - highlight: true, + highlight: 0, }, { text: 'Foo', modifiers: {}, - highlight: true, + highlight: 1, }, { text: 'Fo', modifiers: {}, - highlight: true, + highlight: 2, }, { text: 'o', modifiers: { italic: true }, - highlight: true, + highlight: 2, }, { text: 'BarBaz', @@ -324,32 +324,32 @@ describe('calculateHighlightedChunks', () => { { text: 'Foo', modifiers: { foreground: 'blue' }, - highlight: true, + highlight: 3, }, { text: 'Foo', modifiers: { italic: true }, - highlight: true, + highlight: 4, }, { text: 'Foo', modifiers: { italic: true }, - highlight: true, + highlight: 5, }, { text: 'F', modifiers: { bold: true }, - highlight: true, + highlight: 6, }, { text: 'o', modifiers: {}, - highlight: true, + highlight: 6, }, { text: 'o', modifiers: { bold: true }, - highlight: true, + highlight: 6, }, ]); }); diff --git a/packages/core-components/src/components/LogViewer/LogLine.tsx b/packages/core-components/src/components/LogViewer/LogLine.tsx index 315ecb0222..f1296982b0 100644 --- a/packages/core-components/src/components/LogViewer/LogLine.tsx +++ b/packages/core-components/src/components/LogViewer/LogLine.tsx @@ -68,7 +68,7 @@ export function findSearchResults(text: string, searchText: string) { } export interface HighlightAnsiChunk extends AnsiChunk { - highlight?: boolean; + highlight?: number; } export function calculateHighlightedChunks( @@ -83,23 +83,24 @@ export function calculateHighlightedChunks( const chunks = new Array(); let lineOffset = 0; - let nextResult = results.shift(); + let resultIndex = 0; + let result = results[resultIndex]; for (const chunk of line.chunks) { const { text, modifiers } = chunk; - if (!nextResult || lineOffset + text.length < nextResult.start) { + if (!result || lineOffset + text.length < result.start) { chunks.push(chunk); lineOffset += text.length; continue; } let localOffset = 0; - while (nextResult) { - const localStart = Math.max(nextResult.start - lineOffset, 0); + while (result) { + const localStart = Math.max(result.start - lineOffset, 0); if (localStart > text.length) { break; // The next result is not in this chunk } - const localEnd = Math.min(nextResult.end - lineOffset, text.length); + const localEnd = Math.min(result.end - lineOffset, text.length); const hasTextBeforeResult = localStart > localOffset; if (hasTextBeforeResult) { @@ -109,16 +110,17 @@ export function calculateHighlightedChunks( if (hasResultText) { chunks.push({ modifiers, - highlight: true, + highlight: resultIndex, text: text.slice(localStart, localEnd), }); } localOffset = localEnd; - const foundCompleteResult = nextResult.end - lineOffset === localEnd; + const foundCompleteResult = result.end - lineOffset === localEnd; if (foundCompleteResult) { - nextResult = results.shift(); + resultIndex += 1; + result = results[resultIndex]; } else { break; // The rest of the result is in the following chunks } @@ -139,9 +141,15 @@ export interface LogLineProps { line: AnsiLine; classes: ReturnType; searchText: string; + highlightResultIndex?: number; } -export function LogLine({ line, classes, searchText }: LogLineProps) { +export function LogLine({ + line, + classes, + searchText, + highlightResultIndex, +}: LogLineProps) { const chunks = calculateHighlightedChunks(line, searchText); const elements = chunks.map(({ text, modifiers, highlight }, index) => ( @@ -149,7 +157,10 @@ export function LogLine({ line, classes, searchText }: LogLineProps) { key={index} className={clsx( getModifierClasses(classes, modifiers), - highlight && classes.textHighlight, + highlight !== undefined && + (highlight === highlightResultIndex + ? classes.textSelectedHighlight + : classes.textHighlight), )} > {text} diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index 7ff24acda5..f974f9bb90 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -234,6 +234,11 @@ export function LogViewer(props: LogViewerProps) { line={line} classes={classes} searchText={searchText} + highlightResultIndex={ + searchResultLine === lineNumber + ? searchResult!.lineIndex + : undefined + } />
); diff --git a/packages/core-components/src/components/LogViewer/styles.ts b/packages/core-components/src/components/LogViewer/styles.ts index fd66571915..25db095645 100644 --- a/packages/core-components/src/components/LogViewer/styles.ts +++ b/packages/core-components/src/components/LogViewer/styles.ts @@ -55,7 +55,10 @@ export const useStyles = makeStyles(theme => ({ cursor: 'pointer', }, textHighlight: { - background: alpha(theme.palette.primary.main, 0.3), + background: alpha(theme.palette.info.main, 0.15), + }, + textSelectedHighlight: { + background: alpha(theme.palette.info.main, 0.4), }, modifierBold: { fontWeight: theme.typography.fontWeightBold, From 8a71f91bf270a240280bfd15991ea461fba52bcc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 18:00:37 +0100 Subject: [PATCH 15/35] core-components: split out LogViewerControls Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogViewer.tsx | 79 +-------------- .../LogViewer/LogViewerControls.tsx | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+), 78 deletions(-) create mode 100644 packages/core-components/src/components/LogViewer/LogViewerControls.tsx diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index f974f9bb90..aaf9949a74 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -17,16 +17,11 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; -import Typography from '@material-ui/core/Typography'; -import IconButton from '@material-ui/core/IconButton'; -import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; -import ChevronRightIcon from '@material-ui/icons/ChevronRight'; -import FilterListIcon from '@material-ui/icons/FilterList'; import { AnsiLine, AnsiProcessor } from './AnsiProcessor'; import { HEADER_SIZE, useStyles } from './styles'; import clsx from 'clsx'; -import TextField from '@material-ui/core/TextField'; import { LogLine } from './LogLine'; +import { LogViewerControls } from './LogViewerControls'; import { useToggle } from 'react-use'; export interface LogViewerProps { @@ -85,78 +80,6 @@ function applySearchFilter(lines: AnsiLine[], searchText: string) { }; } -function LogViewerControls(props: { - search: string; - onSearchChange: (search: string) => void; - resultIndex: number | undefined; - resultCount: number | undefined; - onResultIndexChange: (index: number) => void; - shouldFilter: boolean; - onToggleShouldFilter: () => void; -}) { - const { resultCount, onResultIndexChange, onToggleShouldFilter } = props; - const resultIndex = props.resultIndex ?? 0; - - const increment = () => { - if (resultCount !== undefined) { - const next = resultIndex + 1; - onResultIndexChange(next >= resultCount ? 0 : next); - } - }; - - const decrement = () => { - if (resultCount !== undefined) { - const next = resultIndex - 1; - onResultIndexChange(next < 0 ? resultCount - 1 : next); - } - }; - - const handleKeyPress = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - if (event.metaKey || event.ctrlKey || event.altKey) { - onToggleShouldFilter(); - } else if (event.shiftKey) { - decrement(); - } else { - increment(); - } - } - }; - - return ( - <> - {resultCount !== undefined && ( - <> - - - - - {Math.min(resultIndex + 1, resultCount)}/{resultCount} - - - - - - )} - props.onSearchChange(e.target.value)} - /> - - {props.shouldFilter ? ( - - ) : ( - - )} - - - ); -} - export function LogViewer(props: LogViewerProps) { const classes = useStyles(); const listRef = useRef(null); diff --git a/packages/core-components/src/components/LogViewer/LogViewerControls.tsx b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx new file mode 100644 index 0000000000..f7c06edd05 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import IconButton from '@material-ui/core/IconButton'; +import TextField from '@material-ui/core/TextField'; +import Typography from '@material-ui/core/Typography'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import FilterListIcon from '@material-ui/icons/FilterList'; + +export interface LogViewerControlsProps { + search: string; + onSearchChange: (search: string) => void; + resultIndex: number | undefined; + resultCount: number | undefined; + onResultIndexChange: (index: number) => void; + shouldFilter: boolean; + onToggleShouldFilter: () => void; +} + +export function LogViewerControls(props: LogViewerControlsProps) { + const { resultCount, onResultIndexChange, onToggleShouldFilter } = props; + const resultIndex = props.resultIndex ?? 0; + + const increment = () => { + if (resultCount !== undefined) { + const next = resultIndex + 1; + onResultIndexChange(next >= resultCount ? 0 : next); + } + }; + + const decrement = () => { + if (resultCount !== undefined) { + const next = resultIndex - 1; + onResultIndexChange(next < 0 ? resultCount - 1 : next); + } + }; + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + if (event.metaKey || event.ctrlKey || event.altKey) { + onToggleShouldFilter(); + } else if (event.shiftKey) { + decrement(); + } else { + increment(); + } + } + }; + + return ( + <> + {resultCount !== undefined && ( + <> + + + + + {Math.min(resultIndex + 1, resultCount)}/{resultCount} + + + + + + )} + props.onSearchChange(e.target.value)} + /> + + {props.shouldFilter ? ( + + ) : ( + + )} + + + ); +} From b38e4c636beebb0410097c8da6a451ca87b85b0f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 18:01:20 +0100 Subject: [PATCH 16/35] core-components: remove duplicate types from LogViewer Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogViewer.tsx | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index aaf9949a74..24f10ca5f6 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -28,25 +28,6 @@ export interface LogViewerProps { text: string; } -export type AnsiColor = - | 'black' - | 'red' - | 'green' - | 'yellow' - | 'blue' - | 'magenta' - | 'cyan' - | 'white' - | 'grey'; - -export interface ChunkModifiers { - foreground?: AnsiColor; - background?: AnsiColor; - bold?: boolean; - italic?: boolean; - underline?: boolean; -} - function applySearchFilter(lines: AnsiLine[], searchText: string) { if (!searchText) { return { lines }; From ed042d205692b3327937c0f1fc4de3ac3a6559f5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 18:39:11 +0100 Subject: [PATCH 17/35] core-components: memo LogViewer LogLines Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogLine.tsx | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/LogLine.tsx b/packages/core-components/src/components/LogViewer/LogLine.tsx index f1296982b0..c14df4a45a 100644 --- a/packages/core-components/src/components/LogViewer/LogLine.tsx +++ b/packages/core-components/src/components/LogViewer/LogLine.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { AnsiChunk, AnsiLine, ChunkModifiers } from './AnsiProcessor'; import startCase from 'lodash/startCase'; import clsx from 'clsx'; @@ -150,22 +150,29 @@ export function LogLine({ searchText, highlightResultIndex, }: LogLineProps) { - const chunks = calculateHighlightedChunks(line, searchText); + const chunks = useMemo( + () => calculateHighlightedChunks(line, searchText), + [line, searchText], + ); - const elements = chunks.map(({ text, modifiers, highlight }, index) => ( - - {text} - - )); + const elements = useMemo( + () => + chunks.map(({ text, modifiers, highlight }, index) => ( + + {text} + + )), + [chunks, highlightResultIndex, classes], + ); return <>{elements}; } From e708a0109f23fbc9373880a04dae5e6b05119f9b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 18:38:44 +0100 Subject: [PATCH 18/35] core-components: refactor out LogViewer search Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogViewer.tsx | 77 +++----------- .../LogViewer/LogViewerControls.tsx | 25 ++--- .../LogViewer/useLogViewerSearch.tsx | 100 ++++++++++++++++++ 3 files changed, 121 insertions(+), 81 deletions(-) create mode 100644 packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index 24f10ca5f6..2ce1e5f04f 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -17,102 +17,49 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; -import { AnsiLine, AnsiProcessor } from './AnsiProcessor'; +import { AnsiProcessor } from './AnsiProcessor'; import { HEADER_SIZE, useStyles } from './styles'; import clsx from 'clsx'; import { LogLine } from './LogLine'; import { LogViewerControls } from './LogViewerControls'; -import { useToggle } from 'react-use'; +import { useLogViewerSearch } from './useLogViewerSearch'; export interface LogViewerProps { text: string; } -function applySearchFilter(lines: AnsiLine[], searchText: string) { - if (!searchText) { - return { lines }; - } - - const matchingLines = []; - const searchResults = []; - for (const line of lines) { - if (line.text.includes(searchText)) { - matchingLines.push(line); - - let offset = 0; - let lineResultIndex = 0; - for (;;) { - const start = line.text.indexOf(searchText, offset); - if (start === -1) { - break; - } - searchResults.push({ - lineNumber: line.lineNumber, - lineIndex: lineResultIndex++, - }); - offset = start + searchText.length; - } - } - } - - return { - lines: matchingLines, - results: searchResults, - }; -} - export function LogViewer(props: LogViewerProps) { const classes = useStyles(); const listRef = useRef(null); const [selectedLine, setSelectedLine] = useState(); - const [resultIndex, setResultIndex] = useState(); - const [shouldFilter, toggleShouldFilter] = useToggle(false); - const [searchInput, setSearchInput] = useState(''); - const searchText = searchInput.toLocaleLowerCase('en-US'); // The processor keeps state that optimizes appending to the text const processor = useMemo(() => new AnsiProcessor(), []); const lines = processor.process(props.text); - const filter = useMemo( - () => applySearchFilter(lines, searchText), - [lines, searchText], - ); - - const searchResult = filter.results?.[resultIndex ?? 0]; - const searchResultLine = searchResult?.lineNumber; - - const displayLines = shouldFilter ? filter.lines : lines; + const search = useLogViewerSearch(lines); useEffect(() => { - if (searchResultLine !== undefined && listRef.current) { - listRef.current.scrollToItem(searchResultLine - 1, 'center'); + if (search.resultLine !== undefined && listRef.current) { + listRef.current.scrollToItem(search.resultLine - 1, 'center'); } - }, [searchResultLine]); + }, [search.resultLine]); return ( {({ height, width }) => (
- +
{({ index, style, data }) => { const line = data[index]; @@ -137,10 +84,10 @@ export function LogViewer(props: LogViewerProps) { diff --git a/packages/core-components/src/components/LogViewer/LogViewerControls.tsx b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx index f7c06edd05..6d55e6f76f 100644 --- a/packages/core-components/src/components/LogViewer/LogViewerControls.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx @@ -21,39 +21,32 @@ import Typography from '@material-ui/core/Typography'; import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import FilterListIcon from '@material-ui/icons/FilterList'; +import { LogViewerSearch } from './useLogViewerSearch'; -export interface LogViewerControlsProps { - search: string; - onSearchChange: (search: string) => void; - resultIndex: number | undefined; - resultCount: number | undefined; - onResultIndexChange: (index: number) => void; - shouldFilter: boolean; - onToggleShouldFilter: () => void; -} +export interface LogViewerControlsProps extends LogViewerSearch {} export function LogViewerControls(props: LogViewerControlsProps) { - const { resultCount, onResultIndexChange, onToggleShouldFilter } = props; + const { resultCount, setResultIndex, toggleShouldFilter } = props; const resultIndex = props.resultIndex ?? 0; const increment = () => { if (resultCount !== undefined) { const next = resultIndex + 1; - onResultIndexChange(next >= resultCount ? 0 : next); + setResultIndex(next >= resultCount ? 0 : next); } }; const decrement = () => { if (resultCount !== undefined) { const next = resultIndex - 1; - onResultIndexChange(next < 0 ? resultCount - 1 : next); + setResultIndex(next < 0 ? resultCount - 1 : next); } }; const handleKeyPress = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { if (event.metaKey || event.ctrlKey || event.altKey) { - onToggleShouldFilter(); + toggleShouldFilter(); } else if (event.shiftKey) { decrement(); } else { @@ -81,11 +74,11 @@ export function LogViewerControls(props: LogViewerControlsProps) { size="small" variant="standard" placeholder="Search" - value={props.search} + value={props.searchInput} onKeyPress={handleKeyPress} - onChange={e => props.onSearchChange(e.target.value)} + onChange={e => props.setSearchInput(e.target.value)} /> - + {props.shouldFilter ? ( ) : ( diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx new file mode 100644 index 0000000000..4e93178c20 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useMemo, useState } from 'react'; +import { useToggle } from 'react-use'; +import { AnsiLine } from './AnsiProcessor'; + +export function applySearchFilter(lines: AnsiLine[], searchText: string) { + if (!searchText) { + return { lines }; + } + + const matchingLines = []; + const searchResults = []; + for (const line of lines) { + if (line.text.includes(searchText)) { + matchingLines.push(line); + + let offset = 0; + let lineResultIndex = 0; + for (;;) { + const start = line.text.indexOf(searchText, offset); + if (start === -1) { + break; + } + searchResults.push({ + lineNumber: line.lineNumber, + lineIndex: lineResultIndex++, + }); + offset = start + searchText.length; + } + } + } + + return { + lines: matchingLines, + results: searchResults, + }; +} + +export interface LogViewerSearch { + lines: AnsiLine[]; + + searchText: string; + searchInput: string; + setSearchInput: (searchInput: string) => void; + + shouldFilter: boolean; + toggleShouldFilter: () => void; + + resultIndex: number | undefined; + resultCount: number | undefined; + setResultIndex: (number: number) => void; + + resultLine: number | undefined; + resultLineIndex: number | undefined; +} + +export function useLogViewerSearch(lines: AnsiLine[]): LogViewerSearch { + const [searchInput, setSearchInput] = useState(''); + const searchText = searchInput.toLocaleLowerCase('en-US'); + + const [resultIndex, setResultIndex] = useState(); + + const [shouldFilter, toggleShouldFilter] = useToggle(false); + + const filter = useMemo( + () => applySearchFilter(lines, searchText), + [lines, searchText], + ); + + const searchResult = filter.results?.[resultIndex ?? 0]; + + return { + lines: shouldFilter ? filter.lines : lines, + searchText, + searchInput, + setSearchInput, + shouldFilter, + toggleShouldFilter, + resultIndex, + resultCount: filter.results?.length, + setResultIndex, + resultLine: searchResult?.lineNumber, + resultLineIndex: searchResult?.lineIndex, + }; +} From cb3246ef89247d51c8d2b35c669da1b2061a7e8b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 18:58:24 +0100 Subject: [PATCH 19/35] core-components: lazy load log viewer Signed-off-by: Patrik Oldsberg --- .../components/LogViewer/LazyLogViewer.tsx | 32 +++++++++++++++++++ .../src/components/LogViewer/index.ts | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/core-components/src/components/LogViewer/LazyLogViewer.tsx diff --git a/packages/core-components/src/components/LogViewer/LazyLogViewer.tsx b/packages/core-components/src/components/LogViewer/LazyLogViewer.tsx new file mode 100644 index 0000000000..fb765bbbc4 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/LazyLogViewer.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { lazy, Suspense } from 'react'; +import { useApp } from '@backstage/core-plugin-api'; +import { LogViewerProps } from './LogViewer'; + +const LogViewer = lazy(() => + import('./LogViewer').then(m => ({ default: m.LogViewer })), +); + +export function LazyLogViewer(props: LogViewerProps) { + const { Progress } = useApp().getComponents(); + return ( + }> + + + ); +} diff --git a/packages/core-components/src/components/LogViewer/index.ts b/packages/core-components/src/components/LogViewer/index.ts index 839f34f81e..aba2e8ea16 100644 --- a/packages/core-components/src/components/LogViewer/index.ts +++ b/packages/core-components/src/components/LogViewer/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { LogViewer } from './LogViewer'; +export { LazyLogViewer as LogViewer } from './LazyLogViewer'; export type { LogViewerProps } from './LogViewer'; From d06c6a4cfe58a399c2a69cfe39450b4b773f54a7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 19:45:10 +0100 Subject: [PATCH 20/35] core-components: proper LogViewer selection and copy Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/LogViewer.tsx | 30 ++++++-- .../src/components/LogViewer/styles.ts | 6 ++ .../LogViewer/useLogViewerSelection.tsx | 70 +++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index 2ce1e5f04f..b78764e86f 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -14,7 +14,9 @@ * limitations under the License. */ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; +import IconButton from '@material-ui/core/IconButton'; +import CopyIcon from '@material-ui/icons/FileCopy'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; import { AnsiProcessor } from './AnsiProcessor'; @@ -23,6 +25,7 @@ import clsx from 'clsx'; import { LogLine } from './LogLine'; import { LogViewerControls } from './LogViewerControls'; import { useLogViewerSearch } from './useLogViewerSearch'; +import { useLogViewerSelection } from './useLogViewerSelection'; export interface LogViewerProps { text: string; @@ -31,13 +34,13 @@ export interface LogViewerProps { export function LogViewer(props: LogViewerProps) { const classes = useStyles(); const listRef = useRef(null); - const [selectedLine, setSelectedLine] = useState(); // The processor keeps state that optimizes appending to the text const processor = useMemo(() => new AnsiProcessor(), []); const lines = processor.process(props.text); const search = useLogViewerSearch(lines); + const selection = useLogViewerSelection(lines); useEffect(() => { if (search.resultLine !== undefined && listRef.current) { @@ -45,6 +48,14 @@ export function LogViewer(props: LogViewerProps) { } }, [search.resultLine]); + const handleSelectLine = ( + line: number, + event: { shiftKey: boolean; preventDefault: () => void }, + ) => { + event.preventDefault(); + selection.setSelection(line, event.shiftKey); + }; + return ( {({ height, width }) => ( @@ -68,16 +79,25 @@ export function LogViewer(props: LogViewerProps) {
+ {selection.shouldShowButton(lineNumber) && ( + selection.copySelection()} + > + + + )} setSelectedLine(lineNumber)} - onKeyPress={() => setSelectedLine(lineNumber)} + onClick={event => handleSelectLine(lineNumber, event)} + onKeyPress={event => handleSelectLine(lineNumber, event)} > {lineNumber} diff --git a/packages/core-components/src/components/LogViewer/styles.ts b/packages/core-components/src/components/LogViewer/styles.ts index 25db095645..d074e56632 100644 --- a/packages/core-components/src/components/LogViewer/styles.ts +++ b/packages/core-components/src/components/LogViewer/styles.ts @@ -34,6 +34,7 @@ export const useStyles = makeStyles(theme => ({ fontSize: theme.typography.fontSize, }, line: { + position: 'relative', whiteSpace: 'pre', '&:hover': { @@ -47,6 +48,11 @@ export const useStyles = makeStyles(theme => ({ background: theme.palette.action.selected, }, }, + lineCopyButton: { + position: 'absolute', + paddingTop: 0, + paddingBottom: 0, + }, lineNumber: { display: 'inline-block', textAlign: 'end', diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx new file mode 100644 index 0000000000..cae56e5e43 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { useEffect, useState } from 'react'; +import { useCopyToClipboard } from 'react-use'; +import { AnsiLine } from './AnsiProcessor'; + +export function useLogViewerSelection(lines: AnsiLine[]) { + const errorApi = useApi(errorApiRef); + const [sel, setSelection] = useState<{ start: number; end: number }>(); + const start = sel ? Math.min(sel.start, sel.end) : undefined; + const end = sel ? Math.max(sel.start, sel.end) : undefined; + + const [{ error }, copyToClipboard] = useCopyToClipboard(); + + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + + return { + shouldShowButton(line: number) { + return start === line || end === line; + }, + isSelected(line: number) { + if (!sel) { + return false; + } + return start! <= line && line <= end!; + }, + setSelection(line: number, add: boolean) { + if (add) { + setSelection(s => + s ? { start: s.start, end: line } : { start: line, end: line }, + ); + } else { + setSelection(s => + s?.start === line && s?.end === line + ? undefined + : { start: line, end: line }, + ); + } + }, + copySelection() { + if (sel) { + const copyText = lines + .slice(sel.start - 1, sel.end) + .map(l => l.text) + .join('\n'); + copyToClipboard(copyText); + setSelection(undefined); + } + }, + }; +} From b291c3176ef66126d94135cef3f0813d1ebd4489 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 19:51:51 +0100 Subject: [PATCH 21/35] scaffolder: switch to using LogViewer Signed-off-by: Patrik Oldsberg --- .changeset/soft-shoes-check.md | 5 ++++ plugins/scaffolder/package.json | 1 - .../src/components/TaskPage/TaskPage.tsx | 26 ++++--------------- 3 files changed, 10 insertions(+), 22 deletions(-) create mode 100644 .changeset/soft-shoes-check.md diff --git a/.changeset/soft-shoes-check.md b/.changeset/soft-shoes-check.md new file mode 100644 index 0000000000..5600344cc1 --- /dev/null +++ b/.changeset/soft-shoes-check.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Switch to using `LogViewer` component from `@backstage/core-components` to display scaffolder logs. diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index ef2fa32974..8a6123b7a9 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -55,7 +55,6 @@ "lodash": "^4.17.21", "luxon": "^2.0.2", "qs": "^6.9.4", - "react-lazylog": "^4.5.2", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 11d12282d2..58ec3dfc02 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -20,7 +20,7 @@ import { Header, Lifecycle, Page, - Progress, + LogViewer, } from '@backstage/core-components'; import { BackstageTheme } from '@backstage/theme'; import { @@ -40,15 +40,13 @@ import Check from '@material-ui/icons/Check'; import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; import classNames from 'classnames'; import { DateTime, Interval } from 'luxon'; -import React, { memo, Suspense, useEffect, useMemo, useState } from 'react'; +import React, { memo, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router'; import { useInterval } from 'react-use'; import { Status, TaskOutput } from '../../types'; import { useTaskEventStream } from '../hooks/useEventStream'; import { TaskPageLinks } from './TaskPageLinks'; -const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); - // typings are wrong for this library, so fallback to not parsing types. const humanizeDuration = require('humanize-duration'); @@ -213,22 +211,6 @@ export const TaskStatusStepper = memo( }, ); -const TaskLogger = memo(({ log }: { log: string }) => { - return ( - }> -
- -
-
- ); -}); - const hasLinks = ({ entityRef, remoteUrl, links = [] }: TaskOutput): boolean => !!(entityRef || remoteUrl || links.length > 0); @@ -318,7 +300,9 @@ export const TaskPage = () => { - +
+ +
From 37d80d0a9d717b98b81cfc8fe3fe0a3c1b621c47 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 20:40:44 +0100 Subject: [PATCH 22/35] core-components: add className prop for LogViewer Signed-off-by: Patrik Oldsberg --- .../core-components/src/components/LogViewer/LogViewer.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index b78764e86f..36d25153cd 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -29,6 +29,7 @@ import { useLogViewerSelection } from './useLogViewerSelection'; export interface LogViewerProps { text: string; + className?: string; } export function LogViewer(props: LogViewerProps) { @@ -59,7 +60,10 @@ export function LogViewer(props: LogViewerProps) { return ( {({ height, width }) => ( -
+
From d90dad84b0cae18ce88c6706c04915503202e0eb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 20:44:59 +0100 Subject: [PATCH 23/35] techdocs: switch to using LogViewer Signed-off-by: Patrik Oldsberg --- .changeset/kind-ways-nail.md | 5 +++ plugins/techdocs/package.json | 1 - .../components/TechDocsBuildLogs.test.tsx | 36 +++++++++---------- .../reader/components/TechDocsBuildLogs.tsx | 24 +++++-------- 4 files changed, 30 insertions(+), 36 deletions(-) create mode 100644 .changeset/kind-ways-nail.md diff --git a/.changeset/kind-ways-nail.md b/.changeset/kind-ways-nail.md new file mode 100644 index 0000000000..14821e4f44 --- /dev/null +++ b/.changeset/kind-ways-nail.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Switch to using `LogViewer` component from `@backstage/core-components` to display build logs. diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index b048761e1c..50cabace51 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -51,7 +51,6 @@ "event-source-polyfill": "^1.0.25", "git-url-parse": "^11.6.0", "lodash": "^4.17.21", - "react-lazylog": "^4.5.2", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-text-truncate": "^0.16.0", diff --git a/plugins/techdocs/src/reader/components/TechDocsBuildLogs.test.tsx b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.test.tsx index fa82527282..ea24a3627f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsBuildLogs.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.test.tsx @@ -14,32 +14,30 @@ * limitations under the License. */ -import { render } from '@testing-library/react'; -import React from 'react'; +import React, { ReactNode } from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; import { TechDocsBuildLogs, TechDocsBuildLogsDrawerContent, } from './TechDocsBuildLogs'; -// react-lazylog is based on a react-virtualized component which doesn't -// write the content to the dom, so we mock it. -jest.mock('react-lazylog/build/LazyLog', () => { - return { - default: ({ text }: { text: string }) => { - return

{text}

; - }, - }; -}); +// The inside needs mocking to render in jsdom +jest.mock('react-virtualized-auto-sizer', () => ({ + __esModule: true, + default: (props: { + children: (size: { width: number; height: number }) => ReactNode; + }) => <>{props.children({ width: 400, height: 200 })}, +})); describe('', () => { - it('should render with button', () => { - const rendered = render(); + it('should render with button', async () => { + const rendered = await renderInTestApp(); expect(rendered.getByText(/Show Build Logs/i)).toBeInTheDocument(); expect(rendered.queryByText(/Build Details/i)).not.toBeInTheDocument(); }); - it('should open drawer', () => { - const rendered = render(); + it('should open drawer', async () => { + const rendered = await renderInTestApp(); rendered.getByText(/Show Build Logs/i).click(); expect(rendered.getByText(/Build Details/i)).toBeInTheDocument(); }); @@ -48,7 +46,7 @@ describe('', () => { describe('', () => { it('should render with empty log', async () => { const onClose = jest.fn(); - const rendered = render( + const rendered = await renderInTestApp( , ); expect(rendered.getByText(/Build Details/i)).toBeInTheDocument(); @@ -61,7 +59,7 @@ describe('', () => { it('should render logs', async () => { const onClose = jest.fn(); - const rendered = render( + const rendered = await renderInTestApp( ', () => { expect(onClose).toBeCalledTimes(0); }); - it('should call onClose', () => { + it('should call onClose', async () => { const onClose = jest.fn(); - const rendered = render( + const rendered = await renderInTestApp( , ); rendered.getByTitle('Close the drawer').click(); diff --git a/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx index 8c739a9bcf..e49d486ab8 100644 --- a/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsBuildLogs.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Progress } from '@backstage/core-components'; +import { LogViewer } from '@backstage/core-components'; import { Button, createStyles, @@ -26,9 +26,7 @@ import { Typography, } from '@material-ui/core'; import Close from '@material-ui/icons/Close'; -import React, { Suspense, useState } from 'react'; - -const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); +import React, { useState } from 'react'; const useDrawerStyles = makeStyles((theme: Theme) => createStyles({ @@ -46,6 +44,9 @@ const useDrawerStyles = makeStyles((theme: Theme) => height: '100%', overflow: 'hidden', }, + logs: { + background: theme.palette.background.default, + }, }), ); @@ -57,6 +58,8 @@ export const TechDocsBuildLogsDrawerContent = ({ onClose: () => void; }) => { const classes = useDrawerStyles(); + const logText = + buildLog.length === 0 ? 'Waiting for logs...' : buildLog.join('\n'); return ( - - }> - - + ); }; From ea8d73a7ed9b716cbd3545fd40a40f1dc7c0d529 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 20:56:44 +0100 Subject: [PATCH 24/35] core-components: reduce font size in LogViewer Signed-off-by: Patrik Oldsberg --- packages/core-components/src/components/LogViewer/styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-components/src/components/LogViewer/styles.ts b/packages/core-components/src/components/LogViewer/styles.ts index d074e56632..4f4312bdd9 100644 --- a/packages/core-components/src/components/LogViewer/styles.ts +++ b/packages/core-components/src/components/LogViewer/styles.ts @@ -31,7 +31,7 @@ export const useStyles = makeStyles(theme => ({ }, log: { fontFamily: '"Monaco", monospace', - fontSize: theme.typography.fontSize, + fontSize: theme.typography.pxToRem(12), }, line: { position: 'relative', From cbd20c46f14f16ec73724454b1a524e220d9c4f9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 20:57:31 +0100 Subject: [PATCH 25/35] github-actions: switch to using LogViewer Signed-off-by: Patrik Oldsberg --- .changeset/red-chairs-wave.md | 5 ++ plugins/github-actions/package.json | 1 - .../WorkflowRunLogs/WorkflowRunLogs.tsx | 69 +++++-------------- 3 files changed, 24 insertions(+), 51 deletions(-) create mode 100644 .changeset/red-chairs-wave.md diff --git a/.changeset/red-chairs-wave.md b/.changeset/red-chairs-wave.md new file mode 100644 index 0000000000..a052afe4b5 --- /dev/null +++ b/.changeset/red-chairs-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-github-actions': patch +--- + +Switch to using `LogViewer` component from `@backstage/core-components` to display build logs. diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 0ea8bbe9f2..ace9527598 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -44,7 +44,6 @@ "@material-ui/lab": "4.0.0-alpha.57", "@octokit/rest": "^18.5.3", "luxon": "^2.0.2", - "react-lazylog": "^4.5.3", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" diff --git a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx index 399fed9557..509e404194 100644 --- a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx +++ b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { Progress } from '@backstage/core-components'; +import { LogViewer } from '@backstage/core-components'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { @@ -32,14 +32,11 @@ import { } from '@material-ui/core'; import DescriptionIcon from '@material-ui/icons/Description'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import React, { Suspense } from 'react'; +import React from 'react'; import { useProjectName } from '../useProjectName'; import { useDownloadWorkflowRunLogs } from './useDownloadWorkflowRunLogs'; -const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); -const LinePart = React.lazy(() => import('react-lazylog/build/LinePart')); - -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles(theme => ({ button: { order: -1, marginRight: 0, @@ -53,49 +50,19 @@ const useStyles = makeStyles(() => ({ justifyContent: 'center', margin: 'auto', }, - normalLog: { + normalLogContainer: { height: '75vh', width: '100%', }, - modalLog: { + modalLogContainer: { height: '100%', width: '100%', }, + log: { + background: theme.palette.background.default, + }, })); -const DisplayLog = ({ - jobLogs, - className, -}: { - jobLogs: any; - className: string; -}) => { - return ( - }> -
- { - if ( - line.toLocaleLowerCase().includes('error') || - line.toLocaleLowerCase().includes('failed') || - line.toLocaleLowerCase().includes('failure') - ) { - return ( - - ); - } - return line; - }} - /> -
-
- ); -}; - /** * A component for Run Logs visualization. */ @@ -123,6 +90,7 @@ export const WorkflowRunLogs = ({ repo, id: runId, }); + const logText = jobLogs.value ? String(jobLogs.value) : undefined; const [open, setOpen] = React.useState(false); const handleOpen = () => { @@ -162,18 +130,19 @@ export const WorkflowRunLogs = ({ onClose={handleClose} > - +
+ +
- {jobLogs.value && ( - + {logText && ( +
+ +
)} ); From 56d04330c499ae0f237885c32c999c1e046cc773 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 21:01:00 +0100 Subject: [PATCH 26/35] circleci: switch to use LogViewer Signed-off-by: Patrik Oldsberg --- .changeset/rich-carrots-relax.md | 5 +++++ plugins/circleci/package.json | 2 -- .../lib/ActionOutput/ActionOutput.tsx | 13 +++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 .changeset/rich-carrots-relax.md diff --git a/.changeset/rich-carrots-relax.md b/.changeset/rich-carrots-relax.md new file mode 100644 index 0000000000..9f7c822883 --- /dev/null +++ b/.changeset/rich-carrots-relax.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-circleci': patch +--- + +Switch to using `LogViewer` component from `@backstage/core-components` to display action output. diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 9d94d61dcd..dfcf7b61e1 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -44,7 +44,6 @@ "humanize-duration": "^3.27.0", "lodash": "^4.17.21", "luxon": "^2.0.2", - "react-lazylog": "^4.5.2", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" @@ -63,7 +62,6 @@ "@types/humanize-duration": "^3.25.1", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "@types/react-lazylog": "^4.5.0", "cross-fetch": "^3.0.6", "msw": "^0.35.0" }, diff --git a/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx index a4c2af241f..4a4a8a45f2 100644 --- a/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx +++ b/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Progress } from '@backstage/core-components'; +import { LogViewer } from '@backstage/core-components'; import { Accordion, AccordionDetails, @@ -24,10 +24,9 @@ import { import { makeStyles } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { BuildStepAction } from 'circleci-api'; -import React, { Suspense, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { durationHumanized } from '../../../../util'; -const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); const useStyles = makeStyles({ accordionDetails: { padding: 0, @@ -85,11 +84,9 @@ export const ActionOutput = ({ {messages.length === 0 ? ( 'Nothing here...' ) : ( - }> -
- -
-
+
+ +
)} From c77def982f3095a209fa2c9e3b2cabd2b348090f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 21:05:59 +0100 Subject: [PATCH 27/35] cloudbuild: remove unnecessary lazylog dependency Signed-off-by: Patrik Oldsberg --- .changeset/cool-starfishes-press.md | 5 +++++ plugins/cloudbuild/package.json | 1 - yarn.lock | 12 ++---------- 3 files changed, 7 insertions(+), 11 deletions(-) create mode 100644 .changeset/cool-starfishes-press.md diff --git a/.changeset/cool-starfishes-press.md b/.changeset/cool-starfishes-press.md new file mode 100644 index 0000000000..6cba5c8204 --- /dev/null +++ b/.changeset/cool-starfishes-press.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cloudbuild': patch +--- + +Remove unnecessary dependency. diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 6fab2b8d35..da206280f4 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -41,7 +41,6 @@ "@material-ui/lab": "4.0.0-alpha.57", "luxon": "^2.0.2", "qs": "^6.9.4", - "react-lazylog": "^4.5.3", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" diff --git a/yarn.lock b/yarn.lock index a71567ffe8..015d5d6e79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8036,14 +8036,6 @@ dependencies: "@types/react" "*" -"@types/react-lazylog@^4.5.0": - version "4.5.1" - resolved "https://registry.npmjs.org/@types/react-lazylog/-/react-lazylog-4.5.1.tgz#babb5d814f7035b5434518769975e12f299356a8" - integrity sha512-g4yeosa1zYhu2BUJmuu2H2o0dsdRj0o8Omw3pBiVHdLHJaeYIyArvyMRR3bI/MxZxG4EaiRl8AOQ6zeM8P46jA== - dependencies: - "@types/react" "*" - immutable ">=3.8.2" - "@types/react-redux@^7.1.16": version "7.1.19" resolved "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.19.tgz#477bd0a9b01bae6d6bf809418cdfa7d3c16d4c62" @@ -17047,7 +17039,7 @@ immer@^9.0.1, immer@^9.0.6: resolved "https://registry.npmjs.org/immer/-/immer-9.0.7.tgz#b6156bd7db55db7abc73fd2fdadf4e579a701075" integrity sha512-KGllzpbamZDvOIxnmJ0jI840g7Oikx58lBPWV0hUh7dtAyZpFqqrBZdKka5GlTwMTZ1Tjc/bKKW4VSFAt6BqMA== -immutable@>=3.8.2, immutable@^3.8.2, immutable@^3.x.x: +immutable@^3.8.2, immutable@^3.x.x: version "3.8.2" resolved "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= @@ -24561,7 +24553,7 @@ react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-i resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-lazylog@^4.5.2, react-lazylog@^4.5.3: +react-lazylog@^4.5.2: version "4.5.3" resolved "https://registry.npmjs.org/react-lazylog/-/react-lazylog-4.5.3.tgz#289e24995b5599e75943556ac63f5e2c04d0001e" integrity sha512-lyov32A/4BqihgXgtNXTHCajXSXkYHPlIEmV8RbYjHIMxCFSnmtdg4kDCI3vATz7dURtiFTvrw5yonHnrS+NNg== From e83950028621608417542389b63003f97424a551 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 21:29:55 +0100 Subject: [PATCH 28/35] changesets: added changeset for LogViewer Signed-off-by: Patrik Oldsberg --- .changeset/many-items-own.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/many-items-own.md diff --git a/.changeset/many-items-own.md b/.changeset/many-items-own.md new file mode 100644 index 0000000000..64c8abafe2 --- /dev/null +++ b/.changeset/many-items-own.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Introduce new `LogViewer` component that can be used to display logs. It supports copying, searching, filtering, and displaying text with ANSI color escape codes. From a6622d97046e171ec0eaa1803e22b1fdb1ed66b6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 21:47:01 +0100 Subject: [PATCH 29/35] core-components: document LogViewer + restructure Signed-off-by: Patrik Oldsberg --- .../components/LogViewer/LazyLogViewer.tsx | 32 ----- .../LogViewer/LogViewer.stories.tsx | 4 +- .../src/components/LogViewer/LogViewer.tsx | 132 +++++------------- .../components/LogViewer/RealLogViewer.tsx | 126 +++++++++++++++++ .../src/components/LogViewer/index.ts | 2 +- 5 files changed, 162 insertions(+), 134 deletions(-) delete mode 100644 packages/core-components/src/components/LogViewer/LazyLogViewer.tsx create mode 100644 packages/core-components/src/components/LogViewer/RealLogViewer.tsx diff --git a/packages/core-components/src/components/LogViewer/LazyLogViewer.tsx b/packages/core-components/src/components/LogViewer/LazyLogViewer.tsx deleted file mode 100644 index fb765bbbc4..0000000000 --- a/packages/core-components/src/components/LogViewer/LazyLogViewer.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { lazy, Suspense } from 'react'; -import { useApp } from '@backstage/core-plugin-api'; -import { LogViewerProps } from './LogViewer'; - -const LogViewer = lazy(() => - import('./LogViewer').then(m => ({ default: m.LogViewer })), -); - -export function LazyLogViewer(props: LogViewerProps) { - const { Progress } = useApp().getComponents(); - return ( - }> - - - ); -} diff --git a/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx b/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx index b7a56e1a7b..255e1d03b8 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.stories.tsx @@ -14,12 +14,14 @@ * limitations under the License. */ -import React from 'react'; +import React, { ComponentType } from 'react'; +import { wrapInTestApp } from '@backstage/test-utils'; import { LogViewer } from './LogViewer'; export default { title: 'Data Display/LogViewer', component: LogViewer, + decorators: [(Story: ComponentType<{}>) => wrapInTestApp()], }; const exampleLog = `Starting up task with 3 steps diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index 36d25153cd..a85603b465 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -14,113 +14,45 @@ * limitations under the License. */ -import React, { useEffect, useMemo, useRef } from 'react'; -import IconButton from '@material-ui/core/IconButton'; -import CopyIcon from '@material-ui/icons/FileCopy'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { FixedSizeList } from 'react-window'; -import { AnsiProcessor } from './AnsiProcessor'; -import { HEADER_SIZE, useStyles } from './styles'; -import clsx from 'clsx'; -import { LogLine } from './LogLine'; -import { LogViewerControls } from './LogViewerControls'; -import { useLogViewerSearch } from './useLogViewerSearch'; -import { useLogViewerSelection } from './useLogViewerSelection'; +import React, { lazy, Suspense } from 'react'; +import { useApp } from '@backstage/core-plugin-api'; +const RealLogViewer = lazy(() => + import('./RealLogViewer').then(m => ({ default: m.RealLogViewer })), +); + +/** + * The properties for the LogViewer component. + */ export interface LogViewerProps { + /** + * The text of the logs to display. + * + * The LogViewer component is optimized for appending content at the end of the text. + */ text: string; + /** + * The className to apply to the root LogViewer element inside the auto sizer. + */ className?: string; } +/** + * A component that displays logs in a scrollable text area. + * + * The LogViewer has support for search and filtering, as well as displaying + * text content with ANSI color escape codes. + * + * Since the LogViewer uses windowing to avoid rendering all contents at once, the + * log is sized automatically to fill the available vertical space. This means + * it may often be needed to wrap the LogViewer in a container that provides it + * with a fixed amount of space. + */ export function LogViewer(props: LogViewerProps) { - const classes = useStyles(); - const listRef = useRef(null); - - // The processor keeps state that optimizes appending to the text - const processor = useMemo(() => new AnsiProcessor(), []); - const lines = processor.process(props.text); - - const search = useLogViewerSearch(lines); - const selection = useLogViewerSelection(lines); - - useEffect(() => { - if (search.resultLine !== undefined && listRef.current) { - listRef.current.scrollToItem(search.resultLine - 1, 'center'); - } - }, [search.resultLine]); - - const handleSelectLine = ( - line: number, - event: { shiftKey: boolean; preventDefault: () => void }, - ) => { - event.preventDefault(); - selection.setSelection(line, event.shiftKey); - }; - + const { Progress } = useApp().getComponents(); return ( - - {({ height, width }) => ( -
-
- -
- - {({ index, style, data }) => { - const line = data[index]; - const { lineNumber } = line; - return ( -
- {selection.shouldShowButton(lineNumber) && ( - selection.copySelection()} - > - - - )} - handleSelectLine(lineNumber, event)} - onKeyPress={event => handleSelectLine(lineNumber, event)} - > - {lineNumber} - - -
- ); - }} -
-
- )} -
+ }> + + ); } diff --git a/packages/core-components/src/components/LogViewer/RealLogViewer.tsx b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx new file mode 100644 index 0000000000..d41ce80672 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useMemo, useRef } from 'react'; +import IconButton from '@material-ui/core/IconButton'; +import CopyIcon from '@material-ui/icons/FileCopy'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeList } from 'react-window'; +import { AnsiProcessor } from './AnsiProcessor'; +import { HEADER_SIZE, useStyles } from './styles'; +import clsx from 'clsx'; +import { LogLine } from './LogLine'; +import { LogViewerControls } from './LogViewerControls'; +import { useLogViewerSearch } from './useLogViewerSearch'; +import { useLogViewerSelection } from './useLogViewerSelection'; + +export interface RealLogViewerProps { + text: string; + className?: string; +} + +export function RealLogViewer(props: RealLogViewerProps) { + const classes = useStyles(); + const listRef = useRef(null); + + // The processor keeps state that optimizes appending to the text + const processor = useMemo(() => new AnsiProcessor(), []); + const lines = processor.process(props.text); + + const search = useLogViewerSearch(lines); + const selection = useLogViewerSelection(lines); + + useEffect(() => { + if (search.resultLine !== undefined && listRef.current) { + listRef.current.scrollToItem(search.resultLine - 1, 'center'); + } + }, [search.resultLine]); + + const handleSelectLine = ( + line: number, + event: { shiftKey: boolean; preventDefault: () => void }, + ) => { + event.preventDefault(); + selection.setSelection(line, event.shiftKey); + }; + + return ( + + {({ height, width }) => ( +
+
+ +
+ + {({ index, style, data }) => { + const line = data[index]; + const { lineNumber } = line; + return ( +
+ {selection.shouldShowButton(lineNumber) && ( + selection.copySelection()} + > + + + )} + handleSelectLine(lineNumber, event)} + onKeyPress={event => handleSelectLine(lineNumber, event)} + > + {lineNumber} + + +
+ ); + }} +
+
+ )} +
+ ); +} diff --git a/packages/core-components/src/components/LogViewer/index.ts b/packages/core-components/src/components/LogViewer/index.ts index aba2e8ea16..839f34f81e 100644 --- a/packages/core-components/src/components/LogViewer/index.ts +++ b/packages/core-components/src/components/LogViewer/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { LazyLogViewer as LogViewer } from './LazyLogViewer'; +export { LogViewer } from './LogViewer'; export type { LogViewerProps } from './LogViewer'; From beed531a9d5f5619730d02a852cb3eb73ce6085b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 23:14:50 +0100 Subject: [PATCH 30/35] core-components: tests for LogViewer search state + refactor Signed-off-by: Patrik Oldsberg --- .../LogViewer/LogViewerControls.tsx | 24 +- .../LogViewer/useLogViewerSearch.test.tsx | 221 ++++++++++++++++++ .../LogViewer/useLogViewerSearch.tsx | 29 ++- 3 files changed, 248 insertions(+), 26 deletions(-) create mode 100644 packages/core-components/src/components/LogViewer/useLogViewerSearch.test.tsx diff --git a/packages/core-components/src/components/LogViewer/LogViewerControls.tsx b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx index 6d55e6f76f..4d3baae68d 100644 --- a/packages/core-components/src/components/LogViewer/LogViewerControls.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewerControls.tsx @@ -26,31 +26,15 @@ import { LogViewerSearch } from './useLogViewerSearch'; export interface LogViewerControlsProps extends LogViewerSearch {} export function LogViewerControls(props: LogViewerControlsProps) { - const { resultCount, setResultIndex, toggleShouldFilter } = props; + const { resultCount, resultIndexStep, toggleShouldFilter } = props; const resultIndex = props.resultIndex ?? 0; - const increment = () => { - if (resultCount !== undefined) { - const next = resultIndex + 1; - setResultIndex(next >= resultCount ? 0 : next); - } - }; - - const decrement = () => { - if (resultCount !== undefined) { - const next = resultIndex - 1; - setResultIndex(next < 0 ? resultCount - 1 : next); - } - }; - const handleKeyPress = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { if (event.metaKey || event.ctrlKey || event.altKey) { toggleShouldFilter(); - } else if (event.shiftKey) { - decrement(); } else { - increment(); + resultIndexStep(event.shiftKey); } } }; @@ -59,13 +43,13 @@ export function LogViewerControls(props: LogViewerControlsProps) { <> {resultCount !== undefined && ( <> - + resultIndexStep(true)}> {Math.min(resultIndex + 1, resultCount)}/{resultCount} - + resultIndexStep()}> diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSearch.test.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSearch.test.tsx new file mode 100644 index 0000000000..a64f22765b --- /dev/null +++ b/packages/core-components/src/components/LogViewer/useLogViewerSearch.test.tsx @@ -0,0 +1,221 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { applySearchFilter, useLogViewerSearch } from './useLogViewerSearch'; +import { AnsiLine } from './AnsiProcessor'; + +const lines = [ + new AnsiLine(1, [{ text: 'FooBar', modifiers: {} }]), + new AnsiLine(2, [{ text: 'Baz', modifiers: {} }]), + new AnsiLine(3, [{ text: 'FooBarFoo', modifiers: {} }]), + new AnsiLine(4, [{ text: 'Baz', modifiers: {} }]), + new AnsiLine(5, [{ text: 'BazFoo', modifiers: {} }]), + new AnsiLine(6, [{ text: 'FooFooFoo', modifiers: {} }]), + new AnsiLine(7, [{ text: '', modifiers: {} }]), + new AnsiLine(8, [{ text: 'Bar', modifiers: {} }]), +]; + +describe('applySearchFilter', () => { + it('should find search results', () => { + expect(applySearchFilter(lines, '')).toEqual({ + lines: lines, + results: undefined, + }); + expect(applySearchFilter(lines, 'foo')).toEqual({ + lines: [lines[0], lines[2], lines[4], lines[5]], + results: [ + { lineNumber: 1, lineIndex: 0 }, + { lineNumber: 3, lineIndex: 0 }, + { lineNumber: 3, lineIndex: 1 }, + { lineNumber: 5, lineIndex: 0 }, + { lineNumber: 6, lineIndex: 0 }, + { lineNumber: 6, lineIndex: 1 }, + { lineNumber: 6, lineIndex: 2 }, + ], + }); + expect(applySearchFilter(lines, 'bar')).toEqual({ + lines: [lines[0], lines[2], lines[7]], + results: [ + { lineNumber: 1, lineIndex: 0 }, + { lineNumber: 3, lineIndex: 0 }, + { lineNumber: 8, lineIndex: 0 }, + ], + }); + expect(applySearchFilter(lines, 'baz')).toEqual({ + lines: [lines[1], lines[3], lines[4]], + results: [ + { lineNumber: 2, lineIndex: 0 }, + { lineNumber: 4, lineIndex: 0 }, + { lineNumber: 5, lineIndex: 0 }, + ], + }); + }); +}); + +describe('useLogViewerSearch', () => { + it('should provide search state', () => { + const rendered = renderHook(() => useLogViewerSearch(lines)); + expect(rendered.result.current).toMatchObject({ + lines, + searchText: '', + shouldFilter: false, + resultCount: undefined, + resultIndex: 0, + resultLine: undefined, + resultLineIndex: undefined, + }); + + rendered.result.current.resultIndexStep(); + expect(rendered.result.current).toMatchObject({ + lines, + searchText: '', + shouldFilter: false, + resultCount: undefined, + resultIndex: 0, + resultLine: undefined, + resultLineIndex: undefined, + }); + + act(() => rendered.result.current.toggleShouldFilter()); + expect(rendered.result.current).toMatchObject({ + lines, + searchText: '', + shouldFilter: true, + resultCount: undefined, + resultIndex: 0, + resultLine: undefined, + resultLineIndex: undefined, + }); + + act(() => rendered.result.current.setSearchInput('BAR')); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + searchInput: 'BAR', + searchText: 'bar', + shouldFilter: true, + resultCount: 3, + resultIndex: 0, + resultLine: 1, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + resultIndex: 1, + resultLine: 3, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + resultIndex: 2, + resultLine: 8, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + resultIndex: 0, + resultLine: 1, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep(true)); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[7]], + resultIndex: 2, + resultLine: 8, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.setSearchInput('FOO')); + expect(rendered.result.current).toMatchObject({ + lines: [lines[0], lines[2], lines[4], lines[5]], + searchInput: 'FOO', + searchText: 'foo', + shouldFilter: true, + resultCount: 7, + resultIndex: 2, + resultLine: 3, + resultLineIndex: 1, + }); + + act(() => rendered.result.current.toggleShouldFilter()); + expect(rendered.result.current).toMatchObject({ + lines, + shouldFilter: false, + resultCount: 7, + resultIndex: 2, + resultLine: 3, + resultLineIndex: 1, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + lines, + searchInput: 'FOO', + searchText: 'foo', + shouldFilter: false, + resultCount: 7, + resultIndex: 3, + resultLine: 5, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + resultIndex: 4, + resultLine: 6, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + resultIndex: 5, + resultLine: 6, + resultLineIndex: 1, + }); + + act(() => rendered.result.current.resultIndexStep()); + expect(rendered.result.current).toMatchObject({ + resultIndex: 6, + resultLine: 6, + resultLineIndex: 2, + }); + + act(() => rendered.result.current.setSearchInput('BAR')); + expect(rendered.result.current).toMatchObject({ + searchText: 'bar', + resultCount: 3, + resultIndex: 6, + resultLine: 8, + resultLineIndex: 0, + }); + + act(() => rendered.result.current.resultIndexStep(true)); + expect(rendered.result.current).toMatchObject({ + searchText: 'bar', + resultCount: 3, + resultIndex: 1, + resultLine: 3, + resultLineIndex: 0, + }); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx index 4e93178c20..4462a3ff50 100644 --- a/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx +++ b/packages/core-components/src/components/LogViewer/useLogViewerSearch.tsx @@ -61,9 +61,9 @@ export interface LogViewerSearch { shouldFilter: boolean; toggleShouldFilter: () => void; - resultIndex: number | undefined; resultCount: number | undefined; - setResultIndex: (number: number) => void; + resultIndex: number | undefined; + resultIndexStep: (decrement?: boolean) => void; resultLine: number | undefined; resultLineIndex: number | undefined; @@ -73,7 +73,7 @@ export function useLogViewerSearch(lines: AnsiLine[]): LogViewerSearch { const [searchInput, setSearchInput] = useState(''); const searchText = searchInput.toLocaleLowerCase('en-US'); - const [resultIndex, setResultIndex] = useState(); + const [resultIndex, setResultIndex] = useState(0); const [shouldFilter, toggleShouldFilter] = useToggle(false); @@ -82,7 +82,24 @@ export function useLogViewerSearch(lines: AnsiLine[]): LogViewerSearch { [lines, searchText], ); - const searchResult = filter.results?.[resultIndex ?? 0]; + const searchResult = filter.results + ? filter.results[Math.min(resultIndex, filter.results.length - 1)] + : undefined; + const resultCount = filter.results?.length; + + const resultIndexStep = (decrement?: boolean) => { + if (decrement) { + if (resultCount !== undefined) { + const next = Math.min(resultIndex - 1, resultCount - 2); + setResultIndex(next < 0 ? resultCount - 1 : next); + } + } else { + if (resultCount !== undefined) { + const next = resultIndex + 1; + setResultIndex(next >= resultCount ? 0 : next); + } + } + }; return { lines: shouldFilter ? filter.lines : lines, @@ -91,9 +108,9 @@ export function useLogViewerSearch(lines: AnsiLine[]): LogViewerSearch { setSearchInput, shouldFilter, toggleShouldFilter, + resultCount, resultIndex, - resultCount: filter.results?.length, - setResultIndex, + resultIndexStep, resultLine: searchResult?.lineNumber, resultLineIndex: searchResult?.lineIndex, }; From 6078c7147a7911d6c5a49dd7fd030afb24ed807d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 5 Dec 2021 23:44:37 +0100 Subject: [PATCH 31/35] core-components: tests + fix for LogViewer selection handling Signed-off-by: Patrik Oldsberg --- .../LogViewer/useLogViewerSelection.test.tsx | 123 ++++++++++++++++++ .../LogViewer/useLogViewerSelection.tsx | 2 +- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx new file mode 100644 index 0000000000..7e00d50f33 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestApiProvider, MockErrorApi } from '@backstage/test-utils'; +import { errorApiRef } from '@backstage/core-plugin-api'; +import { AnsiLine } from './AnsiProcessor'; +import { useLogViewerSelection } from './useLogViewerSelection'; +// eslint-disable-next-line import/no-extraneous-dependencies +import copyToClipboard from 'copy-to-clipboard'; + +// Used by useCopyToClipboard +jest.mock('copy-to-clipboard', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const lines = [ + new AnsiLine(1, [{ text: '1', modifiers: {} }]), + new AnsiLine(2, [{ text: '2', modifiers: {} }]), + new AnsiLine(3, [{ text: '3', modifiers: {} }]), + new AnsiLine(4, [{ text: '4', modifiers: {} }]), + new AnsiLine(5, [{ text: '5', modifiers: {} }]), +]; + +describe('useLogViewerSelection', () => { + it('should manage a selection', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(rendered.result.current.isSelected(1)).toBe(false); + expect(rendered.result.current.isSelected(2)).toBe(false); + expect(rendered.result.current.isSelected(3)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(false); + expect(rendered.result.current.shouldShowButton(2)).toBe(false); + expect(rendered.result.current.shouldShowButton(3)).toBe(false); + + act(() => rendered.result.current.setSelection(2, false)); + + expect(rendered.result.current.isSelected(1)).toBe(false); + expect(rendered.result.current.isSelected(2)).toBe(true); + expect(rendered.result.current.isSelected(3)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(false); + expect(rendered.result.current.shouldShowButton(2)).toBe(true); + expect(rendered.result.current.shouldShowButton(3)).toBe(false); + + act(() => rendered.result.current.setSelection(3, false)); + + expect(rendered.result.current.isSelected(1)).toBe(false); + expect(rendered.result.current.isSelected(2)).toBe(false); + expect(rendered.result.current.isSelected(3)).toBe(true); + expect(rendered.result.current.isSelected(4)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(false); + expect(rendered.result.current.shouldShowButton(2)).toBe(false); + expect(rendered.result.current.shouldShowButton(3)).toBe(true); + expect(rendered.result.current.shouldShowButton(4)).toBe(false); + + act(() => rendered.result.current.setSelection(1, true)); + + expect(rendered.result.current.isSelected(1)).toBe(true); + expect(rendered.result.current.isSelected(2)).toBe(true); + expect(rendered.result.current.isSelected(3)).toBe(true); + expect(rendered.result.current.isSelected(4)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(true); + expect(rendered.result.current.shouldShowButton(2)).toBe(false); + expect(rendered.result.current.shouldShowButton(3)).toBe(true); + expect(rendered.result.current.shouldShowButton(4)).toBe(false); + + act(() => rendered.result.current.setSelection(4, true)); + + expect(rendered.result.current.isSelected(1)).toBe(false); + expect(rendered.result.current.isSelected(2)).toBe(false); + expect(rendered.result.current.isSelected(3)).toBe(true); + expect(rendered.result.current.isSelected(4)).toBe(true); + expect(rendered.result.current.isSelected(5)).toBe(false); + + expect(rendered.result.current.shouldShowButton(1)).toBe(false); + expect(rendered.result.current.shouldShowButton(2)).toBe(false); + expect(rendered.result.current.shouldShowButton(3)).toBe(true); + expect(rendered.result.current.shouldShowButton(4)).toBe(true); + expect(rendered.result.current.shouldShowButton(5)).toBe(false); + + expect(copyToClipboard).not.toHaveBeenCalled(); + act(() => rendered.result.current.copySelection()); + expect(copyToClipboard).toHaveBeenLastCalledWith('3\n4'); + + act(() => rendered.result.current.setSelection(2, true)); + act(() => rendered.result.current.setSelection(4, true)); + + act(() => rendered.result.current.copySelection()); + expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4'); + + act(() => rendered.result.current.setSelection(2, false)); + act(() => rendered.result.current.setSelection(4, false)); + act(() => rendered.result.current.setSelection(4, false)); + act(() => rendered.result.current.setSelection(5, true)); + act(() => rendered.result.current.copySelection()); + expect(copyToClipboard).toHaveBeenCalledWith('5'); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx index cae56e5e43..3bed43d0c5 100644 --- a/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx +++ b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx @@ -59,7 +59,7 @@ export function useLogViewerSelection(lines: AnsiLine[]) { copySelection() { if (sel) { const copyText = lines - .slice(sel.start - 1, sel.end) + .slice(Math.min(sel.start, sel.end) - 1, Math.max(sel.start, sel.end)) .map(l => l.text) .join('\n'); copyToClipboard(copyText); From da0fdf9b5eade742dc452acbf53ea87092bb14f9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 6 Dec 2021 00:32:25 +0100 Subject: [PATCH 32/35] core-components: added test for LogViewer and fix copy Signed-off-by: Patrik Oldsberg --- .../LogViewer/RealLogViewer.test.tsx | 79 +++++++++++++++++++ .../components/LogViewer/RealLogViewer.tsx | 1 + .../LogViewer/useLogViewerSelection.tsx | 2 +- 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx diff --git a/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx b/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx new file mode 100644 index 0000000000..8d5efb9724 --- /dev/null +++ b/packages/core-components/src/components/LogViewer/RealLogViewer.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import UserEvent from '@testing-library/user-event'; +import { renderInTestApp } from '@backstage/test-utils'; +import { RealLogViewer } from './RealLogViewer'; +// eslint-disable-next-line import/no-extraneous-dependencies +import copyToClipboard from 'copy-to-clipboard'; + +// Used by useCopyToClipboard +jest.mock('copy-to-clipboard', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// The inside needs mocking to render in jsdom +jest.mock('react-virtualized-auto-sizer', () => ({ + __esModule: true, + default: (props: { + children: (size: { width: number; height: number }) => ReactNode; + }) => <>{props.children({ width: 400, height: 200 })}, +})); + +const testText = `Some Log Line +Derp +Foo + +Foo Foo +Wat`; + +describe('RealLogViewer', () => { + it('should render text with search and filtering and copying', async () => { + const rendered = await renderInTestApp(); + expect(rendered.getByText('Derp')).toBeInTheDocument(); + expect(rendered.getByText('Foo Foo')).toBeInTheDocument(); + + UserEvent.tab(); + UserEvent.keyboard('Foo'); + + expect(rendered.getByText('1/3')).toBeInTheDocument(); + UserEvent.keyboard('{enter}'); + expect(rendered.getByText('2/3')).toBeInTheDocument(); + UserEvent.keyboard('{enter}'); + expect(rendered.getByText('3/3')).toBeInTheDocument(); + UserEvent.keyboard('{enter}'); + expect(rendered.getByText('1/3')).toBeInTheDocument(); + UserEvent.keyboard('{shift}{enter}{/shift}'); + expect(rendered.getByText('3/3')).toBeInTheDocument(); + + expect(rendered.queryByText('Some Log Line')).toBeInTheDocument(); + UserEvent.keyboard('{meta}{enter}{/meta}'); + expect(rendered.queryByText('Some Log Line')).not.toBeInTheDocument(); + UserEvent.keyboard('{meta}{enter}{/meta}'); + expect(rendered.queryByText('Some Log Line')).toBeInTheDocument(); + + // Tab down to line #2 and click + UserEvent.tab(); + UserEvent.tab(); + UserEvent.tab(); + UserEvent.click(document.activeElement!); + UserEvent.click(rendered.getByTestId('copy-button')); + + expect(copyToClipboard).toHaveBeenCalledWith('Derp'); + }); +}); diff --git a/packages/core-components/src/components/LogViewer/RealLogViewer.tsx b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx index d41ce80672..47bc05c85c 100644 --- a/packages/core-components/src/components/LogViewer/RealLogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx @@ -88,6 +88,7 @@ export function RealLogViewer(props: RealLogViewerProps) { > {selection.shouldShowButton(lineNumber) && ( selection.copySelection()} diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx index 3bed43d0c5..a41f5e7159 100644 --- a/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx +++ b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx @@ -60,7 +60,7 @@ export function useLogViewerSelection(lines: AnsiLine[]) { if (sel) { const copyText = lines .slice(Math.min(sel.start, sel.end) - 1, Math.max(sel.start, sel.end)) - .map(l => l.text) + .map(l => l.chunks.map(c => c.text).join('')) .join('\n'); copyToClipboard(copyText); setSelection(undefined); From 1efed71f6018e2ba8f3e0418b0183be1f5a3308c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 6 Dec 2021 00:38:45 +0100 Subject: [PATCH 33/35] core-components: make LogViewer styles overridable Signed-off-by: Patrik Oldsberg --- .../src/components/LogViewer/index.ts | 1 + .../src/components/LogViewer/styles.ts | 247 ++++++++++-------- .../src/overridableComponents.ts | 2 + 3 files changed, 144 insertions(+), 106 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/index.ts b/packages/core-components/src/components/LogViewer/index.ts index 839f34f81e..f99695f163 100644 --- a/packages/core-components/src/components/LogViewer/index.ts +++ b/packages/core-components/src/components/LogViewer/index.ts @@ -16,3 +16,4 @@ export { LogViewer } from './LogViewer'; export type { LogViewerProps } from './LogViewer'; +export type { LogViewerClassKey } from './styles'; diff --git a/packages/core-components/src/components/LogViewer/styles.ts b/packages/core-components/src/components/LogViewer/styles.ts index 4f4312bdd9..edb2086675 100644 --- a/packages/core-components/src/components/LogViewer/styles.ts +++ b/packages/core-components/src/components/LogViewer/styles.ts @@ -19,114 +19,149 @@ import * as colors from '@material-ui/core/colors'; export const HEADER_SIZE = 40; -export const useStyles = makeStyles(theme => ({ - root: { - background: theme.palette.background.paper, - }, - header: { - height: HEADER_SIZE, - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - }, - log: { - fontFamily: '"Monaco", monospace', - fontSize: theme.typography.pxToRem(12), - }, - line: { - position: 'relative', - whiteSpace: 'pre', +export type LogViewerClassKey = + | 'root' + | 'header' + | 'log' + | 'line' + | 'lineSelected' + | 'lineCopyButton' + | 'lineNumber' + | 'textHighlight' + | 'textSelectedHighlight' + | 'modifierBold' + | 'modifierItalic' + | 'modifierUnderline' + | 'modifierForegroundBlack' + | 'modifierForegroundRed' + | 'modifierForegroundGreen' + | 'modifierForegroundYellow' + | 'modifierForegroundBlue' + | 'modifierForegroundMagenta' + | 'modifierForegroundCyan' + | 'modifierForegroundWhite' + | 'modifierForegroundGrey' + | 'modifierBackgroundBlack' + | 'modifierBackgroundRed' + | 'modifierBackgroundGreen' + | 'modifierBackgroundYellow' + | 'modifierBackgroundBlue' + | 'modifierBackgroundMagenta' + | 'modifierBackgroundCyan' + | 'modifierBackgroundWhite' + | 'modifierBackgroundGrey'; - '&:hover': { - background: theme.palette.action.hover, +export const useStyles = makeStyles( + theme => ({ + root: { + background: theme.palette.background.paper, }, - }, - lineSelected: { - background: theme.palette.action.selected, + header: { + height: HEADER_SIZE, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + }, + log: { + fontFamily: '"Monaco", monospace', + fontSize: theme.typography.pxToRem(12), + }, + line: { + position: 'relative', + whiteSpace: 'pre', - '&:hover': { + '&:hover': { + background: theme.palette.action.hover, + }, + }, + lineSelected: { background: theme.palette.action.selected, + + '&:hover': { + background: theme.palette.action.selected, + }, }, - }, - lineCopyButton: { - position: 'absolute', - paddingTop: 0, - paddingBottom: 0, - }, - lineNumber: { - display: 'inline-block', - textAlign: 'end', - width: 60, - marginRight: theme.spacing(1), - cursor: 'pointer', - }, - textHighlight: { - background: alpha(theme.palette.info.main, 0.15), - }, - textSelectedHighlight: { - background: alpha(theme.palette.info.main, 0.4), - }, - modifierBold: { - fontWeight: theme.typography.fontWeightBold, - }, - modifierItalic: { - fontStyle: 'italic', - }, - modifierUnderline: { - textDecoration: 'underline', - }, - modifierForegroundBlack: { - color: colors.common.black, - }, - modifierForegroundRed: { - color: colors.red[500], - }, - modifierForegroundGreen: { - color: colors.green[500], - }, - modifierForegroundYellow: { - color: colors.yellow[500], - }, - modifierForegroundBlue: { - color: colors.blue[500], - }, - modifierForegroundMagenta: { - color: colors.purple[500], - }, - modifierForegroundCyan: { - color: colors.cyan[500], - }, - modifierForegroundWhite: { - color: colors.common.white, - }, - modifierForegroundGrey: { - color: colors.grey[500], - }, - modifierBackgroundBlack: { - background: colors.common.black, - }, - modifierBackgroundRed: { - background: colors.red[500], - }, - modifierBackgroundGreen: { - background: colors.green[500], - }, - modifierBackgroundYellow: { - background: colors.yellow[500], - }, - modifierBackgroundBlue: { - background: colors.blue[500], - }, - modifierBackgroundMagenta: { - background: colors.purple[500], - }, - modifierBackgroundCyan: { - background: colors.cyan[500], - }, - modifierBackgroundWhite: { - background: colors.common.white, - }, - modifierBackgroundGrey: { - background: colors.grey[500], - }, -})); + lineCopyButton: { + position: 'absolute', + paddingTop: 0, + paddingBottom: 0, + }, + lineNumber: { + display: 'inline-block', + textAlign: 'end', + width: 60, + marginRight: theme.spacing(1), + cursor: 'pointer', + }, + textHighlight: { + background: alpha(theme.palette.info.main, 0.15), + }, + textSelectedHighlight: { + background: alpha(theme.palette.info.main, 0.4), + }, + modifierBold: { + fontWeight: theme.typography.fontWeightBold, + }, + modifierItalic: { + fontStyle: 'italic', + }, + modifierUnderline: { + textDecoration: 'underline', + }, + modifierForegroundBlack: { + color: colors.common.black, + }, + modifierForegroundRed: { + color: colors.red[500], + }, + modifierForegroundGreen: { + color: colors.green[500], + }, + modifierForegroundYellow: { + color: colors.yellow[500], + }, + modifierForegroundBlue: { + color: colors.blue[500], + }, + modifierForegroundMagenta: { + color: colors.purple[500], + }, + modifierForegroundCyan: { + color: colors.cyan[500], + }, + modifierForegroundWhite: { + color: colors.common.white, + }, + modifierForegroundGrey: { + color: colors.grey[500], + }, + modifierBackgroundBlack: { + background: colors.common.black, + }, + modifierBackgroundRed: { + background: colors.red[500], + }, + modifierBackgroundGreen: { + background: colors.green[500], + }, + modifierBackgroundYellow: { + background: colors.yellow[500], + }, + modifierBackgroundBlue: { + background: colors.blue[500], + }, + modifierBackgroundMagenta: { + background: colors.purple[500], + }, + modifierBackgroundCyan: { + background: colors.cyan[500], + }, + modifierBackgroundWhite: { + background: colors.common.white, + }, + modifierBackgroundGrey: { + background: colors.grey[500], + }, + }), + { name: 'BackstageLogViewer' }, +); diff --git a/packages/core-components/src/overridableComponents.ts b/packages/core-components/src/overridableComponents.ts index af35445e24..98b8f83abd 100644 --- a/packages/core-components/src/overridableComponents.ts +++ b/packages/core-components/src/overridableComponents.ts @@ -34,6 +34,7 @@ import { LifecycleClassKey, MarkdownContentClassKey, LoginRequestListItemClassKey, + LogViewerClassKey, OAuthRequestDialogClassKey, OverflowTooltipClassKey, GaugeClassKey, @@ -110,6 +111,7 @@ type BackstageComponentsNameToClassKey = { BackstageLifecycle: LifecycleClassKey; BackstageMarkdownContent: MarkdownContentClassKey; BackstageLoginRequestListItem: LoginRequestListItemClassKey; + BackstageLogViewer: LogViewerClassKey; OAuthRequestDialog: OAuthRequestDialogClassKey; BackstageOverflowTooltip: OverflowTooltipClassKey; BackstageGauge: GaugeClassKey; From 85d54b888cefbab8e7d025d6ce558e3cc9106f73 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 6 Dec 2021 00:57:35 +0100 Subject: [PATCH 34/35] core-components: update API report for LogViewer + fixes Signed-off-by: Patrik Oldsberg --- packages/core-components/api-report.md | 42 +++++++++++++++++++ .../src/components/LogViewer/LogViewer.tsx | 4 ++ .../src/components/LogViewer/styles.ts | 1 + 3 files changed, 47 insertions(+) diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index cc7db32586..a8611ad299 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -605,6 +605,48 @@ export type LinkProps = LinkProps_2 & // @public (undocumented) export type LoginRequestListItemClassKey = 'root'; +// @public +export function LogViewer(props: LogViewerProps): JSX.Element; + +// @public +export type LogViewerClassKey = + | 'root' + | 'header' + | 'log' + | 'line' + | 'lineSelected' + | 'lineCopyButton' + | 'lineNumber' + | 'textHighlight' + | 'textSelectedHighlight' + | 'modifierBold' + | 'modifierItalic' + | 'modifierUnderline' + | 'modifierForegroundBlack' + | 'modifierForegroundRed' + | 'modifierForegroundGreen' + | 'modifierForegroundYellow' + | 'modifierForegroundBlue' + | 'modifierForegroundMagenta' + | 'modifierForegroundCyan' + | 'modifierForegroundWhite' + | 'modifierForegroundGrey' + | 'modifierBackgroundBlack' + | 'modifierBackgroundRed' + | 'modifierBackgroundGreen' + | 'modifierBackgroundYellow' + | 'modifierBackgroundBlue' + | 'modifierBackgroundMagenta' + | 'modifierBackgroundCyan' + | 'modifierBackgroundWhite' + | 'modifierBackgroundGrey'; + +// @public +export interface LogViewerProps { + className?: string; + text: string; +} + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "MarkdownContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index a85603b465..643d2ba9f8 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -23,6 +23,8 @@ const RealLogViewer = lazy(() => /** * The properties for the LogViewer component. + * + * @public */ export interface LogViewerProps { /** @@ -47,6 +49,8 @@ export interface LogViewerProps { * log is sized automatically to fill the available vertical space. This means * it may often be needed to wrap the LogViewer in a container that provides it * with a fixed amount of space. + * + * @public */ export function LogViewer(props: LogViewerProps) { const { Progress } = useApp().getComponents(); diff --git a/packages/core-components/src/components/LogViewer/styles.ts b/packages/core-components/src/components/LogViewer/styles.ts index edb2086675..2028809543 100644 --- a/packages/core-components/src/components/LogViewer/styles.ts +++ b/packages/core-components/src/components/LogViewer/styles.ts @@ -19,6 +19,7 @@ import * as colors from '@material-ui/core/colors'; export const HEADER_SIZE = 40; +/** @public Class keys for overriding LogViewer styles */ export type LogViewerClassKey = | 'root' | 'header' From ed3a7567a0e1638cc372f1e167fc84ae08ba41a6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 6 Dec 2021 00:59:31 +0100 Subject: [PATCH 35/35] core-components: remove dead code in LogViewer Signed-off-by: Patrik Oldsberg chore: updating new yarn.lock changes Signed-off-by: blam --- .../src/components/LogViewer/AnsiProcessor.ts | 5 -- yarn.lock | 49 +++++++++++++++++-- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts index 5c4bf58865..d0f835a70e 100644 --- a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts @@ -71,11 +71,6 @@ export interface ChunkModifiers { underline?: boolean; } -// export interface AnsiLine { -// lineNumber: number; -// chunks: AnsiChunk[]; -// } - export interface AnsiChunk { text: string; modifiers: ChunkModifiers; diff --git a/yarn.lock b/yarn.lock index 015d5d6e79..f1c17c0022 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6974,6 +6974,13 @@ dependencies: "@types/node" "*" +"@types/ansi-regex@^5.0.0": + version "5.0.0" + resolved "https://registry.npmjs.org/@types/ansi-regex/-/ansi-regex-5.0.0.tgz#569a5189a92cc46d63fb2ad91e6b130f33d999c1" + integrity sha512-SQafVL3pXFh/5qq/nN6p5858g//zSVzcb8JzCLtoVxm8YNPggMQfEIm7aaTNysxpw1S+lFTaW8kv+aR0/CEhCA== + dependencies: + ansi-regex "*" + "@types/archiver@^5.1.0": version "5.3.0" resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.0.tgz#2b34ba56d4d7102d256b922c7e91e09eab79db6f" @@ -8088,6 +8095,20 @@ dependencies: "@types/react" "*" +"@types/react-virtualized-auto-sizer@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz#b3187dae1dfc4c15880c9cfc5b45f2719ea6ebd4" + integrity sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong== + dependencies: + "@types/react" "*" + +"@types/react-window@^1.8.5": + version "1.8.5" + resolved "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1" + integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0": version "16.14.18" resolved "https://registry.npmjs.org/@types/react/-/react-16.14.18.tgz#b2bcea05ee244fde92d409f91bd888ca8e54b20f" @@ -9227,6 +9248,11 @@ ansi-html@0.0.7, ansi-html@^0.0.7: resolved "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= +ansi-regex@*, ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -9247,11 +9273,6 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== - ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -20516,6 +20537,11 @@ memjs@^1.3.0: resolved "https://registry.npmjs.org/memjs/-/memjs-1.3.0.tgz#b7959b4ff3770e4c785463fd147f1e4fafd47a24" integrity sha512-y/V9a0auepA9Lgyr4QieK6K2FczjHucEdTpSS+hHVNmVEkYxruXhkHu8n6DSRQ4HXHEE3cc6Sf9f88WCJXGXsQ== +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + memoize-one@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" @@ -24799,6 +24825,11 @@ react-use@^17.2.4: ts-easing "^0.2.0" tslib "^2.1.0" +react-virtualized-auto-sizer@^1.0.6: + version "1.0.6" + resolved "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca" + integrity sha512-7tQ0BmZqfVF6YYEWcIGuoR3OdYe8I/ZFbNclFlGOC3pMqunkYF/oL30NCjSGl9sMEb17AnzixDz98Kqc3N76HQ== + react-virtualized@^9.21.0: version "9.21.2" resolved "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.21.2.tgz#02e6df65c1e020c8dbf574ec4ce971652afca84e" @@ -24811,6 +24842,14 @@ react-virtualized@^9.21.0: prop-types "^15.6.0" react-lifecycles-compat "^3.0.4" +react-window@^1.8.6: + version "1.8.6" + resolved "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz#d011950ac643a994118632665aad0c6382e2a112" + integrity sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@^16.12.0, react@^16.13.1: version "16.13.1" resolved "https://registry.npmjs.org/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"