-
Notifications
You must be signed in to change notification settings - Fork 141
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
base: master
Are you sure you want to change the base?
Changes from all commits
9bc61a3
cf679ff
8eba686
c01feb2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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() | ||
--- | ||
-- 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() | ||
--- | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
||
--- | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
||
--- | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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 | ||
|
||
--- | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here, re |
||
-- Public methods | ||
--- | ||
|
||
function obj:commands() | ||
return { | ||
|
@@ -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 |
There was a problem hiding this comment.
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.