diff --git a/README.md b/README.md index c825ab4..e0683f1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ABAP remote filesystem for visual studio code -This extension allows editing of ABAP code on your server directly in Visual studio code. -It's still in its early stages +This extension allows editing and activation of ABAP code on your server directly in Visual studio code. +It's still in its early stages, for now no transport selection is provided, save and activate does work for local objects **WRITE SUPPORT IS EXPERIMANTAL USE AT YOUR OWN RISK** diff --git a/images/activate_multi.svg b/images/activate_multi.svg new file mode 100644 index 0000000..7a532f1 --- /dev/null +++ b/images/activate_multi.svg @@ -0,0 +1,201 @@ + + + + + + image/svg+xml + + + + + + + + + + Layer 1 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/activate_single.svg b/images/activate_single.svg new file mode 100644 index 0000000..9082b66 --- /dev/null +++ b/images/activate_single.svg @@ -0,0 +1,263 @@ + + + + + + image/svg+xml + + + + + + + + + + Layer 1 + + + + + + + + + + + + + + + + + + Layer 1 + + + + + + + + + + + + + + + + + diff --git a/images/inactive.svg b/images/inactive.svg new file mode 100644 index 0000000..0bea355 --- /dev/null +++ b/images/inactive.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index 0361391..a74c537 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,29 @@ ], "main": "./out/extension", "contributes": { + "commands": [ + { + "command": "abapfs.connect", + "title": "Connect to an ABAP system" + }, + { + "command": "abapfs.activate", + "title": "Activate the current ABAP object", + "icon": { + "dark": "images/activate_single.svg", + "light": "images/activate_single.svg" + } + } + ], + "menus": { + "editor/title": [ + { + "command": "abapfs.activate", + "group": "navigation", + "when": "resourceScheme == adt && abapfs:objectInactive" + } + ] + }, "configuration": { "title": "ABAP-FS configuration", "properties": { @@ -87,12 +110,6 @@ } } } - }, - "commands": [ - { - "command": "abapfs.connect", - "title": "Connect to an ABAP system" - } - ] + } } } diff --git a/src/abap/AbapObject.ts b/src/abap/AbapObject.ts index 177ddb8..7d4d144 100644 --- a/src/abap/AbapObject.ts +++ b/src/abap/AbapObject.ts @@ -6,7 +6,7 @@ import { NodeStructure, ObjectNode } from "../adt/AdtNodeStructParser" -import { parsetoPromise } from "../adt/AdtParserBase" +import { parsetoPromise, getNode } from "../adt/AdtParserBase" import { parseObject, firstTextLink } from "../adt/AdtObjectParser" import { aggregateNodes } from "./AbapObjectUtilities" import { adtLockParser } from "../adt/AdtLockParser" @@ -31,6 +31,11 @@ export interface AbapMetaData { masterLanguage?: string masterSystem?: string } +interface MainProgram { + "adtcore:uri": string + "adtcore:type": string + "adtcore:name": string +} export class AbapObject { readonly type: string @@ -70,6 +75,47 @@ export class AbapObject { path: this.path }) } + async activate( + connection: AdtConnection, + mainInclude?: string + ): Promise { + const uri = this.getUri(connection).with({ + path: "/sap/bc/adt/activation", + query: "method=activate&preauditRequested=true" + }) + const incl = mainInclude + ? `?context=${encodeURIComponent(mainInclude)}` + : "" + const payload = + `` + + `` + + `` + + `` + + const response = await connection.request(uri, "POST", { body: payload }) + if (response.body) { + //activation error(s) + const messages = (await parsetoPromise( + getNode("chkl:messages/msg/shortText/txt") + )(response.body)) as string[] + + return messages[0] + } + return "" + } + + async getMainPrograms(connection: AdtConnection): Promise { + const response = await connection.request( + followLink(this.getUri(connection), "mainprograms"), + "GET" + ) + const parsed: any = await parsetoPromise()(response.body) + return parsed["adtcore:objectReferences"]["adtcore:objectReference"].map( + (link: any) => link["$"] + ) + } async setContents( connection: AdtConnection, @@ -82,17 +128,13 @@ export class AbapObject { ) let contentUri = this.getContentsUri(connection) - const lockRecord = await connection - .request( - contentUri.with({ query: "_action=LOCK&accessMode=MODIFY" }), - "POST", - { headers: { "X-sap-adt-sessiontype": "stateful" } } - ) - .then(pick("body")) - .then(parsetoPromise() as any) - .then(adtLockParser) - - const lock = encodeURI(lockRecord.LOCK_HANDLE) + 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) + const lock = encodeURIComponent(lockRecord.LOCK_HANDLE) await connection.request( contentUri.with({ query: `lockHandle=${lock}` }), diff --git a/src/adt/AdtConnection.ts b/src/adt/AdtConnection.ts index 4eaf3af..723cb62 100644 --- a/src/adt/AdtConnection.ts +++ b/src/adt/AdtConnection.ts @@ -1,6 +1,8 @@ import * as request from "request" import { Uri } from "vscode" import { RemoteConfig } from "../config" +import { AdtException, AdtHttpException } from "./AdtExceptions" +import { Response } from "request" enum ConnStatus { new, @@ -77,18 +79,18 @@ export class AdtConnection { } } - return new Promise((resolve, reject) => { - request(urlOptions, (error, response, body) => { + return new Promise((resolve, reject) => { + request(urlOptions, async (error, response, body) => { if (error) reject(error) + //TODO:support 304 non modified? Should only happen if I send a header like + //If-None-Match: 201811061933580005ZDEMO_CALENDAR else if (response.statusCode < 300) resolve(response) else - reject( - new Error( - `Failed to connect to ${this.name}:${response.statusCode}:${ - response.statusMessage - }` - ) - ) + try { + reject(await AdtException.fromXml(body)) + } catch (e) { + reject(new AdtHttpException(response)) + } }) }) } diff --git a/src/adt/AdtExceptions.ts b/src/adt/AdtExceptions.ts new file mode 100644 index 0000000..46126f0 --- /dev/null +++ b/src/adt/AdtExceptions.ts @@ -0,0 +1,66 @@ +import { parsetoPromise, getFieldAttribute, recxml2js } from "./AdtParserBase" +import { Response } from "request" + +export class AdtException extends Error { + namespace: string + type: string + message: string + localizedMessage: string + properties: Map + constructor( + namespace: string, + type: string, + message: string, + localizedMessage: string, + properties: Map + ) { + super() + this.namespace = namespace + this.type = type + this.message = message + this.localizedMessage = localizedMessage + this.properties = properties + } + + static async fromXml(xml: string): Promise { + const raw: any = await parsetoPromise()(xml) + const root: any = raw["exc:exception"] + const namespace = getFieldAttribute("namespace", "id", root) + const type = getFieldAttribute("type", "id", root) + const values = recxml2js(root) + return new AdtException( + namespace, + type, + values.message, + values.localizedMessage, + new Map() + ) + } +} +export class AdtHttpException extends Error { + statusCode: number + statusMessage: string + message: string + + constructor(response: Response, message?: string) { + super() + this.statusCode = response.statusCode + this.statusMessage = response.statusMessage + this.message = + message || + `Error ${this.statusCode}:${this.statusMessage} fetching ${ + response.request.uri.path + } from ${response.request.uri.hostname}` + } +} +// const test = ` +// +// +// +// Select a master program for include ZDEMO_EXCEL_OUTPUTOPT_INCL in the properties view +// Select a master program for include ZDEMO_EXCEL_OUTPUTOPT_INCL in the properties view +// +// +// +// ` +// export const tested = AdtException.fromXml(test) diff --git a/src/adt/AdtParserBase.ts b/src/adt/AdtParserBase.ts index bcc7dc1..833644e 100644 --- a/src/adt/AdtParserBase.ts +++ b/src/adt/AdtParserBase.ts @@ -1,8 +1,13 @@ import { parseString, convertableToString } from "xml2js" import { pipe } from "../functions" -// when the field is an array getfield will return its first line -// use with caution! +/** + * Returns a function to select the contents of a simple field + * when the object is an array the function will look for the field in its first line + * + * @param name name of the field + * + */ export const getField = (name: string) => (subj: any) => { if (subj instanceof Array) { return subj[0][name] @@ -10,11 +15,65 @@ export const getField = (name: string) => (subj: any) => { return subj[name] } } - -//{foo:[fooval],bar:[barval]}=>{foo:fooval,bar:barval} +/** + * returns the attributes of a field + * + * It's curried:if node is omitted returns a function + * + * if the xml looked like + * getFieldAttributes("foo", parent) or getFieldAttributes("foo")(parent) + * will return {bar:"barval",baz:"bazval"} + */ +export const getFieldAttributes = ( + fieldname: string, + node?: any +): any | ((x: any) => any) => { + function getAttributes(o: any): any { + return o && o[fieldname] && o[fieldname][0] && o[fieldname][0]["$"] + } + if (node) return getAttributes(node) + return getAttributes +} +/** + * returns an attribute of a given node + * + * It's curried: if node is omitted returns a function + * + * if the xml looked like + * getFieldAttribute("foo","bar", parent) or getFieldAttributes("foo","bar")(parent) + * will return "barval" + * returns an empty string if there isn't one + */ +export function getFieldAttribute( + fieldname: string, + attrname: string, + node: any +): string +export function getFieldAttribute( + fieldname: string, + attrname: string +): ((x: any) => string) +export function getFieldAttribute( + fieldname: string, + attrname: string, + node?: any +): string | ((x: any) => string) { + const getter = getFieldAttributes(fieldname) + function getValue(o: any): string { + const attrs = getter(o) + return (attrs && attrs[attrname]) || "" + } + if (node) return getValue(node) + return getValue +} +/** + * @param record extracts XML field values. value becomes {field:value} rather than {field:[value]} + * {foo:[fooval],bar:[barval]}=>{foo:fooval,bar:barval} + */ export const recxml2js = (record: any) => Object.keys(record).reduce((acc: any, current: any) => { acc[current] = record[current][0] + if (acc[current] && acc[current]._) acc[current] = acc[current]._ return acc }, {}) @@ -47,7 +106,7 @@ export const getNode = (...args: any[]) => { } return fn(...args) } -export const parsetoPromise = (parser?: Function) => ( +export const parsetoPromise = (parser?: (raw: any) => T) => ( xml: convertableToString ): Promise => new Promise(resolve => { diff --git a/src/adt/AdtServer.ts b/src/adt/AdtServer.ts index 43c26b6..9f45fb9 100644 --- a/src/adt/AdtServer.ts +++ b/src/adt/AdtServer.ts @@ -1,7 +1,7 @@ import { AdtConnection } from "./AdtConnection" -import { Uri, FileSystemError, FileType } from "vscode" +import { Uri, FileSystemError, FileType, window, commands } from "vscode" import { MetaFolder } from "../fs/MetaFolder" -import { AbapObjectNode, AbapNode } from "../fs/AbapNode" +import { AbapObjectNode, AbapNode, isAbap } from "../fs/AbapNode" import { AbapObject } from "../abap/AbapObject" import { getRemoteList } from "../config" export const ADTBASEURL = "/sap/bc/adt/repository/nodestructure" @@ -26,6 +26,29 @@ export class AdtServer { readonly connectionP: Promise private root: MetaFolder + constructor(connectionId: string) { + const config = getRemoteList().filter( + config => config.name.toLowerCase() === connectionId.toLowerCase() + )[0] + + if (!config) throw new Error(`connection ${connectionId}`) + + const connection = AdtConnection.fromRemote(config) + + this.connectionId = config.name.toLowerCase() + this.connectionP = connection.waitReady() + connection.connect() + + this.root = new MetaFolder() + this.root.setChild( + `$TMP`, + new AbapObjectNode(new AbapObject("DEVC/K", "$TMP", ADTBASEURL, "X")) + ) + this.root.setChild( + "System Library", + new AbapObjectNode(new AbapObject("DEVC/K", "", ADTBASEURL, "X")) + ) + } findNode(uri: Uri): AbapNode { const parts = uriParts(uri) return parts.reduce((current: any, name) => { @@ -34,6 +57,12 @@ export class AdtServer { }, this.root) } + async findAbapObject(uri: Uri): Promise { + const node = await this.findNodePromise(uri) + if (isAbap(node)) return node.abapObject + return Promise.reject(new Error("Not an abap object")) + } + async stat(uri: Uri) { const node = await this.findNodePromise(uri) if (node.canRefresh()) { @@ -61,28 +90,40 @@ export class AdtServer { return node } - constructor(connectionId: string) { - const config = getRemoteList().filter( - config => config.name.toLowerCase() === connectionId.toLowerCase() - )[0] - - if (!config) throw new Error(`connection ${connectionId}`) - - const connection = AdtConnection.fromRemote(config) - - this.connectionId = config.name.toLowerCase() - this.connectionP = connection.waitReady() - connection.connect() - - this.root = new MetaFolder() - this.root.setChild( - `$TMP`, - new AbapObjectNode(new AbapObject("DEVC/K", "$TMP", ADTBASEURL, "X")) - ) - this.root.setChild( - "System Library", - new AbapObjectNode(new AbapObject("DEVC/K", "", ADTBASEURL, "X")) - ) + async activate(obj: AbapObject) { + const conn = await this.connectionP + let message = "" + try { + message = await obj.activate(conn) + } catch (e) { + const mainPrograms = await obj.getMainPrograms(conn) + let url = "" + if (mainPrograms.length === 1) url = mainPrograms[0]["adtcore:uri"] + else { + const mainProg = + (await window.showQuickPick( + mainPrograms.map(p => p["adtcore:name"]), + { placeHolder: "Please select a main program" } + )) || "" + if (mainProg) + url = mainPrograms.find(x => x["adtcore:name"] === mainProg)![ + "adtcore:uri" + ] + else return + } + if (url) + try { + message = await obj.activate(conn, url) + } catch (err) { + window.showErrorMessage(err) + } + } + if (message) window.showErrorMessage(message) + else { + //activation successful, update the status. By the book we should check if it's set by this object first... + await obj.loadMetadata(conn) + commands.executeCommand("setContext", "abapfs:objectInactive", false) + } } } const servers = new Map() diff --git a/src/extension.ts b/src/extension.ts index 599eb6b..2d61d6d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,9 @@ import * as vscode from "vscode" import { FsProvider } from "./fs/FsProvider" import { getRemoteList, RemoteConfig } from "./config" import { AdtConnection } from "./adt/AdtConnection" +import { window, Uri } from "vscode" +import { activeTextEditorChangedListener } from "./listeners" +import { fromUri } from "./adt/AdtServer" function selectRemote(connection: string): Thenable { const remotes = getRemoteList() @@ -25,35 +28,45 @@ function selectRemote(connection: string): Thenable { throw new Error("No connection selected") }) } +async function activateCurrent(selector: Uri) { + const server = fromUri(selector) + const obj = await server.findAbapObject(selector) + server.activate(obj) +} +async function connect(selector: any) { + const connectionID = selector && selector.connection + const remote = await selectRemote(connectionID) + const connection = AdtConnection.fromRemote(remote) + + await connection.connect() // if connection raises an exception don't mount any folder + + vscode.workspace.updateWorkspaceFolders(0, 0, { + uri: vscode.Uri.parse("adt://" + remote.name), + name: remote.name + "(ABAP)" + }) +} export function activate(context: vscode.ExtensionContext) { const abapFS = new FsProvider() + //register the filesystem type context.subscriptions.push( vscode.workspace.registerFileSystemProvider("adt", abapFS, { isCaseSensitive: true }) ) - let disposable = vscode.commands.registerCommand( - "abapfs.connect", - async (selector: any) => { - const connectionID = selector && selector.connection - const remote = await selectRemote(connectionID) - const connection = AdtConnection.fromRemote(remote) - - try { - const response = await connection.connect() - if (response.statusCode > 300) - throw new Error(`Error connecting to server ${connectionID}`) - } catch (error) { - throw new Error(`Error connecting to server ${connectionID}`) - } + // + context.subscriptions.push( + window.onDidChangeActiveTextEditor(activeTextEditorChangedListener) + ) - vscode.workspace.updateWorkspaceFolders(0, 0, { - uri: vscode.Uri.parse("adt://" + remote.name), - name: remote.name + "(ABAP)" - }) - } + //connect command + let disposable = vscode.commands.registerCommand("abapfs.connect", connect) + context.subscriptions.push(disposable) + //activate command + disposable = vscode.commands.registerCommand( + "abapfs.activate", + activateCurrent ) context.subscriptions.push(disposable) } diff --git a/src/fs/AbapNode.ts b/src/fs/AbapNode.ts index 7a8c1ad..71f301d 100644 --- a/src/fs/AbapNode.ts +++ b/src/fs/AbapNode.ts @@ -124,7 +124,8 @@ export class AbapObjectNode implements FileStat, Iterable<[string, AbapNode]> { } public save(connection: AdtConnection, contents: Uint8Array) { if (this.isFolder()) throw FileSystemError.FileIsADirectory() - this.abapObject.setContents(connection, contents) + //returning a promise will allow the exceptions to propagate + return this.abapObject.setContents(connection, contents) } public refresh(connection: AdtConnection): Promise { return this.abapObject.getChildren(connection).then(objects => { @@ -154,3 +155,6 @@ export class AbapObjectNode implements FileStat, Iterable<[string, AbapNode]> { } export type AbapNode = AbapObjectNode | MetaFolder +export function isAbap(node: AbapNode): node is AbapObjectNode { + return (node).abapObject !== undefined +} diff --git a/src/fs/FsProvider.ts b/src/fs/FsProvider.ts index 45dc003..ae4708d 100644 --- a/src/fs/FsProvider.ts +++ b/src/fs/FsProvider.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" import { fromUri } from "../adt/AdtServer" -import { FileSystemError } from "vscode" +import { FileSystemError, commands } from "vscode" export class FsProvider implements vscode.FileSystemProvider { private _eventEmitter = new vscode.EventEmitter() @@ -46,11 +46,11 @@ export class FsProvider implements vscode.FileSystemProvider { } catch (error) {} throw FileSystemError.Unavailable(uri) } - writeFile( + async writeFile( uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean } - ): void | Thenable { + ): Promise { const server = fromUri(uri) const file = server.findNode(uri) if (!file && options.create) @@ -58,7 +58,12 @@ export class FsProvider implements vscode.FileSystemProvider { "Not a real filesystem, file creation is not supported" ) if (!file) throw FileSystemError.FileNotFound(uri) - return server.connectionP.then(conn => file.save(conn, content)) + const connection = await server.connectionP + await file.save(connection, content) + //not active anymore... update the status. By the book we should check if it's set by this object first... + //TODO: move this logic somewhere else... + await this.stat(uri) + commands.executeCommand("setContext", "abapfs:objectInactive", true) } delete( uri: vscode.Uri, diff --git a/src/listeners.ts b/src/listeners.ts new file mode 100644 index 0000000..9335b24 --- /dev/null +++ b/src/listeners.ts @@ -0,0 +1,19 @@ +import { TextEditor, commands } from "vscode" + +import { fromUri } from "./adt/AdtServer" + +export async function activeTextEditorChangedListener( + editor: TextEditor | undefined +) { + try { + if (editor && editor.document.uri.scheme === "adt") { + const server = fromUri(editor.document.uri) + const obj = await server.findAbapObject(editor.document.uri) + if (obj.metaData && obj.metaData.version === "inactive") { + commands.executeCommand("setContext", "abapfs:objectInactive", true) + return + } + } + } catch (e) {} + commands.executeCommand("setContext", "abapfs:objectInactive", false) +}