459 lines
13 KiB

import type { QuickPickItem } from 'vscode';
import { QuickInputButtons } from 'vscode';
import type { Container } from '../../container';
import { reveal } from '../../git/actions/remote';
import type { GitRemote } from '../../git/models/remote';
import { Repository } from '../../git/models/repository';
import { Logger } from '../../logger';
import { showGenericErrorMessage } from '../../messages';
import type { QuickPickItemOfT } from '../../quickpicks/items/common';
import { FlagsQuickPickItem } from '../../quickpicks/items/flags';
import type { ViewsWithRepositoryFolders } from '../../views/viewBase';
import type {
AsyncStepResultGenerator,
PartialStepState,
QuickPickStep,
StepGenerator,
StepResultGenerator,
StepSelection,
StepState,
} from '../quickCommand';
import {
appendReposToTitle,
inputRemoteNameStep,
inputRemoteUrlStep,
pickRemoteStep,
pickRepositoryStep,
QuickCommand,
StepResult,
} from '../quickCommand';
interface Context {
repos: Repository[];
associatedView: ViewsWithRepositoryFolders;
title: string;
}
type AddFlags = '-f';
interface AddState {
subcommand: 'add';
repo: string | Repository;
name: string;
url: string;
flags: AddFlags[];
reveal?: boolean;
}
interface RemoveState {
subcommand: 'remove';
repo: string | Repository;
remote: string | GitRemote;
}
interface PruneState {
subcommand: 'prune';
repo: string | Repository;
remote: string | GitRemote;
}
type State = AddState | RemoveState | PruneState;
type RemoteStepState<T extends State> = SomeNonNullable<StepState<T>, 'subcommand'>;
type AddStepState<T extends AddState = AddState> = RemoteStepState<ExcludeSome<T, 'repo', string>>;
function assertStateStepAdd(state: PartialStepState<State>): asserts state is AddStepState {
if (state.repo instanceof Repository && state.subcommand === 'add') return;
debugger;
throw new Error('Missing repository');
}
type RemoveStepState<T extends RemoveState = RemoveState> = RemoteStepState<ExcludeSome<T, 'repo', string>>;
function assertStateStepRemove(state: PartialStepState<State>): asserts state is RemoveStepState {
if (state.repo instanceof Repository && state.subcommand === 'remove') return;
debugger;
throw new Error('Missing repository');
}
type PruneStepState<T extends PruneState = PruneState> = RemoteStepState<ExcludeSome<T, 'repo', string>>;
function assertStateStepPrune(state: PartialStepState<State>): asserts state is PruneStepState {
if (state.repo instanceof Repository && state.subcommand === 'prune') return;
debugger;
throw new Error('Missing repository');
}
function assertStateStepRemoveRemotes(
state: RemoveStepState,
): asserts state is ExcludeSome<typeof state, 'remote', string> {
if (typeof state.remote !== 'string') return;
debugger;
throw new Error('Missing remote');
}
function assertStateStepPruneRemotes(
state: PruneStepState,
): asserts state is ExcludeSome<typeof state, 'remote', string> {
if (typeof state.remote !== 'string') return;
debugger;
throw new Error('Missing remote');
}
const subcommandToTitleMap = new Map<State['subcommand'], string>([
['add', 'Add'],
['prune', 'Prune'],
['remove', 'Remove'],
]);
function getTitle(title: string, subcommand: State['subcommand'] | undefined) {
return subcommand == null ? title : `${subcommandToTitleMap.get(subcommand)} ${title}`;
}
export interface RemoteGitCommandArgs {
readonly command: 'remote';
confirm?: boolean;
state?: Partial<State>;
}
export class RemoteGitCommand extends QuickCommand<State> {
private subcommand: State['subcommand'] | undefined;
constructor(container: Container, args?: RemoteGitCommandArgs) {
super(container, 'remote', 'remote', 'Remote', {
description: 'add, prune, or remove remotes',
});
let counter = 0;
if (args?.state?.subcommand != null) {
counter++;
switch (args?.state.subcommand) {
case 'add':
if (args.state.name != null) {
counter++;
}
if (args.state.url != null) {
counter++;
}
break;
case 'prune':
case 'remove':
if (args.state.remote != null) {
counter++;
}
break;
}
}
if (args?.state?.repo != null) {
counter++;
}
this.initialState = {
counter: counter,
confirm: args?.confirm,
...args?.state,
};
}
override get canConfirm(): boolean {
return this.subcommand != null;
}
override get canSkipConfirm(): boolean {
return this.subcommand === 'remove' || this.subcommand === 'prune' ? false : super.canSkipConfirm;
}
override get skipConfirmKey() {
return `${this.key}${this.subcommand == null ? '' : `-${this.subcommand}`}:${this.pickedVia}`;
}
protected async *steps(state: PartialStepState<State>): StepGenerator {
const context: Context = {
repos: this.container.git.openRepositories,
associatedView: this.container.remotesView,
title: this.title,
};
let skippedStepTwo = false;
while (this.canStepsContinue(state)) {
context.title = this.title;
if (state.counter < 1 || state.subcommand == null) {
this.subcommand = undefined;
const result = yield* this.pickSubcommandStep(state);
// Always break on the first step (so we will go back)
if (result === StepResult.Break) break;
state.subcommand = result;
}
this.subcommand = state.subcommand;
context.title = getTitle(this.title, state.subcommand);
if (state.counter < 2 || state.repo == null || typeof state.repo === 'string') {
skippedStepTwo = false;
if (context.repos.length === 1) {
skippedStepTwo = true;
state.counter++;
state.repo = context.repos[0];
} else {
const result = yield* pickRepositoryStep(state, context);
if (result === StepResult.Break) continue;
state.repo = result;
}
}
switch (state.subcommand) {
case 'add':
assertStateStepAdd(state);
yield* this.addCommandSteps(state, context);
// Clear any chosen name, since we are exiting this subcommand
state.name = undefined!;
state.url = undefined!;
break;
case 'prune':
assertStateStepPrune(state);
yield* this.pruneCommandSteps(state, context);
break;
case 'remove':
assertStateStepRemove(state);
yield* this.removeCommandSteps(state, context);
break;
default:
QuickCommand.endSteps(state);
break;
}
// If we skipped the previous step, make sure we back up past it
if (skippedStepTwo) {
state.counter--;
}
}
return state.counter < 0 ? StepResult.Break : undefined;
}
private *pickSubcommandStep(state: PartialStepState<State>): StepResultGenerator<State['subcommand']> {
const step = QuickCommand.createPickStep<QuickPickItemOfT<State['subcommand']>>({
title: this.title,
placeholder: `Choose a ${this.label} command`,
items: [
{
label: 'add',
description: 'adds a new remote',
picked: state.subcommand === 'add',
item: 'add',
},
{
label: 'prune',
description: 'prunes remote branches on the specified remote',
picked: state.subcommand === 'prune',
item: 'prune',
},
{
label: 'remove',
description: 'removes the specified remote',
picked: state.subcommand === 'remove',
item: 'remove',
},
],
buttons: [QuickInputButtons.Back],
});
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
}
private async *addCommandSteps(state: AddStepState, context: Context): AsyncStepResultGenerator<void> {
if (state.flags == null) {
state.flags = ['-f'];
}
let alreadyExists = (await state.repo.getRemotes({ filter: r => r.name === state.name })).length !== 0;
while (this.canStepsContinue(state)) {
if (state.counter < 3 || state.name == null || alreadyExists) {
const result = yield* inputRemoteNameStep(state, context, {
placeholder: 'Please provide a name for the remote',
value: state.name,
});
if (result === StepResult.Break) continue;
alreadyExists = (await state.repo.getRemotes({ filter: r => r.name === result })).length !== 0;
if (alreadyExists) {
state.counter--;
continue;
}
state.name = result;
}
if (state.counter < 4 || state.url == null) {
const result = yield* inputRemoteUrlStep(state, context, {
placeholder: 'Please provide a URL for the remote',
value: state.url,
});
if (result === StepResult.Break) continue;
state.url = result;
}
if (this.confirm(state.confirm)) {
const result = yield* this.addCommandConfirmStep(state, context);
if (result === StepResult.Break) continue;
state.flags = result;
}
QuickCommand.endSteps(state);
await state.repo.addRemote(state.name, state.url, state.flags.includes('-f') ? { fetch: true } : undefined);
if (state.reveal !== false) {
void reveal(undefined, {
focus: true,
select: true,
});
}
}
}
private *addCommandConfirmStep(state: AddStepState<AddState>, context: Context): StepResultGenerator<AddFlags[]> {
const step: QuickPickStep<FlagsQuickPickItem<AddFlags>> = QuickCommand.createConfirmStep(
appendReposToTitle(`Confirm ${context.title}`, state, context),
[
FlagsQuickPickItem.create<AddFlags>(state.flags, [], {
label: context.title,
detail: `Will add remote '${state.name}' for ${state.url}`,
}),
FlagsQuickPickItem.create<AddFlags>(state.flags, ['-f'], {
label: `${context.title} and Fetch`,
description: '-f',
detail: `Will add and fetch remote '${state.name}' for ${state.url}`,
}),
],
context,
);
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
}
private async *removeCommandSteps(state: RemoveStepState, context: Context): AsyncStepResultGenerator<void> {
while (this.canStepsContinue(state)) {
if (state.remote != null) {
if (typeof state.remote === 'string') {
const [remote] = await state.repo.getRemotes({ filter: r => r.name === state.remote });
if (remote != null) {
state.remote = remote;
} else {
state.remote = undefined!;
}
}
}
if (state.counter < 3 || state.remote == null) {
context.title = getTitle('Remotes', state.subcommand);
const result = yield* pickRemoteStep(state, context, {
picked: state.remote?.name,
placeholder: 'Choose remote to remove',
});
// Always break on the first step (so we will go back)
if (result === StepResult.Break) break;
state.remote = result;
}
assertStateStepRemoveRemotes(state);
const result = yield* this.removeCommandConfirmStep(state, context);
if (result === StepResult.Break) continue;
QuickCommand.endSteps(state);
try {
await state.repo.removeRemote(state.remote.name);
} catch (ex) {
Logger.error(ex);
void showGenericErrorMessage('Unable to remove remote');
}
}
}
private *removeCommandConfirmStep(
state: RemoveStepState<ExcludeSome<RemoveState, 'remote', string>>,
context: Context,
): StepResultGenerator<void> {
const step: QuickPickStep<QuickPickItem> = QuickCommand.createConfirmStep(
appendReposToTitle(`Confirm ${context.title}`, state, context),
[
{
label: context.title,
detail: `Will remove remote '${state.remote.name}'`,
},
],
context,
);
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? undefined : StepResult.Break;
}
private async *pruneCommandSteps(state: PruneStepState, context: Context): AsyncStepResultGenerator<void> {
while (this.canStepsContinue(state)) {
if (state.remote != null) {
if (typeof state.remote === 'string') {
const [remote] = await state.repo.getRemotes({ filter: r => r.name === state.remote });
if (remote != null) {
state.remote = remote;
} else {
state.remote = undefined!;
}
}
}
if (state.counter < 3 || state.remote == null) {
const result = yield* pickRemoteStep(state, context, {
picked: state.remote?.name,
placeholder: 'Choose a remote to prune',
});
// Always break on the first step (so we will go back)
if (result === StepResult.Break) break;
state.remote = result;
}
assertStateStepPruneRemotes(state);
const result = yield* this.pruneCommandConfirmStep(state, context);
if (result === StepResult.Break) continue;
QuickCommand.endSteps(state);
void state.repo.pruneRemote(state.remote.name);
}
}
private *pruneCommandConfirmStep(
state: PruneStepState<ExcludeSome<PruneState, 'remote', string>>,
context: Context,
): StepResultGenerator<void> {
const step: QuickPickStep<QuickPickItem> = QuickCommand.createConfirmStep(
appendReposToTitle(`Confirm ${context.title}`, state, context),
[
{
label: context.title,
detail: `Will prune remote '${state.remote.name}'`,
},
],
context,
);
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? undefined : StepResult.Break;
}
}