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.

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