├── .coveragerc ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .readme ├── embedded-readme.md └── images │ ├── animation.gif │ ├── configuration.png │ └── screenshot.png ├── CONTRIBUTORS.md ├── LICENSE.md ├── README.md ├── alfred └── template.plist ├── build.py ├── icon.png ├── icons ├── README.md ├── androidstudio.png ├── appcode.png ├── clion.png ├── datagrip.png ├── goland.png ├── idea.png ├── ideace.png ├── phpstorm.png ├── pycharm.png ├── pycharmce.png ├── rider.png ├── rubymine.png ├── rustrover.png └── webstorm.png ├── products.json ├── recent_projects.py ├── recent_projects_test.py └── requirements.txt /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = venv/* 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | indent_size = 4 8 | indent_style = space 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: artemy 4 | buy_me_a_coffee: artemy 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: test & release 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Python 3 11 | uses: actions/setup-python@v5 12 | with: 13 | python-version: 3.9 14 | - name: Install dependencies 15 | run: | 16 | python3 -m pip install --upgrade pip 17 | pip3 install -r requirements.txt 18 | - name: Test 19 | run: python3 -m unittest recent_projects_test 20 | release: 21 | runs-on: ubuntu-latest 22 | needs: test 23 | if: startsWith(github.ref, 'refs/tags/') 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Build artifacts 27 | run: python3 build.py ${{ github.ref_name }} 28 | - name: Create release 29 | uses: softprops/action-gh-release@v2 30 | with: 31 | files: '*.alfredworkflow' 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .idea 3 | venv 4 | out 5 | *.alfredworkflow 6 | htmlcov 7 | .coverage 8 | coverage.xml 9 | *.pyc 10 | 11 | # Generated by Finder 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /.readme/embedded-readme.md: -------------------------------------------------------------------------------- 1 | # Recent JetBrains Projects for Alfred 2 | 3 | An Alfred workflow for opening your JetBrains IDEs projects 4 | 5 | ## Getting started 6 | 7 | ### ⚠️ Assign a keyword for every IDE you use ⚠️ 8 | 9 | By default, all IDEs are disabled. Assigning keywords activates specific IDEs. 10 | 11 | Keyword settings are persisted across workflow upgrades. 12 | 13 | ## How to Use 14 | 15 | **_For more information, check detailed [README](https://github.com/artemy/alfred-jetbrains-projects/blob/master/README.md)_** 16 | 17 | Open Alfred and type the keyword for your IDE. The workflow will display a list of recent 18 | projects (sorted by time last opened descending). 19 | 20 | You can further filter a project list by typing additional characters. Fuzzy first-letter search is supported (i.e., 21 | typing `map` will find `my-awesome-project`). 22 | 23 | ## Legal 24 | 25 | This workflow is not associated with JetBrains in any way. 26 | 27 | Copyright © 2024 JetBrains s.r.o. JetBrains and the JetBrains logo are trademarks of JetBrains s.r.o. 28 | -------------------------------------------------------------------------------- /.readme/images/animation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/.readme/images/animation.gif -------------------------------------------------------------------------------- /.readme/images/configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/.readme/images/configuration.png -------------------------------------------------------------------------------- /.readme/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/.readme/images/screenshot.png -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Recognized contributors 2 | 3 | * [Robin Trietsch](https://github.com/trietsch) 4 | * [Wouter Oet](https://github.com/WouterOet) 5 | * [Graceson Aufderheide](https://github.com/gaufde) 6 | * [Vítor Galvão](https://github.com/vitorgalvao) 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Artem Makarov 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Recent JetBrains Projects for Alfred 2 | 3 | ![test & release](https://github.com/artemy/alfred-jetbrains-projects/workflows/test%20&%20release/badge.svg) 4 | ![MIT License](https://img.shields.io/github/license/artemy/alfred-jetbrains-projects) 5 | 6 | An Alfred workflow for opening your JetBrains IDEs projects 7 | 8 | ![image](.readme/images/screenshot.png) 9 | 10 | ## Getting started 11 | 12 | ### Prerequisites 13 | 14 | Project requires Python 3.9+ to run, which is not included by default with macOS. You can install Python 3 using 15 | this [guide](https://docs.python-guide.org/starting/install3/osx/). 16 | 17 | ### Installing 18 | 19 | Download the `alfred-jetbrains-projects.alfredworkflow` file from the latest release 20 | at [Releases](https://github.com/artemy/alfred-jetbrains-projects/releases) page and open it with Alfred. 21 | 22 | ### ⚠️ Assign a keyword for every IDE you use ⚠️ 23 | 24 | By default, all IDEs are disabled. Assigning keywords activates specific IDEs: 25 | 26 | 27 | Keyword settings are persisted across workflow upgrades. 28 | 29 | ## How to Use 30 | 31 | Open Alfred and type the keyword for your IDE (see Supported IDEs below). 32 | The workflow will display a list of recent 33 | projects (sorted by time last opened descending). 34 | 35 | You can further filter a project list by typing additional characters. 36 | Fuzzy first-letter search is supported (i.e., 37 | typing `map` will find `my-awesome-project`): 38 | 39 | ![animation](.readme/images/animation.gif) 40 | 41 | ### Supported IDEs 42 | 43 | | IDE Name | Version | Keyword | 44 | |---------------------------------|---------|---------------| 45 | | Android Studio | 4.1+ | androidstudio | 46 | | AppCode | 2020.3+ | appcode | 47 | | CLion | 2020.3+ | clion | 48 | | DataGrip | 2020.3+ | datagrip | 49 | | GoLand | 2020.3+ | goland | 50 | | IntelliJ IDEA Ultimate | 2020.3+ | idea | 51 | | IntelliJ IDEA Community Edition | 2024.3+ | ideace | 52 | | PhpStorm | 2024.3+ | phpstorm | 53 | | PyCharm Professional | 2020.3+ | pycharm | 54 | | PyCharm Community Edition | 2024.3+ | pycharmce | 55 | | Rider | 2024.3+ | rider | 56 | | RubyMine | 2024.3+ | rubymine | 57 | | RustRover | 2024.1+ | rustrover | 58 | | WebStorm | 2020.3+ | webstorm | 59 | 60 | Support for older IDE versions is not guaranteed. 61 | 62 | ## Contributing 63 | 64 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 65 | 66 | ### Running the tests 67 | 68 | Make sure to first install test dependencies: 69 | 70 | ```shell 71 | pip3 install -r requirements.txt 72 | ``` 73 | 74 | To run tests, execute 75 | 76 | ```shell 77 | python3 -m recent_projects_test 78 | ``` 79 | 80 | If you want to get coverage figures through `coverage` tool: 81 | 82 | ```shell 83 | coverage run -m unittest recent_projects_test # gather test data 84 | coverage report -m # display coverage figures 85 | ``` 86 | 87 | ## Built With 88 | 89 | * [Python 3.9](https://docs.python.org/3.9/) 90 | * [coverage.py](https://coverage.readthedocs.io/) - Code coverage measurement 91 | 92 | ## License 93 | 94 | This project is licensed under the MIT License — see the [LICENSE.md](LICENSE.md) file for details 95 | 96 | ## Acknowledgments 97 | 98 | See [CONTRIBUTORS.md](CONTRIBUTORS.md) 99 | and [contributors](https://github.com/artemy/alfred-jetbrains-projects/contributors) for the list of contributors. 100 | 101 | ## Legal 102 | 103 | This workflow is not associated with JetBrains in any way. 104 | 105 | Copyright © 2024 JetBrains s.r.o. JetBrains and the JetBrains logo are trademarks of JetBrains s.r.o. 106 | -------------------------------------------------------------------------------- /alfred/template.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.github.artemy.jb_projects 7 | connections 8 | 9 | createdby 10 | Artem Makarov 11 | description 12 | Alfred workflow for opening your JetBrains IDEs projects 13 | disabled 14 | 15 | name 16 | Recent JetBrains Projects 17 | objects 18 | 19 | 20 | config 21 | 22 | concurrently 23 | 24 | escaping 25 | 102 26 | script 27 | open -nb "${bundle_id}" --args "${1}" 28 | scriptargtype 29 | 1 30 | scriptfile 31 | 32 | type 33 | 0 34 | 35 | type 36 | alfred.workflow.action.script 37 | uid 38 | 2A23DCF4-C92B-48F0-99E8-A560582B30B5 39 | version 40 | 2 41 | 42 | 43 | readme 44 | 45 | uidata 46 | 47 | 2A23DCF4-C92B-48F0-99E8-A560582B30B5 48 | 49 | xpos 50 | 470 51 | ypos 52 | 480 53 | 54 | 55 | userconfigurationconfig 56 | 57 | version 58 | 3.0 59 | webaddress 60 | https://github.com/artemy/alfred-jetbrains-projects 61 | 62 | 63 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import plistlib 4 | import re 5 | import sys 6 | from dataclasses import dataclass 7 | from typing import Optional 8 | 9 | 10 | @dataclass 11 | class Product: 12 | keyword: str 13 | uid: str 14 | folder_name: str 15 | bundle_id: str 16 | display_name: Optional[str] = None 17 | preferences_path: Optional[str] = None 18 | 19 | def name(self) -> str: 20 | return self.display_name if self.display_name else self.folder_name 21 | 22 | 23 | def create_connection(destination_uid: str) -> list[dict]: 24 | return [{'destinationuid': destination_uid, 25 | 'modifiers': 0, 26 | 'modifiersubtext': '', 27 | 'vitoclose': False}] 28 | 29 | 30 | def create_script_filter(product: Product) -> dict: 31 | return { 32 | 'config': {'alfredfiltersresults': False, 33 | 'alfredfiltersresultsmatchmode': 0, 34 | 'argumenttreatemptyqueryasnil': False, 35 | 'argumenttrimmode': 0, 36 | 'argumenttype': 1, 37 | 'escaping': 102, 38 | 'keyword': f'{{var:{product.keyword}}}', 39 | 'queuedelaycustom': 3, 40 | 'queuedelayimmediatelyinitially': True, 41 | 'queuedelaymode': 0, 42 | 'queuemode': 1, 43 | 'runningsubtext': '', 44 | 'script': f'/usr/bin/python3 recent_projects.py "{product.keyword}" "${{1}}"', 45 | 'scriptargtype': 1, 46 | 'scriptfile': '', 47 | 'subtext': '', 48 | 'skipuniversalaction': True, 49 | 'title': f'Search through your recent {product.name()} projects', 50 | 'type': 0, 51 | 'withspace': True}, 52 | 'type': 'alfred.workflow.input.scriptfilter', 53 | 'uid': product.uid, 54 | 'version': 3} 55 | 56 | 57 | def create_userconfigurationconfig(product: Product) -> dict: 58 | return {'config': {'default': '', 59 | 'placeholder': product.keyword, 60 | 'required': False, 61 | 'trim': True}, 62 | 'description': f'Assign a keyword to enable {product.name()}', 63 | 'label': f'{product.name()} keyword', 64 | 'type': 'textfield', 65 | 'variable': product.keyword} 66 | 67 | 68 | def create_coordinates(xpos: int, ypos: int) -> dict[str, int]: 69 | return {'xpos': xpos, 'ypos': ypos} 70 | 71 | 72 | def get_run_script_uid(plist) -> str: 73 | for obj in plist["objects"]: 74 | if obj["config"]["script"] == 'open -nb "${bundle_id}" --args "${1}"' and obj["uid"] is not None: 75 | return obj["uid"] 76 | raise ValueError( 77 | f'Could not find the script object with \'open -nb "${{bundle_id}}" --args "${{1}}"\' as the script in the template') 78 | 79 | 80 | def create_coordinate_ruler(size: int) -> list[int]: 81 | start = 40 82 | step = 120 83 | return list(range(start, start + (step * size), step)) 84 | 85 | 86 | def build(): 87 | # Collect info 88 | products = get_products() 89 | 90 | with open('alfred/template.plist', 'rb') as fp: 91 | plist = plistlib.load(fp) 92 | 93 | version = sys.argv[1] if len(sys.argv) > 1 else "unknown" 94 | version = re.sub(r'v(.*)', r'\1', version) 95 | 96 | # Modify plist 97 | # Get the UID of the runscript action in the template 98 | run_script_uid = get_run_script_uid(plist) 99 | run_script_connection = create_connection(run_script_uid) 100 | 101 | plist["connections"].update({product.uid: run_script_connection for product in products}) 102 | 103 | y_coordinate_ruler = create_coordinate_ruler(len(products)) 104 | plist["uidata"].update( 105 | {product.uid: create_coordinates(30, coord) for product, coord in zip(products, y_coordinate_ruler)}) 106 | 107 | plist["uidata"][run_script_uid]["ypos"] = sum(y_coordinate_ruler) / len(y_coordinate_ruler) 108 | 109 | plist["objects"].extend([create_script_filter(product) for product in products]) 110 | 111 | plist["userconfigurationconfig"].extend([create_userconfigurationconfig(product) for product in products]) 112 | 113 | plist["version"] = version 114 | 115 | with open(".readme/embedded-readme.md", 'r', encoding='utf-8') as file: 116 | plist["readme"] = file.read() 117 | 118 | # Output 119 | print(f"Building {[product.name() for product in products]}") 120 | os.system(f'mkdir -p out') 121 | 122 | with open('out/info.plist', 'wb') as fp: 123 | plistlib.dump(plist, fp) 124 | 125 | for product in products: 126 | os.system(f'cp icons/{product.keyword}.png ./out/{product.uid}.png') 127 | 128 | os.system( 129 | f'zip -j -r alfred-jetbrains-projects.alfredworkflow out/* recent_projects.py products.json icon.png') 130 | 131 | 132 | def get_products() -> list[Product]: 133 | with open('products.json', 'r') as outfile: 134 | js = json.load(outfile) 135 | products = [Product(k, **v) for k, v in js.items()] 136 | return products 137 | 138 | 139 | def clean(): 140 | os.system("rm out/*") 141 | os.system("rm *.alfredworkflow") 142 | 143 | 144 | def main(): 145 | clean() 146 | build() 147 | 148 | 149 | if __name__ == '__main__': 150 | main() 151 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icon.png -------------------------------------------------------------------------------- /icons/README.md: -------------------------------------------------------------------------------- 1 | # Icons 2 | 3 | All [brand Assets](https://www.jetbrains.com/company/brand/) are subject to a copyright 4 | of [JetBrains](https://www.jetbrains.com/) s.r.o. 5 | 6 | ## Working with icons 7 | 8 | All PNG icons are converted from SVG format 9 | using [librsvg](https://gitlab.gnome.org/GNOME/librsvg): 10 | 11 | ```shell 12 | rsvg-convert -h 128 icon.svg > icon.png 13 | ``` 14 | -------------------------------------------------------------------------------- /icons/androidstudio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/androidstudio.png -------------------------------------------------------------------------------- /icons/appcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/appcode.png -------------------------------------------------------------------------------- /icons/clion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/clion.png -------------------------------------------------------------------------------- /icons/datagrip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/datagrip.png -------------------------------------------------------------------------------- /icons/goland.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/goland.png -------------------------------------------------------------------------------- /icons/idea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/idea.png -------------------------------------------------------------------------------- /icons/ideace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/ideace.png -------------------------------------------------------------------------------- /icons/phpstorm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/phpstorm.png -------------------------------------------------------------------------------- /icons/pycharm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/pycharm.png -------------------------------------------------------------------------------- /icons/pycharmce.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/pycharmce.png -------------------------------------------------------------------------------- /icons/rider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/rider.png -------------------------------------------------------------------------------- /icons/rubymine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/rubymine.png -------------------------------------------------------------------------------- /icons/rustrover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/rustrover.png -------------------------------------------------------------------------------- /icons/webstorm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artemy/alfred-jetbrains-projects/3833f80ccfc6e61d273f0e5f86b1140a45549152/icons/webstorm.png -------------------------------------------------------------------------------- /products.json: -------------------------------------------------------------------------------- 1 | { 2 | "androidstudio": { 3 | "display_name": "Android Studio", 4 | "folder_name": "AndroidStudio", 5 | "preferences_path": "~/Library/Application Support/Google/", 6 | "bundle_id": "com.google.android.studio", 7 | "uid": "92b0f147-c697-4f0b-b10a-10d823534408" 8 | }, 9 | "appcode": { 10 | "folder_name": "AppCode", 11 | "bundle_id": "com.jetbrains.appcode", 12 | "uid": "d813b1dd-ee4a-470f-8c4d-73a55248e1b5" 13 | }, 14 | "clion": { 15 | "folder_name": "CLion", 16 | "bundle_id": "com.jetbrains.clion", 17 | "uid": "51a5ab39-dad4-47da-90af-a59be0f3b960" 18 | }, 19 | "datagrip": { 20 | "folder_name": "DataGrip", 21 | "bundle_id": "com.jetbrains.datagrip", 22 | "uid": "cd332057-2b16-4243-bbdf-a7d63718cf44" 23 | }, 24 | "goland": { 25 | "folder_name": "GoLand", 26 | "bundle_id": "com.jetbrains.goland", 27 | "uid": "4d578e7e-c467-4e5c-9126-977f2226cf9e" 28 | }, 29 | "idea": { 30 | "display_name": "IntelliJ IDEA Ultimate", 31 | "folder_name": "IntelliJIdea", 32 | "bundle_id": "com.jetbrains.intellij", 33 | "uid": "24d32321-9a0e-4c25-a26b-009544cfbd8b" 34 | }, 35 | "ideace": { 36 | "display_name": "IntelliJ IDEA Community Edition", 37 | "folder_name": "IdeaIC", 38 | "bundle_id": "com.jetbrains.intellij.ce", 39 | "uid": "d19d2af2-272d-46cc-836c-ae87d052de43" 40 | }, 41 | "phpstorm": { 42 | "folder_name": "PhpStorm", 43 | "bundle_id": "com.jetbrains.phpstorm", 44 | "uid": "9611a7b4-f91a-46f9-b2ef-aec592cbc366" 45 | }, 46 | "pycharm": { 47 | "display_name": "PyCharm Professional", 48 | "folder_name": "PyCharm", 49 | "bundle_id": "com.jetbrains.pycharm", 50 | "uid": "ee83c053-d389-4de0-8227-f7b836fbec98" 51 | }, 52 | "pycharmce": { 53 | "display_name": "PyCharm Community Edition", 54 | "folder_name": "PyCharmCE", 55 | "bundle_id": "com.jetbrains.pycharm.ce", 56 | "uid": "a1f3b9c7-2e48-4d2e-9f6e-8c2b1d3e4a7f" 57 | }, 58 | "rider": { 59 | "folder_name": "Rider", 60 | "bundle_id": "com.jetbrains.rider", 61 | "uid": "af0bcd2c-ac22-4d1d-8999-b7ff683f5280" 62 | }, 63 | "rubymine": { 64 | "folder_name": "RubyMine", 65 | "bundle_id": "com.jetbrains.rubymine", 66 | "uid": "81dc0669-985c-48ea-a174-5485c1c2cf94" 67 | }, 68 | "rustrover": { 69 | "folder_name": "RustRover", 70 | "bundle_id": "com.jetbrains.rustrover", 71 | "uid": "3a187dcf-2949-427a-a9cb-96a90f329e71" 72 | }, 73 | "webstorm": { 74 | "folder_name": "WebStorm", 75 | "bundle_id": "com.jetbrains.webstorm", 76 | "uid": "714d0708-2185-43e9-979f-92e09c09aca0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /recent_projects.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import os 5 | import re 6 | import sys 7 | from xml.etree import ElementTree 8 | 9 | BREAK_CHARACTERS = ["_", "-"] 10 | 11 | 12 | class AlfredItem: 13 | def __init__(self, title, subtitle, arg, type="file"): 14 | self.title = title 15 | self.subtitle = subtitle 16 | self.arg = arg 17 | self.type = type 18 | 19 | 20 | class AlfredOutput: 21 | def __init__(self, items, bundle_id): 22 | self.variables = {"bundle_id": bundle_id} 23 | self.items = items 24 | 25 | 26 | class CustomEncoder(json.JSONEncoder): 27 | def default(self, obj): 28 | return obj.__dict__ 29 | 30 | 31 | def create_json(projects, bundle_id): 32 | return CustomEncoder().encode( 33 | AlfredOutput([AlfredItem(project.name, project.path, project.path) for project in projects], bundle_id)) 34 | 35 | 36 | class Project: 37 | def __init__(self, path): 38 | self.path = path 39 | # os.path.expanduser() is needed for os.path.isfile(), but Alfred can handle the `~` shorthand in the returned JSON. 40 | name_file = os.path.expanduser(self.path) + "/.idea/.name" 41 | 42 | if os.path.isfile(name_file): 43 | self.name = open(name_file).read() 44 | else: 45 | self.name = path.split('/')[-1] 46 | self.abbreviation = self.abbreviate() 47 | 48 | def __eq__(self, other): 49 | if isinstance(other, self.__class__): 50 | return self.name == other.name and self.path == other.path and self.abbreviation == other.abbreviation 51 | return False 52 | 53 | def abbreviate(self): 54 | previous_was_break = False 55 | abbreviation = self.name[0] 56 | for char in self.name[1: len(self.name)]: 57 | if char in BREAK_CHARACTERS: 58 | previous_was_break = True 59 | else: 60 | if previous_was_break: 61 | abbreviation += char 62 | previous_was_break = False 63 | return abbreviation 64 | 65 | def matches_query(self, query): 66 | return query in self.path.lower() or query in self.abbreviation.lower() or query in self.name.lower() 67 | 68 | def sort_on_match_type(self, query): 69 | if query == self.abbreviation: 70 | return 0 71 | elif query in self.name: 72 | return 1 73 | return 2 74 | 75 | 76 | def find_app_data(app): 77 | try: 78 | with open('products.json', 'r') as outfile: 79 | data = json.load(outfile) 80 | return data[app] 81 | except IOError: 82 | print("Can't open products file") 83 | except KeyError: 84 | print("App '{}' is not found in the products.json".format(app)) 85 | exit(1) 86 | 87 | 88 | def find_recentprojects_file(app_data): 89 | preferences_path = os.path.expanduser(preferences_path_or_default(app_data)) 90 | most_recent_preferences = max(find_preferences_folders(preferences_path, app_data)) 91 | filename = "recentSolutions.xml" if app_data['folder_name'] == 'Rider' else "recentProjects.xml" 92 | return "{}{}/options/{}".format(preferences_path, most_recent_preferences, filename) 93 | 94 | 95 | def preferences_path_or_default(application): 96 | return application["preferences_path"] if "preferences_path" in application \ 97 | else "~/Library/Application Support/JetBrains/" 98 | 99 | 100 | def find_preferences_folders(preferences_path, application): 101 | # Naming scheme seems to be %PRODUCT_NAME%%VERSION_NUMBER%, e.g. IntelliJIdea2024.1 102 | # https://github.com/JetBrains/intellij-community/blob/master/platform/remoteDev-util/src/com/intellij/remoteDev/util/ProductPaths.kt#L21 103 | # Make sure to ignore `-backup` postfix 104 | folder_name_pattern = r"^%s\d*?\.\d(?!-backup)" % application["folder_name"] 105 | return [folder_name for folder_name in next(os.walk(preferences_path))[1] 106 | if re.match(folder_name_pattern, folder_name)] 107 | 108 | 109 | def read_projects_from_file(most_recent_projects_file, app_name): 110 | tree = ElementTree.parse(most_recent_projects_file) 111 | component_name = "RiderRecentProjectsManager" if app_name == 'rider' else "RecentProjectsManager" 112 | 113 | projects = [t.attrib['key'].replace('$USER_HOME$', "~") for t 114 | in tree.findall(f".//component[@name='{component_name}']/option[@name='additionalInfo']/map/entry") 115 | if t.find("value/RecentProjectMetaInfo[@hidden='true']") is None] 116 | return reversed(projects) 117 | 118 | 119 | def filter_and_sort_projects(query, projects): 120 | if len(query) < 1: 121 | return projects 122 | results = [p for p in projects if p.matches_query(query)] 123 | results.sort(key=lambda p: p.sort_on_match_type(query)) 124 | return results 125 | 126 | 127 | def main(): # pragma: nocover 128 | app_name = sys.argv[1] 129 | try: 130 | app_data = find_app_data(app_name) 131 | recent_projects_file = find_recentprojects_file(app_data) 132 | 133 | query = sys.argv[2].strip().lower() 134 | 135 | projects = list(map(Project, read_projects_from_file(recent_projects_file, app_name))) 136 | projects = filter_and_sort_projects(query, projects) 137 | 138 | print(create_json(projects, app_data["bundle_id"])) 139 | except IndexError: 140 | print("No app specified, exiting") 141 | exit(1) 142 | except FileNotFoundError: 143 | print(f"The projects file for {app_name} does not exist.") 144 | exit(1) 145 | 146 | 147 | if __name__ == "__main__": # pragma: nocover 148 | main() 149 | -------------------------------------------------------------------------------- /recent_projects_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from recent_projects import create_json, Project, find_app_data, find_recentprojects_file, read_projects_from_file, \ 5 | filter_and_sort_projects 6 | 7 | 8 | class Unittests(unittest.TestCase): 9 | def setUp(self): 10 | self.recentProjectsPath = '/Users/JohnSnow/Library/Application Support' \ 11 | '/JetBrains/IntelliJIdea2020.2/options/recentProjects.xml' 12 | self.example_projects_paths = ["~/Documents/spring-petclinic", "~/Desktop/trash/My Project (42)"] 13 | 14 | with mock.patch("os.path.expanduser") as mock_expanduser: 15 | mock_expanduser.return_value = '/Users/JohnSnow/Documents/spring-petclinic' 16 | self.example_project = Project(self.example_projects_paths[0]) 17 | 18 | @mock.patch('os.path.isfile') 19 | def test_create_json(self, mock_isfile): 20 | mock_isfile.return_value = False 21 | expected = '{"variables": {"bundle_id": "app_name"}, ' \ 22 | '"items": [{"title": "spring-petclinic", ' \ 23 | '"subtitle": "~/Documents/spring-petclinic", ' \ 24 | '"arg": "~/Documents/spring-petclinic", ' \ 25 | '"type": "file"}]}' 26 | self.assertEqual(expected, create_json([self.example_project], "app_name")) 27 | 28 | @mock.patch("os.path.expanduser") 29 | @mock.patch('os.path.isfile') 30 | @mock.patch("builtins.open", mock.mock_open(read_data="custom_project_name")) 31 | def test_create_json_from_custom_name(self, mock_isfile, mock_expand_user): 32 | mock_expand_user.return_value = '/Users/JohnSnow/Documents/spring-petclinic' 33 | mock_isfile.return_value = True 34 | expected = '{"variables": {"bundle_id": "app_name"}, ' \ 35 | '"items": [{"title": "custom_project_name", ' \ 36 | '"subtitle": "~/Documents/spring-petclinic", ' \ 37 | '"arg": "~/Documents/spring-petclinic", ' \ 38 | '"type": "file"}]}' 39 | self.assertEqual(expected, create_json([Project("~/Documents/spring-petclinic")], "app_name")) 40 | 41 | @mock.patch("builtins.open", mock.mock_open( 42 | read_data='{"clion": {"bundle_id": "com.jetbrains.clion", "folder_name": "CLion"}}')) 43 | def test_read_app_data(self): 44 | self.assertEqual(find_app_data("clion"), { 45 | "folder_name": "CLion", 46 | "bundle_id": "com.jetbrains.clion" 47 | }) 48 | 49 | with self.assertRaises(SystemExit) as exitcode: 50 | find_app_data("rider") 51 | self.assertEqual(exitcode.exception.code, 1) 52 | 53 | @mock.patch("builtins.open") 54 | def test_read_app_data_products_file_missing(self, mock_open): 55 | mock_open.side_effect = IOError() 56 | with self.assertRaises(SystemExit) as exitcode: 57 | find_app_data("clion") 58 | self.assertEqual(exitcode.exception.code, 1) 59 | 60 | @mock.patch("os.path.expanduser") 61 | @mock.patch("os.walk") 62 | def test_find_recent_files_xml(self, mock_walk, expand_user): 63 | expand_user.return_value = '/Users/JohnSnow/Library/Application Support/JetBrains/' 64 | mock_walk.return_value = iter([ 65 | ('/Path', 66 | ['IntelliJIdea2020.1', 67 | 'IntelliJIdea2020.2', 68 | 'IntelliJIdea2020.2-backup', 69 | 'GoLand2020.1', 70 | 'GoLand2020.2'], []), 71 | ]) 72 | """Happy Flow""" 73 | self.assertEqual(find_recentprojects_file({"folder_name": "IntelliJIdea"}), 74 | self.recentProjectsPath) 75 | 76 | @mock.patch("os.path.expanduser") 77 | @mock.patch("os.walk") 78 | def test_find_recent_files_xml_android_studio(self, mock_walk, expand_user): 79 | expand_user.return_value = '/Users/JohnSnow/Library/Application Support/Google/' 80 | mock_walk.return_value = iter([ 81 | ('/Path', 82 | ['AndroidStudio4.0', 83 | 'AndroidStudio4.1', 84 | 'Chrome'], []), 85 | ]) 86 | """Happy Flow""" 87 | self.assertEqual( 88 | find_recentprojects_file({"folder_name": "AndroidStudio"}), 89 | '/Users/JohnSnow/Library/Application Support/Google/AndroidStudio4.1/options/recentProjects.xml') 90 | 91 | @mock.patch("os.path.expanduser") 92 | @mock.patch("os.walk") 93 | def test_find_recent_files_xml_rider(self, mock_walk, expand_user): 94 | expand_user.return_value = '/Users/JohnSnow/Library/Application Support/JetBrains/' 95 | mock_walk.return_value = iter([ 96 | ('/Path', 97 | ['Rider2024.3'], []), 98 | ]) 99 | """Happy Flow""" 100 | self.assertEqual( 101 | find_recentprojects_file({"folder_name": "Rider"}), 102 | '/Users/JohnSnow/Library/Application Support/JetBrains/Rider2024.3/options/recentSolutions.xml') 103 | 104 | @mock.patch("builtins.open", mock.mock_open( 105 | read_data='' 106 | '' 107 | '' 118 | '' 119 | '')) 120 | def test_read_projects(self): 121 | self.assertEqual(list(read_projects_from_file(self.recentProjectsPath, 'clion')), self.example_projects_paths) 122 | 123 | @mock.patch("builtins.open", mock.mock_open( 124 | read_data='' 125 | '' 126 | '' 137 | '' 138 | '')) 139 | def test_read_rider_projects(self): 140 | self.assertEqual(list(read_projects_from_file(self.recentProjectsPath, 'rider')), self.example_projects_paths) 141 | 142 | def test_filter_projects(self): 143 | projects = list(map(Project, self.example_projects_paths)) 144 | self.assertEqual([Project(self.example_projects_paths[0])], filter_and_sort_projects("petclinic", projects)) 145 | 146 | def test_filter_projects_no_query(self): 147 | projects = list(map(Project, self.example_projects_paths)) 148 | self.assertEqual(filter_and_sort_projects("", projects), projects) 149 | 150 | def test_project_equals(self): 151 | project = Project(self.example_projects_paths[0]) 152 | self.assertTrue(project == Project("~/Documents/spring-petclinic")) 153 | self.assertFalse(project == "some-other-object") 154 | 155 | def test_project_sort_on_match_type(self): 156 | project = Project(self.example_projects_paths[0]) 157 | self.assertEqual(project.sort_on_match_type("sp"), 0) 158 | self.assertEqual(project.sort_on_match_type("spring-petclinic"), 1) 159 | self.assertEqual(project.sort_on_match_type("foobar"), 2) 160 | 161 | 162 | if __name__ == '__main__': # pragma: nocover 163 | unittest.main() 164 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coverage==7.6.11 2 | --------------------------------------------------------------------------------