diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dde3dd334683..e9293c2e9dff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Changes copying changes to a worktree to no longer prompt to open that worktree afterward - Changes stashing to use the _Commit Graph_ working changes (WIP) commit-box draft message when one is present - Renames the _Open Worktree File_ action to _Open File (Worktree)_ for consistency with the new _Open Changes with Working File (Worktree)_ action +- Changes how paused merge, rebase, cherry-pick, and revert operations with conflicts are surfaced — a single surface now opens: the _Commit Graph_'s working changes (WIP) details with the conflict banner, or the _Interactive Rebase Editor_ for rebases when the _Commit Graph_ is unavailable. Previously a rebase conflict could simultaneously open the rebase editor, force-focus the _Commits_ view, and show a toast offering to open the already-opening editor. The _Commits_ view is no longer force-focused by any paused operation, the rebase conflict toasts no longer offer _Open Rebase Editor_, and _Show Conflicts_ now consistently opens the _Commit Graph_ from all locations, including inside the rebase editor; interactive rebase pauses (todo editing, `edit`, `reword`, `break`) still open the rebase editor ### Removed diff --git a/src/commands/git/cherry-pick.ts b/src/commands/git/cherry-pick.ts index 266427b2d9402..d2794809ff905 100644 --- a/src/commands/git/cherry-pick.ts +++ b/src/commands/git/cherry-pick.ts @@ -11,7 +11,7 @@ import { ensureArray } from '@gitlens/utils/array.js'; import { Logger } from '@gitlens/utils/logger.js'; import { pluralize } from '@gitlens/utils/string.js'; import type { Container } from '../../container.js'; -import { skipPausedOperation } from '../../git/actions/pausedOperation.js'; +import { showPausedOperationStatus, skipPausedOperation } from '../../git/actions/pausedOperation.js'; import type { GlRepository } from '../../git/models/repository.js'; import { showGitErrorMessage } from '../../messages.js'; import { isSubscriptionTrialOrPaidFromState } from '../../plus/gk/utils/subscription.utils.js'; @@ -20,7 +20,6 @@ import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive.js import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive.js'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags.js'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags.js'; -import { executeCommand } from '../../system/-webview/command.js'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase.js'; import type { AsyncStepResultGenerator, @@ -98,7 +97,7 @@ export class CherryPickGitCommand extends QuickCommand { void window.showWarningMessage( 'Unable to cherry-pick due to conflicts. Resolve the conflicts before continuing, or abort the cherry-pick.', ); - void executeCommand('gitlens.showCommitsView'); + void showPausedOperationStatus(this.container, state.repo.path); } } catch (ex) { // Don't show an error message if the user intentionally aborted the cherry-pick @@ -120,7 +119,7 @@ export class CherryPickGitCommand extends QuickCommand { void window.showWarningMessage( 'Unable to cherry-pick. A cherry-pick is already in progress. Continue or abort the current cherry-pick first.', ); - void executeCommand('gitlens.showCommitsView'); + void showPausedOperationStatus(this.container, state.repo.path); return; } @@ -146,10 +145,10 @@ export class CherryPickGitCommand extends QuickCommand { cancel, ); if (result === skip) { - return void skipPausedOperation(state.repo.git); + return void skipPausedOperation(this.container, state.repo.git); } - void executeCommand('gitlens.showCommitsView'); + void showPausedOperationStatus(this.container, state.repo.path); return; } diff --git a/src/commands/git/merge.ts b/src/commands/git/merge.ts index a6d709b8dbe38..832d958071156 100644 --- a/src/commands/git/merge.ts +++ b/src/commands/git/merge.ts @@ -9,6 +9,7 @@ import { createRevisionRange } from '@gitlens/git/utils/revision.utils.js'; import { Logger } from '@gitlens/utils/logger.js'; import { pluralize } from '@gitlens/utils/string.js'; import type { Container } from '../../container.js'; +import { showPausedOperationStatus } from '../../git/actions/pausedOperation.js'; import type { GlRepository } from '../../git/models/repository.js'; import { showGitErrorMessage } from '../../messages.js'; import { isSubscriptionTrialOrPaidFromState } from '../../plus/gk/utils/subscription.utils.js'; @@ -17,7 +18,6 @@ import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive.js import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive.js'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags.js'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags.js'; -import { executeCommand } from '../../system/-webview/command.js'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase.js'; import type { AsyncStepResultGenerator, @@ -106,7 +106,7 @@ export class MergeGitCommand extends QuickCommand { void window.showWarningMessage( 'Unable to merge due to conflicts. Resolve the conflicts before continuing, or abort the merge.', ); - void executeCommand('gitlens.showCommitsView'); + void showPausedOperationStatus(this.container, state.repo.path); } } catch (ex) { // Don't show an error message if the user intentionally aborted the merge @@ -128,7 +128,7 @@ export class MergeGitCommand extends QuickCommand { void window.showWarningMessage( 'Unable to merge. A merge is already in progress. Continue or abort the current merge first.', ); - void executeCommand('gitlens.showCommitsView'); + void showPausedOperationStatus(this.container, state.repo.path); return; } diff --git a/src/commands/git/rebase.ts b/src/commands/git/rebase.ts index 02b36fbf84c40..2610fc1e66fa2 100644 --- a/src/commands/git/rebase.ts +++ b/src/commands/git/rebase.ts @@ -10,12 +10,9 @@ import { createDisposable } from '@gitlens/utils/disposable.js'; import { Logger } from '@gitlens/utils/logger.js'; import { pluralize } from '@gitlens/utils/string.js'; import type { Container } from '../../container.js'; +import { showPausedOperationStatus } from '../../git/actions/pausedOperation.js'; import type { GlRepository } from '../../git/models/repository.js'; -import { - isRebaseTodoEditorEnabled, - openRebaseEditor, - reopenRebaseTodoEditor, -} from '../../git/utils/-webview/rebase.utils.js'; +import { isRebaseTodoEditorEnabled, reopenRebaseTodoEditor } from '../../git/utils/-webview/rebase.utils.js'; import { showGitErrorMessage } from '../../messages.js'; import { isSubscriptionTrialOrPaidFromState } from '../../plus/gk/utils/subscription.utils.js'; import { createQuickPickSeparator } from '../../quickpicks/items/common.js'; @@ -23,7 +20,6 @@ import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive.js import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive.js'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags.js'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags.js'; -import { executeCommand } from '../../system/-webview/command.js'; import { getHostEditorCommand } from '../../system/-webview/vscode.js'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase.js'; import type { @@ -116,18 +112,10 @@ export class RebaseGitCommand extends QuickCommand { updateRefs: updateRefs, }); if (result?.conflicted) { - const openEditor = { title: 'Open Rebase Editor' }; - void window - .showWarningMessage( - 'Unable to rebase due to conflicts. Resolve the conflicts before continuing, or abort the rebase.', - openEditor, - ) - .then(r => { - if (r === openEditor) { - void openRebaseEditor(this.container, state.repo.path); - } - }); - void executeCommand('gitlens.showCommitsView'); + void window.showWarningMessage( + 'Unable to rebase due to conflicts. Resolve the conflicts before continuing, or abort the rebase.', + ); + void showPausedOperationStatus(this.container, state.repo.path); } } catch (ex) { // Don't show an error message if the user intentionally aborted the rebase @@ -146,18 +134,10 @@ export class RebaseGitCommand extends QuickCommand { } if (RebaseError.is(ex, 'alreadyInProgress')) { - const openEditor = { title: 'Open Rebase Editor' }; - void window - .showWarningMessage( - 'Unable to rebase. A rebase is already in progress. Continue or abort the current rebase first.', - openEditor, - ) - .then(result => { - if (result === openEditor) { - void openRebaseEditor(this.container, state.repo.path); - } - }); - void executeCommand('gitlens.showCommitsView'); + void window.showWarningMessage( + 'Unable to rebase. A rebase is already in progress. Continue or abort the current rebase first.', + ); + void showPausedOperationStatus(this.container, state.repo.path); return; } diff --git a/src/commands/git/revert.ts b/src/commands/git/revert.ts index 63f5625113bac..ba33cd2798224 100644 --- a/src/commands/git/revert.ts +++ b/src/commands/git/revert.ts @@ -6,12 +6,12 @@ import type { GitRevisionReference } from '@gitlens/git/models/reference.js'; import { getReferenceLabel } from '@gitlens/git/utils/reference.utils.js'; import { Logger } from '@gitlens/utils/logger.js'; import type { Container } from '../../container.js'; +import { showPausedOperationStatus } from '../../git/actions/pausedOperation.js'; import type { GlRepository } from '../../git/models/repository.js'; import { showGitErrorMessage } from '../../messages.js'; import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive.js'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags.js'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags.js'; -import { executeCommand } from '../../system/-webview/command.js'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase.js'; import type { PartialStepState, @@ -88,7 +88,7 @@ export class RevertGitCommand extends QuickCommand { void window.showWarningMessage( 'Unable to revert due to conflicts. Resolve the conflicts before continuing, or abort the revert.', ); - void executeCommand('gitlens.showCommitsView'); + void showPausedOperationStatus(this.container, state.repo.path); } } catch (ex) { // Don't show an error message if the user intentionally aborted the revert @@ -110,7 +110,7 @@ export class RevertGitCommand extends QuickCommand { void window.showWarningMessage( 'Unable to revert. A revert is already in progress. Continue or abort the current revert first.', ); - void executeCommand('gitlens.showCommitsView'); + void showPausedOperationStatus(this.container, state.repo.path); return; } diff --git a/src/git/actions/pausedOperation.ts b/src/git/actions/pausedOperation.ts index 13b7bcbd66c21..6cc7b30873124 100644 --- a/src/git/actions/pausedOperation.ts +++ b/src/git/actions/pausedOperation.ts @@ -1,9 +1,12 @@ import { window } from 'vscode'; import { PausedOperationAbortError, PausedOperationContinueError } from '@gitlens/git/errors.js'; import type { GitPausedOperationStatus } from '@gitlens/git/models/pausedOperationStatus.js'; +import { uncommitted } from '@gitlens/git/models/revision.js'; import { getReferenceLabel } from '@gitlens/git/utils/reference.utils.js'; +import type { Source } from '../../constants.telemetry.js'; import type { Container } from '../../container.js'; import { showGitErrorMessage } from '../../messages.js'; +import { arePlusFeaturesEnabled } from '../../plus/gk/utils/-webview/plus.utils.js'; import { executeCommand } from '../../system/-webview/command.js'; import type { GitRepositoryService } from '../gitRepositoryService.js'; import { openRebaseEditor } from '../utils/-webview/rebase.utils.js'; @@ -19,15 +22,19 @@ export async function abortPausedOperation(svc: GitRepositoryService, options?: } } -export async function continuePausedOperation(svc: GitRepositoryService): Promise { - return continuePausedOperationCore(svc); +export async function continuePausedOperation(container: Container, svc: GitRepositoryService): Promise { + return continuePausedOperationCore(container, svc); } -export async function skipPausedOperation(svc: GitRepositoryService): Promise { - return continuePausedOperationCore(svc, true); +export async function skipPausedOperation(container: Container, svc: GitRepositoryService): Promise { + return continuePausedOperationCore(container, svc, true); } -async function continuePausedOperationCore(svc: GitRepositoryService, skip: boolean = false): Promise { +async function continuePausedOperationCore( + container: Container, + svc: GitRepositoryService, + skip: boolean = false, +): Promise { try { return await svc.pausedOps?.continuePausedOperation?.(skip ? { skip: true } : undefined); } catch (ex) { @@ -39,22 +46,22 @@ async function continuePausedOperationCore(svc: GitRepositoryService, skip: bool const pausedAt = getReferenceLabel(operation.incoming, { icon: false, label: true, quoted: true }); - const skip = { title: 'Skip' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; + const skipItem = { title: 'Skip' }; + const cancelItem = { title: 'Cancel', isCloseAffordance: true }; // TODO@eamodio: We should offer a continue with allowing an empty commit option const result = await window.showInformationMessage( `The ${operation.type} operation cannot be continued because ${pausedAt} resulted in an empty commit.\n\nDo you want to skip ${pausedAt}?`, { modal: true }, - skip, - cancel, + skipItem, + cancelItem, ); - if (result === skip) { - return void continuePausedOperationCore(svc, true); + if (result === skipItem) { + return void continuePausedOperationCore(container, svc, true); } - void executeCommand('gitlens.showCommitsView'); + void showPausedOperationStatus(container, svc.path); return; } @@ -70,7 +77,7 @@ async function continuePausedOperationCore(svc: GitRepositoryService, skip: bool if (PausedOperationContinueError.is(ex, 'conflicts') || PausedOperationContinueError.is(ex, 'unmergedFiles')) { void window.showWarningMessage(ex.message); - void executeCommand('gitlens.showCommitsView'); + void showPausedOperationStatus(container, svc.path); return; } @@ -79,19 +86,48 @@ async function continuePausedOperationCore(svc: GitRepositoryService, skip: bool } export interface ShowPausedOperationStatusOptions { - openRebaseEditor?: boolean; + /** When set, the request comes from inside the rebase editor, so always surface the graph */ + fromRebaseEditor?: boolean; + source?: Source; } +/** + * Surfaces a paused operation (merge/rebase/cherry-pick/revert) in a single, consistent place: + * the Graph's WIP details (which renders the paused-op/conflict banner), except for a paused + * rebase when the graph is gated — then the rebase editor, since it's the only usable surface. + */ export async function showPausedOperationStatus( container: Container, repoPath: string, options?: ShowPausedOperationStatusOptions, ): Promise { - if (options?.openRebaseEditor) { - await openRebaseEditor(container, repoPath); + const svc = container.git.getRepositoryService(repoPath); + // Force a fresh read: a caller may invoke this right after mutating paused-op state (e.g. a + // wizard conflict) before the `'pausedOp'` FS-watcher event lands, so a cached `undefined` + // must not make us silently surface nothing. The readdir-based detection is cheap to repeat. + const status = await svc.pausedOps?.getPausedOperationStatus?.({ force: true }); + if (status == null) return; + + const toGraph = + options?.fromRebaseEditor || status.type !== 'rebase' || (await isGraphAccessible(container, repoPath)); + if (toGraph) { + revealPausedOperationInGraph(repoPath, options?.source); return; } - await container.views.commits.show({ preserveFocus: false }); - await container.views.commits.revealPausedOperationStatus(repoPath, { focus: true, expand: true, select: true }); + await openRebaseEditor(container, repoPath); +} + +async function isGraphAccessible(container: Container, repoPath: string): Promise { + if (!arePlusFeaturesEnabled()) return false; + + return (await container.git.access('graph', repoPath)).allowed !== false; +} + +function revealPausedOperationInGraph(repoPath: string, source?: Source): void { + void executeCommand('gitlens.showGraph', { + action: 'show-wip', + target: { sha: uncommitted, worktreePath: repoPath }, + source: source, + }); } diff --git a/src/views/commitsView.ts b/src/views/commitsView.ts index aaedc23b30387..de44b4aa03496 100644 --- a/src/views/commitsView.ts +++ b/src/views/commitsView.ts @@ -439,24 +439,6 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie return node; } - @gate(() => '') - async revealPausedOperationStatus(repoPath: string, options?: RevealOptions): Promise { - const node = await this.findNode( - n => n.is('paused-operation-status') && n.pausedOpStatus.repoPath === repoPath, - { - maxDepth: 3, - canTraverse: n => - n instanceof CommitsViewNode || n instanceof RepositoryFolderNode || n instanceof BranchNode, - }, - ); - - if (node !== undefined) { - await this.reveal(node, options); - } - - return node; - } - private setFilesLayout(layout: ViewFilesLayout) { return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout); } diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 26a565432afaf..0ecea87e2d6dd 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -638,7 +638,10 @@ export class ViewCommands implements Disposable { private async continuePausedOperation(node: PausedOperationStatusNode) { if (!node.is('paused-operation-status')) return; - await continuePausedOperation(this.container.git.getRepositoryService(node.pausedOpStatus.repoPath)); + await continuePausedOperation( + this.container, + this.container.git.getRepositoryService(node.pausedOpStatus.repoPath), + ); } @command('gitlens.views.pausedOperation.skip') @@ -646,7 +649,10 @@ export class ViewCommands implements Disposable { private async skipPausedOperation(node: PausedOperationStatusNode) { if (!node.is('paused-operation-status')) return; - await skipPausedOperation(this.container.git.getRepositoryService(node.pausedOpStatus.repoPath)); + await skipPausedOperation( + this.container, + this.container.git.getRepositoryService(node.pausedOpStatus.repoPath), + ); } @command('gitlens.views.pausedOperation.open') diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index 655415ec31276..f858bb5c4cb14 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -623,13 +623,13 @@ export class HomeWebviewProvider implements WebviewProvider ({ pausedOpArgs: pausedOpArgs.type }) }) private async skipPausedOperation(pausedOpArgs: GitPausedOperationStatus) { - await skipPausedOperation(this.container.git.getRepositoryService(pausedOpArgs.repoPath)); + await skipPausedOperation(this.container, this.container.git.getRepositoryService(pausedOpArgs.repoPath)); } @command('gitlens.pausedOperation.open:') @@ -649,9 +649,7 @@ export class HomeWebviewProvider implements WebviewProvider ({ pausedOpArgs: pausedOpArgs.type }) }) private async showConflicts(pausedOpArgs: GitPausedOperationStatus) { - await showPausedOperationStatus(this.container, pausedOpArgs.repoPath, { - openRebaseEditor: pausedOpArgs.type === 'rebase', - }); + await showPausedOperationStatus(this.container, pausedOpArgs.repoPath); } @command('gitlens.createCloudPatch:') diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index e8d804524608a..4e723cb18079c 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -10299,7 +10299,7 @@ export class GraphWebviewProvider implements WebviewProvider): Promise { +async function getActionableRebaseState( + svc: ReturnType, +): Promise<{ actionable: boolean; hasConflicts: boolean }> { const gitDir = await svc.config.getGitDir?.(); - if (gitDir == null) return false; + if (gitDir == null) return { actionable: false, hasConflicts: false }; const todoUri = Uri.joinPath(gitDir.uri, 'rebase-merge', 'git-rebase-todo'); @@ -284,11 +296,13 @@ async function hasActionableRebaseState(svc: ReturnType 0) return true; + if ((getSettledValue(conflictsResult)?.length ?? 0) > 0) return { actionable: true, hasConflicts: true }; const todoContent = getSettledValue(todoContentResult); - if (todoContent != null && parseRebaseTodo(todoContent).entries.length > 0) return true; + if (todoContent != null && parseRebaseTodo(todoContent).entries.length > 0) { + return { actionable: true, hasConflicts: false }; + } const lastAction = getSettledValue(doneResult)?.entries.at(-1)?.action; - return getActionablePauseAction(lastAction) != null; + return { actionable: getActionablePauseAction(lastAction) != null, hasConflicts: false }; } diff --git a/src/webviews/rebase/rebaseWebviewProvider.ts b/src/webviews/rebase/rebaseWebviewProvider.ts index 78af49a0cf5bd..9acb9fb922e90 100644 --- a/src/webviews/rebase/rebaseWebviewProvider.ts +++ b/src/webviews/rebase/rebaseWebviewProvider.ts @@ -651,7 +651,7 @@ export class RebaseWebviewProvider implements Disposable { await this._todoDocument.save(); const svc = this.container.git.getRepositoryService(this.repoPath); - await continuePausedOperation(svc); + await continuePausedOperation(this.container, svc); } @ipcCommand(RecomposeCommand) @@ -701,7 +701,7 @@ export class RebaseWebviewProvider implements Disposable { @debug() private async onShowConflicts(): Promise { this.host.sendTelemetryEvent('rebaseEditor/action/showConflicts'); - await showPausedOperationStatus(this.container, this.repoPath); + await showPausedOperationStatus(this.container, this.repoPath, { fromRebaseEditor: true }); } @ipcCommand(SearchCommand) @@ -714,7 +714,7 @@ export class RebaseWebviewProvider implements Disposable { private async onSkip(): Promise { this.host.sendTelemetryEvent('rebaseEditor/action/skip'); const svc = this.container.git.getRepositoryService(this.repoPath); - await skipPausedOperation(svc); + await skipPausedOperation(this.container, svc); } @ipcCommand(StartCommand)