Adds worktree open, create, & delete palette commandmain
@ -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); | |||
} | |||
} |