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 @@
+
+
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 @@
+
+
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)
+}