Skip to content

Commit

Permalink
feat: faster, more robust search
Browse files Browse the repository at this point in the history
  • Loading branch information
josdejong authored Jun 30, 2021
1 parent 03c5b6c commit 5e4c3ed
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 242 deletions.
53 changes: 24 additions & 29 deletions src/components/modes/treemode/TreeMode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@
import {
CONTEXT_MENU_HEIGHT,
CONTEXT_MENU_WIDTH,
MAX_SEARCH_RESULTS,
SCROLL_DURATION,
SEARCH_PROGRESS_THROTTLE,
SEARCH_UPDATE_THROTTLE,
SIMPLE_MODAL_OPTIONS,
SORT_MODAL_OPTIONS,
STATE_EXPANDED,
Expand All @@ -49,7 +48,7 @@
insert
} from '../../../logic/operations.js'
import {
searchAsync,
search,
searchNext,
searchPrevious,
updateSearchResult
Expand Down Expand Up @@ -171,19 +170,6 @@
let searching = false
let searchText = ''
let searchResult
let searchHandler
function handleSearchProgress (results) {
searchResult = updateSearchResult(json, results, searchResult)
}
const handleSearchProgressDebounced = throttle(handleSearchProgress, SEARCH_PROGRESS_THROTTLE)
function handleSearchDone (results) {
searchResult = updateSearchResult(json, results, searchResult)
searching = false
// debug('finished search')
}
async function handleSearchText (text) {
searchText = text
Expand All @@ -210,23 +196,32 @@
}
}
$: {
// cancel previous search when still running
if (searchHandler && searchHandler.cancel) {
// debug('cancel previous search')
searchHandler.cancel()
function applySearch () {
if (searchText === '') {
searchResult = undefined
return
}
// debug('start search', searchText)
searching = true
searchHandler = searchAsync(searchText, json, state, {
onProgress: handleSearchProgressDebounced,
onDone: handleSearchDone,
maxResults: MAX_SEARCH_RESULTS
// setTimeout is to wait until the search icon has been rendered
setTimeout(() => {
debug('searching...', searchText)
// console.time('search') // TODO: cleanup
const flatResults = search(searchText, json, state)
searchResult = updateSearchResult(json, flatResults, searchResult)
// console.timeEnd('search') // TODO: cleanup
searching = false
})
}
const applySearchThrottled = throttle(applySearch, SEARCH_UPDATE_THROTTLE)
// we pass non-used arguments searchText and json to trigger search when these variables change
$: applySearchThrottled(searchText, json)
/**
* @param {ValidationError} error
**/
Expand Down Expand Up @@ -471,9 +466,9 @@
}
// TODO: cleanup logging
$: debug('json', json)
$: debug('state', state)
$: debug('selection', selection)
// $: debug('json', json)
// $: debug('state', state)
// $: debug('selection', selection)
function hasSelectionContents () {
return selection && (
Expand Down
2 changes: 1 addition & 1 deletion src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const VALIDATION_ERROR = Symbol('validation:error')

export const SCROLL_DURATION = 300 // ms
export const DEBOUNCE_DELAY = 300
export const SEARCH_PROGRESS_THROTTLE = 300 // ms
export const SEARCH_UPDATE_THROTTLE = 300 // ms
export const CHECK_VALID_JSON_DELAY = 300 // ms
export const MAX_SEARCH_RESULTS = 1000
export const ARRAY_SECTION_SIZE = 100
Expand Down
5 changes: 1 addition & 4 deletions src/logic/documentState.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import createDebug from 'debug'
import {
compileJSONPointer,
existsIn,
Expand All @@ -25,8 +24,6 @@ import {
nextRoundNumber
} from './expandItemsSections.js'

const debug = createDebug('jsoneditor:documentState')

/**
* Sync a state object with the json it belongs to: update keys, limit, and expanded state
*
Expand Down Expand Up @@ -480,7 +477,7 @@ export function documentStatePatch (json, state, operations) {
return updatedState
}

debug('documentStatePatch', json, state, operations)
// debug('documentStatePatch', json, state, operations) // TODO: cleanup logging

const updatedJson = immutableJSONPatch(json, operations)
const initializedState = initializeState(json, state, operations)
Expand Down
121 changes: 33 additions & 88 deletions src/logic/search.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsIn, getIn, setIn } from 'immutable-json-patch'
import { initial, isEqual } from 'lodash-es'
import {
ACTIVE_SEARCH_RESULT,
Expand All @@ -6,8 +7,6 @@ import {
STATE_SEARCH_PROPERTY,
STATE_SEARCH_VALUE
} from '../constants.js'
import { existsIn, getIn, setIn } from 'immutable-json-patch'
import { valueType } from '../utils/typeUtils.js'

/**
* @typedef {Object} SearchResult
Expand Down Expand Up @@ -112,118 +111,64 @@ export function searchPrevious (searchResult) {
}
}

async function tick () {
return new Promise(setTimeout)
}

// TODO: comment
export function searchAsync (searchText, json, state, { onProgress, onDone, maxResults = Infinity, yieldAfterItemCount = 10000 }) {
// TODO: what is a good value for yieldAfterItemCount? (larger means faster results but also less responsive during search)
const search = searchGenerator(searchText, json, state, yieldAfterItemCount)

// TODO: implement pause after having found x results (like 999)?

let cancelled = false
export function search (searchText, json, state) {
const results = []
let newResults = false
const path = [] // we reuse the same Array recursively, this is *much* faster than creating a new path every time

async function executeSearch () {
if (!searchText || searchText === '') {
onDone(results)
return
}
// TODO: implement maxResults

let next
do {
next = search.next()
if (next.value) {
if (results.length < maxResults) {
results.push(next.value) // TODO: make this immutable?
newResults = true
} else {
// max results limit reached
cancelled = true
onDone(results)
}
} else {
// time for a small break, give the browser space to do stuff
if (newResults) {
newResults = false
if (onProgress) {
onProgress(results)
}
}
function searchRecursive (searchTextLowerCase, json, state) {
if (Array.isArray(json)) {
const level = path.length
path.push(0)

await tick()
for (let i = 0; i < json.length; i++) {
path[level] = i
searchRecursive(searchTextLowerCase, json[i], state ? state[i] : undefined)
}

// eslint-disable-next-line no-unmodified-loop-condition
} while (!cancelled && !next.done)

if (next.done) {
onDone(results)
} // else: cancelled
}
path.pop()
} else if (json !== null && typeof json === 'object') {
const level = path.length
path.push(0)

// start searching on the next tick
setTimeout(executeSearch)

return {
cancel: () => {
cancelled = true
}
}
}

// TODO: comment
export function * searchGenerator (searchText, json, state = undefined, yieldAfterItemCount = undefined) {
let count = 0

function * incrementCounter () {
count++
if (typeof yieldAfterItemCount === 'number' && count % yieldAfterItemCount === 0) {
// pause every x items
yield null
}
}

function * searchRecursiveAsync (searchText, json, state, path) {
const type = valueType(json)

if (type === 'array') {
for (let i = 0; i < json.length; i++) {
yield * searchRecursiveAsync(searchText, json[i], state ? state[i] : undefined, path.concat([i]))
}
} else if (type === 'object') {
const keys = state
? state[STATE_KEYS]
: Object.keys(json)

for (const key of keys) {
if (typeof key === 'string' && containsCaseInsensitive(key, searchText)) {
yield path.concat([key, STATE_SEARCH_PROPERTY])
path[level] = key

if (containsCaseInsensitive(key, searchTextLowerCase)) {
results.push(path.concat([STATE_SEARCH_PROPERTY]))
}
yield * incrementCounter()

yield * searchRecursiveAsync(searchText, json[key], state ? state[key] : undefined, path.concat([key]))
searchRecursive(searchTextLowerCase, json[key], state ? state[key] : undefined)
}

path.pop()
} else { // type is a value
if (containsCaseInsensitive(json, searchText)) {
yield path.concat([STATE_SEARCH_VALUE])
if (containsCaseInsensitive(json, searchTextLowerCase)) {
results.push(path.concat([STATE_SEARCH_VALUE]))
}
yield * incrementCounter()
}
}

return yield * searchRecursiveAsync(searchText, json, state, [])
if (typeof searchText === 'string' && searchText !== '') {
const searchTextLowerCase = searchText.toLowerCase()
searchRecursive(searchTextLowerCase, json, state, [])
}

return results
}

/**
* Do a case insensitive search for a search text in a text
* @param {String} text
* @param {String} searchText
* @param {String} searchTextLowerCase
* @return {boolean} Returns true if `search` is found in `text`
*/
export function containsCaseInsensitive (text, searchText) {
return String(text).toLowerCase().indexOf(searchText.toLowerCase()) !== -1
export function containsCaseInsensitive (text, searchTextLowerCase) {
return String(text).toLowerCase().indexOf(searchTextLowerCase) !== -1
}
Loading

0 comments on commit 5e4c3ed

Please sign in to comment.