diff --git a/spec/System/TestBuildListHelpers_spec.lua b/spec/System/TestBuildListHelpers_spec.lua new file mode 100644 index 00000000000..81262568fd7 --- /dev/null +++ b/spec/System/TestBuildListHelpers_spec.lua @@ -0,0 +1,110 @@ +describe("BuildListHelpers", function() + local originalBuildPath + local originalCloudErrorPopup + local originalFileSearch + local originalOpen + local buildListHelpers + local fileHeaders + local searches + local fileOpenCount + local searchCount + local cloudErrorPath + + before_each(function() + originalBuildPath = main.buildPath + originalCloudErrorPopup = main.OpenCloudErrorPopup + originalFileSearch = _G.NewFileSearch + originalOpen = io.open + buildListHelpers = LoadModule("Modules/BuildListHelpers") + fileHeaders = { } + searches = { } + fileOpenCount = 0 + searchCount = 0 + cloudErrorPath = nil + main.buildPath = "Builds/" + main.OpenCloudErrorPopup = function(_, path) + cloudErrorPath = path + end + _G.NewFileSearch = function(pattern, foldersOnly) + searchCount = searchCount + 1 + local entries = searches[(foldersOnly and "folders:" or "files:")..pattern] + if not entries or not entries[1] then return end + local index = 1 + return { + GetFileName = function() return entries[index].name end, + GetFileModifiedTime = function() return entries[index].modified or 0 end, + NextFile = function() + index = index + 1 + return entries[index] ~= nil + end, + } + end + io.open = function(path) + fileOpenCount = fileOpenCount + 1 + if fileHeaders[path] == nil then return end + return { + read = function() return fileHeaders[path] or nil end, + close = function() end, + } + end + end) + + after_each(function() + main.buildPath = originalBuildPath + main.OpenCloudErrorPopup = originalCloudErrorPopup + _G.NewFileSearch = originalFileSearch + io.open = originalOpen + end) + + it("filters a recursive index without rescanning files", function() + searches["files:Builds/*.xml"] = { { name = "Root.xml", modified = 1 } } + searches["folders:Builds/*"] = { { name = "League", modified = 2 } } + searches["files:Builds/League/*.xml"] = { { name = "Nested.xml", modified = 3 } } + fileHeaders["Builds/Root.xml"] = '' + fileHeaders["Builds/League/Nested.xml"] = '' + + local index = buildListHelpers.ScanFolder("") + local scansAfterIndex = searchCount + local opensAfterIndex = fileOpenCount + local directEntries = buildListHelpers.FilterList(index, "", "") + local classMatches = buildListHelpers.FilterList(index, "", "CLASS:assassin") + + assert.are.same(3, #index) + assert.are.same(2, #directEntries) + assert.are.same("Nested.xml", classMatches[1].fileName) + assert.are.same("League/", classMatches[1].subPath) + assert.are.same(scansAfterIndex, searchCount) + assert.are.same(opensAfterIndex, fileOpenCount) + end) + + it("reports cloud read failures", function() + searches["files:Builds/*.xml"] = { { name = "Cloud.xml" } } + fileHeaders["Builds/Cloud.xml"] = false + + local index = buildListHelpers.ScanFolder("") + + assert.are.same(0, #index) + assert.are.same("Builds/Cloud.xml", cloudErrorPath) + end) + + it("blocks descendant folder targets and selects duplicate filenames by path", function() + local rootFolder = { folderName = "Alpha", subPath = "" } + local childFolder = { folderName = "Beta", subPath = "Alpha/" } + local firstBuild = { fileName = "Same.xml", subPath = "Alpha/", fullFileName = "Builds/Alpha/Same.xml" } + local secondBuild = { fileName = "Same.xml", subPath = "Other/", fullFileName = "Builds/Other/Same.xml" } + local listMode = { + list = { firstBuild, secondBuild }, + subPath = "", + BuildList = function() end, + } + local control = new("BuildListControl", nil, { 0, 0, 500, 500 }, listMode) + + control:SelByFullFileName("Builds/Other/Same.xml") + + assert.are.same(secondBuild, control.selValue) + assert.is_false(control:CanDragToValue(1, childFolder, { selValue = rootFolder })) + assert.is_true(buildListHelpers.CanMoveToSubPath(rootFolder, "Other/")) + control:SelByFullFileName("Builds/Missing.xml") + assert.is_nil(control.selValue) + end) +end) diff --git a/src/Classes/BuildListControl.lua b/src/Classes/BuildListControl.lua index 3f42430ce9a..08b56e06055 100644 --- a/src/Classes/BuildListControl.lua +++ b/src/Classes/BuildListControl.lua @@ -5,6 +5,7 @@ -- local ipairs = ipairs local s_format = string.format +local buildListHelpers = LoadModule("Modules/BuildListHelpers") local BuildListClass = newClass("BuildListControl", "ListControl", function(self, anchor, rect, listMode) self.ListControl(anchor, rect, 20, "VERTICAL", false, listMode.list) @@ -29,13 +30,21 @@ local BuildListClass = newClass("BuildListControl", "ListControl", function(self function self.controls.path:ReceiveDrag(type, build, source) if type == "Build" then for index, folder in ipairs(self.folderList) do - if index < #self.folderList and folder.button:IsMouseOver() then - if build.folderName then - main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..folder.path) - else - os.rename(build.fullFileName, listMode:GetDestName(folder.path, build.fileName)) + if folder.button:IsMouseOver() then + if buildListHelpers.CanMoveToSubPath(build, folder.path) then + if build.folderName then + main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..folder.path) + else + local destPath = listMode:GetDestName(folder.path, build.fileName) + local res, msg = os.rename(build.fullFileName, destPath) + if not res then + main:OpenMessagePopup("Error", "Couldn't move '"..build.fullFileName.."' to '"..destPath.."': "..(msg or "")) + return + end + end + listMode:BuildList() end - listMode:BuildList() + break end end end @@ -46,18 +55,22 @@ local BuildListClass = newClass("BuildListControl", "ListControl", function(self end end) -function BuildListClass:SelByFileName(selFileName) - for index, build in ipairs(self.list) do - if build.fileName == selFileName then - self:SelectIndex(index) - break +function BuildListClass:SelByFullFileName(fullFileName) + if fullFileName then + for index, build in ipairs(self.list) do + if build.fullFileName == fullFileName then + self:SelectIndex(index) + return + end end end + self.selIndex = nil + self.selValue = nil end function BuildListClass:LoadBuild(build) if build.folderName then - self.controls.path:SetSubPath(self.listMode.subPath .. build.folderName .. "/") + self.controls.path:SetSubPath(build.subPath .. build.folderName .. "/") else main:SetMode("BUILD", build.fullFileName, build.buildName) end @@ -126,7 +139,7 @@ function BuildListClass:RenameBuild(build, copyOnName) end end self.listMode:BuildList() - self:SelByFileName(newFileName) + self:SelByFullFileName(main.buildPath..build.subPath..newFileName) end main:ClosePopup() self.listMode:SelectControl(self) @@ -171,12 +184,21 @@ end function BuildListClass:GetRowValue(column, index, build) if column == 1 then local label + local subPathPrefix = "" + if build.subPath and self.listMode and self.listMode.subPath and build.subPath ~= self.listMode.subPath then + local baseSub = self.listMode.subPath + if build.subPath:sub(1, #baseSub) == baseSub then + subPathPrefix = build.subPath:sub(#baseSub + 1) + else + subPathPrefix = build.subPath + end + end if build.folderName then - label = ">> " .. build.folderName + label = ">> " .. subPathPrefix .. build.folderName else - label = build.buildName or "?" + label = subPathPrefix .. (build.buildName or "?") end - if self.cutBuild and self.cutBuild.buildName == build.buildName and self.cutBuild.folderName == build.folderName then + if self.cutBuild and self.cutBuild.buildName == build.buildName and self.cutBuild.folderName == build.folderName and self.cutBuild.subPath == build.subPath then return "^xC0B0B0"..label else return label @@ -205,18 +227,26 @@ end function BuildListClass:ReceiveDrag(type, build, source) if type == "Build" then if self.hoverValue and self.hoverValue.folderName then - if build.folderName then - main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..self.hoverValue.subPath..self.hoverValue.folderName.."/") - else - os.rename(build.fullFileName, self.listMode:GetDestName(self.listMode.subPath..self.hoverValue.folderName.."/", build.fileName)) + local targetSubPath = self.hoverValue.subPath .. self.hoverValue.folderName .. "/" + if buildListHelpers.CanMoveToSubPath(build, targetSubPath) then + if build.folderName then + main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..targetSubPath) + else + local destPath = self.listMode:GetDestName(targetSubPath, build.fileName) + local res, msg = os.rename(build.fullFileName, destPath) + if not res then + main:OpenMessagePopup("Error", "Couldn't move '"..build.fullFileName.."' to '"..destPath.."': "..(msg or "")) + return + end + end + self.listMode:BuildList() end - self.listMode:BuildList() end end end function BuildListClass:CanDragToValue(index, build, source) - return build.folderName and source.selValue ~= build + return build.folderName and source.selValue ~= build and buildListHelpers.CanMoveToSubPath(source.selValue, build.subPath .. build.folderName .. "/") end function BuildListClass:OnSelClick(index, build, doubleClick) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 33f41615acd..c490fb9a129 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1538,12 +1538,22 @@ function CompareTabClass:OpenImportFolderPopup() controls = { }, } function listHost:BuildList() + self.buildIndex = buildListHelpers.ScanFolder(self.subPath) + self:FilterBuildList() + end + function listHost:FilterBuildList() wipeTable(self.list) - local scanned = buildListHelpers.ScanFolder(self.subPath, searchText) - for _, entry in ipairs(scanned) do + for _, entry in ipairs(buildListHelpers.FilterList(self.buildIndex, self.subPath, searchText)) do t_insert(self.list, entry) end + self:SortList() + end + function listHost:SortList() + local selectedFullFileName = controls.buildList and controls.buildList.selValue and controls.buildList.selValue.fullFileName buildListHelpers.SortList(self.list, sortMode) + if controls.buildList then + controls.buildList:SelByFullFileName(selectedFullFileName) + end end function listHost:SelectControl(control) -- Focus is managed by the popup's ControlHost; this is a no-op for the popup list. @@ -1572,12 +1582,13 @@ function CompareTabClass:OpenImportFolderPopup() -- Search box and sort dropdown sit above the build list. controls.searchText = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 25, 450, 20}, "", "Search", "%c%(%)", 100, function(buf) searchText = buf - listHost:BuildList() + listHost:FilterBuildList() end, nil, nil, true) + controls.searchText:SetPlaceholder("(e.g. class:assassin myfilename)") controls.sort = new("DropDownControl", {"TOPLEFT", nil, "TOPLEFT"}, {475, 25, 210, 20}, buildListHelpers.buildSortDropList, function(index, value) sortMode = value.sortMode main.buildSortMode = value.sortMode - buildListHelpers.SortList(listHost.list, sortMode) + listHost:SortList() end) controls.sort:SelByValue(sortMode, "sortMode") @@ -1590,7 +1601,7 @@ function CompareTabClass:OpenImportFolderPopup() -- navigate folders, import builds, and suppress rename/delete/drag behaviors. function controls.buildList:LoadBuild(build) if build.folderName then - self.controls.path:SetSubPath(self.listMode.subPath .. build.folderName .. "/") + self.controls.path:SetSubPath(build.subPath .. build.folderName .. "/") else importBuildEntry(build) end diff --git a/src/Classes/EditControl.lua b/src/Classes/EditControl.lua index 115d2551e14..97ce2c92fe1 100644 --- a/src/Classes/EditControl.lua +++ b/src/Classes/EditControl.lua @@ -401,6 +401,10 @@ function EditClass:Draw(viewPort, noTooltip) DrawImage(nil, caretX, textY, 1, textHeight) end else + if self.buf == '' and self.placeholder then + SetDrawColor(self.disableCol) + DrawString(textX, textY, "LEFT", textHeight, self.font, self.placeholder) + end local pre = self.textCol .. self.buf:sub(1, self.caret - 1) local post = self.buf:sub(self.caret) if self.protected then diff --git a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua index c9b67025cd6..027bd676b28 100644 --- a/src/Modules/BuildList.lua +++ b/src/Modules/BuildList.lua @@ -16,7 +16,6 @@ function listMode:Init(selBuildName, subPath) if self.initialised then self.subPath = subPath or self.subPath self.controls.buildList.controls.path:SetSubPath(self.subPath) - self.controls.buildList:SelByFileName(selBuildName and selBuildName..".xml") --if main.showPublicBuilds then if false then self.controls.ExtBuildList = self:getPublicBuilds() @@ -24,6 +23,7 @@ function listMode:Init(selBuildName, subPath) self.controls.ExtBuildList = nil end self:BuildList() + self.controls.buildList:SelByFullFileName(selBuildName and main.buildPath..self.subPath..selBuildName..".xml") self:SelectControl(self.controls.buildList) return end @@ -95,13 +95,14 @@ function listMode:Init(selBuildName, subPath) self.controls.searchText = new("EditControl", {"TOP",self.anchor,"TOP"}, {0, 25, 640, 20}, self.filterBuildList, "Search", "%c%(%)", 100, function(buf) main.filterBuildList = buf - self:BuildList() + self:FilterBuildList() end, nil, nil, true) + self.controls.searchText:SetPlaceholder("(e.g. class:assassin myfilename)") self.controls.searchText.width = buildListWidth self.controls.searchText.x = buildListOffset self:BuildList() - self.controls.buildList:SelByFileName(selBuildName and selBuildName..".xml") + self.controls.buildList:SelByFullFileName(selBuildName and main.buildPath..self.subPath..selBuildName..".xml") self:SelectControl(self.controls.buildList) self.initialised = true @@ -138,8 +139,10 @@ function listMode:OnFrame(inputEvents) if self.controls.buildList.copyBuild then local build = self.controls.buildList.copyBuild if build.subPath ~= self.subPath then - if build.folderName then - main:CopyFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..self.subPath) + if not buildListHelpers.CanMoveToSubPath(build, self.subPath) then + main:OpenMessagePopup("Error", "A folder cannot be copied into itself.") + elseif build.folderName then + main:CopyFolder(build.fullFileName, main.buildPath..self.subPath..build.folderName) else copyFile(build.fullFileName, self:GetDestName(self.subPath, build.fileName)) end @@ -151,7 +154,9 @@ function listMode:OnFrame(inputEvents) elseif self.controls.buildList.cutBuild then local build = self.controls.buildList.cutBuild if build.subPath ~= self.subPath then - if build.folderName then + if not buildListHelpers.CanMoveToSubPath(build, self.subPath) then + main:OpenMessagePopup("Error", "A folder cannot be moved into itself.") + elseif build.folderName then main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..self.subPath) else os.rename(build.fullFileName, self:GetDestName(self.subPath, build.fileName)) @@ -180,9 +185,11 @@ function listMode:GetDestName(subPath, fileName) local i = 2 local destName = fileName while true do - local test = io.open(destName, "r") + local test = io.open(main.buildPath..subPath..destName, "r") if test then - destName = fileName .. "[" .. i .. "]" + test:close() + local baseName = fileName:gsub("%.xml$", "") + destName = baseName .. "[" .. i .. "].xml" i = i + 1 else break @@ -192,20 +199,22 @@ function listMode:GetDestName(subPath, fileName) end function listMode:BuildList() + self.buildIndex = buildListHelpers.ScanFolder(self.subPath) + self:FilterBuildList() +end + +function listMode:FilterBuildList() wipeTable(self.list) - local scanned = buildListHelpers.ScanFolder(self.subPath, main.filterBuildList or "") - for _, entry in ipairs(scanned) do + for _, entry in ipairs(buildListHelpers.FilterList(self.buildIndex, self.subPath, main.filterBuildList)) do t_insert(self.list, entry) end self:SortList() end function listMode:SortList() - local oldSelFileName = self.controls.buildList.selValue and self.controls.buildList.selValue.fileName + local oldSelFullFileName = self.controls.buildList.selValue and self.controls.buildList.selValue.fullFileName buildListHelpers.SortList(self.list, main.buildSortMode) - if oldSelFileName then - self.controls.buildList:SelByFileName(oldSelFileName) - end + self.controls.buildList:SelByFullFileName(oldSelFullFileName) end return listMode diff --git a/src/Modules/BuildListHelpers.lua b/src/Modules/BuildListHelpers.lua index aefc34f1745..cb88c5e3f63 100644 --- a/src/Modules/BuildListHelpers.lua +++ b/src/Modules/BuildListHelpers.lua @@ -14,68 +14,128 @@ local buildSortDropList = { { label = "Sort by Level", sortMode = "LEVEL"}, } --- Scan main.buildPath..subPath for .xml builds and sub-folders. --- filterText is an optional substring filter applied to build filenames. --- Returns a freshly allocated list of entries in the shape used by BuildListControl. --- On cloud-read failure opens main:OpenCloudErrorPopup and returns whatever has been --- collected so far (matching the prior in-module behavior in Modules/BuildList). -local function ScanFolder(subPath, filterText) - subPath = subPath or "" - filterText = filterText or "" - local list = { } - local handle - if filterText ~= "" then - handle = NewFileSearch(main.buildPath..subPath.."*"..filterText.."*.xml") - else - handle = NewFileSearch(main.buildPath..subPath.."*.xml") +local function ReadBuildHeader(fullFileName) + local fileHnd = io.open(fullFileName, "r") + if not fileHnd then return { } end + local headerText = fileHnd:read(2048) + fileHnd:close() + if not headerText then + main:OpenCloudErrorPopup(fullFileName) + return end - while handle do - local fileName = handle:GetFileName() - local build = { } - build.fileName = fileName - build.subPath = subPath - build.fullFileName = main.buildPath..subPath..fileName - build.modified = handle:GetFileModifiedTime() - build.buildName = fileName:gsub("%.xml$","") - local fileHnd = io.open(build.fullFileName, "r") - if fileHnd then - local fileText = fileHnd:read("*a") - fileHnd:close() - if not fileText then - main:OpenCloudErrorPopup(build.fullFileName) - return list - end - fileText = fileText:match("()") - if fileText then - local xml = common.xml.ParseXML(fileText.."") - if xml and xml[1] then - build.level = tonumber(xml[1].attrib.level) - build.className = xml[1].attrib.className - build.ascendClassName = xml[1].attrib.ascendClassName - end + + local buildTag = headerText:match("]->") + if not buildTag then return { } end + + return { + level = tonumber(buildTag:match('level="([^"]+)"')), + className = buildTag:match('className="([^"]+)"'), + ascendClassName = buildTag:match('ascendClassName="([^"]+)"'), + } +end + +local function MatchEntry(entry, terms) + for _, term in ipairs(terms) do + local termLower = term:lower() + local val = termLower:match("^class:(.*)$") + if val then + if val ~= "" then + local match = (entry.className and entry.className:lower():find(val, 1, true)) or + (entry.ascendClassName and entry.ascendClassName:lower():find(val, 1, true)) or + (entry.folderName and entry.folderName:lower():find(val, 1, true)) + if not match then return false end end + else + local match = (entry.buildName and entry.buildName:lower():find(termLower, 1, true)) or + (entry.folderName and entry.folderName:lower():find(termLower, 1, true)) or + (entry.className and entry.className:lower():find(termLower, 1, true)) or + (entry.ascendClassName and entry.ascendClassName:lower():find(termLower, 1, true)) or + (entry.subPath and entry.subPath:lower():find(termLower, 1, true)) + if not match then return false end + end + end + return true +end + +-- Recursively index builds and folders below main.buildPath..subPath. +local function ScanFolder(subPath) + subPath = subPath or "" + local list = { } + + local function scanDir(currentSubPath) + local handle = NewFileSearch(main.buildPath..currentSubPath.."*.xml") + while handle do + local fileName = handle:GetFileName() + local fullFileName = main.buildPath..currentSubPath..fileName + local header = ReadBuildHeader(fullFileName) + if not header then return false end + t_insert(list, { + fileName = fileName, + subPath = currentSubPath, + fullFileName = fullFileName, + modified = handle:GetFileModifiedTime(), + buildName = fileName:gsub("%.xml$",""), + level = header.level, + className = header.className, + ascendClassName = header.ascendClassName, + }) + + if not handle:NextFile() then break end + end + + handle = NewFileSearch(main.buildPath..currentSubPath.."*", true) + local subFolders = { } + while handle do + local folderName = handle:GetFileName() + local modified = handle:GetFileModifiedTime() + t_insert(subFolders, { name = folderName, modified = modified }) + if not handle:NextFile() then break end end - t_insert(list, build) - if not handle:NextFile() then - break + + for _, folder in ipairs(subFolders) do + local folderName = folder.name + local nextSubPath = currentSubPath .. folderName .. "/" + t_insert(list, { + folderName = folderName, + subPath = currentSubPath, + fullFileName = main.buildPath..currentSubPath..folderName, + modified = folder.modified + }) + if not scanDir(nextSubPath) then return false end end + return true end - handle = NewFileSearch(main.buildPath..subPath.."*", true) - while handle do - local folderName = handle:GetFileName() - t_insert(list, { - folderName = folderName, - subPath = subPath, - fullFileName = main.buildPath..subPath..folderName, - modified = handle:GetFileModifiedTime() - }) - if not handle:NextFile() then - break + + scanDir(subPath) + return list +end + +-- Filtering the cached index avoids filesystem work on every keystroke. +local function FilterList(index, subPath, filterText) + local terms = { } + for term in (filterText or ""):gmatch("%S+") do + t_insert(terms, term) + end + + local list = { } + for _, entry in ipairs(index or { }) do + if (#terms == 0 and entry.subPath == subPath) or (#terms > 0 and MatchEntry(entry, terms)) then + t_insert(list, entry) end end return list end +local function CanMoveToSubPath(build, targetSubPath) + if build.subPath == targetSubPath then return false end + if build.folderName then + -- MoveFolder and CopyFolder recurse, so their destination cannot be inside the source. + local sourceSubPath = build.subPath .. build.folderName .. "/" + if targetSubPath:sub(1, #sourceSubPath) == sourceSubPath then return false end + end + return true +end + -- Sort the given list in place using the same rules as the startup build list. -- sortMode: "NAME" (default), "CLASS", "EDITED", or "LEVEL". local function SortList(list, sortMode) @@ -135,6 +195,8 @@ end return { buildSortDropList = buildSortDropList, + CanMoveToSubPath = CanMoveToSubPath, + FilterList = FilterList, ScanFolder = ScanFolder, SortList = SortList, }