From cc1c160233a4dd45795fb3f0357b12ccf6560140 Mon Sep 17 00:00:00 2001 From: Ludovic Dubost Date: Mon, 2 Feb 2026 15:46:32 +0100 Subject: [PATCH] Drive "search-as-you-type" resulting in slow and cumbersome experience #2129 Implementation of asyncSearch and async search result display to avoid bad experience on large drive. Includes also search time display and debug --- customize.dist/src/less2/include/drive.less | 15 +- src/common/proxy-manager.js | 30 ++ src/common/user-object.js | 103 +++++++ www/common/drive-ui.js | 313 ++++++++++++++------ 4 files changed, 371 insertions(+), 90 deletions(-) diff --git a/customize.dist/src/less2/include/drive.less b/customize.dist/src/less2/include/drive.less index 1ea26cd98..4255972b3 100644 --- a/customize.dist/src/less2/include/drive.less +++ b/customize.dist/src/less2/include/drive.less @@ -233,6 +233,7 @@ font-size: 30px; margin: 15px; height: 2.5rem; + position: relative; input { background: transparent; color: @cp_drive-fg; @@ -262,6 +263,13 @@ .cp-app-drive-tree-search-icon, .cp-app-drive-search-cancel { color: @cp_drive-fg; } + .cp-app-drive-search-timing { + font-size: 0.5em; + position: absolute; + right: 60px; + top: 100%; + padding-top: 0.25rem; + } } .cp-app-drive-search-spinner { display: inline-flex; @@ -271,7 +279,8 @@ svg { height: 2rem; width: 2rem; - margin-bottom: 1.5rem; + margin-bottom: 0.5rem; + margin-left: -1rem; } .cp-spinner svg { animation: spin 10s linear infinite; @@ -297,7 +306,8 @@ } .cp-app-drive-search-spinner svg { width: 1.5rem; - margin-bottom: 1rem; + margin-bottom: 0.3rem; + margin-left: -0.75rem; } .cp-app-drive-search-noresult { font-size: 22px; @@ -1146,4 +1156,3 @@ } } - diff --git a/src/common/proxy-manager.js b/src/common/proxy-manager.js index 4439db254..92b087d2c 100644 --- a/src/common/proxy-manager.js +++ b/src/common/proxy-manager.js @@ -1656,6 +1656,35 @@ const factory = (UserObject, Util, Hash, }); return ret; }; + var searchAsync = function (Env, value, cb, opts) { + cb = Util.once(cb || function () {}); + var ret = []; + var userObjects = _getUserObjects(Env); + var index = 0; + var next = function () { + if (index >= userObjects.length) { return void cb(ret); } + var uo = userObjects[index++]; + var fPath = _getUserObjectPath(Env, uo); + var handleResults = function (results) { + if (!results || !results.length) { return void next(); } + if (fPath) { + results.forEach(function (r) { + r.inSharedFolder = true; + r.paths.forEach(function (p) { + Array.prototype.unshift.apply(p, fPath); + }); + }); + } + Array.prototype.push.apply(ret, results); + next(); + }; + if (uo.searchAsync) { + return void uo.searchAsync(value, handleResults, opts); + } + handleResults(uo.search(value)); + }; + next(); + }; var getRecentPads = function (Env) { var files = []; @@ -1799,6 +1828,7 @@ const factory = (UserObject, Util, Hash, isStaticFile: callWithEnv(isStaticFile), getFiles: callWithEnv(getFiles), search: callWithEnv(search), + searchAsync: callWithEnv(searchAsync), getRecentPads: callWithEnv(getRecentPads), getOwnedPads: callWithEnv(getOwnedPads), getTagsList: callWithEnv(getTagsList), diff --git a/src/common/user-object.js b/src/common/user-object.js index 5a6ec3c73..8f431a431 100644 --- a/src/common/user-object.js +++ b/src/common/user-object.js @@ -782,6 +782,109 @@ const factory = (Util, Hash, return ret; }; + exp.searchAsync = function (value, cb, opts) { + cb = Util.once(cb || function () {}); + if (typeof(value) !== "string") { return void cb([]); } + value = value.trim(); + var res = []; + var allFilesList = files[FILES_DATA]; + var allSFList = files[SHARED_FOLDERS]; + var lValue = value.toLowerCase(); + var tags; + if (/^#/.test(lValue)) { + tags = [lValue.slice(1).trim()]; + } + + var containsSearchedTag = function (T) { + if (!tags) { return false; } + if (!T.length) { return false; } + T = T.map(function (t) { return t.toLowerCase(); }); + return tags.some(function (tag) { + return T.some(function (t) { + return t === tag; + }); + }); + }; + + var ids = getFiles([FILES_DATA, SHARED_FOLDERS]); + var index = 0; + var batchSize = opts && opts.batchSize ? opts.batchSize : 250; + + var processFilesBatch = function () { + console.log("SEARCH: " + new Date().toISOString() + " searchAsync files batch start"); + var end = Math.min(index + batchSize, ids.length); + for (; index < end; index++) { + var id = ids[index]; + var data = allFilesList[id] || allSFList[id]; + if (!data) { continue; } + if (Array.isArray(data.tags) && containsSearchedTag(data.tags)) { + res.push(id); + continue; + } + var title = data.title || data.lastTitle; + if ((title && title.toLowerCase().indexOf(lValue) !== -1) || + (data.filename && data.filename.toLowerCase().indexOf(lValue) !== -1)) { + res.push(id); + } + } + if (index < ids.length) { + return void setTimeout(processFilesBatch, 0); + } + var href = Hash.getRelativeHref(value); + if (href) { + var hrefId = getIdFromHref(href); + if (hrefId) { res.push(hrefId); } + } + res = Util.deduplicateString(res); + + var ret = []; + res.forEach(function (l) { + ret.push({ + id: l, + paths: findFile(l), + data: exp.getFileData(l) + }); + }); + + var resFolders = []; + var stack = [{ folder: files[ROOT], path: [ROOT] }]; + var folderBatchSize = opts && opts.folderBatchSize ? opts.folderBatchSize : 50; + var processFoldersBatch = function () { + console.log("SEARCH: " + new Date().toISOString() + " searchAsync folders batch start"); + var processed = 0; + while (stack.length && processed < folderBatchSize) { + var current = stack.pop(); + var folder = current.folder; + var path = current.path; + for (var key in folder) { + if (isFolder(folder[key]) && !isSharedFolder(folder[key])) { + if (key.toLowerCase().indexOf(lValue) !== -1) { + resFolders.push({ + id: null, + paths: [path.concat(key)], + data: { + title: key + } + }); + } + stack.push({ folder: folder[key], path: path.concat(key) }); + } + } + processed++; + } + if (stack.length) { + return void setTimeout(processFoldersBatch, 0); + } + resFolders = resFolders.sort(function (a, b) { + return a.data.title.toLowerCase() > b.data.title.toLowerCase(); + }); + ret = resFolders.concat(ret); + cb(ret); + }; + processFoldersBatch(); + }; + processFilesBatch(); + }; exp.getRecentPads = function () { var allFiles = files[FILES_DATA]; var sorted = Object.keys(allFiles).filter(function (a) { return allFiles[a]; }) diff --git a/www/common/drive-ui.js b/www/common/drive-ui.js index 0466f4e3b..9cc94a944 100644 --- a/www/common/drive-ui.js +++ b/www/common/drive-ui.js @@ -3931,6 +3931,9 @@ define([ APP.Search = {}; var displaySearch = function ($list, value) { var search = APP.Search; + var logSearch = function (message) { + console.log("SEARCH: " + new Date().toISOString() + " " + message); + }; var $div = $('
', {'id': 'cp-app-drive-search', 'class': 'cp-unselectable'}); $searchIcon.clone().appendTo($div); @@ -3945,56 +3948,56 @@ define([ draggable: false, tabindex: 1, }).keyup(function (e) { - if (searching) { - e.preventDefault(); - e.stopPropagation(); - return; - } + logSearch("new text detected"); var currentValue = $input.val().trim(); if (search.to) { window.clearTimeout(search.to); } if (e.which === 13) { spinner.spin(); - var newLocation = [SEARCH, $input.val()]; search.cursor = $input[0].selectionStart; - if (!manager.comparePath(newLocation, currentPath.slice())) { - searching = true; - APP.displayDirectory(newLocation); - } + logSearch("search launched via enter"); + renderSearchResults($input.val()); return; } if (e.which === 27) { $input.val(''); + logSearch("text field updated (escape clear)"); search.cursor = 0; - searching = true; - APP.displayDirectory([SEARCH]); + renderSearchResults(''); return; } if (currentValue === "") { search.cursor = 0; - APP.displayDirectory([SEARCH]); + renderSearchResults(''); return; } if (currentValue.length < 2) { return; } // Don't autosearch 1 character + search.inputVersion = (search.inputVersion || 0) + 1; + var inputVersion = search.inputVersion; search.to = window.setTimeout(function () { - var newLocation = [SEARCH, $input.val()]; + if (inputVersion !== search.inputVersion) { return; } search.cursor = $input[0].selectionStart; if (currentValue === search.value) { return; } - if (!manager.comparePath(newLocation, currentPath.slice())) { - searching = true; - APP.displayDirectory(newLocation); - } + logSearch("search launched via debounce"); + renderSearchResults($input.val()); }, 500); }).on('click mousedown mouseup', function (e) { e.stopPropagation(); + }).on('focus', function () { + logSearch("input focus"); + }).on('blur', function () { + logSearch("input blur"); }).val(value || '').appendTo($div); + logSearch("text field updated (initial value)"); + search.value = typeof(value) === "string" ? value : ''; $input[0].selectionStart = search.cursor || 0; $input[0].selectionEnd = search.cursor || 0; var cancel = Icons.get('close', {class: 'cp-app-drive-search-cancel',title:Messages.cancel}); // not visible cancel.addEventListener('click', function () { $input.val(''); + logSearch("text field updated (cancel click)"); search.cursor = 0; APP.displayDirectory([SEARCH]); }); @@ -4002,90 +4005,226 @@ define([ $div.append($spinnerContainer); $list.append($div); + var $timing = $('
', { + 'class': 'cp-app-drive-search-timing' + }).appendTo($div); setTimeout(function () { $input.focus(); }); - if (typeof(value) === "string" && value.trim()) { - spinner.spin(); - } else { - searching = false; - return; - } + var clearSearchResults = function () { + $list.children().not('#cp-app-drive-search').remove(); + }; - setTimeout(function () { - //$list.closest('#cp-app-drive-content-folder').addClass('cp-app-drive-content-list'); - var filesList = manager.search(value); - if (!filesList.length) { - $list.append(h('div.cp-app-drive-search-noresult', Messages.fm_noResult)); + var renderSearchResults = function (nextValue) { + logSearch("launching search for value " + nextValue); + if (searching==true) { + logSearch("search already in progress"); + setTimeout(function () { + logSearch("retrying search for value " + nextValue); + renderSearchResults(nextValue); + }, 1000); + return; + } + + var searchValue = typeof(nextValue) === "string" ? nextValue : ''; + search.value = searchValue; + clearSearchResults(); + $timing.text(''); + if (!searchValue.trim()) { spinner.hide(); searching = false; + logSearch("finished search (empty query)"); return; } - var sortable = {}; - var sortableFolders = []; - filesList.forEach(function (r) { - // if r.id === null, then it's a folder, not a file - r.paths.forEach(function (path) { - if (!r.inSharedFolder && - APP.hideDuplicateOwned && manager.isDuplicateOwned(path)) { return; } - var _path = path.slice(); - var key = path.pop(); - var root = manager.find(path); - var obj = { - path: path, - _path: _path, - key: key, - root: root, - data: r.data - }; - if (manager.isFolder(root[key])) { - sortableFolders.push(obj); + var searchStartedAt = Date.now(); + spinner.spin(); + searching = true; + search.displayVersion = (search.displayVersion || 0) + 1; + var displayVersion = search.displayVersion; + setTimeout(function () { + if (displayVersion !== search.displayVersion) { + spinner.hide(); + searching = false; + return; + } + var latestValue = $input.val(); + if (latestValue !== searchValue) { + spinner.hide(); + searching = false; + return; + } + //$list.closest('#cp-app-drive-content-folder').addClass('cp-app-drive-content-list'); + logSearch("search execution scheduled for value " + searchValue); + setTimeout(function () { + if (displayVersion !== search.displayVersion) { + spinner.hide(); + searching = false; return; } - sortable[root[key]] = obj; - }); - }); - var _folders = sortElements(true, [ROOT], sortableFolders, null, !getSortFolderDesc(), true); - var sortableKeys = Object.keys(sortable).map(Number); - var _files = sortElements(false, [ROOT], sortableKeys, APP.store[SORT_FILE_BY], !getSortFileDesc(), true); + if ($input.val() !== searchValue) { + spinner.hide(); + searching = false; + return; + } + logSearch("search execution started for value " + searchValue); + var searchExecStartedAt = Date.now(); + var handleSearchResults = function (filesList) { + if (displayVersion !== search.displayVersion) { + spinner.hide(); + searching = false; + return; + } + if ($input.val() !== searchValue) { + spinner.hide(); + searching = false; + return; + } + var searchExecElapsed = Date.now() - searchExecStartedAt; + logSearch("search execution done in " + searchExecElapsed + "ms"); + logSearch("starting render search results"); + var sortStartedAt = Date.now(); + if (!filesList.length) { + $list.append(h('div.cp-app-drive-search-noresult', Messages.fm_noResult)); + spinner.hide(); + searching = false; + var totalElapsed = Date.now() - searchStartedAt; + logSearch("sort time 0ms (no results)"); + logSearch("render time 0ms (no results)"); + logSearch("total time " + totalElapsed + "ms"); + $timing.text(totalElapsed + 'ms'); + logSearch("finished render (no results)"); + return; + } + var sortable = {}; + var sortableFolders = []; + filesList.forEach(function (r) { + // if r.id === null, then it's a folder, not a file + r.paths.forEach(function (path) { + if (!r.inSharedFolder && + APP.hideDuplicateOwned && manager.isDuplicateOwned(path)) { return; } + var _path = path.slice(); + var key = path.pop(); + var root = manager.find(path); + var obj = { + path: path, + _path: _path, + key: key, + root: root, + data: r.data + }; + if (manager.isFolder(root[key])) { + sortableFolders.push(obj); + return; + } + sortable[root[key]] = obj; + }); + }); + var _folders = sortElements(true, [ROOT], sortableFolders, null, !getSortFolderDesc(), true); + var sortableKeys = Object.keys(sortable).map(Number); + var _files = sortElements(false, [ROOT], sortableKeys, APP.store[SORT_FILE_BY], !getSortFileDesc(), true); + var sortElapsed = Date.now() - sortStartedAt; - var addEl = function (obj, folder) { - var $element = createElement(obj.path, obj.key, obj.root, folder); - $element.addClass('cp-app-drive-element-notrash cp-app-drive-search-result'); - $element.off('contextmenu'); - $element.contextmenu(openContextMenu('default')); - $element.data('context', 'default'); - if (folder) { - $element.find('.cp-app-drive-element-list').css({ - visibility: 'hidden' - }).text(''); - } - if (manager.isPathIn(obj._path, ['hrefArray'])) { - obj._path.pop(); - obj._path.push(obj.data.title); - } - var $path = $('', { - 'class': 'cp-app-drive-search-path' - }).appendTo($element.find('.cp-app-drive-element-name')); - createTitle($path, obj._path); + var addEl = function (obj, folder) { + var $element = createElement(obj.path, obj.key, obj.root, folder); + $element.addClass('cp-app-drive-element-notrash cp-app-drive-search-result'); + $element.off('contextmenu'); + $element.contextmenu(openContextMenu('default')); + $element.data('context', 'default'); + if (folder) { + $element.find('.cp-app-drive-element-list').css({ + visibility: 'hidden' + }).text(''); + } + if (manager.isPathIn(obj._path, ['hrefArray'])) { + obj._path.pop(); + obj._path.push(obj.data.title); + } + var $path = $('', { + 'class': 'cp-app-drive-search-path' + }).appendTo($element.find('.cp-app-drive-element-name')); + createTitle($path, obj._path); - $list.append($element); - }; - if (_folders.length) { getFolderListHeader(true, true).appendTo($list); } - _folders.forEach(function (el) { - var obj = el; - addEl(obj, true); + $list.append($element); + }; + + var renderStartedAt = Date.now(); + var appendQueue = []; + if (_folders.length) { + appendQueue.push(function () { + getFolderListHeader(true, true).appendTo($list); + }); + _folders.forEach(function (el) { + var obj = el; + appendQueue.push(function () { + addEl(obj, true); + }); + }); + } + if (_files.length) { + appendQueue.push(function () { + getFileListHeader(true).appendTo($list); + }); + _files.forEach(function (el) { + var obj = sortable[el]; + appendQueue.push(function () { + addEl(obj, false); + }); + }); + } + + var appendIndex = 0; + var appendBatch = function () { + var batchStartedAt = Date.now(); + if (displayVersion !== search.displayVersion) { + spinner.hide(); + searching = false; + return; + } + if ($input.val() !== searchValue) { + spinner.hide(); + searching = false; + return; + } + var end = Math.min(appendIndex + 30, appendQueue.length); + for (; appendIndex < end; appendIndex++) { + appendQueue[appendIndex](); + } + var batchElapsed = Date.now() - batchStartedAt; + logSearch("render batch time " + batchElapsed + "ms (" + appendIndex + "/" + appendQueue.length + ")"); + if (appendIndex < appendQueue.length) { + window.requestAnimationFrame(appendBatch); + return; + } + var renderElapsed = Date.now() - renderStartedAt; + setTimeout(collapseDrivePath); + spinner.hide(); + searching = false; + var totalElapsed = Date.now() - searchStartedAt; + logSearch("sort time " + sortElapsed + "ms"); + logSearch("render time " + renderElapsed + "ms"); + logSearch("total time " + totalElapsed + "ms"); + $timing.text(totalElapsed + 'ms'); + logSearch("finished render (results displayed)"); + }; + + appendBatch(); + }; + + if (manager.searchAsync) { + manager.searchAsync(searchValue, handleSearchResults); + } else { + handleSearchResults(manager.search(searchValue)); + } + }, 0); }); - if (_files.length) { getFileListHeader(true).appendTo($list); } - _files.forEach(function (el) { - var obj = sortable[el]; - addEl(obj, false); - }); - setTimeout(collapseDrivePath); - spinner.hide(); + }; + + if (typeof(value) === "string") { + renderSearchResults(value); + } else { searching = false; - }); + } }; var displayRecent = function ($list, typeFilter) {