@ -0,0 +1,3 @@ | |||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |||||
<path fill="#fff" d="M1.5 14h11l.48-.37 2.63-7-.48-.63H14V3.5l-.5-.5H7.71l-.86-.85L6.5 2h-5l-.5.5v11l.5.5zM2 3h4.29l.86.85.35.15H13v2H8.5l-.35.15-.86.85H3.5l-.47.34-1 3.08L2 3zm10.13 10H2.19l1.67-5H7.5l.35-.15.86-.85h5.79l-2.37 6z"/> | |||||
</svg> |
@ -0,0 +1,640 @@ | |||||
import { MessageItem, QuickInputButtons, Uri, window } from 'vscode'; | |||||
import { configuration } from '../../configuration'; | |||||
import { Container } from '../../container'; | |||||
import { | |||||
WorktreeCreateError, | |||||
WorktreeCreateErrorReason, | |||||
WorktreeDeleteError, | |||||
WorktreeDeleteErrorReason, | |||||
} from '../../git/errors'; | |||||
import { PremiumFeatures } from '../../git/gitProvider'; | |||||
import { GitReference, GitWorktree, Repository } from '../../git/models'; | |||||
import { Messages } from '../../messages'; | |||||
import { QuickPickItemOfT } from '../../quickpicks/items/common'; | |||||
import { Directive } from '../../quickpicks/items/directive'; | |||||
import { FlagsQuickPickItem } from '../../quickpicks/items/flags'; | |||||
import { pad, pluralize } from '../../system/string'; | |||||
import { OpenWorkspaceLocation } from '../../system/utils'; | |||||
import { ViewsWithRepositoryFolders } from '../../views/viewBase'; | |||||
import { GitActions } from '../gitCommands.actions'; | |||||
import { | |||||
appendReposToTitle, | |||||
AsyncStepResultGenerator, | |||||
CustomStep, | |||||
ensureAccessStep, | |||||
inputBranchNameStep, | |||||
PartialStepState, | |||||
pickBranchOrTagStep, | |||||
pickRepositoryStep, | |||||
pickWorktreesStep, | |||||
pickWorktreeStep, | |||||
QuickCommand, | |||||
QuickPickStep, | |||||
StepGenerator, | |||||
StepResult, | |||||
StepResultGenerator, | |||||
StepSelection, | |||||
StepState, | |||||
} from '../quickCommand'; | |||||
interface Context { | |||||
repos: Repository[]; | |||||
associatedView: ViewsWithRepositoryFolders; | |||||
defaultUri?: Uri; | |||||
showTags: boolean; | |||||
title: string; | |||||
worktrees?: GitWorktree[]; | |||||
} | |||||
type CreateFlags = '--force' | '-b' | '--detach'; | |||||
interface CreateState { | |||||
subcommand: 'create'; | |||||
repo: string | Repository; | |||||
uri: Uri; | |||||
reference?: GitReference; | |||||
createBranch: string; | |||||
flags: CreateFlags[]; | |||||
} | |||||
type DeleteFlags = '--force'; | |||||
interface DeleteState { | |||||
subcommand: 'delete'; | |||||
repo: string | Repository; | |||||
uris: Uri[]; | |||||
flags: DeleteFlags[]; | |||||
} | |||||
type OpenFlags = '--new-window'; | |||||
interface OpenState { | |||||
subcommand: 'open'; | |||||
repo: string | Repository; | |||||
uri: Uri; | |||||
flags: OpenFlags[]; | |||||
} | |||||
type State = CreateState | DeleteState | OpenState; | |||||
type WorktreeStepState<T extends State> = SomeNonNullable<StepState<T>, 'subcommand'>; | |||||
type CreateStepState<T extends CreateState = CreateState> = WorktreeStepState<ExcludeSome<T, 'repo', string>>; | |||||
type DeleteStepState<T extends DeleteState = DeleteState> = WorktreeStepState<ExcludeSome<T, 'repo', string>>; | |||||
type OpenStepState<T extends OpenState = OpenState> = WorktreeStepState<ExcludeSome<T, 'repo', string>>; | |||||
const subcommandToTitleMap = new Map<State['subcommand'], string>([ | |||||
['create', 'Create'], | |||||
['delete', 'Delete'], | |||||
['open', 'Open'], | |||||
]); | |||||
function getTitle(title: string, subcommand: State['subcommand'] | undefined) { | |||||
return subcommand == null ? title : `${subcommandToTitleMap.get(subcommand)} ${title}`; | |||||
} | |||||
export interface WorktreeGitCommandArgs { | |||||
readonly command: 'worktree'; | |||||
confirm?: boolean; | |||||
state?: Partial<State>; | |||||
} | |||||
export class WorktreeGitCommand extends QuickCommand<State> { | |||||
private subcommand: State['subcommand'] | undefined; | |||||
private overrideCanConfirm: boolean | undefined; | |||||
constructor(container: Container, args?: WorktreeGitCommandArgs) { | |||||
super(container, 'worktree', 'worktree', 'Worktree', { | |||||
description: 'open, create, or delete worktrees', | |||||
}); | |||||
let counter = 0; | |||||
if (args?.state?.subcommand != null) { | |||||
counter++; | |||||
switch (args.state.subcommand) { | |||||
case 'create': | |||||
if (args.state.uri != null) { | |||||
counter++; | |||||
} | |||||
if (args.state.reference != null) { | |||||
counter++; | |||||
} | |||||
break; | |||||
case 'delete': | |||||
if (args.state.uris != null && (!Array.isArray(args.state.uris) || args.state.uris.length !== 0)) { | |||||
counter++; | |||||
} | |||||
break; | |||||
case 'open': | |||||
if (args.state.uri != null) { | |||||
counter++; | |||||
} | |||||
break; | |||||
} | |||||
} | |||||
if (args?.state?.repo != null) { | |||||
counter++; | |||||
} | |||||
this.initialState = { | |||||
counter: counter, | |||||
confirm: args?.confirm, | |||||
...args?.state, | |||||
}; | |||||
} | |||||
override get canConfirm(): boolean { | |||||
return this.overrideCanConfirm != null ? this.overrideCanConfirm : this.subcommand != null; | |||||
} | |||||
override get canSkipConfirm(): boolean { | |||||
return this.subcommand === 'delete' ? 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: Container.instance.git.openRepositories, | |||||
associatedView: Container.instance.worktreesView, | |||||
showTags: false, | |||||
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; | |||||
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; | |||||
} | |||||
} | |||||
const result = yield* ensureAccessStep(state as any, context, PremiumFeatures.Worktrees); | |||||
if (result === StepResult.Break) break; | |||||
context.title = getTitle(state.subcommand === 'delete' ? 'Worktrees' : this.title, state.subcommand); | |||||
switch (state.subcommand) { | |||||
case 'create': { | |||||
yield* this.createCommandSteps(state as CreateStepState, context); | |||||
// Clear any chosen path, since we are exiting this subcommand | |||||
state.uri = undefined; | |||||
break; | |||||
} | |||||
case 'delete': { | |||||
if (state.uris != null && !Array.isArray(state.uris)) { | |||||
state.uris = [state.uris]; | |||||
} | |||||
yield* this.deleteCommandSteps(state as DeleteStepState, context); | |||||
break; | |||||
} | |||||
case 'open': { | |||||
yield* this.openCommandSteps(state as OpenStepState, 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: 'open', | |||||
description: 'opens the specified worktree', | |||||
picked: state.subcommand === 'open', | |||||
item: 'open', | |||||
}, | |||||
{ | |||||
label: 'create', | |||||
description: 'creates a new worktree', | |||||
picked: state.subcommand === 'create', | |||||
item: 'create', | |||||
}, | |||||
{ | |||||
label: 'delete', | |||||
description: 'deletes the specified worktrees', | |||||
picked: state.subcommand === 'delete', | |||||
item: 'delete', | |||||
}, | |||||
], | |||||
buttons: [QuickInputButtons.Back], | |||||
}); | |||||
const selection: StepSelection<typeof step> = yield step; | |||||
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break; | |||||
} | |||||
private async *createCommandSteps(state: CreateStepState, context: Context): AsyncStepResultGenerator<void> { | |||||
if (context.defaultUri == null) { | |||||
context.defaultUri = await state.repo.getWorktreesDefaultUri(); | |||||
} | |||||
if (state.flags == null) { | |||||
state.flags = []; | |||||
} | |||||
while (this.canStepsContinue(state)) { | |||||
this.overrideCanConfirm = undefined; | |||||
if (state.counter < 3 || state.reference == null) { | |||||
const result = yield* pickBranchOrTagStep(state, context, { | |||||
placeholder: context => | |||||
`Choose a branch${context.showTags ? ' or tag' : ''} to create the new worktree for`, | |||||
picked: state.reference?.ref ?? (await state.repo.getBranch())?.ref, | |||||
titleContext: ' for', | |||||
value: GitReference.isRevision(state.reference) ? state.reference.ref : undefined, | |||||
}); | |||||
// Always break on the first step (so we will go back) | |||||
if (result === StepResult.Break) break; | |||||
state.reference = result; | |||||
} | |||||
if (state.counter < 4 || state.uri == null) { | |||||
if ( | |||||
state.reference != null && | |||||
!configuration.get('worktrees.promptForLocation', state.repo.folder) && | |||||
context.defaultUri != null | |||||
) { | |||||
state.uri = Uri.joinPath(context.defaultUri, state.reference.name); | |||||
} else { | |||||
const result = yield* this.createCommandChoosePathStep(state, context, { | |||||
titleContext: ` for ${GitReference.toString(state.reference, { | |||||
capitalize: true, | |||||
icon: false, | |||||
label: state.reference.refType !== 'branch', | |||||
})}`, | |||||
}); | |||||
if (result === StepResult.Break) continue; | |||||
state.uri = result; | |||||
} | |||||
} | |||||
// Clear the flags, since we can backup after the confirm step below (which is non-standard) | |||||
state.flags = []; | |||||
if (this.confirm(state.confirm)) { | |||||
const result = yield* this.createCommandConfirmStep(state, context); | |||||
if (result === StepResult.Break) continue; | |||||
state.flags = result; | |||||
} | |||||
if (state.flags.includes('-b') && state.createBranch == null) { | |||||
this.overrideCanConfirm = false; | |||||
const result = yield* inputBranchNameStep(state, context, { | |||||
placeholder: 'Please provide a name for the new branch', | |||||
titleContext: ` from ${GitReference.toString(state.reference, { | |||||
capitalize: true, | |||||
icon: false, | |||||
label: state.reference.refType !== 'branch', | |||||
})}`, | |||||
value: state.createBranch ?? GitReference.getNameWithoutRemote(state.reference), | |||||
}); | |||||
if (result === StepResult.Break) continue; | |||||
state.createBranch = result; | |||||
} | |||||
QuickCommand.endSteps(state); | |||||
let retry = false; | |||||
do { | |||||
retry = false; | |||||
const force = state.flags.includes('--force'); | |||||
const friendlyPath = GitWorktree.getFriendlyPath(state.uri); | |||||
try { | |||||
await state.repo.createWorktree(state.uri, { | |||||
commitish: state.reference?.name, | |||||
createBranch: state.flags.includes('-b') ? state.createBranch : undefined, | |||||
detach: state.flags.includes('--detach'), | |||||
force: force, | |||||
}); | |||||
} catch (ex) { | |||||
if ( | |||||
!force && | |||||
ex instanceof WorktreeCreateError && | |||||
ex.reason === WorktreeCreateErrorReason.AlreadyCheckedOut | |||||
) { | |||||
const confirm: MessageItem = { title: 'Create Anyway' }; | |||||
const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; | |||||
const result = await window.showWarningMessage( | |||||
`Unable to create a new worktree in '${friendlyPath}' because ${GitReference.toString( | |||||
state.reference, | |||||
{ icon: false, quoted: true }, | |||||
)} is already checked out.\n\nWould you like to create this worktree anyway?`, | |||||
{ modal: true }, | |||||
confirm, | |||||
cancel, | |||||
); | |||||
if (result === confirm) { | |||||
state.flags.push('--force'); | |||||
retry = true; | |||||
} | |||||
} else if ( | |||||
ex instanceof WorktreeCreateError && | |||||
ex.reason === WorktreeCreateErrorReason.AlreadyExists | |||||
) { | |||||
void Messages.showGenericErrorMessage( | |||||
`Unable to create a new worktree in '${friendlyPath} because that folder already exists.`, | |||||
); | |||||
} else { | |||||
void Messages.showGenericErrorMessage(`Unable to create a new worktree in '${friendlyPath}.`); | |||||
} | |||||
} | |||||
} while (retry); | |||||
} | |||||
} | |||||
private async *createCommandChoosePathStep( | |||||
state: CreateStepState, | |||||
context: Context, | |||||
options?: { titleContext?: string }, | |||||
): AsyncStepResultGenerator<Uri> { | |||||
const step = QuickCommand.createCustomStep<Uri>({ | |||||
show: async (_step: CustomStep<Uri>) => { | |||||
const uris = await window.showOpenDialog({ | |||||
canSelectFiles: false, | |||||
canSelectFolders: true, | |||||
canSelectMany: false, | |||||
defaultUri: state.uri ?? context.defaultUri, | |||||
openLabel: 'Select Worktree Location', | |||||
title: appendReposToTitle(`${context.title}${options?.titleContext ?? ''}`, state, context), | |||||
}); | |||||
if (uris == null || uris.length === 0) return Directive.Back; | |||||
return uris[0]; | |||||
}, | |||||
}); | |||||
const value: StepSelection<typeof step> = yield step; | |||||
if ( | |||||
!QuickCommand.canStepContinue(step, state, value) || | |||||
!(await QuickCommand.canInputStepContinue(step, state, value)) | |||||
) { | |||||
return StepResult.Break; | |||||
} | |||||
return value; | |||||
} | |||||
private *createCommandConfirmStep(state: CreateStepState, context: Context): StepResultGenerator<CreateFlags[]> { | |||||
const friendlyPath = GitWorktree.getFriendlyPath(state.uri); | |||||
const step: QuickPickStep<FlagsQuickPickItem<CreateFlags>> = QuickCommand.createConfirmStep( | |||||
appendReposToTitle(`Confirm ${context.title}`, state, context), | |||||
[ | |||||
FlagsQuickPickItem.create<CreateFlags>(state.flags, [], { | |||||
label: context.title, | |||||
detail: `Will create a new worktree for ${GitReference.toString(state.reference)} in${pad( | |||||
'$(folder)', | |||||
2, | |||||
2, | |||||
)}${friendlyPath}`, | |||||
}), | |||||
FlagsQuickPickItem.create<CreateFlags>(state.flags, ['-b'], { | |||||
label: 'Create Branch and Worktree', //context.title, | |||||
description: `-b`, | |||||
detail: `Will create a new branch and worktree for ${GitReference.toString( | |||||
state.reference, | |||||
)} in${pad('$(folder)', 2, 2)}${friendlyPath}`, | |||||
}), | |||||
FlagsQuickPickItem.create<CreateFlags>(state.flags, ['--force'], { | |||||
label: `Force ${context.title}`, | |||||
description: `--force`, | |||||
detail: `Will forcibly create a new worktree for ${GitReference.toString(state.reference)} in${pad( | |||||
'$(folder)', | |||||
2, | |||||
2, | |||||
)}${friendlyPath}`, | |||||
}), | |||||
], | |||||
context, | |||||
); | |||||
const selection: StepSelection<typeof step> = yield step; | |||||
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break; | |||||
} | |||||
private async *deleteCommandSteps(state: DeleteStepState, context: Context): StepGenerator { | |||||
context.worktrees = await state.repo.getWorktrees(); | |||||
if (state.flags == null) { | |||||
state.flags = []; | |||||
} | |||||
while (this.canStepsContinue(state)) { | |||||
if (state.counter < 3 || state.uris == null || state.uris.length === 0) { | |||||
context.title = getTitle('Worktrees', state.subcommand); | |||||
const result = yield* pickWorktreesStep(state, context, { | |||||
filter: wt => !wt.opened, // Can't delete an open worktree | |||||
includeStatus: true, | |||||
picked: state.uris?.map(uri => uri.toString()), | |||||
placeholder: 'Choose worktrees to delete', | |||||
}); | |||||
// Always break on the first step (so we will go back) | |||||
if (result === StepResult.Break) break; | |||||
state.uris = result.map(w => w.uri); | |||||
} | |||||
context.title = getTitle(pluralize('Worktree', state.uris.length, { only: true }), state.subcommand); | |||||
const result = yield* this.deleteCommandConfirmStep(state, context); | |||||
if (result === StepResult.Break) continue; | |||||
state.flags = result; | |||||
QuickCommand.endSteps(state); | |||||
for (const uri of state.uris) { | |||||
let retry = false; | |||||
do { | |||||
retry = false; | |||||
const force = state.flags.includes('--force'); | |||||
try { | |||||
if (force) { | |||||
const worktree = context.worktrees.find(wt => wt.uri.toString() === uri.toString()); | |||||
const status = await worktree?.getStatus(); | |||||
if (status?.hasChanges ?? false) { | |||||
const confirm: MessageItem = { title: 'Force Delete' }; | |||||
const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; | |||||
const result = await window.showWarningMessage( | |||||
`The worktree in '${uri.fsPath}' has uncommitted changes.\n\nDeleting it will cause those changes to be FOREVER LOST.\nThis is IRREVERSIBLE!\n\nAre you sure you still want to delete it?`, | |||||
{ modal: true }, | |||||
confirm, | |||||
cancel, | |||||
); | |||||
if (result !== confirm) return; | |||||
} | |||||
} | |||||
await state.repo.deleteWorktree(uri, { force: force }); | |||||
} catch (ex) { | |||||
if (!force && ex instanceof WorktreeDeleteError) { | |||||
const confirm: MessageItem = { title: 'Force Delete' }; | |||||
const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; | |||||
const result = await window.showErrorMessage( | |||||
ex.reason === WorktreeDeleteErrorReason.HasChanges | |||||
? `Unable to delete worktree because there are UNCOMMITTED changes in '${uri.fsPath}'.\n\nForcibly deleting it will cause those changes to be FOREVER LOST.\nThis is IRREVERSIBLE!\n\nWould you like to forcibly delete it?` | |||||
: `Unable to delete worktree in '${uri.fsPath}'.\n\nWould you like to try to forcibly delete it?`, | |||||
{ modal: true }, | |||||
confirm, | |||||
cancel, | |||||
); | |||||
if (result === confirm) { | |||||
state.flags.push('--force'); | |||||
retry = true; | |||||
} | |||||
} else { | |||||
void Messages.showGenericErrorMessage(`Unable to delete worktree in '${uri.fsPath}.`); | |||||
} | |||||
} | |||||
} while (retry); | |||||
} | |||||
} | |||||
} | |||||
private *deleteCommandConfirmStep(state: DeleteStepState, context: Context): StepResultGenerator<DeleteFlags[]> { | |||||
const step: QuickPickStep<FlagsQuickPickItem<DeleteFlags>> = QuickCommand.createConfirmStep( | |||||
appendReposToTitle(`Confirm ${context.title}`, state, context), | |||||
[ | |||||
FlagsQuickPickItem.create<DeleteFlags>(state.flags, [], { | |||||
label: context.title, | |||||
detail: `Will delete ${pluralize('worktree', state.uris.length, { | |||||
only: state.uris.length === 1, | |||||
})}${ | |||||
state.uris.length === 1 | |||||
? ` in${pad('$(folder)', 2, 2)}${GitWorktree.getFriendlyPath(state.uris[0])}` | |||||
: '' | |||||
}`, | |||||
}), | |||||
FlagsQuickPickItem.create<DeleteFlags>(state.flags, ['--force'], { | |||||
label: `Force ${context.title}`, | |||||
detail: `Will forcibly delete ${pluralize('worktree', state.uris.length, { | |||||
only: state.uris.length === 1, | |||||
})} even with UNCOMMITTED changes${ | |||||
state.uris.length === 1 | |||||
? ` in${pad('$(folder)', 2, 2)}${GitWorktree.getFriendlyPath(state.uris[0])}` | |||||
: '' | |||||
}`, | |||||
}), | |||||
], | |||||
context, | |||||
); | |||||
const selection: StepSelection<typeof step> = yield step; | |||||
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break; | |||||
} | |||||
private async *openCommandSteps(state: OpenStepState, context: Context): StepGenerator { | |||||
context.worktrees = await state.repo.getWorktrees(); | |||||
if (state.flags == null) { | |||||
state.flags = []; | |||||
} | |||||
while (this.canStepsContinue(state)) { | |||||
if (state.counter < 3 || state.uri == null) { | |||||
context.title = getTitle('Worktree', state.subcommand); | |||||
const result = yield* pickWorktreeStep(state, context, { | |||||
includeStatus: true, | |||||
picked: state.uri?.toString(), | |||||
placeholder: 'Choose worktree to open', | |||||
}); | |||||
// Always break on the first step (so we will go back) | |||||
if (result === StepResult.Break) break; | |||||
state.uri = result.uri; | |||||
} | |||||
context.title = getTitle('Worktree', state.subcommand); | |||||
const result = yield* this.openCommandConfirmStep(state, context); | |||||
if (result === StepResult.Break) continue; | |||||
state.flags = result; | |||||
QuickCommand.endSteps(state); | |||||
const worktree = context.worktrees.find(wt => wt.uri.toString() === state.uri.toString())!; | |||||
GitActions.Worktree.open(worktree, { | |||||
location: state.flags.includes('--new-window') | |||||
? OpenWorkspaceLocation.NewWindow | |||||
: OpenWorkspaceLocation.CurrentWindow, | |||||
}); | |||||
} | |||||
} | |||||
private *openCommandConfirmStep(state: OpenStepState, context: Context): StepResultGenerator<OpenFlags[]> { | |||||
const step: QuickPickStep<FlagsQuickPickItem<OpenFlags>> = QuickCommand.createConfirmStep( | |||||
appendReposToTitle(`Confirm ${context.title}`, state, context), | |||||
[ | |||||
FlagsQuickPickItem.create<OpenFlags>(state.flags, [], { | |||||
label: context.title, | |||||
detail: `Will open the worktree in ${GitWorktree.getFriendlyPath(state.uri)} in the current window`, | |||||
}), | |||||
FlagsQuickPickItem.create<OpenFlags>(state.flags, ['--new-window'], { | |||||
label: `${context.title} in New Window`, | |||||
detail: `Will open the worktree in ${GitWorktree.getFriendlyPath(state.uri)} in a new window`, | |||||
}), | |||||
], | |||||
context, | |||||
); | |||||
const selection: StepSelection<typeof step> = yield step; | |||||
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break; | |||||
} | |||||
} |
@ -0,0 +1,66 @@ | |||||
import { Uri, workspace, WorkspaceFolder } from 'vscode'; | |||||
import { Container } from '../../container'; | |||||
import { memoize } from '../../system/decorators/memoize'; | |||||
import { normalizePath, relative } from '../../system/path'; | |||||
import { GitRevision } from './reference'; | |||||
import type { GitStatus } from './status'; | |||||
export class GitWorktree { | |||||
static is(worktree: any): worktree is GitWorktree { | |||||
return worktree instanceof GitWorktree; | |||||
} | |||||
constructor( | |||||
public readonly type: 'bare' | 'branch' | 'detached', | |||||
public readonly repoPath: string, | |||||
public readonly uri: Uri, | |||||
public readonly locked: boolean | string, | |||||
public readonly prunable: boolean | string, | |||||
public readonly sha?: string, | |||||
public readonly branch?: string, | |||||
) {} | |||||
get opened(): boolean { | |||||
return this.workspaceFolder?.uri.toString() === this.uri.toString(); | |||||
} | |||||
get name(): string { | |||||
switch (this.type) { | |||||
case 'bare': | |||||
return '(bare)'; | |||||
case 'detached': | |||||
return GitRevision.shorten(this.sha); | |||||
default: | |||||
return this.branch || this.friendlyPath; | |||||
} | |||||
} | |||||
@memoize() | |||||
get friendlyPath(): string { | |||||
const path = GitWorktree.getFriendlyPath(this.uri); | |||||
return path; | |||||
} | |||||
@memoize() | |||||
get workspaceFolder(): WorkspaceFolder | undefined { | |||||
return workspace.getWorkspaceFolder(this.uri); | |||||
} | |||||
private _status: Promise<GitStatus | undefined> | undefined; | |||||
getStatus(options?: { force?: boolean }): Promise<GitStatus | undefined> { | |||||
if (this.type === 'bare') return Promise.resolve(undefined); | |||||
if (this._status == null || options?.force) { | |||||
this._status = Container.instance.git.getStatusForRepo(this.uri.fsPath); | |||||
} | |||||
return this._status; | |||||
} | |||||
static getFriendlyPath(uri: Uri): string { | |||||
const folder = workspace.getWorkspaceFolder(uri); | |||||
if (folder == null) return normalizePath(uri.fsPath); | |||||
const relativePath = normalizePath(relative(folder.uri.fsPath, uri.fsPath)); | |||||
return relativePath.length === 0 ? folder.name : relativePath; | |||||
} | |||||
} |
@ -0,0 +1,88 @@ | |||||
import { Uri } from 'vscode'; | |||||
import { debug } from '../../system/decorators/log'; | |||||
import { normalizePath } from '../../system/path'; | |||||
import { getLines } from '../../system/string'; | |||||
import { GitWorktree } from '../models/worktree'; | |||||
interface WorktreeEntry { | |||||
path: string; | |||||
sha?: string; | |||||
branch?: string; | |||||
bare: boolean; | |||||
detached: boolean; | |||||
locked?: boolean | string; | |||||
prunable?: boolean | string; | |||||
} | |||||
export class GitWorktreeParser { | |||||
@debug({ args: false, singleLine: true }) | |||||
static parse(data: string, repoPath: string): GitWorktree[] { | |||||
if (!data) return []; | |||||
if (repoPath !== undefined) { | |||||
repoPath = normalizePath(repoPath); | |||||
} | |||||
const worktrees: GitWorktree[] = []; | |||||
let entry: Partial<WorktreeEntry> | undefined = undefined; | |||||
let line: string; | |||||
let key: string; | |||||
let value: string; | |||||
let locked: string; | |||||
let prunable: string; | |||||
for (line of getLines(data)) { | |||||
[key, value] = line.split(' ', 2); | |||||
if (key.length === 0 && entry !== undefined) { | |||||
worktrees.push( | |||||
new GitWorktree( | |||||
entry.bare ? 'bare' : entry.detached ? 'detached' : 'branch', | |||||
repoPath, | |||||
Uri.file(entry.path!), | |||||
entry.locked ?? false, | |||||
entry.prunable ?? false, | |||||
entry.sha, | |||||
entry.branch, | |||||
), | |||||
); | |||||
entry = undefined; | |||||
continue; | |||||
} | |||||
if (entry === undefined) { | |||||
entry = {}; | |||||
} | |||||
switch (key) { | |||||
case 'worktree': | |||||
entry.path = value; | |||||
break; | |||||
case 'bare': | |||||
entry.bare = true; | |||||
break; | |||||
case 'HEAD': | |||||
entry.sha = value; | |||||
break; | |||||
case 'branch': | |||||
// Strip off refs/heads/ | |||||
entry.branch = value.substr(11); | |||||
break; | |||||
case 'detached': | |||||
entry.detached = true; | |||||
break; | |||||
case 'locked': | |||||
[, locked] = value.split(' ', 2); | |||||
entry.locked = locked?.trim() || true; | |||||
break; | |||||
case 'prunable': | |||||
[, prunable] = value.split(' ', 2); | |||||
entry.prunable = prunable?.trim() || true; | |||||
break; | |||||
} | |||||
} | |||||
return worktrees; | |||||
} | |||||
} |
@ -0,0 +1,123 @@ | |||||
import { Command, TreeItem, TreeItemCollapsibleState } from 'vscode'; | |||||
import { DiffWithPreviousCommandArgs } from '../../commands'; | |||||
import { Commands } from '../../constants'; | |||||
import { StatusFileFormatter } from '../../git/formatters'; | |||||
import { GitUri } from '../../git/gitUri'; | |||||
import { GitFile } from '../../git/models'; | |||||
import { dirname, joinPaths } from '../../system/path'; | |||||
import { ViewsWithCommits } from '../viewBase'; | |||||
import { FileNode } from './folderNode'; | |||||
import { ContextValues, ViewNode } from './viewNode'; | |||||
export class UncommittedFileNode extends ViewNode<ViewsWithCommits> implements FileNode { | |||||
public readonly file: GitFile; | |||||
public readonly repoPath: string; | |||||
constructor(view: ViewsWithCommits, parent: ViewNode, repoPath: string, file: GitFile) { | |||||
super(GitUri.fromFile(file, repoPath), view, parent); | |||||
this.repoPath = repoPath; | |||||
this.file = file; | |||||
} | |||||
override toClipboard(): string { | |||||
return this.path; | |||||
} | |||||
get path(): string { | |||||
return this.file.path; | |||||
} | |||||
getChildren(): ViewNode[] { | |||||
return []; | |||||
} | |||||
getTreeItem(): TreeItem { | |||||
const item = new TreeItem(this.label, TreeItemCollapsibleState.None); | |||||
item.contextValue = ContextValues.File; | |||||
item.description = this.description; | |||||
// Use the file icon and decorations | |||||
item.resourceUri = this.view.container.git.getAbsoluteUri(this.file.path, this.repoPath); | |||||
const icon = GitFile.getStatusIcon(this.file.status); | |||||
item.iconPath = { | |||||
dark: this.view.container.context.asAbsolutePath(joinPaths('images', 'dark', icon)), | |||||
light: this.view.container.context.asAbsolutePath(joinPaths('images', 'light', icon)), | |||||
}; | |||||
item.tooltip = StatusFileFormatter.fromTemplate( | |||||
`\${file}\n\${directory}/\n\n\${status}\${ (originalPath)}`, | |||||
this.file, | |||||
); | |||||
item.command = this.getCommand(); | |||||
// Only cache the label/description for a single refresh | |||||
this._label = undefined; | |||||
this._description = undefined; | |||||
return item; | |||||
} | |||||
private _description: string | undefined; | |||||
get description() { | |||||
if (this._description == null) { | |||||
this._description = StatusFileFormatter.fromTemplate( | |||||
this.view.config.formats.files.description, | |||||
{ ...this.file }, | |||||
{ relativePath: this.relativePath }, | |||||
); | |||||
} | |||||
return this._description; | |||||
} | |||||
private _folderName: string | undefined; | |||||
get folderName() { | |||||
if (this._folderName == null) { | |||||
this._folderName = dirname(this.uri.relativePath); | |||||
} | |||||
return this._folderName; | |||||
} | |||||
private _label: string | undefined; | |||||
get label() { | |||||
if (this._label == null) { | |||||
this._label = StatusFileFormatter.fromTemplate( | |||||
`\${file}`, | |||||
{ ...this.file }, | |||||
{ relativePath: this.relativePath }, | |||||
); | |||||
} | |||||
return this._label; | |||||
} | |||||
get priority(): number { | |||||
return 0; | |||||
} | |||||
private _relativePath: string | undefined; | |||||
get relativePath(): string | undefined { | |||||
return this._relativePath; | |||||
} | |||||
set relativePath(value: string | undefined) { | |||||
this._relativePath = value; | |||||
this._label = undefined; | |||||
this._description = undefined; | |||||
} | |||||
override getCommand(): Command | undefined { | |||||
const commandArgs: DiffWithPreviousCommandArgs = { | |||||
uri: GitUri.fromFile(this.file, this.repoPath), | |||||
line: 0, | |||||
showOptions: { | |||||
preserveFocus: true, | |||||
preview: true, | |||||
}, | |||||
}; | |||||
return { | |||||
title: 'Open Changes with Previous Revision', | |||||
command: Commands.DiffWithPrevious, | |||||
arguments: [undefined, commandArgs], | |||||
}; | |||||
} | |||||
} |
@ -0,0 +1,143 @@ | |||||
'use strict'; | |||||
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; | |||||
import { ViewFilesLayout } from '../../config'; | |||||
import { GitUri } from '../../git/gitUri'; | |||||
import { | |||||
GitCommit, | |||||
GitCommitIdentity, | |||||
GitFileChange, | |||||
GitFileWithCommit, | |||||
GitRevision, | |||||
GitStatus, | |||||
GitStatusFile, | |||||
GitTrackingState, | |||||
} from '../../git/models'; | |||||
import { groupBy, makeHierarchical } from '../../system/array'; | |||||
import { flatMap } from '../../system/iterable'; | |||||
import { joinPaths, normalizePath } from '../../system/path'; | |||||
import { RepositoriesView } from '../repositoriesView'; | |||||
import { WorktreesView } from '../worktreesView'; | |||||
import { FileNode, FolderNode } from './folderNode'; | |||||
import { RepositoryNode } from './repositoryNode'; | |||||
import { UncommittedFileNode } from './UncommittedFileNode'; | |||||
import { ContextValues, ViewNode } from './viewNode'; | |||||
export class UncommittedFilesNode extends ViewNode<RepositoriesView | WorktreesView> { | |||||
static key = ':uncommitted-files'; | |||||
static getId(repoPath: string): string { | |||||
return `${RepositoryNode.getId(repoPath)}${this.key}`; | |||||
} | |||||
readonly repoPath: string; | |||||
constructor( | |||||
view: RepositoriesView | WorktreesView, | |||||
parent: ViewNode, | |||||
public readonly status: | |||||
| GitStatus | |||||
| { | |||||
readonly repoPath: string; | |||||
readonly files: GitStatusFile[]; | |||||
readonly state: GitTrackingState; | |||||
readonly upstream?: string; | |||||
}, | |||||
public readonly range: string | undefined, | |||||
) { | |||||
super(GitUri.fromRepoPath(status.repoPath), view, parent); | |||||
this.repoPath = status.repoPath; | |||||
} | |||||
override get id(): string { | |||||
return UncommittedFilesNode.getId(this.repoPath); | |||||
} | |||||
getChildren(): ViewNode[] { | |||||
const repoPath = this.repoPath; | |||||
const files: GitFileWithCommit[] = [ | |||||
...flatMap(this.status.files, f => { | |||||
if (f.workingTreeStatus != null && f.indexStatus != null) { | |||||
// Decrements the date to guarantee this entry will be sorted after the previous entry (most recent first) | |||||
const older = new Date(); | |||||
older.setMilliseconds(older.getMilliseconds() - 1); | |||||
return [ | |||||
this.getFileWithPseudoCommit(f, GitRevision.uncommitted, GitRevision.uncommittedStaged), | |||||
this.getFileWithPseudoCommit(f, GitRevision.uncommittedStaged, 'HEAD', older), | |||||
]; | |||||
} else if (f.indexStatus != null) { | |||||
return [this.getFileWithPseudoCommit(f, GitRevision.uncommittedStaged, 'HEAD')]; | |||||
} | |||||
return [this.getFileWithPseudoCommit(f, GitRevision.uncommitted, 'HEAD')]; | |||||
}), | |||||
]; | |||||
files.sort((a, b) => b.commit.date.getTime() - a.commit.date.getTime()); | |||||
const groups = groupBy(files, f => f.path); | |||||
let children: FileNode[] = Object.values(groups).map( | |||||
files => new UncommittedFileNode(this.view, this, repoPath, files[files.length - 1]), | |||||
); | |||||
if (this.view.config.files.layout !== ViewFilesLayout.List) { | |||||
const hierarchy = makeHierarchical( | |||||
children, | |||||
n => n.uri.relativePath.split('/'), | |||||
(...parts: string[]) => normalizePath(joinPaths(...parts)), | |||||
this.view.config.files.compact, | |||||
); | |||||
const root = new FolderNode(this.view, this, repoPath, '', hierarchy, true); | |||||
children = root.getChildren() as FileNode[]; | |||||
} else { | |||||
children.sort( | |||||
(a, b) => | |||||
a.priority - b.priority || | |||||
a.label!.localeCompare(b.label!, undefined, { numeric: true, sensitivity: 'base' }), | |||||
); | |||||
} | |||||
return children; | |||||
} | |||||
getTreeItem(): TreeItem { | |||||
const item = new TreeItem('Uncommitted changes', TreeItemCollapsibleState.Collapsed); | |||||
item.id = this.id; | |||||
item.contextValue = ContextValues.UncommittedFiles; | |||||
item.iconPath = new ThemeIcon('folder'); | |||||
return item; | |||||
} | |||||
private getFileWithPseudoCommit( | |||||
file: GitStatusFile, | |||||
ref: string, | |||||
previousRef: string, | |||||
date?: Date, | |||||
): GitFileWithCommit { | |||||
date = date ?? new Date(); | |||||
return { | |||||
status: file.status, | |||||
repoPath: file.repoPath, | |||||
indexStatus: file.indexStatus, | |||||
workingTreeStatus: file.workingTreeStatus, | |||||
path: file.path, | |||||
originalPath: file.originalPath, | |||||
commit: new GitCommit( | |||||
this.view.container, | |||||
file.repoPath, | |||||
ref, | |||||
new GitCommitIdentity('You', undefined, date), | |||||
new GitCommitIdentity('You', undefined, date), | |||||
'Uncommitted changes', | |||||
[previousRef], | |||||
'Uncommitted changes', | |||||
new GitFileChange(file.repoPath, file.path, file.status, file.originalPath, previousRef), | |||||
undefined, | |||||
[], | |||||
), | |||||
}; | |||||
} | |||||
} |
@ -0,0 +1,256 @@ | |||||
import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, window } from 'vscode'; | |||||
import { GlyphChars } from '../../constants'; | |||||
import { GitUri } from '../../git/gitUri'; | |||||
import { GitLog, GitRemote, GitRemoteType, GitRevision, GitWorktree } from '../../git/models'; | |||||
import { gate } from '../../system/decorators/gate'; | |||||
import { debug } from '../../system/decorators/log'; | |||||
import { map } from '../../system/iterable'; | |||||
import { pad } from '../../system/string'; | |||||
import { RepositoriesView } from '../repositoriesView'; | |||||
import { WorktreesView } from '../worktreesView'; | |||||
import { CommitNode } from './commitNode'; | |||||
import { LoadMoreNode, MessageNode } from './common'; | |||||
import { insertDateMarkers } from './helpers'; | |||||
import { RepositoryNode } from './repositoryNode'; | |||||
import { UncommittedFilesNode } from './UncommittedFilesNode'; | |||||
import { ContextValues, ViewNode } from './viewNode'; | |||||
export class WorktreeNode extends ViewNode<WorktreesView | RepositoriesView> { | |||||
static key = ':worktree'; | |||||
static getId(repoPath: string, uri: Uri): string { | |||||
return `${RepositoryNode.getId(repoPath)}${this.key}(${uri.path})`; | |||||
} | |||||
constructor( | |||||
uri: GitUri, | |||||
view: WorktreesView | RepositoriesView, | |||||
parent: ViewNode, | |||||
public readonly worktree: GitWorktree, | |||||
) { | |||||
super(uri, view, parent); | |||||
} | |||||
override toClipboard(): string { | |||||
return this.worktree.uri.fsPath; | |||||
} | |||||
override get id(): string { | |||||
return WorktreeNode.getId(this.worktree.repoPath, this.worktree.uri); | |||||
} | |||||
get repoPath(): string { | |||||
return this.uri.repoPath!; | |||||
} | |||||
async getChildren(): Promise<ViewNode[]> { | |||||
const log = await this.getLog(); | |||||
if (log == null) return [new MessageNode(this.view, this, 'No commits could be found.')]; | |||||
const getBranchAndTagTips = await this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath); | |||||
const children = [ | |||||
...insertDateMarkers( | |||||
map( | |||||
log.commits.values(), | |||||
c => new CommitNode(this.view, this, c, undefined, undefined, getBranchAndTagTips), | |||||
), | |||||
this, | |||||
), | |||||
]; | |||||
if (log.hasMore) { | |||||
children.push(new LoadMoreNode(this.view, this, children[children.length - 1])); | |||||
} | |||||
const status = await this.worktree.getStatus(); | |||||
if (status?.hasChanges) { | |||||
children.splice(0, 0, new UncommittedFilesNode(this.view, this, status, undefined)); | |||||
} | |||||
return children; | |||||
} | |||||
async getTreeItem(): Promise<TreeItem> { | |||||
this.splatted = false; | |||||
let description = ''; | |||||
const tooltip = new MarkdownString('', true); | |||||
let icon: ThemeIcon | undefined; | |||||
let hasChanges = false; | |||||
switch (this.worktree.type) { | |||||
case 'bare': | |||||
icon = new ThemeIcon('folder'); | |||||
tooltip.appendMarkdown(`Bare Worktree\\\n\`${this.worktree.friendlyPath}\``); | |||||
break; | |||||
case 'branch': { | |||||
const [branch, status] = await Promise.all([ | |||||
this.worktree.branch | |||||
? this.view.container.git | |||||
.getBranches(this.uri.repoPath, { filter: b => b.name === this.worktree.branch }) | |||||
.then(b => b.values[0]) | |||||
: undefined, | |||||
this.worktree.getStatus(), | |||||
]); | |||||
tooltip.appendMarkdown( | |||||
`Worktree for Branch $(git-branch) ${branch?.getNameWithoutRemote() ?? this.worktree.branch}${ | |||||
this.worktree.opened ? `${pad(GlyphChars.Dash, 2, 2)} _Active_ ` : '' | |||||
}\\\n\`${this.worktree.friendlyPath}\``, | |||||
); | |||||
icon = new ThemeIcon('git-branch'); | |||||
if (status != null) { | |||||
hasChanges = status.hasChanges; | |||||
tooltip.appendMarkdown( | |||||
`\n\n${status.getFormattedDiffStatus({ | |||||
prefix: 'Has Uncommitted Changes\\\n', | |||||
empty: 'No Uncommitted Changes', | |||||
expand: true, | |||||
})}`, | |||||
); | |||||
} | |||||
if (branch != null) { | |||||
tooltip.appendMarkdown(`\n\nBranch $(git-branch) ${branch.getNameWithoutRemote()}`); | |||||
if (!branch.remote) { | |||||
if (branch.upstream != null) { | |||||
let arrows = GlyphChars.Dash; | |||||
const remote = await branch.getRemote(); | |||||
if (!branch.upstream.missing) { | |||||
if (remote != null) { | |||||
let left; | |||||
let right; | |||||
for (const { type } of remote.urls) { | |||||
if (type === GitRemoteType.Fetch) { | |||||
left = true; | |||||
if (right) break; | |||||
} else if (type === GitRemoteType.Push) { | |||||
right = true; | |||||
if (left) break; | |||||
} | |||||
} | |||||
if (left && right) { | |||||
arrows = GlyphChars.ArrowsRightLeft; | |||||
} else if (right) { | |||||
arrows = GlyphChars.ArrowRight; | |||||
} else if (left) { | |||||
arrows = GlyphChars.ArrowLeft; | |||||
} | |||||
} | |||||
} else { | |||||
arrows = GlyphChars.Warning; | |||||
} | |||||
description = `${branch.getTrackingStatus({ | |||||
empty: pad(arrows, 0, 2), | |||||
suffix: pad(arrows, 2, 2), | |||||
})}${branch.upstream.name}`; | |||||
tooltip.appendMarkdown( | |||||
` is ${branch.getTrackingStatus({ | |||||
empty: branch.upstream.missing | |||||
? `missing upstream $(git-branch) ${branch.upstream.name}` | |||||
: `up to date with $(git-branch) ${branch.upstream.name}${ | |||||
remote?.provider?.name ? ` on ${remote.provider.name}` : '' | |||||
}`, | |||||
expand: true, | |||||
icons: true, | |||||
separator: ', ', | |||||
suffix: ` $(git-branch) ${branch.upstream.name}${ | |||||
remote?.provider?.name ? ` on ${remote.provider.name}` : '' | |||||
}`, | |||||
})}`, | |||||
); | |||||
} else { | |||||
const providerName = GitRemote.getHighlanderProviderName( | |||||
await this.view.container.git.getRemotesWithProviders(branch.repoPath), | |||||
); | |||||
tooltip.appendMarkdown(` hasn't been published to ${providerName ?? 'a remote'}`); | |||||
} | |||||
} | |||||
} | |||||
break; | |||||
} | |||||
case 'detached': { | |||||
icon = new ThemeIcon('git-commit'); | |||||
tooltip.appendMarkdown( | |||||
`Detached Worktree at $(git-commit) ${GitRevision.shorten(this.worktree.sha)}${ | |||||
this.worktree.opened ? `${pad(GlyphChars.Dash, 2, 2)} _Active_` : '' | |||||
}\\\n\`${this.worktree.friendlyPath}\``, | |||||
); | |||||
const status = await this.worktree.getStatus(); | |||||
if (status != null) { | |||||
hasChanges = status.hasChanges; | |||||
tooltip.appendMarkdown( | |||||
`\n\n${status.getFormattedDiffStatus({ | |||||
prefix: 'Has Uncommitted Changes', | |||||
empty: 'No Uncommitted Changes', | |||||
expand: true, | |||||
})}`, | |||||
); | |||||
} | |||||
break; | |||||
} | |||||
} | |||||
const item = new TreeItem(this.worktree.name, TreeItemCollapsibleState.Collapsed); | |||||
item.id = this.id; | |||||
item.description = description; | |||||
item.contextValue = `${ContextValues.Worktree}${this.worktree.opened ? '+active' : ''}`; | |||||
item.iconPath = this.worktree.opened ? new ThemeIcon('check') : icon; | |||||
item.tooltip = tooltip; | |||||
item.resourceUri = hasChanges ? Uri.parse('gitlens-view://worktree/changes') : undefined; | |||||
return item; | |||||
} | |||||
@gate() | |||||
@debug() | |||||
override refresh(reset?: boolean) { | |||||
if (reset) { | |||||
this._log = undefined; | |||||
} | |||||
} | |||||
private _log: GitLog | undefined; | |||||
private async getLog() { | |||||
if (this._log == null) { | |||||
this._log = await this.view.container.git.getLog(this.uri.repoPath!, { | |||||
ref: this.worktree.sha, | |||||
limit: this.limit ?? this.view.config.defaultItemLimit, | |||||
}); | |||||
} | |||||
return this._log; | |||||
} | |||||
get hasMore() { | |||||
return this._log?.hasMore ?? true; | |||||
} | |||||
limit: number | undefined = this.view.getNodeLastKnownLimit(this); | |||||
@gate() | |||||
async loadMore(limit?: number | { until?: any }) { | |||||
let log = await window.withProgress( | |||||
{ | |||||
location: { viewId: this.view.id }, | |||||
}, | |||||
() => this.getLog(), | |||||
); | |||||
if (log == null || !log.hasMore) return; | |||||
log = await log.more?.(limit ?? this.view.config.pageItemLimit); | |||||
if (this._log === log) return; | |||||
this._log = log; | |||||
this.limit = log?.count; | |||||
void this.triggerChange(false); | |||||
} | |||||
} |
@ -0,0 +1,63 @@ | |||||
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; | |||||
import { GitUri } from '../../git/gitUri'; | |||||
import { Repository } from '../../git/models'; | |||||
import { gate } from '../../system/decorators/gate'; | |||||
import { debug } from '../../system/decorators/log'; | |||||
import { RepositoriesView } from '../repositoriesView'; | |||||
import { WorktreesView } from '../worktreesView'; | |||||
import { MessageNode } from './common'; | |||||
import { RepositoryNode } from './repositoryNode'; | |||||
import { ContextValues, ViewNode } from './viewNode'; | |||||
import { WorktreeNode } from './worktreeNode'; | |||||
export class WorktreesNode extends ViewNode<WorktreesView | RepositoriesView> { | |||||
static key = ':worktrees'; | |||||
static getId(repoPath: string): string { | |||||
return `${RepositoryNode.getId(repoPath)}${this.key}`; | |||||
} | |||||
private _children: WorktreeNode[] | undefined; | |||||
constructor( | |||||
uri: GitUri, | |||||
view: WorktreesView | RepositoriesView, | |||||
parent: ViewNode, | |||||
public readonly repo: Repository, | |||||
) { | |||||
super(uri, view, parent); | |||||
} | |||||
override get id(): string { | |||||
return WorktreesNode.getId(this.repo.path); | |||||
} | |||||
get repoPath(): string { | |||||
return this.repo.path; | |||||
} | |||||
async getChildren(): Promise<ViewNode[]> { | |||||
if (this._children == null) { | |||||
const worktrees = await this.repo.getWorktrees(); | |||||
if (worktrees.length === 0) return [new MessageNode(this.view, this, 'No worktrees could be found.')]; | |||||
this._children = worktrees.map(c => new WorktreeNode(this.uri, this.view, this, c)); | |||||
} | |||||
return this._children; | |||||
} | |||||
getTreeItem(): TreeItem { | |||||
const item = new TreeItem('Worktrees', TreeItemCollapsibleState.Collapsed); | |||||
item.id = this.id; | |||||
item.contextValue = ContextValues.Worktrees; | |||||
// TODO@eamodio `folder` icon won't work here for some reason | |||||
item.iconPath = new ThemeIcon('folder-opened'); | |||||
return item; | |||||
} | |||||
@gate() | |||||
@debug() | |||||
override refresh() { | |||||
this._children = undefined; | |||||
} | |||||
} |
@ -0,0 +1,263 @@ | |||||
import { | |||||
CancellationToken, | |||||
commands, | |||||
ConfigurationChangeEvent, | |||||
Disposable, | |||||
ProgressLocation, | |||||
ThemeColor, | |||||
TreeItem, | |||||
TreeItemCollapsibleState, | |||||
window, | |||||
} from 'vscode'; | |||||
import { configuration, ViewFilesLayout, WorktreesViewConfig } from '../configuration'; | |||||
import { Container } from '../container'; | |||||
import { PremiumFeatures } from '../git/gitProvider'; | |||||
import { GitUri } from '../git/gitUri'; | |||||
import { GitWorktree, RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../git/models'; | |||||
import { gate } from '../system/decorators/gate'; | |||||
import { | |||||
RepositoriesSubscribeableNode, | |||||
RepositoryFolderNode, | |||||
RepositoryNode, | |||||
ViewNode, | |||||
WorktreeNode, | |||||
WorktreesNode, | |||||
} from './nodes'; | |||||
import { ViewBase } from './viewBase'; | |||||
export class WorktreesRepositoryNode extends RepositoryFolderNode<WorktreesView, WorktreesNode> { | |||||
getChildren(): Promise<ViewNode[]> { | |||||
if (this.child == null) { | |||||
this.child = new WorktreesNode(this.uri, this.view, this, this.repo); | |||||
} | |||||
return this.child.getChildren(); | |||||
} | |||||
protected changed(e: RepositoryChangeEvent) { | |||||
return e.changed( | |||||
RepositoryChange.Config, | |||||
RepositoryChange.Worktrees, | |||||
RepositoryChange.Unknown, | |||||
RepositoryChangeComparisonMode.Any, | |||||
); | |||||
} | |||||
} | |||||
export class WorktreesViewNode extends RepositoriesSubscribeableNode<WorktreesView, WorktreesRepositoryNode> { | |||||
async getChildren(): Promise<ViewNode[]> { | |||||
const access = await this.view.container.git.access(PremiumFeatures.Worktrees); | |||||
if (!access.allowed) return []; | |||||
if (this.children == null) { | |||||
const repositories = this.view.container.git.openRepositories; | |||||
if (repositories.length === 0) { | |||||
this.view.message = 'No worktrees could be found.'; | |||||
return []; | |||||
} | |||||
this.view.message = undefined; | |||||
const splat = repositories.length === 1; | |||||
this.children = repositories.map( | |||||
r => new WorktreesRepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r, splat), | |||||
); | |||||
} | |||||
if (this.children.length === 1) { | |||||
const [child] = this.children; | |||||
const children = await child.getChildren(); | |||||
if (children.length <= 1) { | |||||
this.view.message = undefined; | |||||
this.view.title = 'Worktrees'; | |||||
void child.ensureSubscription(); | |||||
return []; | |||||
} | |||||
this.view.message = undefined; | |||||
this.view.title = `Worktrees (${children.length})`; | |||||
return children; | |||||
} | |||||
return this.children; | |||||
} | |||||
getTreeItem(): TreeItem { | |||||
const item = new TreeItem('Worktrees', TreeItemCollapsibleState.Expanded); | |||||
return item; | |||||
} | |||||
} | |||||
export class WorktreesView extends ViewBase<WorktreesViewNode, WorktreesViewConfig> { | |||||
protected readonly configKey = 'worktrees'; | |||||
constructor(container: Container) { | |||||
super('gitlens.views.worktrees', 'Worktrees', container); | |||||
this.disposables.push( | |||||
window.registerFileDecorationProvider({ | |||||
provideFileDecoration: (uri, _token) => { | |||||
if ( | |||||
uri.scheme !== 'gitlens-view' || | |||||
uri.authority !== 'worktree' || | |||||
!uri.path.includes('/changes') | |||||
) { | |||||
return undefined; | |||||
} | |||||
return { | |||||
badge: '●', | |||||
color: new ThemeColor('gitlens.decorations.worktreeView.hasUncommittedChangesForegroundColor'), | |||||
tooltip: 'Has Uncommitted Changes', | |||||
}; | |||||
}, | |||||
}), | |||||
); | |||||
} | |||||
override get canReveal(): boolean { | |||||
return this.config.reveal || !configuration.get('views.repositories.showWorktrees'); | |||||
} | |||||
protected getRoot() { | |||||
return new WorktreesViewNode(this); | |||||
} | |||||
protected registerCommands(): Disposable[] { | |||||
void this.container.viewCommands; | |||||
return [ | |||||
commands.registerCommand( | |||||
this.getQualifiedCommand('copy'), | |||||
() => commands.executeCommand('gitlens.views.copy', this.selection), | |||||
this, | |||||
), | |||||
commands.registerCommand( | |||||
this.getQualifiedCommand('refresh'), | |||||
async () => { | |||||
// this.container.git.resetCaches('worktrees'); | |||||
return this.refresh(true); | |||||
}, | |||||
this, | |||||
), | |||||
commands.registerCommand( | |||||
this.getQualifiedCommand('setFilesLayoutToAuto'), | |||||
() => this.setFilesLayout(ViewFilesLayout.Auto), | |||||
this, | |||||
), | |||||
commands.registerCommand( | |||||
this.getQualifiedCommand('setFilesLayoutToList'), | |||||
() => this.setFilesLayout(ViewFilesLayout.List), | |||||
this, | |||||
), | |||||
commands.registerCommand( | |||||
this.getQualifiedCommand('setFilesLayoutToTree'), | |||||
() => this.setFilesLayout(ViewFilesLayout.Tree), | |||||
this, | |||||
), | |||||
commands.registerCommand( | |||||
this.getQualifiedCommand('setShowAvatarsOn'), | |||||
() => this.setShowAvatars(true), | |||||
this, | |||||
), | |||||
commands.registerCommand( | |||||
this.getQualifiedCommand('setShowAvatarsOff'), | |||||
() => this.setShowAvatars(false), | |||||
this, | |||||
), | |||||
]; | |||||
} | |||||
protected override filterConfigurationChanged(e: ConfigurationChangeEvent) { | |||||
const changed = super.filterConfigurationChanged(e); | |||||
if ( | |||||
!changed && | |||||
!configuration.changed(e, 'defaultDateFormat') && | |||||
!configuration.changed(e, 'defaultDateShortFormat') && | |||||
!configuration.changed(e, 'defaultDateSource') && | |||||
!configuration.changed(e, 'defaultDateStyle') && | |||||
!configuration.changed(e, 'defaultGravatarsStyle') && | |||||
!configuration.changed(e, 'defaultTimeFormat') | |||||
// !configuration.changed(e, 'sortWorktreesBy') | |||||
) { | |||||
return false; | |||||
} | |||||
return true; | |||||
} | |||||
findWorktree(worktree: GitWorktree, token?: CancellationToken) { | |||||
const repoNodeId = RepositoryNode.getId(worktree.repoPath); | |||||
return this.findNode(WorktreeNode.getId(worktree.repoPath, worktree.uri), { | |||||
maxDepth: 2, | |||||
canTraverse: n => { | |||||
if (n instanceof WorktreesViewNode) return true; | |||||
if (n instanceof WorktreesRepositoryNode) { | |||||
return n.id.startsWith(repoNodeId); | |||||
} | |||||
return false; | |||||
}, | |||||
token: token, | |||||
}); | |||||
} | |||||
@gate(() => '') | |||||
async revealRepository( | |||||
repoPath: string, | |||||
options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, | |||||
) { | |||||
const node = await this.findNode(RepositoryFolderNode.getId(repoPath), { | |||||
maxDepth: 1, | |||||
canTraverse: n => n instanceof WorktreesViewNode || n instanceof RepositoryFolderNode, | |||||
}); | |||||
if (node !== undefined) { | |||||
await this.reveal(node, options); | |||||
} | |||||
return node; | |||||
} | |||||
@gate(() => '') | |||||
revealWorktree( | |||||
worktree: GitWorktree, | |||||
options?: { | |||||
select?: boolean; | |||||
focus?: boolean; | |||||
expand?: boolean | number; | |||||
}, | |||||
) { | |||||
return window.withProgress( | |||||
{ | |||||
location: ProgressLocation.Notification, | |||||
title: `Revealing worktree '${worktree.name}' in the side bar...`, | |||||
cancellable: true, | |||||
}, | |||||
async (progress, token) => { | |||||
const node = await this.findWorktree(worktree, token); | |||||
if (node == null) return undefined; | |||||
await this.ensureRevealNode(node, options); | |||||
return node; | |||||
}, | |||||
); | |||||
} | |||||
private setFilesLayout(layout: ViewFilesLayout) { | |||||
return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout); | |||||
} | |||||
private setShowAvatars(enabled: boolean) { | |||||
return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled); | |||||
} | |||||
} |