├── requirements.txt ├── .gitignore ├── .github ├── demo.gif └── logo.svg ├── versions.json ├── .mypy.ini ├── .pylintrc ├── data ├── IdeKey.py ├── IdeProject.py └── IdeData.py ├── events ├── PreferencesUpdateEventListener.py ├── PreferencesEventListener.py └── KeywordQueryEventListener.py ├── Makefile ├── LICENSE ├── images ├── icon.svg ├── rubymine.svg ├── rider.svg ├── phpstorm.svg ├── android-studio.svg ├── rustrover.svg ├── clion.svg ├── idea.svg ├── webstorm.svg ├── pycharm.svg ├── goland.svg └── datagrip.svg ├── manifest.json ├── utils ├── ProjectsList.py ├── fuzzy_search.py ├── RecentProjectsParser.py └── SortedCollection.py ├── README.md └── main.py /requirements.txt: -------------------------------------------------------------------------------- 1 | semver~=2.13.0 2 | pylint~=2.12.2 3 | mypy~=0.930 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | .cache 4 | *.log 5 | .vscode/ 6 | .idea/ 7 | -------------------------------------------------------------------------------- /.github/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakuciael/ulauncher-jetbrains-reloaded/HEAD/.github/demo.gif -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "required_api_version": "^2.0.0", 4 | "commit": "master" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | mypy_path = ./ 3 | ignore_missing_imports = True 4 | explicit_package_bases = True 5 | namespace_packages = True -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | init-hook="from pylint.config import find_pylintrc; import os, sys; sys.path.append(os.path.dirname(find_pylintrc()))" 3 | 4 | [MESSAGES CONTROL] 5 | disable=invalid-name -------------------------------------------------------------------------------- /data/IdeKey.py: -------------------------------------------------------------------------------- 1 | """ Contains IdeKey type """ 2 | 3 | from typing import Literal 4 | 5 | IdeKey = Literal[ 6 | "clion", 7 | "idea", 8 | "phpstorm", 9 | "pycharm", 10 | "rider", 11 | "webstorm", 12 | "goland", 13 | "datagrip", 14 | "rubymine", 15 | "android-studio", 16 | "rustrover" 17 | ] 18 | -------------------------------------------------------------------------------- /data/IdeProject.py: -------------------------------------------------------------------------------- 1 | """ Contains project type """ 2 | from __future__ import annotations 3 | 4 | from data.IdeKey import IdeKey 5 | 6 | 7 | # pylint: disable=too-few-public-methods 8 | class IdeProject: 9 | """ Dictionary describing project data """ 10 | name: str 11 | ide: IdeKey 12 | path: str 13 | timestamp: int | None 14 | icon: str | None 15 | score: int 16 | 17 | # pylint: disable=too-many-arguments 18 | def __init__(self, name: str, ide: IdeKey, path: str, 19 | timestamp: int | None, icon: str | None = None) -> None: 20 | super().__init__() 21 | 22 | self.name = name 23 | self.ide = ide 24 | self.path = path 25 | self.timestamp = timestamp 26 | self.icon = icon 27 | self.score = 0 28 | -------------------------------------------------------------------------------- /data/IdeData.py: -------------------------------------------------------------------------------- 1 | """ Contains IdeData class """ 2 | from __future__ import annotations 3 | 4 | from typing import List 5 | 6 | 7 | # pylint: disable=too-few-public-methods 8 | class IdeData: 9 | """ Class describing ide options""" 10 | name: str 11 | config_prefixes: [str] 12 | launcher_prefixes: List[str] 13 | custom_config_key: str | None 14 | recent_projects_file: str 15 | 16 | def __init__(self, name: str, config_prefixes: [str], launcher_prefixes: List[str], 17 | custom_config_key: str | None = None, recent_projects_file: str = "recentProjects.xml") -> None: 18 | super().__init__() 19 | self.name = name 20 | self.config_prefixes = config_prefixes 21 | self.launcher_prefixes = launcher_prefixes 22 | self.custom_config_key = custom_config_key 23 | self.recent_projects_file = recent_projects_file 24 | -------------------------------------------------------------------------------- /events/PreferencesUpdateEventListener.py: -------------------------------------------------------------------------------- 1 | """ Contains class for handling preference update events from Ulauncher""" 2 | 3 | from typing import TYPE_CHECKING 4 | from ulauncher.api.client.EventListener import EventListener 5 | from ulauncher.api.shared.event import PreferencesUpdateEvent 6 | 7 | if TYPE_CHECKING: 8 | from main import JetbrainsLauncherExtension 9 | 10 | 11 | # pylint: disable=too-few-public-methods 12 | class PreferencesUpdateEventListener(EventListener): 13 | """ Handles updates to user settings and parses them """ 14 | 15 | def on_event(self, event: PreferencesUpdateEvent, extension: 'JetbrainsLauncherExtension') -> \ 16 | None: 17 | """ 18 | Handles the preference update event 19 | :param event: Event data 20 | :param extension: Extension class 21 | """ 22 | 23 | extension.preferences[event.id] = event.new_value 24 | if event.id == "custom_aliases": 25 | extension.set_aliases(extension.parse_aliases(event.new_value)) 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PORT_REGEX := ^[0-9]+([.][0-9]+)?$ 2 | EXT_NAME:=com.github.zakuciael.ulauncher-jetbrains-reloaded 3 | EXT_DIR:=$(shell pwd) 4 | 5 | .PHONY: help link unlink deps start dev 6 | .DEFAULT_GOAL := help 7 | 8 | link: ## Symlink the project source directory with Ulauncher extensions dir. 9 | @ln -s ${EXT_DIR} ~/.local/share/ulauncher/extensions/${EXT_NAME} 10 | 11 | unlink: ## Unlink extension from Ulauncher 12 | @rm -r ~/.local/share/ulauncher/extensions/${EXT_NAME} 13 | 14 | deps: ## Install Python Dependencies 15 | @pip install -r requirements.txt 16 | 17 | dev: ## Runs ulauncher on development mode 18 | ulauncher -v --dev --no-extensions |& grep "${EXT_NAME}" 19 | 20 | start: ## Starts extension backend for development 21 | ifeq ($(shell echo ${PORT} | egrep "${PORT_REGEX}"),) 22 | @echo "Port is not an number" 23 | else 24 | VERBOSE=1 ULAUNCHER_WS_API=ws://127.0.0.1:${PORT}/${EXT_NAME} python3 ~/.local/share/ulauncher/extensions/${EXT_NAME}/main.py 25 | endif 26 | 27 | help: ## Show help menu 28 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Krzysztof Saczuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /events/PreferencesEventListener.py: -------------------------------------------------------------------------------- 1 | """ Contains class for handling initial preference event from Ulauncher""" 2 | 3 | from typing import TYPE_CHECKING 4 | from ulauncher.api.client.EventListener import EventListener 5 | from ulauncher.api.shared.event import PreferencesEvent 6 | 7 | if TYPE_CHECKING: 8 | from main import JetbrainsLauncherExtension 9 | 10 | 11 | # pylint: disable=too-few-public-methods 12 | class PreferencesEventListener(EventListener): 13 | """ Handles initial user settings and parses them """ 14 | 15 | def on_event(self, event: PreferencesEvent, extension: 'JetbrainsLauncherExtension') -> None: 16 | """ 17 | Handles the preference event 18 | :param event: Event data 19 | :param extension: Extension class 20 | """ 21 | 22 | if "studio_config_path" not in event.preferences: 23 | event.preferences["studio_config_path"] = "~/.config/Google" 24 | 25 | if "sort_by" not in event.preferences: 26 | event.preferences["sort_by"] = "none" 27 | 28 | aliases = extension.parse_aliases(event.preferences.get("custom_aliases")) 29 | 30 | if not any((ide_key for ide_key in aliases.values() if ide_key == "rustrover")): 31 | aliases["rust"] = "rustrover" 32 | 33 | extension.preferences.update(event.preferences) 34 | extension.set_aliases(aliases) 35 | -------------------------------------------------------------------------------- /.github/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "required_api_version": "^2.0.0", 3 | "name": "Jetbrains Launcher Reloaded", 4 | "description": "Open your projects in Jetbrains IDEs", 5 | "developer_name": "Krzysztof Saczuk", 6 | "icon": "images/icon.svg", 7 | "options": { 8 | "query_debounce": 0.5 9 | }, 10 | "preferences": [ 11 | { 12 | "id": "global_keyword", 13 | "type": "keyword", 14 | "name": "Global keyword", 15 | "description": "Global keyword used by the extension", 16 | "default_value": "ide" 17 | }, 18 | { 19 | "id": "scripts_path", 20 | "type": "input", 21 | "name": "Shell scripts location", 22 | "description": "Path to the shell scripts generated by the JetBrains Toolbox app.", 23 | "default_value": "~/.local/bin" 24 | }, 25 | { 26 | "id": "configs_path", 27 | "type": "input", 28 | "name": "Configs location", 29 | "description": "Path to the configs location where all IDEs store their configuration.", 30 | "default_value": "~/.config/JetBrains" 31 | }, 32 | { 33 | "id": "studio_config_path", 34 | "type": "input", 35 | "name": "Android Studio config location", 36 | "description": "Path to the config location where the Android Studio IDE sores its configuration.", 37 | "default_value": "~/.config/Google" 38 | }, 39 | { 40 | "id": "custom_aliases", 41 | "type": "text", 42 | "name": "Custom aliases", 43 | "description": "Custom aliases for the IDEs. Example format: web: webstorm; php: phpstorm;", 44 | "default_value": "intellij: idea; php: phpstorm; py: pycharm; web: webstorm; go: goland; grip: datagrip; ruby: rubymine; studio: android-studio; android: android-studio; rust: rustrover;" 45 | }, 46 | { 47 | "id": "sort_by", 48 | "type": "select", 49 | "name": "Sort by", 50 | "description": "Changes how the query results are sorted", 51 | "default_value": "none", 52 | "options": [ 53 | { 54 | "value": "none", 55 | "text": "No sorting" 56 | }, 57 | { 58 | "value": "recent", 59 | "text": "Recent project" 60 | }, 61 | { 62 | "value": "ascending", 63 | "text": "Name [Ascending]" 64 | }, 65 | { 66 | "value": "descending", 67 | "text": "Name [Descending]" 68 | } 69 | ] 70 | } 71 | ] 72 | } -------------------------------------------------------------------------------- /utils/ProjectsList.py: -------------------------------------------------------------------------------- 1 | """ Contains custom implementation of SortedList found in Ulauncher's code """ 2 | from typing import Iterator, List 3 | 4 | from utils.SortedCollection import SortedCollection # type: ignore 5 | from utils.fuzzy_search import get_score # type: ignore 6 | 7 | from data.IdeProject import IdeProject 8 | 9 | 10 | class ProjectsList: 11 | """ 12 | List maintains items in a sorted order 13 | (sorted by a score, which is a similarity between item's name and a query) 14 | and limited to a number `limit` passed into the constructor 15 | """ 16 | 17 | _query: str 18 | _min_score: int 19 | _limit: int 20 | _items: SortedCollection 21 | 22 | def __init__(self, query, min_score=30, limit=9) -> None: 23 | self._query = query.lower().strip() 24 | self._min_score = min_score 25 | self._limit = limit 26 | self._items = SortedCollection( 27 | key=lambda item: 28 | (-item.timestamp if item.timestamp is not None else 0) 29 | if not self._query else item.score 30 | ) 31 | 32 | def __len__(self) -> int: 33 | return len(self._items) 34 | 35 | def __getitem__(self, i) -> IdeProject: 36 | return self._items[i] 37 | 38 | def __iter__(self) -> Iterator[IdeProject]: 39 | return iter(self._items) 40 | 41 | def __reversed__(self) -> Iterator[IdeProject]: 42 | return reversed(self._items) 43 | 44 | def __repr__(self) -> str: 45 | return f"{self.__class__.__name__}" + \ 46 | f"({self._items}, min_score={self._min_score}, limit={self._limit})" 47 | 48 | def __contains__(self, item: IdeProject) -> bool: 49 | return item in self._items 50 | 51 | def extend(self, items: List[IdeProject]) -> None: 52 | """ 53 | Merges all provided items into this list 54 | :param items: A list of items to merge 55 | """ 56 | for item in items: 57 | self.append(item) 58 | 59 | def append(self, item: IdeProject) -> None: 60 | """ 61 | Adds item to the list 62 | :param item: Item to add 63 | """ 64 | name = item.name 65 | path = item.path.replace(r"^~", "") 66 | 67 | if not self._query: 68 | self._items.insert(item) 69 | 70 | while len(self._items) > self._limit: 71 | self._items.pop() 72 | else: 73 | score = max(get_score(self._query, name), get_score(self._query, path)) 74 | 75 | if score >= self._min_score: 76 | # use negative to sort by score in desc. order 77 | item.score = -score 78 | 79 | self._items.insert(item) 80 | while len(self._items) > self._limit: 81 | # remove items with the lowest score to maintain limited number of items 82 | self._items.pop() 83 | -------------------------------------------------------------------------------- /utils/fuzzy_search.py: -------------------------------------------------------------------------------- 1 | # The following source code was obtained from https://github.com/Ulauncher/Ulauncher under GPLv3 license. 2 | # The original file can be found at: 3 | # https://raw.githubusercontent.com/Ulauncher/Ulauncher/7a667ff629b4e838e29619b26738a8683ded7421/ulauncher/utils/fuzzy_search.py 4 | 5 | from __future__ import annotations 6 | 7 | import logging 8 | import unicodedata 9 | from difflib import Match, SequenceMatcher 10 | from functools import lru_cache 11 | 12 | logger = logging.getLogger() 13 | 14 | 15 | def _get_matching_blocks_native(query: str, text: str) -> list[Match]: 16 | return SequenceMatcher(None, query, text).get_matching_blocks() 17 | 18 | 19 | # Using Levenshtein is ~10x faster, but some older distro releases might not package Levenshtein 20 | # with these methods. So we fall back on difflib.SequenceMatcher (native Python library) to be sure. 21 | try: 22 | from Levenshtein import editops, matching_blocks # type: ignore[import-not-found, unused-ignore] 23 | 24 | def _get_matching_blocks(query: str, text: str) -> list[tuple[int, int, int]]: 25 | return matching_blocks(editops(query, text), query, text) # type: ignore[no-any-return, unused-ignore] 26 | 27 | except ImportError: 28 | logger.info( 29 | "Using fuzzy-matching with Native Python SequenceMatcher module. " 30 | "optional dependency 'python-Levenshtein' is recommended for better performance" 31 | ) 32 | _get_matching_blocks = _get_matching_blocks_native # type: ignore[assignment] 33 | 34 | 35 | # convert strings to easily typable ones without accents, so ex "motorhead" matches "motörhead" 36 | def _normalize(string: str) -> str: 37 | return unicodedata.normalize("NFD", string.casefold()).encode("ascii", "ignore").decode("utf-8") 38 | 39 | 40 | @lru_cache(maxsize=1000) 41 | def get_matching_blocks(query: str, text: str) -> tuple[list[tuple[int, str]], int]: 42 | """ 43 | Uses our _get_matching_blocks wrapper method to find the blocks using "Longest Common Substrings", 44 | :returns: list of tuples, containing the index and matching block, number of characters that matched 45 | """ 46 | blocks = _get_matching_blocks(_normalize(query), _normalize(text))[:-1] 47 | output = [] 48 | total_len = 0 49 | for _, text_index, length in blocks: 50 | output.append((text_index, text[text_index : text_index + length])) 51 | total_len += length 52 | return output, total_len 53 | 54 | 55 | def get_score(query: str, text: str) -> float: 56 | """ 57 | Uses get_matching_blocks() to figure out how much of the query that matches the text, 58 | and tries to weight this to slightly favor shorter results and largely favor word matches 59 | :returns: number between 0 and 100 60 | """ 61 | 62 | if not query or not text: 63 | return 0.0 64 | 65 | query_len = len(query) 66 | text_len = len(text) 67 | max_len = max(query_len, text_len) 68 | blocks, matching_chars = get_matching_blocks(query, text) 69 | 70 | # Ratio of the query that matches the text 71 | base_similarity = matching_chars / query_len 72 | 73 | # Lower the score if the match is in the middle of a word. 74 | for index, _ in blocks: 75 | is_word_boundary = index == 0 or text[index - 1] == " " 76 | if not is_word_boundary: 77 | base_similarity -= 0.5 / query_len 78 | 79 | # Rank matches lower for each extra character, to slightly favor shorter ones. 80 | return 100 * base_similarity * query_len / (query_len + (max_len - query_len) * 0.001) 81 | 82 | -------------------------------------------------------------------------------- /utils/RecentProjectsParser.py: -------------------------------------------------------------------------------- 1 | """ Contains parser for JetBrains IDEs "Recent projects" files """ 2 | 3 | import glob 4 | import os 5 | from typing import Optional, cast, List, TypedDict 6 | from xml.etree import ElementTree 7 | from xml.etree.ElementTree import Element 8 | 9 | from data.IdeKey import IdeKey 10 | from data.IdeProject import IdeProject 11 | 12 | EntryData = TypedDict("EntryData", {"path": str, "timestamp": Optional[int]}) 13 | 14 | TIMESTAMP_XML_PATH = 'value/RecentProjectMetaInfo/option[@name="projectOpenTimestamp"]' 15 | 16 | 17 | # pylint: disable=too-few-public-methods 18 | class RecentProjectsParser: 19 | """ Parser for JetBrains IDEs "Recent projects" files """ 20 | 21 | @staticmethod 22 | def scan_paths(root: Element) -> List[Element]: 23 | """ 24 | Find all elements from paths 25 | :param root: Root element of the XML file 26 | :return: Found elements 27 | """ 28 | 29 | raw_projects = [] 30 | paths = [ 31 | './/component[@name="RecentProjectsManager"][1]', 32 | './/component[@name="RecentDirectoryProjectsManager"][1]', 33 | './/component[@name="RiderRecentProjectsManager"][1]', 34 | './/component[@name="RiderRecentDirectoryProjectsManager"][1]' 35 | ] 36 | 37 | for path in paths: 38 | raw_projects += \ 39 | root.findall(f'{path}/option[@name="recentPaths"]/list/option') + \ 40 | root.findall(f'{path}/option[@name="additionalInfo"]/map/entry') + \ 41 | root.findall( 42 | f'{path}/option[@name="groups"]/list/ProjectGroup/' + 43 | 'option[@name="projects"]/list/option' 44 | ) 45 | 46 | return raw_projects 47 | 48 | @staticmethod 49 | def parse(file_path: str, ide_key: IdeKey) -> List[IdeProject]: 50 | """ 51 | Parses the "Recent projects" file 52 | :param file_path: The path to the file 53 | :param ide_key: IDE key identified with the file 54 | :return: Parsed projects 55 | """ 56 | 57 | if not os.path.isfile(file_path): 58 | return [] 59 | 60 | root = ElementTree.parse(file_path).getroot() 61 | raw_projects = RecentProjectsParser.scan_paths(root) 62 | 63 | projects: List[EntryData] = [ 64 | cast( 65 | EntryData, 66 | { 67 | "path": ( 68 | project.attrib['value' if 'value' in project.attrib else 'key']) 69 | .replace("$USER_HOME$", "~"), 70 | "timestamp": ( 71 | int(cast(Element, project.find(TIMESTAMP_XML_PATH)).attrib["value"]) 72 | if project.find(TIMESTAMP_XML_PATH) is not None else None 73 | ) if project.tag == "entry" else None 74 | } 75 | ) for project in raw_projects 76 | ] 77 | 78 | filtered: List[EntryData] = [] 79 | for project in projects: 80 | index = next( 81 | (index for index, d in enumerate(filtered) if d["path"] == project["path"]), 82 | None 83 | ) 84 | 85 | if index is None: 86 | filtered.append(project) 87 | elif index is not None and project["timestamp"] is not None: 88 | filtered[index]["timestamp"] = project["timestamp"] 89 | 90 | output = [] 91 | for data in filtered: 92 | full_path = os.path.expanduser(data["path"]) 93 | name_file = full_path + '/.idea/.name' 94 | name = '' 95 | 96 | if os.path.exists(name_file): 97 | with open(name_file, 'r', encoding="utf8") as file: 98 | name = file.read().replace('\n', '') 99 | 100 | icons = glob.glob(os.path.join(full_path, '.idea', 'icon.*')) 101 | 102 | output.append(IdeProject( 103 | name=name or os.path.basename(data["path"]), 104 | ide=ide_key, 105 | path=data["path"], 106 | timestamp=data["timestamp"], 107 | icon=cast(Optional[str], icons[0] if len(icons) > 0 else None), 108 | )) 109 | 110 | return output 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | Ulauncher JetBrains 5 | 6 |
7 |
8 | Ulauncher JetBrains Reloaded 9 |

10 | 11 |
Overview
12 |

13 | Ulauncher extension that let's you open your 14 | projects in JetBrains IDEs 15 |

16 | 17 |
18 | This project is a fork of the extension called ulauncher-jetbrains made by Bruno Paz.
19 | 20 | It adds new features such as fuzzy project search, multi-ide queries and custom ide aliases;
21 | It also adds improvements over the original project, most notably support for 2021.3 versions of 22 | IDEs and better preferences settings. 23 |
24 | 25 |

26 | 27 | Ulauncher Extension 29 | 30 | 31 | License 33 | 34 |

35 | 36 |

37 | Extension Demo 38 |

39 | 40 |
41 | 42 | ## Install 43 | 44 | ### Requirements 45 | 46 | #### Programs 47 | 48 | - Ulauncher 5 49 | - Python 3 50 | - Jetbrains IDE 51 | 52 | #### Python Packages 53 | 54 | - semver >=2.13.0 55 | 56 | To install this extension: 57 | 58 | 1. Install required packages 59 | 2. Open `Preferences` window 60 | 3. Select `Extensions` tab 61 | 4. Click `Add extension` button on the sidebar 62 | 5. Paste the following url: `https://github.com/zakuciael/ulauncher-jetbrains-reloaded` 63 | 64 | ## Usage 65 | 66 | ### Supported IDEs 67 | 68 | - PHPStorm 69 | - WebStorm 70 | - PyCharm 71 | - IntelliJ IDEA 72 | - CLion 73 | - Rider 74 | - GoLand 75 | - DataGrip 76 | - RubyMine 77 | - Android Studio 78 | 79 | To use this extension first **generate shell scripts** in the JetBrains Toolbox app by doing the 80 | following: 81 | 82 | 1. Open JetBrains Toolbox app 83 | 2. Go to settings 84 | 3. Click on the `Tools` dropdown 85 | 4. Check `Generate shell scripts` checkbox 86 | 5. Enter the shell scripts location 87 | 88 | After that, follow below instructions to configure the extension settings: 89 | 90 | 1. Open `Preferences` window 91 | 2. Select `Extensions` tab 92 | 3. Click on `JetBrains Launcher` extension 93 | 4. Set the `Shell scripts location` value to the path configured in the JetBrains Toolbox app 94 | 5. Set the `Configs location` value to the folder in which JetBrains IDEs store their 95 | configurations 96 | **Default location:** ``~/.config/JetBrains/`` 97 | 98 | ## Contributing 99 | 100 | Clone this repository and run: 101 | 102 | ```bash 103 | make link 104 | ``` 105 | 106 | The `make link` command will symlink the project into the ulauncher extensions folder. 107 | 108 | To see your changes, stop ulauncher and run it from the command line with: `make dev`. 109 | 110 | The output will display something like this: 111 | 112 | ``` 113 | 2020-11-15 10:24:16,869 | WARNING | ulauncher.api.server.ExtensionRunner: _run_process() | VERBOSE=1 ULAUNCHER_WS_API=ws://127.0.0.1:5054/com.github.zakuciael.ulauncher-jetbrains-reloaded PYTHONPATH=/usr/lib/python3.10/site-packages /usr/bin/python3 /home/zakku/.local/share/ulauncher/extensions/com.github.zakuciael.ulauncher-jetbrains-reloaded/main.py 114 | ``` 115 | 116 | In another terminal run `make PORT= start` command to run the extension backend. 117 | > Note: The ```` variable refers to the port number found in the ``ULAUNCHER_WS_API`` env located in the above 118 | > log. 119 | 120 | To see your changes, CTRL+C the previous command and run it again to refresh. 121 | 122 | ## Credits 123 | 124 | - [Bruno Paz](https://github.com/brpaz) - Original author 125 | - [Vince](https://github.com/vinceliuice) - Author of the [icons](https://github.com/vinceliuice/WhiteSur-icon-theme) 126 | used by the extension 127 | 128 | ## License 129 | 130 | MIT © [Krzysztof Saczuk \](https://github.com/zakuciael) -------------------------------------------------------------------------------- /events/KeywordQueryEventListener.py: -------------------------------------------------------------------------------- 1 | """ Contains class for handling keyword events from Ulauncher""" 2 | from __future__ import annotations 3 | 4 | import os 5 | import re 6 | from typing import cast, List, TYPE_CHECKING 7 | 8 | from ulauncher.api.client.EventListener import EventListener 9 | from ulauncher.api.shared.action.CopyToClipboardAction import CopyToClipboardAction 10 | from ulauncher.api.shared.action.HideWindowAction import HideWindowAction 11 | from ulauncher.api.shared.action.RenderResultListAction import RenderResultListAction 12 | from ulauncher.api.shared.action.RunScriptAction import RunScriptAction 13 | from ulauncher.api.shared.event import KeywordQueryEvent 14 | from ulauncher.api.shared.item.ExtensionResultItem import ExtensionResultItem 15 | 16 | from data.IdeKey import IdeKey 17 | from data.IdeProject import IdeProject 18 | from utils.ProjectsList import ProjectsList 19 | 20 | if TYPE_CHECKING: 21 | from main import JetbrainsLauncherExtension 22 | 23 | 24 | # pylint: disable=too-few-public-methods 25 | class KeywordQueryEventListener(EventListener): 26 | """ Handles users input and searches for results """ 27 | 28 | def on_event(self, event: KeywordQueryEvent, extension: 'JetbrainsLauncherExtension') -> \ 29 | RenderResultListAction: 30 | """ 31 | Handles the keyword event 32 | :param event: Event data 33 | :param extension: Extension class 34 | :return: List of actions to render 35 | """ 36 | 37 | args = [arg.lower() for arg in re.split("[ /]+", (event.get_argument() or ""))] 38 | keyword = args[0] if len(args) > 0 else "" 39 | ide_key: IdeKey | None = None 40 | 41 | if extension.check_ide_key(keyword): 42 | ide_key = cast(IdeKey, keyword) 43 | elif keyword in extension.aliases: 44 | ide_key = extension.aliases.get(keyword) 45 | 46 | query = " ".join(args[1:] if ide_key is not None else args).strip() 47 | projects = ProjectsList(query, min_score=(60 if len(query) > 0 else 0), limit=8) 48 | 49 | try: 50 | if ide_key is not None: 51 | if not extension.get_ide_launcher_script(ide_key): 52 | return self.make_error( 53 | extension=extension, 54 | title="IDE launcher not found", 55 | desc="Please verify that you have the IDE installed.", 56 | ide_key=ide_key 57 | ) 58 | 59 | projects.extend(extension.get_recent_projects(ide_key)) 60 | else: 61 | for key in [key for key in extension.ides if 62 | extension.get_ide_launcher_script(key)]: 63 | projects.extend(extension.get_recent_projects(cast(IdeKey, key))) 64 | except FileNotFoundError: 65 | return self.make_error( 66 | extension=extension, 67 | title="Unable to find IDE configuration", 68 | desc="Make sure that you provided a valid path to the IDE config directory.", 69 | ide_key=ide_key 70 | ) 71 | 72 | results = [] 73 | 74 | if len(projects) == 0: 75 | return self.make_error( 76 | extension=extension, 77 | title="No projects found", 78 | ide_key=ide_key 79 | ) 80 | 81 | sort_by = extension.preferences.get("sort_by") 82 | for project in self.sort_projects(list(projects), sort_by): 83 | results.append( 84 | ExtensionResultItem( 85 | icon=project.icon if project.icon is not None else 86 | extension.get_ide_icon(project.ide), 87 | name=project.name, 88 | description=project.path, 89 | on_enter=RunScriptAction( 90 | cast(str, extension.get_ide_launcher_script(project.ide)) + 91 | f' "{os.path.expanduser(project.path)}" &' 92 | ), 93 | on_alt_enter=CopyToClipboardAction(project.path) 94 | ) 95 | ) 96 | 97 | return RenderResultListAction(results) 98 | 99 | @staticmethod 100 | def sort_projects(projects: List[IdeProject], sort_by: str) -> List[IdeProject]: 101 | """ 102 | Sorts list of projects by a given sorting mode 103 | :param projects: List of projects to sort 104 | :param sort_by: Sorting mode 105 | :return List[IdeProject] Sorted projects 106 | """ 107 | 108 | if sort_by == "recent": 109 | return sorted( 110 | projects, 111 | key=lambda item: -item.timestamp if item.timestamp is not None else 0 112 | ) 113 | 114 | if sort_by in ("ascending", "descending"): 115 | return sorted( 116 | projects, 117 | key=lambda item: item.name, 118 | reverse=sort_by == "descending" 119 | ) 120 | 121 | return list(projects) 122 | 123 | @staticmethod 124 | def make_error(extension: 'JetbrainsLauncherExtension', title: str, 125 | desc: str | None = None, ide_key: IdeKey | None = None): 126 | """ 127 | Create an error in form of ExtensionResultItem 128 | :param extension: Extension class 129 | :param title: The title of the error 130 | :param desc: The description of the error 131 | :param ide_key: The IDE key 132 | :return RenderResultListAction with the error inside 133 | """ 134 | 135 | return RenderResultListAction([ 136 | ExtensionResultItem( 137 | icon=extension.get_ide_icon(ide_key) \ 138 | if ide_key is not None else extension.get_base_icon(), 139 | name=title, 140 | description=desc if desc is not None else "", 141 | on_enter=HideWindowAction() 142 | ) 143 | ]) 144 | -------------------------------------------------------------------------------- /images/rubymine.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /images/rider.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /images/phpstorm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /utils/SortedCollection.py: -------------------------------------------------------------------------------- 1 | # The following source code was obtained from https://github.com/Ulauncher/Ulauncher under GPLv3 license. 2 | # The original file can be found at: 3 | # https://raw.githubusercontent.com/Ulauncher/Ulauncher/f4a36b77f1e4660b427e2ade969c78b440ab8c75/ulauncher/utils/SortedCollection.py 4 | 5 | 6 | from bisect import bisect_left, bisect_right 7 | 8 | 9 | class SortedCollection: 10 | '''Sequence sorted by a key function. 11 | 12 | SortedCollection() is much easier to work with than using bisect() directly. 13 | It supports key functions like those use in sorted(), min(), and max(). 14 | The result of the key function call is saved so that keys can be searched 15 | efficiently. 16 | 17 | Instead of returning an insertion-point which can be hard to interpret, the 18 | five find-methods return a specific item in the sequence. They can scan for 19 | exact matches, the last item less-than-or-equal to a key, or the first item 20 | greater-than-or-equal to a key. 21 | 22 | Once found, an item's ordinal position can be located with the index() method. 23 | New items can be added with the insert() and insert_right() methods. 24 | Old items can be deleted with the remove() method. 25 | 26 | The usual sequence methods are provided to support indexing, slicing, 27 | length lookup, clearing, copying, forward and reverse iteration, contains 28 | checking, item counts, item removal, and a nice looking repr. 29 | 30 | Finding and indexing are O(log n) operations while iteration and insertion 31 | are O(n). The initial sort is O(n log n). 32 | 33 | The key function is stored in the 'key' attribute for easy introspection or 34 | so that you can assign a new key function (triggering an automatic re-sort). 35 | 36 | In short, the class was designed to handle all of the common use cases for 37 | bisect but with a simpler API and support for key functions. 38 | 39 | >>> from pprint import pprint 40 | >>> from operator import itemgetter 41 | 42 | >>> s = SortedCollection(key=itemgetter(2)) 43 | >>> for record in [ 44 | ... ('roger', 'young', 30), 45 | ... ('angela', 'jones', 28), 46 | ... ('bill', 'smith', 22), 47 | ... ('david', 'thomas', 32)]: 48 | ... s.insert(record) 49 | 50 | >>> pprint(list(s)) # show records sorted by age 51 | [('bill', 'smith', 22), 52 | ('angela', 'jones', 28), 53 | ('roger', 'young', 30), 54 | ('david', 'thomas', 32)] 55 | 56 | >>> s.find_le(29) # find oldest person aged 29 or younger 57 | ('angela', 'jones', 28) 58 | >>> s.find_lt(28) # find oldest person under 28 59 | ('bill', 'smith', 22) 60 | >>> s.find_gt(28) # find youngest person over 28 61 | ('roger', 'young', 30) 62 | 63 | >>> r = s.find_ge(32) # find youngest person aged 32 or older 64 | >>> s.index(r) # get the index of their record 65 | 3 66 | >>> s[3] # fetch the record at that index 67 | ('david', 'thomas', 32) 68 | 69 | >>> s.key = itemgetter(0) # now sort by first name 70 | >>> pprint(list(s)) 71 | [('angela', 'jones', 28), 72 | ('bill', 'smith', 22), 73 | ('david', 'thomas', 32), 74 | ('roger', 'young', 30)] 75 | 76 | ''' 77 | 78 | def __init__(self, iterable=(), key=None): 79 | self._given_key = key 80 | key = (lambda x: x) if key is None else key 81 | decorated = sorted((key(item), item) for item in iterable) 82 | self._keys = [k for k, item in decorated] 83 | self._items = [item for k, item in decorated] 84 | self._key = key 85 | 86 | def _getkey(self): 87 | return self._key 88 | 89 | def _setkey(self, key): 90 | if key is not self._key: 91 | self.__init__(self._items, key=key) 92 | 93 | def _delkey(self): 94 | self._setkey(None) 95 | 96 | key = property(_getkey, _setkey, _delkey, 'key function') 97 | 98 | def clear(self): 99 | self.__init__([], self._key) 100 | 101 | def copy(self): 102 | return self.__class__(self, self._key) 103 | 104 | def __len__(self): 105 | return len(self._items) 106 | 107 | def __getitem__(self, i): 108 | return self._items[i] 109 | 110 | def __iter__(self): 111 | return iter(self._items) 112 | 113 | def __reversed__(self): 114 | return reversed(self._items) 115 | 116 | def __repr__(self): 117 | return '%s(%r, key=%s)' % ( 118 | self.__class__.__name__, 119 | self._items, 120 | getattr(self._given_key, '__name__', repr(self._given_key)) 121 | ) 122 | 123 | def __reduce__(self): 124 | return self.__class__, (self._items, self._given_key) 125 | 126 | def __contains__(self, item): 127 | k = self._key(item) 128 | i = bisect_left(self._keys, k) 129 | j = bisect_right(self._keys, k) 130 | return item in self._items[i:j] 131 | 132 | def index(self, item): 133 | 'Find the position of an item. Raise ValueError if not found.' 134 | k = self._key(item) 135 | i = bisect_left(self._keys, k) 136 | j = bisect_right(self._keys, k) 137 | return self._items[i:j].index(item) + i 138 | 139 | def count(self, item): 140 | 'Return number of occurrences of item' 141 | k = self._key(item) 142 | i = bisect_left(self._keys, k) 143 | j = bisect_right(self._keys, k) 144 | return self._items[i:j].count(item) 145 | 146 | def insert(self, item): 147 | 'Insert a new item. If equal keys are found, add to the left' 148 | k = self._key(item) 149 | i = bisect_left(self._keys, k) 150 | self._keys.insert(i, k) 151 | self._items.insert(i, item) 152 | 153 | def insert_right(self, item): 154 | 'Insert a new item. If equal keys are found, add to the right' 155 | k = self._key(item) 156 | i = bisect_right(self._keys, k) 157 | self._keys.insert(i, k) 158 | self._items.insert(i, item) 159 | 160 | def pop(self, index=-1): 161 | self._keys.pop(index) 162 | self._items.pop(index) 163 | 164 | def remove(self, item): 165 | 'Remove first occurrence of item. Raise ValueError if not found' 166 | i = self.index(item) 167 | del self._keys[i] 168 | del self._items[i] 169 | 170 | def find(self, k): 171 | 'Return first item with a key == k. Raise ValueError if not found.' 172 | i = bisect_left(self._keys, k) 173 | if i != len(self) and self._keys[i] == k: 174 | return self._items[i] 175 | raise ValueError('No item found with key equal to: %r' % (k,)) 176 | 177 | def find_le(self, k): 178 | 'Return last item with a key <= k. Raise ValueError if not found.' 179 | i = bisect_right(self._keys, k) 180 | if i: 181 | return self._items[i - 1] 182 | raise ValueError('No item found with key at or below: %r' % (k,)) 183 | 184 | def find_lt(self, k): 185 | 'Return last item with a key < k. Raise ValueError if not found.' 186 | i = bisect_left(self._keys, k) 187 | if i: 188 | return self._items[i - 1] 189 | raise ValueError('No item found with key below: %r' % (k,)) 190 | 191 | def find_ge(self, k): 192 | 'Return first item with a key >= equal to k. Raise ValueError if not found' 193 | i = bisect_left(self._keys, k) 194 | if i != len(self): 195 | return self._items[i] 196 | raise ValueError('No item found with key at or above: %r' % (k,)) 197 | 198 | def find_gt(self, k): 199 | 'Return first item with a key > k. Raise ValueError if not found' 200 | i = bisect_right(self._keys, k) 201 | if i != len(self): 202 | return self._items[i] 203 | raise ValueError('No item found with key above: %r' % (k,)) 204 | 205 | -------------------------------------------------------------------------------- /images/android-studio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /images/rustrover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """ Ulauncher extension for opening recent projects on Jetbrains IDEs. """ 2 | from __future__ import annotations 3 | 4 | import os 5 | import re 6 | from typing import Dict, cast 7 | 8 | import semver 9 | from ulauncher.api.client.Extension import Extension 10 | from ulauncher.api.shared.event import KeywordQueryEvent, PreferencesEvent, PreferencesUpdateEvent 11 | 12 | from data.IdeData import IdeData 13 | from data.IdeKey import IdeKey 14 | from data.IdeProject import IdeProject 15 | from events.KeywordQueryEventListener import KeywordQueryEventListener 16 | from events.PreferencesEventListener import PreferencesEventListener 17 | from events.PreferencesUpdateEventListener import PreferencesUpdateEventListener 18 | from utils.RecentProjectsParser import RecentProjectsParser 19 | 20 | 21 | class JetbrainsLauncherExtension(Extension): 22 | """ Main Extension Class """ 23 | ides: Dict[IdeKey, IdeData] = { 24 | "clion": IdeData(name="CLion", config_prefixes=["CLion"], launcher_prefixes=["clion"]), 25 | "idea": IdeData(name="IntelliJ IDEA", config_prefixes=["IntelliJIdea", "IdeaIC"], 26 | launcher_prefixes=["idea"]), 27 | "phpstorm": IdeData(name="PHPStorm", config_prefixes=["PhpStorm"], 28 | launcher_prefixes=["phpstorm", "pstorm"]), 29 | "pycharm": IdeData(name="PyCharm", config_prefixes=["PyCharm", "PyCharmCE"], 30 | launcher_prefixes=["pycharm", "charm"]), 31 | "rider": IdeData(name="Rider", config_prefixes=["Rider"], launcher_prefixes=["rider"], 32 | recent_projects_file="recentSolutions.xml"), 33 | "webstorm": IdeData(name="WebStorm", config_prefixes=["WebStorm"], 34 | launcher_prefixes=["webstorm"]), 35 | "goland": IdeData(name="GoLand", config_prefixes=["GoLand"], launcher_prefixes=["goland"]), 36 | "datagrip": IdeData(name="DataGrip", config_prefixes=["DataGrip"], 37 | launcher_prefixes=["datagrip"]), 38 | "rubymine": IdeData(name="RubyMine", config_prefixes=["RubyMine"], 39 | launcher_prefixes=["rubymine"]), 40 | "android-studio": IdeData(name="Android Studio", config_prefixes=["AndroidStudio"], 41 | launcher_prefixes=["studio"], 42 | custom_config_key="studio_config_path"), 43 | "rustrover": IdeData(name="RustRover", config_prefixes=["RustRover"], launcher_prefixes=["rustrover"]) 44 | } 45 | 46 | aliases: Dict[str, IdeKey] = {} 47 | 48 | def __init__(self): 49 | """ Initializes the extension """ 50 | super().__init__() 51 | self.subscribe(KeywordQueryEvent, KeywordQueryEventListener()) 52 | self.subscribe(PreferencesEvent, PreferencesEventListener()) 53 | self.subscribe(PreferencesUpdateEvent, PreferencesUpdateEventListener()) 54 | 55 | @staticmethod 56 | def get_base_icon(): 57 | """ 58 | Returns the base (project) icon 59 | :return: None 60 | """ 61 | 62 | path = os.path.join(os.path.dirname(__file__), "images", "icon.svg") 63 | if path is None or not os.path.isfile(path): 64 | raise FileNotFoundError("Cant find base icon") 65 | 66 | return path 67 | 68 | def parse_aliases(self, raw_aliases: str) -> Dict[str, IdeKey] | None: 69 | """ 70 | Parses raw aliases list into a python list 71 | :param raw_aliases: Raw aliases list 72 | """ 73 | 74 | if raw_aliases is None: 75 | return 76 | 77 | matches = re.findall(r"(\w+):(?: +|)([\w-]+)*;", raw_aliases) 78 | aliases = {} 79 | 80 | for alias, ide_key in matches: 81 | if self.check_ide_key(ide_key): 82 | aliases[alias] = cast(IdeKey, ide_key) 83 | else: 84 | self.logger.warning( 85 | "Invalid ide key specified for alias %s. Expected one of %s", 86 | alias, ", ".join(self.ides.keys())) 87 | 88 | return aliases 89 | 90 | def set_aliases(self, aliases: Dict[str, IdeKey]) -> None: 91 | """ 92 | Sets aliases used by the extension 93 | :param aliases: Aliases to set 94 | """ 95 | 96 | if aliases is None: 97 | return 98 | 99 | for alias, ide_key in aliases.items(): 100 | self.aliases[alias] = ide_key 101 | self.logger.info("Loaded alias: %s -> %s", ide_key, alias) 102 | 103 | def check_ide_key(self, key: str) -> bool: 104 | """ 105 | Checks if the provided key is valid 106 | :param key: Key to check 107 | :return: Result of the check 108 | """ 109 | 110 | return key in self.ides 111 | 112 | def get_ide_data(self, ide_key: IdeKey) -> IdeData | None: 113 | """ 114 | Gets IDE data for specified key 115 | :parm ide_key: IDE key 116 | :return: IDE data 117 | """ 118 | 119 | if not self.check_ide_key(ide_key): 120 | raise AttributeError("Invalid ide key specified") 121 | 122 | return next((ide_data for key, ide_data in self.ides.items() if key == ide_key), None) 123 | 124 | def get_recent_projects(self, ide_key: IdeKey) -> list[IdeProject]: 125 | """ 126 | Get parsed recent projects for specified key 127 | :param ide_key: IDE key 128 | :return: Parsed recent projects 129 | """ 130 | 131 | ide_data = self.get_ide_data(ide_key) 132 | if ide_data is None: 133 | raise AttributeError("Invalid ide key specified") 134 | 135 | base_path = os.path.expanduser( 136 | self.preferences.get(ide_data.custom_config_key) \ 137 | if ide_data.custom_config_key else self.preferences.get("configs_path") 138 | ) 139 | if base_path is None or not os.path.isdir(base_path): 140 | raise FileNotFoundError("Cant find configs directory") 141 | 142 | versions: Dict[str, semver.VersionInfo] = {} 143 | for path in os.listdir(base_path): 144 | if os.path.exists(os.path.join(base_path, path, "options", ide_data.recent_projects_file)): 145 | for config_prefix in ide_data.config_prefixes: 146 | match = re.match( 147 | f"^{config_prefix}" + 148 | r"(?P0|[1-9]\d*)(\.(?P0|[1-9]\d*)(\.(?P0|[1-9]\d*))?)?", 149 | path) 150 | 151 | if match is not None: 152 | version_dict = { 153 | key: 0 if value is None else value for key, value in match.groupdict().items() 154 | } 155 | 156 | versions[path] = semver.VersionInfo(**version_dict) 157 | 158 | if len(versions) == 0: 159 | return [] 160 | 161 | directory = max(versions, key=versions.get) 162 | config_dir = os.path.join( 163 | base_path, 164 | directory, 165 | "options" 166 | ) 167 | 168 | projects = RecentProjectsParser.parse( 169 | os.path.join(config_dir, ide_data.recent_projects_file), 170 | ide_key 171 | ) 172 | 173 | return projects 174 | 175 | def get_ide_icon(self, ide_key: IdeKey) -> str: 176 | """ 177 | Gets path to the IDE icon for specified key 178 | :param ide_key: IDE key 179 | :return: Path to the IDE icon 180 | """ 181 | 182 | if not self.check_ide_key(ide_key): 183 | raise AttributeError("Invalid ide key specified") 184 | 185 | path = os.path.join(os.path.dirname(__file__), "images", f"{ide_key}.svg") 186 | if path is None or not os.path.isfile(path): 187 | raise FileNotFoundError(f"Cant find {ide_key} IDE icon") 188 | 189 | return path 190 | 191 | def get_ide_launcher_script(self, ide_key: IdeKey) -> str | None: 192 | """ 193 | Gets path to the IDE launcher script for specified key 194 | :param ide_key: IDE key 195 | :return: Path to the IDE launcher script 196 | """ 197 | 198 | scripts_path = self.preferences.get("scripts_path") 199 | if scripts_path is None or not os.path.isdir(os.path.expanduser(scripts_path)): 200 | raise AttributeError("Cant find shell scripts directory") 201 | 202 | ide_data = self.get_ide_data(ide_key) 203 | if ide_data is None: 204 | raise AttributeError("Invalid ide key specified") 205 | 206 | for prefix in ide_data.launcher_prefixes: 207 | path = os.path.join(os.path.expanduser(scripts_path), prefix) 208 | if path is not None and os.path.isfile(path): 209 | return path 210 | 211 | return None 212 | 213 | 214 | if __name__ == "__main__": 215 | JetbrainsLauncherExtension().run() 216 | -------------------------------------------------------------------------------- /images/clion.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /images/idea.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /images/webstorm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /images/pycharm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /images/goland.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /images/datagrip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | --------------------------------------------------------------------------------