mirror of
https://github.com/Kong/insomnia.git
synced 2026-04-18 13:18:59 -04:00
feat(Git Sync): Introduce the option to select a branch when cloning a repository (#8883)
* Allow users to pick a branch when cloning a repo * save * cleanup * add ref option to git clone action * cleanup UI * fix ts in credentials * fix ts issues from rebase * fix lint issue * add ref option to git actions and update credentials handling in UI components * fix: include credentials in dependency array for useEffect in GitRemoteBranchSelect
This commit is contained in:
@@ -26,10 +26,12 @@ import type { GitRepository } from '../models/git-repository';
|
||||
import { isWorkspace, type WorkspaceScope, WorkspaceScopeKeys } from '../models/workspace';
|
||||
import { fsClient } from '../sync/git/fs-client';
|
||||
import GitVCS, {
|
||||
fetchRemoteBranches,
|
||||
GIT_CLONE_DIR,
|
||||
GIT_INSOMNIA_DIR,
|
||||
GIT_INSOMNIA_DIR_NAME,
|
||||
GIT_INTERNAL_DIR,
|
||||
type GitCredentials,
|
||||
MergeConflictError,
|
||||
} from '../sync/git/git-vcs';
|
||||
import { MemClient } from '../sync/git/mem-client';
|
||||
@@ -189,6 +191,7 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId:
|
||||
try {
|
||||
const gitRepository = await getGitRepository({ workspaceId, projectId });
|
||||
|
||||
const bufferId = await database.bufferChanges();
|
||||
const fsClient = await getGitFSClient({ gitRepositoryId: gitRepository._id, projectId, workspaceId });
|
||||
|
||||
if (GitVCS.isInitializedForRepo(gitRepository._id) && !gitRepository.needsFullClone) {
|
||||
@@ -241,6 +244,8 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId:
|
||||
legacyInsomniaWorkspace = await containsLegacyInsomniaDir({ fsClient });
|
||||
}
|
||||
|
||||
await database.flushChanges(bufferId);
|
||||
|
||||
return {
|
||||
branch: await GitVCS.getCurrentBranch(),
|
||||
branches: await GitVCS.listBranches(),
|
||||
@@ -591,6 +596,7 @@ export const initGitRepoCloneAction = async ({
|
||||
token,
|
||||
username,
|
||||
oauth2format,
|
||||
ref,
|
||||
}: {
|
||||
organizationId: string;
|
||||
uri: string;
|
||||
@@ -599,6 +605,7 @@ export const initGitRepoCloneAction = async ({
|
||||
token: string;
|
||||
username: string;
|
||||
oauth2format?: string;
|
||||
ref?: string;
|
||||
}): Promise<
|
||||
| {
|
||||
files: {
|
||||
@@ -647,6 +654,7 @@ export const initGitRepoCloneAction = async ({
|
||||
|
||||
try {
|
||||
await shallowClone({
|
||||
ref,
|
||||
fsClient: inMemoryFsClient,
|
||||
gitRepository: repoSettingsPatch as GitRepository,
|
||||
});
|
||||
@@ -701,6 +709,7 @@ export const cloneGitRepoAction = async ({
|
||||
token,
|
||||
username,
|
||||
oauth2format,
|
||||
ref,
|
||||
}: {
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
@@ -712,6 +721,7 @@ export const cloneGitRepoAction = async ({
|
||||
token: string;
|
||||
username: string;
|
||||
oauth2format?: string;
|
||||
ref?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (!projectId) {
|
||||
@@ -750,6 +760,7 @@ export const cloneGitRepoAction = async ({
|
||||
|
||||
try {
|
||||
await shallowClone({
|
||||
ref,
|
||||
fsClient: inMemoryFsClient,
|
||||
gitRepository: repoSettingsPatch as GitRepository,
|
||||
});
|
||||
@@ -822,6 +833,7 @@ export const cloneGitRepoAction = async ({
|
||||
directory: GIT_CLONE_DIR,
|
||||
fs: fsClient,
|
||||
gitDirectory: GIT_INTERNAL_DIR,
|
||||
ref,
|
||||
});
|
||||
|
||||
await models.gitRepository.update(gitRepository, {
|
||||
@@ -846,7 +858,10 @@ export const cloneGitRepoAction = async ({
|
||||
await migrateLegacyInsomniaFolderToFile({ projectId: project._id });
|
||||
}
|
||||
|
||||
await models.gitRepository.update(gitRepository, {
|
||||
const updateRepository = await models.gitRepository.getById(gitRepository._id);
|
||||
invariant(updateRepository, 'Git Repository not found');
|
||||
|
||||
await models.gitRepository.update(updateRepository, {
|
||||
cachedGitLastCommitTime: Date.now(),
|
||||
cachedGitRepositoryBranch: await GitVCS.getCurrentBranch(),
|
||||
});
|
||||
@@ -900,6 +915,7 @@ export const cloneGitRepoAction = async ({
|
||||
const providerName = getOauth2FormatName(repoSettingsPatch.credentials);
|
||||
try {
|
||||
await shallowClone({
|
||||
ref,
|
||||
fsClient: inMemoryFsClient,
|
||||
gitRepository: repoSettingsPatch as GitRepository,
|
||||
});
|
||||
@@ -1089,6 +1105,7 @@ export const updateGitRepoAction = async ({
|
||||
oauth2format,
|
||||
username,
|
||||
token,
|
||||
ref,
|
||||
}: {
|
||||
projectId: string;
|
||||
workspaceId?: string;
|
||||
@@ -1098,6 +1115,7 @@ export const updateGitRepoAction = async ({
|
||||
oauth2format?: string;
|
||||
username: string;
|
||||
token: string;
|
||||
ref?: string;
|
||||
}) => {
|
||||
try {
|
||||
let gitRepositoryId: string | null | undefined = null;
|
||||
@@ -1178,6 +1196,7 @@ export const updateGitRepoAction = async ({
|
||||
gitDirectory: GIT_INTERNAL_DIR,
|
||||
gitCredentials: gitRepository.credentials,
|
||||
legacyDiff: Boolean(workspaceId),
|
||||
ref,
|
||||
});
|
||||
|
||||
await GitVCS.setAuthor();
|
||||
@@ -1704,6 +1723,26 @@ export const pushToGitRemoteAction = async ({
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchGitRemoteBranches({
|
||||
uri,
|
||||
credentials,
|
||||
}: {
|
||||
uri: string;
|
||||
credentials?: GitCredentials;
|
||||
}): Promise<{ branches: string[]; errors?: string[] }> {
|
||||
try {
|
||||
const branches = await fetchRemoteBranches({
|
||||
uri: parseGitToHttpsURL(uri),
|
||||
credentials,
|
||||
});
|
||||
|
||||
return { branches };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Error while fetching remote branches';
|
||||
return { branches: [], errors: [errorMessage] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function pullFromGitRemote({ projectId, workspaceId }: { projectId: string; workspaceId?: string }) {
|
||||
try {
|
||||
const gitRepository = await getGitRepository({ projectId, workspaceId });
|
||||
@@ -2472,7 +2511,7 @@ export interface GitServiceAPI {
|
||||
diffFileLoader: typeof diffFileLoader;
|
||||
getRepositoryDirectoryTree: typeof getRepositoryDirectoryTree;
|
||||
migrateLegacyInsomniaFolderToFile: typeof migrateLegacyInsomniaFolderToFile;
|
||||
|
||||
fetchGitRemoteBranches: typeof fetchGitRemoteBranches;
|
||||
initSignInToGitHub: typeof initSignInToGitHub;
|
||||
completeSignInToGitHub: typeof completeSignInToGitHub;
|
||||
signOutOfGitHub: typeof signOutOfGitHub;
|
||||
@@ -2489,6 +2528,9 @@ export const registerGitServiceAPI = () => {
|
||||
loadGitRepository(options),
|
||||
);
|
||||
ipcMainHandle('git.getGitBranches', (_, options: Parameters<typeof getGitBranches>[0]) => getGitBranches(options));
|
||||
ipcMainHandle('git.fetchGitRemoteBranches', (_, options: Parameters<typeof fetchGitRemoteBranches>[0]) =>
|
||||
fetchGitRemoteBranches(options),
|
||||
);
|
||||
ipcMainHandle('git.gitFetchAction', (_, options: Parameters<typeof gitFetchAction>[0]) => gitFetchAction(options));
|
||||
ipcMainHandle('git.gitLogLoader', (_, options: Parameters<typeof gitLogLoader>[0]) => gitLogLoader(options));
|
||||
ipcMainHandle('git.gitChangesLoader', (_, options: Parameters<typeof gitChangesLoader>[0]) =>
|
||||
|
||||
@@ -49,6 +49,7 @@ export type HandleChannels =
|
||||
| 'secretStorage.decryptString'
|
||||
| 'git.loadGitRepository'
|
||||
| 'git.getGitBranches'
|
||||
| 'git.fetchGitRemoteBranches'
|
||||
| 'git.gitFetchAction'
|
||||
| 'git.gitLogLoader'
|
||||
| 'git.gitChangesLoader'
|
||||
|
||||
@@ -70,6 +70,7 @@ const secretStorage: secretStorageBridgeAPI = {
|
||||
const git: GitServiceAPI = {
|
||||
loadGitRepository: options => ipcRenderer.invoke('git.loadGitRepository', options),
|
||||
getGitBranches: options => ipcRenderer.invoke('git.getGitBranches', options),
|
||||
fetchGitRemoteBranches: options => ipcRenderer.invoke('git.fetchGitRemoteBranches', options),
|
||||
gitFetchAction: options => ipcRenderer.invoke('git.gitFetchAction', options),
|
||||
gitLogLoader: options => ipcRenderer.invoke('git.gitLogLoader', options),
|
||||
gitChangesLoader: options => ipcRenderer.invoke('git.gitChangesLoader', options),
|
||||
|
||||
@@ -68,6 +68,7 @@ interface InitOptions {
|
||||
gitCredentials?: GitCredentials | null;
|
||||
uri?: string;
|
||||
repoId: string;
|
||||
ref?: string;
|
||||
// If enabled git-vcs will only diff files inside a .insomnia directory
|
||||
legacyDiff?: boolean;
|
||||
}
|
||||
@@ -78,6 +79,7 @@ interface InitFromCloneOptions {
|
||||
directory: string;
|
||||
fs: git.FsClient;
|
||||
gitDirectory: string;
|
||||
ref?: string;
|
||||
repoId: string;
|
||||
}
|
||||
|
||||
@@ -102,7 +104,7 @@ function getInsomniaFileName(blob: void | Uint8Array | undefined): string {
|
||||
try {
|
||||
const parsed = parse(Buffer.from(blob).toString('utf-8'));
|
||||
return parsed?.fileName || parsed?.name || '';
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// If the document couldn't be parsed as yaml return an empty string
|
||||
return '';
|
||||
}
|
||||
@@ -120,13 +122,14 @@ interface BaseOpts {
|
||||
uri: string;
|
||||
repoId: string;
|
||||
legacyDiff?: boolean;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
export class GitVCS {
|
||||
// @ts-expect-error -- TSCONVERSION not initialized with required properties
|
||||
_baseOpts: BaseOpts = gitCallbacks();
|
||||
|
||||
async init({ directory, fs, gitDirectory, gitCredentials, uri = '', repoId, legacyDiff = false }: InitOptions) {
|
||||
async init({ directory, fs, gitDirectory, gitCredentials, uri = '', repoId, legacyDiff = false, ref }: InitOptions) {
|
||||
this._baseOpts = {
|
||||
...this._baseOpts,
|
||||
dir: directory,
|
||||
@@ -137,6 +140,7 @@ export class GitVCS {
|
||||
uri,
|
||||
repoId,
|
||||
legacyDiff,
|
||||
ref,
|
||||
};
|
||||
|
||||
if (await this.repoExists()) {
|
||||
@@ -158,7 +162,7 @@ export class GitVCS {
|
||||
});
|
||||
|
||||
defaultBranch = mainRef?.target?.replace('refs/heads/', '') || 'main';
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
@@ -174,13 +178,13 @@ export class GitVCS {
|
||||
});
|
||||
|
||||
return remoteOriginURI;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Ignore error
|
||||
return this._baseOpts.uri || '';
|
||||
}
|
||||
}
|
||||
|
||||
async initFromClone({ repoId, url, gitCredentials, directory, fs, gitDirectory }: InitFromCloneOptions) {
|
||||
async initFromClone({ repoId, url, gitCredentials, directory, fs, gitDirectory, ref }: InitFromCloneOptions) {
|
||||
this._baseOpts = {
|
||||
...this._baseOpts,
|
||||
...gitCallbacks(gitCredentials),
|
||||
@@ -190,11 +194,14 @@ export class GitVCS {
|
||||
http: httpClient,
|
||||
repoId,
|
||||
};
|
||||
|
||||
const initRef = ref || this._baseOpts.ref;
|
||||
|
||||
try {
|
||||
await git.clone({
|
||||
...this._baseOpts,
|
||||
url,
|
||||
singleBranch: true,
|
||||
...(initRef ? { ref: initRef } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
// If we there is a checkout conflict we only want to clone the repo
|
||||
@@ -202,11 +209,12 @@ export class GitVCS {
|
||||
await git.clone({
|
||||
...this._baseOpts,
|
||||
url,
|
||||
singleBranch: true,
|
||||
...(initRef ? { ref: initRef } : {}),
|
||||
noCheckout: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[git] Cloned repo to ${gitDirectory} from ${url}`);
|
||||
}
|
||||
|
||||
@@ -366,7 +374,7 @@ export class GitVCS {
|
||||
|
||||
try {
|
||||
return Buffer.from(blob).toString('utf-8');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
@@ -974,6 +982,7 @@ export class GitVCS {
|
||||
await git.checkout({
|
||||
...this._baseOpts,
|
||||
ref: branch,
|
||||
|
||||
remote: 'origin',
|
||||
});
|
||||
const branches = await this.listBranches();
|
||||
@@ -986,7 +995,7 @@ export class GitVCS {
|
||||
async repoExists() {
|
||||
try {
|
||||
await git.getConfig({ ...this._baseOpts, path: '' });
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1067,4 +1076,35 @@ function assertIsPromiseFsClient(fs: git.FsClient): asserts fs is git.PromiseFsC
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRemoteBranches({ uri, credentials }: { uri: string; credentials?: GitCredentials | null }) {
|
||||
const [mainRef] = await git.listServerRefs({
|
||||
...gitCallbacks(credentials),
|
||||
http: httpClient,
|
||||
url: uri,
|
||||
prefix: 'HEAD',
|
||||
symrefs: true,
|
||||
});
|
||||
|
||||
const remoteRefs = await git.listServerRefs({
|
||||
...gitCallbacks(credentials),
|
||||
http: httpClient,
|
||||
url: uri,
|
||||
prefix: 'refs/heads/',
|
||||
symrefs: true,
|
||||
});
|
||||
|
||||
const defaultBranch = mainRef?.target?.replace('refs/heads/', '') || 'main';
|
||||
|
||||
const remoteBranches = remoteRefs
|
||||
.filter(b => b.ref !== 'HEAD')
|
||||
.map(b => b.ref.replace('refs/heads/', ''))
|
||||
.sort((a, b) => {
|
||||
if (a === defaultBranch) return -1;
|
||||
if (b === defaultBranch) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return remoteBranches;
|
||||
}
|
||||
|
||||
export default new GitVCS();
|
||||
|
||||
@@ -8,14 +8,16 @@ import { gitCallbacks } from './utils';
|
||||
interface Options {
|
||||
fsClient: git.FsClient;
|
||||
gitRepository: Pick<GitRepository, 'credentials' | 'uri'>;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shallow clone into the provided FS plugin.
|
||||
* */
|
||||
export const shallowClone = async ({ fsClient, gitRepository }: Options) => {
|
||||
export const shallowClone = async ({ fsClient, gitRepository, ref = undefined }: Options) => {
|
||||
await git.clone({
|
||||
...gitCallbacks(gitRepository.credentials),
|
||||
...(ref ? { ref } : {}),
|
||||
fs: fsClient,
|
||||
http: httpClient,
|
||||
dir: GIT_CLONE_DIR,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { docsGitAccessToken } from '../../../common/documentation';
|
||||
import type { GitRepository } from '../../../models/git-repository';
|
||||
import { Link } from '../base/link';
|
||||
import { HelpTooltip } from '../help-tooltip';
|
||||
import { GitRemoteBranchSelect } from './git-remote-branch-select';
|
||||
|
||||
export interface Props {
|
||||
gitRepository?: Partial<GitRepository> | null;
|
||||
@@ -15,13 +16,19 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent<Props> = ({ gi
|
||||
const linkIcon = <i className="fa fa-external-link-square" />;
|
||||
const defaultValues = gitRepository || {
|
||||
uri: '',
|
||||
credentials: { username: '', token: '' },
|
||||
credentials: { username: '', password: '' },
|
||||
author: { name: '', email: '' },
|
||||
};
|
||||
|
||||
const uri = defaultValues.uri;
|
||||
const [credentials, setCredentials] = React.useState({
|
||||
username: defaultValues.credentials?.username || '',
|
||||
password:
|
||||
defaultValues.credentials && 'password' in defaultValues.credentials ? defaultValues.credentials.password : '',
|
||||
});
|
||||
|
||||
const [uri, setUri] = React.useState(defaultValues.uri || '');
|
||||
|
||||
const author = defaultValues.author;
|
||||
const credentials = defaultValues?.credentials || { username: '', token: '' };
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -49,6 +56,7 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent<Props> = ({ gi
|
||||
type="url"
|
||||
autoFocus
|
||||
defaultValue={uri}
|
||||
onChange={e => setUri(e.currentTarget.value)}
|
||||
disabled={Boolean(uri)}
|
||||
placeholder="https://github.com/org/repo.git"
|
||||
className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors placeholder:text-sm placeholder:italic focus:outline-none focus:ring-1 focus:ring-[--hl-md]"
|
||||
@@ -81,6 +89,7 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent<Props> = ({ gi
|
||||
placeholder="MyUser"
|
||||
disabled={Boolean(uri)}
|
||||
defaultValue={credentials?.username}
|
||||
onChange={e => setCredentials({ ...credentials, username: e.currentTarget.value })}
|
||||
className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors placeholder:text-sm placeholder:italic focus:outline-none focus:ring-1 focus:ring-[--hl-md]"
|
||||
/>
|
||||
</TextField>
|
||||
@@ -104,12 +113,21 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent<Props> = ({ gi
|
||||
<Input
|
||||
type="password"
|
||||
disabled={Boolean(uri)}
|
||||
defaultValue={'token' in credentials ? credentials?.token : ''}
|
||||
onChange={e => setCredentials({ ...credentials, password: e.currentTarget.value })}
|
||||
defaultValue={'password' in credentials ? credentials?.password : ''}
|
||||
placeholder="88e7ee63b254e4b0bf047559eafe86ba9dd49507"
|
||||
className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors placeholder:text-sm placeholder:italic focus:outline-none focus:ring-1 focus:ring-[--hl-md]"
|
||||
/>
|
||||
</TextField>
|
||||
</div>
|
||||
<GitRemoteBranchSelect
|
||||
credentials={{
|
||||
password: credentials.password,
|
||||
username: credentials.username,
|
||||
}}
|
||||
url={uri || ''}
|
||||
isDisabled={Boolean(uri)}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useDeferredValue, useEffect } from 'react';
|
||||
import { Button, ComboBox, Input, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components';
|
||||
import { useFetcher, useParams } from 'react-router';
|
||||
|
||||
import type { GitCredentials } from '../../../sync/git/git-vcs';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
export const GitRemoteBranchSelect = ({
|
||||
url,
|
||||
isDisabled,
|
||||
credentials,
|
||||
}: {
|
||||
url: string;
|
||||
isDisabled: boolean;
|
||||
credentials: GitCredentials;
|
||||
}) => {
|
||||
const remoteBranchesFetcher = useFetcher<{ branches: string[] }>({ key: url || 'branch-select' });
|
||||
const { organizationId } = useParams<{ organizationId: string }>();
|
||||
|
||||
const isLoadingRemoteBranches = remoteBranchesFetcher.state !== 'idle';
|
||||
const uri = useDeferredValue(url);
|
||||
|
||||
useEffect(() => {
|
||||
if (uri && remoteBranchesFetcher.state === 'idle' && !remoteBranchesFetcher.data) {
|
||||
remoteBranchesFetcher.submit(
|
||||
// @ts-expect-error credentials is not defined in the type, but it is used here
|
||||
{
|
||||
uri,
|
||||
credentials,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
encType: 'application/json',
|
||||
action: `/organization/${organizationId}/git/remote-branches`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [organizationId, remoteBranchesFetcher, uri, credentials]);
|
||||
|
||||
const remoteBranches = remoteBranchesFetcher.data?.branches || [];
|
||||
|
||||
const isComboboxDisabled = remoteBranches.length === 0 || isLoadingRemoteBranches || !url || isDisabled;
|
||||
|
||||
return (
|
||||
<Label className="flex flex-col">
|
||||
<span className="text-sm font-semibold">Branch</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ComboBox
|
||||
key={`${url}:${remoteBranches[0]}:branch-select`}
|
||||
aria-label="Branch to clone"
|
||||
allowsCustomValue={false}
|
||||
className="w-full"
|
||||
defaultSelectedKey={remoteBranches[0]}
|
||||
isDisabled={isComboboxDisabled}
|
||||
items={remoteBranches.map(branch => ({
|
||||
id: branch,
|
||||
name: branch,
|
||||
}))}
|
||||
>
|
||||
<div className="group flex items-center gap-2 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] transition-colors focus:outline-none focus:ring-1 focus:ring-[--hl-md]">
|
||||
<Input
|
||||
name="branch"
|
||||
aria-label="Search branches"
|
||||
placeholder={isLoadingRemoteBranches ? 'Fetching remote branches...' : 'Default branch'}
|
||||
className="w-full py-1 pl-2 pr-7 placeholder:italic"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="m-2 flex aspect-square items-center justify-center gap-2 truncate rounded-sm !border-none text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
>
|
||||
<Icon icon="caret-down" className="w-5 flex-shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
<Popover
|
||||
className="grid w-[--trigger-width] min-w-max select-none grid-flow-col divide-x divide-solid divide-[--hl-md] overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] text-sm shadow-lg focus:outline-none"
|
||||
placement="bottom start"
|
||||
offset={8}
|
||||
>
|
||||
<ListBox<{
|
||||
id: string;
|
||||
name: string;
|
||||
}> className="flex min-w-max select-none flex-col p-2 text-sm focus:outline-none">
|
||||
{item => (
|
||||
<ListBoxItem
|
||||
textValue={item.name}
|
||||
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap rounded bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-30 aria-selected:bg-[--hl-sm] aria-selected:font-bold data-[focused]:bg-[--hl-xs]"
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</ComboBox>
|
||||
<Button
|
||||
type="button"
|
||||
isDisabled={isComboboxDisabled}
|
||||
className="m-2 flex aspect-square size-[--line-height-xs] items-center justify-center gap-2 truncate rounded-sm border border-solid border-[--hl-sm] p-2 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] disabled:opacity-30 aria-pressed:bg-[--hl-sm]"
|
||||
aria-label="Refresh repositories"
|
||||
onPress={() => {
|
||||
if (uri && remoteBranchesFetcher.state === 'idle') {
|
||||
remoteBranchesFetcher.submit(
|
||||
// @ts-expect-error credentials is not defined in the type, but it is used here
|
||||
{
|
||||
uri,
|
||||
credentials,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
encType: 'application/json',
|
||||
action: `/organization/${organizationId}/git/remote-branches`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="refresh" className={isLoadingRemoteBranches ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</div>
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button as ComboButton, ComboBox, Input, ListBox, ListBoxItem, Popover } from 'react-aria-components';
|
||||
import { Button, ComboBox, Input, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components';
|
||||
|
||||
import { getAppWebsiteBaseURL } from '../../../common/constants';
|
||||
import { isGitHubAppUserToken } from '../github-app-config-link';
|
||||
import { Icon } from '../icon';
|
||||
import { Button } from '../themed-button';
|
||||
import { GitRemoteBranchSelect } from './git-remote-branch-select';
|
||||
|
||||
type GitHubRepository = Awaited<ReturnType<typeof window.main.git.getGitHubRepositories>>['repos'][number];
|
||||
|
||||
@@ -47,94 +47,99 @@ export const GitHubRepositorySelect = ({ uri, token }: { uri?: string; token: st
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="flex gap-1 text-sm font-semibold">Repository</span>
|
||||
{uri && (
|
||||
<div className="form-control form-control--outlined">
|
||||
<input className="form-control" disabled defaultValue={uri} />
|
||||
</div>
|
||||
)}
|
||||
{!uri && (
|
||||
<>
|
||||
<div className="flex flex-row items-center gap-2 py-2">
|
||||
<ComboBox
|
||||
aria-label="Repositories"
|
||||
allowsCustomValue={false}
|
||||
className="w-full"
|
||||
isDisabled={loading}
|
||||
defaultItems={repositories.map(repo => ({
|
||||
id: repo.clone_url,
|
||||
name: repo.full_name,
|
||||
}))}
|
||||
onSelectionChange={key => setSelectedRepository(repositories.find(r => r.clone_url === key) || null)}
|
||||
>
|
||||
<div className="group flex items-center gap-2 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] transition-colors focus:outline-none focus:ring-1 focus:ring-[--hl-md]">
|
||||
<Input
|
||||
aria-label="Repository Search"
|
||||
placeholder={loading ? 'Fetching...' : 'Find a repository...'}
|
||||
className="w-full py-1 pl-2 pr-7 placeholder:italic"
|
||||
/>
|
||||
<ComboButton
|
||||
id="github_repo_select_dropdown_button"
|
||||
type="button"
|
||||
className="m-2 flex aspect-square items-center justify-center gap-2 truncate rounded-sm !border-none text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
>
|
||||
<Icon icon="caret-down" className="w-5 flex-shrink-0" />
|
||||
</ComboButton>
|
||||
</div>
|
||||
<Popover
|
||||
className="grid w-[--trigger-width] min-w-max select-none grid-flow-col divide-x divide-solid divide-[--hl-md] overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] text-sm shadow-lg focus:outline-none"
|
||||
placement="bottom start"
|
||||
offset={8}
|
||||
>
|
||||
<ListBox<{
|
||||
id: string;
|
||||
name: string;
|
||||
}> className="flex min-w-max select-none flex-col p-2 text-sm focus:outline-none">
|
||||
{item => (
|
||||
<ListBoxItem
|
||||
textValue={item.name}
|
||||
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap rounded bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-30 aria-selected:bg-[--hl-sm] aria-selected:font-bold data-[focused]:bg-[--hl-xs]"
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
<input type="hidden" name="uri" value={selectedRepository?.clone_url || uri || ''} />
|
||||
</ComboBox>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
className="m-2 flex aspect-square items-center justify-center gap-2 truncate rounded-sm border border-solid border-[--hl-sm] !p-0 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
aria-label="Refresh repositories"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
getRepositories();
|
||||
}}
|
||||
>
|
||||
<Icon icon="refresh" className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</div>
|
||||
{errors.length > 0 && (
|
||||
<div className="notice error margin-bottom-sm">
|
||||
{errors.map(error => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isGitHubAppUserToken(token) && (
|
||||
<div className={`flex gap-1 text-sm ${loading ? 'opacity-40' : ''}`}>
|
||||
Can't find a repository?
|
||||
<Label className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 text-sm font-semibold">Repository</span>
|
||||
{!uri && isGitHubAppUserToken(token) && (
|
||||
<div className={`flex items-center gap-1 text-sm ${loading ? 'opacity-40' : ''}`}>
|
||||
<Icon icon="info-circle" className="text-[--hl]" />
|
||||
<span>Can't find a repository?</span>
|
||||
<a
|
||||
className="flex items-center gap-1 text-purple-500"
|
||||
className="flex items-center gap-1 text-[--color-surprise]"
|
||||
href={`${getAppWebsiteBaseURL()}/oauth/github-app`}
|
||||
>
|
||||
Configure the App <i className="fa-solid fa-up-right-from-square" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{uri && (
|
||||
<div className="form-control form-control--outlined">
|
||||
<input className="form-control" disabled defaultValue={uri} />
|
||||
</div>
|
||||
)}
|
||||
{!uri && (
|
||||
<>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ComboBox
|
||||
aria-label="Repositories"
|
||||
allowsCustomValue={false}
|
||||
className="w-full"
|
||||
isDisabled={loading}
|
||||
defaultItems={repositories.map(repo => ({
|
||||
id: repo.clone_url,
|
||||
name: repo.full_name,
|
||||
}))}
|
||||
onSelectionChange={key => setSelectedRepository(repositories.find(r => r.clone_url === key) || null)}
|
||||
>
|
||||
<div className="group flex items-center gap-2 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] transition-colors focus:outline-none focus:ring-1 focus:ring-[--hl-md]">
|
||||
<Input
|
||||
aria-label="Repository Search"
|
||||
placeholder={loading ? 'Fetching...' : 'Find a repository...'}
|
||||
className="w-full py-1 pl-2 pr-7 placeholder:italic"
|
||||
/>
|
||||
<Button
|
||||
id="github_repo_select_dropdown_button"
|
||||
type="button"
|
||||
className="m-2 flex aspect-square items-center justify-center gap-2 truncate rounded-sm !border-none text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
>
|
||||
<Icon icon="caret-down" className="w-5 flex-shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
<Popover
|
||||
className="grid w-[--trigger-width] min-w-max select-none grid-flow-col divide-x divide-solid divide-[--hl-md] overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] text-sm shadow-lg focus:outline-none"
|
||||
placement="bottom start"
|
||||
offset={8}
|
||||
>
|
||||
<ListBox<{
|
||||
id: string;
|
||||
name: string;
|
||||
}> className="flex min-w-max select-none flex-col p-2 text-sm focus:outline-none">
|
||||
{item => (
|
||||
<ListBoxItem
|
||||
textValue={item.name}
|
||||
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap rounded bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-30 aria-selected:bg-[--hl-sm] aria-selected:font-bold data-[focused]:bg-[--hl-xs]"
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
<input type="hidden" name="uri" value={selectedRepository?.clone_url || uri || ''} />
|
||||
</ComboBox>
|
||||
<Button
|
||||
type="button"
|
||||
isDisabled={loading}
|
||||
className="m-2 flex aspect-square size-[--line-height-xs] items-center justify-center gap-2 truncate rounded-sm border border-solid border-[--hl-sm] p-2 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
aria-label="Refresh repositories"
|
||||
onPress={() => {
|
||||
setLoading(true);
|
||||
getRepositories();
|
||||
}}
|
||||
>
|
||||
<Icon icon="refresh" className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</div>
|
||||
{errors.length > 0 && (
|
||||
<div className="notice error margin-bottom-sm">
|
||||
{errors.map(error => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
{cannotFindRepository && (
|
||||
<div className="text-sm text-red-500">
|
||||
<Icon icon="warning" /> Repository information could not be retrieved. Please <code>Reset</code> and select a
|
||||
@@ -146,6 +151,18 @@ export const GitHubRepositorySelect = ({ uri, token }: { uri?: string; token: st
|
||||
<Icon icon="warning" /> You do not have write access to this repository
|
||||
</div>
|
||||
)}
|
||||
{!uri && (
|
||||
<GitRemoteBranchSelect
|
||||
credentials={{
|
||||
oauth2format: 'github',
|
||||
token: '',
|
||||
password: '',
|
||||
username: '',
|
||||
}}
|
||||
isDisabled={loading}
|
||||
url={selectedRepository?.clone_url || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ const Avatar = ({ src }: { src: string }) => {
|
||||
|
||||
interface GitHubRepositoryFormProps {
|
||||
uri?: string;
|
||||
onSubmit: (args: Partial<GitRepository>) => void;
|
||||
onSubmit: (args: Partial<GitRepository & { ref?: string }>) => void;
|
||||
credentials: GitCredentials;
|
||||
}
|
||||
|
||||
@@ -77,12 +77,14 @@ const GitHubRepositoryForm = ({ uri, credentials, onSubmit }: GitHubRepositoryFo
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const uri = formData.get('uri') as string;
|
||||
const ref = formData.get('branch') as string;
|
||||
if (!uri) {
|
||||
setError('Please select a repository');
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
uri,
|
||||
ref,
|
||||
credentials: {
|
||||
oauth2format: 'github',
|
||||
password: '',
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { GitCredentials } from '../../../models/git-credentials';
|
||||
import type { GitRepository } from '../../../models/git-repository';
|
||||
import { PromptButton } from '../base/prompt-button';
|
||||
import { Icon } from '../icon';
|
||||
import { GitRemoteBranchSelect } from './git-remote-branch-select';
|
||||
|
||||
interface Props {
|
||||
uri?: string;
|
||||
@@ -67,7 +68,7 @@ interface GitLabRepositoryFormProps {
|
||||
|
||||
const GitLabRepositoryForm = ({ uri, credentials, onSubmit }: GitLabRepositoryFormProps) => {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [gitlabUri, setGitlabUri] = useState(uri || '');
|
||||
const signOutFetcher = useFetcher();
|
||||
|
||||
return (
|
||||
@@ -111,11 +112,22 @@ const GitLabRepositoryForm = ({ uri, credentials, onSubmit }: GitLabRepositoryFo
|
||||
<Input
|
||||
type="url"
|
||||
defaultValue={uri}
|
||||
onChange={e => setGitlabUri(e.currentTarget.value)}
|
||||
disabled={Boolean(uri)}
|
||||
placeholder="https://github.com/org/repo.git"
|
||||
placeholder="https://gitlab.com/org/repo.git"
|
||||
className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors placeholder:text-sm placeholder:italic focus:outline-none focus:ring-1 focus:ring-[--hl-md]"
|
||||
/>
|
||||
</TextField>
|
||||
<GitRemoteBranchSelect
|
||||
credentials={{
|
||||
oauth2format: 'gitlab',
|
||||
token: '',
|
||||
password: '',
|
||||
username: '',
|
||||
}}
|
||||
url={gitlabUri || ''}
|
||||
isDisabled={Boolean(uri)}
|
||||
/>
|
||||
{error && (
|
||||
<p className="notice error margin-bottom-sm">
|
||||
<button className="pull-right icon" onClick={() => setError('')}>
|
||||
|
||||
@@ -97,6 +97,7 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
authorName?: string;
|
||||
authorEmail?: string;
|
||||
uri?: string;
|
||||
ref?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
@@ -144,7 +145,7 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
}
|
||||
}, [storageRules, project]);
|
||||
|
||||
const onGitRepoFormSubmit = (gitRepositoryPatch: Partial<GitRepository>) => {
|
||||
const onGitRepoFormSubmit = (gitRepositoryPatch: Partial<GitRepository & { ref?: string }>) => {
|
||||
const { author, credentials, created, modified, isPrivate, needsFullClone, uriNeedsMigration, ...repoPatch } =
|
||||
gitRepositoryPatch;
|
||||
|
||||
@@ -154,6 +155,7 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
authorName: author?.name || '',
|
||||
authorEmail: author?.email || '',
|
||||
uri: repoPatch.uri,
|
||||
ref: repoPatch.ref,
|
||||
});
|
||||
|
||||
initCloneGitRepositoryFetcher.submit(
|
||||
|
||||
@@ -259,6 +259,11 @@ async function renderApp() {
|
||||
action: async (...args) =>
|
||||
(await import('./routes/$organizationId.git')).initGitCloneAction(...args),
|
||||
},
|
||||
{
|
||||
path: 'remote-branches',
|
||||
action: async (...args) =>
|
||||
(await import('./routes/$organizationId.git')).fetchRemoteBranchesAction(...args),
|
||||
},
|
||||
{
|
||||
path: 'clone',
|
||||
action: async (...args) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ActionFunction, redirect } from 'react-router';
|
||||
|
||||
import type { WorkspaceScope } from '../../models/workspace';
|
||||
import type { GitCredentials } from '../../sync/git/git-vcs';
|
||||
import { invariant } from '../../utils/invariant';
|
||||
|
||||
export type InitGitCloneResult =
|
||||
@@ -78,3 +79,14 @@ export const cloneGitRepoAction: ActionFunction = async ({ request, params }): P
|
||||
|
||||
return redirect(`/organization/${organizationId}/project/${projectId}`);
|
||||
};
|
||||
|
||||
export const fetchRemoteBranchesAction: ActionFunction = async ({ request }) => {
|
||||
const data = (await request.json()) as {
|
||||
uri: string;
|
||||
credentials: GitCredentials;
|
||||
};
|
||||
|
||||
const { uri, credentials } = data;
|
||||
|
||||
return window.main.git.fetchGitRemoteBranches({ uri, credentials });
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ActionFunction, type LoaderFunction } from 'react-router';
|
||||
import { type ActionFunction, type LoaderFunction, redirect } from 'react-router';
|
||||
|
||||
import * as models from '../../models';
|
||||
import type { GitRepository } from '../../models/git-repository';
|
||||
@@ -105,6 +105,84 @@ export const canPushLoader: LoaderFunction = async ({ params }): Promise<GitCanP
|
||||
};
|
||||
|
||||
// Actions
|
||||
export type InitGitCloneResult =
|
||||
| {
|
||||
files: {
|
||||
scope: WorkspaceScope;
|
||||
name: string;
|
||||
path: string;
|
||||
}[];
|
||||
}
|
||||
| {
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export const initGitCloneAction: ActionFunction = async ({ request, params }) => {
|
||||
const { organizationId } = params;
|
||||
invariant(organizationId, 'Organization ID is required');
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const data = Object.fromEntries(formData.entries()) as {
|
||||
authorEmail: string;
|
||||
authorName: string;
|
||||
token: string;
|
||||
uri: string;
|
||||
username: string;
|
||||
oauth2format: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
const initCloneResult = await window.main.git.initGitRepoClone({
|
||||
organizationId,
|
||||
...data,
|
||||
});
|
||||
|
||||
if ('errors' in initCloneResult) {
|
||||
return { errors: initCloneResult.errors };
|
||||
}
|
||||
|
||||
return {
|
||||
files: initCloneResult.files,
|
||||
};
|
||||
};
|
||||
|
||||
type CloneGitActionResult =
|
||||
| Response
|
||||
| {
|
||||
errors?: string[];
|
||||
};
|
||||
export const cloneGitRepoAction: ActionFunction = async ({ request, params }): Promise<CloneGitActionResult> => {
|
||||
const { organizationId } = params;
|
||||
invariant(organizationId, 'Organization ID is required');
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const data = Object.fromEntries(formData.entries()) as {
|
||||
authorEmail: string;
|
||||
authorName: string;
|
||||
token: string;
|
||||
uri: string;
|
||||
username: string;
|
||||
oauth2format: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
const { errors, projectId } = await window.main.git.cloneGitRepo({
|
||||
organizationId,
|
||||
...data,
|
||||
});
|
||||
|
||||
if (errors) {
|
||||
return { errors };
|
||||
}
|
||||
|
||||
invariant(organizationId, 'Organization ID is required');
|
||||
invariant(projectId, 'Project ID is required');
|
||||
|
||||
return redirect(`/organization/${organizationId}/project/${projectId}`);
|
||||
};
|
||||
|
||||
export const updateGitRepoAction: ActionFunction = async ({ request, params }) => {
|
||||
const { projectId } = params;
|
||||
invariant(projectId, 'Project ID is required');
|
||||
@@ -117,6 +195,7 @@ export const updateGitRepoAction: ActionFunction = async ({ request, params }) =
|
||||
uri: string;
|
||||
username: string;
|
||||
oauth2format: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
return window.main.git.updateGitRepo({
|
||||
|
||||
@@ -187,6 +187,7 @@ export const updateGitRepoAction: ActionFunction = async ({ request, params }) =
|
||||
uri: string;
|
||||
username: string;
|
||||
oauth2format: string;
|
||||
ref?: string; // Optional ref for shallow clone
|
||||
};
|
||||
|
||||
return window.main.git.updateGitRepo({
|
||||
|
||||
Reference in New Issue
Block a user