fsbrowser.py 16 KB

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