Skip to content

Commit b4ae719

Browse files
author
Yulong
committed
[Code] code viewer for APM integration (#46884)
* [Code] code viewer for APM integration
1 parent 7ef04d4 commit b4ae719

11 files changed

Lines changed: 464 additions & 44 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.codeFlyout__subHeader {
2+
display: flex;
3+
flex-direction: column;
4+
justify-content: center;
5+
}
6+
7+
.codeFlyout__fileHeader {
8+
position: absolute;
9+
display: flex;
10+
align-items: center;
11+
z-index: 1;
12+
}
13+
14+
.codeFlyout__fileLink {
15+
font-weight: $euiFontWeightBold;
16+
}
17+
18+
.codeFlyout__icon {
19+
margin: $euiSizeS;
20+
}
21+
22+
.codeFlyout__tabs {
23+
justify-content: flex-end;
24+
}
25+
26+
.codeFlyout__content {
27+
display: flex;
28+
height: 100%;
29+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
import React from 'react';
7+
import { EuiFlyout } from '@elastic/eui';
8+
import { CodeFlyoutMain } from './code_flyout_main';
9+
10+
export const CodeFlyout = (props: {
11+
repo: string;
12+
file: string;
13+
revision: string;
14+
open: boolean;
15+
onClose: () => void;
16+
}) => {
17+
if (props.open) {
18+
return (
19+
<EuiFlyout
20+
onClose={props.onClose}
21+
size="l"
22+
aria-labelledby="flyoutTitle"
23+
className="codeFlyout"
24+
>
25+
<CodeFlyoutMain repo={props.repo} file={props.file} revision={props.revision} />
26+
</EuiFlyout>
27+
);
28+
} else {
29+
return null;
30+
}
31+
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import React, { Fragment, ReactNode } from 'react';
8+
import {
9+
EuiTitle,
10+
EuiFlyoutHeader,
11+
EuiText,
12+
EuiTabs,
13+
EuiTab,
14+
EuiIcon,
15+
EuiLink,
16+
} from '@elastic/eui';
17+
import { CodeViewer } from './code_viewer';
18+
import { History } from './history';
19+
import { absoluteCodeFileURI } from './helpers';
20+
21+
enum Tab {
22+
code,
23+
history,
24+
blame,
25+
}
26+
27+
export const CodeFlyoutMain = (props: { repo: string; file: string; revision: string }) => {
28+
const [selectedTab, setSelectedTab] = React.useState<Tab>(Tab.code);
29+
30+
let content: ReactNode;
31+
32+
switch (selectedTab) {
33+
case Tab.blame:
34+
content = (
35+
<CodeViewer
36+
key="blame"
37+
repo={props.repo}
38+
file={props.file}
39+
revision={props.revision}
40+
showBlame={true}
41+
/>
42+
);
43+
break;
44+
case Tab.history:
45+
content = <History revision={props.revision} repo={props.repo} file={props.file} />;
46+
break;
47+
case Tab.code:
48+
content = (
49+
<CodeViewer key="code" repo={props.repo} file={props.file} revision={props.revision} />
50+
);
51+
break;
52+
}
53+
54+
return (
55+
<Fragment>
56+
<EuiFlyoutHeader hasBorder>
57+
<EuiTitle size="m">
58+
<h2 id="flyoutTitle">File Preview</h2>
59+
</EuiTitle>
60+
</EuiFlyoutHeader>
61+
<div className="codeFlyout__subHeader">
62+
<div className="codeFlyout__fileHeader">
63+
<EuiIcon className="codeFlyout__icon" type="codeApp" size="m" />
64+
<EuiText size="s">
65+
<EuiLink
66+
className="codeFlyout__fileLink"
67+
href={absoluteCodeFileURI(props.repo, props.file, props.revision)}
68+
>
69+
{props.file}
70+
</EuiLink>
71+
</EuiText>
72+
</div>
73+
<EuiTabs className="codeFlyout__tabs">
74+
<EuiTab onClick={() => setSelectedTab(Tab.code)} isSelected={selectedTab === Tab.code}>
75+
Code
76+
</EuiTab>
77+
<EuiTab
78+
onClick={() => setSelectedTab(Tab.history)}
79+
isSelected={selectedTab === Tab.history}
80+
>
81+
History
82+
</EuiTab>
83+
<EuiTab onClick={() => setSelectedTab(Tab.blame)} isSelected={selectedTab === Tab.blame}>
84+
Blame
85+
</EuiTab>
86+
</EuiTabs>
87+
</div>
88+
<div className="codeFlyout__content">{content}</div>
89+
</Fragment>
90+
);
91+
};
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import React, { Component } from 'react';
8+
import { editor } from 'monaco-editor';
9+
import { ResizeChecker } from 'ui/resize_checker';
10+
import {
11+
EuiFlexGroup,
12+
EuiProgress,
13+
EuiPage,
14+
EuiPageBody,
15+
EuiPageContent,
16+
EuiPageContentHeader,
17+
EuiPageContentHeaderSection,
18+
EuiPageContentBody,
19+
} from '@elastic/eui';
20+
import { monaco } from '../../monaco/monaco';
21+
import { requestFile } from '../../sagas/file';
22+
import { GitBlame } from '../../../common/git_blame';
23+
import { BlameWidget } from '../../monaco/blame/blame_widget';
24+
import { requestBlame } from '../../sagas/blame';
25+
26+
export interface Props {
27+
repo: string;
28+
file: string;
29+
revision: string;
30+
showBlame?: boolean;
31+
}
32+
33+
interface State {
34+
loading: boolean;
35+
}
36+
37+
export class CodeViewer extends Component<Props, State> {
38+
public readonly state: State = {
39+
loading: true,
40+
};
41+
public blameWidgets: any;
42+
43+
private ed?: editor.IStandaloneCodeEditor;
44+
private lineDecorations: string[] | null = null;
45+
private resizeChecker?: ResizeChecker;
46+
private viewerRef = React.createRef<HTMLDivElement>();
47+
48+
public componentDidMount(): void {
49+
this.tryLoadFile(this.props);
50+
}
51+
52+
public componentWillUnmount(): void {
53+
if (this.ed) {
54+
this.ed.dispose();
55+
this.destroyBlameWidgets();
56+
}
57+
}
58+
59+
private async tryLoadFile({ file, revision, repo, showBlame }: Props) {
60+
this.setState({ loading: true });
61+
const { content, lang } = await requestFile({
62+
path: file,
63+
revision,
64+
uri: repo,
65+
});
66+
try {
67+
await monaco.editor.colorize(content!, lang!, {});
68+
this.loadFile(content!, lang);
69+
} catch (e) {
70+
this.loadFile(content!);
71+
}
72+
if (showBlame) {
73+
const blames: GitBlame[] = await requestBlame(repo, revision, file);
74+
this.loadBlame(blames);
75+
}
76+
this.setState({ loading: false });
77+
}
78+
79+
public loadBlame(blames: GitBlame[]) {
80+
if (this.blameWidgets) {
81+
this.destroyBlameWidgets();
82+
}
83+
if (!this.lineDecorations) {
84+
this.lineDecorations = this.ed!.deltaDecorations(
85+
[],
86+
[
87+
{
88+
range: new monaco.Range(1, 1, Infinity, 1),
89+
options: { isWholeLine: true, linesDecorationsClassName: 'code-line-decoration' },
90+
},
91+
]
92+
);
93+
}
94+
this.blameWidgets = blames.map((b, index) => {
95+
return new BlameWidget(b, index === 0, this.ed!);
96+
});
97+
}
98+
99+
public destroyBlameWidgets() {
100+
if (this.blameWidgets) {
101+
this.blameWidgets.forEach((bw: BlameWidget) => bw.destroy());
102+
}
103+
if (this.lineDecorations) {
104+
this.ed!.deltaDecorations(this.lineDecorations!, []);
105+
this.lineDecorations = null;
106+
}
107+
this.blameWidgets = null;
108+
if (this.resizeChecker) {
109+
this.resizeChecker.destroy();
110+
}
111+
}
112+
113+
private loadFile(code: string, language: string = 'text') {
114+
const container = this.viewerRef.current!;
115+
this.ed = monaco.editor.create(container, {
116+
value: code,
117+
language,
118+
readOnly: true,
119+
minimap: {
120+
enabled: false,
121+
},
122+
hover: {
123+
enabled: false,
124+
},
125+
contextmenu: false,
126+
selectOnLineNumbers: false,
127+
selectionHighlight: false,
128+
renderLineHighlight: 'none',
129+
scrollBeyondLastLine: false,
130+
renderIndentGuides: false,
131+
automaticLayout: false,
132+
lineDecorationsWidth: this.props.showBlame ? 316 : 16,
133+
});
134+
this.resizeChecker = new ResizeChecker(container);
135+
this.resizeChecker.on('resize', () => {
136+
setTimeout(() => {
137+
this.ed!.layout();
138+
});
139+
});
140+
}
141+
142+
renderFileLoadingIndicator = () => {
143+
const fileName = this.props.file;
144+
return (
145+
<EuiPage restrictWidth>
146+
<EuiPageBody>
147+
<EuiPageContent verticalPosition="center" horizontalPosition="center">
148+
<EuiPageContentHeader>
149+
<EuiPageContentHeaderSection>
150+
<h2>{fileName} is loading...</h2>
151+
</EuiPageContentHeaderSection>
152+
</EuiPageContentHeader>
153+
<EuiPageContentBody>
154+
<EuiProgress size="s" color="primary" />
155+
</EuiPageContentBody>
156+
</EuiPageContent>
157+
</EuiPageBody>
158+
</EuiPage>
159+
);
160+
};
161+
162+
render() {
163+
return (
164+
<EuiFlexGroup direction="row" className="codeContainer__blame" gutterSize="none">
165+
{this.state.loading && this.renderFileLoadingIndicator()}
166+
<div tabIndex={0} className="codeViewer codeContainer__monaco" ref={this.viewerRef} />
167+
</EuiFlexGroup>
168+
);
169+
}
170+
}

x-pack/legacy/plugins/code/public/components/integrations/helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,9 @@
77
// TODO(rylnd): make this an actual external link
88
export const externalFileURI: (repoUri: string, filePath: string) => string = (uri, path) =>
99
`/${uri}/blob/HEAD/${path}`;
10+
11+
export const absoluteCodeFileURI: (
12+
repoUri: string,
13+
filePath: string,
14+
revision: string
15+
) => string = (uri, path, revision = 'HEAD') => `/app/code#/${uri}/blob/${revision}/${path}`;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
import React from 'react';
7+
import { EuiTitle } from '@elastic/eui';
8+
import { FormattedMessage } from '@kbn/i18n/react';
9+
import { CommitHistoryComponent } from '../commits';
10+
import { requestCommits } from '../../sagas/file';
11+
import { CommitInfo } from '../../../model/commit';
12+
13+
const PAGE_SIZE = 20;
14+
15+
export const History = (props: { repo: string; file: string; revision: string }) => {
16+
const [loading, setLoading] = React.useState(true);
17+
const [commits, setCommits] = React.useState<CommitInfo[]>([]);
18+
const [hasMore, setHasMore] = React.useState(false);
19+
20+
const fetchCommits = async (loadMore: boolean) => {
21+
setLoading(true);
22+
const revision = loadMore ? commits[commits.length - 1].id : props.revision;
23+
const newCommits = await requestCommits(
24+
{ uri: props.repo, revision },
25+
props.file,
26+
loadMore,
27+
PAGE_SIZE
28+
);
29+
setLoading(false);
30+
setHasMore(newCommits.length >= PAGE_SIZE);
31+
setCommits(commits.concat(newCommits));
32+
return newCommits;
33+
};
34+
35+
React.useEffect(() => {
36+
fetchCommits(false).then(setCommits);
37+
}, []);
38+
39+
return (
40+
<CommitHistoryComponent
41+
commits={commits}
42+
fetchMoreCommits={() => fetchCommits(true)}
43+
loadingCommits={loading}
44+
hasMoreCommit={hasMore}
45+
header={
46+
<EuiTitle className="codeMargin__title">
47+
<h3>
48+
<FormattedMessage
49+
id="xpack.code.mainPage.history.commitHistoryTitle"
50+
defaultMessage="Commit History"
51+
/>
52+
</h3>
53+
</EuiTitle>
54+
}
55+
showPagination={true}
56+
repoUri={props.repo}
57+
/>
58+
);
59+
};

0 commit comments

Comments
 (0)