Project Manager for Sublime Text 3 . mirror of https://github.com/budRich/ProjectManager
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

603 lines
22 KiB

  1. import sublime
  2. import sublime_plugin
  3. import subprocess
  4. import os
  5. import platform
  6. import re
  7. import operator
  8. from .json_file import JsonFile
  9. def subl(*args):
  10. executable_path = sublime.executable_path()
  11. if sublime.platform() == 'osx':
  12. app_path = executable_path[:executable_path.rfind('.app/') + 5]
  13. executable_path = app_path + 'Contents/SharedSupport/bin/subl'
  14. subprocess.Popen([executable_path] + list(args))
  15. def on_activated():
  16. window = sublime.active_window()
  17. view = window.active_view()
  18. if sublime.platform() == 'windows':
  19. # fix focus on windows
  20. window.run_command('focus_neighboring_group')
  21. window.focus_view(view)
  22. sublime_plugin.on_activated(view.id())
  23. sublime_plugin.on_activated_async(view.id())
  24. sublime.set_timeout(on_activated, 300)
  25. def expand_path(path, relative_to=None):
  26. root = None
  27. if relative_to:
  28. if os.path.isfile(relative_to):
  29. root = os.path.dirname(relative_to)
  30. elif os.path.isdir(relative_to):
  31. root = relative_to
  32. if path:
  33. path = os.path.expanduser(path)
  34. if root and not os.path.isabs(path):
  35. path = os.path.normpath(os.path.join(root, path))
  36. return path
  37. def pretty_path(path):
  38. user_home = os.path.expanduser('~') + os.sep
  39. if path and path.startswith(user_home):
  40. path = os.path.join("~", path[len(user_home):])
  41. return path
  42. def itemgetter(*index):
  43. """
  44. A version of itemgetter returning a list
  45. """
  46. def _itemgetter(a):
  47. _ret = operator.itemgetter(*index)(a)
  48. if len(index) > 1:
  49. _ret = list(_ret)
  50. return _ret
  51. return _itemgetter
  52. def computer_name():
  53. if sublime.platform() == 'osx':
  54. node = subprocess.check_output(['scutil', '--get', 'ComputerName']).decode().strip()
  55. else:
  56. node = platform.node().split('.')[0]
  57. return node
  58. def dont_close_windows_when_empty(func):
  59. def f(*args, **kwargs):
  60. s = sublime.load_settings('Preferences.sublime-settings')
  61. close_windows_when_empty = s.get('close_windows_when_empty')
  62. s.set('close_windows_when_empty', False)
  63. func(*args, **kwargs)
  64. if close_windows_when_empty:
  65. sublime.set_timeout(
  66. lambda: s.set('close_windows_when_empty', close_windows_when_empty),
  67. 1000)
  68. return f
  69. class Manager:
  70. def __init__(self, window):
  71. self.window = window
  72. s = 'project_manager.sublime-settings'
  73. self.settings = sublime.load_settings(s)
  74. default_projects_dir = os.path.join(
  75. sublime.packages_path(), 'User', 'Projects')
  76. user_projects_dirs = self.settings.get('projects_path')
  77. self.projects_path = []
  78. for folder in user_projects_dirs:
  79. if os.path.isdir(os.path.expanduser(folder)):
  80. self.projects_path.append(folder)
  81. if not self.projects_path:
  82. self.projects_path = [default_projects_dir]
  83. self.projects_path = [
  84. os.path.normpath(os.path.expanduser(d)) for d in self.projects_path]
  85. node = computer_name()
  86. if self.settings.get('use_local_projects_dir', False):
  87. self.projects_path = \
  88. [d + ' - ' + node for d in self.projects_path] + self.projects_path
  89. self.primary_dir = self.projects_path[0]
  90. if not os.path.isdir(self.primary_dir):
  91. os.makedirs(self.primary_dir)
  92. self.projects_info = self.get_all_projects_info()
  93. def load_sublime_project_files(self, folder):
  94. pfiles = []
  95. for path, dirs, files in os.walk(folder, followlinks=True):
  96. for f in files:
  97. f = os.path.join(path, f)
  98. if f.endswith('.sublime-project') and f not in pfiles:
  99. pfiles.append(os.path.normpath(f))
  100. # remove empty directories
  101. for d in dirs:
  102. d = os.path.join(path, d)
  103. if len(os.listdir(d)) == 0:
  104. os.rmdir(d)
  105. return pfiles
  106. def load_library(self, folder):
  107. pfiles = []
  108. library = os.path.join(folder, 'library.json')
  109. if os.path.exists(library):
  110. j = JsonFile(library)
  111. for f in j.load([]):
  112. pfile = expand_path(f)
  113. if os.path.exists(pfile) and pfile not in pfiles:
  114. pfiles.append(os.path.normpath(pfile))
  115. pfiles.sort()
  116. j.save(pfiles)
  117. return pfiles
  118. def get_info_from_project_file(self, pfile):
  119. pdir = self.which_project_dir(pfile)
  120. info = {}
  121. basename = os.path.relpath(pfile, pdir) if pdir else os.path.basename(pfile)
  122. pname = re.sub('\.sublime-project$', '', basename)
  123. pd = JsonFile(pfile).load()
  124. if pd and 'folders' in pd and pd['folders']:
  125. folder = expand_path(pd['folders'][0].get('path', ''), relative_to=pfile)
  126. else:
  127. folder = ''
  128. info["name"] = pname
  129. info["folder"] = folder
  130. info["file"] = pfile
  131. return info
  132. def mark_open_projects(self, all_info):
  133. project_file_names = [
  134. os.path.realpath(w.project_file_name())
  135. for w in sublime.windows() if w.project_file_name()]
  136. for v in all_info.values():
  137. if os.path.realpath(v["file"]) in project_file_names:
  138. v["star"] = True
  139. def get_all_projects_info(self):
  140. all_info = {}
  141. for pdir in self.projects_path:
  142. for f in self.load_library(pdir):
  143. info = self.get_info_from_project_file(f)
  144. info["type"] = "library"
  145. all_info[info["name"]] = info
  146. for f in self.load_sublime_project_files(pdir):
  147. info = self.get_info_from_project_file(f)
  148. info["type"] = "sublime-project"
  149. all_info[info["name"]] = info
  150. self.mark_open_projects(all_info)
  151. return all_info
  152. def which_project_dir(self, pfile):
  153. for pdir in self.projects_path:
  154. if (os.path.realpath(os.path.dirname(pfile)) + os.path.sep).startswith(
  155. os.path.realpath(pdir) + os.path.sep):
  156. return pdir
  157. return None
  158. def render_display_item(self, item):
  159. project_name, info = item
  160. active_project_indicator = str(self.settings.get('active_project_indicator', '*'))
  161. display_format = str(self.settings.get(
  162. 'project_display_format', '{project_name}{active_project_indicator}'))
  163. if "star" in info:
  164. display_name = display_format.format(
  165. project_name=project_name, active_project_indicator=active_project_indicator)
  166. else:
  167. display_name = display_format.format(
  168. project_name=project_name, active_project_indicator='')
  169. return [
  170. project_name,
  171. display_name.strip(),
  172. pretty_path(info['folder']),
  173. pretty_path(info['file'])]
  174. def display_projects(self):
  175. plist = list(map(self.render_display_item, self.projects_info.items()))
  176. plist.sort(key=lambda p: p[0])
  177. if self.settings.get('show_recent_projects_first', True):
  178. self.move_recent_projects_to_top(plist)
  179. if self.settings.get('show_active_projects_first', True):
  180. self.move_openning_projects_to_top(plist)
  181. return list(map(itemgetter(0), plist)), list(map(itemgetter(1, 2), plist))
  182. def move_recent_projects_to_top(self, plist):
  183. j = JsonFile(os.path.join(self.primary_dir, 'recent.json'))
  184. recent = j.load([])
  185. # TODO: it is not needed
  186. recent = [pretty_path(p) for p in recent]
  187. return plist.sort(
  188. key=lambda p: recent.index(p[3]) if p[3] in recent else -1,
  189. reverse=True)
  190. def move_openning_projects_to_top(self, plist):
  191. count = 0
  192. for i in range(len(plist)):
  193. if plist[i][0] != plist[i][1]:
  194. plist.insert(count, plist.pop(i))
  195. count = count + 1
  196. def project_file_name(self, project):
  197. return self.projects_info[project]['file']
  198. def project_workspace(self, project):
  199. return re.sub('\.sublime-project$',
  200. '.sublime-workspace',
  201. self.project_file_name(project))
  202. def update_recent(self, project):
  203. j = JsonFile(os.path.join(self.primary_dir, 'recent.json'))
  204. recent = j.load([])
  205. # TODO: it is not needed
  206. recent = [pretty_path(p) for p in recent]
  207. pname = pretty_path(self.project_file_name(project))
  208. if pname not in recent:
  209. recent.append(pname)
  210. else:
  211. recent.append(recent.pop(recent.index(pname)))
  212. # only keep the most recent 50 records
  213. if len(recent) > 50:
  214. recent = recent[(50 - len(recent)):len(recent)]
  215. j.save(recent)
  216. def clear_recent_projects(self):
  217. def clear_callback():
  218. answer = sublime.ok_cancel_dialog('Clear Recent Projects?')
  219. if answer is True:
  220. j = JsonFile(os.path.join(self.primary_dir, 'recent.json'))
  221. j.remove()
  222. self.window.run_command("clear_recent_projects_and_workspaces")
  223. sublime.set_timeout(clear_callback, 100)
  224. def get_project_data(self, project):
  225. return JsonFile(self.project_file_name(project)).load()
  226. def check_project(self, project):
  227. wsfile = self.project_workspace(project)
  228. j = JsonFile(wsfile)
  229. if not os.path.exists(wsfile):
  230. j.save({})
  231. def close_project_by_window(self, window):
  232. window.run_command('close_workspace')
  233. def close_project_by_name(self, project):
  234. pfile = os.path.realpath(self.project_file_name(project))
  235. for w in sublime.windows():
  236. if w.project_file_name():
  237. if os.path.realpath(w.project_file_name()) == pfile:
  238. self.close_project_by_window(w)
  239. if w.id() != sublime.active_window().id():
  240. w.run_command('close_window')
  241. return True
  242. return False
  243. def add_project(self):
  244. def add_callback(project):
  245. pd = self.window.project_data()
  246. pf = self.window.project_file_name()
  247. pfile = os.path.join(self.primary_dir, '%s.sublime-project' % project)
  248. if pd:
  249. if "folders" in pd:
  250. for folder in pd["folders"]:
  251. if "path" in folder:
  252. path = folder["path"]
  253. if sublime.platform() == "windows":
  254. folder["path"] = expand_path(path, relative_to=pf)
  255. else:
  256. folder["path"] = pretty_path(
  257. expand_path(path, relative_to=pf))
  258. JsonFile(pfile).save(pd)
  259. else:
  260. JsonFile(pfile).save({})
  261. # create workspace file
  262. wsfile = re.sub('\.sublime-project$', '.sublime-workspace', pfile)
  263. if not os.path.exists(wsfile):
  264. JsonFile(wsfile).save({})
  265. self.close_project_by_window(self.window)
  266. # nuke the current window by closing sidebar and all files
  267. self.window.run_command('close_project')
  268. self.window.run_command('close_all')
  269. # reload projects info
  270. self.__init__(self.window)
  271. self.switch_project(project)
  272. def show_input_panel():
  273. project = 'New Project'
  274. pd = self.window.project_data()
  275. pf = self.window.project_file_name()
  276. try:
  277. path = pd['folders'][0]['path']
  278. project = os.path.basename(expand_path(path, relative_to=pf))
  279. except Exception:
  280. pass
  281. v = self.window.show_input_panel('Project name:',
  282. project,
  283. add_callback,
  284. None,
  285. None)
  286. v.run_command('select_all')
  287. sublime.set_timeout(show_input_panel, 100)
  288. def import_sublime_project(self):
  289. pfile = pretty_path(self.window.project_file_name())
  290. if not pfile:
  291. sublime.message_dialog('Project file not found!')
  292. return
  293. if self.which_project_dir(pfile):
  294. sublime.message_dialog('This project was created by Project Manager!')
  295. return
  296. answer = sublime.ok_cancel_dialog('Import %s?' % os.path.basename(pfile))
  297. if answer is True:
  298. j = JsonFile(os.path.join(self.primary_dir, 'library.json'))
  299. data = j.load([])
  300. if pfile not in data:
  301. data.append(pfile)
  302. j.save(data)
  303. def append_project(self, project):
  304. self.update_recent(project)
  305. pd = self.get_project_data(project)
  306. paths = [expand_path(f.get('path'), self.project_file_name(project))
  307. for f in pd.get('folders')]
  308. subl('-a', *paths)
  309. @dont_close_windows_when_empty
  310. def switch_project(self, project):
  311. self.update_recent(project)
  312. self.check_project(project)
  313. self.close_project_by_window(self.window)
  314. self.close_project_by_name(project)
  315. subl(self.project_file_name(project))
  316. @dont_close_windows_when_empty
  317. def open_in_new_window(self, project):
  318. self.update_recent(project)
  319. self.check_project(project)
  320. self.close_project_by_name(project)
  321. subl('-n', self.project_file_name(project))
  322. def _remove_project(self, project):
  323. answer = sublime.ok_cancel_dialog('Remove "%s" from Project Manager?' % project)
  324. if answer is True:
  325. pfile = self.project_file_name(project)
  326. if self.which_project_dir(pfile):
  327. self.close_project_by_name(project)
  328. os.remove(self.project_file_name(project))
  329. os.remove(self.project_workspace(project))
  330. else:
  331. for pdir in self.projects_path:
  332. j = JsonFile(os.path.join(pdir, 'library.json'))
  333. data = j.load([])
  334. if pfile in data:
  335. data.remove(pfile)
  336. j.save(data)
  337. sublime.status_message('Project "%s" is removed.' % project)
  338. def remove_project(self, project):
  339. sublime.set_timeout(lambda: self._remove_project(project), 100)
  340. def clean_dead_projects(self):
  341. projects_to_remove = []
  342. for pname, pi in self.projects_info.items():
  343. folder = pi['folder']
  344. if not os.path.exists(folder):
  345. projects_to_remove.append(pname)
  346. def remove_projects_iteratively():
  347. pname = projects_to_remove[0]
  348. self._remove_project(pname)
  349. projects_to_remove.remove(pname)
  350. if len(projects_to_remove) > 0:
  351. sublime.set_timeout(remove_projects_iteratively, 100)
  352. if len(projects_to_remove) > 0:
  353. sublime.set_timeout(remove_projects_iteratively, 100)
  354. else:
  355. sublime.message_dialog('No Dead Projects.')
  356. def edit_project(self, project):
  357. def on_open():
  358. self.window.open_file(self.project_file_name(project))
  359. sublime.set_timeout_async(on_open, 100)
  360. def rename_project(self, project):
  361. def rename_callback(new_project):
  362. if project == new_project:
  363. return
  364. pfile = self.project_file_name(project)
  365. wsfile = self.project_workspace(project)
  366. pdir = self.which_project_dir(pfile)
  367. if not pdir:
  368. pdir = os.path.dirname(pfile)
  369. new_pfile = os.path.join(pdir, '%s.sublime-project' % new_project)
  370. new_wsfile = re.sub('\.sublime-project$', '.sublime-workspace', new_pfile)
  371. reopen = self.close_project_by_name(project)
  372. os.rename(pfile, new_pfile)
  373. os.rename(wsfile, new_wsfile)
  374. j = JsonFile(new_wsfile)
  375. data = j.load({})
  376. if 'project' in data:
  377. data['project'] = '%s.sublime-project' % os.path.basename(new_project)
  378. j.save(data)
  379. if not self.which_project_dir(pfile):
  380. for pdir in self.projects_path:
  381. library = os.path.join(pdir, 'library.json')
  382. if os.path.exists(library):
  383. j = JsonFile(library)
  384. data = j.load([])
  385. if pfile in data:
  386. data.remove(pfile)
  387. data.append(new_pfile)
  388. j.save(data)
  389. if reopen:
  390. # reload projects info
  391. self.__init__(self.window)
  392. self.open_in_new_window(new_project)
  393. def show_input_panel():
  394. v = self.window.show_input_panel('New project name:',
  395. project,
  396. rename_callback,
  397. None,
  398. None)
  399. v.run_command('select_all')
  400. sublime.set_timeout(show_input_panel, 100)
  401. def cancellable(func):
  402. def _ret(self, action):
  403. if action >= 0:
  404. func(self, action)
  405. elif action < 0 and self.caller == 'manager':
  406. sublime.set_timeout(self.run, 10)
  407. return _ret
  408. class ProjectManagerCloseProject(sublime_plugin.WindowCommand):
  409. def run(self):
  410. if self.window.project_file_name():
  411. # if it is a project, close the project
  412. self.window.run_command('close_workspace')
  413. else:
  414. self.window.run_command('close_all')
  415. # exit if there are dirty views
  416. for v in self.window.views():
  417. if v.is_dirty():
  418. return
  419. # close the sidebar
  420. self.window.run_command('close_project')
  421. class ProjectManagerEventHandler(sublime_plugin.EventListener):
  422. def on_window_command(self, window, command_name, args):
  423. if sublime.platform() == "osx":
  424. return
  425. settings = sublime.load_settings('project_manager.sublime-settings')
  426. if settings.get("close_project_when_close_window", True) and \
  427. command_name == "close_window":
  428. window.run_command("project_manager_close_project")
  429. class ProjectManager(sublime_plugin.WindowCommand):
  430. def show_quick_panel(self, items, on_done):
  431. sublime.set_timeout(
  432. lambda: self.window.show_quick_panel(items, on_done),
  433. 10)
  434. def run(self, action=None, caller=None):
  435. self.manager = Manager(self.window)
  436. if action is None:
  437. self.show_options()
  438. elif action == 'edit_current':
  439. for p in self.manager.projects_info.items():
  440. project_name, info = p
  441. if "star" in info:
  442. self.manager.edit_project(project_name)
  443. elif action == 'add_project':
  444. self.manager.add_project()
  445. elif action == 'import_sublime_project':
  446. self.manager.import_sublime_project()
  447. elif action == 'clear_recent_projects':
  448. self.manager.clear_recent_projects()
  449. elif action == 'remove_dead_projects':
  450. self.manager.clean_dead_projects()
  451. else:
  452. self.caller = caller
  453. callback = eval('self.on_' + action)
  454. self.projects, display = self.manager.display_projects()
  455. if not self.projects:
  456. sublime.message_dialog('Project list is empty.')
  457. return
  458. self.show_quick_panel(display, callback)
  459. def show_options(self):
  460. items = [
  461. ['Open Project', 'Open project in the current window'],
  462. ['Open Project in New Window', 'Open project in a new window'],
  463. ['Append Project', 'Append project to current window'],
  464. ['Edit Project', 'Edit project settings'],
  465. ['Rename Project', 'Rename project'],
  466. ['Remove Project', 'Remove from Project Manager'],
  467. ['Add New Project', 'Add current folders to Project Manager'],
  468. ['Import Project', 'Import current .sublime-project file'],
  469. ['Clear Recent Projects', 'Clear Recent Projects'],
  470. ['Remove Dead Projects', 'Remove Dead Projects']
  471. ]
  472. def callback(a):
  473. if a < 0:
  474. return
  475. elif a <= 5:
  476. actions = ['switch', 'new', 'append', 'edit', 'rename', 'remove']
  477. self.run(action=actions[a], caller='manager')
  478. elif a == 6:
  479. self.run(action='add_project')
  480. elif a == 7:
  481. self.run(action='import_sublime_project')
  482. elif a == 8:
  483. self.run(action='clear_recent_projects')
  484. elif a == 9:
  485. self.run(action='remove_dead_projects')
  486. self.show_quick_panel(items, callback)
  487. @cancellable
  488. def on_new(self, action):
  489. self.manager.open_in_new_window(self.projects[action])
  490. @cancellable
  491. def on_switch(self, action):
  492. self.manager.switch_project(self.projects[action])
  493. @cancellable
  494. def on_append(self, action):
  495. self.manager.append_project(self.projects[action])
  496. @cancellable
  497. def on_remove(self, action):
  498. self.manager.remove_project(self.projects[action])
  499. @cancellable
  500. def on_edit(self, action):
  501. self.manager.edit_project(self.projects[action])
  502. @cancellable
  503. def on_rename(self, action):
  504. self.manager.rename_project(self.projects[action])