diff --git a/.vscode/settings.json b/.vscode/settings.json index d1e3f07..9d7c9df 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,15 @@ // Place your settings in this file to overwrite default and user settings. { - "files.exclude": { - "out": false // set this to true to hide the "out" folder with the compiled JS files - }, - "search.exclude": { - "out": true // set this to false to include "out" folder in search results - }, - // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off", - "javascript.format.enable": false, - "typescript.locale": "en", - "gitlens.codeLens.enabled": false -} \ No newline at end of file + "files.exclude": { + "out": false // set this to true to hide the "out" folder with the compiled JS files + }, + "search.exclude": { + "out": true // set this to false to include "out" folder in search results + }, + // Turn off tsc task auto detection since we have the necessary tasks as npm scripts + "typescript.tsc.autoDetect": "off", + "javascript.format.enable": false, + "typescript.locale": "en", + "gitlens.codeLens.enabled": false, + "editor.fontLigatures": true +} diff --git a/README.md b/README.md index 6cd6845..ea84d0e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # ABAP remote filesystem for visual studio code -This extension allows editing and activation of ABAP code on your server directly in Visual studio code. -Doesn't allow to create objects yet, but does read/save/activate several object types +This extension allows editing and activation of ABAP code on your server directly in Visual studio code, including transport assignment and creation (if your system supports it). **Unless your system is very modern, write support will require you to install [this extension](https://github.com/marcellourbani/abapfs_extensions)** in your dev server to enable locking files -**WRITE SUPPORT IS EXPERIMANTAL USE AT YOUR OWN RISK** +**THIS SOFTWARE IS IN BETA TEST, USE AT YOUR OWN RISK** ![anim](https://user-images.githubusercontent.com/2453277/47482169-ae0cc300-d82d-11e8-8d19-f55dd877c166.gif) ![image](https://user-images.githubusercontent.com/2453277/47466602-dd99dc00-d7e9-11e8-97ed-28e23dfd8f90.png) @@ -21,16 +20,16 @@ The complete list of editable objects depends on your installation, on my local - programs/includes - function groups - classes -- transformations +- transformations (except creation) ![anim](https://user-images.githubusercontent.com/2453277/48232926-30a78d80-e3ab-11e8-8a12-00844431f9af.gif) ## setup -Too early to publish as an extension, there's a compiled extension you can run from source or install from the command line with +Will soon be published in the marketplace, in the meanwhile there's a compiled extension you can run from source or install from the command line with ```shell -code --install-extension vscode-abap-remote-fs-0.1.0.vsix +code --install-extension vscode-abap-remote-fs-0.3.0.vsix ``` The compiled file can be either downloaded from for the diff --git a/images/abapfs_icon.png b/images/abapfs_icon.png new file mode 100644 index 0000000..c51f063 Binary files /dev/null and b/images/abapfs_icon.png differ diff --git a/package-lock.json b/package-lock.json index 986ee79..90adfcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "vscode-abap-remote-fs", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1744,6 +1744,11 @@ "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", "dev": true }, + "node-cleanup": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", + "integrity": "sha1-esGavSl+Caf3KnFUXZUbUX5N3iw=" + }, "node.extend": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-1.1.6.tgz", diff --git a/package.json b/package.json index 1417aa7..21878a3 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,10 @@ "name": "vscode-abap-remote-fs", "displayName": "vscode_abap_remote_fs", "description": "Work on your ABAP code straight from the server", - "version": "0.2.0", + "version": "0.3.0", "publisher": "murbani", "license": "MIT", + "icon": "images/abapfs_icon.png", "author": { "email": "marcello.urbani@gmail.com", "name": "Marcello Urbani" @@ -43,12 +44,12 @@ "tsconfig-paths": "^3.7.0", "tslint": "^5.8.0", "typescript": "^2.6.1", - "vsce": "^1.52.0", - "vscode": "^1.1.21" + "vsce": "^1.53.2", + "vscode": "^1.1.26" }, "dependencies": { - "request": "^2.88.0", "event-stream": "3.3.4", + "request": "^2.88.0", "xml2js": "^0.4.19" }, "activationEvents": [ diff --git a/src/adt/AdtConnection.ts b/src/adt/AdtConnection.ts index aa059f4..5ceb086 100644 --- a/src/adt/AdtConnection.ts +++ b/src/adt/AdtConnection.ts @@ -11,17 +11,24 @@ enum ConnStatus { active, failed } +export interface StateRequestor { + needStateFul: boolean +} export class AdtConnection { readonly name: string readonly url: string readonly username: string readonly password: string - //TODO: proper session support - stateful = true + + get stateful() { + for (const r of this._stateRequestors) if (r.needStateFul) return true + return false + } private _csrftoken: string = FETCH_CSRF_TOKEN private _status: ConnStatus = ConnStatus.new private _listeners: Array = [] private _clone?: AdtConnection + private _stateRequestors: Set = new Set() constructor(name: string, url: string, username: string, password: string) { this.name = name @@ -44,12 +51,15 @@ export class AdtConnection { this.username, this.password ) - this._clone.stateful = false } await this._clone.connect() return this._clone } + addStateRequestor(r: StateRequestor) { + this._stateRequestors.add(r) + } + isActive(): boolean { return this._status === ConnStatus.active } @@ -138,6 +148,21 @@ export class AdtConnection { query }) } + + dropSession() { + return this.myrequest( + "/sap/bc/adt/repository/informationsystem/objecttypes", + "GET", + { + headers: { + "x-csrf-token": this._csrftoken, + "X-sap-adt-sessiontype": "", + Accept: "*/*" + } + } + ) + } + connect(): Promise { return this.myrequest( "/sap/bc/adt/repository/informationsystem/objecttypes?maxItemCount=999&name=*&data=usedByProvider" diff --git a/src/adt/AdtExceptions.ts b/src/adt/AdtExceptions.ts index 3875a61..870897b 100644 --- a/src/adt/AdtExceptions.ts +++ b/src/adt/AdtExceptions.ts @@ -1,4 +1,8 @@ -import { parsetoPromise, getFieldAttribute, recxml2js } from "./AdtParserBase" +import { + parseToPromise, + getFieldAttribute, + recxml2js +} from "./parsers/AdtParserBase" import { Response } from "request" const TYPEID = Symbol() @@ -23,7 +27,7 @@ export class AdtException extends Error { } static async fromXml(xml: string): Promise { - const raw: any = await parsetoPromise()(xml) + const raw: any = await parseToPromise()(xml) const root: any = raw["exc:exception"] const namespace = getFieldAttribute("namespace", "id", root) const type = getFieldAttribute("type", "id", root) diff --git a/src/adt/AdtServer.ts b/src/adt/AdtServer.ts index aad23f3..4458efa 100644 --- a/src/adt/AdtServer.ts +++ b/src/adt/AdtServer.ts @@ -2,14 +2,16 @@ import { AdtConnection } from "./AdtConnection" import { Uri, FileSystemError, FileType, commands } from "vscode" import { MetaFolder } from "../fs/MetaFolder" import { AbapObjectNode, AbapNode, isAbapNode } from "../fs/AbapNode" -import { AbapObject, TransportStatus, isAbapObject } from "../abap/AbapObject" +import { AbapObject, TransportStatus, isAbapObject } from "./abap/AbapObject" import { getRemoteList } from "../config" import { selectTransport } from "./AdtTransports" -import { AdtObjectActivator } from "./AdtObjectActivator" +import { AdtObjectActivator } from "./operations/AdtObjectActivator" import { pick } from "../functions" -import { AdtObjectFinder } from "./AdtObjectFinder" -import { AdtObjectCreator } from "./create/AdtObjectCreator" -import { PACKAGE } from "./create/AdtObjectTypes" +import { AdtObjectFinder } from "./operations/AdtObjectFinder" +import { AdtObjectCreator } from "./operations/AdtObjectCreator" +import { PACKAGE } from "./operations/AdtObjectTypes" +import { LockManager } from "./operations/LockManager" +import { AdtException } from "./AdtExceptions" export const ADTBASEURL = "/sap/bc/adt/repository/nodestructure" /** @@ -20,7 +22,7 @@ export const ADTBASEURL = "/sap/bc/adt/repository/nodestructure" const uriParts = (uri: Uri): string[] => uri.path .split("/") - .filter((v, idx, arr) => (idx > 0 && idx < arr.length - 1) || v) //ignore empty at begginning or end + .filter((v, idx, arr) => (idx > 0 && idx < arr.length - 1) || v) //ignore empty at beginning or end /** * centralizes most API accesses * some will be delegated/provided from members or ABAP object nodes @@ -30,7 +32,8 @@ export class AdtServer { private readonly activator: AdtObjectActivator readonly root: MetaFolder readonly objectFinder: AdtObjectFinder - creator: AdtObjectCreator + readonly creator: AdtObjectCreator + readonly lockManager: LockManager private lastRefreshed?: string /** @@ -50,6 +53,7 @@ export class AdtServer { this.creator = new AdtObjectCreator(this) this.activator = new AdtObjectActivator(this.connection) this.objectFinder = new AdtObjectFinder(this.connection) + this.lockManager = new LockManager(this.connection) this.connection .connect() .then(pick("body")) @@ -109,32 +113,40 @@ export class AdtServer { if (!isAbapNode(file)) throw FileSystemError.NoPermissions("Can only save source code") - await file.abapObject.lock(this.connection) - if (file.abapObject.transport === TransportStatus.REQUIRED) { + const obj = file.abapObject + //check file is locked + if (!this.lockManager.isLocked(obj)) + throw new AdtException( + "lockNotFound", + `Object not locked ${obj.type} ${obj.name}` + ) + + if (obj.transport === TransportStatus.REQUIRED) { const transport = await selectTransport( - file.abapObject.getContentsUri(this.connection), + obj.getContentsUri(this.connection), "", this.connection ) if (transport) file.abapObject.transport = transport } - await file.abapObject.setContents(this.connection, content) + const lockId = this.lockManager.getLockId(obj) + await obj.setContents(this.connection, content, lockId) - await file.abapObject.unlock(this.connection) await file.stat(this.connection) + await this.lockManager.unlock(obj) //might have a race condition with user changing editor... commands.executeCommand("setContext", "abapfs:objectInactive", true) } /** * converts vscode URI to ADT URI - * @see findNodeHierarcy for more details + * @see findNodeHierarchy for more details * * @param uri vscode URI */ findNode(uri: Uri): AbapNode { - return this.findNodeHierarcy(uri)[0] + return this.findNodeHierarchy(uri)[0] } /** @@ -145,7 +157,7 @@ export class AdtServer { * @abstract visual studio paths are hierarchic, adt ones aren't * so we need a way to translate the hierarchic ones to the original ones * this file is concerned with telling whether a path is a real ADT one or one from vscode - * /sap/bc/adt/repository/nodestructure (with ampty query) is the root of both + * /sap/bc/adt/repository/nodestructure (with empty query) is the root of both * also, several objects have namespaces. * Class /foo/bar of package /foo/baz in code will have a path like * /sap/bc/adt/repository/nodestructure/foo/baz/foo/bar @@ -155,7 +167,7 @@ export class AdtServer { * * @param uri VSCode URI */ - findNodeHierarcy(uri: Uri): AbapNode[] { + findNodeHierarchy(uri: Uri): AbapNode[] { const parts = uriParts(uri) return parts.reduce( (current: AbapNode[], name) => { @@ -185,7 +197,7 @@ export class AdtServer { for (const part of parts) { let next: AbapNode | undefined = node.getChild(part) if (!next && refreshable) { - //refreshable will tipically be the current node or its first abap parent (usually a package) + //refreshable will typically be the current node or its first abap parent (usually a package) await refreshable.refresh(this.connection) next = node.getChild(part) } @@ -259,3 +271,10 @@ export const fromUri = (uri: Uri) => { if (uri.scheme === "adt") return getServer(uri.authority) throw FileSystemError.FileNotFound(uri) } +export async function disconnect() { + const promises: Promise[] = [] + for (const server of servers) { + promises.push(server[1].connection.dropSession()) + } + await Promise.all(promises) +} diff --git a/src/adt/AdtTransports.ts b/src/adt/AdtTransports.ts index 1adf9b3..2491e46 100644 --- a/src/adt/AdtTransports.ts +++ b/src/adt/AdtTransports.ts @@ -1,5 +1,5 @@ -import { JSON2AbapXML } from "../abap/JSONToAbapXml" -import { parsetoPromise, getNode, recxml2js } from "./AdtParserBase" +import { JSON2AbapXML } from "./abap/JSONToAbapXml" +import { parseToPromise, getNode, recxml2js } from "./parsers/AdtParserBase" import { mapWith, flat } from "../functions" import { AdtConnection } from "./AdtConnection" import { window, Uri } from "vscode" @@ -84,7 +84,7 @@ export async function getTransportCandidates( }) } ) - const rawdata = await parsetoPromise()(response.body) + const rawdata = await parseToPromise()(response.body) const header = getNode( "asx:abap/asx:values/DATA", mapWith(recxml2js), diff --git a/src/abap/AbapClass.ts b/src/adt/abap/AbapClass.ts similarity index 78% rename from src/abap/AbapClass.ts rename to src/adt/abap/AbapClass.ts index 4c61bc8..4d27492 100644 --- a/src/abap/AbapClass.ts +++ b/src/adt/abap/AbapClass.ts @@ -3,13 +3,13 @@ import { AbapNodeComponentByCategory, AbapMetaData } from "./AbapObject" -import { NodeStructure } from "../adt/AdtNodeStructParser" -import { AdtConnection } from "../adt/AdtConnection" +import { NodeStructure } from "../parsers/AdtNodeStructParser" +import { AdtConnection } from "../AdtConnection" import { FileSystemError } from "vscode" -import { pick, followLink } from "../functions" +import { pick, followLink } from "../../functions" import { aggregateNodes } from "./AbapObjectUtilities" -import { parseClass, firstTextLink } from "../adt/AdtObjectParser" -import { parsetoPromise } from "../adt/AdtParserBase" +import { parseClass, firstTextLink } from "../parsers/AdtObjectParser" +import { parseToPromise } from "../parsers/AdtParserBase" import { ClassIncludeMeta, isClassInclude } from "./AbapClassInclude" interface ClassMetaData extends AbapMetaData { @@ -26,13 +26,14 @@ export class AbapClass extends AbapObject { ) { super(type, name, path, expandable, techName) } + async loadMetadata(connection: AdtConnection): Promise { if (this.name) { const mainUri = this.getUri(connection) const meta = await connection .request(mainUri, "GET") .then(pick("body")) - .then(parsetoPromise()) + .then(parseToPromise()) .then(parseClass) const includes = meta.includes.map(i => { const sourcePath = i.header["abapsource:sourceUri"] @@ -64,6 +65,7 @@ export class AbapClass extends AbapObject { } return this } + async getChildren( connection: AdtConnection ): Promise> { @@ -94,21 +96,6 @@ export class AbapClass extends AbapObject { else ns.nodes.push(node) } - // for (const classInc of parsed.includes) { - // const name = this.name + "." + classInc.header["class:includeType"] - // const node = { - // EXPANDABLE: "", - // OBJECT_NAME: name, - // OBJECT_TYPE: classInc.header["adtcore:type"], - // OBJECT_URI: follow(classInc.header["abapsource:sourceUri"]).path, - // OBJECT_VIT_URI: "", - // TECH_NAME: name - // } - // if (classInc.header["abapsource:sourceUri"] === "source/main") - // ns.nodes.unshift(node) - // else ns.nodes.push(node) - // } - const aggregated = aggregateNodes(ns) for (const cat of aggregated) for (const type of cat.types) diff --git a/src/abap/AbapClassInclude.ts b/src/adt/abap/AbapClassInclude.ts similarity index 92% rename from src/abap/AbapClassInclude.ts rename to src/adt/abap/AbapClassInclude.ts index 87bd8bc..af54242 100644 --- a/src/abap/AbapClassInclude.ts +++ b/src/adt/abap/AbapClassInclude.ts @@ -1,5 +1,5 @@ import { AbapObject, AbapMetaData } from "./AbapObject" -import { AdtConnection } from "../adt/AdtConnection" +import { AdtConnection } from "../AdtConnection" import { AbapClass } from "./AbapClass" import { Uri, FileSystemError } from "vscode" export interface ClassIncludeMeta extends AbapMetaData { @@ -32,6 +32,10 @@ export class AbapClassInclude extends AbapObject { return this.parent || this } + getLockTarget(): AbapObject { + return this.parent || this + } + async loadMetadata(connection: AdtConnection): Promise { if (this.parent) { await this.parent.loadMetadata(connection) diff --git a/src/abap/AbapInclude.ts b/src/adt/abap/AbapInclude.ts similarity index 100% rename from src/abap/AbapInclude.ts rename to src/adt/abap/AbapInclude.ts diff --git a/src/abap/AbapObject.ts b/src/adt/abap/AbapObject.ts similarity index 81% rename from src/abap/AbapObject.ts rename to src/adt/abap/AbapObject.ts index 6986755..7702630 100644 --- a/src/abap/AbapObject.ts +++ b/src/adt/abap/AbapObject.ts @@ -1,15 +1,14 @@ import { Uri, FileSystemError } from "vscode" -import { AdtConnection } from "../adt/AdtConnection" -import { pick, followLink } from "../functions" +import { AdtConnection } from "../AdtConnection" +import { pick, followLink } from "../../functions" import { parseNode, NodeStructure, ObjectNode -} from "../adt/AdtNodeStructParser" -import { parsetoPromise, getNode } from "../adt/AdtParserBase" -import { parseObject, firstTextLink } from "../adt/AdtObjectParser" +} from "../parsers/AdtNodeStructParser" +import { parseToPromise, getNode } from "../parsers/AdtParserBase" +import { parseObject, firstTextLink } from "../parsers/AdtObjectParser" import { aggregateNodes } from "./AbapObjectUtilities" -import { adtLockParser } from "../adt/AdtLockParser" const TYPEID = Symbol() export const XML_EXTENSION = ".XML" @@ -49,7 +48,6 @@ export class AbapObject { readonly techName: string readonly path: string readonly expandable: boolean - lockId?: string transport: TransportStatus | string = TransportStatus.UNKNOWN metaData?: AbapMetaData protected sapguiOnly: boolean @@ -84,7 +82,7 @@ export class AbapObject { return this.name.replace(/\//g, "/") + this.getExtension() } - protected getUri(connection: AdtConnection) { + getUri(connection: AdtConnection) { return Uri.parse("adt://" + connection.name).with({ path: this.path }) @@ -111,10 +109,10 @@ export class AbapObject { const response = await connection.request(uri, "POST", { body: payload }) if (response.body) { //activation error(s?) - const raw = (await parsetoPromise()(response.body)) as any + const raw = (await parseToPromise()(response.body)) as any if (raw && raw["chkl:messages"]) { - const messages = (await parsetoPromise( + const messages = (await parseToPromise( getNode("chkl:messages/msg/shortText/txt") )(response.body)) as string[] @@ -130,40 +128,17 @@ export class AbapObject { followLink(this.getUri(connection), "mainprograms"), "GET" ) - const parsed: any = await parsetoPromise()(response.body) + const parsed: any = await parseToPromise()(response.body) return parsed["adtcore:objectReferences"]["adtcore:objectReference"].map( (link: any) => link["$"] ) } - async lock(connection: AdtConnection) { - this.checkWritable() - let contentUri = this.getContentsUri(connection) - - const response = await connection.request( - contentUri.with({ query: "_action=LOCK&accessMode=MODIFY" }), - "POST", - { headers: { "X-sap-adt-sessiontype": "stateful" } } - ) - const lockRecord = await parsetoPromise(adtLockParser)(response.body) - this.lockId = lockRecord.LOCK_HANDLE - this.transport = - lockRecord.CORRNR || - (lockRecord.IS_LOCAL ? TransportStatus.LOCAL : TransportStatus.REQUIRED) - } - async unlock(connection: AdtConnection) { - this.checkWritable() - if (!this.lockId) return - let contentUri = this.getContentsUri(connection) - await connection.request( - contentUri.with({ - query: `_action=UNLOCK&lockHandle=${encodeURIComponent(this.lockId)}` - }), - "POST" - ) + getLockTarget(): AbapObject { + return this } - protected checkWritable() { + canBeWritten() { if (!this.isLeaf()) throw FileSystemError.FileIsADirectory(this.vsName) if (this.sapguiOnly) throw FileSystemError.FileNotFound( @@ -173,9 +148,10 @@ export class AbapObject { async setContents( connection: AdtConnection, - contents: Uint8Array + contents: Uint8Array, + lockId: string ): Promise { - this.checkWritable() + this.canBeWritten() let contentUri = this.getContentsUri(connection) const trselection = @@ -183,9 +159,7 @@ export class AbapObject { await connection.request( contentUri.with({ - query: `lockHandle=${encodeURIComponent( - this.lockId || "" - )}${trselection}` + query: `lockHandle=${encodeURIComponent(lockId)}${trselection}` }), "PUT", { body: contents } @@ -198,7 +172,7 @@ export class AbapObject { const meta = await connection .request(mainUri, "GET") .then(pick("body")) - .then(parsetoPromise()) + .then(parseToPromise()) .then(parseObject) const link = firstTextLink(meta.links) const sourcePath = link ? link.href : "" @@ -213,6 +187,7 @@ export class AbapObject { } return this } + getContentsUri(connection: AdtConnection): Uri { if (!this.metaData) throw FileSystemError.FileNotFound(this.path) // baseUri = baseUri.with({ path: baseUri.path.replace(/\?.*/, "") }) diff --git a/src/abap/AbapObjectUtilities.ts b/src/adt/abap/AbapObjectUtilities.ts similarity index 94% rename from src/abap/AbapObjectUtilities.ts rename to src/adt/abap/AbapObjectUtilities.ts index 9f7e166..79edfa1 100644 --- a/src/abap/AbapObjectUtilities.ts +++ b/src/adt/abap/AbapObjectUtilities.ts @@ -4,13 +4,13 @@ import { AbapSimpleObject, AbapXmlObject } from "./AbapObject" -import { NodeStructure, ObjectNode } from "../adt/AdtNodeStructParser" -import { selectMap } from "../functions" +import { NodeStructure, ObjectNode } from "../parsers/AdtNodeStructParser" +import { selectMap } from "../../functions" import { AbapProgram } from "./AbapProgram" import { AbapClass } from "./AbapClass" import { AbapInclude } from "./AbapInclude" import { AbapClassInclude } from "./AbapClassInclude" -import { AbapNode, isAbapNode } from "../fs/AbapNode" +import { AbapNode, isAbapNode } from "../../fs/AbapNode" export interface NodePath { path: string diff --git a/src/abap/AbapProgram.ts b/src/adt/abap/AbapProgram.ts similarity index 92% rename from src/abap/AbapProgram.ts rename to src/adt/abap/AbapProgram.ts index 4acd30f..c912423 100644 --- a/src/abap/AbapProgram.ts +++ b/src/adt/abap/AbapProgram.ts @@ -1,5 +1,5 @@ import { AbapObject } from "./AbapObject" -import { NodeStructure } from "../adt/AdtNodeStructParser" +import { NodeStructure } from "../parsers/AdtNodeStructParser" export class AbapProgram extends AbapObject { constructor( diff --git a/src/abap/JSONToAbapXml.ts b/src/adt/abap/JSONToAbapXml.ts similarity index 100% rename from src/abap/JSONToAbapXml.ts rename to src/adt/abap/JSONToAbapXml.ts diff --git a/src/adt/AdtObjectActivator.ts b/src/adt/operations/AdtObjectActivator.ts similarity index 89% rename from src/adt/AdtObjectActivator.ts rename to src/adt/operations/AdtObjectActivator.ts index 2fcbe47..f427410 100644 --- a/src/adt/AdtObjectActivator.ts +++ b/src/adt/operations/AdtObjectActivator.ts @@ -1,9 +1,13 @@ -import { AdtConnection } from "./AdtConnection" +import { AdtConnection } from "../AdtConnection" import { commands, window } from "vscode" import { AbapObject } from "../abap/AbapObject" -import { isAdtException } from "./AdtExceptions" -import { parsetoPromise, getNode, getFieldAttributes } from "./AdtParserBase" -import { mapWith } from "../functions" +import { isAdtException } from "../AdtExceptions" +import { + parseToPromise, + getNode, + getFieldAttributes +} from "../parsers/AdtParserBase" +import { mapWith } from "../../functions" import { isString } from "util" import { JSON2AbapXMLNode } from "../abap/JSONToAbapXml" interface InactiveComponents { @@ -63,7 +67,7 @@ export class AdtObjectActivator { }) if (response.body) { //activation error(s?) - const raw = (await parsetoPromise()(response.body)) as any + const raw = (await parseToPromise()(response.body)) as any if (raw && raw["chkl:messages"]) { const messages = getNode( @@ -73,12 +77,13 @@ export class AdtObjectActivator { return messages[0] } else if (raw && raw["ioc:inactiveObjects"]) { - return getNode( + const components = (getNode( "ioc:inactiveObjects/ioc:entry", mapWith(getNode("ioc:object/ioc:ref")), mapWith(getFieldAttributes()), raw - ) as InactiveComponents[] + ) as InactiveComponents[]).filter(x => x) + return components } } return "" diff --git a/src/adt/create/AdtObjectCreator.ts b/src/adt/operations/AdtObjectCreator.ts similarity index 95% rename from src/adt/create/AdtObjectCreator.ts rename to src/adt/operations/AdtObjectCreator.ts index b811c25..28019e4 100644 --- a/src/adt/create/AdtObjectCreator.ts +++ b/src/adt/operations/AdtObjectCreator.ts @@ -1,5 +1,5 @@ import { Uri, window } from "vscode" -import { parsetoPromise, getNode, recxml2js } from "../AdtParserBase" +import { parseToPromise, getNode, recxml2js } from "../parsers/AdtParserBase" import { mapWith } from "../../functions" import { AdtServer } from "../AdtServer" import { isAbapNode, AbapNode } from "../../fs/AbapNode" @@ -11,7 +11,7 @@ import { PACKAGE } from "./AdtObjectTypes" import { selectTransport } from "../AdtTransports" -import { abapObjectFromNode } from "../../abap/AbapObjectUtilities" +import { abapObjectFromNode } from "../abap/AbapObjectUtilities" interface ValidationMessage { SEVERITY: string @@ -39,7 +39,7 @@ export class AdtObjectCreator { "/sap/bc/adt/repository/typestructure" ) const response = await this.server.connection.request(uri, "POST") - const raw = await parsetoPromise()(response.body) + const raw = await parseToPromise()(response.body) return getNode( "asx:abap/asx:values/DATA/SEU_ADT_OBJECT_TYPE_DESCRIPTOR", mapWith(recxml2js), @@ -66,7 +66,7 @@ export class AdtObjectCreator { private getHierarchy(uri: Uri | undefined): AbapNode[] { if (uri) try { - return this.server.findNodeHierarcy(uri) + return this.server.findNodeHierarchy(uri) } catch (e) {} return [] } @@ -101,7 +101,7 @@ export class AdtObjectCreator { objType.getValidatePath(objDetails) ) const response = await this.server.connection.request(url, "POST") - const rawValidation = await parsetoPromise()(response.body) + const rawValidation = await parseToPromise()(response.body) return getNode( "asx:abap/asx:values/DATA", mapWith(recxml2js), diff --git a/src/adt/AdtObjectFinder.ts b/src/adt/operations/AdtObjectFinder.ts similarity index 93% rename from src/adt/AdtObjectFinder.ts rename to src/adt/operations/AdtObjectFinder.ts index eab3983..ba2b852 100644 --- a/src/adt/AdtObjectFinder.ts +++ b/src/adt/operations/AdtObjectFinder.ts @@ -1,20 +1,20 @@ import { - parsetoPromise, + parseToPromise, getNode, recxml2js, nodeProperties -} from "./AdtParserBase" -import { mapWith, ArrayToMap, pick, sapEscape } from "../functions" -import { AdtConnection } from "./AdtConnection" +} from "../parsers/AdtParserBase" +import { mapWith, ArrayToMap, pick, sapEscape } from "../../functions" +import { AdtConnection } from "../AdtConnection" import { window, QuickPickItem, workspace } from "vscode" import * as vscode from "vscode" -import { getServer } from "./AdtServer" +import { getServer } from "../AdtServer" import { NodePath, findObjectInNode, findMainInclude } from "../abap/AbapObjectUtilities" -import { isAbapNode } from "../fs/AbapNode" +import { isAbapNode } from "../../fs/AbapNode" interface AdtObjectType { "nameditem:name": string @@ -78,7 +78,7 @@ export class AdtObjectFinder { `operation=quickSearch&query=${query}${ot}&maxResults=51` ) const response = await conn.request(uri, "GET") - const raw = await parsetoPromise()(response.body) + const raw = await parseToPromise()(response.body) const results = getNode( "adtcore:objectReferences/adtcore:objectReference", nodeProperties, @@ -96,7 +96,7 @@ export class AdtObjectFinder { const raw = await this.conn .request(uri, "POST") .then(pick("body")) - .then(parsetoPromise()) + .then(parseToPromise()) const objectPath = getNode( "projectexplorer:nodepath/projectexplorer:objectLinkReferences/objectLinkReference", nodeProperties, @@ -200,7 +200,7 @@ export class AdtObjectFinder { return o } async setTypes(source: string) { - const parser = parsetoPromise( + const parser = parseToPromise( getNode("nameditem:namedItemList/nameditem:namedItem", mapWith(recxml2js)) ) const raw = (await parser(source)) as AdtObjectType[] diff --git a/src/adt/create/AdtObjectTypes.ts b/src/adt/operations/AdtObjectTypes.ts similarity index 99% rename from src/adt/create/AdtObjectTypes.ts rename to src/adt/operations/AdtObjectTypes.ts index 9d3ce4c..378aa0b 100644 --- a/src/adt/create/AdtObjectTypes.ts +++ b/src/adt/operations/AdtObjectTypes.ts @@ -1,6 +1,6 @@ import { sapEscape } from "../../functions" import { window, QuickPickItem } from "vscode" -import { ObjectNode } from "../AdtNodeStructParser" +import { ObjectNode } from "../parsers/AdtNodeStructParser" export const PACKAGE = "DEVC/K" diff --git a/src/adt/operations/LockManager.ts b/src/adt/operations/LockManager.ts new file mode 100644 index 0000000..8924fff --- /dev/null +++ b/src/adt/operations/LockManager.ts @@ -0,0 +1,160 @@ +import { AdtConnection, StateRequestor } from "../AdtConnection" +import { AbapObject, TransportStatus } from "../abap/AbapObject" +import { parseToPromise } from "../parsers/AdtParserBase" +import { adtLockParser } from "../parsers/AdtLockParser" + +enum LockStatuses { + LOCKED, + UNLOCKED, + LOCKING, + UNLOCKING +} + +class LockObject { + children: Set = new Set() + listeners: Array<(s: LockStatuses) => void> = [] + private _lockStatus = LockStatuses.UNLOCKED + get lockStatus() { + return this._lockStatus + } + lockId: string = "" + + constructor(public main: AbapObject) {} + + setLockStatus(status: LockStatuses, lockId: string = "") { + this._lockStatus = status + this.lockId = status === LockStatuses.LOCKED ? lockId : "" + const l = this.listeners + this.listeners = [] + l.forEach(x => x(status)) + } + + needLock(child: AbapObject) { + this.children.add(child) + return ( + this.lockStatus === LockStatuses.UNLOCKED || + this.lockStatus === LockStatuses.UNLOCKING + ) + } + + isLocked(child: AbapObject) { + return this.children.has(child) + } + needUnlock(child: AbapObject) { + this.children.delete(child) + return ( + this.children.size === 0 && + (this.lockStatus === LockStatuses.LOCKED || + this.lockStatus === LockStatuses.LOCKING) + ) + } + + waitStatusUpdate() { + const waitUpdate = new Promise(resolve => { + this.listeners.push(resolve) + }) + return waitUpdate + } +} + +export class LockManager implements StateRequestor { + l: Map = new Map() + constructor(private conn: AdtConnection) { + conn.addStateRequestor(this) + } + + private getLockObject(child: AbapObject) { + const lockSubject = child.getLockTarget() + let lockObj = this.l.get(lockSubject) + if (!lockObj) { + lockObj = new LockObject(lockSubject) + this.l.set(lockSubject, lockObj) + } + return lockObj + } + + getLockId(obj: AbapObject): string { + const lockObj = this.getLockObject(obj) + // const lockId = this.locks.get(obj) + if (lockObj.lockId) return lockObj.lockId + throw new Error(`Object ${obj.name} is not locked`) + } + + get needStateFul(): boolean { + return this.lockedObjects.length > 0 + } + + async lock(obj: AbapObject) { + if (!obj.canBeWritten) return + const lockObj = this.getLockObject(obj) + if (!lockObj.needLock(obj)) return + //if unlocking in process, wait for it to finish and then lock + // perhaps we should check the status returned... + if (lockObj.lockStatus === LockStatuses.UNLOCKING) + await lockObj.waitStatusUpdate() + + if (!lockObj.needLock(obj)) return // in case another object triggered before + + lockObj.setLockStatus(LockStatuses.LOCKING) + try { + const uri = lockObj.main + .getUri(this.conn) + .with({ query: "_action=LOCK&accessMode=MODIFY" }) + const response = await this.conn.request(uri, "POST") + const lockRecord = await parseToPromise(adtLockParser)(response.body) + lockObj.setLockStatus(LockStatuses.LOCKED, lockRecord.LOCK_HANDLE) + obj.transport = + lockRecord.CORRNR || + (lockRecord.IS_LOCAL ? TransportStatus.LOCAL : TransportStatus.REQUIRED) + console.log("locked", obj.name) + } catch (e) { + if ( + lockObj.needUnlock(obj) && + lockObj.lockStatus === LockStatuses.LOCKING + ) + lockObj.setLockStatus(LockStatuses.UNLOCKED) + throw e + } + } + + async unlock(obj: AbapObject) { + if (!obj.canBeWritten) return + const lockObj = this.getLockObject(obj) + if (!lockObj.needUnlock(obj)) return + //if locking in process, wait for it to finish and then unlock + // perhaps we should check the status returned... + if (lockObj.lockStatus === LockStatuses.LOCKING) + await lockObj.waitStatusUpdate() + + if (!lockObj.needUnlock(obj)) return // in case another object triggered before + const lockId = lockObj.lockId + + lockObj.setLockStatus(LockStatuses.UNLOCKING) + try { + const uri = obj.getUri(this.conn).with({ + query: `_action=UNLOCK&lockHandle=${encodeURIComponent(lockObj.lockId)}` + }) + await this.conn.request(uri, "POST") + lockObj.setLockStatus(LockStatuses.UNLOCKED) + console.log("unlocked", obj.name) + } catch (e) { + //unlocking failed, restore the original ID + if ( + lockObj.needLock(obj) && + lockObj.lockStatus === LockStatuses.UNLOCKING + ) + lockObj.setLockStatus(LockStatuses.LOCKED, lockId) + } + } + + isLocked(obj: AbapObject) { + const lockObj = this.getLockObject(obj) + return lockObj.isLocked(obj) + } + + get lockedObjects() { + let children: AbapObject[] = [] + this.l.forEach(x => (children = [...children, ...[...x.children]])) + return children + } +} diff --git a/src/adt/AdtLockParser.ts b/src/adt/parsers/AdtLockParser.ts similarity index 73% rename from src/adt/AdtLockParser.ts rename to src/adt/parsers/AdtLockParser.ts index 569989e..1c491bf 100644 --- a/src/adt/AdtLockParser.ts +++ b/src/adt/parsers/AdtLockParser.ts @@ -1,6 +1,6 @@ -import { defaultVal, mapWith } from "../functions" +import { defaultVal, mapWith } from "../../functions" -import { getNode, recxml2js } from "./AdtParserBase" +import { getNode, recxml2js } from "../parsers/AdtParserBase" interface AdtLock { LOCK_HANDLE: string diff --git a/src/adt/AdtNodeStructParser.ts b/src/adt/parsers/AdtNodeStructParser.ts similarity index 91% rename from src/adt/AdtNodeStructParser.ts rename to src/adt/parsers/AdtNodeStructParser.ts index fd86b0b..2449a41 100644 --- a/src/adt/AdtNodeStructParser.ts +++ b/src/adt/parsers/AdtNodeStructParser.ts @@ -1,6 +1,6 @@ -import { getNode, recxml2js, parsetoPromise } from "./AdtParserBase" +import { getNode, recxml2js, parseToPromise } from "./AdtParserBase" -import { mapWith, ArrayToMap, filterComplex, defaultVal } from "../functions" +import { mapWith, ArrayToMap, filterComplex, defaultVal } from "../../functions" import { convertableToString } from "xml2js" export interface ObjectNode { @@ -62,7 +62,7 @@ const ObjectTypeParser: (a: string) => Map = defaultVal( export const parseNode: ( rawpayload: convertableToString -) => Promise = parsetoPromise((payload: any) => { +) => Promise = parseToPromise((payload: any) => { return { nodes: treecontentParser(payload), categories: categoryNodeParser(payload), diff --git a/src/adt/AdtObjectParser.ts b/src/adt/parsers/AdtObjectParser.ts similarity index 100% rename from src/adt/AdtObjectParser.ts rename to src/adt/parsers/AdtObjectParser.ts diff --git a/src/adt/AdtParserBase.ts b/src/adt/parsers/AdtParserBase.ts similarity index 96% rename from src/adt/AdtParserBase.ts rename to src/adt/parsers/AdtParserBase.ts index 4591004..9b59afa 100644 --- a/src/adt/AdtParserBase.ts +++ b/src/adt/parsers/AdtParserBase.ts @@ -1,5 +1,6 @@ +/* cSpell:disable */ import { parseString, convertableToString } from "xml2js" -import { pipe, pick, removeNameSpace, mapWith } from "../functions" +import { pipe, pick, removeNameSpace, mapWith } from "../../functions" import { isArray } from "util" /** @@ -120,7 +121,7 @@ export const getNode = (...args: any[]) => { } return fn(...args) } -export const parsetoPromise = (parser?: (raw: any) => T) => ( +export const parseToPromise = (parser?: (raw: any) => T) => ( xml: convertableToString ): Promise => new Promise(resolve => { diff --git a/src/commands.ts b/src/commands.ts index adfe76e..d268a56 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,18 +2,23 @@ import { AdtConnection } from "./adt/AdtConnection" import { workspace, Uri, window } from "vscode" import { fromUri } from "./adt/AdtServer" import { selectRemote, pickAdtRoot } from "./config" +import { log } from "./logger" export async function connectAdtServer(selector: any) { const connectionID = selector && selector.connection const remote = await selectRemote(connectionID) const connection = AdtConnection.fromRemote(remote) + log(`Connecting to server ${connectionID}`) + await connection.connect() // if connection raises an exception don't mount any folder workspace.updateWorkspaceFolders(0, 0, { uri: Uri.parse("adt://" + remote.name), name: remote.name + "(ABAP)" }) + + log(`Connected to server ${connectionID}`) } export async function activateCurrent(selector: Uri) { diff --git a/src/extension.ts b/src/extension.ts index ebed4d0..68136e0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,47 +1,60 @@ -"use strict"; -import * as vscode from "vscode"; -import { FsProvider } from "./fs/FsProvider"; -import { window, commands } from "vscode"; -import { activeTextEditorChangedListener } from "./listeners"; +"use strict" +import * as vscode from "vscode" +import { FsProvider } from "./fs/FsProvider" +import { window, commands, workspace } from "vscode" +import { + activeTextEditorChangedListener, + documentChangedListener, + documentClosedListener +} from "./listeners" import { connectAdtServer, activateCurrent, searchAdtObject, createAdtObject -} from "./commands"; +} from "./commands" +import { disconnect } from "./adt/AdtServer" +import { log } from "./logger" export function activate(context: vscode.ExtensionContext) { - const abapFS = new FsProvider(); + const abapFS = new FsProvider() + const sub = context.subscriptions //register the filesystem type - context.subscriptions.push( + sub.push( vscode.workspace.registerFileSystemProvider("adt", abapFS, { isCaseSensitive: true }) - ); + ) + + //change document listener, for locking (and possibly validation in future) + sub.push(workspace.onDidChangeTextDocument(documentChangedListener)) + //closed document listener, for locking + sub.push(workspace.onDidCloseTextDocument(documentClosedListener)) - //Editor changed listener - context.subscriptions.push( - window.onDidChangeActiveTextEditor(activeTextEditorChangedListener) - ); + //Editor changed listener, updates context and icons + sub.push(window.onDidChangeActiveTextEditor(activeTextEditorChangedListener)) //connect command - let disposable = commands.registerCommand("abapfs.connect", connectAdtServer); - context.subscriptions.push(disposable); + sub.push(commands.registerCommand("abapfs.connect", connectAdtServer)) //activate command - disposable = commands.registerCommand("abapfs.activate", activateCurrent); - context.subscriptions.push(disposable); + sub.push(commands.registerCommand("abapfs.activate", activateCurrent)) //search command - context.subscriptions.push( - commands.registerCommand("abapfs.search", searchAdtObject) - ); + sub.push(commands.registerCommand("abapfs.search", searchAdtObject)) //create command - context.subscriptions.push( - commands.registerCommand("abapfs.create", createAdtObject) - ); + sub.push(commands.registerCommand("abapfs.create", createAdtObject)) + + log(`Activated,pid=${process.pid}`) } // this method is called when your extension is deactivated -export function deactivate() {} +// it's important to kill these sessions as there might be an open process on the abap side +// most commonly because of locked sources. +// Locks will not be released until either explicitly closed or the session is terminates +// an open session can leave sources locked without any UI able to release them (except SM12 and the like) +export async function deactivate() { + await disconnect() + log(`Deactivated,pid=${process.pid}`) +} diff --git a/src/fs/AbapNode.ts b/src/fs/AbapNode.ts index a89694e..52f6103 100644 --- a/src/fs/AbapNode.ts +++ b/src/fs/AbapNode.ts @@ -1,6 +1,6 @@ import { FileStat, FileType, FileSystemError } from "vscode" -import { aggregateNodes } from "../abap/AbapObjectUtilities" -import { AbapObject, AbapNodeComponentByCategory } from "../abap/AbapObject" +import { aggregateNodes } from "../adt/abap/AbapObjectUtilities" +import { AbapObject, AbapNodeComponentByCategory } from "../adt/abap/AbapObject" import { MetaFolder } from "./MetaFolder" import { AdtConnection } from "../adt/AdtConnection" import { flatMap, pick } from "../functions" diff --git a/src/listeners.ts b/src/listeners.ts index 9335b24..b3dbf1d 100644 --- a/src/listeners.ts +++ b/src/listeners.ts @@ -1,7 +1,52 @@ -import { TextEditor, commands } from "vscode" +import { + TextEditor, + commands, + window, + StatusBarAlignment, + TextDocumentChangeEvent, + TextDocument +} from "vscode" import { fromUri } from "./adt/AdtServer" +const status = window.createStatusBarItem(StatusBarAlignment.Right, 100) + +export async function documentClosedListener(doc: TextDocument) { + const uri = doc.uri + if (uri.scheme === "adt") { + const server = fromUri(uri) + const obj = await server.findAbapObject(uri) + if (server.lockManager.isLocked(obj)) await server.lockManager.unlock(obj) + } +} + +export async function documentChangedListener(event: TextDocumentChangeEvent) { + const uri = event.document.uri + if (uri.scheme === "adt") { + const server = fromUri(uri) + const obj = await server.findAbapObject(uri) + const shouldLock = event.document.isDirty + //no need to lock objects already locked + if (shouldLock !== server.lockManager.isLocked(obj)) { + if (shouldLock) { + try { + await server.lockManager.lock(obj) + } catch (e) { + window.showErrorMessage( + `Object not locked ${obj.type} ${ + obj.name + }.Won't be able to save changes` + ) + } + } else await server.lockManager.unlock(obj) + } + + status.text = `${uri.authority}:${ + server.lockManager.lockedObjects.length + } objects locked` + status.show() + } +} export async function activeTextEditorChangedListener( editor: TextEditor | undefined ) { diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..2b5c968 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,7 @@ +import { window } from "vscode" + +const channel = window.createOutputChannel("ABAPFS") + +export function log(...messages: string[]) { + channel.appendLine(messages.join("")) +}