├── .gitignore ├── support ├── Default (Linux).sublime-keymap ├── Default (OSX).sublime-keymap ├── Default (Windows).sublime-keymap ├── Default.sublime-commands └── Main.sublime-menu ├── .github └── workflows │ └── test.yaml ├── LICENSE.txt ├── json_file.py ├── project_manager.sublime-settings ├── tests └── test_add_and_open.py ├── README.md └── project_manager.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.cache 2 | *.log 3 | *.pyc 4 | *.pyo 5 | *.sublime-workspace 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /support/Default (Linux).sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "keys": ["ctrl+alt+p"], 4 | "command": "project_manager", "args": {"action": "open_project"} 5 | }, 6 | // { 7 | // "keys": ["ctrl+alt+o"], 8 | // "command": "project_manager", "args": {"action": "open_project_in_new_window"} 9 | // } 10 | ] 11 | -------------------------------------------------------------------------------- /support/Default (OSX).sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "keys": ["super+ctrl+p"], 4 | "command": "project_manager", "args": {"action": "open_project"} 5 | }, 6 | // { 7 | // "keys": ["super+ctrl+o"], 8 | // "command": "project_manager", "args": {"action": "open_project_in_new_window"} 9 | // } 10 | ] 11 | -------------------------------------------------------------------------------- /support/Default (Windows).sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "keys": ["ctrl+alt+p"], 4 | "command": "project_manager", "args": {"action": "open_project"} 5 | }, 6 | // { 7 | // "keys": ["ctrl+alt+o"], 8 | // "command": "project_manager", "args": {"action": "open_project_in_new_window"} 9 | // } 10 | ] 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run-tests: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | st-version: [3, 4] 11 | os: ["ubuntu-latest", "macOS-latest", "windows-latest"] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: SublimeText/UnitTesting/actions/setup@master 16 | with: 17 | sublime-text-version: ${{ matrix.st-version }} 18 | unittesting-version: master 19 | # we need xfce for the subl command to work in st3 20 | window-manager: xfce 21 | - uses: SublimeText/UnitTesting/actions/run-tests@master 22 | with: 23 | coverage: true 24 | codecov-upload: true 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Randy Lai 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /json_file.py: -------------------------------------------------------------------------------- 1 | import sublime 2 | import os 3 | 4 | 5 | class JsonFile: 6 | def __init__(self, fpath, encoding='utf-8'): 7 | self.encoding = encoding 8 | self.fpath = fpath 9 | 10 | def load(self, default=[]): 11 | self.fdir = os.path.dirname(self.fpath) 12 | if not os.path.isdir(self.fdir): 13 | os.makedirs(self.fdir) 14 | if os.path.exists(self.fpath): 15 | with open(self.fpath, mode='r', encoding=self.encoding) as f: 16 | content = f.read() 17 | try: 18 | data = sublime.decode_value(content) 19 | except Exception: 20 | sublime.message_dialog('%s is bad!' % self.fpath) 21 | raise 22 | if not data: 23 | data = default 24 | else: 25 | with open(self.fpath, mode='w', encoding=self.encoding, newline='\n') as f: 26 | data = default 27 | f.write(sublime.encode_value(data, True)) 28 | return data 29 | 30 | def save(self, data, indent=4): 31 | self.fdir = os.path.dirname(self.fpath) 32 | if not os.path.isdir(self.fdir): 33 | os.makedirs(self.fdir) 34 | with open(self.fpath, mode='w', encoding=self.encoding, newline='\n') as f: 35 | f.write(sublime.encode_value(data, True)) 36 | 37 | def remove(self): 38 | if os.path.exists(self.fpath): 39 | os.remove(self.fpath) 40 | -------------------------------------------------------------------------------- /project_manager.sublime-settings: -------------------------------------------------------------------------------- 1 | { 2 | // The path to the Projects folder, could be either a string or a directory with keys 3 | // associated with computer names. 4 | // If you are not sure about your computer name, run the following in the sublime console 5 | // 6 | // from ProjectManager.project_manager import computer_name; print(computer_name()) 7 | // 8 | // "projects": { 9 | // "computer1": "path/to/computer1/projects", 10 | // "computer2": "path/to/computer1/projects" 11 | // } 12 | // For backward compatibility, each entry could be also an array of directories. 13 | // The variable `$default` will expand to `PATH/TO/Packages/Users/Projects`, 14 | // and `$hostname` will expand to the computer name. 15 | // Please note that they shall be the folders containing solely *.sublime-project or 16 | // *.sublime-workspace files. It is not intended to be used for automatic project discovery. 17 | "projects": "$default", 18 | 19 | // If there are more than one directory, prompt which directory to save the project files. 20 | "prompt_project_location": true, 21 | 22 | // Windows/Linux only 23 | // close project when the window is closed 24 | "close_project_when_close_window": true, 25 | 26 | // Show recent projects first 27 | // if false, the projects are sorted alphabetically 28 | "show_recent_projects_first": true, 29 | 30 | // Show active projects first 31 | "show_active_projects_first": true, 32 | 33 | // The string to use as the active indicator for the project list. 34 | "active_project_indicator": "*", 35 | 36 | // How the projects in the project list should be formatted. Supported variables 37 | // are "project_name" and "active_project_indicator". For inactive projects 38 | // "active_project_indicator" will be an empty string. 39 | "project_display_format": "{project_name}{active_project_indicator}" 40 | } 41 | -------------------------------------------------------------------------------- /support/Default.sublime-commands: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "caption": "Project Manager", 4 | "command": "project_manager" 5 | }, 6 | { 7 | "caption": "Project Manager: Add New Project", 8 | "command": "project_manager", "args": {"action": "add_project"} 9 | }, 10 | { 11 | "caption": "Project Manager: Import *.sublime-project File", 12 | "command": "project_manager", "args": {"action": "import_sublime_project"} 13 | }, 14 | { 15 | "caption": "Project Manager: Open Project", 16 | "command": "project_manager", "args": {"action": "open_project"} 17 | }, 18 | { 19 | "caption": "Project Manager: Open Project in New Window", 20 | "command": "project_manager", "args": {"action": "open_project_in_new_window"} 21 | }, 22 | { 23 | "caption": "Project Manager: Append Project", 24 | "command": "project_manager", "args": {"action": "append_project"} 25 | }, 26 | { 27 | "caption": "Project Manager: Remove Project", 28 | "command": "project_manager", "args": {"action": "remove_project"} 29 | }, 30 | { 31 | "caption": "Project Manager: Edit Project", 32 | "command": "project_manager", "args": {"action": "edit_project"} 33 | }, 34 | { 35 | "caption": "Project Manager: Rename Project", 36 | "command": "project_manager", "args": {"action": "rename_project"} 37 | }, 38 | { 39 | "caption": "Project Manager: Refresh Projects", 40 | "command": "project_manager", "args": {"action": "refresh_projects"} 41 | }, 42 | { 43 | "caption": "Project Manager: Clear Recent Projects", 44 | "command": "project_manager", "args": {"action": "clear_recent_projects"} 45 | }, 46 | { 47 | "caption": "Project Manager: Remove Dead Projects", 48 | "command": "project_manager", "args": {"action": "remove_dead_projects"} 49 | }, 50 | { 51 | "caption": "Project Manager: Documentation Readme", 52 | "command": "pm_readme" 53 | }, 54 | { 55 | "caption": "Project Manager: Documentation Changelog", 56 | "command": "pm_changelog" 57 | }, 58 | { 59 | "caption": "Preferences: Project Manager Settings", 60 | "command": "edit_settings", 61 | "args": 62 | { 63 | "base_file": "${packages}/ProjectManager/project_manager.sublime-settings", 64 | "default": "{\n\t$0\n}\n" 65 | } 66 | }, 67 | { 68 | "caption": "Preferences: Project Manager Key Bindings", 69 | "command": "edit_settings", 70 | "args": 71 | { 72 | "base_file": "${packages}/ProjectManager/support/Default (${platform}).sublime-keymap", 73 | "default": "[\n\t$0\n]\n" 74 | } 75 | } 76 | ] 77 | -------------------------------------------------------------------------------- /tests/test_add_and_open.py: -------------------------------------------------------------------------------- 1 | import sublime 2 | from unittesting.helpers import TempDirectoryTestCase, OverridePreferencesTestCase 3 | from ProjectManager.project_manager import Manager 4 | 5 | 6 | import os 7 | from unittest.mock import patch 8 | 9 | 10 | class TestBasicFeatures(TempDirectoryTestCase, OverridePreferencesTestCase): 11 | override_preferences = { 12 | "project_manager.sublime-settings": {} 13 | } 14 | project_name = None 15 | 16 | @classmethod 17 | def setUpClass(cls): 18 | yield from super().setUpClass() 19 | cls.project_name = os.path.basename(cls._temp_dir) 20 | cls.manager = Manager(cls.window) 21 | 22 | @classmethod 23 | def tearDownClass(cls): 24 | yield from super().tearDownClass() 25 | if cls.project_name in cls.manager.projects_info.info(): 26 | with patch("sublime.ok_cancel_dialog", return_value=True) as mocked: 27 | cls.manager.remove_project(cls.project_name) 28 | yield lambda: mocked.called 29 | 30 | def setUp(self): 31 | yield from self.__class__.setWindowFolder() 32 | 33 | def test_add_and_open_with_mock(self): 34 | def _window_show_input_panel(wid, caption, initial_text, on_done, on_change, on_cancel): 35 | sublime.set_timeout(lambda: on_done(initial_text), 100) 36 | return 0 37 | 38 | with patch("sublime_api.window_show_input_panel", _window_show_input_panel): 39 | self.window.run_command("project_manager", {"action": "add_project"}) 40 | yield lambda: self.window.project_file_name() is not None 41 | 42 | projects_info = self.manager.projects_info.info() 43 | 44 | self.assertTrue(self.project_name in projects_info) 45 | 46 | # clear sidebar 47 | self.window.run_command('close_workspace') 48 | 49 | self.assertTrue(self.window.project_file_name() is None) 50 | 51 | if sublime.version() >= '4000': 52 | def _window_show_quick_panel(wid, items, on_done, *args, **kwargs): 53 | index = next(i for i, item in enumerate(items) 54 | if item[0].startswith(self.project_name)) 55 | sublime.set_timeout(lambda: on_done(index), 100) 56 | else: 57 | def _window_show_quick_panel(wid, items, items_per_row, on_done, *args, **kwargs): 58 | index = next(int(i / items_per_row) for i, item in enumerate(items) 59 | if i % items_per_row == 0 and item.startswith(self.project_name)) 60 | sublime.set_timeout(lambda: on_done(index), 100) 61 | 62 | with patch("sublime_api.window_show_quick_panel", _window_show_quick_panel): 63 | self.window.run_command("project_manager", {"action": "open_project"}) 64 | yield lambda: self.window.project_file_name() is not None 65 | 66 | self.assertEqual(os.path.basename(self.window.folders()[0]), self.project_name) 67 | 68 | with patch("sublime_api.window_show_quick_panel", _window_show_quick_panel): 69 | with patch("sublime.ok_cancel_dialog", return_value=True): 70 | self.window.run_command("project_manager", {"action": "remove_project"}) 71 | yield lambda: self.window.project_file_name() is None 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project Manager for [Sublime Text](https://www.sublimetext.com) 2 | 3 | [![test](https://github.com/randy3k/ProjectManager/actions/workflows/test.yaml/badge.svg)](https://github.com/randy3k/ProjectManager/actions/workflows/test.yaml) 4 | [![codecov](https://codecov.io/gh/randy3k/ProjectManager/branch/master/graph/badge.svg)](https://codecov.io/gh/randy3k/ProjectManager) 5 | 6 | 7 | 8 | 9 | Don't have any idea what `*.sublime-project` and `*.sublime-workspace` are doing? Forget where the project files are? Don't worry, Project Manager will help organizing the project files by putting them in a centralized location. (It is inspired by Atom's [Project Manager](https://atom.io/packages/project-manager), but Atom's Project Manager is inspired by the built-in Sublime Text Project Manager, so there is a circular reasoning here). 10 | 11 | ![Screenshot](https://user-images.githubusercontent.com/1690993/141353224-d1d98169-bf8e-4302-a882-3d4961223507.png) 12 | 13 | Check [this video](https://laracasts.com/series/professional-php-workflow-in-sublime-text/episodes/9) by [Laracasts](https://laracasts.com/series/professional-php-workflow-in-sublime-text). 14 | 15 | 16 | ## Installation 17 | 18 | Using **Package Control** is not required, but recommended as it keeps your packages (with their dependencies) up-to-date! 19 | 20 | ### Installation via Package Control 21 | 22 | * [Install Package Control](https://packagecontrol.io/installation#st3) 23 | * Close and reopen Sublime Text after having installed Package Control. 24 | * Open the Command Palette (`Tools > Command Palette`). 25 | * Choose `Package Control: Install Package`. 26 | * Search for [`ProjectManager` on Package Control](https://packagecontrol.io/packages/ProjectManager) and select to install. 27 | 28 | ## Usage 29 | 30 | To launch ProjectManager, use the main menu (`Project > Project Manager`) or the command palette (`Project Manager: ...`). 31 | 32 | To quickly switch between projects, use the hotkey CtrlCmdP on macOS (CtrlAltP on Windows / Linux). 33 | 34 | ProjectManager also improves the shortcut CtrlShiftW on Windows / Linux so that it will close the project when the window is closed. On OSX, this is the default behaviour. 35 | 36 | ![](https://cloud.githubusercontent.com/assets/1690993/20858332/9f6508ea-b911-11e6-93b9-3cccca1d663e.png) 37 | ![](https://cloud.githubusercontent.com/assets/1690993/20858333/a7a16a1c-b911-11e6-938c-0fe77e2cf405.png) 38 | 39 | Options are self-explanatory, enjoy! 40 | 41 | ### Create new project 42 | 43 | Just drag some folders to Sublime Text and then "Add Project". The project files will be created in `Packages/User/Projects/`. 44 | 45 | ### Add existing projects to Project Manager 46 | 47 | There are two ways to add existing projects to Project Manager. 48 | 49 | - If you want Project Manager manages the project files: move your `*.sublime- 50 | project` and `*.sublime-workspace` files in the project directory 51 | `Packages/User/Projects/`. You may need to update the project's folder 52 | information of the files. Don't forget to run `Project Manager: Refresh Projects` after it. 53 | 54 | - If you want to keep the project files (`*.sublime-project` and `*.sublime-workspace`) in your 55 | project directory: open your project file `*.sublime-project`, and then use the import option of 56 | Project Manager. This tells Project Manager where `*.sublime-project` is located and Project 57 | Manager will know where to look when the project is opened. In other words, you can put the 58 | `*.sublime-project` file in any places. 59 | 60 | 61 | 62 | ### FAQ 63 | 64 | - _How to open project in a new window with a shortcut?_ 65 | It can be done by adding the following keybind in your user keybind settings file: 66 | 67 | ``` 68 | { 69 | "keys": ["super+ctrl+o"], // or ["ctrl+alt+o"] for Windows/Linux 70 | "command": "project_manager", "args": {"action": "open_project_in_new_window"} 71 | } 72 | ``` 73 | 74 | ### License 75 | 76 | Project Manager is MIT licensed. 77 | -------------------------------------------------------------------------------- /support/Main.sublime-menu: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "project", 4 | "children": 5 | [ 6 | { "caption": "-" }, 7 | { 8 | "caption": "Project Manager", 9 | "id": "project_manager", 10 | "children": 11 | [ 12 | { 13 | "caption": "Project Manager", 14 | "command": "project_manager" 15 | }, 16 | { "caption": "-" }, 17 | { 18 | "caption": "Add New Project", 19 | "command": "project_manager", "args": {"action": "add_project"} 20 | }, 21 | { 22 | "caption": "Import *.sublime-project File", 23 | "command": "project_manager", "args": {"action": "import_sublime_project"} 24 | }, 25 | { 26 | "caption": "Open Project", 27 | "command": "project_manager", "args": {"action": "open_project"} 28 | }, 29 | { 30 | "caption": "Open Project in New Window", 31 | "command": "project_manager", "args": {"action": "open_project_in_new_window"} 32 | }, 33 | { 34 | "caption": "Append Project", 35 | "command": "project_manager", "args": {"action": "append_project"} 36 | }, 37 | { 38 | "caption": "Remove Project", 39 | "command": "project_manager", "args": {"action": "remove_project"} 40 | }, 41 | { 42 | "caption": "Edit Project", 43 | "command": "project_manager", "args": {"action": "edit_project"} 44 | }, 45 | { 46 | "caption": "Rename Project", 47 | "command": "project_manager", "args": {"action": "rename_project"} 48 | }, 49 | { 50 | "caption": "Refresh Projects", 51 | "command": "project_manager", "args": {"action": "refresh_projects"} 52 | }, 53 | { 54 | "caption": "Clear Recent Projects", 55 | "command": "project_manager", "args": {"action": "clear_recent_projects"} 56 | }, 57 | { 58 | "caption": "Remove Dead Projects", 59 | "command": "project_manager", "args": {"action": "remove_dead_projects"} 60 | } 61 | ] 62 | } 63 | ] 64 | }, 65 | { 66 | "id": "preferences", 67 | "children": 68 | [ 69 | { 70 | "id": "package-settings", 71 | "children": 72 | [ 73 | { 74 | "caption": "Project Manager", 75 | "children": 76 | [ 77 | { 78 | "caption": "Settings", 79 | "command": "edit_settings", 80 | "args": 81 | { 82 | "base_file": "${packages}/ProjectManager/project_manager.sublime-settings", 83 | "default": "{\n\t$0\n}\n" 84 | } 85 | }, 86 | { 87 | "caption": "Key Bindings", 88 | "command": "edit_settings", 89 | "args": 90 | { 91 | "base_file": "${packages}/ProjectManager/support/Default (${platform}).sublime-keymap", 92 | "default": "[\n\t$0\n]\n" 93 | } 94 | } 95 | ] 96 | } 97 | ] 98 | } 99 | ] 100 | } 101 | ] 102 | -------------------------------------------------------------------------------- /project_manager.py: -------------------------------------------------------------------------------- 1 | import sublime 2 | import sublime_plugin 3 | import subprocess 4 | import os 5 | import platform 6 | import re 7 | import copy 8 | 9 | 10 | from .json_file import JsonFile 11 | 12 | SETTINGS_FILENAME = 'project_manager.sublime-settings' 13 | pm_settings = None 14 | 15 | 16 | def preferences_migrator(): 17 | projects_path = pm_settings.get("projects_path", []) 18 | 19 | if pm_settings.get("use_local_projects_dir", False): 20 | if projects_path: 21 | pm_settings.set( 22 | "projects", 23 | [p + " - $hostname" for p in projects_path] + projects_path + 24 | ["$default - $hostname", "$default"]) 25 | else: 26 | pm_settings.set("projects", "$default - $hostname") 27 | elif projects_path: 28 | if len(projects_path) > 1: 29 | pm_settings.set("projects", projects_path) 30 | else: 31 | pm_settings.set("projects", projects_path[0]) 32 | 33 | pm_settings.erase("projects_path") 34 | pm_settings.erase("use_local_projects_dir") 35 | sublime.save_settings(SETTINGS_FILENAME) 36 | 37 | 38 | def plugin_loaded(): 39 | global pm_settings 40 | pm_settings = sublime.load_settings(SETTINGS_FILENAME) 41 | if pm_settings.has("projects_path") and pm_settings.get("projects") == "$default": 42 | preferences_migrator() 43 | projects_info = ProjectsInfo.get_instance() 44 | pm_settings.add_on_change("refresh_projects", projects_info.refresh_projects) 45 | 46 | 47 | def plugin_unloaded(): 48 | pm_settings.clear_on_change("refresh_projects") 49 | 50 | 51 | def subl(*args): 52 | print(args) 53 | executable_path = sublime.executable_path() 54 | if sublime.platform() == 'osx': 55 | app_path = executable_path[:executable_path.rfind('.app/') + 5] 56 | executable_path = app_path + 'Contents/SharedSupport/bin/subl' 57 | 58 | subprocess.Popen([executable_path] + list(args)) 59 | 60 | def on_activated(): 61 | window = sublime.active_window() 62 | view = window.active_view() 63 | 64 | if sublime.platform() == 'windows': 65 | # fix focus on windows 66 | window.run_command('focus_neighboring_group') 67 | window.focus_view(view) 68 | 69 | sublime_plugin.on_activated(view.id()) 70 | sublime.set_timeout_async(lambda: sublime_plugin.on_activated_async(view.id())) 71 | 72 | sublime.set_timeout(on_activated, 300) 73 | 74 | 75 | def expand_path(path, relative_to=None): 76 | root = None 77 | if relative_to: 78 | if os.path.isfile(relative_to): 79 | root = os.path.dirname(relative_to) 80 | elif os.path.isdir(relative_to): 81 | root = relative_to 82 | 83 | if path: 84 | path = os.path.expanduser(path) 85 | if path.endswith(os.sep): 86 | path = path[:-1] 87 | if root and not os.path.isabs(path): 88 | path = os.path.normpath(os.path.join(root, path)) 89 | return path 90 | 91 | 92 | def pretty_path(path): 93 | user_home = os.path.expanduser('~') + os.sep 94 | if path and path.startswith(user_home): 95 | path = os.path.join("~", path[len(user_home):]) 96 | return path 97 | 98 | 99 | def format_directory(item, folder): 100 | if hasattr(sublime, "QuickPanelItem"): 101 | return sublime.QuickPanelItem( 102 | item, 103 | '%s' % ( 104 | sublime.command_url('open_dir', {'dir': folder}), 105 | pretty_path(folder))) 106 | else: 107 | return [item, pretty_path(folder)] 108 | 109 | 110 | def safe_remove(path): 111 | if os.path.exists(path): 112 | try: 113 | os.remove(path) 114 | except Exception: 115 | pass 116 | 117 | 118 | _computer_name = [] 119 | 120 | 121 | def computer_name(): 122 | if _computer_name: 123 | node = _computer_name[0] 124 | else: 125 | if sublime.platform() == 'osx': 126 | node = subprocess.check_output(['scutil', '--get', 'ComputerName']).decode().strip() 127 | else: 128 | node = platform.node().split('.')[0] 129 | _computer_name.append(node) 130 | 131 | return node 132 | 133 | 134 | def dont_close_windows_when_empty(func): 135 | def f(*args, **kwargs): 136 | s = sublime.load_settings('Preferences.sublime-settings') 137 | close_windows_when_empty = s.get('close_windows_when_empty') 138 | s.set('close_windows_when_empty', False) 139 | func(*args, **kwargs) 140 | if close_windows_when_empty: 141 | sublime.set_timeout( 142 | lambda: s.set('close_windows_when_empty', close_windows_when_empty), 143 | 1000) 144 | return f 145 | 146 | 147 | class ProjectsInfo: 148 | _instance = None 149 | 150 | def __init__(self): 151 | self.refresh_projects() 152 | 153 | @classmethod 154 | def get_instance(cls): 155 | if not cls._instance: 156 | cls._instance = cls() 157 | return cls._instance 158 | 159 | def projects_path(self): 160 | return self._projects_path 161 | 162 | def primary_dir(self): 163 | return self._primary_dir 164 | 165 | def default_dir(self): 166 | return self._default_dir 167 | 168 | def info(self): 169 | return self._info 170 | 171 | def which_project_dir(self, pfile): 172 | pfile = expand_path(pfile) 173 | for pdir in self._projects_path: 174 | if (os.path.realpath(os.path.dirname(pfile)) + os.path.sep).startswith( 175 | os.path.realpath(pdir) + os.path.sep): 176 | return pdir 177 | return None 178 | 179 | def refresh_projects(self): 180 | self._default_dir = os.path.join( 181 | sublime.packages_path(), 'User', 'Projects') 182 | 183 | self._projects_path = [] 184 | 185 | user_projects_dirs = pm_settings.get('projects') 186 | node = computer_name() 187 | 188 | if isinstance(user_projects_dirs, dict): 189 | if node in user_projects_dirs: 190 | user_projects_dirs = user_projects_dirs[node] 191 | else: 192 | user_projects_dirs = [] 193 | 194 | if isinstance(user_projects_dirs, str): 195 | user_projects_dirs = [user_projects_dirs] 196 | 197 | for folder in user_projects_dirs: 198 | p = expand_path(folder) 199 | p = p.replace("$default", self._default_dir) 200 | p = p.replace("$hostname", node) 201 | self._projects_path.append(p) 202 | 203 | if self._default_dir not in self._projects_path: 204 | self._projects_path.append(self._default_dir) 205 | 206 | self._projects_path = [expand_path(d) for d in self._projects_path] 207 | 208 | self._primary_dir = self._projects_path[0] 209 | 210 | if not os.path.isdir(self._default_dir): 211 | os.makedirs(self._default_dir) 212 | 213 | if not os.path.isdir(self._primary_dir): 214 | raise Exception("Directory \"{}\" does not exists.".format(self._primary_dir)) 215 | 216 | self._info = self._get_all_projects_info() 217 | 218 | def _get_all_projects_info(self): 219 | all_projects_info = {} 220 | for pdir in self._projects_path: 221 | for f in self._load_library(pdir): 222 | info = self._get_info_from_project_file(f) 223 | info["type"] = "library" 224 | all_projects_info[info["name"]] = info 225 | 226 | for f in self._load_sublime_project_files(pdir): 227 | info = self._get_info_from_project_file(f) 228 | info["type"] = "sublime-project" 229 | all_projects_info[info["name"]] = info 230 | 231 | return all_projects_info 232 | 233 | def _load_library(self, folder): 234 | pfiles = [] 235 | library = os.path.join(folder, 'library.json') 236 | if os.path.exists(library): 237 | j = JsonFile(library) 238 | for f in j.load([]): 239 | pfile = expand_path(f) 240 | if os.path.exists(pfile) and pfile not in pfiles: 241 | pfiles.append(os.path.normpath(pfile)) 242 | pfiles.sort() 243 | j.save(pfiles) 244 | return pfiles 245 | 246 | def _get_info_from_project_file(self, pfile): 247 | pdir = self.which_project_dir(pfile) 248 | info = {} 249 | 250 | basename = os.path.relpath(pfile, pdir) if pdir else os.path.basename(pfile) 251 | pname = re.sub(r'\.sublime-project$', '', basename) 252 | 253 | pd = JsonFile(pfile).load() 254 | if pd and 'folders' in pd and pd['folders']: 255 | folder = expand_path(pd['folders'][0].get('path', ''), relative_to=pfile) 256 | else: 257 | folder = '' 258 | info["name"] = pname 259 | info["folder"] = folder 260 | info["file"] = pfile 261 | return info 262 | 263 | def _load_sublime_project_files(self, folder): 264 | pfiles = [] 265 | for path, dirs, files in os.walk(folder, followlinks=True): 266 | for f in files: 267 | f = os.path.join(path, f) 268 | if f.endswith('.sublime-project') and f not in pfiles: 269 | pfiles.append(os.path.normpath(f)) 270 | # remove empty directories 271 | for d in dirs: 272 | d = os.path.join(path, d) 273 | if os.path.exists(d) and len(os.listdir(d)) == 0: 274 | os.rmdir(d) 275 | return pfiles 276 | 277 | 278 | class Manager: 279 | def __init__(self, window): 280 | self.window = window 281 | self.projects_info = ProjectsInfo.get_instance() 282 | 283 | def display_projects(self): 284 | info = copy.deepcopy(self.projects_info.info()) 285 | self.mark_open_projects(info) 286 | plist = list(map(self.render_display_item, info.items())) 287 | plist.sort(key=lambda p: p[0]) 288 | if pm_settings.get('show_recent_projects_first', True): 289 | self.move_recent_projects_to_top(plist) 290 | 291 | if pm_settings.get('show_active_projects_first', True): 292 | self.move_openning_projects_to_top(plist) 293 | 294 | return [p[0] for p in plist], [format_directory(p[1], p[2]) for p in plist] 295 | 296 | def mark_open_projects(self, info): 297 | project_file_names = [ 298 | os.path.realpath(w.project_file_name()) 299 | for w in sublime.windows() if w.project_file_name()] 300 | 301 | for v in info.values(): 302 | if os.path.realpath(v["file"]) in project_file_names: 303 | v["star"] = True 304 | 305 | def render_display_item(self, item): 306 | project_name, info = item 307 | active_project_indicator = str(pm_settings.get('active_project_indicator', '*')) 308 | display_format = str(pm_settings.get( 309 | 'project_display_format', '{project_name}{active_project_indicator}')) 310 | if "star" in info: 311 | display_name = display_format.format( 312 | project_name=project_name, active_project_indicator=active_project_indicator) 313 | else: 314 | display_name = display_format.format( 315 | project_name=project_name, active_project_indicator='') 316 | return [ 317 | project_name, 318 | display_name.strip(), 319 | info['folder'], 320 | pretty_path(info['file'])] 321 | 322 | def move_recent_projects_to_top(self, plist): 323 | j = JsonFile(os.path.join(self.projects_info.primary_dir(), 'recent.json')) 324 | recent = j.load([]) 325 | # TODO: it is not needed 326 | recent = [pretty_path(p) for p in recent] 327 | return plist.sort( 328 | key=lambda p: recent.index(p[3]) if p[3] in recent else -1, 329 | reverse=True) 330 | 331 | def move_openning_projects_to_top(self, plist): 332 | count = 0 333 | for i in range(len(plist)): 334 | if plist[i][0] != plist[i][1]: 335 | plist.insert(count, plist.pop(i)) 336 | count = count + 1 337 | 338 | def project_file_name(self, project): 339 | return self.projects_info.info()[project]['file'] 340 | 341 | def project_workspace(self, project): 342 | return re.sub(r'\.sublime-project$', 343 | '.sublime-workspace', 344 | self.project_file_name(project)) 345 | 346 | def update_recent(self, project): 347 | j = JsonFile(os.path.join(self.projects_info.primary_dir(), 'recent.json')) 348 | recent = j.load([]) 349 | # TODO: it is not needed 350 | recent = [pretty_path(p) for p in recent] 351 | pname = pretty_path(self.project_file_name(project)) 352 | if pname not in recent: 353 | recent.append(pname) 354 | else: 355 | recent.append(recent.pop(recent.index(pname))) 356 | # only keep the most recent 50 records 357 | if len(recent) > 50: 358 | recent = recent[(50 - len(recent)):len(recent)] 359 | j.save(recent) 360 | 361 | def clear_recent_projects(self): 362 | def clear_callback(): 363 | answer = sublime.ok_cancel_dialog('Clear Recent Projects?') 364 | if answer is True: 365 | j = JsonFile(os.path.join(self.projects_info.primary_dir(), 'recent.json')) 366 | j.remove() 367 | self.window.run_command("clear_recent_projects_and_workspaces") 368 | 369 | sublime.set_timeout(clear_callback, 100) 370 | 371 | def get_project_data(self, project): 372 | return JsonFile(self.project_file_name(project)).load() 373 | 374 | def check_project(self, project): 375 | wsfile = self.project_workspace(project) 376 | j = JsonFile(wsfile) 377 | if not os.path.exists(wsfile): 378 | j.save({}) 379 | 380 | def close_project_by_window(self, window): 381 | window.run_command('close_workspace') 382 | 383 | def close_project_by_name(self, project): 384 | pfile = os.path.realpath(self.project_file_name(project)) 385 | for w in sublime.windows(): 386 | if w.project_file_name(): 387 | if os.path.realpath(w.project_file_name()) == pfile: 388 | self.close_project_by_window(w) 389 | if w.id() != sublime.active_window().id(): 390 | w.run_command('close_window') 391 | return True 392 | return False 393 | 394 | def prompt_directory(self, callback, on_cancel=None): 395 | primary_dir = self.projects_info.primary_dir() 396 | default_dir = self.projects_info.default_dir() 397 | remaining_path = self.projects_info.projects_path() 398 | if primary_dir in remaining_path: 399 | remaining_path.remove(primary_dir) 400 | if default_dir in remaining_path: 401 | remaining_path.remove(default_dir) 402 | 403 | if pm_settings.get("prompt_project_location", True): 404 | if primary_dir != default_dir: 405 | items = [ 406 | format_directory("Primary Directory", primary_dir), 407 | format_directory("Default Directory", default_dir) 408 | ] + [ 409 | format_directory(os.path.basename(p), p) 410 | for p in remaining_path 411 | ] 412 | 413 | def _on_select(index): 414 | if index < 0: 415 | if on_cancel: 416 | on_cancel() 417 | elif index == 0: 418 | sublime.set_timeout(lambda: callback(primary_dir), 100) 419 | elif index == 1: 420 | sublime.set_timeout(lambda: callback(default_dir), 100) 421 | elif index >= 2: 422 | sublime.set_timeout(lambda: callback(remaining_path[index - 2]), 100) 423 | 424 | self.window.show_quick_panel(items, _on_select) 425 | return 426 | 427 | # fallback 428 | sublime.set_timeout(lambda: callback(primary_dir), 100) 429 | 430 | def add_project(self, on_cancel=None): 431 | def add_callback(project, pdir): 432 | pd = self.window.project_data() 433 | pf = self.window.project_file_name() 434 | pfile = os.path.join(pdir, '%s.sublime-project' % project) 435 | if pd: 436 | if "folders" in pd: 437 | for folder in pd["folders"]: 438 | if "path" in folder: 439 | path = folder["path"] 440 | if sublime.platform() == "windows": 441 | folder["path"] = expand_path(path, relative_to=pf) 442 | else: 443 | folder["path"] = pretty_path( 444 | expand_path(path, relative_to=pf)) 445 | JsonFile(pfile).save(pd) 446 | else: 447 | JsonFile(pfile).save({}) 448 | 449 | # create workspace file 450 | wsfile = re.sub(r'\.sublime-project$', '.sublime-workspace', pfile) 451 | if not os.path.exists(wsfile): 452 | JsonFile(wsfile).save({}) 453 | 454 | self.close_project_by_window(self.window) 455 | # nuke the current window by closing sidebar and all files 456 | self.window.run_command('close_project') 457 | self.window.run_command('close_all') 458 | 459 | self.projects_info.refresh_projects() 460 | self.switch_project(project) 461 | 462 | def _ask_project_name(pdir): 463 | project = 'New Project' 464 | pd = self.window.project_data() 465 | pf = self.window.project_file_name() 466 | try: 467 | path = pd['folders'][0]['path'] 468 | project = os.path.basename(expand_path(path, relative_to=pf)) 469 | except Exception: 470 | pass 471 | 472 | v = self.window.show_input_panel('Project name:', 473 | project, 474 | lambda x: add_callback(x, pdir), 475 | None, 476 | None) 477 | v.run_command('select_all') 478 | 479 | self.prompt_directory(_ask_project_name, on_cancel=on_cancel) 480 | 481 | def import_sublime_project(self, on_cancel=None): 482 | def _import_sublime_project(pdir): 483 | pfile = pretty_path(self.window.project_file_name()) 484 | if not pfile: 485 | sublime.message_dialog('Project file not found!') 486 | return 487 | if self.projects_info.which_project_dir(pfile): 488 | sublime.message_dialog('This project was created by Project Manager!') 489 | return 490 | answer = sublime.ok_cancel_dialog('Import %s?' % os.path.basename(pfile)) 491 | if answer is True: 492 | j = JsonFile(os.path.join(pdir, 'library.json')) 493 | data = j.load([]) 494 | if pfile not in data: 495 | data.append(pfile) 496 | j.save(data) 497 | 498 | self.projects_info.refresh_projects() 499 | 500 | self.prompt_directory(_import_sublime_project, on_cancel=on_cancel) 501 | 502 | def prompt_project(self, callback, on_cancel=None): 503 | projects, display = self.display_projects() 504 | 505 | def _(i): 506 | if i >= 0: 507 | callback(projects[i]) 508 | elif on_cancel: 509 | on_cancel() 510 | 511 | sublime.set_timeout(lambda: self.window.show_quick_panel(display, _), 100) 512 | 513 | def append_project(self, project): 514 | self.update_recent(project) 515 | pd = self.get_project_data(project) 516 | paths = [expand_path(f.get('path'), self.project_file_name(project)) 517 | for f in pd.get('folders')] 518 | subl('-a', *paths) 519 | 520 | @dont_close_windows_when_empty 521 | def switch_project(self, project): 522 | self.update_recent(project) 523 | self.check_project(project) 524 | self.close_project_by_window(self.window) 525 | self.close_project_by_name(project) 526 | subl('--project', self.project_file_name(project)) 527 | self.projects_info.refresh_projects() 528 | 529 | @dont_close_windows_when_empty 530 | def open_in_new_window(self, project): 531 | self.update_recent(project) 532 | self.check_project(project) 533 | self.close_project_by_name(project) 534 | subl('-n', '--project', self.project_file_name(project)) 535 | self.projects_info.refresh_projects() 536 | 537 | def _remove_project(self, project): 538 | answer = sublime.ok_cancel_dialog('Remove "%s" from Project Manager?' % project) 539 | if answer is True: 540 | pfile = self.project_file_name(project) 541 | if self.projects_info.which_project_dir(pfile): 542 | self.close_project_by_name(project) 543 | safe_remove(self.project_file_name(project)) 544 | safe_remove(self.project_workspace(project)) 545 | else: 546 | for pdir in self.projects_info.projects_path(): 547 | j = JsonFile(os.path.join(pdir, 'library.json')) 548 | data = j.load([]) 549 | if pfile in data: 550 | data.remove(pfile) 551 | j.save(data) 552 | sublime.status_message('Project "%s" is removed.' % project) 553 | 554 | def remove_project(self, project): 555 | def _(): 556 | self._remove_project(project) 557 | self.projects_info.refresh_projects() 558 | 559 | sublime.set_timeout(_, 100) 560 | 561 | def clean_dead_projects(self): 562 | projects_to_remove = [] 563 | for pname, pi in self.projects_info.info().items(): 564 | folder = pi['folder'] 565 | if not os.path.exists(folder): 566 | projects_to_remove.append(pname) 567 | 568 | def remove_projects_iteratively(): 569 | pname = projects_to_remove[0] 570 | self._remove_project(pname) 571 | projects_to_remove.remove(pname) 572 | if len(projects_to_remove) > 0: 573 | sublime.set_timeout(remove_projects_iteratively, 100) 574 | else: 575 | self.projects_info.refresh_projects() 576 | 577 | if len(projects_to_remove) > 0: 578 | sublime.set_timeout(remove_projects_iteratively, 100) 579 | else: 580 | sublime.message_dialog('No Dead Projects.') 581 | 582 | def edit_project(self, project): 583 | def on_open(): 584 | self.window.open_file(self.project_file_name(project)) 585 | sublime.set_timeout_async(on_open, 100) 586 | 587 | def rename_project(self, project): 588 | def rename_callback(new_project): 589 | if project == new_project: 590 | return 591 | pfile = self.project_file_name(project) 592 | wsfile = self.project_workspace(project) 593 | pdir = self.projects_info.which_project_dir(pfile) 594 | if not pdir: 595 | pdir = os.path.dirname(pfile) 596 | new_pfile = os.path.join(pdir, '%s.sublime-project' % new_project) 597 | new_wsfile = re.sub(r'\.sublime-project$', '.sublime-workspace', new_pfile) 598 | 599 | reopen = self.close_project_by_name(project) 600 | os.rename(pfile, new_pfile) 601 | os.rename(wsfile, new_wsfile) 602 | 603 | j = JsonFile(new_wsfile) 604 | data = j.load({}) 605 | if 'project' in data: 606 | data['project'] = '%s.sublime-project' % os.path.basename(new_project) 607 | j.save(data) 608 | 609 | if not self.projects_info.which_project_dir(pfile): 610 | for pdir in self.projects_info.projects_path(): 611 | library = os.path.join(pdir, 'library.json') 612 | if os.path.exists(library): 613 | j = JsonFile(library) 614 | data = j.load([]) 615 | if pfile in data: 616 | data.remove(pfile) 617 | data.append(new_pfile) 618 | j.save(data) 619 | 620 | self.projects_info.refresh_projects() 621 | 622 | if reopen: 623 | self.open_in_new_window(new_project) 624 | 625 | def _ask_project_name(): 626 | v = self.window.show_input_panel('New project name:', 627 | project, 628 | rename_callback, 629 | None, 630 | None) 631 | v.run_command('select_all') 632 | 633 | sublime.set_timeout(_ask_project_name, 100) 634 | 635 | 636 | class ProjectManagerCloseProject(sublime_plugin.WindowCommand): 637 | def run(self): 638 | if self.window.project_file_name(): 639 | # if it is a project, close the project 640 | self.window.run_command('close_workspace') 641 | else: 642 | self.window.run_command('close_all') 643 | # exit if there are dirty views 644 | for v in self.window.views(): 645 | if v.is_dirty(): 646 | return 647 | # close the sidebar 648 | self.window.run_command('close_project') 649 | 650 | 651 | class ProjectManagerEventHandler(sublime_plugin.EventListener): 652 | 653 | def on_window_command(self, window, command_name, args): 654 | if sublime.platform() == "osx": 655 | return 656 | settings = sublime.load_settings(SETTINGS_FILENAME) 657 | if settings.get("close_project_when_close_window", True) and \ 658 | command_name == "close_window": 659 | window.run_command("project_manager_close_project") 660 | 661 | 662 | class ProjectManagerCommand(sublime_plugin.WindowCommand): 663 | manager = None 664 | 665 | def run(self, action=None, caller=None): 666 | self.caller = caller 667 | if action is None: 668 | self.show_options() 669 | return 670 | 671 | if not self.manager: 672 | self.manager = Manager(self.window) 673 | 674 | old_action_mapping = { 675 | "switch": "open_project", 676 | "new": "open_project_in_new_window", 677 | "append": "append_project", 678 | "edit": "edit_project", 679 | "rename": "rename_project", 680 | "remove": "remove_project" 681 | } 682 | try: 683 | action = old_action_mapping[action] 684 | except KeyError: 685 | pass 686 | 687 | getattr(self, action)() 688 | 689 | def show_options(self): 690 | items = [ 691 | ['Open Project', 'Open project in the current window'], 692 | ['Open Project in New Window', 'Open project in a new window'], 693 | ['Append Project', 'Append project to current window'], 694 | ['Edit Project', 'Edit project settings'], 695 | ['Rename Project', 'Rename project'], 696 | ['Remove Project', 'Remove from Project Manager'], 697 | ['Add New Project', 'Add current folders to Project Manager'], 698 | ['Import Project', 'Import current .sublime-project file'], 699 | ['Refresh Projects', 'Refresh Projects'], 700 | ['Clear Recent Projects', 'Clear Recent Projects'], 701 | ['Remove Dead Projects', 'Remove Dead Projects'] 702 | ] 703 | 704 | actions = [ 705 | "open_project", 706 | "open_in_new_window", 707 | "append_project", 708 | "edit_project", 709 | "rename_project", 710 | "remove_project", 711 | "add_project", 712 | "import_sublime_project", 713 | "refresh_projects", 714 | "clear_recent_projects", 715 | "remove_dead_projects" 716 | ] 717 | 718 | def callback(i): 719 | if i < 0: 720 | return 721 | self.run(action=actions[i], caller="manager") 722 | 723 | sublime.set_timeout( 724 | lambda: self.window.show_quick_panel(items, callback), 725 | 100) 726 | 727 | def _prompt_project(self, callback): 728 | self.manager.prompt_project(callback, on_cancel=self._on_cancel) 729 | 730 | def _on_cancel(self): 731 | if self.caller == "manager": 732 | sublime.set_timeout(self.run, 100) 733 | 734 | def open_project(self): 735 | self._prompt_project(self.manager.switch_project) 736 | 737 | def open_project_in_new_window(self): 738 | self._prompt_project(self.manager.open_in_new_window) 739 | 740 | def append_project(self): 741 | self._prompt_project(self.manager.append_project) 742 | 743 | def edit_project(self): 744 | self._prompt_project(self.manager.edit_project) 745 | 746 | def rename_project(self): 747 | self._prompt_project(self.manager.rename_project) 748 | 749 | def remove_project(self): 750 | self._prompt_project(self.manager.remove_project) 751 | 752 | def add_project(self): 753 | self.manager.add_project(on_cancel=self._on_cancel) 754 | 755 | def import_sublime_project(self): 756 | self.manager.import_sublime_project(on_cancel=self._on_cancel) 757 | 758 | def refresh_projects(self): 759 | self.manager.projects_info.refresh_projects() 760 | 761 | def clear_recent_projects(self): 762 | self.manager.clear_recent_projects() 763 | 764 | def remove_dead_projects(self): 765 | self.manager.clean_dead_projects() 766 | --------------------------------------------------------------------------------