From 49170880548b626e90f1e26cf45245f129fad6cf Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Sat, 29 Aug 2026 20:19:58 +0000 Subject: [PATCH 1/2] Apply changes from https://github.com/PathOfBuildingCommunity/PathOfBuilding/pull/10084 --- spec/System/TestBuildListHelpers_spec.lua | 110 ++++++++++++++ src/Classes/BuildListControl.lua | 59 +++++--- src/Classes/BuildListControl.lua.rej | 32 +++++ src/Classes/CompareTab.lua | 16 ++- src/Classes/CompareTab.lua.rej | 17 +++ src/Classes/EditControl.lua | 4 + src/Modules/BuildList.lua | 32 +++-- src/Modules/BuildList.lua.rej | 18 +++ src/Modules/BuildListHelpers.lua | 166 +++++++++++++++------- 9 files changed, 370 insertions(+), 84 deletions(-) create mode 100644 spec/System/TestBuildListHelpers_spec.lua create mode 100644 src/Classes/BuildListControl.lua.rej create mode 100644 src/Classes/CompareTab.lua.rej create mode 100644 src/Modules/BuildList.lua.rej diff --git a/spec/System/TestBuildListHelpers_spec.lua b/spec/System/TestBuildListHelpers_spec.lua new file mode 100644 index 0000000000..81262568fd --- /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 65e2083c7d..d2e7af5076 100644 --- a/src/Classes/BuildListControl.lua +++ b/src/Classes/BuildListControl.lua @@ -35,13 +35,21 @@ function BuildListClass:BuildListControl(anchor, rect, listMode) 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 @@ -64,7 +72,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 @@ -133,7 +141,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) @@ -178,12 +186,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 @@ -212,18 +229,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/BuildListControl.lua.rej b/src/Classes/BuildListControl.lua.rej new file mode 100644 index 0000000000..b4beca772b --- /dev/null +++ b/src/Classes/BuildListControl.lua.rej @@ -0,0 +1,32 @@ +diff a/src/Classes/BuildListControl.lua b/src/Classes/BuildListControl.lua (rejected hunks) +@@ -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) +@@ -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) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 24ce2fca35..3879823d90 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1593,12 +1593,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. @@ -1645,7 +1655,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/CompareTab.lua.rej b/src/Classes/CompareTab.lua.rej new file mode 100644 index 0000000000..c3836aa03f --- /dev/null +++ b/src/Classes/CompareTab.lua.rej @@ -0,0 +1,17 @@ +diff a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua (rejected hunks) +@@ -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/Classes/EditControl.lua b/src/Classes/EditControl.lua index 22ddd51e3e..ff60d9dedd 100644 --- a/src/Classes/EditControl.lua +++ b/src/Classes/EditControl.lua @@ -405,6 +405,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 9162fdc076..2a71d57a2c 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 @@ -138,8 +138,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 +153,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 +184,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 +198,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/BuildList.lua.rej b/src/Modules/BuildList.lua.rej new file mode 100644 index 0000000000..4221c4a86c --- /dev/null +++ b/src/Modules/BuildList.lua.rej @@ -0,0 +1,18 @@ +diff a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua (rejected hunks) +@@ -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 diff --git a/src/Modules/BuildListHelpers.lua b/src/Modules/BuildListHelpers.lua index aefc34f174..cb88c5e3f6 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, } From 98da1bdea7143c0ecd871be9a20bbd859a889fca Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Sun, 30 Aug 2026 07:32:40 +1000 Subject: [PATCH 2/2] Fix port --- spec/System/TestBuildListHelpers_spec.lua | 48 ++++++++++++++++++-- src/Classes/BuildListControl.lua | 15 ++++--- src/Classes/BuildListControl.lua.rej | 32 -------------- src/Classes/CompareTab.lua | 5 ++- src/Classes/CompareTab.lua.rej | 17 ------- src/Modules/BuildList.lua | 17 +++++-- src/Modules/BuildList.lua.rej | 18 -------- src/Modules/BuildListHelpers.lua | 54 +++++++++++++++++------ 8 files changed, 112 insertions(+), 94 deletions(-) delete mode 100644 src/Classes/BuildListControl.lua.rej delete mode 100644 src/Classes/CompareTab.lua.rej delete mode 100644 src/Modules/BuildList.lua.rej diff --git a/spec/System/TestBuildListHelpers_spec.lua b/spec/System/TestBuildListHelpers_spec.lua index 81262568fd..f390ff60d4 100644 --- a/spec/System/TestBuildListHelpers_spec.lua +++ b/spec/System/TestBuildListHelpers_spec.lua @@ -2,6 +2,7 @@ describe("BuildListHelpers", function() local originalBuildPath local originalCloudErrorPopup local originalFileSearch + local originalFilterBuildList local originalOpen local buildListHelpers local fileHeaders @@ -14,6 +15,7 @@ describe("BuildListHelpers", function() originalBuildPath = main.buildPath originalCloudErrorPopup = main.OpenCloudErrorPopup originalFileSearch = _G.NewFileSearch + originalFilterBuildList = main.filterBuildList originalOpen = io.open buildListHelpers = LoadModule("Modules/BuildListHelpers") fileHeaders = { } @@ -22,6 +24,7 @@ describe("BuildListHelpers", function() searchCount = 0 cloudErrorPath = nil main.buildPath = "Builds/" + main.filterBuildList = "" main.OpenCloudErrorPopup = function(_, path) cloudErrorPath = path end @@ -53,6 +56,7 @@ describe("BuildListHelpers", function() main.buildPath = originalBuildPath main.OpenCloudErrorPopup = originalCloudErrorPopup _G.NewFileSearch = originalFileSearch + main.filterBuildList = originalFilterBuildList io.open = originalOpen end) @@ -60,14 +64,14 @@ describe("BuildListHelpers", 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"] = '' + 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") + local classMatches = buildListHelpers.FilterList(index, "", "CLASS:invoker") assert.are.same(3, #index) assert.are.same(2, #directEntries) @@ -77,6 +81,42 @@ describe("BuildListHelpers", function() assert.are.same(opensAfterIndex, fileOpenCount) end) + it("filters the startup build list 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 listMode = LoadModule("Modules/BuildList") + + listMode:Init() + local scansAfterIndex = searchCount + listMode.controls.searchText.changeFunc("class:invoker") + + assert.are.same(scansAfterIndex, searchCount) + assert.are.same(1, #listMode.list) + assert.are.same("Nested.xml", listMode.list[1].fileName) + assert.are.same("(e.g. class:invoker myfilename)", listMode.controls.searchText.placeholder) + assert.are.same("Builds/League/Nested[2].xml", listMode:GetDestName("League/", "Nested.xml")) + local opensBeforeReturn = fileOpenCount + + searches["files:Builds/Root.xml"] = { { name = "Root.xml", modified = 4 } } + fileHeaders["Builds/Root.xml"] = '' + main.filterBuildList = "" + listMode:Init("Root", "") + + assert.are.same(scansAfterIndex + 1, searchCount) + assert.are.same(opensBeforeReturn + 1, fileOpenCount) + assert.are.same(81, listMode.controls.buildList.selValue.level) + assert.are.same("Invoker", listMode.controls.buildList.selValue.ascendClassName) + assert.are.same(4, listMode.controls.buildList.selValue.modified) + + local scansBeforeFolderChange = searchCount + listMode:Init("Nested", "League/") + assert.are.same(scansBeforeFolderChange + 2, searchCount) + assert.are.same("Builds/League/Nested.xml", listMode.controls.buildList.selValue.fullFileName) + end) + it("reports cloud read failures", function() searches["files:Builds/*.xml"] = { { name = "Cloud.xml" } } fileHeaders["Builds/Cloud.xml"] = false @@ -97,7 +137,7 @@ describe("BuildListHelpers", function() subPath = "", BuildList = function() end, } - local control = new("BuildListControl", nil, { 0, 0, 500, 500 }, listMode) + local control = new("BuildListControl"):BuildListControl(nil, { 0, 0, 500, 500 }, listMode) control:SelByFullFileName("Builds/Other/Same.xml") diff --git a/src/Classes/BuildListControl.lua b/src/Classes/BuildListControl.lua index d2e7af5076..7c3ec18b53 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") ---@class BuildListControl: ListControl local BuildListClass = newClass("BuildListControl", "ListControl") @@ -61,13 +62,17 @@ function BuildListClass:BuildListControl(anchor, rect, listMode) return self 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) diff --git a/src/Classes/BuildListControl.lua.rej b/src/Classes/BuildListControl.lua.rej deleted file mode 100644 index b4beca772b..0000000000 --- a/src/Classes/BuildListControl.lua.rej +++ /dev/null @@ -1,32 +0,0 @@ -diff a/src/Classes/BuildListControl.lua b/src/Classes/BuildListControl.lua (rejected hunks) -@@ -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) -@@ -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) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 3879823d90..b9de34ec03 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1637,12 +1637,13 @@ function CompareTabClass:OpenImportFolderPopup() -- Search box and sort dropdown sit above the build list. controls.searchText = new("EditControl"):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:invoker myfilename)") controls.sort = new("DropDownControl"):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/Classes/CompareTab.lua.rej b/src/Classes/CompareTab.lua.rej deleted file mode 100644 index c3836aa03f..0000000000 --- a/src/Classes/CompareTab.lua.rej +++ /dev/null @@ -1,17 +0,0 @@ -diff a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua (rejected hunks) -@@ -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 2a71d57a2c..176065891f 100644 --- a/src/Modules/BuildList.lua +++ b/src/Modules/BuildList.lua @@ -14,6 +14,8 @@ local listMode = new("ControlHost"):ControlHost() function listMode:Init(selBuildName, subPath) if self.initialised then + local subPathChanged = subPath and subPath ~= self.subPath + local reuseBuildIndex = not subPathChanged and self.indexedBuildPath == main.buildPath self.subPath = subPath or self.subPath self.controls.buildList.controls.path:SetSubPath(self.subPath) --if main.showPublicBuilds then @@ -22,7 +24,14 @@ function listMode:Init(selBuildName, subPath) else self.controls.ExtBuildList = nil end - self:BuildList() + if reuseBuildIndex then + if selBuildName then + buildListHelpers.RefreshBuild(self.buildIndex, self.subPath, selBuildName..".xml") + end + self:FilterBuildList() + elseif not subPathChanged then + self:BuildList() + end self.controls.buildList:SelByFullFileName(selBuildName and main.buildPath..self.subPath..selBuildName..".xml") self:SelectControl(self.controls.buildList) return @@ -95,13 +104,14 @@ function listMode:Init(selBuildName, subPath) self.controls.searchText = new("EditControl"):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:invoker 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 @@ -198,6 +208,7 @@ function listMode:GetDestName(subPath, fileName) end function listMode:BuildList() + self.indexedBuildPath = main.buildPath self.buildIndex = buildListHelpers.ScanFolder(self.subPath) self:FilterBuildList() end diff --git a/src/Modules/BuildList.lua.rej b/src/Modules/BuildList.lua.rej deleted file mode 100644 index 4221c4a86c..0000000000 --- a/src/Modules/BuildList.lua.rej +++ /dev/null @@ -1,18 +0,0 @@ -diff a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua (rejected hunks) -@@ -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 diff --git a/src/Modules/BuildListHelpers.lua b/src/Modules/BuildListHelpers.lua index cb88c5e3f6..b8e45114eb 100644 --- a/src/Modules/BuildListHelpers.lua +++ b/src/Modules/BuildListHelpers.lua @@ -34,6 +34,22 @@ local function ReadBuildHeader(fullFileName) } end +local function ReadBuild(subPath, fileName, modified) + local fullFileName = main.buildPath..subPath..fileName + local header = ReadBuildHeader(fullFileName) + if not header then return end + return { + fileName = fileName, + subPath = subPath, + fullFileName = fullFileName, + modified = modified, + buildName = fileName:gsub("%.xml$",""), + level = header.level, + className = header.className, + ascendClassName = header.ascendClassName, + } +end + local function MatchEntry(entry, terms) for _, term in ipairs(terms) do local termLower = term:lower() @@ -66,19 +82,9 @@ local function ScanFolder(subPath) 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, - }) + local build = ReadBuild(currentSubPath, fileName, handle:GetFileModifiedTime()) + if not build then return false end + t_insert(list, build) if not handle:NextFile() then break end end @@ -110,6 +116,27 @@ local function ScanFolder(subPath) return list end +local function RefreshBuild(index, subPath, fileName) + local fullFileName = main.buildPath..subPath..fileName + local handle = NewFileSearch(fullFileName) + local build = handle and ReadBuild(subPath, fileName, handle:GetFileModifiedTime()) + if handle and not build then return end + + for buildIndex, entry in ipairs(index) do + if entry.fullFileName == fullFileName then + if build then + index[buildIndex] = build + else + table.remove(index, buildIndex) + end + return + end + end + if build then + t_insert(index, build) + end +end + -- Filtering the cached index avoids filesystem work on every keystroke. local function FilterList(index, subPath, filterText) local terms = { } @@ -197,6 +224,7 @@ return { buildSortDropList = buildSortDropList, CanMoveToSubPath = CanMoveToSubPath, FilterList = FilterList, + RefreshBuild = RefreshBuild, ScanFolder = ScanFolder, SortList = SortList, }