Github Sync: initial commit

This commit is contained in:
yu-i-i
2026-09-22 02:41:42 +02:00
parent ac73b428ff
commit de2744f834
36 changed files with 3969 additions and 10 deletions
@@ -0,0 +1,41 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import GithubLogo from '@/shared/svgs/github-logo'
import IntegrationCard from '@/features/integrations-panel/integration-card'
import GitSyncModal from './modals/git-sync-modal'
import { GitSyncModalStatus } from '../types/git-sync-types'
const GitHubSyncCard = () => {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const { projectId, name } = useProjectContext()
const [modalStatus, setModalStatus] = useState<GitSyncModalStatus>('loading')
return (
<>
<IntegrationCard
title={t('github')}
description={t('sync_with_a_github_repository')}
icon={<GithubLogo size={32} />}
showPaywallBadge={false}
onClick={() => setShowModal(true)}
>
</IntegrationCard>
<GitSyncModal
show={showModal}
modalStatus={modalStatus}
setModalStatus={setModalStatus}
handleHide={() => {
setShowModal(false)
setModalStatus('loading')
}}
projectId={projectId}
projectName={name}
/>
</>
)
}
export default GitHubSyncCard
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/shared/components/ol/ol-button'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLNotification from '@/shared/components/ol/ol-notification'
import GithubLogo from '@/shared/svgs/github-logo'
export const GitHubSyncWidget = function GitHubSyncWidget() {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
const {
isLoading: isCheckingConn,
isError: isErrorConnCheck,
runAsync: runAsyncConnCheck,
data: isConnected,
setData: setConnState,
} = useAsync<boolean>()
const {
isLoading: isUnlinking,
isError: isErrorUnlink,
runAsync: runAsyncUnlink,
} = useAsync<void>()
const [showUnlinkModal, setShowUnlinkModal] = useState(false)
const handleConnCheck = useCallback(() => {
runAsyncConnCheck(getJSON('/user/github-sync/status')).catch(err =>
debugConsole.error(err?.data?.message || err?.message || err),
)
}, [runAsyncConnCheck])
useEffect(() => {
handleConnCheck()
}, [handleConnCheck])
const handleUnlink = useCallback(() => {
runAsyncUnlink(postJSON('/user/github-sync/unlink'))
.then(() => setConnState(false))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
.finally(() => setShowUnlinkModal(false))
}, [runAsyncUnlink])
if (isCheckingConn) {
return (
<div className="settings-widget-container">
<div>
<GithubLogo />
</div>
<div className="description-container">
<div className="title-row">
<h4>GitHub</h4>
</div>
<p className="small">
<span>{t('loading')}…</span>
</p>
</div>
</div>
)
}
return (
<>
<div className="settings-widget-container">
<div>
<GithubLogo size={40} />
</div>
<div className="description-container">
<div className="title-row">
<h4 id="github-sync">GitHub</h4>
</div>
<p className="small">
{t('github_sync_description', { appName })}
</p>
{isErrorConnCheck && (
<OLNotification
type="error"
content={t('github_sync_error')}
/>
)}
{isErrorUnlink && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</div>
<div>
{isConnected ? (
<OLButton
variant="danger-ghost"
onClick={() => setShowUnlinkModal(true)}
disabled={isUnlinking}
>
{isUnlinking ? t('unlinking') : t('unlink')}
</OLButton>
) : isErrorConnCheck ? (
<OLButton
variant="secondary"
onClick={handleConnCheck}
>
{t('reconnect')}
</OLButton>
) : (
<OLButton
variant="secondary"
href="/user/github-sync/oauth2"
>
{t('link')}
</OLButton>
)}
</div>
</div>
<OLModal
id="git-sync-modal"
show={showUnlinkModal}
onHide={() => setShowUnlinkModal(false)}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>
{t('unlink_provider_account_title', {
provider: 'GitHub',
})}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{t('unlink_github_warning', {
provider: 'GitHub',
})}
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={() => setShowUnlinkModal(false)}
>
{t('cancel')}
</OLButton>
<OLButton
variant="danger-ghost"
onClick={handleUnlink}
disabled={isUnlinking}
>
{isUnlinking ? t('unlinking') : t('unlink')}
</OLButton>
</OLModalFooter>
</OLModal>
</>
)
}
@@ -0,0 +1,12 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
export default function ImportFromGitHubMenu({ onClick }: { onClick: (e: React.MouseEvent) => void }) {
const { t } = useTranslation()
return (
<DropdownItem onClick={onClick}>
{t('import_from_github')}
</DropdownItem>
)
}
@@ -0,0 +1,218 @@
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import {
postJSON,
getJSON,
} from '@/infrastructure/fetch-json'
import getMeta from '@/utils/meta'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import OLNotification from '@/shared/components/ol/ol-notification'
type GitSyncRepo = {
name: string
fullName: string
defaultBranchName: string
}
type GitSyncReposResponse = {
repos?: GitSyncRepo[]
}
function ImportFromGitHubModalContent({ handleHide }: { handleHide: () => void }) {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
const {
isLoading,
isSuccess,
isError,
data,
runAsync,
} = useAsync<GitSyncReposResponse>()
useEffect(() => {
runAsync(getJSON('/user/github-sync/repos'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}, [runAsync])
const reposExist = data?.repos != null
const repos = reposExist ? data.repos : []
const {
isLoading: isImporting,
isSuccess: isImported,
isError: isErrorImport,
runAsync: runAsyncImport,
} = useAsync<{ projectId: string }>()
useEffect(() => {
if (isImported) {
handleHide()
}
}, [handleHide, isImported])
const showLinkToGitHub = !isImporting && isSuccess && !reposExist
const showRepos = !isImporting && isSuccess && reposExist
const handleImport = (repo: GitSyncRepo) => {
runAsyncImport(
postJSON('/project/new/github-sync', {
body: repo
})
)
.then(data => {
window.location.href = `/project/${data.projectId}`
})
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
return (
<>
<OLModalHeader onClose={handleHide}>
<OLModalTitle>{t('import_from_github')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isLoading && (
<span>
<OLSpinner size="sm" className="me-2"/>
{t('loading_github_repositories')}
</span>
)}
{isImporting && (
<span>
<OLSpinner size="sm" className="me-2"/>
{t('importing')}
</span>
)}
{isError && (
<div className="notification-list">
<OLNotification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
{showLinkToGitHub && (
<div className="text-center">
<p>
{t('link_to_github_description', { appName })}
</p>
<OLButton
variant="secondary"
href="/user/github-sync/oauth2"
>
{t('link_to_github')}
</OLButton>
</div>
)}
{showRepos && (
<div>
{repos.length === 0 ? (
<p className="text-center">
{t('you_dont_have_any_repositories')}
</p>
) : (
<>
<p className="text-center">
{t('select_github_repository', { appName })}
</p>
<div className="table-container table-container-bordered">
<table className="table table-striped table-hover">
<tbody>
{repos.map(repo => (
<tr key={repo.fullName}>
<td>
{repo.name}
<div className="small">
<a
href={`https://github.com/${repo.fullName}`}
target="_blank"
rel="noopener noreferrer"
>
{repo.fullName}
</a>
</div>
</td>
<td className="text-end">
<OLButton
variant="primary"
onClick={() => handleImport(repo)}
>
{t('import_to_sharelatex', { appName: 'Overleaf' })}
</OLButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{isErrorImport && (
<div className="notification-list">
<OLNotification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
</OLModalBody>
<OLModalFooter>
{!isImporting && (
<span className="me-auto">
<a
href="https://help.github.com/en/articles/requesting-organization-approval-for-oauth-apps"
target="_blank"
rel="noopener noreferrer"
>
{t('cant_see_what_youre_looking_for_question')}
</a>
</span>
)}
<OLButton
variant="secondary"
onClick={handleHide}
disabled={isImporting}
>
{t('cancel')}
</OLButton>
</OLModalFooter>
</>
)
}
export default function ImportFromGitHubModal({ onHide }: { onHide: () => void }) {
return (
<OLModal
id="git-import-modal"
show
animation
size="lg"
onHide={onHide}
backdrop="static"
>
<ImportFromGitHubModalContent handleHide={onHide} />
</OLModal>
)
}
@@ -0,0 +1,47 @@
import { useTranslation, Trans } from 'react-i18next'
import OLNotification from '@/shared/components/ol/ol-notification'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import { ProjectSyncState } from '../../types/git-sync-types'
type GitSyncNeedPermissionModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
}
const GitSyncCannotExportModal = ({ projectSyncState, handleHide }: GitSyncCannotExportModalProps) => {
const { t } = useTranslation()
return (
<>
<OLModalBody>
<OLNotification
type="warning"
content={(
<Trans
i18nKey="ask_proj_owner_to_export_to_github"
values={{
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
}}
components={[
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
]}
/>
)}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncCannotExportModal
@@ -0,0 +1,101 @@
import { useTranslation, Trans } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { deleteJSON } from '@/infrastructure/fetch-json'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { debugConsole } from '@/utils/debugging'
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncUnlinkModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
projectSyncState: ProjectSyncState
projectId: string
}
const GitSyncUnlinkModal = ({
handleHide,
setModalStatus,
projectSyncState,
projectId,
}: GitSyncInitModalProps) => {
const { t } = useTranslation()
const {
error,
isLoading,
runAsync,
} = useAsync<void>()
const handleUnlink = () => {
runAsync(deleteJSON(`/project/${projectId}/github-sync`))
.then(() => setModalStatus('need-export'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
return (
<>
<OLModalBody>
<p>
{t('project_linked_to')}:&nbsp;
<a
href={`https://github.com/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
>
{projectSyncState.repoFullName}
</a>
</p>
<hr />
{!error ? (
<OLNotification
type="warning"
content={t('unlink_the_project_from_the_current_github_repo')}
/>
) : (
<OLNotification
type="error"
content={
error.info?.statusCode === 403 ? (
<Trans
i18nKey="ask_proj_owner_to_unlink_from_current_github"
values={{ projectOwnerEmail: error?.data?.ownerEmail ?? '?' }}
components={[
error?.data?.ownerEmail ? <a href={`mailto:${error.data.ownerEmail}`} /> : <></>
]}
/>
) : (
t('something_went_wrong_server')
)
}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex gap-2">
<OLButton
variant="danger"
onClick={handleUnlink}
disabled={isLoading || !!error}
>
{t('confirm')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncUnlinkModal
@@ -0,0 +1,57 @@
import { useTranslation, Trans } from 'react-i18next'
import getMeta from '@/utils/meta'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncConflictModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
}
const GitSyncConflictModal = ({ projectSyncState, handleHide, setModalStatus }: GitSyncConflictModalProps) => {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
return (
<>
<OLModalBody>
<OLNotification
type="warning"
content={t('github_merge_failed_error', { appName })}
/>
<p className="mt-2">
<Trans
i18nKey="github_manual_merge_user_prompt"
values={{sharelatex_branch: projectSyncState.unmergedBranchName }}
components={[<b />]}
/>
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
<OLButton
variant="primary"
onClick={
() => setModalStatus('run-merge-resolved')
}
>
{t('continue_github_merge')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncConflictModal
@@ -0,0 +1,229 @@
import { useTranslation } from 'react-i18next'
import { useState, useEffect } from 'react'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import {
getJSON,
postJSON
} from '@/infrastructure/fetch-json'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLNotification from '@/shared/components/ol/ol-notification'
import OLButton from '@/shared/components/ol/ol-button'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import OLFormSelect from '@/shared/components/ol/ol-form-select'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import { GitSyncModalStatus } from '../../types/git-sync-types'
type OrgsResponse = {
user: string
orgs: string[]
}
type GitSyncExportModalProps = {
projectId: string
projectName: string
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
}
const GitSyncExportModal = ({
projectId,
projectName,
handleHide,
setModalStatus
}: GitSyncExportModalProps) => {
const { t } = useTranslation()
const [selectedOwner, setSelectedOwner] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const [repoName, setRepoName] = useState(projectName)
const [description, setDescription] = useState('')
const [visibility, setVisibility] = useState<'public' | 'private'>('private')
const {
runAsync: runAsyncUserAndOrgs,
data: userAndOrgs,
error: errorUserAndOrgs
} = useAsync<OrgsResponse>()
useEffect(() => {
runAsyncUserAndOrgs(getJSON('/user/github-sync/orgs'))
.then(userAndOrgs => setSelectedOwner(userAndOrgs?.user))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}, [])
const { isLoading, error, setError, runAsync } = useAsync<void>()
const createRepo = () => {
const isPublic = visibility === 'public'
const org = selectedOwner === userAndOrgs?.user ? undefined : selectedOwner
runAsync(postJSON(`/project/${projectId}/github-sync/export`, {
body: {
name: repoName,
description,
isPublic,
org,
},
}))
.then(() => setModalStatus('loading'))
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
if (!err?.data?.key) setError(t('something_went_wrong_server'))
else setError(t(err.data.key))
})
}
return (
<>
<OLModalBody>
<h4>{t('export_project_to_github')}</h4>
<p>{t('project_not_linked_to_github')}</p>
{error && (
<OLNotification
type="error"
content={error}
/>
)}
{errorUserAndOrgs && (
<OLNotification
type="error"
content={t('something_went_wrong_server')}
/>
)}
<OLForm onSubmit={createRepo}>
<OLRow>
<OLCol xs={4}>
<OLFormGroup>
<OLFormLabel htmlFor="github-sync-owner">
{selectedOwner === userAndOrgs?.user
? t('owner')
: t('organization')}
</OLFormLabel>
<OLFormSelect
id="github-sync-owner"
name="org"
value={selectedOwner}
onChange={e => setSelectedOwner(e.target.value)}
>
<option key={userAndOrgs?.user} value={userAndOrgs?.user}>
{userAndOrgs?.user}
</option>
{userAndOrgs?.orgs.map(org => (
<option key={org} value={org}>
{org}
</option>
))}
</OLFormSelect>
</OLFormGroup>
</OLCol>
<OLCol xs={5}>
<OLFormGroup>
<OLFormLabel htmlFor="github-sync-name">
{t('repository_name')}
</OLFormLabel>
<OLFormControl
id="github-sync-name"
name="name"
type="text"
value={repoName}
onChange={e => setRepoName(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
<OLRow>
<OLCol xs={12}>
<OLFormGroup>
<OLFormLabel htmlFor="github-sync-description">
{t('description')} ({t('optional')})
</OLFormLabel>
<OLFormControl
id="github-sync-description"
name="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
<hr />
<fieldset>
<legend className="visually-hidden">
{t('repository_visibility')}
</legend>
<OLFormGroup>
<OLRow>
<OLCol xs={12}>
<OLFormCheckbox
type="radio"
id="public"
name="repository"
value="public"
checked={visibility === 'public'}
onChange={() => setVisibility('public')}
label={t('public', { defaultValue: 'Public' })}
description={t('github_public_description')}
/>
</OLCol>
</OLRow>
<OLRow>
<OLCol xs={12}>
<OLFormCheckbox
className="mr-1"
type="radio"
id="private"
name="repository"
value="private"
checked={visibility === 'private'}
onChange={() => setVisibility('private')}
label={t('private', { defaultValue: 'Private' })}
description={t('github_private_description')}
/>
</OLCol>
</OLRow>
</OLFormGroup>
</fieldset>
</OLForm>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={createRepo}
disabled={!repoName.trim() || isLoading}
isLoading={isLoading}
>
{t('create_project_in_github')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncExportModal
@@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncLoadingModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
errorMessage: string | null
}
const GitSyncLoadingModal = ({ handleHide, setModalStatus, errorMessage }: GitSyncLoadingModalProps) => {
const { t } = useTranslation()
return (
<>
<OLModalBody>
<div role="status" className="loading align-items-start">
<div aria-hidden="true" data-testid="ol-spinner" className="spinner-border spinner-border-sm"></div>
{t('checking_project_github_status')}
</div>
</OLModalBody>
{errorMessage && (
<OLNotification
type="error"
content={errorMessage}
/>
)}
<OLModalFooter>
{errorMessage && (
<OLButton
variant="danger-ghost"
onClick={() => setModalStatus('confirm-unlink')}
>
{t('unlink')}
</OLButton>
)}
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncLoadingModal
@@ -0,0 +1,119 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { debugConsole } from '@/utils/debugging'
import { postJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types'
type GitSyncMergeModalProps = {
handleHide: () => void
modalStatus: GitSyncModalStatus
setModalStatus: (modalStatus: GitSyncModalStatus) => void
setProjectSyncState: (projectSyncState: ProjectSyncState) => void
commitMessage: string
setCommitMessage: (message: string) => void
projectId: string
}
const GitSyncMergeModal = ({
handleHide,
modalStatus,
setModalStatus,
setProjectSyncState,
commitMessage,
setCommitMessage,
projectId
}: GitSynMergeModalProps) => {
const { t } = useTranslation()
const { error, isError, isLoading, setError, runAsync } = useAsync<ProjectSyncState>()
useEffect(() => {
if (modalStatus !== 'run-merge' &&
modalStatus !== 'run-merge-resolved'
) return
const claimConflictIsResolved = (modalStatus === 'run-merge-resolved')
runAsync(
postJSON(`/project/${projectId}/github-sync/merge`, {
body: { message: commitMessage, claimConflictIsResolved },
})
)
.then(data => {
switch (data.mergeStatus) {
case 'clean':
setModalStatus('merge-overview')
break
case 'conflict':
setModalStatus('show-conflict')
break
default:
const message = `Unexpected merge status received: ${data.mergeStatus}`
const msError = new Error(message)
setError(msError)
throw msError
}
setProjectSyncState(data)
setCommitMessage('')
})
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
if (err?.info?.statusCode === 403 || err?.info?.statusCode === 404) setModalStatus('loading')
})
}, [
modalStatus,
projectId,
commitMessage,
runAsync,
])
if (!isLoading && !isError) return
return (
<>
<OLModalBody>
<div
role="status"
className="loading align-items-start"
>
<div
aria-hidden="true"
data-testid="ol-spinner"
className="spinner-border spinner-border-sm"
></div>
{t('importing_and_merging_changes_in_github')}
</div>
</OLModalBody>
{error && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncMergeModal
@@ -0,0 +1,238 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { getJSON } from '@/infrastructure/fetch-json'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control'
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 OLIconButton from '@/shared/components/ol/ol-icon-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { debugConsole } from '@/utils/debugging'
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncMergeOverviewModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
projectSyncState: ProjectSyncState
projectId: string
commitMessage: string
setCommitMessage: (message: string) => void
}
type GitCommit = {
message: string
author: {
name: string
email: string
date: string
}
sha: string
}
type UnmergedCommitsResponse = {
commits: GitCommit[]
diverged: boolean
isProjectUpdated: boolean
}
const GitSyncMergeOverviewModal = ({
handleHide,
setModalStatus,
projectSyncState,
projectId,
commitMessage,
setCommitMessage
}: GitSyncMergeOverviewModalProps) => {
const { t } = useTranslation()
const appName = 'Overleaf'
const {
data,
setData,
error,
isSuccess,
isLoading,
runAsync,
} = useAsync<UnmergedCommitsResponse | null>()
const loadUnmergedCommits = () => {
setData({})
runAsync(getJSON(`/project/${projectId}/github-sync/merge/overview`))
.then(data => { if (!data) setModalStatus('loading') })
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
if (err?.info?.statusCode === 403 ||
err?.info?.statusCode === 404 ||
err?.info?.statusCode === 401
) setModalStatus('loading')
})
}
useEffect(() => {
loadUnmergedCommits()
}, [projectId, runAsync])
return (
<>
<OLModalBody>
<p>
{t('project_linked_to')}:&nbsp;
<a
href={`https://github.com/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
>
{projectSyncState.repoFullName}
</a>
</p>
{isLoading && (
<>
<div role="status" className="loading align-items-start">
<div
aria-hidden="true"
data-testid="ol-spinner"
className="spinner-border spinner-border-sm"
></div>
{t('loading')}
</div>
</>
)}
{isSuccess && (
<>
<hr />
{data.diverged && (
<OLNotification
type="warning"
content={t('github_repository_diverged')}
/>
)}
<h3 className="github-sync-commits-heading">
{t('recent_commits_in_github')}
<OLIconButton
icon="refresh"
size="sm"
variant="ghost"
onClick={loadUnmergedCommits}
accessibilityLabel={t('refresh')}
/>
</h3>
{data.commits?.length === 0 && (
<div>
<p className="small">{t('no_new_commits_in_github')}</p>
</div>
)}
{data.commits?.length > 0 && (
<div style={{ maxHeight: '200px', overflow: 'auto' }}>
{data.commits.map((commit: GitCommit) => (
<div key={commit.sha}>
<span className="small float-end">
<a
href={`https://github.com/${projectSyncState.repoFullName}/commit/${commit.sha}`}
target="_blank"
rel="noreferrer noopener"
>
{commit.sha.substring(0, 7)}
</a>
</span>
<a
href={`https://github.com/${projectSyncState.repoFullName}/commit/${commit.sha}`}
target="_blank"
className="commit-message"
rel="noreferrer noopener"
>
{commit.message}
</a>
<div className="small">
by {commit.author.name} &lt;{commit.author.email}&gt;
</div>
</div>
))}
</div>
)}
{data.isProjectUpdated && (
<>
<hr />
<OLForm>
<p>{t('sync_project_to_github_explanation', { appName })}</p>
<OLRow>
<OLCol xs={12}>
<OLFormGroup>
<OLFormControl
as="textarea"
rows={1}
value={commitMessage}
placeholder={t('github_commit_message_placeholder', { appName })}
onChange={(e) => setCommitMessage(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
</OLForm>
</>
)}
</>
)}
{error && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex justify-content-between w-100">
<div className="d-flex gap-2">
<OLButton
variant="danger-ghost"
onClick={() => setModalStatus('confirm-unlink')}
disabled={!isSuccess && error?.info?.statusCode !== 404}
>
{t('unlink')}
</OLButton>
</div>
<div className="d-flex gap-2">
<OLButton
variant="primary"
leadingIcon="sync"
onClick={() => setModalStatus('run-merge')}
disabled={!isSuccess}
>
{t('sync')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</div>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncMergeOverviewModal
@@ -0,0 +1,226 @@
import { useTranslation } from 'react-i18next'
import { useState, useEffect } from 'react'
import {
OLModalHeader,
OLModalTitle,
OLModal,
} from '@/shared/components/ol/ol-modal'
import { getJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import GitSyncLoadingModal from './git-sync-loading-modal'
import GitSyncMergeOverviewModal from './git-sync-merge-overview-modal'
import GitSyncMergeModal from './git-sync-merge-modal'
import GitSyncConflictModal from './git-sync-conflict-modal'
import GitSyncExportModal from './git-sync-export-modal'
import GitSyncNeedAuthModal from './git-sync-need-auth-modal'
import GitSyncNeedPermissionModal from './git-sync-need-permission-modal'
import GitSyncConfirmUnlinkModal from './git-sync-confirm-unlink-modal'
import GitSyncUnlinkUnavailableModal from './git-sync-unlink-unavailable-modal'
import GitSyncCannotExportModal from './git-sync-cannot-export-modal'
import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types'
type GitSyncModalProps = {
show: boolean
handleHide: () => void
projectId: string
projectName: string
modalStatus: GitSyncModalStatus
setModalStatus: (modalStatus: GitSyncModalStatus) => void
}
function GitSyncModal({
show,
handleHide,
projectId,
projectName,
modalStatus,
setModalStatus,
}: GitSyncModalProps) {
const { t } = useTranslation()
const [commitMessage, setCommitMessage] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const {
runAsync: runAsyncConn,
error: errorConn,
setError: setErrorConn,
data: dataConn,
} = useAsync<boolean>()
const {
runAsync,
data: projectSyncState,
setData: setProjectSyncState,
error,
setError,
} = useAsync<ProjectSyncState>()
useEffect(() => {
if (!show) return
if (modalStatus !== 'loading') return
const fetchProjectState = () => {
setError(null)
setErrorMessage('')
runAsync(getJSON(`/project/${projectId}/github-sync/state`))
.then(pss => {
switch (pss.mergeStatus) {
// pss.ownerEmail is set to the email address of the project owner
// or null if the user is the project owner
// only owner can export project to git server
case 'need-export':
if (!pss.ownerEmail) setModalStatus('need-export')
else setModalStatus('cannot-export')
break
case 'clean':
case 'diverged':
setModalStatus('merge-overview')
break
case 'conflict':
setModalStatus('show-conflict')
break
// user does not have push permission to repo
// pss.ownerEmail is set to the email address of the owner
// or null if the user is the project owner
// if the user is owner, it seems that repo is not accessible
case 'need-permission': {
if (!pss.ownerEmail) setModalStatus('unlink-unavailable')
else setModalStatus('need-permission')
break
}
}
})
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
setErrorMessage(t('generic_something_went_wrong'))
})
}
const fetchConnectionStatus = () => {
setErrorConn(null)
setErrorMessage('')
runAsyncConn(getJSON(`/user/github-sync/status`))
.then(isConnected => {
if (!isConnected) {
setModalStatus('need-auth')
return
}
fetchProjectState()
})
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
setErrorMessage(t('generic_something_went_wrong'))
})
}
fetchConnectionStatus()
}, [show, modalStatus, projectId, runAsyncConn, runAsync])
return (
<OLModal show={show} onHide={handleHide} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('sync_with_github')}</OLModalTitle>
</OLModalHeader>
{modalStatus === 'loading' && (
<GitSyncLoadingModal
handleHide={handleHide}
setModalStatus={setModalStatus}
errorMessage={errorMessage}
/>
)}
{modalStatus === 'need-export' && (
<GitSyncExportModal
projectId={projectId}
projectName={projectName}
handleHide={handleHide}
setModalStatus={setModalStatus}
/>
)}
{modalStatus === 'cannot-export' && (
<GitSyncCannotExportModal
projectSyncState={projectSyncState}
handleHide={handleHide}
/>
)}
{modalStatus === 'need-auth' && (
<GitSyncNeedAuthModal
handleHide={handleHide}
/>
)}
{modalStatus === 'need-permission' && (
<GitSyncNeedPermissionModal
handleHide={handleHide}
projectSyncState={projectSyncState}
/>
)}
{modalStatus === 'merge-overview' && (
<GitSyncMergeOverviewModal
projectId={projectId}
projectSyncState={projectSyncState}
setModalStatus={setModalStatus}
handleHide={handleHide}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
/>
)}
{modalStatus === 'confirm-unlink' && (
<GitSyncConfirmUnlinkModal
projectId={projectId}
projectSyncState={projectSyncState}
setModalStatus={setModalStatus}
handleHide={handleHide}
/>
)}
{modalStatus === 'unlink-unavailable' && (
<GitSyncUnlinkUnavailableModal
projectId={projectId}
setModalStatus={setModalStatus}
handleHide={handleHide}
/>
)}
{(modalStatus === 'run-merge' ||
modalStatus === 'run-merge-resolved') && (
<GitSyncMergeModal
handleHide={handleHide}
modalStatus={modalStatus}
setModalStatus={setModalStatus}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
projectId={projectId}
setProjectSyncState={setProjectSyncState}
/>
)}
{modalStatus === 'show-conflict' && (
<GitSyncConflictModal
projectSyncState={projectSyncState}
handleHide={handleHide}
setModalStatus={setModalStatus}
/>
)}
</OLModal>
)
}
export default GitSyncModal
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
const GitSyncNeedAuthModal = ({ handleHide }: { handleHide: () => void }) => {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
return (
<>
<OLModalBody>
<p>{t('link_to_github_description', { appName })}</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
<OLButton
variant="primary"
onClick={() => {
handleHide()
window.open(
'/user/github-sync/oauth2',
'githubAuth',
'width=600,height=700'
)
}}
>
{t('link_to_github')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncNeedAuthModal
@@ -0,0 +1,57 @@
import { useTranslation, Trans } from 'react-i18next'
import OLNotification from '@/shared/components/ol/ol-notification'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import { ProjectSyncState } from '../../types/git-sync-types'
type GitSyncNeedPermissionModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
}
const GitSyncNeedPermissionModal = ({ projectSyncState, handleHide }: GitSyncConflictModalProps) => {
const { t } = useTranslation()
return (
<>
<OLModalBody>
<OLNotification
type="warning"
content={(
<Trans
i18nKey="ask_proj_owner_to_add_you_as_github_collaborator"
values={{
repoFullName: projectSyncState.repoFullName ?? '?',
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
}}
components={[
projectSyncState.repoFullName ? (
<a
href={`https://github.com/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
/>
) : (
<></>
),
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
]}
/>
)}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncNeedPermissionModal
@@ -0,0 +1,85 @@
import { useTranslation, Trans } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { deleteJSON } from '@/infrastructure/fetch-json'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import { debugConsole } from '@/utils/debugging'
import { GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncUnlinkUnavailableModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
projectId: string
}
const GitSyncUnlinkUnavailableModal = ({
handleHide,
setModalStatus,
projectId,
}: GitSyncInitModalProps) => {
const { t } = useTranslation()
const {
error,
isLoading,
runAsync,
} = useAsync<void>()
const handleUnlink = () => {
runAsync(deleteJSON(`/project/${projectId}/github-sync`))
.then(() => setModalStatus('need-export'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
return (
<>
<OLModalBody>
<OLNotification
type="error"
content={t('github_sync_repository_not_found_description')}
/>
{error && (
<OLNotification
type="error"
content={
error.info?.statusCode === 403 ? (
<Trans
i18nKey="ask_proj_owner_to_unlink_from_current_github"
values={{ projectOwnerEmail: error?.data?.ownerEmail ?? '?' }}
components={[
error?.data?.ownerEmail ? <a href={`mailto:${error.data.ownerEmail}`} /> : <></>
]}
/>
) : (
t('something_went_wrong_server')
)
}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex gap-2">
<OLButton
variant="primary"
onClick={handleUnlink}
disabled={isLoading}
>
{t('unlink_github_repository')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncUnlinkUnavailableModal
@@ -0,0 +1,19 @@
export type GitSyncModalStatus =
'loading'
| 'need-export'
| 'cannot-export'
| 'need-auth'
| 'need-permission'
| 'merge-overview'
| 'confirm-unlink'
| 'unlink-unavailable'
| 'run-merge'
| 'run-merge-resolved'
| 'show-conflict'
export type ProjectSyncState = {
mergeStatus: 'clean' | 'conflict' | 'diverged' | 'need-export' | 'need-permission'
repoFullName: string
unmergedBranchName: string | null
ownerEmail?: string
}
@@ -0,0 +1,8 @@
.github-sync-commits-heading {
align-items: center;
display: flex;
font-size: var(--font-size-03);
font-weight: 600;
justify-content: space-between;
line-height: var(--line-height-03);
}