diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6a615a..87cb1cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - New UI for the basic mode Sync (#415) - Allow changing namespaces and IPM package context from web UI (#280) +- Support for editing repo from filesystem perspective via web application (#464) +- Support for downloading a VSCode workspace file from web UI ### Fixed - Instance wide settings are placed in proper global (#444) +- Avoid delay/errors in loading interop JS when there is a URL prefix (e.g., instance name in multi-instance webserver configuration) +- Added proper JS escaping in sync output - Added support to switch branch in basic mode from menu (#451) ## [2.4.1] - 2024-08-02 diff --git a/cls/SourceControl/Git/Extension.cls b/cls/SourceControl/Git/Extension.cls index dacf2816..f4eab0b3 100644 --- a/cls/SourceControl/Git/Extension.cls +++ b/cls/SourceControl/Git/Extension.cls @@ -74,9 +74,13 @@ Method AfterUserAction(Type As %Integer, Name As %String, InternalName As %Strin if menu '= "%SourceMenu", menu'="%SourceContext" { quit $$$OK } - set InternalName = ##class(SourceControl.Git.Utils).NormalizeInternalName(InternalName) + set InternalName = ##class(SourceControl.Git.Utils).NormalizeInternalName(InternalName, .fromWebApp) set context = ##class(SourceControl.Git.PackageManagerContext).ForInternalName(InternalName) set ec = ##class(SourceControl.Git.Utils).AfterUserAction(Type, Name, InternalName, .Answer, .Msg, .Reload) + if fromWebApp { + // Force reload and compile of actual item if underlying file has changed + do ..OnBeforeLoad(InternalName,1,1) + } quit ec } @@ -235,12 +239,12 @@ Method OnMenuItem(MenuName As %String, InternalName As %String, SelectedText As /// This is called before the actual load of data to give the chance /// to load the item from an external format. -Method OnBeforeLoad(InternalName As %String, verbose As %Boolean) As %Status +Method OnBeforeLoad(InternalName As %String, verbose As %Boolean, compile As %Boolean = 0) As %Status { set context = ##class(SourceControl.Git.PackageManagerContext).ForInternalName(InternalName) set InternalName = ##class(SourceControl.Git.Utils).NormalizeInternalName(InternalName) if ##class(SourceControl.Git.Utils).IsInSourceControl(InternalName) { - quit ##class(SourceControl.Git.Utils).ImportItem(InternalName,,0) + quit ##class(SourceControl.Git.Utils).ImportItem(InternalName,,0,compile) } quit $$$OK } @@ -274,14 +278,21 @@ Method OnAfterSave(InternalName As %String, Object As %RegisteredObject = {$$$NU { set sc = $$$OK try { - set InternalName = ##class(SourceControl.Git.Utils).NormalizeInternalName(.InternalName) + set InternalName = ##class(SourceControl.Git.Utils).NormalizeInternalName(.InternalName,.fromWebApp,.fullExternalName) set context = ##class(SourceControl.Git.PackageManagerContext).ForInternalName(InternalName) if ##class(SourceControl.Git.Utils).IsNamespaceInGit() && ..IsInSourceControl(InternalName) { - set filename = ##class(SourceControl.Git.Utils).FullExternalName(InternalName) - $$$ThrowOnError(##class(SourceControl.Git.Utils).RemoveRoutineTSH(InternalName)) - $$$ThrowOnError(##class(SourceControl.Git.Utils).ExportItem(InternalName)) - if '##class(SourceControl.Git.Change).IsUncommitted(filename) { - $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", InternalName, $username, "", 1, "", "", 0)) + if fromWebApp { + if fullExternalName = ##class(SourceControl.Git.Utils).FullExternalName(InternalName) { + // Reimport item into database + $$$ThrowOnError(##class(SourceControl.Git.Utils).ImportItem(InternalName,,1,1)) + } + } else { + set filename = ##class(SourceControl.Git.Utils).FullExternalName(InternalName) + $$$ThrowOnError(##class(SourceControl.Git.Utils).RemoveRoutineTSH(InternalName)) + $$$ThrowOnError(##class(SourceControl.Git.Utils).ExportItem(InternalName)) + if '##class(SourceControl.Git.Change).IsUncommitted(filename) { + $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", InternalName, $username, "", 1, "", "", 0)) + } } } } catch e { diff --git a/cls/SourceControl/Git/Settings.cls b/cls/SourceControl/Git/Settings.cls index 6a4ce435..da9fdddf 100644 --- a/cls/SourceControl/Git/Settings.cls +++ b/cls/SourceControl/Git/Settings.cls @@ -47,6 +47,9 @@ Property defaultMergeBranch As %String [ InitialExpression = {##class(SourceCont /// Compile using the configured pull event handler when "Import All" is run Property compileOnImport As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).CompileOnImport()} ]; +/// Define a namespace-level web application allowing access to multiple git repos across separate namespaces +Property namespaceLevelGitWebApp As %Boolean [ InitialExpression = {##class(SourceControl.Git.Settings).HasNamespaceWebApp()} ]; + Property Mappings [ MultiDimensional ]; Method %OnNew() As %Status @@ -160,6 +163,75 @@ ClassMethod Configure() As %Boolean [ CodeMode = objectgenerator ] do %code.WriteLine(" quit 1") } +Method ConfigureNamespaceWebApplication() +{ + Set root = ##class(%Library.File).NormalizeDirectory(##class(SourceControl.Git.Utils).TempFolder()) + Set deleteWebApp = ..HasNamespaceWebApp(.appDirectory) && '..namespaceLevelGitWebApp + Set createWebApp = ..namespaceLevelGitWebApp && '..HasNamespaceWebApp() + Do ..WebAppOperation("/git/"_$Namespace_"/", createWebApp, deleteWebApp, root) +} + +Method WebAppOperation(name, create As %Boolean, delete As %Boolean, root As %String) [ Private ] +{ + Set namespace = $Namespace + New $Namespace + Set $Namespace = "%SYS" + If $Extract(name) = "/" { + Set name = $Extract(name,1,*-1) + } + If delete { + If ##class(Security.Applications).Exists(name) { + $$$ThrowOnError(##class(Security.Applications).Delete(name)) + Write !,"Removed web application "_name + } + Quit + } + + // These are the only things we want to coerce. + Set props("AutheEnabled")=0 // No auth methods enabled = impossible to use + Set props("InbndWebServicesEnabled")=0 + Set props("ServeFiles")=0 + Set props("Enabled")=1 + Set props("Name")=name + Set props("NameSpace")=namespace + Set props("Path")=root + Set props("Type")=2 + Set props("Recurse")=1 + If create { + Write !,"Creating web application: "_name_"... " + $$$ThrowOnError(##class(Security.Applications).Create(name,.props)) + Write "done." + } ElseIf ##class(Security.Applications).Exists(name) { + Write !,"Web application '"_name_"' already exists." + $$$ThrowOnError(##class(Security.Applications).Get(name,.existingProps)) + Set changes = 0 + Set key = "" + For { + Set key = $Order(props(key),1,value) + Quit:key="" + If (value '= $Get(existingProps(key))) { + Write !,"Changing "_key_": "_$Get(existingProps(key))_" -> "_value + Set changes = 1 + } + } + If changes { + $$$ThrowOnError(##class(Security.Applications).Modify(name,.props)) + Write !,"Web application '"_name_"' updated." + } Else { + Write !,"No changes made to web application." + } + } +} + +ClassMethod HasNamespaceWebApp(Output webAppDirectory) As %Boolean +{ + Set webAppDirectory = $System.CSP.GetFileName("/git/"_$Namespace_"/") + If (webAppDirectory '= "") { + Set webAppDirectory = ##class(%Library.File).NormalizeDirectory(webAppDirectory) + } + Quit (webAppDirectory '= "") +} + Method OnAfterConfigure() As %Boolean { set defaultPromptFlag = $$$DisableBackupCharMask + $$$TrapCtrlCMask + $$$EnableQuitCharMask + $$$DisableHelpCharMask + $$$DisableHelpContextCharMask + $$$TrapErrorMask @@ -186,6 +258,8 @@ Method OnAfterConfigure() As %Boolean } } + do ..ConfigureNamespaceWebApplication() + set gitDir = ##class(%File).NormalizeDirectory(..namespaceTemp)_".git" if '##class(%File).DirectoryExists(gitDir) { set list(1) = "Initialize empty repo" diff --git a/cls/SourceControl/Git/StreamServer.cls b/cls/SourceControl/Git/StreamServer.cls index daf75a8d..42b2330e 100644 --- a/cls/SourceControl/Git/StreamServer.cls +++ b/cls/SourceControl/Git/StreamServer.cls @@ -7,7 +7,7 @@ Class SourceControl.Git.StreamServer Extends %CSP.StreamServer ClassMethod OnPage() As %Status { if (%stream '= $$$NULLOREF) && $data(%base)#2 { - set sourceControlInclude = ##class(SourceControl.Git.Utils).GetSourceControlInclude() + set sourceControlInclude = ##class(SourceControl.Git.Utils).GetSourceControlInclude(%request.URLPrefix) while '%stream.AtEnd { set text = %stream.Read() set text = $replace(text,"{{baseHref}}",..EscapeHTML(%base)) @@ -19,4 +19,3 @@ ClassMethod OnPage() As %Status } } - diff --git a/cls/SourceControl/Git/Utils.cls b/cls/SourceControl/Git/Utils.cls index 9b6405c7..94290b60 100644 --- a/cls/SourceControl/Git/Utils.cls +++ b/cls/SourceControl/Git/Utils.cls @@ -1116,7 +1116,7 @@ ClassMethod FullExternalName(ByRef InternalName As %String, ByRef MappingExists ..TempFolder()_..ExternalName(.InternalName, .MappingExists) } -ClassMethod NormalizeInternalName(ByRef name As %String) As %String +ClassMethod NormalizeInternalName(ByRef name As %String, Output fromWebApp As %Boolean = 0, Output cspFilename) As %String { //Studio passes name of routine with dots as it is in folders //e.g. Package.SubPackage.Routine.mac has InternalName = /Package/SubPackage/Routine.mac @@ -1132,6 +1132,16 @@ ClassMethod NormalizeInternalName(ByRef name As %String) As %String if ($extract(name) '= "/") && (type'="csp") { quit $piece(name,".",1,*-1)_"."_$zconvert($piece(name,".",*),"U") } + + if (name [ "/") && (type = "csp") { + set cspFilename = $System.CSP.GetFileName(name) + if (cspFilename '= "") && (cspFilename [ ..TempFolder()) { + set name = ..NameToInternalName(cspFilename) + set fromWebApp = 1 + } else { + kill cspFilename + } + } if (type = "inc") || (type = "mac") || (type = "int") { set name = $extract($translate(name, "/", "."), 2, *) @@ -1246,7 +1256,7 @@ ClassMethod FixProjectCspReferences(projectName As %String) As %Status /// imports file if version in system is newer then version on disk. /// if force = 1 then imports in any case. -ClassMethod ImportItem(InternalName As %String, force As %Boolean = 0, verbose As %Boolean = 1) As %Status +ClassMethod ImportItem(InternalName As %String, force As %Boolean = 0, verbose As %Boolean = 1, compile As %Boolean = 0) As %Status { #dim filename as %String = ..FullExternalName(.InternalName) #dim fileTSH = ##class(%File).GetFileDateModified(filename) @@ -1265,7 +1275,7 @@ ClassMethod ImportItem(InternalName As %String, force As %Boolean = 0, verbose A if ($extract(InternalName, 1) = "/"){ set sc = ..ImportCSPFile(InternalName) } else{ - set sc = $system.OBJ.Load(filename,"-l-d") + set sc = $system.OBJ.Load(filename,$Select(compile:"ck-l",1:"-l-d")) } } if sc { @@ -2396,10 +2406,10 @@ ClassMethod GetPackageVersion() As %String [ CodeMode = objectgenerator ] quit $$$OK } -ClassMethod GetSourceControlInclude() As %String +ClassMethod GetSourceControlInclude(prefix As %String = "") As %String { quit $select(##class(%Library.EnsembleMgr).IsEnsembleInstalled(): - "", + "", 1: "") } @@ -2615,4 +2625,3 @@ ClassMethod BaselineExport(pCommitMessage = "", pPushToRemote = "") As %Status } } - diff --git a/cls/SourceControl/Git/WebUIDriver.cls b/cls/SourceControl/Git/WebUIDriver.cls index 5d8a2b17..d5580448 100644 --- a/cls/SourceControl/Git/WebUIDriver.cls +++ b/cls/SourceControl/Git/WebUIDriver.cls @@ -27,6 +27,65 @@ ClassMethod HandleRequest(pagePath As %String, InternalName As %String = "", Out if $isobject($get(responseJSON)) { do responseJSON.%ToJSON(%data) } + } elseif pathStart = "vscode-workspace" { + set handled = 1 + set namespace = $Namespace + set instanceName = $ZConvert(##class(%SYS.System).InstanceGUID(),"L") + set hasCSP = 0 + if context.IsInGitEnabledPackage && 'context.IsInDefaultPackage { + set workspaceFilename = context.Package.Name_"-"_instanceName_".code-workspace" + // Loading an IPM package in dev mode automatically creates a project for it, so filter to that. + set filter = "?project="_context.Package.Name + } else { + set hasCSP = ##class(SourceControl.Git.Settings).HasNamespaceWebApp() + set workspaceFilename = namespace_"-"_instanceName_".code-workspace" + set filter = "?mapped=0" + } + set server = { + "webServer": { + "host": (%request.CgiEnvs("SERVER_NAME")), + "port": (+%request.CgiEnvs("SERVER_PORT")), + "scheme": ($Select(%request.Secure:"https",1:"http")) + }, + "username": ($Username) + } + if (%request.URLPrefix '= "") { + set server.webServer.pathPrefix = %request.URLPrefix + } + set workspaceDef = { + "folders": [ + ], + "settings": { + "intersystems.servers": { + "/default": (instanceName) + }, + "objectscript.conn": { + "active": false + } + } + } + if hasCSP { + do workspaceDef.folders.%Push({ + "name": ($Namespace), + "uri": ("isfs://"_instanceName_":"_$Namespace_"/git/"_$Namespace_"?csp") + }) + } else { + do workspaceDef.folders.%Push({ + "name": ($Namespace), + "uri": ("isfs://"_instanceName_":"_$Namespace_"/"_filter) + }) + } + set $Property(workspaceDef.settings."intersystems.servers",instanceName) = server + set stream = ##class(%CSP.CharacterStream).%New() + do stream.SetAttribute("ContentDisposition","attachment; filename="_workspaceFilename) + set formatter = ##class(%JSON.Formatter).%New() + do formatter.FormatToStream(workspaceDef,stream) + $$$ThrowOnError(stream.%Save()) + do %request.Set("STREAMOID",##class(%CSP.Page).Encrypt(stream.%Oid())) + set %request.Data("EXPIRES",1,"encrypt") = 1 + do %request.Set("EXPIRES",0) + do ##class(%CSP.StreamServer).OnPreHTTP() // Need to call this to set headers properly + set %stream = 1 // Leak this to webuidriver.csp } elseif $match(pathStart,"git-command|git|dirname|hostname|viewonly|contexts") { if (%request.Method = "GET") { set %data = ##class(%Stream.TmpCharacter).%New() diff --git a/csp/sync.csp b/csp/sync.csp index 78be9c2c..bab0d58d 100644 --- a/csp/sync.csp +++ b/csp/sync.csp @@ -146,7 +146,7 @@ &js< var outputContainer = document.getElementById('outputBox'); - var lineText = "#(escapedLine)#"; + var lineText = #(..QuoteJS(escapedLine))#; var lineTextNode = document.createTextNode(lineText); outputContainer.innerHTML += lineText + "
"; > diff --git a/git-webui/release/share/git-webui/webui/css/git-webui.css b/git-webui/release/share/git-webui/webui/css/git-webui.css index 0a0043b7..0c75c1f4 100644 --- a/git-webui/release/share/git-webui/webui/css/git-webui.css +++ b/git-webui/release/share/git-webui/webui/css/git-webui.css @@ -183,7 +183,7 @@ body { padding-bottom: 100px; } #sidebar #sidebar-content > :first-child, -#sidebar #sidebar-content > :nth-last-child(2) { +#sidebar #sidebar-content > :nth-last-child(3) { border-top: 1px solid #5e5e5e; } #sidebar #sidebar-content h4:before { @@ -216,17 +216,36 @@ body { #sidebar #sidebar-content #sidebar-tags h4:before { content: url(../img/tag.svg); } +#sidebar #sidebar-content #sidebar-vscode a { + color: white; +} +#sidebar #sidebar-content #sidebar-vscode h4 { + padding: 0px; +} +#sidebar #sidebar-content #sidebar-vscode h4:before { + content: url(../img/file.svg); +} +#sidebar #sidebar-content #sidebar-vscode { + position: absolute; + bottom: 80px; + width: 16.7em; + background-color: #333333; +} +#sidebar #sidebar-content #sidebar-context h4 { + padding: 0px; +} #sidebar #sidebar-content #sidebar-context h4:before { content: url(../img/context.svg); } #sidebar #sidebar-content #sidebar-context { position: absolute; - bottom: 50px; + bottom: 40px; width: 16.7em; - padding-bottom: 0.3rem; - margin-bottom: 0; background-color: #333333; } +#sidebar #sidebar-content #sidebar-settings h4 { + padding: 0px; +} #sidebar #sidebar-content #sidebar-settings h4:before { content: url(../img/gear-fill.svg); } @@ -234,8 +253,6 @@ body { position: absolute; bottom: 0px; width: 16.7em; - padding-bottom: 0.3rem; - margin-bottom: 0px; background-color: #333333; } #sidebar #sidebar-content ul { diff --git a/git-webui/release/share/git-webui/webui/img/context.svg b/git-webui/release/share/git-webui/webui/img/context.svg index e3732172..f5ef6c98 100644 --- a/git-webui/release/share/git-webui/webui/img/context.svg +++ b/git-webui/release/share/git-webui/webui/img/context.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/git-webui/release/share/git-webui/webui/js/git-webui.js b/git-webui/release/share/git-webui/webui/js/git-webui.js index 9625c506..632c10f1 100644 --- a/git-webui/release/share/git-webui/webui/js/git-webui.js +++ b/git-webui/release/share/git-webui/webui/js/git-webui.js @@ -421,6 +421,9 @@ webui.SideBarView = function(mainView, noEventHandlers) { if (id.indexOf("local-branches") > -1) { // parses the output of git branch --verbose --verbose var matches = /^\*?\s*([\w-\/]+)\s+([^\s]+)\s+(\[.*\])?.*/.exec(ref); + if (!matches) { + continue; + } var branchInfo = { "branch_name": matches[1], "hash": matches[2], @@ -973,6 +976,9 @@ webui.SideBarView = function(mainView, noEventHandlers) { '' + + '' + '' + diff --git a/git-webui/src/share/git-webui/webui/css/git-webui.less b/git-webui/src/share/git-webui/webui/css/git-webui.less index 652d5bdb..8fb86733 100644 --- a/git-webui/src/share/git-webui/webui/css/git-webui.less +++ b/git-webui/src/share/git-webui/webui/css/git-webui.less @@ -257,7 +257,7 @@ body { color: @gray-lighter; padding-bottom: 100px; - > :first-child, >:nth-last-child(2) { + > :first-child, >:nth-last-child(3) { border-top: 1px solid darken(@separator-line, 50%); } @@ -301,19 +301,44 @@ body { content: url(../img/tag.svg); } + #sidebar-vscode a { + color: white; + } + + #sidebar-vscode h4 { + padding: 0px; + } + + #sidebar-vscode h4:before { + content: url(../img/file.svg); + } + + #sidebar-vscode { + position: absolute; + bottom: 80px; + width: 16.7em; + background-color: rgba(51, 51, 51, 1); + } + + #sidebar-context h4 { + padding: 0px; + } + #sidebar-context h4:before { - content: url(../img/context.svg) + content: url(../img/context.svg); } #sidebar-context { position: absolute; - bottom: 50px; + bottom: 40px; width: 16.7em; - padding-bottom: 0.3rem; - margin-bottom: 0; background-color: rgba(51, 51, 51, 1); } + #sidebar-settings h4 { + padding: 0px; + } + #sidebar-settings h4:before { content: url(../img/gear-fill.svg); } @@ -322,8 +347,6 @@ body { position: absolute; bottom: 0px; width: 16.7em; - padding-bottom: 0.3rem; - margin-bottom:0px; background-color: rgba(51, 51, 51, 1); } diff --git a/git-webui/src/share/git-webui/webui/img/context.svg b/git-webui/src/share/git-webui/webui/img/context.svg index e3732172..f5ef6c98 100644 --- a/git-webui/src/share/git-webui/webui/img/context.svg +++ b/git-webui/src/share/git-webui/webui/img/context.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/git-webui/src/share/git-webui/webui/js/git-webui.js b/git-webui/src/share/git-webui/webui/js/git-webui.js index 9625c506..632c10f1 100644 --- a/git-webui/src/share/git-webui/webui/js/git-webui.js +++ b/git-webui/src/share/git-webui/webui/js/git-webui.js @@ -421,6 +421,9 @@ webui.SideBarView = function(mainView, noEventHandlers) { if (id.indexOf("local-branches") > -1) { // parses the output of git branch --verbose --verbose var matches = /^\*?\s*([\w-\/]+)\s+([^\s]+)\s+(\[.*\])?.*/.exec(ref); + if (!matches) { + continue; + } var branchInfo = { "branch_name": matches[1], "hash": matches[2], @@ -973,6 +976,9 @@ webui.SideBarView = function(mainView, noEventHandlers) { '' + + '' + '' +