@ -2,6 +2,7 @@
import { TextDecoder } from 'util' ;
import {
CancellationToken ,
commands ,
ConfigurationTarget ,
CustomTextEditorProvider ,
Disposable ,
@ -9,7 +10,6 @@ import {
Range ,
TextDocument ,
Uri ,
Webview ,
WebviewPanel ,
window ,
workspace ,
@ -17,9 +17,12 @@ import {
} from 'vscode' ;
import { ShowQuickCommitCommand } from '../commands' ;
import { configuration } from '../configuration' ;
import { BuiltInCommands } from '../constants' ;
import { Container } from '../container' ;
import { Repository , RepositoryChange } from '../git/git' ;
import { Logger } from '../logger' ;
import { debug } from '../system' ;
import { Messages } from '../messages' ;
import { debug , gate , Iterables } from '../system' ;
import {
Author ,
Commit ,
@ -31,6 +34,7 @@ import {
RebaseDidDisableCommandType ,
RebaseDidMoveEntryCommandType ,
RebaseDidStartCommandType ,
RebaseDidSwitchCommandType ,
RebaseEntry ,
RebaseEntryAction ,
RebaseState ,
@ -47,6 +51,17 @@ function nextIpcId() {
return ` host: ${ ipcSequence } ` ;
}
let webviewId = 0 ;
function nextWebviewId() {
if ( webviewId === Number . MAX_SAFE_INTEGER ) {
webviewId = 1 ;
} else {
webviewId ++ ;
}
return webviewId ;
}
const rebaseRegex = /^\s?#\s?Rebase\s([0-9a-f]+)(?:..([0-9a-f]+))?\sonto\s([0-9a-f]+)\s.*$/im ;
const rebaseCommandsRegex = /^\s?(p|pick|r|reword|e|edit|s|squash|f|fixup|d|drop)\s([0-9a-f]+?)\s(.*)$/gm ;
@ -65,14 +80,29 @@ const rebaseActionsMap = new Map([
[ 'drop' , 'drop' ] ,
] ) ;
interface RebaseEditorContext {
dispose ( ) : void ;
readonly id : number ;
readonly document : TextDocument ;
readonly panel : WebviewPanel ;
readonly repo : Repository ;
readonly subscriptions : Disposable [ ] ;
abortOnClose : boolean ;
pendingChange? : boolean ;
}
export class RebaseEditorProvider implements CustomTextEditorProvider , Disposable {
private readonly _disposable : Disposable ;
constructor ( ) {
this . _disposable = Disposable . from (
window . registerCustomEditorProvider ( 'gitlens.rebase' , this , {
supportsMultipleEditorsPerDocument : false ,
webviewOptions : {
enableFindWidget : true ,
retainContextWhenHidden : true ,
} ,
} ) ,
) ;
@ -142,20 +172,62 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
@debug < RebaseEditorProvider [ ' resolveCustomTextEditor ' ] > ( { args : false } )
async resolveCustomTextEditor ( document : TextDocument , panel : WebviewPanel , _token : CancellationToken ) {
const disposable = Disposable . from (
panel . onDidDispose ( ( ) = > disposable . dispose ( ) ) ,
panel . webview . onDidReceiveMessage ( e = >
this . onMessageReceived ( { document : document , panel : panel , disposable : disposable } , e ) ,
) ,
const repo = await this . getRepository ( document ) ;
const subscriptions : Disposable [ ] = [ ] ;
const context : RebaseEditorContext = {
dispose : ( ) = > Disposable . from ( . . . subscriptions ) . dispose ( ) ,
id : nextWebviewId ( ) ,
subscriptions : subscriptions ,
document : document ,
panel : panel ,
repo : repo ,
abortOnClose : true ,
} ;
subscriptions . push (
panel . onDidDispose ( ( ) = > {
// If the user closed this without taking an action, consider it an abort
if ( context . abortOnClose ) {
void this . abort ( context ) ;
}
Disposable . from ( . . . subscriptions ) . dispose ( ) ;
} ) ,
panel . onDidChangeViewState ( ( ) = > {
if ( ! context . pendingChange ) return ;
void this . getStateAndNotify ( context ) ;
} ) ,
panel . webview . onDidReceiveMessage ( e = > this . onMessageReceived ( context , e ) ) ,
workspace . onDidChangeTextDocument ( e = > {
if ( e . contentChanges . length === 0 || e . document . uri . toString ( ) !== document . uri . toString ( ) ) return ;
this . parseEntriesAndSendChange ( panel , document ) ;
void this . getStateAndNotify ( context ) ;
} ) ,
workspace . onDidSaveTextDocument ( e = > {
if ( e . uri . toString ( ) !== document . uri . toString ( ) ) return ;
void this . getStateAndNotify ( context ) ;
} ) ,
repo . onDidChange ( e = > {
if (
e . changed ( RepositoryChange . Closed , true ) ||
e . changed ( RepositoryChange . Ignores , true ) ||
e . changed ( RepositoryChange . Remotes , true ) ||
e . changed ( RepositoryChange . Starred , true ) ||
e . changed ( RepositoryChange . Stash , true ) ||
e . changed ( RepositoryChange . Tags , true )
) {
return ;
}
void this . getStateAndNotify ( context ) ;
} ) ,
) ;
panel . webview . options = { enableCommandUris : true , enableScripts : true } ;
panel . webview . html = await this . getHtml ( panel . webview , document ) ;
panel . webview . html = await this . getHtml ( contex t) ;
if ( this . _disableAfterNextUse ) {
this . _disableAfterNextUse = false ;
@ -163,135 +235,42 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
}
}
private parseEntries ( contents : string ) : RebaseEntry [ ] ;
private parseEntries ( document : TextDocument ) : RebaseEntry [ ] ;
@debug < RebaseEditorProvider [ ' parseEntries ' ] > ( { args : false } )
private parseEntries ( contentsOrDocument : string | TextDocument ) : RebaseEntry [ ] {
const contents = typeof contentsOrDocument === 'string' ? contentsOrDocument : contentsOrDocument.getText ( ) ;
const entries : RebaseEntry [ ] = [ ] ;
@gate ( ( context : RebaseEditorContext ) = > ` ${ context . id } ` )
private async getStateAndNotify ( context : RebaseEditorContext ) {
if ( ! context . panel . visible ) {
context . pendingChange = true ;
let match ;
let action ;
let ref ;
let message ;
do {
match = rebaseCommandsRegex . exec ( contents ) ;
if ( match == null ) break ;
[ , action , ref , message ] = match ;
entries . push ( {
index : match.index ,
action : rebaseActionsMap.get ( action ) ? ? 'pick' ,
// Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869
ref : ` ${ ref } ` . substr ( 1 ) ,
// Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869
message : message == null || message . length === 0 ? '' : ` ${ message } ` . substr ( 1 ) ,
} ) ;
} while ( true ) ;
return entries . reverse ( ) ;
}
return ;
}
private parseEntriesAndSendChange ( panel : WebviewPanel , document : TextDocument ) {
const entries = this . parseEntries ( document ) ;
void this . postMessage ( panel , {
const state = await this . parseState ( context ) ;
void this . postMessage ( context , {
id : nextIpcId ( ) ,
method : RebaseDidChangeNotificationType.method ,
params : { entries : entries } ,
params : { state : state } ,
} ) ;
}
private async parseState ( document : TextDocument ) : Promise < RebaseState > {
const repoPath = await Container . git . getRepoPath ( Uri . joinPath ( document . uri , '../../..' ) ) ;
const branch = await Container . git . getBranch ( repoPath ) ;
const contents = document . getText ( ) ;
const entries = this . parseEntries ( contents ) ;
let [ , , , onto ] = rebaseRegex . exec ( contents ) ? ? [ '' , '' , '' ] ;
const authors = new Map < string , Author > ( ) ;
const commits : Commit [ ] = [ ] ;
const ontoCommit = await Container . git . getCommit ( repoPath ! , onto ) ;
if ( ontoCommit != null ) {
if ( ! authors . has ( ontoCommit . author ) ) {
authors . set ( ontoCommit . author , {
author : ontoCommit.author ,
avatarUrl : (
await ontoCommit . getAvatarUri ( { defaultStyle : Container.config.defaultGravatarsStyle } )
) . toString ( true ) ,
email : ontoCommit.email ,
} ) ;
}
commits . push ( {
ref : ontoCommit.ref ,
author : ontoCommit.author ,
date : ontoCommit.formatDate ( Container . config . defaultDateFormat ) ,
dateFromNow : ontoCommit.formatDateFromNow ( ) ,
message : ontoCommit.message || 'root' ,
} ) ;
}
for ( const entry of entries ) {
const commit = await Container . git . getCommit ( repoPath ! , entry . ref ) ;
if ( commit == null ) continue ;
// If the onto commit is contained in the list of commits, remove it and clear the 'onto' value — See #1201
if ( commit . ref === ontoCommit ? . ref ) {
commits . splice ( 0 , 1 ) ;
onto = '' ;
}
if ( ! authors . has ( commit . author ) ) {
authors . set ( commit . author , {
author : commit.author ,
avatarUrl : (
await commit . getAvatarUri ( { defaultStyle : Container.config.defaultGravatarsStyle } )
) . toString ( true ) ,
email : commit.email ,
} ) ;
}
commits . push ( {
ref : commit.ref ,
author : commit.author ,
date : commit.formatDate ( Container . config . defaultDateFormat ) ,
dateFromNow : commit.formatDateFromNow ( ) ,
message : commit.message ,
} ) ;
}
return {
branch : branch?.name ? ? '' ,
onto : onto ,
entries : entries ,
authors : [ . . . authors . values ( ) ] ,
commits : commits ,
commands : {
// eslint-disable-next-line no-template-curly-in-string
commit : ShowQuickCommitCommand.getMarkdownCommandArgs ( '${commit}' , repoPath ) ,
} ,
} ;
private async parseState ( context : RebaseEditorContext ) : Promise < RebaseState > {
const branch = await context . repo . getBranch ( ) ;
const state = await parseRebaseTodo ( context . document . getText ( ) , context . repo , branch ? . name ) ;
return state ;
}
private async postMessage ( panel : WebviewPanel , message : IpcMessage ) {
private async postMessage ( context : RebaseEditorContext , message : IpcMessage ) {
try {
const success = await panel . webview . postMessage ( message ) ;
const success = await context . panel . webview . postMessage ( message ) ;
context . pendingChange = ! success ;
return success ;
} catch ( ex ) {
Logger . error ( ex ) ;
context . pendingChange = true ;
return false ;
}
}
private onMessageReceived (
{ document , panel , disposable } : { document : TextDocument ; panel : WebviewPanel ; disposable : Disposable } ,
e : IpcMessage ,
) {
private onMessageReceived ( context : RebaseEditorContext , e : IpcMessage ) {
switch ( e . method ) {
// case ReadyCommandType.method:
// onIpcCommand(ReadyCommandType, e, params => {
@ -300,41 +279,39 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
// break;
case RebaseDidDisableCommandType . method :
onIpcCommand ( RebaseDidDisableCommandType , e , async ( ) = > {
await this . abort ( document , panel , disposable ) ;
await this . setEnabled ( false ) ;
} ) ;
case RebaseDidAbortCommandType . method :
onIpcCommand ( RebaseDidAbortCommandType , e , ( ) = > this . abort ( context ) ) ;
break ;
case RebaseDidStartCommandType . method :
onIpcCommand ( RebaseDidStartCommandType , e , async ( ) = > {
await this . rebase ( document , panel , disposable ) ;
} ) ;
case RebaseDidDisableCommandType . method :
onIpcCommand ( RebaseDidDisableCommandType , e , ( ) = > this . disable ( context ) ) ;
break ;
case RebaseDidAbortCommandType . method :
onIpcCommand ( RebaseDidAbortCommandType , e , async ( ) = > {
await this . abort ( document , panel , disposable ) ;
} ) ;
case RebaseDidStartCommandType . method :
onIpcCommand ( RebaseDidStartCommandType , e , ( ) = > this . rebase ( context ) ) ;
break ;
case RebaseDidSwitchCommandType . method :
onIpcCommand ( RebaseDidSwitchCommandType , e , ( ) = > this . switch ( context ) ) ;
break ;
case RebaseDidChangeEntryCommandType . method :
onIpcCommand ( RebaseDidChangeEntryCommandType , e , async params = > {
const entries = this . parseEntries ( document ) ;
const entries = parseRebaseTodoEntries ( context . document ) ;
const entry = entries . find ( e = > e . ref === params . ref ) ;
if ( entry == null ) return ;
const start = document . positionAt ( entry . index ) ;
const range = document . validateRange (
const start = context . document . positionAt ( entry . index ) ;
const range = context . document . validateRange (
new Range ( new Position ( start . line , 0 ) , new Position ( start . line , Number . MAX_SAFE_INTEGER ) ) ,
) ;
// Fake the new set of entries, so we can ensure that the last entry isn't a squash/fixup
let action = params . action ;
const edit = new WorkspaceEdit ( ) ;
// Fake the new set of entries, to check if last entry is a squash/fixup
const newEntries = [ . . . entries ] ;
newEntries . splice ( entries . indexOf ( entry ) , 1 , {
. . . entry ,
@ -353,29 +330,25 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
}
}
const edit = new WorkspaceEdit ( ) ;
let action = params . action ;
// Ensure that the last entry isn't a squash/fixup
if ( squashing ) {
const lastEntry = newEntries [ newEntries . length - 1 ] ;
if ( entry . ref === lastEntry . ref ) {
action = 'pick' ;
} else {
const start = document . positionAt ( lastEntry . index ) ;
const range = document . validateRange (
const start = context . document . positionAt ( lastEntry . index ) ;
const range = context . document . validateRange (
new Range (
new Position ( start . line , 0 ) ,
new Position ( start . line , Number . MAX_SAFE_INTEGER ) ,
) ,
) ;
edit . replace ( document . uri , range , ` pick ${ lastEntry . ref } ${ lastEntry . message } ` ) ;
edit . replace ( context . document . uri , range , ` pick ${ lastEntry . ref } ${ lastEntry . message } ` ) ;
}
}
edit . replace ( document . uri , range , ` ${ action } ${ entry . ref } ${ entry . message } ` ) ;
edit . replace ( context . document . uri , range , ` ${ action } ${ entry . ref } ${ entry . message } ` ) ;
await workspace . applyEdit ( edit ) ;
} ) ;
@ -383,7 +356,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
case RebaseDidMoveEntryCommandType . method :
onIpcCommand ( RebaseDidMoveEntryCommandType , e , async params = > {
const entries = this . parseEntries ( document ) ;
const entries = parseRebaseTodoEntries ( context . document ) ;
const entry = entries . find ( e = > e . ref === params . ref ) ;
if ( entry == null ) return ;
@ -404,13 +377,13 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
}
const newEntry = entries [ newIndex ] ;
let newLine = document . positionAt ( newEntry . index ) . line ;
let newLine = context . document . positionAt ( newEntry . index ) . line ;
if ( newIndex < index ) {
newLine ++ ;
}
const start = document . positionAt ( entry . index ) ;
const range = document . validateRange (
const start = context . document . positionAt ( entry . index ) ;
const range = context . document . validateRange (
new Range ( new Position ( start . line , 0 ) , new Position ( start . line + 1 , 0 ) ) ,
) ;
@ -441,20 +414,24 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
if ( entry . ref === lastEntry . ref ) {
action = 'pick' ;
} else {
const start = document . positionAt ( lastEntry . index ) ;
const range = document . validateRange (
const start = context . document . positionAt ( lastEntry . index ) ;
const range = context . document . validateRange (
new Range (
new Position ( start . line , 0 ) ,
new Position ( start . line , Number . MAX_SAFE_INTEGER ) ,
) ,
) ;
edit . replace ( document . uri , range , ` pick ${ lastEntry . ref } ${ lastEntry . message } ` ) ;
edit . replace ( context . document . uri , range , ` pick ${ lastEntry . ref } ${ lastEntry . message } ` ) ;
}
}
edit . delete ( document . uri , range ) ;
edit . insert ( document . uri , new Position ( newLine , 0 ) , ` ${ action } ${ entry . ref } ${ entry . message } \ n ` ) ;
edit . delete ( context . document . uri , range ) ;
edit . insert (
context . document . uri ,
new Position ( newLine , 0 ) ,
` ${ action } ${ entry . ref } ${ entry . message } \ n ` ,
) ;
await workspace . applyEdit ( edit ) ;
} ) ;
@ -463,35 +440,58 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
}
}
private async abort ( document : TextDocument , panel : WebviewPanel , disposable : Disposable ) {
private async abort ( context : RebaseEditorContext ) {
context . abortOnClose = false ;
// Avoid triggering events by disposing them first
disposable . dispose ( ) ;
context . dispose ( ) ;
// Delete the contents to abort the rebase
const edit = new WorkspaceEdit ( ) ;
edit . replace ( document . uri , new Range ( 0 , 0 , document . lineCount , 0 ) , '' ) ;
edit . replace ( context . document . uri , new Range ( 0 , 0 , context . document . lineCount , 0 ) , '' ) ;
await workspace . applyEdit ( edit ) ;
await document . save ( ) ;
panel . dispose ( ) ;
await context . document . save ( ) ;
context . panel . dispose ( ) ;
}
private async disable ( context : RebaseEditorContext ) {
await this . abort ( context ) ;
await this . setEnabled ( false ) ;
}
private async rebase ( document : TextDocument , panel : WebviewPanel , disposable : Disposable ) {
private async rebase ( context : RebaseEditorContext ) {
context . abortOnClose = false ;
// Avoid triggering events by disposing them first
disposable . dispose ( ) ;
context . dispose ( ) ;
await context . document . save ( ) ;
context . panel . dispose ( ) ;
}
private switch ( context : RebaseEditorContext ) {
context . abortOnClose = false ;
void Messages . showRebaseSwitchToTextWarningMessage ( ) ;
await document . save ( ) ;
panel . dispose ( ) ;
// Open the text version of the document
void commands . executeCommand ( BuiltInCommands . Open , context . document . uri , {
override : false ,
preview : false ,
} ) ;
}
private async getHtml ( webview : Webview , document : TextDocument ) : Promise < string > {
private async getHtml ( context : RebaseEditorContex t) : Promise < string > {
const uri = Uri . joinPath ( Container . context . extensionUri , 'dist' , 'webviews' , 'rebase.html' ) ;
const content = new TextDecoder ( 'utf8' ) . decode ( await workspace . fs . readFile ( uri ) ) ;
let html = content
. replace ( /#{cspSource}/g , webview . cspSource )
. replace ( /#{root}/g , webview . asWebviewUri ( Container . context . extensionUri ) . toString ( ) ) ;
. replace ( /#{cspSource}/g , context . panel . webview. cspSource )
. replace ( /#{root}/g , context . panel . webview. asWebviewUri ( Container . context . extensionUri ) . toString ( ) ) ;
const bootstrap = await this . parseState ( documen t) ;
const bootstrap = await this . parseState ( contex t) ;
html = html . replace (
/#{endOfBody}/i ,
@ -502,4 +502,132 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
return html ;
}
private async getRepository ( document : TextDocument ) : Promise < Repository > {
const repo = await Container . git . getRepository ( Uri . joinPath ( document . uri , '..' , '..' , '..' ) ) ;
if ( repo == null ) {
// eslint-disable-next-line no-debugger
debugger ;
}
return repo ! ;
}
}
async function parseRebaseTodo (
contents : string | { entries : RebaseEntry [ ] ; onto : string } ,
repo : Repository ,
branch : string | undefined ,
) : Promise < Omit < RebaseState , ' rebasing ' > > {
let onto : string ;
let entries ;
if ( typeof contents === 'string' ) {
entries = parseRebaseTodoEntries ( contents ) ;
[ , , , onto ] = rebaseRegex . exec ( contents ) ? ? [ '' , '' , '' ] ;
} else {
( { entries , onto } = contents ) ;
}
const authors = new Map < string , Author > ( ) ;
const commits : Commit [ ] = [ ] ;
const log = await repo . searchForCommits ( {
pattern : ` ${ onto ? ` #: ${ onto } ` : '' } ${ Iterables . join (
Iterables . map ( entries , e = > ` #: ${ e . ref } ` ) ,
' ' ,
) } ` ,
} ) ;
const foundCommits = log != null ? [ . . . log . commits . values ( ) ] : [ ] ;
const ontoCommit = onto ? foundCommits . find ( c = > c . ref . startsWith ( onto ) ) : undefined ;
if ( ontoCommit != null ) {
if ( ! authors . has ( ontoCommit . author ) ) {
authors . set ( ontoCommit . author , {
author : ontoCommit.author ,
avatarUrl : (
await ontoCommit . getAvatarUri ( { defaultStyle : Container.config.defaultGravatarsStyle } )
) . toString ( true ) ,
email : ontoCommit.email ,
} ) ;
}
commits . push ( {
ref : ontoCommit.ref ,
author : ontoCommit.author ,
date : ontoCommit.formatDate ( Container . config . defaultDateFormat ) ,
dateFromNow : ontoCommit.formatDateFromNow ( ) ,
message : ontoCommit.message || 'root' ,
} ) ;
}
for ( const entry of entries ) {
const commit = foundCommits . find ( c = > c . ref . startsWith ( entry . ref ) ) ;
if ( commit == null ) continue ;
// If the onto commit is contained in the list of commits, remove it and clear the 'onto' value — See #1201
if ( commit . ref === ontoCommit ? . ref ) {
commits . splice ( 0 , 1 ) ;
onto = '' ;
}
if ( ! authors . has ( commit . author ) ) {
authors . set ( commit . author , {
author : commit.author ,
avatarUrl : (
await commit . getAvatarUri ( { defaultStyle : Container.config.defaultGravatarsStyle } )
) . toString ( true ) ,
email : commit.email ,
} ) ;
}
commits . push ( {
ref : commit.ref ,
author : commit.author ,
date : commit.formatDate ( Container . config . defaultDateFormat ) ,
dateFromNow : commit.formatDateFromNow ( ) ,
message : commit.message ,
} ) ;
}
return {
branch : branch ? ? '' ,
onto : onto ,
entries : entries ,
authors : [ . . . authors . values ( ) ] ,
commits : commits ,
commands : {
// eslint-disable-next-line no-template-curly-in-string
commit : ShowQuickCommitCommand.getMarkdownCommandArgs ( '${commit}' , repo . path ) ,
} ,
} ;
}
function parseRebaseTodoEntries ( contents : string ) : RebaseEntry [ ] ;
function parseRebaseTodoEntries ( document : TextDocument ) : RebaseEntry [ ] ;
function parseRebaseTodoEntries ( contentsOrDocument : string | TextDocument ) : RebaseEntry [ ] {
const contents = typeof contentsOrDocument === 'string' ? contentsOrDocument : contentsOrDocument.getText ( ) ;
const entries : RebaseEntry [ ] = [ ] ;
let match ;
let action ;
let ref ;
let message ;
do {
match = rebaseCommandsRegex . exec ( contents ) ;
if ( match == null ) break ;
[ , action , ref , message ] = match ;
entries . push ( {
index : match.index ,
action : rebaseActionsMap.get ( action ) ? ? 'pick' ,
// Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869
ref : ` ${ ref } ` . substr ( 1 ) ,
// Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869
message : message == null || message . length === 0 ? '' : ` ${ message } ` . substr ( 1 ) ,
} ) ;
} while ( true ) ;
return entries . reverse ( ) ;
}