From 9bc61a37060a472dcaebbbda5ec7ff6342a4006f Mon Sep 17 00:00:00 2001 From: Yann Rouillard Date: Sun, 11 Jun 2023 13:31:54 +0200 Subject: [PATCH 1/4] Fix a race condition issue in Seal filesearch plugin Updated the Seal filesearch plugin to resolve a race condition which occurred when a new spotlight search was initiated before the completion of a previous search. This issue resulted in occasional receipt of obsolete `didFinish` spotlight events. To mitigate this, a unique `hs.spotlight` object is now assigned to each new search, and we disregard any event or callback that is not related to the current search. Additionally, the spotlight file search logic has been isolated into a distinct class for improved clarity. --- Source/Seal.spoon/seal_filesearch.lua | 192 +++++++++++++++++--------- 1 file changed, 127 insertions(+), 65 deletions(-) diff --git a/Source/Seal.spoon/seal_filesearch.lua b/Source/Seal.spoon/seal_filesearch.lua index d55cc626..e7accc91 100644 --- a/Source/Seal.spoon/seal_filesearch.lua +++ b/Source/Seal.spoon/seal_filesearch.lua @@ -27,46 +27,120 @@ obj.maxQueryResults = 40 --- * higher value might give you more results but will give a less snappy experience obj.displayResultsTimeout = 0.2 --- Variables +--- +-- Private variables +--- +obj.fileSearchOptions = { + searchPaths = obj.fileSearchPaths, + searchTimeout = obj.displayResultsTimeout, + maxSearchResults = obj.maxQueryResults +} +obj.currentFileSearch = nil +obj.displayedQueryResults = {} obj.currentQuery = nil -obj.currentQueryResults = {} -obj.currentQueryResultsDisplayed = false -obj.showQueryResultsTimer = nil - -obj.spotlight = hs.spotlight.new() +obj.currentQueryResults = nil -- hammerspoon passes .* as empty query EMPTY_QUERY = ".*" --- Private functions +local log = hs.logger.new('seal_filesearch', 'info') -local stopCurrentSearch = function() - if obj.spotlight:isRunning() then - obj.spotlight:stop() - end - if obj.showQueryResultsTimer ~= nil and obj.showQueryResultsTimer:running() then - obj.showQueryResultsTimer:stop() - end +--- +-- Spotlight Helper class +--- +SpotlightFileSearch = {} + +function SpotlightFileSearch:new(query, callback, options) + object = {} + setmetatable(object, self) + self.__index = self + self.query = query + self.callback = callback + self.searchPaths = options.searchPaths + self.searchTimeout = options.searchTimeout + self.maxSearchResults = options.maxSearchResults + self.searchResults = {} + self.running = false + return object end -local displayQueryResults = function() - stopCurrentSearch() - if not obj.currentQueryResultsDisplayed then - obj.currentQueryResultsDisplayed = true - -- we force seal to refresh the choices so we can serve the real query results - obj.seal.chooser:refreshChoicesCallback() +function SpotlightFileSearch:start() + log.d("starting spotlight filesearch for query " .. self.query) + self.spotlight = hs.spotlight.new() + self.spotlight:searchScopes(self.searchPaths) + self.spotlight:callbackMessages("inProgress", "didFinish") + self.spotlight:setCallback(hs.fnutils.partial(self.handleSpotlightCallback, self)) + self.spotlight:queryString(self:buildSpotlightQuery()) + self.searchTimer = hs.timer.doAfter(self.searchTimeout, hs.fnutils.partial(self.runCallback, self)) + self.spotlight:start() + self.running = true +end + +function SpotlightFileSearch:stop() + log.d("stopping spotlight filesearch for query " .. self.query) + if not self.running then + return end + if self.spotlight:isRunning() then + self.spotlight:stop() + end + if self.searchTimer ~= nil and self.searchTimer:running() then + self.searchTimer:stop() + end + self.running = false end -local buildSpotlightQuery = function(query) - local queryWords = hs.fnutils.split(query, "%s+") +-- + +function SpotlightFileSearch:buildSpotlightQuery() + local queryWords = hs.fnutils.split(self.query, "%s+") local searchFilters = hs.fnutils.map(queryWords, function(word) return [[kMDItemFSName like[c] "*]] .. word .. [[*"]] end) - local spotligthQuery = table.concat(searchFilters, [[ && ]]) - return spotligthQuery + local spotlightQuery = table.concat(searchFilters, [[ && ]]) + return spotlightQuery +end + +function SpotlightFileSearch:handleSpotlightCallback(_, msg, info) + log.d("received spotlight callback " .. msg .. " for query " .. self.query) + if not self.running then + log.d("ignoring spotlight callback for non-running query " .. self.query) + return + end + if msg == "inProgress" and info.kMDQueryUpdateAddedItems ~= nil then + self:updateSearchResults(info.kMDQueryUpdateAddedItems) + end + + if msg == "didFinish" or #self.searchResults >= self.maxSearchResults then + self:runCallback() + end + +end + +function SpotlightFileSearch:updateSearchResults(results) + log.d("received " .. #results .. " spotlight results for query " .. self.query) + for _, item in ipairs(results) do + if #self.searchResults >= self.maxSearchResults then + break + end + table.insert(self.searchResults, item) + end end +function SpotlightFileSearch:runCallback() + log.d("calling spotlight filesearch callback with " .. #self.searchResults .. " results for query .. " .. self.query) + if not self.running then + log.d("skipping calling spotlight filesearch callback for non-running query " .. self.query) + return + end + self:stop() + self.callback(self.query, self.searchResults) +end + +--- +-- Private functions +--- + local convertSpotlightResultToQueryResult = function(item) local icon = hs.image.iconForFile(item.kMDItemPath) local bundleID = item.kMDItemCFBundleIdentifier @@ -84,26 +158,16 @@ local convertSpotlightResultToQueryResult = function(item) } end -local updateQueryResults = function(items) - for _, item in ipairs(items) do - if #obj.currentQueryResults >= obj.maxQueryResults then - break - end - table.insert(obj.currentQueryResults, convertSpotlightResultToQueryResult(item)) - end -end - -local handleSpotlightCallback = function(_, msg, info) - if msg == "inProgress" and info.kMDQueryUpdateAddedItems ~= nil then - updateQueryResults(info.kMDQueryUpdateAddedItems) - end - - if msg == "didFinish" or #obj.currentQueryResults >= obj.maxQueryResults then - displayQueryResults() +local handleFileSearchResults = function(query, searchResults) + if query == obj.currentQuery then + obj.currentQueryResults = hs.fnutils.map(searchResults, convertSpotlightResultToQueryResult) + obj.seal.chooser:refreshChoicesCallback() end end +--- -- Public methods +--- function obj:commands() return { @@ -132,37 +196,35 @@ function obj.completionCallback(rowInfo) end function obj.fileSearch(query) - stopCurrentSearch() + if query ~= obj.currentQuery then + if obj.currentFileSearch ~= nil then + obj.currentFileSearch:stop() + obj.currentFileSearch = nil + end - if query == EMPTY_QUERY then - obj.currentQuery = "" - obj.currentQueryResults = {} - return {} - end + if query == EMPTY_QUERY then + obj.currentQuery = "" + obj.currentQueryResults = {} + obj.displayedQueryResults = {} + else + -- Seal want the results synchronously, but spotlight will return then asynchronously + -- to workaround that, we launch the spotlight search in the background and + -- return the currently displayed results (so that Seal doesn't change the current results list) + -- We force a refresh later once we have the results + obj.currentQuery = query + obj.currentQueryResults = nil + obj.currentFileSearch = SpotlightFileSearch:new(query, handleFileSearchResults, obj.fileSearchOptions) + obj.currentFileSearch:start() + end - if query ~= obj.currentQuery then - -- Seal want the results synchronously, but spotlight will return then asynchronously - -- to workaround that, we launch the spotlight search in the background and - -- return the previous results (so that Seal doesn't change the current results list) - -- We force a refresh later once we have the results - local previousResults = obj.currentQueryResults - obj.currentQuery = query - obj.currentQueryResults = {} - obj.currentQueryResultsDisplayed = false - - obj.spotlight:queryString(buildSpotlightQuery(query)):start() - obj.showQueryResultsTimer = hs.timer.doAfter(obj.displayResultsTimeout, displayQueryResults) - - return previousResults - else + elseif obj.currentQueryResults ~= nil then -- If we are here, it's mean the force refreshed has been triggered after receving spotlight results -- we just return the results we accumulated from spotlight - return obj.currentQueryResults + obj.displayedQueryResults = obj.currentQueryResults end -end + return obj.displayedQueryResults -obj.spotlight:searchScopes(obj.fileSearchPaths):callbackMessages("inProgress", "didFinish"):setCallback( - handleSpotlightCallback) +end return obj From cf679ff37a82168a1342d969687952f7e8bab734 Mon Sep 17 00:00:00 2001 From: Yann Rouillard Date: Sun, 11 Jun 2023 15:33:50 +0200 Subject: [PATCH 2/4] Improve responsiveness of Seal filesearch plugin Contrary to most plugins, the Seal filesearch plugin doesn't quickly filter results from an existing set in memory, but it triggers an actual spotlight search each time. That is more resource-intensive and calling it too frequently for each key typed does hurt the responsiveness of the Seal chooser (lua being single-threaded). To avoid that, the plugin now uses it own `queryChangedTimerDuration` parameter so it can be set to higher value compared to the defaut Seal one. The default of 100mn provides a good responsiveness while still display results with an acceptable delay. --- Source/Seal.spoon/seal_filesearch.lua | 36 ++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/Source/Seal.spoon/seal_filesearch.lua b/Source/Seal.spoon/seal_filesearch.lua index e7accc91..b236c716 100644 --- a/Source/Seal.spoon/seal_filesearch.lua +++ b/Source/Seal.spoon/seal_filesearch.lua @@ -20,13 +20,22 @@ obj.maxQueryResults = 40 --- Seal.plugins.filesearch.displayResultsTimeout --- Variable ---- Maximum time to wait before displaying the results ---- Defaults to 0.2s (200ms). +--- Maximum time to wait before displaying the results. Defaults to 0.2s (200ms). --- --- Notes: --- * higher value might give you more results but will give a less snappy experience obj.displayResultsTimeout = 0.2 +--- Seal.plugins.filesearch.queryChangedTimerDuration +--- Variable +--- Maximum delay after typing a query before initiating a file search. Defaults to 0.1s (100ms). +--- +--- Notes: This plugin uses its own parameter, separate from the Seal one, as each query +--- initiates a new spotlight search that is more resource-intensive than other plugins. +--- To prevent impacting interface responsiveness, a value higher than the default Seal +--- one is typically desired. +obj.queryChangedTimerDuration = 0.1 + --- -- Private variables --- @@ -39,6 +48,7 @@ obj.currentFileSearch = nil obj.displayedQueryResults = {} obj.currentQuery = nil obj.currentQueryResults = nil +obj.delayedFileSearchTimer = nil -- hammerspoon passes .* as empty query EMPTY_QUERY = ".*" @@ -165,6 +175,21 @@ local handleFileSearchResults = function(query, searchResults) end end +local triggerFileSearch = function(query) + if query == obj.currentQuery then + obj.currentFileSearch = SpotlightFileSearch:new(query, handleFileSearchResults, obj.fileSearchOptions) + obj.currentFileSearch:start() + end +end + +local triggerFileSearchAfterDelay = function(query, delay) + if obj.delayedFileSearchTimer ~= nil then + obj.delayedFileSearchTimer:stop() + end + obj.delayedFileSearchTimer = hs.timer.doAfter(delay, hs.fnutils.partial(triggerFileSearch, query)) + obj.delayedFileSearchTimer:start() +end + --- -- Public methods --- @@ -213,8 +238,11 @@ function obj.fileSearch(query) -- We force a refresh later once we have the results obj.currentQuery = query obj.currentQueryResults = nil - obj.currentFileSearch = SpotlightFileSearch:new(query, handleFileSearchResults, obj.fileSearchOptions) - obj.currentFileSearch:start() + if obj.queryChangedTimerDuration == 0 then + triggerFileSearch(query) + else + triggerFileSearchAfterDelay(query, obj.queryChangedTimerDuration) + end end elseif obj.currentQueryResults ~= nil then From 8eba686f7be10e464549c0ca1adc67e4a6f874cf Mon Sep 17 00:00:00 2001 From: Yann Rouillard Date: Sun, 11 Jun 2023 17:05:09 +0200 Subject: [PATCH 3/4] Improve search results order in Seal filesearch plugin Spotlight results naturally put first the items that were recently accessed. However it doesn't put first the item that really contain the word as it (not as a sub-part of the word) whereas it is often the ones you want first typically. We emulate that behaviour by re-ordering the results to boost the item that contains whole words. --- Source/Seal.spoon/seal_filesearch.lua | 59 +++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/Source/Seal.spoon/seal_filesearch.lua b/Source/Seal.spoon/seal_filesearch.lua index b236c716..56b8303b 100644 --- a/Source/Seal.spoon/seal_filesearch.lua +++ b/Source/Seal.spoon/seal_filesearch.lua @@ -55,6 +55,29 @@ EMPTY_QUERY = ".*" local log = hs.logger.new('seal_filesearch', 'info') +--- +-- Generic helper functions +--- + +local imapWithIndex = function(t, mapFn) + local mappedTable = {} + for i, v in ipairs(t) do + mappedTable[#mappedTable + 1] = mapFn(v, i) + end + return mappedTable +end + +local containsWholeWord = function(s, word) + local pattern = "%f[%w]" .. string.lower(word) .. "%f[%W]" + return string.find(string.lower(s), pattern) +end + +local containsCamelCaseWholeWord = function(s, word) + local camelCaseWord = word:sub(1, 1):upper() .. word:sub(2):lower() + local pattern = "%f[%u]" .. camelCaseWord .. "(%f[%u%z])" + return string.find(s, pattern) +end + --- -- Spotlight Helper class --- @@ -151,7 +174,7 @@ end -- Private functions --- -local convertSpotlightResultToQueryResult = function(item) +local convertSpotlightResultToQueryResult = function(item, index) local icon = hs.image.iconForFile(item.kMDItemPath) local bundleID = item.kMDItemCFBundleIdentifier if (not icon) and (bundleID) then @@ -164,13 +187,43 @@ local convertSpotlightResultToQueryResult = function(item) uuid = obj.__name .. "__" .. (bundleID or item.kMDItemDisplayName), plugin = obj.__name, type = "open", - image = icon + image = icon, + index = index } end +-- This function returns a sort function suitable for table.sort +-- that will boost the search results that: +-- * contains the whole word (not part of another word) +-- * contains the word in CamelCase +local buildBoostResultsSortForQuery = function(query) + local queryWords = hs.fnutils.split(query, "%s+") + local scoreItem = function(item) + local score = 0 + for _, word in ipairs(queryWords) do + if containsWholeWord(item.text, word) or containsCamelCaseWholeWord(item.text, word) then + score = score + 1 + end + end + return score + end + + return function(itemA, itemB) + local scoreA = scoreItem(itemA) + local scoreB = scoreItem(itemB) + if scoreA ~= scoreB then + return scoreA > scoreB + else + -- we just preserve the existing order otherwise + return itemA.index < itemB.index + end + end +end + local handleFileSearchResults = function(query, searchResults) if query == obj.currentQuery then - obj.currentQueryResults = hs.fnutils.map(searchResults, convertSpotlightResultToQueryResult) + obj.currentQueryResults = imapWithIndex(searchResults, convertSpotlightResultToQueryResult) + table.sort(obj.currentQueryResults, buildBoostResultsSortForQuery(query)) obj.seal.chooser:refreshChoicesCallback() end end From c01feb2cb8187818d78a39762ff311be148d0ea0 Mon Sep 17 00:00:00 2001 From: Yann Rouillard Date: Sun, 11 Jun 2023 17:35:13 +0200 Subject: [PATCH 4/4] Better handle accentued char in Seal filesearch plugin --- Source/Seal.spoon/seal_filesearch.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Source/Seal.spoon/seal_filesearch.lua b/Source/Seal.spoon/seal_filesearch.lua index 56b8303b..925bf041 100644 --- a/Source/Seal.spoon/seal_filesearch.lua +++ b/Source/Seal.spoon/seal_filesearch.lua @@ -128,7 +128,9 @@ end function SpotlightFileSearch:buildSpotlightQuery() local queryWords = hs.fnutils.split(self.query, "%s+") local searchFilters = hs.fnutils.map(queryWords, function(word) - return [[kMDItemFSName like[c] "*]] .. word .. [[*"]] + -- [c] means case-insensitive + -- [d] means don't care about accents (diacritic) + return [[kMDItemFSName like[cd] "*]] .. word .. [[*"]] end) local spotlightQuery = table.concat(searchFilters, [[ && ]]) return spotlightQuery