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:
@@ -0,0 +1,48 @@
|
||||
# JetBrains Provider
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+236
@@ -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 = "</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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user