From e67f1f79bc22ca09b76f6b1541bedbb6987d3bb3 Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 19 Feb 2026 18:19:29 -0500 Subject: [PATCH] launcher/dsearch: support for folder search and extra filters --- .../Modals/DankLauncherV2/Controller.qml | 137 +++++++++++++--- .../DankLauncherV2/DankLauncherV2Modal.qml | 4 + .../Modals/DankLauncherV2/ItemTransformers.js | 40 +++-- .../Modals/DankLauncherV2/LauncherContent.qml | 148 +++++++++++++++++- .../Modals/DankLauncherV2/ResultItem.qml | 3 +- .../Modals/DankLauncherV2/ResultsList.qml | 20 ++- quickshell/Services/DSearchService.qml | 116 +++++++++----- 7 files changed, 393 insertions(+), 75 deletions(-) diff --git a/quickshell/Modals/DankLauncherV2/Controller.qml b/quickshell/Modals/DankLauncherV2/Controller.qml index 3b0882cf..0cbcfacb 100644 --- a/quickshell/Modals/DankLauncherV2/Controller.qml +++ b/quickshell/Modals/DankLauncherV2/Controller.qml @@ -162,6 +162,11 @@ Item { } ] + property string fileSearchType: "all" + property string fileSearchExt: "" + property string fileSearchFolder: "" + property string fileSearchSort: "score" + property string pluginFilter: "" property string activePluginName: "" property var activePluginCategories: [] @@ -346,6 +351,10 @@ Item { previousSearchMode = "all"; autoSwitchedToFiles = false; isFileSearching = false; + fileSearchType = "all"; + fileSearchExt = ""; + fileSearchFolder = ""; + fileSearchSort = "score"; sections = []; flatModel = []; selectedFlatIndex = 0; @@ -399,6 +408,34 @@ Item { performSearch(); } + function setFileSearchType(type) { + if (fileSearchType === type) + return; + fileSearchType = type; + performFileSearch(); + } + + function setFileSearchExt(ext) { + if (fileSearchExt === ext) + return; + fileSearchExt = ext; + performFileSearch(); + } + + function setFileSearchFolder(folder) { + if (fileSearchFolder === folder) + return; + fileSearchFolder = folder; + performFileSearch(); + } + + function setFileSearchSort(sort) { + if (fileSearchSort === sort) + return; + fileSearchSort = sort; + performFileSearch(); + } + function clearPluginFilter() { if (pluginFilter) { pluginFilter = ""; @@ -832,10 +869,20 @@ Item { var params = { limit: 20, fuzzy: true, - sort: "score", + sort: fileSearchSort || "score", desc: true }; + if (DSearchService.supportsTypeFilter) { + params.type = (fileSearchType && fileSearchType !== "all") ? fileSearchType : "all"; + } + if (fileSearchExt) { + params.ext = fileSearchExt; + } + if (fileSearchFolder) { + params.folder = fileSearchFolder; + } + DSearchService.search(fileQuery, params, function (response) { isFileSearching = false; if (response.error) @@ -845,34 +892,73 @@ Item { for (var i = 0; i < hits.length; i++) { var hit = hits[i]; + var docTypes = hit.locations?.doc_type; + var isDir = docTypes ? !!docTypes["dir"] : false; fileItems.push(transformFileResult({ path: hit.id || "", - score: hit.score || 0 + score: hit.score || 0, + is_dir: isDir })); } - var fileSection = { - id: "files", - title: I18n.tr("Files"), - icon: "folder", - priority: 4, - items: fileItems, - collapsed: collapsedSections["files"] || false, - flatStartIndex: 0 - }; + var fileSections = []; + var showType = fileSearchType || "all"; + + if (showType === "all" && DSearchService.supportsTypeFilter) { + var onlyFiles = []; + var onlyDirs = []; + for (var j = 0; j < fileItems.length; j++) { + if (fileItems[j].data?.is_dir) + onlyDirs.push(fileItems[j]); + else + onlyFiles.push(fileItems[j]); + } + if (onlyFiles.length > 0) { + fileSections.push({ + id: "files", + title: I18n.tr("Files"), + icon: "insert_drive_file", + priority: 4, + items: onlyFiles, + collapsed: collapsedSections["files"] || false, + flatStartIndex: 0 + }); + } + if (onlyDirs.length > 0) { + fileSections.push({ + id: "folders", + title: I18n.tr("Folders"), + icon: "folder", + priority: 4.1, + items: onlyDirs, + collapsed: collapsedSections["folders"] || false, + flatStartIndex: 0 + }); + } + } else { + var filesIcon = showType === "dir" ? "folder" : showType === "file" ? "insert_drive_file" : "folder"; + var filesTitle = showType === "dir" ? I18n.tr("Folders") : I18n.tr("Files"); + if (fileItems.length > 0) { + fileSections.push({ + id: "files", + title: filesTitle, + icon: filesIcon, + priority: 4, + items: fileItems, + collapsed: collapsedSections["files"] || false, + flatStartIndex: 0 + }); + } + } var newSections; if (searchMode === "files") { - newSections = fileItems.length > 0 ? [fileSection] : []; + newSections = fileSections; } else { var existingNonFile = sections.filter(function (s) { - return s.id !== "files"; + return s.id !== "files" && s.id !== "folders"; }); - if (fileItems.length > 0) { - newSections = existingNonFile.concat([fileSection]); - } else { - newSections = existingNonFile; - } + newSections = existingNonFile.concat(fileSections); } newSections.sort(function (a, b) { return a.priority - b.priority; @@ -918,7 +1004,7 @@ Item { } function transformFileResult(file) { - return Transform.transformFileResult(file, I18n.tr("Open"), I18n.tr("Open folder"), I18n.tr("Copy path")); + return Transform.transformFileResult(file, I18n.tr("Open"), I18n.tr("Open folder"), I18n.tr("Copy path"), I18n.tr("Open in terminal")); } function detectTrigger(query) { @@ -1586,6 +1672,9 @@ Item { case "copy_path": copyToClipboard(item.data.path); break; + case "open_terminal": + openTerminal(item.data.path); + break; case "copy": copyToClipboard(item.name); break; @@ -1667,6 +1756,16 @@ Item { Qt.openUrlExternally("file://" + folder); } + function openTerminal(path) { + if (!path) + return; + var terminal = Quickshell.env("TERMINAL") || "xterm"; + Quickshell.execDetached({ + command: [terminal], + workingDirectory: path + }); + } + function copyToClipboard(text) { if (!text) return; diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml index 95e7c2e9..768a5047 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml @@ -107,6 +107,10 @@ Item { spotlightContent.controller.activePluginId = ""; spotlightContent.controller.activePluginName = ""; spotlightContent.controller.pluginFilter = ""; + spotlightContent.controller.fileSearchType = "all"; + spotlightContent.controller.fileSearchExt = ""; + spotlightContent.controller.fileSearchFolder = ""; + spotlightContent.controller.fileSearchSort = "score"; spotlightContent.controller.collapsedSections = {}; spotlightContent.controller.selectedFlatIndex = 0; spotlightContent.controller.selectedItem = null; diff --git a/quickshell/Modals/DankLauncherV2/ItemTransformers.js b/quickshell/Modals/DankLauncherV2/ItemTransformers.js index d09f0854..61c108ca 100644 --- a/quickshell/Modals/DankLauncherV2/ItemTransformers.js +++ b/quickshell/Modals/DankLauncherV2/ItemTransformers.js @@ -116,31 +116,43 @@ function transformBuiltInLauncherItem(item, pluginId, openLabel) { }; } -function transformFileResult(file, openLabel, openFolderLabel, copyPathLabel) { +function transformFileResult(file, openLabel, openFolderLabel, copyPathLabel, openTerminalLabel) { var filename = file.path ? file.path.split("/").pop() : ""; var dirname = file.path ? file.path.substring(0, file.path.lastIndexOf("/")) : ""; + var isDir = file.is_dir || false; + + var actions = []; + if (isDir) { + if (openTerminalLabel) { + actions.push({ + name: openTerminalLabel, + icon: "terminal", + action: "open_terminal" + }); + } + } else { + actions.push({ + name: openFolderLabel, + icon: "folder_open", + action: "open_folder" + }); + } + actions.push({ + name: copyPathLabel, + icon: "content_copy", + action: "copy_path" + }); return { id: file.path || "", type: "file", name: filename, subtitle: dirname, - icon: Utils.getFileIcon(filename), + icon: isDir ? "folder" : Utils.getFileIcon(filename), iconType: "material", section: "files", data: file, - actions: [ - { - name: openFolderLabel, - icon: "folder_open", - action: "open_folder" - }, - { - name: copyPathLabel, - icon: "content_copy", - action: "copy_path" - } - ], + actions: actions, primaryAction: { name: openLabel, icon: "open_in_new", diff --git a/quickshell/Modals/DankLauncherV2/LauncherContent.qml b/quickshell/Modals/DankLauncherV2/LauncherContent.qml index f7b85907..b84bbe0b 100644 --- a/quickshell/Modals/DankLauncherV2/LauncherContent.qml +++ b/quickshell/Modals/DankLauncherV2/LauncherContent.qml @@ -549,8 +549,151 @@ FocusScope { } Item { + id: fileFilterRow width: parent.width - height: parent.height - searchField.height - categoryRow.height - actionPanel.height - Theme.spacingXS * (categoryRow.visible ? 3 : 2) + height: showFileFilters ? fileFilterContent.height : 0 + visible: showFileFilters + + readonly property bool showFileFilters: controller.searchMode === "files" + + Behavior on height { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Row { + id: fileFilterContent + width: parent.width + spacing: Theme.spacingS + + Row { + id: typeChips + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + visible: DSearchService.supportsTypeFilter + + Repeater { + model: [ + { + id: "all", + label: I18n.tr("All"), + icon: "search" + }, + { + id: "file", + label: I18n.tr("Files"), + icon: "insert_drive_file" + }, + { + id: "dir", + label: I18n.tr("Folders"), + icon: "folder" + } + ] + + Rectangle { + required property var modelData + required property int index + + width: chipContent.width + Theme.spacingM * 2 + height: sortDropdown.height + radius: Theme.cornerRadius + color: controller.fileSearchType === modelData.id || chipArea.containsMouse ? Theme.primaryContainer : "transparent" + + Row { + id: chipContent + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: modelData.icon + size: 14 + color: controller.fileSearchType === modelData.id ? Theme.primary : Theme.surfaceVariantText + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: modelData.label + font.pixelSize: Theme.fontSizeSmall + color: controller.fileSearchType === modelData.id ? Theme.primary : Theme.surfaceText + } + } + + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: controller.setFileSearchType(modelData.id) + } + } + } + } + + Rectangle { + width: 1 + height: 20 + anchors.verticalCenter: parent.verticalCenter + color: Theme.outlineMedium + visible: typeChips.visible + } + + DankDropdown { + id: sortDropdown + anchors.verticalCenter: parent.verticalCenter + width: Math.min(130, parent.width / 3) + compactMode: true + dropdownWidth: 130 + popupWidth: 150 + maxPopupHeight: 200 + currentValue: { + switch (controller.fileSearchSort) { + case "score": + return I18n.tr("Score"); + case "name": + return I18n.tr("Name"); + case "modified": + return I18n.tr("Modified"); + case "size": + return I18n.tr("Size"); + default: + return I18n.tr("Score"); + } + } + options: [I18n.tr("Score"), I18n.tr("Name"), I18n.tr("Modified"), I18n.tr("Size")] + + onValueChanged: value => { + var sortMap = {}; + sortMap[I18n.tr("Score")] = "score"; + sortMap[I18n.tr("Name")] = "name"; + sortMap[I18n.tr("Modified")] = "modified"; + sortMap[I18n.tr("Size")] = "size"; + controller.setFileSearchSort(sortMap[value] || "score"); + } + } + + DankTextField { + id: extFilterField + anchors.verticalCenter: parent.verticalCenter + width: Math.min(100, parent.width / 4) + height: sortDropdown.height + placeholderText: I18n.tr("ext") + font.pixelSize: Theme.fontSizeSmall + showClearButton: text.length > 0 + + onTextChanged: { + controller.setFileSearchExt(text.trim()); + } + } + } + } + + Item { + width: parent.width + height: parent.height - searchField.height - categoryRow.height - fileFilterRow.height - actionPanel.height - Theme.spacingXS * ((categoryRow.visible ? 1 : 0) + (fileFilterRow.visible ? 1 : 0) + 2) opacity: root.parentModal?.isClosing ? 0 : 1 ResultsList { @@ -586,6 +729,9 @@ FocusScope { function onSearchQueryRequested(query) { searchField.text = query; } + function onModeChanged() { + extFilterField.text = ""; + } } FocusScope { diff --git a/quickshell/Modals/DankLauncherV2/ResultItem.qml b/quickshell/Modals/DankLauncherV2/ResultItem.qml index 7cce0b42..0a50cd3e 100644 --- a/quickshell/Modals/DankLauncherV2/ResultItem.qml +++ b/quickshell/Modals/DankLauncherV2/ResultItem.qml @@ -113,6 +113,7 @@ Rectangle { font.family: Theme.fontFamily color: Theme.surfaceVariantText elide: Text.ElideRight + clip: true visible: (root.item?.subtitle ?? "").length > 0 horizontalAlignment: Text.AlignLeft } @@ -181,7 +182,7 @@ Rectangle { case "plugin": return I18n.tr("Plugin"); case "file": - return I18n.tr("File"); + return root.item.data?.is_dir ? I18n.tr("Folder") : I18n.tr("File"); default: return ""; } diff --git a/quickshell/Modals/DankLauncherV2/ResultsList.qml b/quickshell/Modals/DankLauncherV2/ResultsList.qml index b36386cb..5bd421db 100644 --- a/quickshell/Modals/DankLauncherV2/ResultsList.qml +++ b/quickshell/Modals/DankLauncherV2/ResultsList.qml @@ -435,7 +435,15 @@ Item { var mode = root.controller?.searchMode ?? "all"; switch (mode) { case "files": - return "folder_open"; + var fileType = root.controller?.fileSearchType ?? "all"; + switch (fileType) { + case "dir": + return "folder_open"; + case "file": + return "insert_drive_file"; + default: + return "folder_open"; + } case "plugins": return "extension"; case "apps": @@ -465,7 +473,15 @@ Item { return I18n.tr("Type to search files"); if (root.controller.searchQuery.length < 2) return I18n.tr("Type at least 2 characters"); - return I18n.tr("No files found"); + var fileType = root.controller?.fileSearchType ?? "all"; + switch (fileType) { + case "dir": + return I18n.tr("No folders found"); + case "file": + return I18n.tr("No files found"); + default: + return I18n.tr("No results found"); + } case "plugins": return hasQuery ? I18n.tr("No plugin results") : I18n.tr("Browse or search plugins"); case "apps": diff --git a/quickshell/Services/DSearchService.qml b/quickshell/Services/DSearchService.qml index 3ccee6d7..2c6da397 100644 --- a/quickshell/Services/DSearchService.qml +++ b/quickshell/Services/DSearchService.qml @@ -1,8 +1,6 @@ pragma Singleton - pragma ComponentBehavior: Bound -import QtCore import QtQuick import Quickshell import Quickshell.Io @@ -13,6 +11,9 @@ Singleton { property bool dsearchAvailable: false property int searchIdCounter: 0 + property int indexVersion: 0 + property bool supportsTypeFilter: false + property bool versionChecked: false signal searchResultsReceived(var results) signal statsReceived(var stats) @@ -26,118 +27,157 @@ Singleton { stdout: SplitParser { onRead: line => { if (line && line.trim().length > 0) { - root.dsearchAvailable = true + root.dsearchAvailable = true; } } } onExited: exitCode => { if (exitCode !== 0) { - root.dsearchAvailable = false + root.dsearchAvailable = false; + } else { + root._checkVersion(); } } } + function _checkVersion() { + Proc.runCommand("dsearch-version", ["dsearch", "version", "--json"], (stdout, exitCode) => { + root.versionChecked = true; + if (exitCode !== 0) + return; + const response = JSON.parse(stdout); + root.indexVersion = response.index_schema || 0; + root.supportsTypeFilter = root.indexVersion >= 2; + }); + } + function ping(callback) { if (!dsearchAvailable) { if (callback) { - callback({ "error": "dsearch not available" }) + callback({ + "error": "dsearch not available" + }); } - return + return; } Proc.runCommand("dsearch-ping", ["dsearch", "ping", "--json"], (stdout, exitCode) => { if (callback) { if (exitCode === 0) { try { - const response = JSON.parse(stdout) - callback({ "result": response }) + const response = JSON.parse(stdout); + callback({ + "result": response + }); } catch (e) { - callback({ "error": "failed to parse ping response" }) + callback({ + "error": "failed to parse ping response" + }); } } else { - callback({ "error": "ping failed" }) + callback({ + "error": "ping failed" + }); } } - }) + }); } function search(query, params, callback) { if (!query || query.length === 0) { if (callback) { - callback({ "error": "query is required" }) + callback({ + "error": "query is required" + }); } - return + return; } if (!dsearchAvailable) { if (callback) { - callback({ "error": "dsearch not available" }) + callback({ + "error": "dsearch not available" + }); } - return + return; } - const args = ["dsearch", "search", query, "--json"] + const args = ["dsearch", "search", query, "--json"]; if (params) { if (params.limit !== undefined) { - args.push("-n", String(params.limit)) + args.push("-n", String(params.limit)); + } + if (params.type) { + args.push("-t", params.type); } if (params.ext) { - args.push("-e", params.ext) + args.push("-e", params.ext); + } + if (params.folder) { + args.push("--folder", params.folder); } if (params.field) { - args.push("-f", params.field) + args.push("-f", params.field); } if (params.fuzzy) { - args.push("--fuzzy") + args.push("--fuzzy"); } if (params.sort) { - args.push("--sort", params.sort) + args.push("--sort", params.sort); } if (params.desc !== undefined) { - args.push("--desc=" + (params.desc ? "true" : "false")) + args.push("--desc=" + (params.desc ? "true" : "false")); } if (params.minSize !== undefined) { - args.push("--min-size", String(params.minSize)) + args.push("--min-size", String(params.minSize)); } if (params.maxSize !== undefined) { - args.push("--max-size", String(params.maxSize)) + args.push("--max-size", String(params.maxSize)); } } Proc.runCommand("dsearch-search", args, (stdout, exitCode) => { if (exitCode === 0) { try { - const response = JSON.parse(stdout) - searchResultsReceived(response) + const response = JSON.parse(stdout); + searchResultsReceived(response); if (callback) { - callback({ "result": response }) + callback({ + "result": response + }); } } catch (e) { - const error = "failed to parse search response" - errorOccurred(error) + const error = "failed to parse search response"; + errorOccurred(error); if (callback) { - callback({ "error": error }) + callback({ + "error": error + }); } } } else if (exitCode === 124) { - const error = "search timed out" - errorOccurred(error) + const error = "search timed out"; + errorOccurred(error); if (callback) { - callback({ "error": error }) + callback({ + "error": error + }); } } else { - const error = "search failed" - errorOccurred(error) + const error = "search failed"; + errorOccurred(error); if (callback) { - callback({ "error": error }) + callback({ + "error": error + }); } } - }, 100, 5000) + }, 100, 5000); } function rediscover() { - checkProcess.running = true + checkProcess.running = true; } }