diff --git a/src/backend/src/filesystem/hl_operations/hl_name_search.js b/src/backend/src/filesystem/hl_operations/hl_name_search.js index b44f75705..fb05e2b25 100644 --- a/src/backend/src/filesystem/hl_operations/hl_name_search.js +++ b/src/backend/src/filesystem/hl_operations/hl_name_search.js @@ -20,7 +20,7 @@ class HLNameSearch extends HLFilesystemOperation { const results = await db.read( `SELECT uuid FROM fsentries WHERE name LIKE ? AND ` + - `user_id = ?`, + `user_id = ? LIMIT 50`, [term, actor.type.user.id] ); diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 560e3d105..55812ec44 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -41,6 +41,7 @@ import UINotification from "./UINotification.js" import UIWindowWelcome from "./UIWindowWelcome.js" import launch_app from "../helpers/launch_app.js" import item_icon from "../helpers/item_icon.js" +import UIWindowSearch from "./UIWindowSearch.js" async function UIDesktop(options){ let h = ''; @@ -1032,6 +1033,9 @@ async function UIDesktop(options){ if(!window.is_embedded) ht += `
`; + // search button + ht += `
`; + // user options menu ht += `
`; h += `${window.user.username}`; @@ -1498,6 +1502,10 @@ $(document).on('click', '.close-launch-popover', function(){ }); }); +$(document).on('click', '.search-btn', function(){ + UIWindowSearch(); +}) + $(document).on('click', '.toolbar-puter-logo', function(){ UIWindowSettings(); }) diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index 942f56df8..f681ac892 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -3449,7 +3449,10 @@ $.fn.focusWindow = function(event) { if(this.hasClass('window')){ const $app_iframe = $(this).find('.window-app-iframe'); const win_id = $(this).attr('data-id'); + + // remove active class from all windows, except for this window $('.window').not(this).removeClass('window-active'); + // add active class to this window $(this).addClass('window-active'); // disable pointer events on all windows' iframes, except for this window's iframe $('.window-app-iframe').not($app_iframe).css('pointer-events', 'none'); @@ -3509,7 +3512,7 @@ $.fn.focusWindow = function(event) { window.history.replaceState({window_id: $(this).attr('data-id')}, '', '/app/'+url_app_name+$(this).attr('data-user_set_url_params')); document.title = $(this).attr('data-name'); } - $(`.taskbar .taskbar-item[data-app="${$(this).attr('data-app')}"]`).addClass('taskbar-item-active'); + $(`.taskbar .taskbar-item[data-app="${$(this).attr('data-app')}"]`).addClass('taskbar-item-active'); }else{ $('.window').find('.item-selected').addClass('item-blurred'); $('.desktop').find('.item-blurred').removeClass('item-blurred'); diff --git a/src/gui/src/UI/UIWindowSearch.js b/src/gui/src/UI/UIWindowSearch.js new file mode 100644 index 000000000..9ff4a1b21 --- /dev/null +++ b/src/gui/src/UI/UIWindowSearch.js @@ -0,0 +1,259 @@ +/** + * Copyright (C) 2024 Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import UIWindow from './UIWindow.js' +import path from "../lib/path.js" +import UIAlert from './UIAlert.js' +import launch_app from '../helpers/launch_app.js' +import item_icon from '../helpers/item_icon.js' + +async function UIWindowSearch(options){ + let h = ''; + + h += `
`; + h += ``; + h += `
`; + h += `
`; + + const el_window = await UIWindow({ + icon: null, + single_instance: true, + app: 'search', + uid: null, + is_dir: false, + body_content: h, + has_head: false, + selectable_body: false, + draggable_body: true, + allow_context_menu: false, + is_draggable: false, + is_resizable: false, + is_droppable: false, + init_center: true, + allow_native_ctxmenu: true, + allow_user_select: true, + window_class: 'window-search', + onAppend: function(el_window){ + }, + width: 500, + dominant: true, + window_css: { + height: 'initial', + padding: '0', + }, + body_css: { + width: 'initial', + 'max-height': 'calc(100vh - 200px)', + 'background-color': 'rgb(241 246 251)', + 'backdrop-filter': 'blur(3px)', + 'padding': '0', + 'height': 'initial', + 'overflow': 'hidden', + 'min-height': '65px', + 'padding-bottom': '10px', + } + }); + + $(el_window).find('.search-input').focus(); + + // Debounce function to limit rate of API calls + function debounce(func, wait) { + let timeout; + return function (...args) { + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => { + func.apply(context, args); + }, wait); + }; + } + + // State for managing loading indicator + let isSearching = false; + + // Debounced search function + const performSearch = debounce(async function(searchInput, resultsContainer) { + // Don't search if input is empty + if (searchInput.val() === '') { + resultsContainer.html(''); + resultsContainer.hide(); + return; + } + + // Set loading state + if (!isSearching) { + isSearching = true; + } + + try { + // Perform the search + let results = await fetch(window.api_origin + '/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${puter.authToken}` + }, + body: JSON.stringify({ text: searchInput.val() }) + }); + + results = await results.json(); + + // Hide results if there are none + if(results.length === 0) + resultsContainer.hide(); + else + resultsContainer.show(); + + // Build results HTML + let h = ''; + + for(let i = 0; i < results.length; i++){ + const result = results[i]; + h += `
`; + // icon + h += ``; + h += html_encode(result.name); + h += `
`; + } + resultsContainer.html(h); + } catch (error) { + resultsContainer.html('
Search failed. Please try again.
'); + console.error('Search error:', error); + } finally { + isSearching = false; + } + }, 300); // Wait 300ms after last keystroke before searching + + // Event binding + $(el_window).find('.search-input').on('input', function(e) { + const searchInput = $(this); + const resultsContainer = $(el_window).find('.search-results'); + performSearch(searchInput, resultsContainer); + }); +} + +$(document).on('click', '.search-result', async function(e){ + const fspath = $(this).data('path'); + const fsuid = $(this).data('uid'); + const is_dir = $(this).attr('data-is_dir') === 'true' || $(this).data('is_dir') === '1'; + let open_item_meta; + + if(is_dir){ + UIWindow({ + path: fspath, + title: path.basename(fspath), + icon: await item_icon({is_dir: true, path: fspath}), + uid: fsuid, + is_dir: is_dir, + app: 'explorer', + // top: options.maximized ? 0 : undefined, + // left: options.maximized ? 0 : undefined, + // height: options.maximized ? `calc(100% - ${window.taskbar_height + window.toolbar_height + 1}px)` : undefined, + // width: options.maximized ? `100%` : undefined, + }); + + // close search window + $(this).closest('.window').close(); + + return; + } + + // get all info needed to open an item + try{ + open_item_meta = await $.ajax({ + url: window.api_origin + "/open_item", + type: 'POST', + contentType: "application/json", + data: JSON.stringify({ + uid: fsuid ?? undefined, + path: fspath ?? undefined, + }), + headers: { + "Authorization": "Bearer "+window.auth_token + }, + statusCode: { + 401: function () { + window.logout(); + }, + }, + }); + }catch(err){ + // Ignored + } + + // get a list of suggested apps for this file type. + let suggested_apps = open_item_meta?.suggested_apps ?? await window.suggest_apps_for_fsentry({uid: fsuid, path: fspath}); + + //--------------------------------------------- + // No suitable apps, ask if user would like to + // download + //--------------------------------------------- + if(suggested_apps.length === 0){ + //--------------------------------------------- + // If .zip file, unzip it + //--------------------------------------------- + if(path.extname(fspath) === '.zip'){ + window.unzipItem(fspath); + return; + } + const alert_resp = await UIAlert( + 'Found no suitable apps to open this file with. Would you like to download it instead?', + [ + { + label: i18n('download_file'), + value: 'download_file', + type: 'primary', + + }, + { + label: i18n('cancel') + } + ]) + if(alert_resp === 'download_file'){ + window.trigger_download([fspath]); + } + return; + } + //--------------------------------------------- + // First suggested app is default app to open this item + //--------------------------------------------- + else{ + launch_app({ + name: suggested_apps[0].name, + token: open_item_meta.token, + file_path: fspath, + app_obj: suggested_apps[0], + window_title: path.basename(fspath), + file_uid: fsuid, + // maximized: options.maximized, + file_signature: open_item_meta.signature, + }); + } + + + // close + $(this).closest('.window').close(); +}) + +export default UIWindowSearch \ No newline at end of file diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 1b052b831..2f7a05429 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -4230,4 +4230,50 @@ fieldset[name=number-code] { .welcome-window-footer a:hover{ color: #1d1e23; -} \ No newline at end of file +} + +/* +* ------------------------------------ +* Search +* ------------------------------------ +*/ +.search-input-wrapper{ + width: 100%; + border-radius: 5px; + padding-bottom: 10px; + padding-top: 20px; + position: absolute; + padding-left: 15px; + padding-right: 15px; + box-sizing: border-box; + background: #f1f6fc; +} +.search-input{ + padding-left: 33px !important; + background-repeat: no-repeat; + background-position: 5px center; + background-size: 20px; +} +.search-results{ + padding-right: 15px; + margin-top: 70px; + padding-left: 15px; + padding-right: 15px; + padding-bottom: 5px; + display: none; +} +.search-result{ + padding: 10px; cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; +} +.search-result-active{ + background-color: #4092da; + color: #fff; + border-radius: 5px; +} +.search-results .search-result:last-child { + margin-bottom: 0; +} + \ No newline at end of file diff --git a/src/gui/src/helpers/item_icon.js b/src/gui/src/helpers/item_icon.js index f17ea7893..44d8d9c6a 100644 --- a/src/gui/src/helpers/item_icon.js +++ b/src/gui/src/helpers/item_icon.js @@ -51,6 +51,10 @@ const item_icon = async (fsentry)=>{ // thumbnail // -------------------------------------------------- if(fsentry.thumbnail){ + // if thumbnail but a directory under AppData, then it's a thumbnail for an app and must be treated as an icon + if(fsentry.path.startsWith(window.appdata_path + '/')) + return {image: fsentry.thumbnail, type: 'icon'}; + // otherwise, it's a thumbnail for a file return {image: fsentry.thumbnail, type: 'thumb'}; } // -------------------------------------------------- diff --git a/src/gui/src/icons/search.svg b/src/gui/src/icons/search.svg new file mode 100644 index 000000000..69f733ba5 --- /dev/null +++ b/src/gui/src/icons/search.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 7f6755d0c..f37044d04 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -1420,6 +1420,12 @@ $(document).on('click', '.generic-close-window-button', function(e){ $(this).closest('.window').close(); }); +$(document).on('click', function(e){ + if(!$(e.target).hasClass('window-search') && $(e.target).closest('.window-search').length === 0 && !$(e.target).is('.toolbar-btn.search-btn')){ + $('.window-search').close(); + } +}) + // Re-calculate desktop height and width on window resize and re-position the login and signup windows $(window).on("resize", function () { // If host env is popup, don't continue because the popup window has its own resize requirements. diff --git a/src/gui/src/keyboard.js b/src/gui/src/keyboard.js index aa9e44870..9f3fedf27 100644 --- a/src/gui/src/keyboard.js +++ b/src/gui/src/keyboard.js @@ -18,11 +18,23 @@ */ import UIAlert from './UI/UIAlert.js'; +import UIWindowSearch from './UI/UIWindowSearch.js'; import launch_app from './helpers/launch_app.js'; import open_item from './helpers/open_item.js'; $(document).bind('keydown', async function(e){ const focused_el = document.activeElement; + //----------------------------------------------------------------------------- + // Search + // ctrl/command + f, will open UIWindowSearch + //----------------------------------------------------------------------------- + if((e.ctrlKey || e.metaKey) && e.which === 70 && !$(focused_el).is('input') && !$(focused_el).is('textarea')){ + e.preventDefault(); + e.stopPropagation(); + UIWindowSearch(); + return false; + } + //----------------------------------------------------------------------- // ← ↑ → ↓: an arrow key is pressed //----------------------------------------------------------------------- @@ -290,6 +302,32 @@ $(document).bind('keydown', async function(e){ next_item.scrollIntoView(false); } } + // ---------------------------------------------- + // Navigate search results in the search window + // ---------------------------------------------- + else if($('.window-search').length > 0){ + let selected_item = $('.window-search .search-result-active').get(0); + let selected_item_index = selected_item ? $('.window-search .search-result').index(selected_item) : -1; + let new_selected_item_index = selected_item_index; + let new_selected_item; + + // if up arrow is pressed + if(e.which === 38){ + new_selected_item_index = selected_item_index - 1; + if(new_selected_item_index < 0) + new_selected_item_index = $('.window-search .search-result').length - 1; + } + // if down arrow is pressed + else if(e.which === 40){ + new_selected_item_index = selected_item_index + 1; + if(new_selected_item_index >= $('.window-search .search-result').length) + new_selected_item_index = 0; + } + new_selected_item = $('.window-search .search-result').get(new_selected_item_index); + $(selected_item).removeClass('search-result-active'); + $(new_selected_item).addClass('search-result-active'); + new_selected_item.scrollIntoView(false); + } } //----------------------------------------------------------------------- // if the Esc key is pressed on a FileDialog/Alert, close that FileDialog/Alert @@ -313,6 +351,12 @@ $(document).bind('keydown', async function(e){ $(focused_el).val($(focused_el).closest('.window').attr('data-path')); $(focused_el).attr('data-path', $(focused_el).closest('.window').attr('data-path')); } + //----------------------------------------------------------------------- + // if the Esc key is pressed on a Search Window, close the Search Window + //----------------------------------------------------------------------- + else if( e.which === 27 && $('.window-search').length > 0){ + $('.window-search').close(); + } //----------------------------------------------------------------------- // Esc key should: @@ -612,6 +656,16 @@ $(document).bind("keyup keydown", async function(e){ return false; } //----------------------------------------------------------------------- + // Enter key on a search window result + //----------------------------------------------------------------------- + if(e.which === 13 && $('.window-search').length > 0 + // prevent firing twice, because this will be fired on both keyup and keydown + && e.type === 'keydown'){ + $('.window-search .search-result-active').trigger('click'); + + return false; + } + //----------------------------------------------------------------------- // Open // Enter key on a selected item will open it //-----------------------------------------------------------------------