Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve seal filesearch plugin #301

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 208 additions & 63 deletions Source/Seal.spoon/seal_filesearch.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,54 +20,163 @@ 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

-- Variables
obj.currentQuery = nil
obj.currentQueryResults = {}
obj.currentQueryResultsDisplayed = false
obj.showQueryResultsTimer = nil
--- 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

obj.spotlight = hs.spotlight.new()
---
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having --- here makes the docstrings parser think you're starting a new entry, but there is nothing specified. This line and line 41 need to be reduced to two dashes.

-- Private variables
---
obj.fileSearchOptions = {
searchPaths = obj.fileSearchPaths,
searchTimeout = obj.displayResultsTimeout,
maxSearchResults = obj.maxQueryResults
}
obj.currentFileSearch = nil
obj.displayedQueryResults = {}
obj.currentQuery = nil
obj.currentQueryResults = nil
obj.delayedFileSearchTimer = 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()
---
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, re ---.

-- 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 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()
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

---
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, re ---.

-- 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

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 .. [[*"]]
-- [c] means case-insensitive
-- [d] means don't care about accents (diacritic)
return [[kMDItemFSName like[cd] "*]] .. 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

local convertSpotlightResultToQueryResult = function(item)
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

---
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, re ---.

-- Private functions
---

local convertSpotlightResultToQueryResult = function(item, index)
local icon = hs.image.iconForFile(item.kMDItemPath)
local bundleID = item.kMDItemCFBundleIdentifier
if (not icon) and (bundleID) then
Expand All @@ -80,30 +189,65 @@ local convertSpotlightResultToQueryResult = function(item)
uuid = obj.__name .. "__" .. (bundleID or item.kMDItemDisplayName),
plugin = obj.__name,
type = "open",
image = icon
image = icon,
index = index
}
end

local updateQueryResults = function(items)
for _, item in ipairs(items) do
if #obj.currentQueryResults >= obj.maxQueryResults then
break
-- 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
table.insert(obj.currentQueryResults, convertSpotlightResultToQueryResult(item))
end
end

local handleSpotlightCallback = function(_, msg, info)
if msg == "inProgress" and info.kMDQueryUpdateAddedItems ~= nil then
updateQueryResults(info.kMDQueryUpdateAddedItems)
local handleFileSearchResults = function(query, searchResults)
if query == obj.currentQuery then
obj.currentQueryResults = imapWithIndex(searchResults, convertSpotlightResultToQueryResult)
table.sort(obj.currentQueryResults, buildBoostResultsSortForQuery(query))
obj.seal.chooser:refreshChoicesCallback()
end
end

if msg == "didFinish" or #obj.currentQueryResults >= obj.maxQueryResults then
displayQueryResults()
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

---
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, re ---.

-- Public methods
---

function obj:commands()
return {
Expand Down Expand Up @@ -132,37 +276,38 @@ 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
if obj.queryChangedTimerDuration == 0 then
triggerFileSearch(query)
else
triggerFileSearchAfterDelay(query, obj.queryChangedTimerDuration)
end
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
Loading