diff --git a/jetbrains-provider/README.md b/jetbrains-provider/README.md new file mode 100644 index 0000000..23db2ca --- /dev/null +++ b/jetbrains-provider/README.md @@ -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//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. diff --git a/jetbrains-provider/jb_provider.luau b/jetbrains-provider/jb_provider.luau new file mode 100644 index 0000000..e7ac92d --- /dev/null +++ b/jetbrains-provider/jb_provider.luau @@ -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 diff --git a/jetbrains-provider/plugin.toml b/jetbrains-provider/plugin.toml new file mode 100644 index 0000000..d5db694 --- /dev/null +++ b/jetbrains-provider/plugin.toml @@ -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 diff --git a/jetbrains-provider/scan_projects.sh b/jetbrains-provider/scan_projects.sh new file mode 100755 index 0000000..38002fb --- /dev/null +++ b/jetbrains-provider/scan_projects.sh @@ -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(""", "\"", text) + gsub("'", "\047", text) + gsub("<", "<", text) + gsub(">", ">", text) + gsub("&", "\\&", 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 = "" } + /"$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" diff --git a/jetbrains-provider/thumbnail.webp b/jetbrains-provider/thumbnail.webp new file mode 100644 index 0000000..66269b7 Binary files /dev/null and b/jetbrains-provider/thumbnail.webp differ diff --git a/jetbrains-provider/translations/en.json b/jetbrains-provider/translations/en.json new file mode 100644 index 0000000..30d6bfe --- /dev/null +++ b/jetbrains-provider/translations/en.json @@ -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" + } + } +} diff --git a/jetbrains-provider/translations/fr.json b/jetbrains-provider/translations/fr.json new file mode 100644 index 0000000..b8f4ced --- /dev/null +++ b/jetbrains-provider/translations/fr.json @@ -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" + } + } +}