mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-26 15:15:58 +00:00
feat: search
This commit is contained in:
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
@@ -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 += `<div class="toolbar-btn qr-btn" title="QR code" style="background-image:url(${window.icons['qr.svg']})"></div>`;
|
||||
|
||||
// search button
|
||||
ht += `<div class="toolbar-btn search-btn" title="Search" style="background-image:url(${window.icons['search.svg']})"></div>`;
|
||||
|
||||
// user options menu
|
||||
ht += `<div class="toolbar-btn user-options-menu-btn" style="background-image:url(${window.icons['profile.svg']})">`;
|
||||
h += `<span class="user-options-menu-username">${window.user.username}</span>`;
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 += `<div class="search-input-wrapper">`;
|
||||
h += `<input type="text" class="search-input" placeholder="Search" style="background-image:url(${window.icons['magnifier-outline.svg']});">`;
|
||||
h += `</div>`;
|
||||
h += `<div class="search-results" style="overflow-y: auto; max-height: 300px;">`;
|
||||
|
||||
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 += `<div
|
||||
class="search-result"
|
||||
data-path="${html_encode(result.path)}"
|
||||
data-uid="${html_encode(result.uid)}"
|
||||
data-is_dir="${html_encode(result.is_dir)}"
|
||||
>`;
|
||||
// icon
|
||||
h += `<img src="${(await item_icon(result)).image}" style="width: 20px; height: 20px; margin-right: 6px;">`;
|
||||
h += html_encode(result.name);
|
||||
h += `</div>`;
|
||||
}
|
||||
resultsContainer.html(h);
|
||||
} catch (error) {
|
||||
resultsContainer.html('<div class="search-error">Search failed. Please try again.</div>');
|
||||
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
|
||||
@@ -4230,4 +4230,50 @@ fieldset[name=number-code] {
|
||||
|
||||
.welcome-window-footer a:hover{
|
||||
color: #1d1e23;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ------------------------------------
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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'};
|
||||
}
|
||||
// --------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
|
||||
<path d="M 11.742 5.658 C 14.7 9.694 12.179 15.418 7.205 15.962 C 2.231 16.505 -1.466 11.46 0.55 6.881 C 2.226 3.076 6.993 1.8 10.345 4.26 L 10.344 4.26 C 10.373 4.22 10.406 4.182 10.442 4.145 L 14.292 0.295 C 14.836 -0.25 15.766 -0.001 15.966 0.743 C 16.058 1.088 15.96 1.456 15.707 1.709 L 11.857 5.559 C 11.821 5.595 11.783 5.629 11.742 5.659 L 11.742 5.658 Z M 12 9.502 C 12 5.268 7.417 2.622 3.75 4.739 C 2.048 5.721 1 7.537 1 9.502 C 1 13.736 5.583 16.382 9.25 14.265 C 10.952 13.283 12 11.467 12 9.502" style="transform-box: fill-box; transform-origin: 50% 50%; fill: rgb(255, 255, 255);" transform="matrix(-1, 0, 0, -1, 0, 0.000001)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 816 B |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user