Files
noctalia-shell/Modules/Panels/Launcher/Plugins/ApplicationsPlugin.qml
T
2025-11-16 17:07:03 -05:00

270 lines
10 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import "../../../../Helpers/FuzzySort.js" as Fuzzysort
import qs.Commons
Item {
property var launcher: null
property string name: I18n.tr("plugins.applications")
property bool handleSearch: true
property var entries: []
// Persistent usage tracking stored in cacheDir
property string usageFilePath: Settings.cacheDir + "launcher_app_usage.json"
// Debounced saver to avoid excessive IO
Timer {
id: saveTimer
interval: 750
repeat: false
onTriggered: usageFile.writeAdapter()
}
FileView {
id: usageFile
path: usageFilePath
printErrors: false
watchChanges: false
onLoadFailed: function (error) {
if (error.toString().includes("No such file") || error === 2) {
writeAdapter();
}
}
onAdapterUpdated: saveTimer.start()
JsonAdapter {
id: usageAdapter
// key: app id/command, value: integer count
property var counts: ({})
}
}
function init() {
loadApplications();
}
function onOpened() {
// Refresh apps when launcher opens
loadApplications();
}
function loadApplications() {
if (typeof DesktopEntries === 'undefined') {
Logger.w("ApplicationsPlugin", "DesktopEntries service not available");
return;
}
const allApps = DesktopEntries.applications.values || [];
entries = allApps.filter(app => app && !app.noDisplay).map(app => {
// Add executable name property for search
app.executableName = getExecutableName(app);
return app;
});
Logger.d("ApplicationsPlugin", `Loaded ${entries.length} applications`);
}
function getExecutableName(app) {
if (!app)
return "";
// Try to get executable name from command array
if (app.command && Array.isArray(app.command) && app.command.length > 0) {
const cmd = app.command[0];
// Extract just the executable name from the full path
const parts = cmd.split('/');
const executable = parts[parts.length - 1];
// Remove any arguments or parameters
return executable.split(' ')[0];
}
// Try to get from exec property if available
if (app.exec) {
const parts = app.exec.split('/');
const executable = parts[parts.length - 1];
return executable.split(' ')[0];
}
// Fallback to app id (desktop file name without .desktop)
if (app.id) {
return app.id.replace('.desktop', '');
}
return "";
}
function getResults(query) {
if (!entries || entries.length === 0)
return [];
if (!query || query.trim() === "") {
// Return all apps, optionally sorted by usage
const favoriteApps = Settings.data.appLauncher.favoriteApps || [];
let sorted;
if (Settings.data.appLauncher.sortByMostUsed) {
sorted = entries.slice().sort((a, b) => {
// Favorites first
const aFav = favoriteApps.includes(getAppKey(a));
const bFav = favoriteApps.includes(getAppKey(b));
if (aFav !== bFav)
return aFav ? -1 : 1;
const ua = getUsageCount(a);
const ub = getUsageCount(b);
if (ub !== ua)
return ub - ua;
return (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase());
});
} else {
sorted = entries.slice().sort((a, b) => {
const aFav = favoriteApps.includes(getAppKey(a));
const bFav = favoriteApps.includes(getAppKey(b));
if (aFav !== bFav)
return aFav ? -1 : 1;
return (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase());
});
}
return sorted.map(app => createResultEntry(app));
}
// Use fuzzy search if available, fallback to simple search
if (typeof Fuzzysort !== 'undefined') {
const fuzzyResults = Fuzzysort.go(query, entries, {
"keys": ["name", "comment", "genericName", "executableName"],
"threshold": -1000,
"limit": 20
});
// Sort favorites first within fuzzy results while preserving fuzzysort order otherwise
const favoriteApps = Settings.data.appLauncher.favoriteApps || [];
const fav = [];
const nonFav = [];
for (const r of fuzzyResults) {
const app = r.obj;
if (favoriteApps.includes(getAppKey(app)))
fav.push(r);
else
nonFav.push(r);
}
return fav.concat(nonFav).map(result => createResultEntry(result.obj));
} else {
// Fallback to simple search
const searchTerm = query.toLowerCase();
return entries.filter(app => {
const name = (app.name || "").toLowerCase();
const comment = (app.comment || "").toLowerCase();
const generic = (app.genericName || "").toLowerCase();
const executable = getExecutableName(app).toLowerCase();
return name.includes(searchTerm) || comment.includes(searchTerm) || generic.includes(searchTerm) || executable.includes(searchTerm);
}).sort((a, b) => {
// Prioritize name matches, then executable matches
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
const aExecutable = getExecutableName(a).toLowerCase();
const bExecutable = getExecutableName(b).toLowerCase();
const aStarts = aName.startsWith(searchTerm);
const bStarts = bName.startsWith(searchTerm);
const aExecStarts = aExecutable.startsWith(searchTerm);
const bExecStarts = bExecutable.startsWith(searchTerm);
// Prioritize name matches first
if (aStarts && !bStarts)
return -1;
if (!aStarts && bStarts)
return 1;
// Then prioritize executable matches
if (aExecStarts && !bExecStarts)
return -1;
if (!aExecStarts && bExecStarts)
return 1;
return aName.localeCompare(bName);
}).slice(0, 20).map(app => createResultEntry(app));
}
}
function createResultEntry(app) {
return {
"appId": getAppKey(app),
"name": app.name || "Unknown",
"description": app.genericName || app.comment || "",
"icon": app.icon || "application-x-executable",
"isImage": false,
"onActivate": function () {
// Close the launcher/SmartPanel immediately without any animations.
// Ensures we are not preventing the future focusing of the app
launcher.close();
Logger.d("ApplicationsPlugin", `Launching: ${app.name}`);
// Record usage and persist asynchronously
if (Settings.data.appLauncher.sortByMostUsed)
recordUsage(app);
if (Settings.data.appLauncher.customLaunchPrefixEnabled && Settings.data.appLauncher.customLaunchPrefix) {
// Use custom launch prefix
const prefix = Settings.data.appLauncher.customLaunchPrefix.split(" ");
if (app.runInTerminal) {
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = prefix.concat(terminal.concat(app.command));
Quickshell.execDetached(command);
} else {
const command = prefix.concat(app.command);
Quickshell.execDetached(command);
}
} else if (Settings.data.appLauncher.useApp2Unit && app.id) {
Logger.d("ApplicationsPlugin", `Using app2unit for: ${app.id}`);
if (app.runInTerminal)
Quickshell.execDetached(["app2unit", "--", app.id + ".desktop"]);
else
Quickshell.execDetached(["app2unit", "--"].concat(app.command));
} else {
// Fallback logic when app2unit is not used
if (app.runInTerminal) {
// If app.execute() fails for terminal apps, we handle it manually.
Logger.d("ApplicationsPlugin", "Executing terminal app manually: " + app.name);
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = terminal.concat(app.command);
Quickshell.execDetached(command);
} else if (app.execute) {
// Default execution for GUI apps
app.execute();
} else {
Logger.w("ApplicationsPlugin", `Could not launch: ${app.name}. No valid launch method.`);
}
}
}
};
}
// -------------------------
// Usage tracking helpers
function getAppKey(app) {
if (app && app.id)
return String(app.id);
if (app && app.command && app.command.join)
return app.command.join(" ");
return String(app && app.name ? app.name : "unknown");
}
function getUsageCount(app) {
const key = getAppKey(app);
const m = usageAdapter && usageAdapter.counts ? usageAdapter.counts : null;
if (!m)
return 0;
const v = m[key];
return typeof v === 'number' && isFinite(v) ? v : 0;
}
function recordUsage(app) {
const key = getAppKey(app);
if (!usageAdapter.counts)
usageAdapter.counts = ({});
const current = getUsageCount(app);
usageAdapter.counts[key] = current + 1;
// Trigger save via debounced timer
saveTimer.restart();
}
}