Git Bridge: Add git integration

This commit is contained in:
yu-i-i
2026-09-22 02:41:39 +02:00
parent b18298c8b0
commit 285d346eb6
23 changed files with 1551 additions and 5 deletions
@@ -0,0 +1,35 @@
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { useProjectContext } from '@/shared/context/project-context'
import IntegrationCard from '@/features/ide-redesign/components/integrations-panel/integration-card'
import GitLogoOrange from '@/shared/svgs/git-logo-orange'
import GitModalWrapper from './git-modal-wrapper'
function GitSyncCard() {
const { t } = useTranslation()
const { project } = useProjectContext()
const [show, setShow] = useState(false)
const projectId = project?._id
if (!projectId) return null
return (
<>
<IntegrationCard
title={t('git')}
description={t('git_clone_this_project')}
icon={<GitLogoOrange size={32} />}
onClick={() => setShow(true)}
/>
<GitModalWrapper
show={show}
handleHide={() => setShow(false)}
projectId={projectId}
/>
</>
)
}
export default GitSyncCard
@@ -0,0 +1,75 @@
import { Trans, useTranslation } from 'react-i18next'
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
import {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
type Props = {
handleHide: () => void
projectId: string
}
export default function GitModalContent({
handleHide,
projectId,
}: Props) {
const { t } = useTranslation()
const gitCloneCommand = `git clone ${window.location.protocol}//git@${window.location.host}/git/${projectId}`
return (
<>
<OLModalHeader closeButton>
<OLModalTitle>{t('clone_with_git')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>{t('git_bridge_modal_git_clone_your_project')}</p>
<div className="git-bridge-copy">
<span aria-label={t('git_clone_project_command')}>
<code>
{gitCloneCommand}
</code>
</span>
<CopyToClipboard
content={gitCloneCommand}
tooltipId="git-copy-clone-project-command-tooltip"
/>
</div>
<Trans
i18nKey="git_bridge_modal_use_previous_token"
components={[
<a
href="/learn/how-to/Git_integration_authentication_tokens"
target="_blank"
rel="noreferrer noopener"
/>,
]}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
<OLButton
variant="primary"
href="/user/settings"
target="_blank"
rel="noreferrer noopener"
>
{t('go_to_settings')}
</OLButton>
</OLModalFooter>
</>
)
}
@@ -0,0 +1,29 @@
import { OLModal } from '@/shared/components/ol/ol-modal'
import GitModalContent from './git-modal-content'
type Props = {
show: boolean
projectId: string
handleHide: () => void
}
export default function GitModalWrapper({
show,
projectId,
handleHide,
}: Props) {
return (
<OLModal
show={show}
onHide={handleHide}
id="git-sync-modal"
backdrop="static"
size="lg"
>
<GitModalContent
projectId={projectId}
handleHide={handleHide}
/>
</OLModal>
)
}
@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import getMeta from '@/utils/meta'
import GitFork from '@/shared/svgs/git-fork'
import { useProjectContext } from '@/shared/context/project-context'
import LeftMenuButton from '@/features/editor-left-menu/components/left-menu-button'
import GitModalWrapper from './git-modal-wrapper'
function GitSyncButton() {
const { t } = useTranslation()
const { project } = useProjectContext()
const [show, setShow] = useState(false)
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
const projectId = project?._id
if (!gitBridgeEnabled || !projectId) return null
return (
<>
<LeftMenuButton
variant="link"
className="left-menu-button"
onClick={() => setShow(true)}
icon={<GitFork />}
>
{t('git')}
</LeftMenuButton>
<GitModalWrapper
show={show}
handleHide={() => setShow(false)}
projectId={projectId}
/>
</>
)
}
export default GitSyncButton
@@ -0,0 +1,144 @@
import { useState, useCallback, useEffect } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { debugConsole } from '@/utils/debugging'
import { postJSON, getJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import Notification from '@/shared/components/notification'
import OLButton from '@/shared/components/ol/ol-button'
import GitLogoOrange from '@/shared/svgs/git-logo-orange'
import TokenTable from './token-table'
import ExposeTokenModal from './modals/expose-token-modal'
import { Token } from '../../../../types/api'
export default function GitIntegrationWidget() {
const { t } = useTranslation()
const [tokens, setTokens] = useState<Token[]>([])
const [showExposeTokenModal, setShowExposeTokenModal] = useState(false)
const [secretToken, setSecretToken] = useState<string | null>(null)
const { runAsync, isLoading, isError, reset } = useAsync()
useEffect(() => {
runAsync(getJSON('/git-bridge/personal-access-tokens'))
.then((data: Token[]) => setTokens(data))
.catch(debugConsole.error)
}, [runAsync])
const handleCreateToken = useCallback(() => {
runAsync(postJSON('/git-bridge/personal-access-tokens'))
.then((data: Token & { accessToken: string }) => {
const { accessToken, ...newToken } = data
setTokens(prev => [...prev, newToken])
setSecretToken(accessToken)
setShowExposeTokenModal(true)
})
.catch((err) => {
debugConsole.error(err)
setTimeout(() => reset(), 5000)
})
}, [runAsync])
const handleDeleteToken = useCallback((id: string) => {
setTokens(prev => prev.filter(t => t._id !== id))
}, [])
const tokenCount = tokens.length
return (
<div className="settings-widget-container">
<div className="linking-icon-fixed-position">
<GitLogoOrange />
</div>
<div className="description-container">
<div className="title-row">
<h4>{t('git_integration')}</h4>
</div>
<p className="small">
<Trans
i18nKey="git_integration_info"
components={[
<a
key="git-link"
href="/learn/how-to/Git_Integration"
target="_blank"
rel="noreferrer noopener"
/>,
]}
/>
</p>
<h4 className="ui-heading">
{t('your_git_access_tokens')}
</h4>
<p className="small">
{t('your_git_access_info')}
</p>
<ul className="small">
{tokenCount > 0 ? (
<>
<li>{t('your_git_access_info_bullet_1')}</li>
<li>{t('your_git_access_info_bullet_2')}</li>
</>
) : (
<>
<li>
<Trans
i18nKey="your_git_access_info_bullet_3"
components={[<strong key="strong" />]}
/>
</li>
<li>{t('your_git_access_info_bullet_4')}</li>
<li>{t('your_git_access_info_bullet_5')}</li>
</>
)}
</ul>
<TokenTable
tokens={tokens}
onCreateToken={handleCreateToken}
onDeleteToken={handleDeleteToken}
/>
{isError && (
<div className="notification-list">
<Notification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
</div>
{tokenCount === 0 && (
<div>
<OLButton
variant="secondary"
id="generate-token-button"
onClick={handleCreateToken}
disabled={isLoading || isError}
>
{t('generate_token')}
</OLButton>
</div>
)}
{showExposeTokenModal && secretToken && (
<ExposeTokenModal
secretToken={secretToken}
handleHide={() => {
setShowExposeTokenModal(false)
setSecretToken(null)
}}
/>
)}
</div>
)
}
@@ -0,0 +1,80 @@
import { useTranslation } from 'react-i18next'
import { deleteJSON } from '@/infrastructure/fetch-json'
import {
OLModal,
OLModalHeader,
OLModalTitle,
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import Notification from '@/shared/components/notification'
import OLButton from '@/shared/components/ol/ol-button'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
type Props = {
show: boolean
handleHide: () => void
tokenId: string
onDeleted: (id: string) => void
}
export default function DeleteTokenModal({
show,
handleHide,
tokenId,
onDeleted,
}: Props) {
const { t } = useTranslation()
const { isLoading, isError, runAsync, reset } = useAsync()
const handleClose = () => {
reset()
handleHide()
}
const handleDelete = () => {
runAsync(
deleteJSON(`/git-bridge/personal-access-tokens/${tokenId}`, {
body: { _csrf: window.csrfToken },
})
)
.then(() => {
onDeleted(tokenId)
handleClose()
})
.catch(debugConsole.error)
}
return (
<OLModal show={show} onHide={handleClose} backdrop="static">
<OLModalHeader>
<OLModalTitle>{t('delete_authentication_token')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>{t('delete_authentication_token_info')}</p>
{isError && (
<div className="notification-list">
<Notification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleClose} disabled={isLoading}>
{t('cancel')}
</OLButton>
<OLButton variant="danger" onClick={handleDelete} disabled={isLoading}>
{t('delete_token')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
@@ -0,0 +1,80 @@
import { Trans, useTranslation } from 'react-i18next'
import {
OLModal,
OLModalHeader,
OLModalTitle,
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
type Props = {
secretToken: string
handleHide: () => void
}
export default function ExposeTokenModal({
secretToken,
handleHide,
}: Props) {
const { t } = useTranslation()
return (
<OLModal
show
onHide={handleHide}
backdrop="static"
id="create-token-modal"
>
<OLModalHeader closeButton>
<OLModalTitle>
{t('git_authentication_token')}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{t('git_authentication_token_create_modal_info_1')}
</p>
<div className="text-center">
<div className="git-bridge-copy">
<span aria-label={t('git_authentication_token')}>
<code>{secretToken}</code>
</span>
<CopyToClipboard
content={secretToken}
tooltipId="git-auth-token-copy"
kind="text"
/>
</div>
</div>
<p>
<Trans
i18nKey="git_authentication_token_create_modal_info_2"
components={[
<strong key="strong" />,
<a
key="link"
href="/learn/how-to/Git_integration_authentication_tokens"
target="_blank"
rel="noreferrer noopener"
/>,
]}
/>
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="primary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
@@ -0,0 +1,49 @@
import { useTranslation } from 'react-i18next'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLButton from '@/shared/components/ol/ol-button'
import Cell from '@/features/settings/components/emails/cell'
type Props = {
tokenCount: number
limitReached: boolean
onCreateToken: () => void
isLoading: boolean
}
export default function TokenTableFooter({
tokenCount,
limitReached,
onCreateToken,
isLoading,
}: Props) {
const { t } = useTranslation()
if (tokenCount === 0) return null
return (
<>
<div className="horizontal-divider" />
<div className="affiliations-table-row-highlighted">
<OLRow className="small">
<OLCol lg={12}>
<Cell>
{limitReached ? (
<p>{t('token_limit_reached')}</p>
) : (
<OLButton
variant="link"
className="btn-inline-link"
onClick={onCreateToken}
disabled={isLoading}
>
{t('add_another_token')}
</OLButton>
)}
</Cell>
</OLCol>
</OLRow>
</div>
</>
)
}
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import Cell from '@/features/settings/components/emails/cell'
export default function TokenTableHeader() {
const { t } = useTranslation()
return (
<>
<OLRow className="small">
<OLCol lg={4} className="linking-git-bridge-table-cell">
<Cell><strong>{t('token')}</strong></Cell>
</OLCol>
<OLCol lg={2} className="linking-git-bridge-table-cell">
<Cell><strong>{t('created_at')}</strong></Cell>
</OLCol>
<OLCol lg={2} className="linking-git-bridge-table-cell">
<Cell><strong>{t('last_used')}</strong></Cell>
</OLCol>
<OLCol lg={3} className="linking-git-bridge-table-cell">
<Cell><strong>{t('expires')}</strong></Cell>
</OLCol>
</OLRow>
<div className="horizontal-divider" />
<div className="horizontal-divider" />
</>
)
}
@@ -0,0 +1,64 @@
import moment from 'moment'
import { useTranslation } from 'react-i18next'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLButton from '@/shared/components/ol/ol-button'
import Cell from '@/features/settings/components/emails/cell'
import { Token } from '../../../../types/api'
type Props = {
token: Token
handleDeleteClick: (id: string) => void
}
function TokenTableRow({ token, handleDeleteClick }: Props) {
const { t } = useTranslation()
const created = moment(token.created_at).format('Do MMM YYYY')
const lastUsed = token.lastUsedAt
? moment(token.lastUsedAt).format('Do MMM YYYY')
: t('never')
const expires = moment(token.expiresAt).format('Do MMM YYYY')
const handleClick = () => handleDeleteClick(token._id)
return (
<OLRow className="small">
<OLCol lg={4} className="linking-git-bridge-table-cell">
<Cell>{token.accessTokenPartial + '************'}</Cell>
</OLCol>
<OLCol lg={2} className="linking-git-bridge-table-cell">
<Cell>{created}</Cell>
</OLCol>
<OLCol lg={2} className="linking-git-bridge-table-cell">
<Cell>{lastUsed}</Cell>
</OLCol>
<OLCol lg={3} className="linking-git-bridge-table-cell">
<Cell>{expires}</Cell>
</OLCol>
<OLCol lg={1} className="linking-git-bridge-table-cell">
<Cell>
<OLButton
className="linking-git-bridge-revoke-button icon-button-small"
variant="secondary"
aria-label={t('remove')}
onClick={handleClick}
>
<span
className="material-symbols icon-small"
aria-hidden="true"
>
delete
</span>
</OLButton>
</Cell>
</OLCol>
</OLRow>
)
}
export default TokenTableRow
@@ -0,0 +1,65 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import DeleteTokenModal from './modals/delete-token-modal'
import TokenTableHeader from './token-table-header'
import TokenTableRow from './token-table-row'
import TokenTableFooter from './token-table-footer'
import { Token } from '../../../../types/api'
const MAX_TOKENS = 10
type Props = {
tokens: Token[]
onCreateToken: () => void
onDeleteToken: (id: string) => void
}
export default function TokenTable({
tokens,
onCreateToken,
onDeleteToken,
}: Props) {
const { t } = useTranslation()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [selectedTokenId, setSelectedTokenId] = useState('')
const handleDeleteClick = (id: string) => {
setSelectedTokenId(id)
setShowDeleteModal(true)
}
const tokenCount = tokens.length
const limitReached = tokenCount >= MAX_TOKENS
return (
<>
{tokenCount > 0 && <TokenTableHeader />}
{tokens.map((token) => (
<TokenTableRow
key={token._id}
token={token}
handleDeleteClick={handleDeleteClick}
/>
))}
<TokenTableFooter
tokenCount={tokenCount}
limitReached={limitReached}
onCreateToken={onCreateToken}
isLoading={false}
/>
<DeleteTokenModal
show={showDeleteModal}
tokenId={selectedTokenId}
handleHide={() => setShowDeleteModal(false)}
onDeleted={(id: string) => {
onDeleteToken(id)
setShowDeleteModal(false)
}}
/>
</>
)
}