feat(jetbrains-provider): add JetBrains Provider launcher plugin (#148)

* feat(jetbrains-provider): add JetBrains Provider launcher plugin

Expose recent JetBrains IDE projects in the Noctalia launcher via /jb.

* fix(jetbrains-provider): address PR review feedback

Support IdeaIC and Android Studio, decode XML entities in project paths,
align launcher caching with zed, and update the catalog thumbnail.
This commit is contained in:
Cleboost
2026-07-31 22:34:16 -04:00
committed by GitHub
parent 3c551c7832
commit 86b5ce601a
7 changed files with 601 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# JetBrains Provider
![JetBrains Provider thumbnail](thumbnail.webp)
JetBrains Provider integrates recent projects from supported JetBrains-based IDEs
with the Noctalia launcher so you can reopen a project quickly without leaving
the shell.
## Plugin
| Field | Value |
| --- | --- |
| ID | `cleboost/jetbrains-provider` |
| Entry | Launcher provider: `provider` |
| Launcher Prefix | `/jb` |
## Requirements
Install at least one supported IDE (via Toolbox or standalone). The plugin uses
`bash` and `awk` to scan recent-project files and `nohup` to launch IDEs in the
background.
## Usage
Open the Noctalia launcher and type `/jb` to list recent projects from your
installed IDEs. Continue typing to filter projects by name, then select one to
open it with the IDE that last used it.
## Supported IDEs
IntelliJ IDEA (Ultimate and Community `IdeaIC`), Android Studio, WebStorm,
CLion, GoLand, RustRover, PyCharm (including Community `PyCharmCE`), PhpStorm,
RubyMine, DataGrip, and Rider.
## Settings
- `config_dir` — root directory for JetBrains IDE configuration folders.
- `toolbox_dir` — JetBrains Toolbox apps directory used to locate IDE launchers.
- `max_results` — maximum number of projects shown in the launcher.
- `ignored_ides` — IDE names to exclude (e.g. `WebStorm`, `CLion`).
## Notes
Projects are read from each IDE's `recentProjects.xml`, typically under
`~/.config/JetBrains/<Product><Version>/options/`. When the same project
appears in multiple IDEs, only the most recently activated entry is shown.
Backup config folders and ignored IDEs are skipped. The list is cached for the
launcher session and refreshed when you clear the query.
+219
View File
@@ -0,0 +1,219 @@
--!nonstrict
local cachedProjects = nil
local productIcons = {}
local PRODUCTS = {
IntelliJIdea = { cmd = "idea", slug = "intellij-idea" },
AndroidStudio = { cmd = "studio", slug = "android-studio" },
WebStorm = { cmd = "webstorm", slug = "webstorm" },
CLion = { cmd = "clion", slug = "clion" },
GoLand = { cmd = "goland", slug = "goland" },
RustRover = { cmd = "rustrover", slug = "rustrover" },
PyCharm = { cmd = "pycharm", slug = "pycharm" },
PhpStorm = { cmd = "phpstorm", slug = "phpstorm" },
RubyMine = { cmd = "rubymine", slug = "rubymine" },
DataGrip = { cmd = "datagrip", slug = "datagrip" },
Rider = { cmd = "rider", slug = "rider" },
}
local function trim(s)
return s:match("^%s*(.-)%s*$")
end
local function shellQuote(s)
return "'" .. s:gsub("'", "'\"'\"'") .. "'"
end
local function getConfigDir()
local configured = noctalia.getConfig("config_dir")
if type(configured) == "string" and configured ~= "" then
return noctalia.expandPath(configured)
end
return noctalia.expandPath("~/.config/JetBrains")
end
local function getToolboxDir()
local configured = noctalia.getConfig("toolbox_dir")
if type(configured) == "string" and configured ~= "" then
return noctalia.expandPath(configured)
end
return noctalia.expandPath("~/.local/share/JetBrains/Toolbox/apps")
end
local function getMaxResults()
local n = noctalia.getConfig("max_results")
return (type(n) == "number" and n > 0) and math.floor(n) or 20
end
local function ignoredIdes()
local raw = noctalia.getConfig("ignored_ides")
if type(raw) ~= "table" then
return {}
end
local list = {}
for _, entry in ipairs(raw) do
local name = trim(tostring(entry))
if name ~= "" then
table.insert(list, name)
end
end
return list
end
local function projectName(path)
return path:match("([^/]+)$") or path
end
local function buildScanCommand()
local script = shellQuote(noctalia.pluginDir() .. "/scan_projects.sh")
local ignoreArgs = ""
for _, name in ipairs(ignoredIdes()) do
ignoreArgs = ignoreArgs .. " " .. shellQuote(name)
end
return string.format(
"JB_CONFIG_DIR=%s JB_TOOLBOX_DIR=%s JB_MAX_RESULTS=%d bash %s%s 2>/dev/null",
shellQuote(getConfigDir()),
shellQuote(getToolboxDir()),
getMaxResults(),
script,
ignoreArgs
)
end
local function parseScanOutput(stdout)
local projects = {}
productIcons = {}
for line in stdout:gmatch("[^\n]+") do
local kind, product, value = line:match("^(%w+)\t([^%c]+)\t(.+)$")
if kind == "ICON" and product and value then
productIcons[product] = value
elseif kind and tonumber(kind) and product and value then
table.insert(projects, {
timestamp = tonumber(kind) or 0,
product = product,
path = value,
})
end
end
return projects
end
local function makeRows(projects, filter)
local home = noctalia.getenv("HOME") or ""
local rows = {}
for _, project in ipairs(projects) do
local path = project.path
local name = projectName(path)
local score = filter == "" and 0 or noctalia.fuzzyScore(filter, name)
if score == nil then
score = noctalia.fuzzyScore(filter, path)
end
if filter == "" or score ~= nil then
local display = (home ~= "" and path:sub(1, #home) == home) and "~" .. path:sub(#home + 1) or path
local iconPath = productIcons[project.product]
local row = {
id = project.product .. "|" .. path,
title = name,
subtitle = project.product .. " · " .. display,
score = filter == "" and nil or score,
}
if iconPath then
row.icon = iconPath
else
row.glyph = "code"
end
table.insert(rows, row)
end
end
return rows
end
local function showResults(query, projects, filter)
local rows = makeRows(projects, filter)
if #rows == 0 then
launcher.setResults(query, {
{
id = "",
title = noctalia.tr("no-projects-found"),
subtitle = filter ~= "" and noctalia.tr("filter-empty", { filter = filter }) or noctalia.tr("database-empty"),
glyph = "folder-x",
},
})
else
launcher.setResults(query, rows)
end
end
local function loadProjects(onDone)
noctalia.runAsync(buildScanCommand(), function(result)
local projects = {}
if result.exitCode == 0 and type(result.stdout) == "string" then
projects = parseScanOutput(result.stdout)
end
cachedProjects = projects
onDone(projects)
end)
end
local function resolveLauncher(product)
local meta = PRODUCTS[product]
if meta == nil then
return nil
end
local script = getToolboxDir() .. "/" .. meta.slug .. "/bin/" .. meta.cmd .. ".sh"
if noctalia.fileExists(script) then
return script
end
return meta.cmd
end
function onQuery(query)
local filter = trim(query or "")
if filter == "" then
cachedProjects = nil
productIcons = {}
end
if cachedProjects then
showResults(query, cachedProjects, filter)
return
end
launcher.setResults(query, {
{
id = "",
title = noctalia.tr("loading"),
subtitle = noctalia.tr("loading-subtitle"),
glyph = "loader",
},
})
loadProjects(function(projects)
showResults(query, projects, filter)
end)
end
function onActivate(id)
if id == "" then
return
end
local product, path = id:match("^(.-)|(.+)$")
if product == nil or path == nil or path == "" then
return
end
local launcherCmd = resolveLauncher(product)
if launcherCmd == nil then
return
end
noctalia.runAsync(string.format("nohup %s %s &>/dev/null &", shellQuote(launcherCmd), shellQuote(path)))
end
+48
View File
@@ -0,0 +1,48 @@
id = "cleboost/jetbrains-provider"
name = "JetBrains Provider"
version = "1.0.0"
plugin_api = 3
author = "Cleboost"
license = "MIT"
icon = "folder-open"
description = "Open a recent JetBrains project from the launcher. Type /jb to list your projects."
tags = ["launcher", "development", "productivity"]
dependencies = ["bash", "awk", "nohup"]
[[setting]]
key = "config_dir"
type = "folder"
label_key = "settings.config_dir.label"
description_key = "settings.config_dir.description"
default = "~/.config/JetBrains"
[[setting]]
key = "toolbox_dir"
type = "folder"
label_key = "settings.toolbox_dir.label"
description_key = "settings.toolbox_dir.description"
default = "~/.local/share/JetBrains/Toolbox/apps"
[[setting]]
key = "max_results"
type = "int"
label_key = "settings.max_results.label"
description_key = "settings.max_results.description"
default = 20
min = 1
max = 100
[[setting]]
key = "ignored_ides"
type = "string_list"
label_key = "settings.ignored_ides.label"
description_key = "settings.ignored_ides.description"
default = []
[[launcher_provider]]
id = "provider"
entry = "jb_provider.luau"
prefix = "jb"
glyph = "code"
include_in_global_search = false
debounce_ms = 0
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env bash
set -eu
CONFIG="${JB_CONFIG_DIR:-$HOME/.config/JetBrains}"
TOOLBOX="${JB_TOOLBOX_DIR:-$HOME/.local/share/JetBrains/Toolbox/apps}"
MAX_RESULTS="${JB_MAX_RESULTS:-20}"
IGNORED=("$@")
PRODUCT_PREFIXES=(
IdeaIC:IntelliJIdea
IntelliJIdea:IntelliJIdea
AndroidStudio:AndroidStudio
WebStorm:WebStorm
CLion:CLion
GoLand:GoLand
RustRover:RustRover
PyCharm:PyCharm
PhpStorm:PhpStorm
RubyMine:RubyMine
DataGrip:DataGrip
Rider:Rider
)
declare -A PRODUCT_CMD=(
[IntelliJIdea]=idea
[AndroidStudio]=studio
[WebStorm]=webstorm
[CLion]=clion
[GoLand]=goland
[RustRover]=rustrover
[PyCharm]=pycharm
[PhpStorm]=phpstorm
[RubyMine]=rubymine
[DataGrip]=datagrip
[Rider]=rider
)
declare -A PRODUCT_SLUG=(
[IntelliJIdea]=intellij-idea
[AndroidStudio]=android-studio
[WebStorm]=webstorm
[CLion]=clion
[GoLand]=goland
[RustRover]=rustrover
[PyCharm]=pycharm
[PhpStorm]=phpstorm
[RubyMine]=rubymine
[DataGrip]=datagrip
[Rider]=rider
)
declare -A PRODUCT_ICON=(
[IntelliJIdea]=jetbrains-intellij-idea
[AndroidStudio]=com.google.AndroidStudio
[WebStorm]=com.jetbrains.WebStorm
[CLion]=com.jetbrains.CLion
[GoLand]=com.jetbrains.GoLand
[RustRover]=com.jetbrains.RustRover
[PyCharm]=com.jetbrains.PyCharm
[PhpStorm]=com.jetbrains.PhpStorm
[RubyMine]=com.jetbrains.RubyMine
[DataGrip]=com.jetbrains.DataGrip
[Rider]=com.jetbrains.Rider
)
declare -A seen_icons=()
product_name() {
local dir="$1" entry prefix canonical
for entry in "${PRODUCT_PREFIXES[@]}"; do
prefix="${entry%%:*}"
canonical="${entry#*:}"
if [[ "$dir" == "$prefix"* ]]; then
printf '%s\n' "$canonical"
return 0
fi
done
return 1
}
is_ignored() {
local dir="$1"
local lower="${dir,,}"
local ig lower_ig
for ig in "${IGNORED[@]}"; do
lower_ig="${ig,,}"
if [[ "$lower" == "$lower_ig"* ]]; then
return 0
fi
done
return 1
}
find_icon() {
local product="$1"
local cmd="${PRODUCT_CMD[$product]:-}"
local slug="${PRODUCT_SLUG[$product]:-}"
local icon_name="${PRODUCT_ICON[$product]:-}"
local base name path root ext
[[ -n "$cmd" && -n "$slug" ]] || return 1
base="${TOOLBOX}/${slug}/bin"
for name in "${cmd}.svg" "${cmd}.png" "idea.svg"; do
path="${base}/${name}"
if [[ -f "$path" ]]; then
printf '%s\n' "$path"
return 0
fi
done
[[ -n "$icon_name" ]] || return 1
for root in \
"${HOME}/.local/share/icons" \
"/usr/share/icons/hicolor/scalable/apps" \
"/usr/share/icons/WhiteSur/apps/scalable" \
"/usr/share/pixmaps"; do
for ext in .svg .png; do
path="${root}/${icon_name}${ext}"
if [[ -f "$path" ]]; then
printf '%s\n' "$path"
return 0
fi
done
done
return 1
}
parse_xml() {
local xml="$1"
local product="$2"
awk -v product="$product" -v home="$HOME" '
function decode_xml(text) {
gsub("&quot;", "\"", text)
gsub("&apos;", "\047", text)
gsub("&lt;", "<", text)
gsub("&gt;", ">", text)
gsub("&amp;", "\\&", text)
return text
}
function expand_path(path, pos, needle) {
needle = "$USER_HOME$"
while ((pos = index(path, needle)) > 0) {
path = substr(path, 1, pos - 1) home substr(path, pos + length(needle))
}
needle = "$APPLICATION_HOME_DIR$"
while ((pos = index(path, needle)) > 0) {
path = substr(path, 1, pos - 1) substr(path, pos + length(needle))
}
return path
}
BEGIN { RS = "</entry>" }
/<entry key="/ {
key = ""
if (match($0, /key="[^"]+"/)) {
key = decode_xml(expand_path(substr($0, RSTART + 5, RLENGTH - 6)))
}
if (key == "" || key ~ /^\$/) next
ts = 0
if (match($0, /activationTimestamp" value="[0-9]+"/)) {
part = substr($0, RSTART, RLENGTH)
sub(/^activationTimestamp" value="/, "", part)
sub(/"$/, "", part)
ts = part + 0
} else if (match($0, /projectOpenTimestamp" value="[0-9]+"/)) {
part = substr($0, RSTART, RLENGTH)
sub(/^projectOpenTimestamp" value="/, "", part)
sub(/"$/, "", part)
ts = part + 0
}
print ts "\t" product "\t" key
}
' "$xml"
}
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
projects_file="${tmpdir}/projects.tsv"
: >"$projects_file"
for xml in "$CONFIG"/*/options/recentProjects.xml; do
[[ -f "$xml" ]] || continue
dir="$(basename "$(dirname "$(dirname "$xml")")")"
[[ "$dir" == *-backup ]] && continue
is_ignored "$dir" && continue
product="$(product_name "$dir" || true)"
[[ -n "$product" ]] || continue
if [[ -z "${seen_icons[$product]:-}" ]]; then
seen_icons[$product]=1
if icon="$(find_icon "$product" || true)" && [[ -n "$icon" ]]; then
printf 'ICON\t%s\t%s\n' "$product" "$icon"
fi
fi
parse_xml "$xml" "$product" >>"$projects_file"
done
awk -F '\t' -v limit="$MAX_RESULTS" '
{
path = $3
if (!(path in best_ts) || $1 > best_ts[path]) {
best_ts[path] = $1
best_line[path] = $0
}
}
END {
count = 0
for (path in best_line) {
split(best_line[path], fields, "\t")
count++
ts[count] = fields[1] + 0
out[count] = best_line[path]
}
for (i = 1; i <= count; i++) {
for (j = i + 1; j <= count; j++) {
if (ts[j] > ts[i]) {
tmp = ts[i]; ts[i] = ts[j]; ts[j] = tmp
tmp = out[i]; out[i] = out[j]; out[j] = tmp
}
}
}
if (limit < 1) {
limit = 20
}
max = count < limit ? count : limit
for (i = 1; i <= max; i++) {
print out[i]
}
}
' "$projects_file"
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"database-empty": "No JetBrains projects found",
"filter-empty": "Filter: \"{filter}\"",
"loading": "Loading…",
"loading-subtitle": "Reading JetBrains projects",
"no-projects-found": "No projects found",
"settings": {
"config_dir": {
"description": "Root directory for JetBrains IDE configuration folders.",
"label": "JetBrains config directory"
},
"ignored_ides": {
"description": "IDE names to exclude (e.g. WebStorm, CLion). Matches the start of each config folder name.",
"label": "Ignored IDEs"
},
"max_results": {
"description": "Maximum number of projects to display.",
"label": "Maximum results"
},
"toolbox_dir": {
"description": "JetBrains Toolbox apps directory used to locate IDE launchers.",
"label": "Toolbox apps directory"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"database-empty": "Aucun projet JetBrains trouvé",
"filter-empty": "Filtre : « {filter} »",
"loading": "Chargement…",
"loading-subtitle": "Lecture des projets JetBrains",
"no-projects-found": "Aucun projet trouvé",
"settings": {
"config_dir": {
"description": "Répertoire racine des dossiers de configuration JetBrains.",
"label": "Répertoire de config JetBrains"
},
"ignored_ides": {
"description": "Noms d'IDE à exclure (ex. WebStorm, CLion). Correspond au début du nom du dossier de config.",
"label": "IDE ignorés"
},
"max_results": {
"description": "Nombre maximum de projets à afficher.",
"label": "Nombre maximum de résultats"
},
"toolbox_dir": {
"description": "Répertoire des apps JetBrains Toolbox pour localiser les lanceurs d'IDE.",
"label": "Répertoire Toolbox apps"
}
}
}