├── .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 | 
4 | 
5 |
6 | An Alfred workflow for opening your JetBrains IDEs projects
7 |
8 | 
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 | 
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 |
--------------------------------------------------------------------------------