import * as vscode from 'vscode' ; import { SessionRepository , getSession , SessionContent , uploadSession , duplicateSession , checkSession } from './SessionRepository' ; import * as path from 'path' ; import { toFuzzyRelativeTime } from '../utils' ; import { SessionConfiguration , saveConfiguration , SessionMode , toSessionConfiguration , CONFIGURATION_FILE } from './SessionConfiguration' ; import { PDDL_PLANNER , EXECUTABLE_OR_SERVICE } from '../configuration/configuration' ; import { exists } from '../util/workspaceFs' ; /**
 * Command for cloning a session to the local storage.
 * Arguments: sessionId?, localWorkspaceUri?
 */ export const SESSION_COMMAND_LOAD = 'pddl.planning.domains.session.load' ; /**
 * Command for downloading the latest version of the session to the local storage.
 */ export const SESSION_COMMAND_CHECKOUT = 'pddl.planning.domains.session.checkout' ; export const SESSION_COMMAND_REFRESH_ALL = 'pddl.planning.domains.session.refresh_all' ; enum ChangedResourceState { New , Deleted , Dirty } /** Handles the version control for session files. */ export class SessionSourceControl implements vscode . Disposable { private sessionScm : vscode . SourceControl ; private changedResources : vscode . SourceControlResourceGroup ; private sessionRepository : SessionRepository ; /** This represents the most recent session version on the server. */ private latestSessionVersionDate : number = Number . MIN_VALUE ; private _onRepositoryChange = new vscode . EventEmitter < SessionContent > ( ) ; private timeout ? : NodeJS . Timer ; private session ! : SessionContent ; private isRefreshing = false ; constructor ( context , private readonly workspaceFolder , session , overwrite ) { this . sessionScm = vscode . scm . createSourceControl ( 'planningDomainsSession' , 'Session #' + session . getHash ( ) , workspaceFolder . uri ) ; this . changedResources = this . sessionScm . createResourceGroup ( 'workingTree' , 'Changes' ) ; this . sessionRepository = new SessionRepository ( workspaceFolder , session ) ; this . sessionScm . quickDiffProvider = this . sessionRepository ; this . sessionScm . inputBox . placeholder = session . canCommit ( ) ? '' : 'Read-only session!' ; this . sessionScm . inputBox . visible = false ; const fileSystemWatcher = vscode . workspace . createFileSystemWatcher ( new vscode . RelativePattern ( workspaceFolder , "*.*" ) ) ; fileSystemWatcher . onDidChange ( uri => this . onResourceChange ( uri ) , context . subscriptions ) ; fileSystemWatcher . onDidCreate ( uri => this . onResourceChange ( uri ) , context . subscriptions ) ; fileSystemWatcher . onDidDelete ( uri => this . onResourceChange ( uri ) , context . subscriptions ) ; context . subscriptions . push ( this . sessionScm ) ; context . subscriptions . push ( fileSystemWatcher ) ; this . setSession ( session , overwrite ) . then ( ( ) => this . refresh ( ) ) . catch ( err => vscode . window . showErrorMessage ( err ) ) ; } static async fromSessionId ( id , mode , context , workspaceFolder , overwrite ) { const sessionConfiguration = toSessionConfiguration ( id , mode ) ; return await SessionSourceControl . fromConfiguration ( sessionConfiguration , workspaceFolder , context , overwrite ) ; } static async fromConfiguration ( configuration , workspaceFolder , context , overwrite ) { const session = configuration . versionDate ? SessionContent . from ( configuration , configuration . versionDate ) : await getSession ( configuration ) ; return new SessionSourceControl ( context , workspaceFolder , session , overwrite ) ; } refreshStatusBar ( ) { let title = "$(repo) " ; if ( this . session . canCommit ( ) ) { title += '$(pencil)' ; } title += toFuzzyRelativeTime ( this . session . versionDate ) ; if ( this . session . versionDate < this . latestSessionVersionDate ) { title = `$(cloud-download) ${ title } (stale)` ; } if ( this . getSourceControl ( ) . count ) { title += " $(primitive-dot)" ; } const refreshTitle = `$(sync${ this . isRefreshing ? '~spin' : '' })` ; this . sessionScm . statusBarCommands = [ { "command" : SESSION_COMMAND_CHECKOUT , "arguments" : [ this ] , "title" : title , "tooltip" : "Fetch latest version of the session files." } , { "command" : SESSION_COMMAND_REFRESH_ALL , "title" : refreshTitle , "tooltip" : "Refresh status of all Planning.domains sessions" } ] ; } async commitAll ( ) { if ( ! this . changedResources . resourceStates . length ) { vscode . window . showErrorMessage ( "There is nothing to commit." ) ; return ; } if ( ! this . session . canCommit ( ) ) { vscode . window . showErrorMessage ( "This session was open as read-only! Save it using the Clone command." ) ; return ; } await this . refresh ( ) ; if ( this . session . versionDate < this . latestSessionVersionDate ) { vscode . window . showErrorMessage ( "Checkout the latest session version before committing your changes." ) ; } else { try { await vscode . window . withProgress ( { location : vscode . ProgressLocation . SourceControl , title : 'Committing...' } , async ( progress ) => { const currentSession = await this . getCurrentSession ( ) ; progress . report ( { message : 'Latest session refreshed.' , increment : 33 } ) ; const newSessionContent = await uploadSession ( currentSession ) ; progress . report ( { message : 'Session committed.' , increment : 33 } ) ; await this . setSession ( newSessionContent , false ) ; progress . report ( { message : 'Session re-downloaded.' , increment : 34 } ) ; this . sessionScm . inputBox . value = '' ; } ) ; } catch ( ex ) { vscode . window . showErrorMessage ( "Cannot commit changes to Planning.Domains session. " + ( ex as Error ) . message ) ; } } } /** Constructs a session instance from the current set of files in the workspace. */ async getCurrentSession ( ) { const newFiles = new Map < string , string > ( ) ; const localFileNames = await this . getLocalFileNames ( ) ; for ( const fileName of localFileNames ) { newFiles . set ( fileName , await this . getFileContent ( fileName ) ) ; } return new SessionContent ( this . session . hash , this . session . writeHash , this . session . versionDate , newFiles , new Map ( ) ) ; } /**
     * Throws away all local changes and resets all files to the checked out version of the repository.
     */ async resetFilesToCheckedOutVersion ( ) { if ( this . getLocalModifiedResources ( ) && this . getLocalModifiedResources ( ) . length > 0 ) { const discardAnswer = "Discard" ; const answer = await vscode . window . showWarningMessage ( `Discard ${ this . getLocalModifiedResources ( ) . length } changes?` , { modal : true } , discardAnswer , "Cancel" ) ; if ( answer !== discardAnswer ) { return ; } } this . session . files . forEach ( ( fileContent , fileName ) => this . resetFile ( fileName , fileContent ) ) ; } /** Resets the given local file content to the checked-out version. */ private async resetFile ( fileName , fileContent ) { const fileUri = this . sessionRepository . createLocalResourceUri ( fileName ) ; await vscode . workspace . fs . writeFile ( fileUri , Buffer . from ( fileContent , "utf-8" ) ) ; } private async getFileContent ( fileName ) { const fileUri = this . sessionRepository . createLocalResourceUri ( fileName ) ; const document = await vscode . workspace . openTextDocument ( fileUri ) ; return document . getText ( ) ; } async tryCheckout ( newVersion ) { if ( ! Number . isFinite ( this . latestSessionVersionDate ) ) { return ; } if ( newVersion === undefined ) { const allVersions = [ ... new Set ( [ this . session . versionDate , this . latestSessionVersionDate ] ) ] . map ( ver => new VersionQuickPickItem ( ver , ver === this . session . versionDate ) ) ; const newVersionPick = await vscode . window . showQuickPick ( allVersions , { canPickMany : false , placeHolder : 'Select a version...' } ) ; if ( newVersionPick ) { newVersion = newVersionPick . version ; } else { return ; } } if ( newVersion === this . session . versionDate ) { return ; } const localUntrackedResources = this . getLocalUntrackedResources ( ) ; if ( this . changedResources . resourceStates . length - localUntrackedResources . length ) { vscode . window . showErrorMessage ( `There is one or more changed resources. Discard your local changes before checking out another version.` , { modal : true } ) ; } else { try { const newSession = await getSession ( this . session ) ; const conflictingUntrackedResources = localUntrackedResources . filter ( resource => newSession . files . has ( path . basename ( resource . resourceUri . fsPath ) ) ) ; if ( conflictingUntrackedResources . length > 0 ) { const conflictingUntrackedResourceNames = conflictingUntrackedResources . map ( resource => this . toOpenFileNotificationLink ( resource . resourceUri ) ) . join ( ", " ) ; vscode . window . showErrorMessage ( `Merge conflict: delete/rename your local version of following session file(s): ${ conflictingUntrackedResourceNames } before checking out the session content.` , { modal : false } ) ; } else { await this . setSession ( newSession , true ) ; } } catch ( ex ) { vscode . window . showErrorMessage ( ( ex as Error ) . message ?? ex ) ; } } } private getLocalModifiedResources ( ) { return this . changedResources . resourceStates . filter ( resource => ! resource . decorations ?. faded ) ; } private getLocalUntrackedResources ( ) { return this . changedResources . resourceStates . filter ( resource => resource . decorations ?. faded ) ; } toOpenFileNotificationLink ( resourceUri ) { return `[${ path . basename ( resourceUri . fsPath ) }](${ this . toOpenFileCommand ( resourceUri ) })` ; } toOpenFileCommand ( resourceUri ) { return encodeURI ( 'command:vscode.open?' + JSON . stringify ( [ resourceUri . toString ( ) ] ) ) ; } private async setSession ( newSession , overwrite ) { if ( newSession . versionDate > this . latestSessionVersionDate ) { this . latestSessionVersionDate = newSession . versionDate ; } this . session = newSession ; if ( overwrite ) { this . resetFilesToCheckedOutVersion ( ) ; } this . _onRepositoryChange . fire ( this . session ) ; await this . updateChangedGroup ( ) ; await this . saveWorkspaceSettings ( ) ; await this . saveCurrentConfiguration ( ) ; } getSession ( ) { return this . session ; } getWorkspaceFolder ( ) { return this . workspaceFolder ; } getSourceControl ( ) { return this . sessionScm ; } getRepository ( ) { return this . sessionRepository ; } /** save configuration for later VS Code sessions */ private async saveCurrentConfiguration ( ) { return await saveConfiguration ( this . workspaceFolder . uri , this . session ) ; } private static readonly SOLVER_PLUGIN = "solver" ; /** Saves setting of eligible session plugins to workspace configuration. */ async saveWorkspaceSettings ( ) { if ( this . session . plugins . has ( SessionSourceControl . SOLVER_PLUGIN ) ) { const solver = this . session . plugins . get ( SessionSourceControl . SOLVER_PLUGIN ) ; if ( solver ?. url !== "/plugins/solver.js" ) { return ; } const solverUrl = solver . settings [ "url" ] ; await vscode . workspace . getConfiguration ( PDDL_PLANNER , this . workspaceFolder . uri ) . update ( EXECUTABLE_OR_SERVICE , solverUrl + "/solve" , vscode . ConfigurationTarget . WorkspaceFolder ) ; } } /**
     * Refresh is used when the information on the server may have changed.
     * For example another user updates the session files online.
     */ async refresh ( ) { this . setRefreshing ( true ) ; try { const sessionCheckResult = await checkSession ( this . session . getHash ( ) ) ; this . latestSessionVersionDate = sessionCheckResult [ 1 ] ; } catch ( ex ) { } await this . updateChangedGroup ( ) ; this . setRefreshing ( false ) ; } get onRepositoryChange ( ) : vscode . Event < SessionContent > { return this . _onRepositoryChange . event ; } onResourceChange ( _uri ) { if ( this . timeout ) { clearTimeout ( this . timeout ) ; } this . timeout = setTimeout ( ( ) => this . tryUpdateChangedGroup ( ) , 500 ) ; } async tryUpdateChangedGroup ( ) { try { await this . updateChangedGroup ( ) ; } catch ( ex ) { vscode . window . showErrorMessage ( ( ex as Error ) . message ?? ex ) ; } } /** Refreshes the list of changed resources under this version control. */ async updateChangedGroup ( ) { const changedResources = [ ] ; const uris = this . provideSourceControlledResources ( ) ; const otherFolderFiles = await this . getLocalFileNames ( ) ; for ( const uri of uris ) { let state ; if ( await exists ( uri ) ) { const document = await vscode . workspace . openTextDocument ( uri ) ; if ( this . isDirty ( document ) ) { state = ChangedResourceState . Dirty ; } otherFolderFiles . splice ( otherFolderFiles . indexOf ( path . basename ( uri . fsPath ) ) , 1 ) ; } else { state = ChangedResourceState . Deleted ; } if ( state ) { const resourceState = this . toSourceControlResourceState ( uri , state ) ; changedResources . push ( resourceState ) ; } } for ( const otherFile of otherFolderFiles ) { const resourceUri = vscode . Uri . joinPath ( this . getWorkspaceFolder ( ) . uri , otherFile ) ; const fileStats = await vscode . workspace . fs . stat ( resourceUri ) ; if ( fileStats . type === vscode . FileType . Directory ) { continue ; } const resourceState = this . toSourceControlResourceState ( resourceUri , ChangedResourceState . New ) ; changedResources . push ( resourceState ) ; } this . changedResources . resourceStates = changedResources ; this . sessionScm . count = this . changedResources . resourceStates . length ; this . refreshStatusBar ( ) ; } /**
     * Enumerates the resources under source control.
     */ provideSourceControlledResources ( ) { return [ ... this . session . files . keys ( ) ] . map ( fileName => this . sessionRepository . createLocalResourceUri ( fileName ) ) ; } async getLocalFileNames ( ) { const children = await vscode . workspace . fs . readDirectory ( this . getWorkspaceFolder ( ) . uri ) ; return children . filter ( fileEnt => fileEnt [ 1 ] === vscode . FileType . File ) . map ( fileEnt => fileEnt [ 0 ] ) . filter ( fileName => fileName !== CONFIGURATION_FILE ) . filter ( fileName => ! fileName . endsWith ( '.code-workspace' ) ) ; } /** Determines whether the resource is different, regardless of line endings. */ isDirty ( doc ) { const originalText = this . session . files . get ( path . basename ( doc . uri . fsPath ) ) ; return originalText ?. replace ( '\r' , '' ) !== doc . getText ( ) . replace ( '\r' , '' ) ; } setRefreshing ( isRefreshing ) { this . isRefreshing = isRefreshing ; this . refreshStatusBar ( ) ; } toSourceControlResourceState ( docUri , state ) { const repositoryUri = this . sessionRepository . provideOriginalResource ( docUri , new vscode . CancellationTokenSource ( ) . token ) ; const fileName = path . basename ( docUri . fsPath ) ; let command ; let tooltip ; switch ( state ) { case ChangedResourceState . Dirty : command = { title : "Show changes" , command : "vscode.diff" , arguments : [ repositoryUri , docUri , `Session#${ this . session . hash }:${ fileName } ↔ Local changes` ] , tooltip : "Diff your changes" } ; tooltip = '' ; break ; case ChangedResourceState . New : command = { title : "Show document" , command : 'vscode.open' , arguments : [ docUri ] } ; tooltip = 'File will be added to the session.' ; break ; case ChangedResourceState . Deleted : command = { title : "Show content of the removed document" , command : 'vscode.open' , arguments : [ repositoryUri ] } ; tooltip = 'File was locally deleted and will be removed from the session.' ; break ; default : throw new Error ( "Unexpected resource state: " + state ) ; } const resourceState = { resourceUri : docUri , command : command , decorations : { strikeThrough : state === ChangedResourceState . Deleted , faded : state === ChangedResourceState . New , tooltip : tooltip } } ; return resourceState ; } param targetLocalFolder param downloadIt returns async duplicateAsWritable ( targetLocalFolder , downloadIt ) { try { const currentSession = await this . getCurrentSession ( ) ; const newSessionWriteHash = await duplicateSession ( currentSession ) ; if ( downloadIt ) { vscode . commands . executeCommand ( SESSION_COMMAND_LOAD , newSessionWriteHash , targetLocalFolder ) ; } return newSessionWriteHash ; } catch ( ex ) { throw new Error ( "Failed creating duplicate session in Planning.Domains. " + ( ( ex as Error ) . message ?? ex ) ) ; } } dispose ( ) { this . _onRepositoryChange . dispose ( ) ; this . sessionScm . dispose ( ) ; } } class VersionQuickPickItem implements vscode . QuickPickItem { constructor ( public readonly version , public readonly picked ) { } get label ( ) : string { return `Version from ${ toFuzzyRelativeTime ( this . version ) }` ; } get description ( ) : string { return this . picked ? ' (currently checked-out)' : '' ; } get alwaysShow ( ) : boolean { return this . picked ; } }