fsbrowser.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. from functools import lru_cache
  2. import logging
  3. from pathlib import Path
  4. from types import MappingProxyType
  5. from typing import List
  6. from PySide6.QtCore import (
  7. QFileSystemWatcher,
  8. QObject,
  9. Qt,
  10. QTimer,
  11. Signal,
  12. Slot,
  13. )
  14. from PySide6.QtGui import QAction
  15. from PySide6.QtWidgets import (
  16. QMenu,
  17. QTreeWidget,
  18. )
  19. from datalad.interface.base import Interface
  20. from datalad.utils import get_dataset_root
  21. from datalad.dataset.gitrepo import GitRepo
  22. from .cmd_actions import add_cmd_actions_to_menu
  23. from .fsbrowser_item import FSBrowserItem
  24. from .lsdir import GooeyLsDir
  25. from .status_light import GooeyStatusLight
  26. lgr = logging.getLogger('datalad.gooey.fsbrowser')
  27. class GooeyFilesystemBrowser(QObject):
  28. # TODO Establish ENUM for columns
  29. # FSBrowserItem
  30. item_requires_annotation = Signal(FSBrowserItem)
  31. # DONE
  32. def __init__(self, app, treewidget: QTreeWidget):
  33. super().__init__()
  34. tw = treewidget
  35. # disable until set_root() was called
  36. tw.setDisabled(True)
  37. self._tree = tw
  38. # TODO must setColumnNumber()
  39. self._app = app
  40. self._fswatcher = QFileSystemWatcher(parent=app)
  41. self.item_requires_annotation.connect(
  42. self._queue_item_for_annotation)
  43. tw.setHeaderLabels(['Name', 'Type', 'State'])
  44. # established defined sorting order of the tree
  45. tw.sortItems(1, Qt.AscendingOrder)
  46. tw.customContextMenuRequested.connect(
  47. self._custom_context_menu)
  48. # whenever a treeview node is expanded, add the path to the fswatcher
  49. tw.itemExpanded.connect(self._watch_dir)
  50. # and also populate it with items for contained paths
  51. tw.itemExpanded.connect(self._populate_item)
  52. tw.itemCollapsed.connect(self._unwatch_dir)
  53. self._fswatcher.directoryChanged.connect(self._inspect_changed_dir)
  54. # items of directories to be annotated, populated by
  55. # _queue_item_for_annotation()
  56. self._annotation_queue = set()
  57. # msec
  58. self._annotation_timer_interval = 3000
  59. self._annotation_timer = QTimer(self)
  60. self._annotation_timer.timeout.connect(
  61. self._process_item_annotation_queue)
  62. self._annotation_timer.start(self._annotation_timer_interval)
  63. self._app._cmdexec.results_received.connect(
  64. self._cmdexec_results_handler)
  65. def set_root(self, path):
  66. tw = self._tree
  67. # wipe the previous state
  68. tw.clear()
  69. # TODO stop any pending annotations
  70. # establish the root item, based on a fake lsdir result
  71. # the info needed is so simple, it is not worth a command
  72. # execution
  73. root = FSBrowserItem.from_lsdir_result(
  74. dict(
  75. path=path,
  76. type='dataset' if GitRepo.is_valid(path) else 'directory',
  77. ),
  78. parent=tw,
  79. )
  80. # set the tooltip to the full path, otherwise only names are shown
  81. root.setToolTip(0, str(path))
  82. tw.addTopLevelItem(root)
  83. self._root_item = root
  84. tw.setEnabled(True)
  85. def _populate_item(self, item):
  86. if item.childCount():
  87. return
  88. # only parse, if there are no children yet
  89. # kick off lsdir command in the background
  90. self._populate_and_annotate(item, no_existing_children=True)
  91. def _populate_and_annotate(self, item, no_existing_children):
  92. self._app.execute_dataladcmd.emit(
  93. 'gooey_lsdir',
  94. MappingProxyType(dict(
  95. path=item.pathobj,
  96. result_renderer='disabled',
  97. on_failure='ignore',
  98. return_type='generator',
  99. )),
  100. MappingProxyType(dict(
  101. preferred_result_interval=0.2,
  102. result_override=dict(
  103. gooey_parent_item=item,
  104. gooey_no_existing_item=no_existing_children,
  105. ),
  106. )),
  107. )
  108. # for now we register the parent for an annotation update
  109. # but we could also report the specific path and let the
  110. # annotation code figure out the optimal way.
  111. # at present however, we get here for items of a whole dir
  112. # being reported at once.
  113. self._queue_item_for_annotation(item)
  114. @Slot(Interface, list)
  115. def _cmdexec_results_handler(self, cls, res):
  116. res_handler = None
  117. if cls == GooeyLsDir:
  118. res_handler = self._lsdir_result_receiver
  119. elif cls == GooeyStatusLight:
  120. res_handler = self._status_result_receiver
  121. else:
  122. lgr.debug('FSBrowser has no handler for result from %s', cls)
  123. return
  124. for r in res:
  125. res_handler(r)
  126. def _lsdir_result_receiver(self, res):
  127. if res.get('action') != 'gooey-lsdir':
  128. # no what we are looking for
  129. return
  130. target_item = None
  131. target_item_parent = res.get('gooey_parent_item')
  132. no_existing_item = res.get('gooey_no_existing_item', False)
  133. ipath = Path(res['path'])
  134. if target_item_parent is None:
  135. # we did not get it delivered in the result, search for it
  136. try:
  137. target_item_parent = self._get_item_from_path(ipath.parent)
  138. except ValueError:
  139. # ok, now we have no clue what this lsdir result is about
  140. # its parent is no in the tree
  141. return
  142. if (no_existing_item and target_item_parent
  143. and target_item_parent.pathobj == ipath):
  144. # sender claims that the item does not exist and provided a parent
  145. # item. reject a result if it matches the parent to avoid
  146. # duplicating the item as a child, and to also prevent an unintended
  147. # item update
  148. return
  149. if not no_existing_item:
  150. # we have no indication that the item this is about does not
  151. # already exist, search for it
  152. try:
  153. # give the parent as a starting item, to speed things up
  154. target_item = self._get_item_from_path(
  155. ipath, target_item_parent)
  156. except ValueError:
  157. # it is quite possible that the item does not exist yet.
  158. # but such cases are expensive, and the triggering code could
  159. # consider sending the 'gooey_no_existing_item' flag
  160. pass
  161. if target_item is None:
  162. # we don't have such an item yet -> make one
  163. target_item = FSBrowserItem.from_lsdir_result(
  164. res, target_item_parent)
  165. else:
  166. # we do have this already, good occasion to update it
  167. target_item.update_from_lsdir_result(res)
  168. @lru_cache(maxsize=1000)
  169. def _get_item_from_path(self, path: Path, root: FSBrowserItem = None):
  170. # this is a key function in terms of result UI snappiness
  171. # it must be as fast as anyhow possible
  172. item = self._root_item if root is None else root
  173. ipath = item.pathobj
  174. if path == ipath:
  175. return item
  176. # otherwise look for the item with the right name at the
  177. # respective level
  178. try:
  179. return self._get_item_from_trace(
  180. item, path.relative_to(ipath).parts)
  181. except ValueError as e:
  182. raise ValueError(f'Cannot find item for {path}') from e
  183. def _get_item_from_trace(self, root: FSBrowserItem, trace: List):
  184. item = root
  185. for p in trace:
  186. item = item[p]
  187. if item is None:
  188. raise ValueError(f'Cannot find item for {trace}')
  189. continue
  190. return item
  191. def _queue_item_for_annotation(self, item):
  192. """This is not thread-safe
  193. `item` should be of type 'directory' or 'dataset' for meaningful
  194. behavior.
  195. """
  196. # wait for at least half a sec longer after a new request came in
  197. # to avoid DDOS'ing the facility?
  198. if self._annotation_timer.remainingTime() < 500:
  199. self._annotation_timer.start(500)
  200. self._annotation_queue.add(item)
  201. def _process_item_annotation_queue(self):
  202. if not self._annotation_queue:
  203. return
  204. if self._app._cmdexec.n_running:
  205. # stuff is still running
  206. # make sure the population of the tree items is done too!
  207. self._annotation_timer.start(1000)
  208. return
  209. # there is stuff to annotate, make sure we do not trigger more
  210. # annotations while this one is running
  211. self._annotation_timer.stop()
  212. print("ANNOTATE!", len(self._annotation_queue))
  213. # TODO stuff could be optimized here: collapsing multiple
  214. # directories belonging to the same dataset into a single `status`
  215. # call...
  216. while self._annotation_queue:
  217. # process the queue in reverse order, assuming a user would be
  218. # interested in the last triggered directory first
  219. # (i.e., assumption is: expanding tree nodes one after
  220. # another, attention would be on the last expanded one, not the
  221. # first)
  222. item = self._annotation_queue.pop()
  223. print('->', item)
  224. ipath = item.pathobj
  225. dsroot = get_dataset_root(ipath)
  226. if dsroot is None:
  227. # no containing dataset, by definition everything is untracked
  228. for child in item.children_():
  229. # get type, only annotate non-directory items
  230. if child.datalad_type != 'directory':
  231. child.update_from_status_result(
  232. dict(state='untracked'))
  233. else:
  234. # trigger datalad-gooey-status-light execution
  235. # giving the target directory as a `path` argument should
  236. # avoid undesired recursion into subDIRECTORIES
  237. paths_to_investigate = [
  238. c.pathobj.relative_to(dsroot)
  239. for c in item.children_()
  240. if c.datalad_type != 'directory'
  241. ]
  242. if paths_to_investigate:
  243. # do not run, if there are no relevant paths to inspect
  244. self._app.execute_dataladcmd.emit(
  245. 'gooey_status_light',
  246. MappingProxyType(dict(
  247. dataset=dsroot,
  248. path=[ipath],
  249. #annex='basic',
  250. result_renderer='disabled',
  251. on_failure='ignore',
  252. return_type='generator',
  253. )),
  254. MappingProxyType(dict(
  255. preferred_result_interval=3.0,
  256. result_override=dict(
  257. gooey_parent_item=item,
  258. ),
  259. )),
  260. )
  261. # restart annotation watcher
  262. self._annotation_timer.start(self._annotation_timer_interval)
  263. def _status_result_receiver(self, res):
  264. if res.get('action') != 'status':
  265. # no what we are looking for
  266. return
  267. path = res.get('path')
  268. if path is None:
  269. # nothing that we could handle
  270. return
  271. try:
  272. target_item = self._get_item_from_trace(
  273. res['gooey_parent_item'],
  274. # the parent will only ever be the literal parent directory
  275. [Path(path).name],
  276. )
  277. except ValueError:
  278. # the corersponding item is no longer around
  279. return
  280. target_item.update_from_status_result(res)
  281. # DONE
  282. def _watch_dir(self, item):
  283. path = item.pathobj
  284. lgr.log(
  285. 9,
  286. "GooeyFilesystemBrowser._watch_dir(%r)",
  287. path,
  288. )
  289. self._fswatcher.addPath(str(path))
  290. if item.datalad_type == 'dataset':
  291. # for a repository, also watch its .git to become aware of more
  292. # Git operation outcomes. specifically watch the HEADS to catch
  293. # updates on any branch
  294. self._fswatcher.addPath(str(path / '.git' / 'refs' / 'heads'))
  295. # DONE
  296. # https://github.com/datalad/datalad-gooey/issues/50
  297. def _unwatch_dir(self, item):
  298. path = str(item.pathobj)
  299. lgr.log(
  300. 9,
  301. "GooeyFilesystemBrowser._unwatch_dir(%r) -> %r",
  302. path,
  303. self._fswatcher.removePath(path),
  304. )
  305. # DONE
  306. def _inspect_changed_dir(self, path: str):
  307. pathobj = Path(path)
  308. # look for special case of the internals of a dataset having changed
  309. path_parts = pathobj.parts
  310. if len(path_parts) > 3 \
  311. and path_parts[-3:] == ('.git', 'refs', 'heads'):
  312. # yep, happened -- inspect corresponding dataset root
  313. self._inspect_changed_dir(pathobj.parent.parent.parent)
  314. return
  315. lgr.log(9, "GooeyFilesystemBrowser._inspect_changed_dir(%r)", pathobj)
  316. # we need to know the item in the tree corresponding
  317. # to the changed directory
  318. try:
  319. item = self._get_item_from_path(pathobj)
  320. except ValueError:
  321. # the changed dir has no (longer) a matching entry in the
  322. # tree model. make sure to take it off the watch list
  323. self._fswatcher.removePath(path)
  324. lgr.log(9, "_inspect_changed_dir() -> not in view (anymore), "
  325. "removed from watcher")
  326. return
  327. parent = item.parent()
  328. if not pathobj.exists():
  329. if parent is None:
  330. # TODO we could have lost the root dir -> special action
  331. raise NotImplementedError
  332. parent.removeChild(item)
  333. lgr.log(8, "-> _inspect_changed_dir() -> item removed")
  334. return
  335. # we will kick off a `lsdir` run to update the widget, but it could
  336. # no detect item that no longer have a file system counterpart
  337. # so we remove them here and now
  338. for child in item.children_():
  339. try:
  340. # same as lexists() but with pathlib
  341. child.pathobj.lstat()
  342. except (OSError, ValueError):
  343. item.removeChild(child)
  344. # now re-explore
  345. self._populate_and_annotate(item, no_existing_children=False)
  346. lgr.log(9, "_inspect_changed_dir() -> requested update")
  347. # DONE
  348. def _custom_context_menu(self, onpoint):
  349. """Present a context menu for the item click in the directory browser
  350. """
  351. # get the tree item for the coordinate that received the
  352. # context menu request
  353. item = self._tree.itemAt(onpoint)
  354. if not item:
  355. # prevent context menus when the request did not actually
  356. # land on an item
  357. return
  358. # what kind of path is this item representing
  359. path_type = item.datalad_type
  360. if path_type is None:
  361. # we don't know what to do with this (but it also is not expected
  362. # to happen)
  363. return
  364. ipath = item.pathobj
  365. cmdkwargs = dict()
  366. context = QMenu(parent=self._tree)
  367. def _check_add_api_submenu(title, api):
  368. if not api:
  369. return
  370. submenu = context.addMenu(title)
  371. add_cmd_actions_to_menu(
  372. self._tree, self._app._cmdui.configure,
  373. api,
  374. submenu,
  375. cmdkwargs,
  376. )
  377. if path_type == 'dataset':
  378. cmdkwargs['dataset'] = ipath
  379. from .active_suite import dataset_api
  380. _check_add_api_submenu('Dataset commands', dataset_api)
  381. elif path_type == 'directory':
  382. dsroot = get_dataset_root(ipath)
  383. # path the directory path to the command's `path` argument
  384. cmdkwargs['path'] = ipath
  385. if dsroot:
  386. # also pass dsroot
  387. cmdkwargs['dataset'] = dsroot
  388. from .active_suite import directory_in_ds_api as cmdapi
  389. else:
  390. from .active_suite import directory_api as cmdapi
  391. _check_add_api_submenu('Directory commands', cmdapi)
  392. elif path_type in ('file', 'symlink', 'annexed-file'):
  393. dsroot = get_dataset_root(ipath)
  394. cmdkwargs['path'] = ipath
  395. if dsroot:
  396. cmdkwargs['dataset'] = dsroot
  397. if path_type == 'annexed-file':
  398. from .active_suite import annexed_file_api as cmdapi
  399. else:
  400. from .active_suite import file_in_ds_api as cmdapi
  401. else:
  402. from .active_suite import file_api as cmdapi
  403. _check_add_api_submenu('File commands', cmdapi)
  404. if path_type in ('directory', 'dataset'):
  405. setbase = QAction('Set &base directory here', context)
  406. setbase.setData(ipath)
  407. setbase.triggered.connect(self._app._set_root_path)
  408. context.addAction(setbase)
  409. if not context.isEmpty():
  410. # present the menu at the clicked point
  411. context.exec(self._tree.viewport().mapToGlobal(onpoint))