Skip to content

Commit 42aaf48

Browse files
committed
feature(FR-1471): migrate the GitHub/GitLab importing feature to React with VFolder usage mode option (#4600)
Resolves #4278 ([FR-1471](https://lablup.atlassian.net/browse/FR-1471)) This PR migrates the GitHub and GitLab repository import functionality from the legacy web components to the React UI. The implementation includes: - Added a new `useGetAvailableFolderName` hook to generate unique folder names for imported repositories - Created `ImportRepoForm` component to handle GitHub and GitLab repository imports - Enhanced the import page UI with separate cards for notebook, GitHub, and GitLab imports - Added badge generation functionality that creates HTML/Markdown code for notebooks - Improved the `CopyButton` component to support translations and proper disabled states - Updated translations for all import-related features across multiple languages The implementation ensures repositories are properly downloaded, extracted, and made available as folders that can be mounted in sessions. [FR-1471]: https://lablup.atlassian.net/browse/FR-1471?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 31eacec commit 42aaf48

34 files changed

+795
-954
lines changed

packages/backend.ai-ui/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ export { default as useErrorMessageResolver } from './useErrorMessageResolver';
3737
export { default as useViewer } from './useViewer';
3838
export type { ErrorResponse } from './useErrorMessageResolver';
3939
export type { ESMClientErrorResponse } from './useErrorMessageResolver';
40+
export { default as useGetAvailableFolderName } from './useGetAvailableFolderName';
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useGetAvailableFolderNameQuery } from '../__generated__/useGetAvailableFolderNameQuery.graphql';
2+
import { generateRandomString } from '../helper';
3+
import { fetchQuery, graphql, useRelayEnvironment } from 'react-relay';
4+
5+
export const useGetAvailableFolderName = () => {
6+
'use memo';
7+
const relayEnv = useRelayEnvironment();
8+
return async (seedName: string) => {
9+
// Limit folder name length to 64 characters
10+
const targetName = seedName.substring(0, 64);
11+
const count = await fetchQuery<useGetAvailableFolderNameQuery>(
12+
relayEnv,
13+
graphql`
14+
query useGetAvailableFolderNameQuery($filter: String!) {
15+
vfolder_nodes(filter: $filter, permission: "read_attribute") {
16+
edges {
17+
node {
18+
name
19+
status
20+
}
21+
}
22+
count
23+
}
24+
}
25+
`,
26+
{
27+
filter: `(name == "${targetName}") & (status != "delete-complete")`,
28+
},
29+
)
30+
.toPromise()
31+
.then((data) => data?.vfolder_nodes?.count)
32+
.catch(() => 0);
33+
34+
const hash = generateRandomString(5);
35+
36+
return count === 0 ? targetName : `${targetName.substring(0, 58)}_${hash}`;
37+
};
38+
};
39+
40+
export default useGetAvailableFolderName;

react/src/App.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,6 @@ const router = createBrowserRouter([
363363
return (
364364
<>
365365
<ImportAndRunPage />
366-
{/* @ts-ignore */}
367-
<backend-ai-import-view active class="page" name="import" />
368366
</>
369367
);
370368
},

react/src/components/Chat/CopyButton.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CopyConfig } from 'antd/es/typography/Base';
33
import { CheckIcon, CopyIcon } from 'lucide-react';
44
import React, { useEffect, useState } from 'react';
55
import { CopyToClipboard } from 'react-copy-to-clipboard';
6+
import { useTranslation } from 'react-i18next';
67

78
interface CopyButtonProps extends ButtonProps {
89
copyable?: Omit<CopyConfig, 'text'> & { text: string };
@@ -14,6 +15,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
1415
...props
1516
}) => {
1617
const [isCopied, setIsCopied] = useState(false);
18+
const { t } = useTranslation();
1719

1820
const handleCopy = async () => {
1921
setIsCopied(true);
@@ -30,7 +32,13 @@ const CopyButton: React.FC<CopyButtonProps> = ({
3032

3133
return (
3234
<Tooltip
33-
title={isCopied ? 'Copied!' : 'Copy'}
35+
title={
36+
props.disabled
37+
? undefined
38+
: isCopied
39+
? t('sourceCodeViewer.Copied')
40+
: t('sourceCodeViewer.Copy')
41+
}
3442
open={isCopied ? true : undefined}
3543
>
3644
<div>

react/src/components/ImportNotebook.tsx

Lines changed: 0 additions & 112 deletions
This file was deleted.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { useSuspendedBackendaiClient, useWebUINavigate } from '../hooks';
2+
import CopyButton from './Chat/CopyButton';
3+
import { PrimaryAppOption } from './ComputeSessionNodeItems/SessionActionButtons';
4+
import { CloudDownloadOutlined } from '@ant-design/icons';
5+
import {
6+
App,
7+
Divider,
8+
Form,
9+
FormInstance,
10+
FormProps,
11+
Input,
12+
Typography,
13+
} from 'antd';
14+
import {
15+
BAIButton,
16+
BAIFlex,
17+
generateRandomString,
18+
useErrorMessageResolver,
19+
} from 'backend.ai-ui';
20+
import { useRef } from 'react';
21+
import { useTranslation } from 'react-i18next';
22+
import {
23+
StartSessionWithDefaultValue,
24+
useStartSession,
25+
} from 'src/hooks/useStartSession';
26+
27+
const regularizeGithubURL = (url: string) => {
28+
url = url.replace('/blob/', '/');
29+
url = url.replace('github.com', 'raw.githubusercontent.com');
30+
return url;
31+
};
32+
33+
const notebookURLPattern = new RegExp('^(https?)://([\\w./-]{1,}).ipynb$');
34+
35+
const ImportNotebookForm: React.FC<FormProps> = (props) => {
36+
const formRef = useRef<FormInstance<{
37+
url: string;
38+
}> | null>(null);
39+
const { t } = useTranslation();
40+
const app = App.useApp();
41+
const webuiNavigate = useWebUINavigate();
42+
const baiClient = useSuspendedBackendaiClient();
43+
const { startSessionWithDefault, upsertSessionNotification } =
44+
useStartSession();
45+
const { getErrorMessage } = useErrorMessageResolver();
46+
47+
const handleNotebookImport = async (url: string) => {
48+
const notebookURL = regularizeGithubURL(url);
49+
const fileName = notebookURL.split('/').pop();
50+
51+
const launcherValue: StartSessionWithDefaultValue = {
52+
sessionName: `imported-notebook-${generateRandomString(5)}`,
53+
environments: {
54+
version: baiClient._config.default_import_environment,
55+
},
56+
bootstrap_script: '#!/bin/sh\ncurl -O ' + notebookURL,
57+
};
58+
59+
const results = await startSessionWithDefault(launcherValue);
60+
if (results.fulfilled && results.fulfilled.length > 0) {
61+
// Handle successful result
62+
upsertSessionNotification(results.fulfilled, [
63+
{
64+
extraData: {
65+
appName: 'jupyter',
66+
urlPostfix: '&redirect=/notebooks/' + fileName,
67+
} as PrimaryAppOption,
68+
},
69+
]);
70+
webuiNavigate('/session');
71+
}
72+
73+
if (results?.rejected && results.rejected.length > 0) {
74+
const error = results.rejected[0].reason;
75+
app.modal.error({
76+
title: error?.title,
77+
content: getErrorMessage(error),
78+
});
79+
}
80+
};
81+
82+
return (
83+
<Form ref={formRef} layout="vertical" {...props}>
84+
<Form.Item
85+
name="url"
86+
label={t('import.NotebookURL')}
87+
rules={[
88+
{
89+
required: true,
90+
},
91+
{
92+
pattern: notebookURLPattern,
93+
message: t('import.InvalidNotebookURL'),
94+
},
95+
{
96+
type: 'string',
97+
max: 2048,
98+
},
99+
]}
100+
style={{
101+
flex: 1,
102+
}}
103+
>
104+
<Input placeholder={t('import.NotebookURL')} />
105+
</Form.Item>
106+
<BAIButton
107+
icon={<CloudDownloadOutlined />}
108+
type="primary"
109+
block
110+
action={async () => {
111+
const values = await formRef.current
112+
?.validateFields()
113+
.catch(() => undefined);
114+
if (values) {
115+
await handleNotebookImport(values.url);
116+
}
117+
}}
118+
>
119+
{t('import.GetAndRunNotebook')}
120+
</BAIButton>
121+
<BAIFlex
122+
direction="column"
123+
wrap="wrap"
124+
data-testid="panel-notebook-badge-code-section"
125+
>
126+
<Divider />
127+
<Typography.Paragraph>
128+
{t('import.YouCanCreateNotebookCode')}
129+
</Typography.Paragraph>
130+
<Form.Item dependencies={[['url']]}>
131+
{({ getFieldValue }) => {
132+
const url = getFieldValue('url') || '';
133+
const rawURL = regularizeGithubURL(url);
134+
const badgeURL = rawURL.replace(
135+
'https://raw.githubusercontent.com/',
136+
'',
137+
);
138+
let baseURL = '';
139+
140+
if (globalThis.isElectron) {
141+
baseURL = 'https://cloud.backend.ai/github?';
142+
} else {
143+
baseURL =
144+
window.location.protocol + '//' + window.location.hostname;
145+
if (window.location.port) {
146+
baseURL = baseURL + ':' + window.location.port;
147+
}
148+
baseURL = baseURL + '/github?';
149+
}
150+
const fullText = `<a href="${
151+
baseURL + badgeURL
152+
}"><img src="https://www.backend.ai/assets/badge.svg" /></a>`;
153+
const fullTextMarkdown = `[![Run on Backend.AI](https://www.backend.ai/assets/badge.svg)](${
154+
baseURL + badgeURL
155+
})`;
156+
157+
const isValidURL =
158+
notebookURLPattern.test(url) && url.length <= 2048;
159+
const isButtonDisabled = !url || !isValidURL;
160+
161+
return (
162+
<BAIFlex justify="start" gap={'sm'} wrap="wrap">
163+
<img
164+
src="/resources/badge.svg"
165+
style={{ marginTop: 5, marginBottom: 5 }}
166+
width="147"
167+
/>
168+
<BAIFlex gap={'sm'}>
169+
<CopyButton
170+
size="small"
171+
copyable={{
172+
text: fullText,
173+
}}
174+
disabled={isButtonDisabled}
175+
>
176+
{t('import.NotebookBadgeCodeHTML')}
177+
</CopyButton>
178+
<CopyButton
179+
size="small"
180+
copyable={{
181+
text: fullTextMarkdown,
182+
}}
183+
disabled={isButtonDisabled}
184+
>
185+
{t('import.NotebookBadgeCodeMarkdown')}
186+
</CopyButton>
187+
</BAIFlex>
188+
</BAIFlex>
189+
);
190+
}}
191+
</Form.Item>
192+
</BAIFlex>
193+
</Form>
194+
);
195+
};
196+
197+
export default ImportNotebookForm;

0 commit comments

Comments
 (0)