mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-08 06:25:45 +01:00
Split frontend code by routes (#1955)
Split large frontend components into own bundles. This way we decrease loading times and load the bundles right as they are used. We replace SystemJS with our own implementation to load the lazy modules right as there are required. Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
This commit is contained in:
@@ -43,9 +43,11 @@ class RunPlugin implements Plugin<Project> {
|
||||
|
||||
project.tasks.register('write-server-config', WriteServerConfigTask) {
|
||||
it.extension = extension
|
||||
dependsOn 'dev-war'
|
||||
}
|
||||
project.tasks.register('prepare-home', PrepareHomeTask) {
|
||||
it.extension = extension
|
||||
dependsOn 'dev-war'
|
||||
}
|
||||
project.tasks.register("run", RunTask) {
|
||||
it.extension = extension
|
||||
|
||||
@@ -44,6 +44,10 @@ class RunTask extends DefaultTask {
|
||||
@Nested
|
||||
ScmServerExtension extension
|
||||
|
||||
@Input
|
||||
@Option(option = 'analyze-bundles', description = 'Include Webpack Bundle Analyzer Plugin')
|
||||
boolean analyzeBundles = false
|
||||
|
||||
@Input
|
||||
@Option(option = 'debug-jvm', description = 'Start ScmServer suspended and listening on debug port (default: 5005)')
|
||||
boolean debugJvm = false
|
||||
@@ -73,7 +77,7 @@ class RunTask extends DefaultTask {
|
||||
|
||||
private void waitForPortToBeOpen() {
|
||||
int retries = 180
|
||||
for (int i=0; i<retries; i++) {
|
||||
for (int i = 0; i < retries; i++) {
|
||||
try {
|
||||
URL urlConnect = new URL("http://localhost:${extension.port}/scm/api/v2");
|
||||
URLConnection conn = (HttpURLConnection) urlConnect.openConnection();
|
||||
@@ -102,9 +106,11 @@ class RunTask extends DefaultTask {
|
||||
}
|
||||
|
||||
private Closure<Void> createBackend() {
|
||||
Map<String,String> scmProperties = System.getProperties().findAll { e -> {
|
||||
Map<String, String> scmProperties = System.getProperties().findAll { e ->
|
||||
{
|
||||
return e.key.startsWith("scm") || e.key.startsWith("sonia")
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
def runProperties = new HashMap<String, String>(scmProperties)
|
||||
runProperties.put("user.home", extension.getHome())
|
||||
@@ -137,7 +143,8 @@ class RunTask extends DefaultTask {
|
||||
script = new File(project.rootProject.projectDir, 'scm-ui/ui-scripts/bin/ui-scripts.js')
|
||||
args = ['serve']
|
||||
environment = [
|
||||
'NODE_ENV': 'development'
|
||||
'NODE_ENV': 'development',
|
||||
'ANALYZE_BUNDLES': analyzeBundles
|
||||
]
|
||||
}
|
||||
return {
|
||||
|
||||
2
gradle/changelog/code_splitting.yaml
Normal file
2
gradle/changelog/code_splitting.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: changed
|
||||
description: Split frontend code by routes ([#1955](https://github.com/scm-manager/scm-manager/pull/1955))
|
||||
@@ -18,11 +18,11 @@
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@scm-manager/remark-preset-lint": "^1.0.0",
|
||||
"babel-plugin-reflow": "^0.2.7",
|
||||
"husky": "^4.2.5",
|
||||
"lerna": "^3.17.0",
|
||||
"lint-staged": "^10.2.11",
|
||||
"@scm-manager/remark-preset-lint": "^1.0.0",
|
||||
"remark-cli": "^9.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -23,20 +23,20 @@
|
||||
*/
|
||||
|
||||
import { LegacyContext, useLegacyContext } from "./LegacyContext";
|
||||
import * as React from "react";
|
||||
import { FC } from "react";
|
||||
import { renderHook } from "@testing-library/react-hooks";
|
||||
import * as React from "react";
|
||||
import ApiProvider from "./ApiProvider";
|
||||
import { useQueryClient } from "react-query";
|
||||
|
||||
describe("ApiProvider tests", () => {
|
||||
const createWrapper = (context?: LegacyContext): FC => {
|
||||
const createWrapper = (context: LegacyContext): FC => {
|
||||
return ({ children }) => <ApiProvider {...context}>{children}</ApiProvider>;
|
||||
};
|
||||
|
||||
it("should register QueryClient", () => {
|
||||
const { result } = renderHook(() => useQueryClient(), {
|
||||
wrapper: createWrapper(),
|
||||
wrapper: createWrapper({ initialize: () => null })
|
||||
});
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
@@ -48,7 +48,7 @@ describe("ApiProvider tests", () => {
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useLegacyContext(), {
|
||||
wrapper: createWrapper({ onIndexFetched }),
|
||||
wrapper: createWrapper({ onIndexFetched, initialize: () => null })
|
||||
});
|
||||
|
||||
if (result.current?.onIndexFetched) {
|
||||
|
||||
@@ -31,9 +31,9 @@ import { reset } from "./reset";
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type Props = LegacyContext & {
|
||||
|
||||
@@ -24,12 +24,17 @@
|
||||
|
||||
import { IndexResources, Me } from "@scm-manager/ui-types";
|
||||
import React, { createContext, FC, useContext } from "react";
|
||||
import { QueryClient, useQueryClient } from "react-query";
|
||||
|
||||
export type LegacyContext = {
|
||||
export type BaseContext = {
|
||||
onIndexFetched?: (index: IndexResources) => void;
|
||||
onMeFetched?: (me: Me) => void;
|
||||
};
|
||||
|
||||
export type LegacyContext = BaseContext & {
|
||||
initialize: () => void;
|
||||
};
|
||||
|
||||
const Context = createContext<LegacyContext | undefined>(undefined);
|
||||
|
||||
export const useLegacyContext = () => {
|
||||
@@ -40,6 +45,31 @@ export const useLegacyContext = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const LegacyContextProvider: FC<LegacyContext> = ({ onIndexFetched, onMeFetched, children }) => (
|
||||
<Context.Provider value={{ onIndexFetched, onMeFetched }}>{children}</Context.Provider>
|
||||
);
|
||||
const createInitialContext = (queryClient: QueryClient, base: BaseContext): LegacyContext => {
|
||||
const ctx = {
|
||||
...base,
|
||||
initialize: () => {
|
||||
if (ctx.onIndexFetched) {
|
||||
const index: IndexResources | undefined = queryClient.getQueryData("index");
|
||||
if (index) {
|
||||
ctx.onIndexFetched(index);
|
||||
}
|
||||
}
|
||||
if (ctx.onMeFetched) {
|
||||
const me: Me | undefined = queryClient.getQueryData("me");
|
||||
if (me) {
|
||||
ctx.onMeFetched(me);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const LegacyContextProvider: FC<BaseContext> = ({ onIndexFetched, onMeFetched, children }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const ctx = createInitialContext(queryClient, { onIndexFetched, onMeFetched });
|
||||
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>;
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("Test base api hooks", () => {
|
||||
describe("useIndex tests", () => {
|
||||
fetchMock.get("/api/v2/", {
|
||||
version: "x.y.z",
|
||||
_links: {},
|
||||
_links: {}
|
||||
});
|
||||
|
||||
it("should return index", async () => {
|
||||
@@ -48,9 +48,10 @@ describe("Test base api hooks", () => {
|
||||
it("should call onIndexFetched of LegacyContext", async () => {
|
||||
let index: IndexResources;
|
||||
const context: LegacyContext = {
|
||||
onIndexFetched: (fetchedIndex) => {
|
||||
onIndexFetched: fetchedIndex => {
|
||||
index = fetchedIndex;
|
||||
},
|
||||
initialize: () => null
|
||||
};
|
||||
const { result, waitFor } = renderHook(() => useIndex(), { wrapper: createWrapper(context) });
|
||||
await waitFor(() => {
|
||||
@@ -70,10 +71,10 @@ describe("Test base api hooks", () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData("index", {
|
||||
version: "x.y.z",
|
||||
_links: {},
|
||||
_links: {}
|
||||
});
|
||||
const { result } = renderHook(() => useIndexLink("spaceships"), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
@@ -86,17 +87,17 @@ describe("Test base api hooks", () => {
|
||||
spaceships: [
|
||||
{
|
||||
name: "heartOfGold",
|
||||
href: "/spaceships/heartOfGold",
|
||||
href: "/spaceships/heartOfGold"
|
||||
},
|
||||
{
|
||||
name: "razorCrest",
|
||||
href: "/spaceships/razorCrest",
|
||||
},
|
||||
],
|
||||
},
|
||||
href: "/spaceships/razorCrest"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
const { result } = renderHook(() => useIndexLink("spaceships"), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
@@ -107,12 +108,12 @@ describe("Test base api hooks", () => {
|
||||
version: "x.y.z",
|
||||
_links: {
|
||||
spaceships: {
|
||||
href: "/api/spaceships",
|
||||
},
|
||||
},
|
||||
href: "/api/spaceships"
|
||||
}
|
||||
}
|
||||
});
|
||||
const { result } = renderHook(() => useIndexLink("spaceships"), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
expect(result.current).toBe("/api/spaceships");
|
||||
});
|
||||
@@ -130,12 +131,12 @@ describe("Test base api hooks", () => {
|
||||
version: "x.y.z",
|
||||
_links: {
|
||||
spaceships: {
|
||||
href: "/api/spaceships",
|
||||
},
|
||||
},
|
||||
href: "/api/spaceships"
|
||||
}
|
||||
}
|
||||
});
|
||||
const { result } = renderHook(() => useIndexLinks(), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
expect((result.current!.spaceships as Link).href).toBe("/api/spaceships");
|
||||
});
|
||||
@@ -150,10 +151,10 @@ describe("Test base api hooks", () => {
|
||||
it("should return version", () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData("index", {
|
||||
version: "x.y.z",
|
||||
version: "x.y.z"
|
||||
});
|
||||
const { result } = renderHook(() => useVersion(), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
expect(result.current).toBe("x.y.z");
|
||||
});
|
||||
@@ -164,10 +165,10 @@ describe("Test base api hooks", () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData("index", {
|
||||
version: "x.y.z",
|
||||
_links: {},
|
||||
_links: {}
|
||||
});
|
||||
const { result } = renderHook(() => useRequiredIndexLink("spaceships"), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
@@ -178,12 +179,12 @@ describe("Test base api hooks", () => {
|
||||
version: "x.y.z",
|
||||
_links: {
|
||||
spaceships: {
|
||||
href: "/api/spaceships",
|
||||
},
|
||||
},
|
||||
href: "/api/spaceships"
|
||||
}
|
||||
}
|
||||
});
|
||||
const { result } = renderHook(() => useRequiredIndexLink("spaceships"), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
expect(result.current).toBe("/api/spaceships");
|
||||
});
|
||||
@@ -196,19 +197,19 @@ describe("Test base api hooks", () => {
|
||||
version: "x.y.z",
|
||||
_links: {
|
||||
spaceships: {
|
||||
href: "/spaceships",
|
||||
},
|
||||
},
|
||||
href: "/spaceships"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const spaceship = {
|
||||
name: "heartOfGold",
|
||||
name: "heartOfGold"
|
||||
};
|
||||
|
||||
fetchMock.get("/api/v2/spaceships", spaceship);
|
||||
|
||||
const { result, waitFor } = renderHook(() => useIndexJsonResource<typeof spaceship>("spaceships"), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -223,11 +224,11 @@ describe("Test base api hooks", () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData("index", {
|
||||
version: "x.y.z",
|
||||
_links: {},
|
||||
_links: {}
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIndexJsonResource<{}>("spaceships"), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
|
||||
@@ -66,3 +66,5 @@ export * from "./compare";
|
||||
|
||||
export { default as ApiProvider } from "./ApiProvider";
|
||||
export * from "./ApiProvider";
|
||||
|
||||
export * from "./LegacyContext";
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("Test login hooks", () => {
|
||||
name: "tricia",
|
||||
displayName: "Tricia",
|
||||
groups: [],
|
||||
_links: {},
|
||||
_links: {}
|
||||
};
|
||||
|
||||
describe("useMe tests", () => {
|
||||
@@ -45,7 +45,7 @@ describe("Test login hooks", () => {
|
||||
name: "tricia",
|
||||
displayName: "Tricia",
|
||||
groups: [],
|
||||
_links: {},
|
||||
_links: {}
|
||||
});
|
||||
|
||||
it("should return me", async () => {
|
||||
@@ -65,9 +65,10 @@ describe("Test login hooks", () => {
|
||||
|
||||
let me: Me;
|
||||
const context: LegacyContext = {
|
||||
onMeFetched: (fetchedMe) => {
|
||||
onMeFetched: fetchedMe => {
|
||||
me = fetchedMe;
|
||||
},
|
||||
initialize: () => null
|
||||
};
|
||||
|
||||
const { result, waitFor } = renderHook(() => useMe(), { wrapper: createWrapper(context, queryClient) });
|
||||
@@ -130,7 +131,7 @@ describe("Test login hooks", () => {
|
||||
name: "_anonymous",
|
||||
displayName: "Anonymous",
|
||||
groups: [],
|
||||
_links: {},
|
||||
_links: {}
|
||||
});
|
||||
const { result } = renderHook(() => useSubject(), { wrapper: createWrapper(undefined, queryClient) });
|
||||
|
||||
@@ -158,8 +159,8 @@ describe("Test login hooks", () => {
|
||||
cookie: true,
|
||||
grant_type: "password",
|
||||
username: "tricia",
|
||||
password: "hitchhikersSecret!",
|
||||
},
|
||||
password: "hitchhikersSecret!"
|
||||
}
|
||||
});
|
||||
|
||||
// required because we invalidate the whole cache and react-query refetches the index
|
||||
@@ -167,13 +168,13 @@ describe("Test login hooks", () => {
|
||||
version: "x.y.z",
|
||||
_links: {
|
||||
login: {
|
||||
href: "/second/login",
|
||||
},
|
||||
},
|
||||
href: "/second/login"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(() => useLogin(), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
const { login } = result.current;
|
||||
expect(login).toBeDefined();
|
||||
@@ -194,7 +195,7 @@ describe("Test login hooks", () => {
|
||||
queryClient.setQueryData("me", tricia);
|
||||
|
||||
const { result } = renderHook(() => useLogin(), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
|
||||
expect(result.current.login).toBeUndefined();
|
||||
@@ -209,7 +210,7 @@ describe("Test login hooks", () => {
|
||||
fetchMock.deleteOnce("/api/v2/logout", {});
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(() => useLogout(), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
const { logout } = result.current;
|
||||
expect(logout).toBeDefined();
|
||||
@@ -229,7 +230,7 @@ describe("Test login hooks", () => {
|
||||
setEmptyIndex(queryClient);
|
||||
|
||||
const { result } = renderHook(() => useLogout(), {
|
||||
wrapper: createWrapper(undefined, queryClient),
|
||||
wrapper: createWrapper(undefined, queryClient)
|
||||
});
|
||||
|
||||
const { logout } = result.current;
|
||||
|
||||
230
scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx
Normal file
230
scm-ui/ui-components/src/markdown/LazyMarkdownView.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { FC } from "react";
|
||||
import { RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import unified from "unified";
|
||||
import parseMarkdown from "remark-parse";
|
||||
import sanitize from "rehype-sanitize";
|
||||
import remark2rehype from "remark-rehype";
|
||||
import rehype2react from "rehype-react";
|
||||
import gfm from "remark-gfm";
|
||||
import { BinderContext } from "@scm-manager/ui-extensions";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
import { create as createMarkdownHeadingRenderer } from "./MarkdownHeadingRenderer";
|
||||
import { create as createMarkdownLinkRenderer } from "./MarkdownLinkRenderer";
|
||||
import { useTranslation, WithTranslation, withTranslation } from "react-i18next";
|
||||
import Notification from "../Notification";
|
||||
import { createTransformer as createChangesetShortlinkParser } from "./remarkChangesetShortLinkParser";
|
||||
import { createTransformer as createValuelessTextAdapter } from "./remarkValuelessTextAdapter";
|
||||
import MarkdownCodeRenderer from "./MarkdownCodeRenderer";
|
||||
import { AstPlugin } from "./PluginApi";
|
||||
import createMdastPlugin from "./createMdastPlugin";
|
||||
// @ts-ignore
|
||||
import gh from "hast-util-sanitize/lib/github";
|
||||
import raw from "rehype-raw";
|
||||
import slug from "rehype-slug";
|
||||
import merge from "deepmerge";
|
||||
import { createComponentList } from "./createComponentList";
|
||||
import { ProtocolLinkRendererExtension, ProtocolLinkRendererExtensionMap } from "./markdownExtensions";
|
||||
|
||||
export type MarkdownProps = {
|
||||
content: string;
|
||||
renderContext?: object;
|
||||
renderers?: any;
|
||||
skipHtml?: boolean;
|
||||
enableAnchorHeadings?: boolean;
|
||||
// basePath for markdown links
|
||||
basePath?: string;
|
||||
permalink?: string;
|
||||
mdastPlugins?: AstPlugin[];
|
||||
};
|
||||
|
||||
type Props = RouteComponentProps & WithTranslation & MarkdownProps;
|
||||
|
||||
type State = {
|
||||
contentRef: HTMLDivElement | null | undefined;
|
||||
};
|
||||
|
||||
const xmlMarkupSample = `\`\`\`xml
|
||||
<your>
|
||||
<xml>
|
||||
<content/>
|
||||
</xml>
|
||||
</your>
|
||||
\`\`\``;
|
||||
|
||||
const MarkdownErrorNotification: FC = () => {
|
||||
const [t] = useTranslation("commons");
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<div className="content">
|
||||
<p className="subtitle">{t("markdownErrorNotification.title")}</p>
|
||||
<p>{t("markdownErrorNotification.description")}</p>
|
||||
<pre>
|
||||
<code>{xmlMarkupSample}</code>
|
||||
</pre>
|
||||
<p>
|
||||
{t("markdownErrorNotification.spec")}:{" "}
|
||||
<a href="https://github.github.com/gfm/" target="_blank" rel="noreferrer">
|
||||
GitHub Flavored Markdown Spec
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Notification>
|
||||
);
|
||||
};
|
||||
|
||||
class LazyMarkdownView extends React.Component<Props, State> {
|
||||
static contextType = BinderContext;
|
||||
|
||||
static defaultProps: Partial<Props> = {
|
||||
enableAnchorHeadings: false,
|
||||
skipHtml: false
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
contentRef: null
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Readonly<Props>, nextState: Readonly<State>): boolean {
|
||||
// We have check if the contentRef changed and update afterwards so the page can scroll to the anchor links.
|
||||
// Otherwise it can happen that componentDidUpdate is never executed depending on how fast the markdown got rendered
|
||||
// We also have to check if props have changed, because we also want to rerender if one of our props has changed
|
||||
return this.state.contentRef !== nextState.contentRef || this.props !== nextProps;
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { contentRef } = this.state;
|
||||
// we have to use componentDidUpdate, because we have to wait until all
|
||||
// children are rendered and componentDidMount is called before the
|
||||
// markdown content was rendered.
|
||||
const hash = this.props.location.hash;
|
||||
if (contentRef && hash) {
|
||||
// we query only child elements, to avoid strange scrolling with multiple
|
||||
// markdown elements on one page.
|
||||
const elementId = decodeURIComponent(hash.substring(1) /* remove # */);
|
||||
const element = contentRef.querySelector(`[id="${elementId}"]`);
|
||||
if (element && element.scrollIntoView) {
|
||||
element.scrollIntoView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
content,
|
||||
renderers,
|
||||
renderContext,
|
||||
enableAnchorHeadings,
|
||||
skipHtml,
|
||||
basePath,
|
||||
permalink,
|
||||
t,
|
||||
mdastPlugins = []
|
||||
} = this.props;
|
||||
|
||||
const rendererFactory = this.context.getExtension("markdown-renderer-factory");
|
||||
let remarkRendererList = renderers;
|
||||
|
||||
if (rendererFactory) {
|
||||
remarkRendererList = rendererFactory(renderContext);
|
||||
}
|
||||
|
||||
if (!remarkRendererList) {
|
||||
remarkRendererList = {};
|
||||
}
|
||||
|
||||
if (enableAnchorHeadings && permalink && !remarkRendererList.heading) {
|
||||
remarkRendererList.heading = createMarkdownHeadingRenderer(permalink);
|
||||
}
|
||||
|
||||
let protocolLinkRendererExtensions: ProtocolLinkRendererExtensionMap = {};
|
||||
if (!remarkRendererList.link) {
|
||||
const extensionPoints = this.context.getExtensions(
|
||||
"markdown-renderer.link.protocol"
|
||||
) as ProtocolLinkRendererExtension[];
|
||||
protocolLinkRendererExtensions = extensionPoints.reduce<ProtocolLinkRendererExtensionMap>(
|
||||
(prev, { protocol, renderer }) => {
|
||||
prev[protocol] = renderer;
|
||||
return prev;
|
||||
},
|
||||
{}
|
||||
);
|
||||
remarkRendererList.link = createMarkdownLinkRenderer(basePath, protocolLinkRendererExtensions);
|
||||
}
|
||||
|
||||
if (!remarkRendererList.code) {
|
||||
remarkRendererList.code = MarkdownCodeRenderer;
|
||||
}
|
||||
|
||||
const remarkPlugins = [...mdastPlugins, createChangesetShortlinkParser(t), createValuelessTextAdapter()].map(
|
||||
createMdastPlugin
|
||||
);
|
||||
|
||||
let processor = unified()
|
||||
.use(parseMarkdown)
|
||||
.use(gfm)
|
||||
.use(remarkPlugins)
|
||||
.use(remark2rehype, { allowDangerousHtml: true });
|
||||
|
||||
if (!skipHtml) {
|
||||
processor = processor.use(raw);
|
||||
}
|
||||
|
||||
processor = processor
|
||||
.use(slug)
|
||||
.use(
|
||||
sanitize,
|
||||
merge(gh, {
|
||||
attributes: {
|
||||
code: ["className"] // Allow className for code elements, this is necessary to extract the code language
|
||||
},
|
||||
clobberPrefix: "", // Do not prefix user-provided ids and class names,
|
||||
protocols: {
|
||||
href: Object.keys(protocolLinkRendererExtensions)
|
||||
}
|
||||
})
|
||||
)
|
||||
.use(rehype2react, {
|
||||
createElement: React.createElement,
|
||||
passNode: true,
|
||||
components: createComponentList(remarkRendererList, { permalink })
|
||||
});
|
||||
|
||||
const renderedMarkdown: any = processor.processSync(content).result;
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={MarkdownErrorNotification}>
|
||||
<div ref={el => this.setState({ contentRef: el })} className="content is-word-break">
|
||||
{renderedMarkdown}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(withTranslation("repos")(LazyMarkdownView));
|
||||
@@ -21,209 +21,16 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { FC } from "react";
|
||||
import { RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import unified from "unified";
|
||||
import parseMarkdown from "remark-parse";
|
||||
import sanitize from "rehype-sanitize";
|
||||
import remark2rehype from "remark-rehype";
|
||||
import rehype2react from "rehype-react";
|
||||
import gfm from "remark-gfm";
|
||||
import { BinderContext } from "@scm-manager/ui-extensions";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
import { create as createMarkdownHeadingRenderer } from "./MarkdownHeadingRenderer";
|
||||
import { create as createMarkdownLinkRenderer } from "./MarkdownLinkRenderer";
|
||||
import { useTranslation, WithTranslation, withTranslation } from "react-i18next";
|
||||
import Notification from "../Notification";
|
||||
import { createTransformer as createChangesetShortlinkParser } from "./remarkChangesetShortLinkParser";
|
||||
import { createTransformer as createValuelessTextAdapter } from "./remarkValuelessTextAdapter";
|
||||
import MarkdownCodeRenderer from "./MarkdownCodeRenderer";
|
||||
import { AstPlugin } from "./PluginApi";
|
||||
import createMdastPlugin from "./createMdastPlugin";
|
||||
// @ts-ignore
|
||||
import gh from "hast-util-sanitize/lib/github";
|
||||
import raw from "rehype-raw";
|
||||
import slug from "rehype-slug";
|
||||
import merge from "deepmerge";
|
||||
import { createComponentList } from "./createComponentList";
|
||||
import { ProtocolLinkRendererExtension, ProtocolLinkRendererExtensionMap } from "./markdownExtensions";
|
||||
import React, { FC, Suspense } from "react";
|
||||
import { MarkdownProps } from "./LazyMarkdownView";
|
||||
import Loading from "../Loading";
|
||||
|
||||
type Props = RouteComponentProps &
|
||||
WithTranslation & {
|
||||
content: string;
|
||||
renderContext?: object;
|
||||
renderers?: any;
|
||||
skipHtml?: boolean;
|
||||
enableAnchorHeadings?: boolean;
|
||||
// basePath for markdown links
|
||||
basePath?: string;
|
||||
permalink?: string;
|
||||
mdastPlugins?: AstPlugin[];
|
||||
};
|
||||
const LazyMarkdownView = React.lazy(() => import("./LazyMarkdownView"));
|
||||
|
||||
type State = {
|
||||
contentRef: HTMLDivElement | null | undefined;
|
||||
};
|
||||
const MarkdownView: FC<MarkdownProps> = props => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<LazyMarkdownView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const xmlMarkupSample = `\`\`\`xml
|
||||
<your>
|
||||
<xml>
|
||||
<content/>
|
||||
</xml>
|
||||
</your>
|
||||
\`\`\``;
|
||||
|
||||
const MarkdownErrorNotification: FC = () => {
|
||||
const [t] = useTranslation("commons");
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<div className="content">
|
||||
<p className="subtitle">{t("markdownErrorNotification.title")}</p>
|
||||
<p>{t("markdownErrorNotification.description")}</p>
|
||||
<pre>
|
||||
<code>{xmlMarkupSample}</code>
|
||||
</pre>
|
||||
<p>
|
||||
{t("markdownErrorNotification.spec")}:{" "}
|
||||
<a href="https://github.github.com/gfm/" target="_blank" rel="noreferrer">
|
||||
GitHub Flavored Markdown Spec
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Notification>
|
||||
);
|
||||
};
|
||||
|
||||
class MarkdownView extends React.Component<Props, State> {
|
||||
static contextType = BinderContext;
|
||||
|
||||
static defaultProps: Partial<Props> = {
|
||||
enableAnchorHeadings: false,
|
||||
skipHtml: false,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
contentRef: null,
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Readonly<Props>, nextState: Readonly<State>): boolean {
|
||||
// We have check if the contentRef changed and update afterwards so the page can scroll to the anchor links.
|
||||
// Otherwise it can happen that componentDidUpdate is never executed depending on how fast the markdown got rendered
|
||||
// We also have to check if props have changed, because we also want to rerender if one of our props has changed
|
||||
return this.state.contentRef !== nextState.contentRef || this.props !== nextProps;
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { contentRef } = this.state;
|
||||
// we have to use componentDidUpdate, because we have to wait until all
|
||||
// children are rendered and componentDidMount is called before the
|
||||
// markdown content was rendered.
|
||||
const hash = this.props.location.hash;
|
||||
if (contentRef && hash) {
|
||||
// we query only child elements, to avoid strange scrolling with multiple
|
||||
// markdown elements on one page.
|
||||
const elementId = decodeURIComponent(hash.substring(1) /* remove # */);
|
||||
const element = contentRef.querySelector(`[id="${elementId}"]`);
|
||||
if (element && element.scrollIntoView) {
|
||||
element.scrollIntoView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
content,
|
||||
renderers,
|
||||
renderContext,
|
||||
enableAnchorHeadings,
|
||||
skipHtml,
|
||||
basePath,
|
||||
permalink,
|
||||
t,
|
||||
mdastPlugins = [],
|
||||
} = this.props;
|
||||
|
||||
const rendererFactory = this.context.getExtension("markdown-renderer-factory");
|
||||
let remarkRendererList = renderers;
|
||||
|
||||
if (rendererFactory) {
|
||||
remarkRendererList = rendererFactory(renderContext);
|
||||
}
|
||||
|
||||
if (!remarkRendererList) {
|
||||
remarkRendererList = {};
|
||||
}
|
||||
|
||||
if (enableAnchorHeadings && permalink && !remarkRendererList.heading) {
|
||||
remarkRendererList.heading = createMarkdownHeadingRenderer(permalink);
|
||||
}
|
||||
|
||||
let protocolLinkRendererExtensions: ProtocolLinkRendererExtensionMap = {};
|
||||
if (!remarkRendererList.link) {
|
||||
const extensionPoints = this.context.getExtensions(
|
||||
"markdown-renderer.link.protocol"
|
||||
) as ProtocolLinkRendererExtension[];
|
||||
protocolLinkRendererExtensions = extensionPoints.reduce<ProtocolLinkRendererExtensionMap>(
|
||||
(prev, { protocol, renderer }) => {
|
||||
prev[protocol] = renderer;
|
||||
return prev;
|
||||
},
|
||||
{}
|
||||
);
|
||||
remarkRendererList.link = createMarkdownLinkRenderer(basePath, protocolLinkRendererExtensions);
|
||||
}
|
||||
|
||||
if (!remarkRendererList.code) {
|
||||
remarkRendererList.code = MarkdownCodeRenderer;
|
||||
}
|
||||
|
||||
const remarkPlugins = [...mdastPlugins, createChangesetShortlinkParser(t), createValuelessTextAdapter()].map(
|
||||
createMdastPlugin
|
||||
);
|
||||
|
||||
let processor = unified()
|
||||
.use(parseMarkdown)
|
||||
.use(gfm)
|
||||
.use(remarkPlugins)
|
||||
.use(remark2rehype, { allowDangerousHtml: true });
|
||||
|
||||
if (!skipHtml) {
|
||||
processor = processor.use(raw);
|
||||
}
|
||||
|
||||
processor = processor
|
||||
.use(slug)
|
||||
.use(
|
||||
sanitize,
|
||||
merge(gh, {
|
||||
attributes: {
|
||||
code: ["className"], // Allow className for code elements, this is necessary to extract the code language
|
||||
},
|
||||
clobberPrefix: "", // Do not prefix user-provided ids and class names,
|
||||
protocols: {
|
||||
href: Object.keys(protocolLinkRendererExtensions),
|
||||
},
|
||||
})
|
||||
)
|
||||
.use(rehype2react, {
|
||||
createElement: React.createElement,
|
||||
passNode: true,
|
||||
components: createComponentList(remarkRendererList, { permalink }),
|
||||
});
|
||||
|
||||
const renderedMarkdown: any = processor.processSync(content).result;
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={MarkdownErrorNotification}>
|
||||
<div ref={(el) => this.setState({ contentRef: el })} className="content is-word-break">
|
||||
{renderedMarkdown}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(withTranslation("repos")(MarkdownView));
|
||||
export default MarkdownView;
|
||||
|
||||
@@ -64,14 +64,14 @@ const Diff: FC<Props> = ({ diff, ...fileProps }) => {
|
||||
{diff.length === 0 ? (
|
||||
<Notification type="info">{t("diff.noDiffFound")}</Notification>
|
||||
) : (
|
||||
diff.map((file) => <DiffFile key={createKey(file)} file={file} {...fileProps} />)
|
||||
diff.map(file => <DiffFile key={createKey(file)} file={file} {...fileProps} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Diff.defaultProps = {
|
||||
sideBySide: false,
|
||||
sideBySide: false
|
||||
};
|
||||
|
||||
export default Diff;
|
||||
|
||||
@@ -21,499 +21,17 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import styled from "styled-components";
|
||||
// @ts-ignore
|
||||
import { Decoration, getChangeKey, Hunk } from "react-diff-view";
|
||||
import { ButtonGroup } from "../buttons";
|
||||
import Tag from "../Tag";
|
||||
import Icon from "../Icon";
|
||||
import { Change, FileDiff, Hunk as HunkType } from "@scm-manager/ui-types";
|
||||
import { ChangeEvent, DiffObjectProps } from "./DiffTypes";
|
||||
import TokenizedDiffView from "./TokenizedDiffView";
|
||||
import DiffButton from "./DiffButton";
|
||||
import { MenuContext, OpenInFullscreenButton } from "@scm-manager/ui-components";
|
||||
import DiffExpander, { ExpandableHunk } from "./DiffExpander";
|
||||
import HunkExpandLink from "./HunkExpandLink";
|
||||
import { Modal } from "../modals";
|
||||
import ErrorNotification from "../ErrorNotification";
|
||||
import HunkExpandDivider from "./HunkExpandDivider";
|
||||
import { escapeWhitespace } from "./diffs";
|
||||
|
||||
const EMPTY_ANNOTATION_FACTORY = {};
|
||||
import React, { FC, Suspense } from "react";
|
||||
import { DiffFileProps } from "./LazyDiffFile";
|
||||
import Loading from "../Loading";
|
||||
|
||||
type Props = DiffObjectProps &
|
||||
WithTranslation & {
|
||||
file: FileDiff;
|
||||
};
|
||||
const LazyDiffFile = React.lazy(() => import("./LazyDiffFile"));
|
||||
|
||||
type Collapsible = {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
const DiffFile: FC<DiffFileProps> = props => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<LazyDiffFile {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
type State = Collapsible & {
|
||||
file: FileDiff;
|
||||
sideBySide?: boolean;
|
||||
diffExpander: DiffExpander;
|
||||
expansionError?: any;
|
||||
};
|
||||
|
||||
const DiffFilePanel = styled.div`
|
||||
/* remove bottom border for collapsed panels */
|
||||
${(props: Collapsible) => (props.collapsed ? "border-bottom: none;" : "")};
|
||||
`;
|
||||
|
||||
const FullWidthTitleHeader = styled.div`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const MarginlessModalContent = styled.div`
|
||||
margin: -1.25rem;
|
||||
|
||||
& .panel-block {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
`;
|
||||
|
||||
class DiffFile extends React.Component<Props, State> {
|
||||
static defaultProps: Partial<Props> = {
|
||||
defaultCollapse: false,
|
||||
markConflicts: true
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: this.defaultCollapse(),
|
||||
sideBySide: props.sideBySide,
|
||||
diffExpander: new DiffExpander(props.file),
|
||||
file: props.file
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<Props>) {
|
||||
if (!this.props.isCollapsed && this.props.defaultCollapse !== prevProps.defaultCollapse) {
|
||||
this.setState({
|
||||
collapsed: this.defaultCollapse()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
defaultCollapse: () => boolean = () => {
|
||||
const { defaultCollapse, file } = this.props;
|
||||
if (typeof defaultCollapse === "boolean") {
|
||||
return defaultCollapse;
|
||||
} else if (typeof defaultCollapse === "function") {
|
||||
return defaultCollapse(file.oldPath, file.newPath);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
toggleCollapse = () => {
|
||||
const { onCollapseStateChange } = this.props;
|
||||
const { file } = this.state;
|
||||
if (this.hasContent(file)) {
|
||||
if (onCollapseStateChange) {
|
||||
onCollapseStateChange(file);
|
||||
} else {
|
||||
this.setState(state => ({
|
||||
collapsed: !state.collapsed
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
toggleSideBySide = (callback: () => void) => {
|
||||
this.setState(
|
||||
state => ({
|
||||
sideBySide: !state.sideBySide
|
||||
}),
|
||||
() => callback()
|
||||
);
|
||||
};
|
||||
|
||||
setCollapse = (collapsed: boolean) => {
|
||||
const { onCollapseStateChange } = this.props;
|
||||
if (onCollapseStateChange) {
|
||||
onCollapseStateChange(this.state.file, collapsed);
|
||||
} else {
|
||||
this.setState({
|
||||
collapsed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
createHunkHeader = (expandableHunk: ExpandableHunk) => {
|
||||
if (expandableHunk.maxExpandHeadRange > 0) {
|
||||
if (expandableHunk.maxExpandHeadRange <= 10) {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-up"}
|
||||
onClick={this.expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
|
||||
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-up"}
|
||||
onClick={this.expandHead(expandableHunk, 10)}
|
||||
text={this.props.t("diff.expandByLines", { count: 10 })}
|
||||
/>{" "}
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-up"}
|
||||
onClick={this.expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
|
||||
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
}
|
||||
}
|
||||
// hunk header must be defined
|
||||
return <span />;
|
||||
};
|
||||
|
||||
createHunkFooter = (expandableHunk: ExpandableHunk) => {
|
||||
if (expandableHunk.maxExpandBottomRange > 0) {
|
||||
if (expandableHunk.maxExpandBottomRange <= 10) {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-down"}
|
||||
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
|
||||
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-down"}
|
||||
onClick={this.expandBottom(expandableHunk, 10)}
|
||||
text={this.props.t("diff.expandByLines", { count: 10 })}
|
||||
/>{" "}
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-down"}
|
||||
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
|
||||
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
}
|
||||
}
|
||||
// hunk footer must be defined
|
||||
return <span />;
|
||||
};
|
||||
|
||||
createLastHunkFooter = (expandableHunk: ExpandableHunk) => {
|
||||
if (expandableHunk.maxExpandBottomRange !== 0) {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-down"}
|
||||
onClick={this.expandBottom(expandableHunk, 10)}
|
||||
text={this.props.t("diff.expandLastBottomByLines", { count: 10 })}
|
||||
/>{" "}
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-down"}
|
||||
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
|
||||
text={this.props.t("diff.expandLastBottomComplete")}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
}
|
||||
// hunk header must be defined
|
||||
return <span />;
|
||||
};
|
||||
|
||||
expandHead = (expandableHunk: ExpandableHunk, count: number) => {
|
||||
return () => {
|
||||
return expandableHunk
|
||||
.expandHead(count)
|
||||
.then(this.diffExpanded)
|
||||
.catch(this.diffExpansionFailed);
|
||||
};
|
||||
};
|
||||
|
||||
expandBottom = (expandableHunk: ExpandableHunk, count: number) => {
|
||||
return () => {
|
||||
return expandableHunk
|
||||
.expandBottom(count)
|
||||
.then(this.diffExpanded)
|
||||
.catch(this.diffExpansionFailed);
|
||||
};
|
||||
};
|
||||
|
||||
diffExpanded = (newFile: FileDiff) => {
|
||||
this.setState({ file: newFile, diffExpander: new DiffExpander(newFile) });
|
||||
};
|
||||
|
||||
diffExpansionFailed = (err: any) => {
|
||||
this.setState({ expansionError: err });
|
||||
};
|
||||
|
||||
collectHunkAnnotations = (hunk: HunkType) => {
|
||||
const { annotationFactory } = this.props;
|
||||
const { file } = this.state;
|
||||
if (annotationFactory) {
|
||||
return annotationFactory({
|
||||
hunk,
|
||||
file
|
||||
});
|
||||
} else {
|
||||
return EMPTY_ANNOTATION_FACTORY;
|
||||
}
|
||||
};
|
||||
|
||||
handleClickEvent = (change: Change, hunk: HunkType) => {
|
||||
const { onClick } = this.props;
|
||||
const { file } = this.state;
|
||||
const context = {
|
||||
changeId: getChangeKey(change),
|
||||
change,
|
||||
hunk,
|
||||
file
|
||||
};
|
||||
if (onClick) {
|
||||
onClick(context);
|
||||
}
|
||||
};
|
||||
|
||||
createGutterEvents = (hunk: HunkType) => {
|
||||
const { onClick } = this.props;
|
||||
if (onClick) {
|
||||
return {
|
||||
onClick: (event: ChangeEvent) => {
|
||||
this.handleClickEvent(event.change, hunk);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
renderHunk = (file: FileDiff, expandableHunk: ExpandableHunk, i: number) => {
|
||||
const hunk = expandableHunk.hunk;
|
||||
if (this.props.markConflicts && hunk.changes) {
|
||||
this.markConflicts(hunk);
|
||||
}
|
||||
const items = [];
|
||||
if (file._links?.lines) {
|
||||
items.push(this.createHunkHeader(expandableHunk));
|
||||
} else if (i > 0) {
|
||||
items.push(
|
||||
<Decoration>
|
||||
<hr className="my-2" />
|
||||
</Decoration>
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
<Hunk
|
||||
key={"hunk-" + hunk.content}
|
||||
hunk={expandableHunk.hunk}
|
||||
widgets={this.collectHunkAnnotations(hunk)}
|
||||
gutterEvents={this.createGutterEvents(hunk)}
|
||||
className={this.props.hunkClass ? this.props.hunkClass(hunk) : null}
|
||||
/>
|
||||
);
|
||||
if (file._links?.lines) {
|
||||
if (i === file.hunks!.length - 1) {
|
||||
items.push(this.createLastHunkFooter(expandableHunk));
|
||||
} else {
|
||||
items.push(this.createHunkFooter(expandableHunk));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
markConflicts = (hunk: HunkType) => {
|
||||
let inConflict = false;
|
||||
for (let i = 0; i < hunk.changes.length; ++i) {
|
||||
if (hunk.changes[i].content === "<<<<<<< HEAD") {
|
||||
inConflict = true;
|
||||
}
|
||||
if (inConflict) {
|
||||
hunk.changes[i].type = "conflict";
|
||||
}
|
||||
if (hunk.changes[i].content.startsWith(">>>>>>>")) {
|
||||
inConflict = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getAnchorId(file: FileDiff) {
|
||||
let path: string;
|
||||
if (file.type === "delete") {
|
||||
path = file.oldPath;
|
||||
} else {
|
||||
path = file.newPath;
|
||||
}
|
||||
return escapeWhitespace(path);
|
||||
}
|
||||
|
||||
renderFileTitle = (file: FileDiff) => {
|
||||
const { t } = this.props;
|
||||
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
|
||||
return (
|
||||
<>
|
||||
{file.oldPath} <Icon name="arrow-right" color="inherit" alt={t("diff.renamedTo")} /> {file.newPath}
|
||||
</>
|
||||
);
|
||||
} else if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
return file.newPath;
|
||||
};
|
||||
|
||||
hoverFileTitle = (file: FileDiff): string => {
|
||||
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
|
||||
return `${file.oldPath} > ${file.newPath}`;
|
||||
} else if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
return file.newPath;
|
||||
};
|
||||
|
||||
renderChangeTag = (file: FileDiff) => {
|
||||
const { t } = this.props;
|
||||
if (!file.type) {
|
||||
return;
|
||||
}
|
||||
const key = "diff.changes." + file.type;
|
||||
let value = t(key);
|
||||
if (key === value) {
|
||||
value = file.type;
|
||||
}
|
||||
|
||||
const color = value === "added" ? "success" : value === "deleted" ? "danger" : "info";
|
||||
return (
|
||||
<Tag
|
||||
className={classNames("has-text-weight-normal", "ml-3")}
|
||||
rounded={true}
|
||||
outlined={true}
|
||||
color={color}
|
||||
label={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
isCollapsed = () => {
|
||||
const { file, isCollapsed } = this.props;
|
||||
if (isCollapsed) {
|
||||
return isCollapsed(file);
|
||||
}
|
||||
return this.state.collapsed;
|
||||
};
|
||||
|
||||
hasContent = (file: FileDiff) => file && !file.isBinary && file.hunks && file.hunks.length > 0;
|
||||
|
||||
render() {
|
||||
const { fileControlFactory, fileAnnotationFactory, t } = this.props;
|
||||
const { file, sideBySide, diffExpander, expansionError } = this.state;
|
||||
const viewType = sideBySide ? "split" : "unified";
|
||||
const collapsed = this.isCollapsed();
|
||||
|
||||
const fileAnnotations = fileAnnotationFactory ? fileAnnotationFactory(file) : null;
|
||||
const innerContent = (
|
||||
<div className="panel-block p-0">
|
||||
{fileAnnotations}
|
||||
<TokenizedDiffView className={viewType} viewType={viewType} file={file}>
|
||||
{(hunks: HunkType[]) =>
|
||||
hunks?.map((hunk, n) => {
|
||||
return this.renderHunk(file, diffExpander.getHunk(n), n);
|
||||
})
|
||||
}
|
||||
</TokenizedDiffView>
|
||||
</div>
|
||||
);
|
||||
let icon = <Icon name="angle-right" color="inherit" alt={t("diff.showContent")} />;
|
||||
let body = null;
|
||||
if (!collapsed) {
|
||||
icon = <Icon name="angle-down" color="inherit" alt={t("diff.hideContent")} />;
|
||||
body = innerContent;
|
||||
}
|
||||
const collapseIcon = this.hasContent(file) ? icon : null;
|
||||
const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null;
|
||||
const modalTitle = file.type === "delete" ? file.oldPath : file.newPath;
|
||||
const openInFullscreen = file?.hunks?.length ? (
|
||||
<OpenInFullscreenButton
|
||||
modalTitle={modalTitle}
|
||||
modalBody={<MarginlessModalContent>{innerContent}</MarginlessModalContent>}
|
||||
/>
|
||||
) : null;
|
||||
const sideBySideToggle = file?.hunks?.length && (
|
||||
<MenuContext.Consumer>
|
||||
{({ setCollapsed }) => (
|
||||
<DiffButton
|
||||
icon={sideBySide ? "align-left" : "columns"}
|
||||
tooltip={t(sideBySide ? "diff.combined" : "diff.sideBySide")}
|
||||
onClick={() =>
|
||||
this.toggleSideBySide(() => {
|
||||
if (this.state.sideBySide) {
|
||||
setCollapsed(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</MenuContext.Consumer>
|
||||
);
|
||||
const headerButtons = (
|
||||
<div className={classNames("level-right", "is-flex", "ml-auto")}>
|
||||
<ButtonGroup>
|
||||
{sideBySideToggle}
|
||||
{openInFullscreen}
|
||||
{fileControls}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
let errorModal;
|
||||
if (expansionError) {
|
||||
errorModal = (
|
||||
<Modal
|
||||
title={t("diff.expansionFailed")}
|
||||
closeFunction={() => this.setState({ expansionError: undefined })}
|
||||
body={<ErrorNotification error={expansionError} />}
|
||||
active={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DiffFilePanel
|
||||
className={classNames("panel", "is-size-6")}
|
||||
collapsed={(file && file.isBinary) || collapsed}
|
||||
id={this.getAnchorId(file)}
|
||||
>
|
||||
{errorModal}
|
||||
<div className="panel-heading">
|
||||
<div className={classNames("level", "is-flex-wrap-wrap")}>
|
||||
<FullWidthTitleHeader
|
||||
className={classNames("level-left", "is-flex", "is-clickable")}
|
||||
onClick={this.toggleCollapse}
|
||||
title={this.hoverFileTitle(file)}
|
||||
>
|
||||
{collapseIcon}
|
||||
<h4 className={classNames("has-text-weight-bold", "is-ellipsis-overflow", "is-size-6", "ml-1")}>
|
||||
{this.renderFileTitle(file)}
|
||||
</h4>
|
||||
{this.renderChangeTag(file)}
|
||||
</FullWidthTitleHeader>
|
||||
{headerButtons}
|
||||
</div>
|
||||
</div>
|
||||
{body}
|
||||
</DiffFilePanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation("repos")(DiffFile);
|
||||
export default DiffFile;
|
||||
|
||||
520
scm-ui/ui-components/src/repos/LazyDiffFile.tsx
Normal file
520
scm-ui/ui-components/src/repos/LazyDiffFile.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import styled from "styled-components";
|
||||
// @ts-ignore
|
||||
import { Decoration, getChangeKey, Hunk } from "react-diff-view";
|
||||
import { ButtonGroup } from "../buttons";
|
||||
import Tag from "../Tag";
|
||||
import Icon from "../Icon";
|
||||
import { Change, FileDiff, Hunk as HunkType } from "@scm-manager/ui-types";
|
||||
import { ChangeEvent, DiffObjectProps } from "./DiffTypes";
|
||||
import TokenizedDiffView from "./TokenizedDiffView";
|
||||
import DiffButton from "./DiffButton";
|
||||
import { MenuContext, OpenInFullscreenButton } from "@scm-manager/ui-components";
|
||||
import DiffExpander, { ExpandableHunk } from "./DiffExpander";
|
||||
import HunkExpandLink from "./HunkExpandLink";
|
||||
import { Modal } from "../modals";
|
||||
import ErrorNotification from "../ErrorNotification";
|
||||
import HunkExpandDivider from "./HunkExpandDivider";
|
||||
import { escapeWhitespace } from "./diffs";
|
||||
|
||||
const EMPTY_ANNOTATION_FACTORY = {};
|
||||
|
||||
type Props = DiffFileProps & WithTranslation;
|
||||
|
||||
export type DiffFileProps = DiffObjectProps & {
|
||||
file: FileDiff;
|
||||
};
|
||||
|
||||
type Collapsible = {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
type State = Collapsible & {
|
||||
file: FileDiff;
|
||||
sideBySide?: boolean;
|
||||
diffExpander: DiffExpander;
|
||||
expansionError?: any;
|
||||
};
|
||||
|
||||
const DiffFilePanel = styled.div`
|
||||
/* remove bottom border for collapsed panels */
|
||||
${(props: Collapsible) => (props.collapsed ? "border-bottom: none;" : "")};
|
||||
`;
|
||||
|
||||
const FullWidthTitleHeader = styled.div`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const MarginlessModalContent = styled.div`
|
||||
margin: -1.25rem;
|
||||
|
||||
& .panel-block {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
`;
|
||||
|
||||
class DiffFile extends React.Component<Props, State> {
|
||||
static defaultProps: Partial<Props> = {
|
||||
defaultCollapse: false,
|
||||
markConflicts: true
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: this.defaultCollapse(),
|
||||
sideBySide: props.sideBySide,
|
||||
diffExpander: new DiffExpander(props.file),
|
||||
file: props.file
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<Props>) {
|
||||
if (!this.props.isCollapsed && this.props.defaultCollapse !== prevProps.defaultCollapse) {
|
||||
this.setState({
|
||||
collapsed: this.defaultCollapse()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
defaultCollapse: () => boolean = () => {
|
||||
const { defaultCollapse, file } = this.props;
|
||||
if (typeof defaultCollapse === "boolean") {
|
||||
return defaultCollapse;
|
||||
} else if (typeof defaultCollapse === "function") {
|
||||
return defaultCollapse(file.oldPath, file.newPath);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
toggleCollapse = () => {
|
||||
const { onCollapseStateChange } = this.props;
|
||||
const { file } = this.state;
|
||||
if (this.hasContent(file)) {
|
||||
if (onCollapseStateChange) {
|
||||
onCollapseStateChange(file);
|
||||
} else {
|
||||
this.setState(state => ({
|
||||
collapsed: !state.collapsed
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
toggleSideBySide = (callback: () => void) => {
|
||||
this.setState(
|
||||
state => ({
|
||||
sideBySide: !state.sideBySide
|
||||
}),
|
||||
() => callback()
|
||||
);
|
||||
};
|
||||
|
||||
setCollapse = (collapsed: boolean) => {
|
||||
const { onCollapseStateChange } = this.props;
|
||||
if (onCollapseStateChange) {
|
||||
onCollapseStateChange(this.state.file, collapsed);
|
||||
} else {
|
||||
this.setState({
|
||||
collapsed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
createHunkHeader = (expandableHunk: ExpandableHunk) => {
|
||||
if (expandableHunk.maxExpandHeadRange > 0) {
|
||||
if (expandableHunk.maxExpandHeadRange <= 10) {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-up"}
|
||||
onClick={this.expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
|
||||
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-up"}
|
||||
onClick={this.expandHead(expandableHunk, 10)}
|
||||
text={this.props.t("diff.expandByLines", { count: 10 })}
|
||||
/>{" "}
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-up"}
|
||||
onClick={this.expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
|
||||
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
}
|
||||
}
|
||||
// hunk header must be defined
|
||||
return <span />;
|
||||
};
|
||||
|
||||
createHunkFooter = (expandableHunk: ExpandableHunk) => {
|
||||
if (expandableHunk.maxExpandBottomRange > 0) {
|
||||
if (expandableHunk.maxExpandBottomRange <= 10) {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-down"}
|
||||
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
|
||||
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-down"}
|
||||
onClick={this.expandBottom(expandableHunk, 10)}
|
||||
text={this.props.t("diff.expandByLines", { count: 10 })}
|
||||
/>{" "}
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-down"}
|
||||
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
|
||||
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
}
|
||||
}
|
||||
// hunk footer must be defined
|
||||
return <span />;
|
||||
};
|
||||
|
||||
createLastHunkFooter = (expandableHunk: ExpandableHunk) => {
|
||||
if (expandableHunk.maxExpandBottomRange !== 0) {
|
||||
return (
|
||||
<HunkExpandDivider>
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-down"}
|
||||
onClick={this.expandBottom(expandableHunk, 10)}
|
||||
text={this.props.t("diff.expandLastBottomByLines", { count: 10 })}
|
||||
/>{" "}
|
||||
<HunkExpandLink
|
||||
icon={"fa-angle-double-down"}
|
||||
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
|
||||
text={this.props.t("diff.expandLastBottomComplete")}
|
||||
/>
|
||||
</HunkExpandDivider>
|
||||
);
|
||||
}
|
||||
// hunk header must be defined
|
||||
return <span />;
|
||||
};
|
||||
|
||||
expandHead = (expandableHunk: ExpandableHunk, count: number) => {
|
||||
return () => {
|
||||
return expandableHunk
|
||||
.expandHead(count)
|
||||
.then(this.diffExpanded)
|
||||
.catch(this.diffExpansionFailed);
|
||||
};
|
||||
};
|
||||
|
||||
expandBottom = (expandableHunk: ExpandableHunk, count: number) => {
|
||||
return () => {
|
||||
return expandableHunk
|
||||
.expandBottom(count)
|
||||
.then(this.diffExpanded)
|
||||
.catch(this.diffExpansionFailed);
|
||||
};
|
||||
};
|
||||
|
||||
diffExpanded = (newFile: FileDiff) => {
|
||||
this.setState({ file: newFile, diffExpander: new DiffExpander(newFile) });
|
||||
};
|
||||
|
||||
diffExpansionFailed = (err: any) => {
|
||||
this.setState({ expansionError: err });
|
||||
};
|
||||
|
||||
collectHunkAnnotations = (hunk: HunkType) => {
|
||||
const { annotationFactory } = this.props;
|
||||
const { file } = this.state;
|
||||
if (annotationFactory) {
|
||||
return annotationFactory({
|
||||
hunk,
|
||||
file
|
||||
});
|
||||
} else {
|
||||
return EMPTY_ANNOTATION_FACTORY;
|
||||
}
|
||||
};
|
||||
|
||||
handleClickEvent = (change: Change, hunk: HunkType) => {
|
||||
const { onClick } = this.props;
|
||||
const { file } = this.state;
|
||||
const context = {
|
||||
changeId: getChangeKey(change),
|
||||
change,
|
||||
hunk,
|
||||
file
|
||||
};
|
||||
if (onClick) {
|
||||
onClick(context);
|
||||
}
|
||||
};
|
||||
|
||||
createGutterEvents = (hunk: HunkType) => {
|
||||
const { onClick } = this.props;
|
||||
if (onClick) {
|
||||
return {
|
||||
onClick: (event: ChangeEvent) => {
|
||||
this.handleClickEvent(event.change, hunk);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
renderHunk = (file: FileDiff, expandableHunk: ExpandableHunk, i: number) => {
|
||||
const hunk = expandableHunk.hunk;
|
||||
if (this.props.markConflicts && hunk.changes) {
|
||||
this.markConflicts(hunk);
|
||||
}
|
||||
const items = [];
|
||||
if (file._links?.lines) {
|
||||
items.push(this.createHunkHeader(expandableHunk));
|
||||
} else if (i > 0) {
|
||||
items.push(
|
||||
<Decoration>
|
||||
<hr className="my-2" />
|
||||
</Decoration>
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
<Hunk
|
||||
key={"hunk-" + hunk.content}
|
||||
hunk={expandableHunk.hunk}
|
||||
widgets={this.collectHunkAnnotations(hunk)}
|
||||
gutterEvents={this.createGutterEvents(hunk)}
|
||||
className={this.props.hunkClass ? this.props.hunkClass(hunk) : null}
|
||||
/>
|
||||
);
|
||||
if (file._links?.lines) {
|
||||
if (i === file.hunks!.length - 1) {
|
||||
items.push(this.createLastHunkFooter(expandableHunk));
|
||||
} else {
|
||||
items.push(this.createHunkFooter(expandableHunk));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
markConflicts = (hunk: HunkType) => {
|
||||
let inConflict = false;
|
||||
for (let i = 0; i < hunk.changes.length; ++i) {
|
||||
if (hunk.changes[i].content === "<<<<<<< HEAD") {
|
||||
inConflict = true;
|
||||
}
|
||||
if (inConflict) {
|
||||
hunk.changes[i].type = "conflict";
|
||||
}
|
||||
if (hunk.changes[i].content.startsWith(">>>>>>>")) {
|
||||
inConflict = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getAnchorId(file: FileDiff) {
|
||||
let path: string;
|
||||
if (file.type === "delete") {
|
||||
path = file.oldPath;
|
||||
} else {
|
||||
path = file.newPath;
|
||||
}
|
||||
return escapeWhitespace(path);
|
||||
}
|
||||
|
||||
renderFileTitle = (file: FileDiff) => {
|
||||
const { t } = this.props;
|
||||
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
|
||||
return (
|
||||
<>
|
||||
{file.oldPath} <Icon name="arrow-right" color="inherit" alt={t("diff.renamedTo")} /> {file.newPath}
|
||||
</>
|
||||
);
|
||||
} else if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
return file.newPath;
|
||||
};
|
||||
|
||||
hoverFileTitle = (file: FileDiff): string => {
|
||||
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
|
||||
return `${file.oldPath} > ${file.newPath}`;
|
||||
} else if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
return file.newPath;
|
||||
};
|
||||
|
||||
renderChangeTag = (file: FileDiff) => {
|
||||
const { t } = this.props;
|
||||
if (!file.type) {
|
||||
return;
|
||||
}
|
||||
const key = "diff.changes." + file.type;
|
||||
let value = t(key);
|
||||
if (key === value) {
|
||||
value = file.type;
|
||||
}
|
||||
|
||||
const color = value === "added" ? "success" : value === "deleted" ? "danger" : "info";
|
||||
return (
|
||||
<Tag
|
||||
className={classNames("has-text-weight-normal", "ml-3")}
|
||||
rounded={true}
|
||||
outlined={true}
|
||||
color={color}
|
||||
label={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
isCollapsed = () => {
|
||||
const { file, isCollapsed } = this.props;
|
||||
if (isCollapsed) {
|
||||
return isCollapsed(file);
|
||||
}
|
||||
return this.state.collapsed;
|
||||
};
|
||||
|
||||
hasContent = (file: FileDiff) => file && !file.isBinary && file.hunks && file.hunks.length > 0;
|
||||
|
||||
render() {
|
||||
const { fileControlFactory, fileAnnotationFactory, t } = this.props;
|
||||
const { file, sideBySide, diffExpander, expansionError } = this.state;
|
||||
const viewType = sideBySide ? "split" : "unified";
|
||||
const collapsed = this.isCollapsed();
|
||||
|
||||
const fileAnnotations = fileAnnotationFactory ? fileAnnotationFactory(file) : null;
|
||||
const innerContent = (
|
||||
<div className="panel-block p-0">
|
||||
{fileAnnotations}
|
||||
<TokenizedDiffView className={viewType} viewType={viewType} file={file}>
|
||||
{(hunks: HunkType[]) =>
|
||||
hunks?.map((hunk, n) => {
|
||||
return this.renderHunk(file, diffExpander.getHunk(n), n);
|
||||
})
|
||||
}
|
||||
</TokenizedDiffView>
|
||||
</div>
|
||||
);
|
||||
let icon = <Icon name="angle-right" color="inherit" alt={t("diff.showContent")} />;
|
||||
let body = null;
|
||||
if (!collapsed) {
|
||||
icon = <Icon name="angle-down" color="inherit" alt={t("diff.hideContent")} />;
|
||||
body = innerContent;
|
||||
}
|
||||
const collapseIcon = this.hasContent(file) ? icon : null;
|
||||
const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null;
|
||||
const modalTitle = file.type === "delete" ? file.oldPath : file.newPath;
|
||||
const openInFullscreen = file?.hunks?.length ? (
|
||||
<OpenInFullscreenButton
|
||||
modalTitle={modalTitle}
|
||||
modalBody={<MarginlessModalContent>{innerContent}</MarginlessModalContent>}
|
||||
/>
|
||||
) : null;
|
||||
const sideBySideToggle = file?.hunks?.length && (
|
||||
<MenuContext.Consumer>
|
||||
{({ setCollapsed }) => (
|
||||
<DiffButton
|
||||
icon={sideBySide ? "align-left" : "columns"}
|
||||
tooltip={t(sideBySide ? "diff.combined" : "diff.sideBySide")}
|
||||
onClick={() =>
|
||||
this.toggleSideBySide(() => {
|
||||
if (this.state.sideBySide) {
|
||||
setCollapsed(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</MenuContext.Consumer>
|
||||
);
|
||||
const headerButtons = (
|
||||
<div className={classNames("level-right", "is-flex", "ml-auto")}>
|
||||
<ButtonGroup>
|
||||
{sideBySideToggle}
|
||||
{openInFullscreen}
|
||||
{fileControls}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
let errorModal;
|
||||
if (expansionError) {
|
||||
errorModal = (
|
||||
<Modal
|
||||
title={t("diff.expansionFailed")}
|
||||
closeFunction={() => this.setState({ expansionError: undefined })}
|
||||
body={<ErrorNotification error={expansionError} />}
|
||||
active={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DiffFilePanel
|
||||
className={classNames("panel", "is-size-6")}
|
||||
collapsed={(file && file.isBinary) || collapsed}
|
||||
id={this.getAnchorId(file)}
|
||||
>
|
||||
{errorModal}
|
||||
<div className="panel-heading">
|
||||
<div className={classNames("level", "is-flex-wrap-wrap")}>
|
||||
<FullWidthTitleHeader
|
||||
className={classNames("level-left", "is-flex", "is-clickable")}
|
||||
onClick={this.toggleCollapse}
|
||||
title={this.hoverFileTitle(file)}
|
||||
>
|
||||
{collapseIcon}
|
||||
<h4 className={classNames("has-text-weight-bold", "is-ellipsis-overflow", "is-size-6", "ml-1")}>
|
||||
{this.renderFileTitle(file)}
|
||||
</h4>
|
||||
{this.renderChangeTag(file)}
|
||||
</FullWidthTitleHeader>
|
||||
{headerButtons}
|
||||
</div>
|
||||
</div>
|
||||
{body}
|
||||
</DiffFilePanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation("repos")(DiffFile);
|
||||
37
scm-ui/ui-legacy/package.json
Normal file
37
scm-ui/ui-legacy/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-legacy",
|
||||
"version": "2.31.1-SNAPSHOT",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-extensions": "^2.31.1-SNAPSHOT",
|
||||
"@scm-manager/ui-api": "^2.31.1-SNAPSHOT",
|
||||
"@scm-manager/ui-types": "^2.31.1-SNAPSHOT",
|
||||
"redux": "^4.0.0",
|
||||
"react-redux": "^5.0.7",
|
||||
"react": "^17.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scm-manager/babel-preset": "^2.12.0",
|
||||
"@scm-manager/jest-preset": "^2.13.0",
|
||||
"@scm-manager/prettier-config": "^2.10.1",
|
||||
"@types/react-redux": "5.0.7",
|
||||
"@types/react": "^17.0.1"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
"@scm-manager/babel-preset"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"preset": "@scm-manager/jest-preset"
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,11 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import { createStore } from "redux";
|
||||
import { createStore, Reducer } from "redux";
|
||||
import { IndexResources, Links, Me } from "@scm-manager/ui-types";
|
||||
import React, { FC } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import ReduxLegacy from "./ReduxLegacy";
|
||||
|
||||
const ACTION_TYPE_INITIAL = "scm/initial";
|
||||
const ACTION_TYPE_INDEX = "scm/index_success";
|
||||
@@ -58,9 +59,12 @@ type State = {
|
||||
|
||||
const initialState: State = {};
|
||||
|
||||
const reducer = (state: State = initialState, action: ActionTypes = { type: ACTION_TYPE_INITIAL }): State => {
|
||||
const reducer: Reducer<State, ActionTypes> = (
|
||||
state: State = initialState,
|
||||
action: ActionTypes = { type: ACTION_TYPE_INITIAL }
|
||||
): State => {
|
||||
switch (action.type) {
|
||||
case "scm/index_success": {
|
||||
case ACTION_TYPE_INDEX: {
|
||||
return {
|
||||
...state,
|
||||
indexResources: {
|
||||
@@ -69,7 +73,7 @@ const reducer = (state: State = initialState, action: ActionTypes = { type: ACTI
|
||||
}
|
||||
};
|
||||
}
|
||||
case "scm/me_success": {
|
||||
case ACTION_TYPE_ME: {
|
||||
return {
|
||||
...state,
|
||||
auth: {
|
||||
@@ -85,22 +89,26 @@ const reducer = (state: State = initialState, action: ActionTypes = { type: ACTI
|
||||
|
||||
// add window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__() as last argument of createStore
|
||||
// to enable redux devtools
|
||||
const store = createStore(reducer, initialState);
|
||||
const store = createStore(reducer);
|
||||
|
||||
export const fetchIndexResourcesSuccess = (index: IndexResources): ActionTypes => {
|
||||
export const fetchIndexResourcesSuccess = (index: IndexResources): IndexActionSuccess => {
|
||||
return {
|
||||
type: ACTION_TYPE_INDEX,
|
||||
payload: index
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMeSuccess = (me: Me): ActionTypes => {
|
||||
export const fetchMeSuccess = (me: Me): MeActionSuccess => {
|
||||
return {
|
||||
type: ACTION_TYPE_ME,
|
||||
payload: me
|
||||
};
|
||||
};
|
||||
|
||||
const LegacyReduxProvider: FC = ({ children }) => <Provider store={store}>{children}</Provider>;
|
||||
const LegacyReduxProvider: FC = ({ children }) => (
|
||||
<Provider store={store}>
|
||||
<ReduxLegacy>{children}</ReduxLegacy>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
export default LegacyReduxProvider;
|
||||
@@ -22,28 +22,29 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { ApiProvider, ApiProviderProps } from "@scm-manager/ui-api";
|
||||
import { IndexResources, Me } from "@scm-manager/ui-types";
|
||||
|
||||
import React, { FC, useEffect } from "react";
|
||||
import { BaseContext, useLegacyContext } from "@scm-manager/ui-api";
|
||||
import { connect, Dispatch } from "react-redux";
|
||||
import { ActionTypes, fetchIndexResourcesSuccess, fetchMeSuccess } from "./LegacyReduxProvider";
|
||||
import { IndexResources, Me } from "@scm-manager/ui-types";
|
||||
|
||||
const ReduxAwareApiProvider: FC<ApiProviderProps> = ({ children, ...listeners }) => (
|
||||
<ApiProvider {...listeners}>{children}</ApiProvider>
|
||||
);
|
||||
const ReduxLegacy: FC<BaseContext> = ({ children, onIndexFetched, onMeFetched }) => {
|
||||
const context = useLegacyContext();
|
||||
useEffect(() => {
|
||||
context.onIndexFetched = onIndexFetched;
|
||||
context.onMeFetched = onMeFetched;
|
||||
context.initialize();
|
||||
}, [context, onIndexFetched, onMeFetched]);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch<ActionTypes>) => {
|
||||
return {
|
||||
onIndexFetched: (index: IndexResources) => {
|
||||
dispatch(fetchIndexResourcesSuccess(index));
|
||||
},
|
||||
onMeFetched: (me: Me) => {
|
||||
dispatch(fetchMeSuccess(me));
|
||||
}
|
||||
onIndexFetched: (index: IndexResources) => dispatch(fetchIndexResourcesSuccess(index)),
|
||||
onMeFetched: (me: Me) => dispatch(fetchMeSuccess(me))
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||
// @ts-ignore no clue how to type it
|
||||
export default connect(undefined, mapDispatchToProps)(ReduxAwareApiProvider);
|
||||
const connector = connect<{}, BaseContext>(undefined, mapDispatchToProps);
|
||||
|
||||
export default connector(ReduxLegacy);
|
||||
32
scm-ui/ui-legacy/src/index.ts
Normal file
32
scm-ui/ui-legacy/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { binder } from "@scm-manager/ui-extensions";
|
||||
import * as Redux from "redux";
|
||||
import * as ReactRedux from "react-redux";
|
||||
import LegacyReduxProvider from "./LegacyReduxProvider";
|
||||
|
||||
binder.bind("main.wrapper", LegacyReduxProvider);
|
||||
|
||||
export { Redux, ReactRedux };
|
||||
3
scm-ui/ui-legacy/tsconfig.json
Normal file
3
scm-ui/ui-legacy/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@scm-manager/tsconfig"
|
||||
}
|
||||
27
scm-ui/ui-modules/package.json
Normal file
27
scm-ui/ui-modules/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-modules",
|
||||
"version": "2.31.1-SNAPSHOT",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scm-manager/babel-preset": "^2.12.0",
|
||||
"@scm-manager/jest-preset":"^2.13.0",
|
||||
"@scm-manager/prettier-config": "^2.10.1"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
"@scm-manager/babel-preset"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"preset": "@scm-manager/jest-preset"
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
91
scm-ui/ui-modules/src/index.ts
Normal file
91
scm-ui/ui-modules/src/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
type Module = {
|
||||
dependencies: string[];
|
||||
fn: (...args: unknown[]) => Module;
|
||||
};
|
||||
|
||||
const modules: { [name: string]: unknown } = {};
|
||||
const lazyModules: { [name: string]: () => Promise<unknown> } = {};
|
||||
const queue: { [name: string]: Module } = {};
|
||||
|
||||
export const defineLazy = (name: string, cmp: () => Promise<unknown>) => {
|
||||
lazyModules[name] = cmp;
|
||||
};
|
||||
|
||||
export const defineStatic = (name: string, cmp: unknown) => {
|
||||
modules[name] = cmp;
|
||||
};
|
||||
|
||||
const resolveModule = (name: string) => {
|
||||
const module = modules[name];
|
||||
if (module) {
|
||||
return Promise.resolve(module);
|
||||
}
|
||||
|
||||
const lazyModule = lazyModules[name];
|
||||
if (lazyModule) {
|
||||
return lazyModule().then((mod: unknown) => {
|
||||
modules[name] = mod;
|
||||
return mod;
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject("Could not resolve module: " + name);
|
||||
};
|
||||
|
||||
const defineModule = (name: string, module: Module) => {
|
||||
Promise.all(module.dependencies.map(resolveModule))
|
||||
.then(resolvedDependencies => {
|
||||
delete queue[name];
|
||||
|
||||
modules["@scm-manager/" + name] = module.fn(...resolvedDependencies);
|
||||
|
||||
Object.keys(queue).forEach(queuedModuleName => {
|
||||
const queueModule = queue[queuedModuleName];
|
||||
defineModule(queuedModuleName, queueModule);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
queue[name] = module;
|
||||
});
|
||||
};
|
||||
|
||||
export const define = (name: string, dependencies: string[], fn: (...args: unknown[]) => Module) => {
|
||||
defineModule(name, { dependencies, fn });
|
||||
};
|
||||
|
||||
export const load = (resource: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = resource;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
|
||||
const body = document.querySelector("body");
|
||||
body?.appendChild(script);
|
||||
body?.removeChild(script);
|
||||
});
|
||||
};
|
||||
3
scm-ui/ui-modules/tsconfig.json
Normal file
3
scm-ui/ui-modules/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@scm-manager/tsconfig"
|
||||
}
|
||||
@@ -28,7 +28,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@scm-manager/eslint-config": "^2.12.0",
|
||||
"@scm-manager/prettier-config": "^2.10.1"
|
||||
"@scm-manager/prettier-config": "^2.10.1",
|
||||
"webpack-bundle-analyzer": "^4.5.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@scm-manager/eslint-config",
|
||||
|
||||
@@ -35,6 +35,13 @@ const root = path.resolve(process.cwd(), "scm-ui");
|
||||
const babelPlugins = [];
|
||||
const webpackPlugins = [];
|
||||
|
||||
if (process.env.ANALYZE_BUNDLES === "true") {
|
||||
// it is ok to use require here, because we want to load the package conditionally
|
||||
// eslint-disable-next-line global-require
|
||||
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");
|
||||
webpackPlugins.push(new BundleAnalyzerPlugin());
|
||||
}
|
||||
|
||||
let mode = "production";
|
||||
|
||||
if (isDevelopment) {
|
||||
@@ -49,8 +56,8 @@ if (isDevelopment) {
|
||||
const themedir = path.join(root, "ui-styles", "src");
|
||||
const themes = fs
|
||||
.readdirSync(themedir)
|
||||
.map((filename) => path.parse(filename))
|
||||
.filter((p) => p.ext === ".scss")
|
||||
.map(filename => path.parse(filename))
|
||||
.filter(p => p.ext === ".scss")
|
||||
.reduce((entries, current) => ({ ...entries, [current.name]: path.join(themedir, current.base) }), {});
|
||||
|
||||
console.log(`build ${mode} bundles`);
|
||||
@@ -162,6 +169,7 @@ module.exports = [
|
||||
},
|
||||
optimization: {
|
||||
runtimeChunk: "single",
|
||||
chunkIds: "named",
|
||||
splitChunks: {
|
||||
chunks: "initial",
|
||||
cacheGroups: {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"@scm-manager/ui-api": "^2.31.1-SNAPSHOT",
|
||||
"@scm-manager/ui-components": "^2.31.1-SNAPSHOT",
|
||||
"@scm-manager/ui-extensions": "^2.31.1-SNAPSHOT",
|
||||
"@scm-manager/ui-modules": "^2.31.1-SNAPSHOT",
|
||||
"classnames": "^2.2.5",
|
||||
"history": "^4.10.1",
|
||||
"i18next": "^19.6.0",
|
||||
|
||||
@@ -21,36 +21,42 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { FC } from "react";
|
||||
import React, { FC, Suspense } from "react";
|
||||
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { Links, Me } from "@scm-manager/ui-types";
|
||||
|
||||
import Overview from "../repos/containers/Overview";
|
||||
import Users from "../users/containers/Users";
|
||||
import Login from "../containers/Login";
|
||||
import Logout from "../containers/Logout";
|
||||
|
||||
import { ErrorBoundary, ProtectedRoute } from "@scm-manager/ui-components";
|
||||
import { ErrorBoundary, Loading, ProtectedRoute } from "@scm-manager/ui-components";
|
||||
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
import CreateUser from "../users/containers/CreateUser";
|
||||
import SingleUser from "../users/containers/SingleUser";
|
||||
import RepositoryRoot from "../repos/containers/RepositoryRoot";
|
||||
// auth routes
|
||||
const Login = React.lazy(() => import("../containers/Login"));
|
||||
const Logout = React.lazy(() => import("../containers/Logout"));
|
||||
|
||||
import Groups from "../groups/containers/Groups";
|
||||
import SingleGroup from "../groups/containers/SingleGroup";
|
||||
import CreateGroup from "../groups/containers/CreateGroup";
|
||||
// repo routes
|
||||
const Overview = React.lazy(() => import("../repos/containers/Overview"));
|
||||
const RepositoryRoot = React.lazy(() => import("../repos/containers/RepositoryRoot"));
|
||||
const NamespaceRoot = React.lazy(() => import("../repos/namespaces/containers/NamespaceRoot"));
|
||||
const CreateRepositoryRoot = React.lazy(() => import("../repos/containers/CreateRepositoryRoot"));
|
||||
|
||||
import Admin from "../admin/containers/Admin";
|
||||
// user routes
|
||||
const Users = React.lazy(() => import("../users/containers/Users"));
|
||||
const CreateUser = React.lazy(() => import("../users/containers/CreateUser"));
|
||||
const SingleUser = React.lazy(() => import("../users/containers/SingleUser"));
|
||||
|
||||
const Groups = React.lazy(() => import("../groups/containers/Groups"));
|
||||
const SingleGroup = React.lazy(() => import("../groups/containers/SingleGroup"));
|
||||
const CreateGroup = React.lazy(() => import("../groups/containers/CreateGroup"));
|
||||
|
||||
const Admin = React.lazy(() => import("../admin/containers/Admin"));
|
||||
|
||||
const Profile = React.lazy(() => import("./Profile"));
|
||||
|
||||
const ImportLog = React.lazy(() => import("../repos/importlog/ImportLog"));
|
||||
const Search = React.lazy(() => import("../search/Search"));
|
||||
const Syntax = React.lazy(() => import("../search/Syntax"));
|
||||
const ExternalError = React.lazy(() => import("./ExternalError"));
|
||||
|
||||
import Profile from "./Profile";
|
||||
import NamespaceRoot from "../repos/namespaces/containers/NamespaceRoot";
|
||||
import ImportLog from "../repos/importlog/ImportLog";
|
||||
import CreateRepositoryRoot from "../repos/containers/CreateRepositoryRoot";
|
||||
import Search from "../search/Search";
|
||||
import Syntax from "../search/Syntax";
|
||||
import ExternalError from "./ExternalError";
|
||||
|
||||
type Props = {
|
||||
me: Me;
|
||||
@@ -74,6 +80,7 @@ const Main: FC<Props> = props => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="main">
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Switch>
|
||||
<Redirect exact from="/" to={url} />
|
||||
<Route exact path="/login" component={Login} />
|
||||
@@ -106,6 +113,7 @@ const Main: FC<Props> = props => {
|
||||
<ProtectedRoute path="/help/search-syntax/" component={Syntax} authenticated={authenticated} />
|
||||
<ExtensionPoint name="main.route" renderAll={true} props={props} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import styled from "styled-components";
|
||||
import { apiClient, Loading, ErrorNotification, ErrorBoundary, Icon } from "@scm-manager/ui-components";
|
||||
import { apiClient, ErrorBoundary, ErrorNotification, Icon, Loading } from "@scm-manager/ui-components";
|
||||
import loadBundle from "./loadBundle";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
type Props = {
|
||||
loaded: boolean;
|
||||
@@ -88,18 +89,10 @@ class PluginLoader extends React.Component<Props, State> {
|
||||
for (const plugin of sortedPlugins) {
|
||||
promises.push(this.loadPlugin(plugin));
|
||||
}
|
||||
return promises.reduce((chain, current) => {
|
||||
return chain.then(chainResults => {
|
||||
return current.then(currentResult => [...chainResults, currentResult]);
|
||||
});
|
||||
}, Promise.resolve([]));
|
||||
return Promise.all(promises);
|
||||
};
|
||||
|
||||
loadPlugin = (plugin: Plugin) => {
|
||||
this.setState({
|
||||
message: `loading ${plugin.name}`
|
||||
});
|
||||
|
||||
const promises = [];
|
||||
for (const bundle of plugin.bundles) {
|
||||
promises.push(
|
||||
@@ -137,11 +130,16 @@ class PluginLoader extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
if (loaded) {
|
||||
return <div>{this.props.children}</div>;
|
||||
return (
|
||||
<ExtensionPoint name="main.wrapper" wrapper={true}>
|
||||
{this.props.children}
|
||||
</ExtensionPoint>
|
||||
);
|
||||
}
|
||||
return <Loading message={message} />;
|
||||
}
|
||||
}
|
||||
|
||||
const comparePluginsByName = (a: Plugin, b: Plugin) => {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
|
||||
@@ -22,115 +22,44 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/* global SystemJS */
|
||||
// eslint-disable-next-line import/no-webpack-loader-syntax
|
||||
import "script-loader!../../../../node_modules/systemjs/dist/system.js";
|
||||
import { define, defineLazy, defineStatic, load } from "@scm-manager/ui-modules";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import * as ReactRouterDom from "react-router-dom";
|
||||
import * as Redux from "redux";
|
||||
import * as ReactRedux from "react-redux";
|
||||
import ReactQueryDefault, * as ReactQuery from "react-query";
|
||||
import StyledComponentsDefault, * as StyledComponents from "styled-components";
|
||||
import ReactHookFormDefault, * as ReactHookForm from "react-hook-form";
|
||||
import * as ReactQuery from "react-query";
|
||||
import * as StyledComponents from "styled-components";
|
||||
import * as ReactHookForm from "react-hook-form";
|
||||
import * as ReactI18Next from "react-i18next";
|
||||
import ClassNamesDefault, * as ClassNames from "classnames";
|
||||
import QueryStringDefault, * as QueryString from "query-string";
|
||||
import * as ClassNames from "classnames";
|
||||
import * as QueryString from "query-string";
|
||||
import * as UIExtensions from "@scm-manager/ui-extensions";
|
||||
import * as UIComponents from "@scm-manager/ui-components";
|
||||
import { urls } from "@scm-manager/ui-components";
|
||||
import * as UIApi from "@scm-manager/ui-api";
|
||||
|
||||
type PluginModule = {
|
||||
name: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
const BundleLoader = {
|
||||
name: "bundle-loader",
|
||||
fetch: (plugin: PluginModule) => {
|
||||
return fetch(plugin.address, {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Cache: "no-cache",
|
||||
// identify the request as ajax request
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
declare global {
|
||||
interface Window {
|
||||
define: typeof define;
|
||||
}
|
||||
}).then(response => {
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
SystemJS.registry.set(BundleLoader.name, SystemJS.newModule(BundleLoader));
|
||||
window.define = define;
|
||||
|
||||
SystemJS.config({
|
||||
baseURL: urls.withContextPath("/assets"),
|
||||
meta: {
|
||||
"/*": {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||
// @ts-ignore typing missing, but seems required
|
||||
esModule: true,
|
||||
authorization: true,
|
||||
loader: BundleLoader.name
|
||||
}
|
||||
}
|
||||
});
|
||||
defineStatic("react", React);
|
||||
defineStatic("react-dom", ReactDOM);
|
||||
defineStatic("react-router-dom", ReactRouterDom);
|
||||
defineStatic("styled-components", StyledComponents);
|
||||
defineStatic("react-i18next", ReactI18Next);
|
||||
defineStatic("react-hook-form", ReactHookForm);
|
||||
defineStatic("react-query", ReactQuery);
|
||||
defineStatic("classnames", ClassNames);
|
||||
defineStatic("query-string", QueryString);
|
||||
defineStatic("@scm-manager/ui-extensions", UIExtensions);
|
||||
defineStatic("@scm-manager/ui-components", UIComponents);
|
||||
defineStatic("@scm-manager/ui-api", UIApi);
|
||||
|
||||
// We have to patch the resolve methods of SystemJS
|
||||
// in order to resolve the correct bundle url for plugins
|
||||
// redux is deprecated in favor of ui-api
|
||||
defineLazy("redux", () => import("@scm-manager/ui-legacy").then(legacy => legacy.Redux));
|
||||
defineLazy("react-redux", () => import("@scm-manager/ui-legacy").then(legacy => legacy.ReactRedux));
|
||||
|
||||
const resolveModuleUrl = (key: string) => {
|
||||
if (key.startsWith("@scm-manager/scm-") && key.endsWith("-plugin")) {
|
||||
const pluginName = key.replace("@scm-manager/", "");
|
||||
return urls.withContextPath(`/assets/${pluginName}.bundle.js`);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const defaultResolve = SystemJS.resolve;
|
||||
SystemJS.resolve = function(key, parentName) {
|
||||
const module = resolveModuleUrl(key);
|
||||
return defaultResolve.apply(this, [module, parentName]);
|
||||
};
|
||||
|
||||
const defaultResolveSync = SystemJS.resolveSync;
|
||||
SystemJS.resolveSync = function(key, parentName) {
|
||||
const module = resolveModuleUrl(key);
|
||||
return defaultResolveSync.apply(this, [module, parentName]);
|
||||
};
|
||||
|
||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const expose = (name: string, cmp: any, defaultCmp?: any) => {
|
||||
let mod = cmp;
|
||||
if (defaultCmp) {
|
||||
// SystemJS default export:
|
||||
// https://github.com/systemjs/systemjs/issues/1749
|
||||
mod = {
|
||||
...cmp,
|
||||
__useDefault: defaultCmp
|
||||
};
|
||||
}
|
||||
SystemJS.set(name, SystemJS.newModule(mod));
|
||||
};
|
||||
|
||||
expose("react", React);
|
||||
expose("react-dom", ReactDOM);
|
||||
expose("react-router-dom", ReactRouterDom);
|
||||
expose("styled-components", StyledComponents, StyledComponentsDefault);
|
||||
expose("react-i18next", ReactI18Next);
|
||||
expose("react-hook-form", ReactHookForm, ReactHookFormDefault);
|
||||
expose("react-query", ReactQuery, ReactQueryDefault);
|
||||
expose("classnames", ClassNames, ClassNamesDefault);
|
||||
expose("query-string", QueryString, QueryStringDefault);
|
||||
expose("@scm-manager/ui-extensions", UIExtensions);
|
||||
expose("@scm-manager/ui-components", UIComponents);
|
||||
expose("@scm-manager/ui-api", UIApi);
|
||||
|
||||
// redux is deprecated in favor of ui-api,
|
||||
// which will be exported soon
|
||||
expose("redux", Redux);
|
||||
expose("react-redux", ReactRedux);
|
||||
|
||||
export default (plugin: string) => SystemJS.import(plugin);
|
||||
export default load;
|
||||
|
||||
@@ -35,8 +35,7 @@ import { binder } from "@scm-manager/ui-extensions";
|
||||
import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink";
|
||||
|
||||
import "./tokenExpired";
|
||||
import LegacyReduxProvider from "./LegacyReduxProvider";
|
||||
import ReduxAwareApiProvider from "./ReduxAwareApiProvider";
|
||||
import { ApiProvider } from "@scm-manager/ui-api";
|
||||
|
||||
binder.bind("changeset.description.tokens", ChangesetShortLink);
|
||||
|
||||
@@ -46,14 +45,12 @@ if (!root) {
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<LegacyReduxProvider>
|
||||
<ReduxAwareApiProvider>
|
||||
<ApiProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Router basename={urls.contextPath}>
|
||||
<Index />
|
||||
</Router>
|
||||
</I18nextProvider>
|
||||
</ReduxAwareApiProvider>
|
||||
</LegacyReduxProvider>,
|
||||
</ApiProvider>,
|
||||
root
|
||||
);
|
||||
|
||||
68
yarn.lock
68
yarn.lock
@@ -3151,6 +3151,11 @@
|
||||
schema-utils "^3.0.0"
|
||||
source-map "^0.7.3"
|
||||
|
||||
"@polka/url@^1.0.0-next.20":
|
||||
version "1.0.0-next.21"
|
||||
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
|
||||
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
|
||||
|
||||
"@popperjs/core@^2.5.4", "@popperjs/core@^2.6.0":
|
||||
version "2.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353"
|
||||
@@ -5188,6 +5193,11 @@ acorn-walk@^7.0.0, acorn-walk@^7.1.1:
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
|
||||
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
|
||||
|
||||
acorn-walk@^8.0.0:
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
|
||||
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
|
||||
|
||||
acorn@^5.5.3:
|
||||
version "5.7.4"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"
|
||||
@@ -5203,6 +5213,11 @@ acorn@^7.0.0, acorn@^7.1.1, acorn@^7.4.0:
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
|
||||
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
|
||||
|
||||
acorn@^8.0.4:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.6.0.tgz#e3692ba0eb1a0c83eaa4f37f5fa7368dd7142895"
|
||||
integrity sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw==
|
||||
|
||||
acorn@^8.1.0:
|
||||
version "8.2.4"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.2.4.tgz#caba24b08185c3b56e3168e97d15ed17f4d31fd0"
|
||||
@@ -8976,7 +8991,7 @@ duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2:
|
||||
dependencies:
|
||||
readable-stream "^2.0.2"
|
||||
|
||||
duplexer@^0.1.1:
|
||||
duplexer@^0.1.1, duplexer@^0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
|
||||
integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==
|
||||
@@ -10879,6 +10894,13 @@ gzip-size@5.1.1:
|
||||
duplexer "^0.1.1"
|
||||
pify "^4.0.1"
|
||||
|
||||
gzip-size@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462"
|
||||
integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==
|
||||
dependencies:
|
||||
duplexer "^0.1.2"
|
||||
|
||||
handle-thing@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"
|
||||
@@ -14891,6 +14913,11 @@ mri@1.1.4:
|
||||
resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.4.tgz#7cb1dd1b9b40905f1fac053abe25b6720f44744a"
|
||||
integrity sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==
|
||||
|
||||
mrmime@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.0.tgz#14d387f0585a5233d291baba339b063752a2398b"
|
||||
integrity sha512-a70zx7zFfVO7XpnQ2IX1Myh9yY4UYvfld/dikWRnsXxbyvMcfz+u6UfgNAtH+k2QqtJuzVpv6eLTx1G2+WKZbQ==
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
@@ -15539,6 +15566,11 @@ opencollective-postinstall@^2.0.2:
|
||||
resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259"
|
||||
integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==
|
||||
|
||||
opener@^1.5.2:
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
|
||||
integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
|
||||
|
||||
openurl@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/openurl/-/openurl-1.1.1.tgz#3875b4b0ef7a52c156f0db41d4609dbb0f94b387"
|
||||
@@ -18781,6 +18813,15 @@ simplebar@^4.2.3:
|
||||
lodash.throttle "^4.1.1"
|
||||
resize-observer-polyfill "^1.5.1"
|
||||
|
||||
sirv@^1.0.7:
|
||||
version "1.0.19"
|
||||
resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49"
|
||||
integrity sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ==
|
||||
dependencies:
|
||||
"@polka/url" "^1.0.0-next.20"
|
||||
mrmime "^1.0.0"
|
||||
totalist "^1.0.0"
|
||||
|
||||
sisteransi@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
|
||||
@@ -20096,6 +20137,11 @@ toidentifier@1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
|
||||
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
||||
|
||||
totalist@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df"
|
||||
integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==
|
||||
|
||||
touch@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164"
|
||||
@@ -21053,6 +21099,21 @@ webidl-conversions@^6.1.0:
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514"
|
||||
integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==
|
||||
|
||||
webpack-bundle-analyzer@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.5.0.tgz#1b0eea2947e73528754a6f9af3e91b2b6e0f79d5"
|
||||
integrity sha512-GUMZlM3SKwS8Z+CKeIFx7CVoHn3dXFcUAjT/dcZQQmfSZGvitPfMob2ipjai7ovFFqPvTqkEZ/leL4O0YOdAYQ==
|
||||
dependencies:
|
||||
acorn "^8.0.4"
|
||||
acorn-walk "^8.0.0"
|
||||
chalk "^4.1.0"
|
||||
commander "^7.2.0"
|
||||
gzip-size "^6.0.0"
|
||||
lodash "^4.17.20"
|
||||
opener "^1.5.2"
|
||||
sirv "^1.0.7"
|
||||
ws "^7.3.1"
|
||||
|
||||
webpack-cli@^4.9.1:
|
||||
version "4.9.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.9.1.tgz#b64be825e2d1b130f285c314caa3b1ba9a4632b3"
|
||||
@@ -21592,6 +21653,11 @@ ws@^5.2.0:
|
||||
dependencies:
|
||||
async-limiter "~1.0.0"
|
||||
|
||||
ws@^7.3.1:
|
||||
version "7.5.6"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b"
|
||||
integrity sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA==
|
||||
|
||||
ws@^7.4.4:
|
||||
version "7.4.5"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.5.tgz#a484dd851e9beb6fdb420027e3885e8ce48986c1"
|
||||
|
||||
Reference in New Issue
Block a user