From d033b0a31ab2be3c63b65c3b356a54e0c0024d31 Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Sat, 1 Aug 2026 09:54:26 +0200 Subject: [PATCH 1/4] Allow recursive build list search with class filtering --- src/Classes/BuildListControl.lua | 53 +++++++--- src/Classes/CompareTab.lua | 5 +- src/Classes/EditControl.lua | 4 + src/Modules/BuildList.lua | 9 +- src/Modules/BuildListHelpers.lua | 168 ++++++++++++++++++++++--------- 5 files changed, 172 insertions(+), 67 deletions(-) diff --git a/src/Classes/BuildListControl.lua b/src/Classes/BuildListControl.lua index 3f42430ce9a..191c3c3a181 100644 --- a/src/Classes/BuildListControl.lua +++ b/src/Classes/BuildListControl.lua @@ -29,13 +29,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 build.subPath ~= 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 @@ -57,7 +65,7 @@ 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 @@ -171,10 +179,19 @@ 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 return "^xC0B0B0"..label @@ -205,12 +222,20 @@ 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 build.subPath ~= 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 diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 33f41615acd..3dda42de236 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1570,10 +1570,11 @@ function CompareTabClass:OpenImportFolderPopup() end -- 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) + controls.searchText = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 25, 450, 20}, "", nil, "%c%(%)", 100, function(buf) searchText = buf listHost:BuildList() end, nil, nil, true) + controls.searchText:SetPlaceholder("Search (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 @@ -1590,7 +1591,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..ccd9726f100 100644 --- a/src/Modules/BuildList.lua +++ b/src/Modules/BuildList.lua @@ -93,10 +93,11 @@ function listMode:Init(selBuildName, subPath) self.controls.ExtBuildList = self:getPublicBuilds() end - self.controls.searchText = new("EditControl", {"TOP",self.anchor,"TOP"}, {0, 25, 640, 20}, self.filterBuildList, "Search", "%c%(%)", 100, function(buf) + self.controls.searchText = new("EditControl", {"TOP",self.anchor,"TOP"}, {0, 25, 640, 20}, self.filterBuildList, nil, "%c%(%)", 100, function(buf) main.filterBuildList = buf self:BuildList() end, nil, nil, true) + self.controls.searchText:SetPlaceholder("Search (e.g. class:assassin myfilename)") self.controls.searchText.width = buildListWidth self.controls.searchText.x = buildListOffset @@ -180,9 +181,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 diff --git a/src/Modules/BuildListHelpers.lua b/src/Modules/BuildListHelpers.lua index aefc34f1745..54a583b094d 100644 --- a/src/Modules/BuildListHelpers.lua +++ b/src/Modules/BuildListHelpers.lua @@ -14,65 +14,137 @@ 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 ReadBuildHeader(fullFileName) + local fileHnd = io.open(fullFileName, "r") + if not fileHnd then return nil, nil, nil end + local headerText = fileHnd:read(2048) + fileHnd:close() + if not headerText then return nil, nil, nil end + + local buildTag = headerText:match("]->") + if not buildTag then return nil, nil, nil end + + local level = tonumber(buildTag:match('level="([^"]+)"')) + local className = buildTag:match('className="([^"]+)"') + local ascendClassName = buildTag:match('ascendClassName="([^"]+)"') + return level, className, ascendClassName +end + +local function MatchEntry(entry, terms) + for _, term in ipairs(terms) do + local val = term:match("^class:(.*)$") + if val then + val = val:lower() + 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 termLower = term:lower() + 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 + 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 terms = { } + if filterText:match("%S") then + for term in filterText:gmatch("%S+") do + t_insert(terms, term) + end 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 + + local function scanDir(currentSubPath) + local handle = NewFileSearch(main.buildPath..currentSubPath.."*.xml") + while handle do + local fileName = handle:GetFileName() + local buildName = fileName:gsub("%.xml$","") + local fullFileName = main.buildPath..currentSubPath..fileName + + if #terms == 0 then + if currentSubPath == subPath then + local level, className, ascendClassName = ReadBuildHeader(fullFileName) + t_insert(list, { + fileName = fileName, + subPath = currentSubPath, + fullFileName = fullFileName, + modified = handle:GetFileModifiedTime(), + buildName = buildName, + level = level, + className = className, + ascendClassName = ascendClassName, + }) + end + else + local level, className, ascendClassName = ReadBuildHeader(fullFileName) + local entry = { + fileName = fileName, + subPath = currentSubPath, + fullFileName = fullFileName, + modified = handle:GetFileModifiedTime(), + buildName = buildName, + level = level, + className = className, + ascendClassName = ascendClassName, + } + if MatchEntry(entry, terms) then + t_insert(list, entry) end end + + if not handle:NextFile() then break end end - t_insert(list, build) - if not handle:NextFile() then - break + + 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 - 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 + + for _, folder in ipairs(subFolders) do + local folderName = folder.name + local nextSubPath = currentSubPath .. folderName .. "/" + + if #terms == 0 then + if currentSubPath == subPath then + t_insert(list, { + folderName = folderName, + subPath = currentSubPath, + fullFileName = main.buildPath..currentSubPath..folderName, + modified = folder.modified + }) + end + else + local folderEntry = { + folderName = folderName, + subPath = currentSubPath, + fullFileName = main.buildPath..currentSubPath..folderName, + modified = folder.modified + } + if MatchEntry(folderEntry, terms) then + t_insert(list, folderEntry) + end + scanDir(nextSubPath) + end end end + + scanDir(subPath) return list end From c12e625bd37d8e663be9de13b0f44070d65ead16 Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Sat, 1 Aug 2026 10:01:25 +0200 Subject: [PATCH 2/4] Cleaner placeholder --- src/Classes/CompareTab.lua | 4 ++-- src/Modules/BuildList.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 3dda42de236..7c4ecab95cd 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1570,11 +1570,11 @@ function CompareTabClass:OpenImportFolderPopup() end -- Search box and sort dropdown sit above the build list. - controls.searchText = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 25, 450, 20}, "", nil, "%c%(%)", 100, function(buf) + controls.searchText = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 25, 450, 20}, "", "Search", "%c%(%)", 100, function(buf) searchText = buf listHost:BuildList() end, nil, nil, true) - controls.searchText:SetPlaceholder("Search (e.g. class:assassin myfilename)") + 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 diff --git a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua index ccd9726f100..62cc542f653 100644 --- a/src/Modules/BuildList.lua +++ b/src/Modules/BuildList.lua @@ -93,11 +93,11 @@ function listMode:Init(selBuildName, subPath) self.controls.ExtBuildList = self:getPublicBuilds() end - self.controls.searchText = new("EditControl", {"TOP",self.anchor,"TOP"}, {0, 25, 640, 20}, self.filterBuildList, nil, "%c%(%)", 100, function(buf) + 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() end, nil, nil, true) - self.controls.searchText:SetPlaceholder("Search (e.g. class:assassin myfilename)") + self.controls.searchText:SetPlaceholder("(e.g. class:assassin myfilename)") self.controls.searchText.width = buildListWidth self.controls.searchText.x = buildListOffset From 5455ff5dcaeabbd3e45e0adabdb19f82567ec54e Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Sat, 1 Aug 2026 10:20:16 +0200 Subject: [PATCH 3/4] Comment --- src/Modules/BuildListHelpers.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Modules/BuildListHelpers.lua b/src/Modules/BuildListHelpers.lua index 54a583b094d..dbe6ed26c1a 100644 --- a/src/Modules/BuildListHelpers.lua +++ b/src/Modules/BuildListHelpers.lua @@ -54,6 +54,10 @@ local function MatchEntry(entry, terms) return true end +-- Scan main.buildPath..subPath for .xml builds and sub-folders. +-- filterText is an optional space-separated filter with class: prefix support. +-- Recursively searches subfolders when filterText is non-empty. +-- Returns a list of build and folder entries for BuildListControl. local function ScanFolder(subPath, filterText) subPath = subPath or "" filterText = filterText or "" From e7fa70b744fca7e7e4dfacd553f42efcbdb60d98 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Mon, 3 Aug 2026 18:48:58 +1000 Subject: [PATCH 4/4] Fix number of issues with previous implementation Fix recursive build search safety and performance Cache the recursive build index so searches filter in memory instead of rescanning files on every keystroke. Prevent folders from being copied or moved into their own descendants, preserve selections using full paths, and restore cloud-read error reporting. Also fix folder copy paths and add regression tests for filtering, duplicate filenames, cloud failures, and unsafe folder targets. --- spec/System/TestBuildListHelpers_spec.lua | 110 +++++++++++++++++ src/Classes/BuildListControl.lua | 25 ++-- src/Classes/CompareTab.lua | 18 ++- src/Modules/BuildList.lua | 30 +++-- src/Modules/BuildListHelpers.lua | 140 ++++++++++------------ 5 files changed, 220 insertions(+), 103 deletions(-) create mode 100644 spec/System/TestBuildListHelpers_spec.lua 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 191c3c3a181..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) @@ -30,7 +31,7 @@ local BuildListClass = newClass("BuildListControl", "ListControl", function(self if type == "Build" then for index, folder in ipairs(self.folderList) do if folder.button:IsMouseOver() then - if build.subPath ~= folder.path 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 @@ -54,13 +55,17 @@ 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) @@ -134,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) @@ -193,7 +198,7 @@ function BuildListClass:GetRowValue(column, index, build) else 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 @@ -223,7 +228,7 @@ function BuildListClass:ReceiveDrag(type, build, source) if type == "Build" then if self.hoverValue and self.hoverValue.folderName then local targetSubPath = self.hoverValue.subPath .. self.hoverValue.folderName .. "/" - if build.subPath ~= targetSubPath then + if buildListHelpers.CanMoveToSubPath(build, targetSubPath) then if build.folderName then main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..targetSubPath) else @@ -241,7 +246,7 @@ function BuildListClass:ReceiveDrag(type, build, source) 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 7c4ecab95cd..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,13 +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") diff --git a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua index 62cc542f653..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,14 +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 @@ -139,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 @@ -152,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)) @@ -195,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 dbe6ed26c1a..cb88c5e3f63 100644 --- a/src/Modules/BuildListHelpers.lua +++ b/src/Modules/BuildListHelpers.lua @@ -16,25 +16,29 @@ local buildSortDropList = { local function ReadBuildHeader(fullFileName) local fileHnd = io.open(fullFileName, "r") - if not fileHnd then return nil, nil, nil end + if not fileHnd then return { } end local headerText = fileHnd:read(2048) fileHnd:close() - if not headerText then return nil, nil, nil end + if not headerText then + main:OpenCloudErrorPopup(fullFileName) + return + end local buildTag = headerText:match("]->") - if not buildTag then return nil, nil, nil end + if not buildTag then return { } end - local level = tonumber(buildTag:match('level="([^"]+)"')) - local className = buildTag:match('className="([^"]+)"') - local ascendClassName = buildTag:match('ascendClassName="([^"]+)"') - return level, className, ascendClassName + 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 val = term:match("^class:(.*)$") + local termLower = term:lower() + local val = termLower:match("^class:(.*)$") if val then - val = val:lower() 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 @@ -42,7 +46,6 @@ local function MatchEntry(entry, terms) if not match then return false end end else - local termLower = term:lower() 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 @@ -54,59 +57,28 @@ local function MatchEntry(entry, terms) return true end --- Scan main.buildPath..subPath for .xml builds and sub-folders. --- filterText is an optional space-separated filter with class: prefix support. --- Recursively searches subfolders when filterText is non-empty. --- Returns a list of build and folder entries for BuildListControl. -local function ScanFolder(subPath, filterText) +-- Recursively index builds and folders below main.buildPath..subPath. +local function ScanFolder(subPath) subPath = subPath or "" - filterText = filterText or "" local list = { } - local terms = { } - if filterText:match("%S") then - for term in filterText:gmatch("%S+") do - t_insert(terms, term) - end - end - local function scanDir(currentSubPath) local handle = NewFileSearch(main.buildPath..currentSubPath.."*.xml") while handle do local fileName = handle:GetFileName() - local buildName = fileName:gsub("%.xml$","") local fullFileName = main.buildPath..currentSubPath..fileName - - if #terms == 0 then - if currentSubPath == subPath then - local level, className, ascendClassName = ReadBuildHeader(fullFileName) - t_insert(list, { - fileName = fileName, - subPath = currentSubPath, - fullFileName = fullFileName, - modified = handle:GetFileModifiedTime(), - buildName = buildName, - level = level, - className = className, - ascendClassName = ascendClassName, - }) - end - else - local level, className, ascendClassName = ReadBuildHeader(fullFileName) - local entry = { - fileName = fileName, - subPath = currentSubPath, - fullFileName = fullFileName, - modified = handle:GetFileModifiedTime(), - buildName = buildName, - level = level, - className = className, - ascendClassName = ascendClassName, - } - if MatchEntry(entry, terms) then - t_insert(list, entry) - end - end + 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 @@ -123,35 +95,47 @@ local function ScanFolder(subPath, filterText) for _, folder in ipairs(subFolders) do local folderName = folder.name local nextSubPath = currentSubPath .. folderName .. "/" - - if #terms == 0 then - if currentSubPath == subPath then - t_insert(list, { - folderName = folderName, - subPath = currentSubPath, - fullFileName = main.buildPath..currentSubPath..folderName, - modified = folder.modified - }) - end - else - local folderEntry = { - folderName = folderName, - subPath = currentSubPath, - fullFileName = main.buildPath..currentSubPath..folderName, - modified = folder.modified - } - if MatchEntry(folderEntry, terms) then - t_insert(list, folderEntry) - end - scanDir(nextSubPath) - end + 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 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) @@ -211,6 +195,8 @@ end return { buildSortDropList = buildSortDropList, + CanMoveToSubPath = CanMoveToSubPath, + FilterList = FilterList, ScanFolder = ScanFolder, SortList = SortList, }