1import datetime 2import logging 3import os.path 4import subprocess 5import time 6import tkinter as tk 7from tkinter import messagebox, ttk 8 9from thonny import get_runner, get_workbench, misc_utils, tktextext 10from thonny.common import InlineCommand, get_dirs_children_info 11from thonny.languages import tr 12from thonny.misc_utils import running_on_windows, sizeof_fmt, running_on_mac_os 13from thonny.ui_utils import ( 14 CommonDialog, 15 create_string_var, 16 lookup_style_option, 17 scrollbar_style, 18 show_dialog, 19 ask_string, 20 ask_one_from_choices, 21) 22 23_dummy_node_text = "..." 24 25_LOCAL_FILES_ROOT_TEXT = "" # needs to be initialized later 26ROOT_NODE_ID = "" 27 28HIDDEN_FILES_OPTION = "file.show_hidden_files" 29 30logger = logging.getLogger(__name__) 31 32 33class BaseFileBrowser(ttk.Frame): 34 def __init__(self, master, show_expand_buttons=True): 35 self.show_expand_buttons = show_expand_buttons 36 self._cached_child_data = {} 37 self.path_to_highlight = None 38 39 ttk.Frame.__init__(self, master, borderwidth=0, relief="flat") 40 self.vert_scrollbar = ttk.Scrollbar( 41 self, orient=tk.VERTICAL, style=scrollbar_style("Vertical") 42 ) 43 self.vert_scrollbar.grid(row=0, column=1, sticky=tk.NSEW, rowspan=3) 44 45 tktextext.fixwordbreaks(tk._default_root) 46 self.building_breadcrumbs = False 47 self.init_header(row=0, column=0) 48 49 spacer = ttk.Frame(self, height=1) 50 spacer.grid(row=1, sticky="nsew") 51 52 self.tree = ttk.Treeview( 53 self, 54 columns=["#0", "kind", "path", "name", "modified", "size"], 55 displaycolumns=( 56 # 4, 57 # 5 58 ), 59 yscrollcommand=self.vert_scrollbar.set, 60 selectmode="extended", 61 ) 62 self.tree.grid(row=2, column=0, sticky=tk.NSEW) 63 self.vert_scrollbar["command"] = self.tree.yview 64 self.columnconfigure(0, weight=1) 65 self.rowconfigure(2, weight=1) 66 67 self.tree["show"] = "tree" 68 69 self.tree.bind("<3>", self.on_secondary_click, True) 70 if misc_utils.running_on_mac_os(): 71 self.tree.bind("<2>", self.on_secondary_click, True) 72 self.tree.bind("<Control-1>", self.on_secondary_click, True) 73 self.tree.bind("<Double-Button-1>", self.on_double_click, True) 74 self.tree.bind("<<TreeviewOpen>>", self.on_open_node) 75 76 wb = get_workbench() 77 self.folder_icon = wb.get_image("folder") 78 self.python_file_icon = wb.get_image("python-icon") 79 self.text_file_icon = wb.get_image("text-file") 80 self.generic_file_icon = wb.get_image("generic-file") 81 self.hard_drive_icon = wb.get_image("hard-drive") 82 83 self.tree.column("#0", width=200, anchor=tk.W) 84 self.tree.heading("#0", text=tr("Name"), anchor=tk.W) 85 self.tree.column("modified", width=60, anchor=tk.W) 86 self.tree.heading("modified", text=tr("Modified"), anchor=tk.W) 87 self.tree.column("size", width=40, anchor=tk.E) 88 self.tree.heading("size", text=tr("Size (bytes)"), anchor=tk.E) 89 self.tree.column("kind", width=30, anchor=tk.W) 90 # self.tree.heading("kind", text="Kind") 91 # self.tree.column("path", width=300, anchor=tk.W) 92 # self.tree.heading("path", text="path") 93 # self.tree.column("name", width=60, anchor=tk.W) 94 # self.tree.heading("name", text="name") 95 96 # set-up root node 97 self.tree.set(ROOT_NODE_ID, "kind", "root") 98 self.menu = tk.Menu(self.tree, tearoff=False) 99 self.current_focus = None 100 101 def init_header(self, row, column): 102 header_frame = ttk.Frame(self, style="ViewToolbar.TFrame") 103 header_frame.grid(row=row, column=column, sticky="nsew") 104 header_frame.columnconfigure(0, weight=1) 105 106 self.path_bar = tktextext.TweakableText( 107 header_frame, 108 borderwidth=0, 109 relief="flat", 110 height=1, 111 font="TkDefaultFont", 112 wrap="word", 113 padx=6, 114 pady=5, 115 insertwidth=0, 116 highlightthickness=0, 117 background=lookup_style_option("ViewToolbar.TFrame", "background"), 118 ) 119 120 self.path_bar.grid(row=0, column=0, sticky="nsew") 121 self.path_bar.set_read_only(True) 122 self.path_bar.bind("<Configure>", self.resize_path_bar, True) 123 self.path_bar.tag_configure( 124 "dir", foreground=lookup_style_option("Url.TLabel", "foreground") 125 ) 126 self.path_bar.tag_configure("underline", underline=True) 127 128 def get_dir_range(event): 129 mouse_index = self.path_bar.index("@%d,%d" % (event.x, event.y)) 130 return self.path_bar.tag_prevrange("dir", mouse_index + "+1c") 131 132 def dir_tag_motion(event): 133 self.path_bar.tag_remove("underline", "1.0", "end") 134 dir_range = get_dir_range(event) 135 if dir_range: 136 range_start, range_end = dir_range 137 self.path_bar.tag_add("underline", range_start, range_end) 138 139 def dir_tag_enter(event): 140 self.path_bar.config(cursor="hand2") 141 142 def dir_tag_leave(event): 143 self.path_bar.config(cursor="") 144 self.path_bar.tag_remove("underline", "1.0", "end") 145 146 def dir_tag_click(event): 147 mouse_index = self.path_bar.index("@%d,%d" % (event.x, event.y)) 148 lineno = int(float(mouse_index)) 149 if lineno == 1: 150 self.request_focus_into("") 151 else: 152 assert lineno == 2 153 dir_range = get_dir_range(event) 154 if dir_range: 155 _, end_index = dir_range 156 path = self.path_bar.get("2.0", end_index) 157 if path.endswith(":"): 158 path += "\\" 159 self.request_focus_into(path) 160 161 self.path_bar.tag_bind("dir", "<1>", dir_tag_click) 162 self.path_bar.tag_bind("dir", "<Enter>", dir_tag_enter) 163 self.path_bar.tag_bind("dir", "<Leave>", dir_tag_leave) 164 self.path_bar.tag_bind("dir", "<Motion>", dir_tag_motion) 165 166 # self.menu_button = ttk.Button(header_frame, text="≡ ", style="ViewToolbar.Toolbutton") 167 self.menu_button = ttk.Button( 168 header_frame, text=" ≡ ", style="ViewToolbar.Toolbutton", command=self.post_button_menu 169 ) 170 # self.menu_button.grid(row=0, column=1, sticky="ne") 171 self.menu_button.place(anchor="ne", rely=0, relx=1) 172 173 def clear(self): 174 self.clear_error() 175 self.invalidate_cache() 176 self.path_bar.direct_delete("1.0", "end") 177 self.tree.set_children("") 178 self.current_focus = None 179 180 def request_focus_into(self, path): 181 return self.focus_into(path) 182 183 def focus_into(self, path): 184 self.clear_error() 185 self.invalidate_cache() 186 187 # clear 188 self.tree.set_children(ROOT_NODE_ID) 189 190 self.tree.set(ROOT_NODE_ID, "path", path) 191 192 self.building_breadcrumbs = True 193 self.path_bar.direct_delete("1.0", "end") 194 195 self.path_bar.direct_insert("1.0", self.get_root_text(), ("dir",)) 196 197 if path and path != "/": 198 self.path_bar.direct_insert("end", "\n") 199 200 def create_spacer(): 201 return ttk.Frame(self.path_bar, height=1, width=4, style="ViewToolbar.TFrame") 202 203 parts = self.split_path(path) 204 for i, part in enumerate(parts): 205 if i > 0: 206 if parts[i - 1] != "": 207 self.path_bar.window_create("end", window=create_spacer()) 208 self.path_bar.direct_insert("end", self.get_dir_separator()) 209 self.path_bar.window_create("end", window=create_spacer()) 210 211 self.path_bar.direct_insert("end", part, tags=("dir",)) 212 213 self.building_breadcrumbs = False 214 self.resize_path_bar() 215 self.render_children_from_cache() 216 self.scroll_to_top() 217 self.current_focus = path 218 219 def scroll_to_top(self): 220 children = self.tree.get_children() 221 if children: 222 self.tree.see(children[0]) 223 224 def split_path(self, path): 225 return path.split(self.get_dir_separator()) 226 227 def get_root_text(self): 228 return get_local_files_root_text() 229 230 def on_open_node(self, event): 231 node_id = self.get_selected_node() 232 path = self.tree.set(node_id, "path") 233 if path: # and path not in self._cached_child_data: 234 self.render_children_from_cache(node_id) 235 # self.request_dirs_child_data(node_id, [path]) 236 # else: 237 238 def resize_path_bar(self, event=None): 239 if self.building_breadcrumbs: 240 return 241 height = self.tk.call((self.path_bar, "count", "-update", "-displaylines", "1.0", "end")) 242 self.path_bar.configure(height=height) 243 244 def _cleaned_selection(self): 245 # In some cases (eg. Python 3.6.9 and Tk 8.6.8 in Ubuntu when selecting a range with shift), 246 # nodes may contain collapsed children. 247 # In most cases this does no harm, because the command would apply to children as well, 248 # but dummy dir marker nodes may cause confusion 249 nodes = self.tree.selection() 250 return [node for node in nodes if self.tree.item(node, "text") != _dummy_node_text] 251 252 def get_selected_node(self): 253 """Returns single node (or nothing)""" 254 nodes = self._cleaned_selection() 255 if len(nodes) == 1: 256 return nodes[0] 257 elif len(nodes) > 1: 258 return self.tree.focus() or None 259 else: 260 return None 261 262 def get_selected_nodes(self, notify_if_empty=False): 263 """Can return several nodes""" 264 result = self._cleaned_selection() 265 if not result and notify_if_empty: 266 self.notify_missing_selection() 267 return result 268 269 def get_selection_info(self, notify_if_empty=False): 270 nodes = self.get_selected_nodes(notify_if_empty) 271 if not nodes: 272 return None 273 elif len(nodes) == 1: 274 description = "'" + self.tree.set(nodes[0], "name") + "'" 275 else: 276 description = tr("%d items") % len(nodes) 277 278 paths = [self.tree.set(node, "path") for node in nodes] 279 kinds = [self.tree.set(node, "kind") for node in nodes] 280 281 return {"description": description, "nodes": nodes, "paths": paths, "kinds": kinds} 282 283 def get_selected_path(self): 284 return self.get_selected_value("path") 285 286 def get_selected_kind(self): 287 return self.get_selected_value("kind") 288 289 def get_selected_name(self): 290 return self.get_selected_value("name") 291 292 def get_extension_from_name(self, name): 293 if name is None: 294 return None 295 if "." in name: 296 return "." + name.split(".")[-1].lower() 297 else: 298 return name.lower() 299 300 def get_selected_value(self, key): 301 node_id = self.get_selected_node() 302 303 if node_id: 304 return self.tree.set(node_id, key) 305 else: 306 return None 307 308 def get_active_directory(self): 309 path = self.tree.set(ROOT_NODE_ID, "path") 310 return path 311 312 def request_dirs_child_data(self, node_id, paths): 313 raise NotImplementedError() 314 315 def show_fs_info(self): 316 path = self.get_selected_path() 317 if path is None: 318 path = self.current_focus 319 self.request_fs_info(path) 320 321 def request_fs_info(self, path): 322 raise NotImplementedError() 323 324 def present_fs_info(self, info): 325 total_str = "?" if info["total"] is None else sizeof_fmt(info["total"]) 326 used_str = "?" if info["used"] is None else sizeof_fmt(info["used"]) 327 free_str = "?" if info["free"] is None else sizeof_fmt(info["free"]) 328 text = tr("Storage space on this drive or filesystem") + ":\n\n" " %s: %s\n" % ( 329 tr("total space"), 330 total_str, 331 ) + " %s: %s\n" % (tr("used space"), used_str) + " %s: %s\n" % ( 332 tr("free space"), 333 free_str, 334 ) 335 336 if info.get("comment"): 337 text += "\n" + info["comment"] 338 339 messagebox.showinfo(tr("Storage info"), text, master=self) 340 341 def cache_dirs_child_data(self, data): 342 from copy import deepcopy 343 344 data = deepcopy(data) 345 346 for parent_path in data: 347 children_data = data[parent_path] 348 if isinstance(children_data, dict): 349 for child_name in children_data: 350 child_data = children_data[child_name] 351 assert isinstance(child_data, dict) 352 if "label" not in child_data: 353 child_data["label"] = child_name 354 355 if "isdir" not in child_data: 356 child_data["isdir"] = child_data.get("size", 0) is None 357 else: 358 assert children_data is None 359 360 self._cached_child_data.update(data) 361 362 def file_exists_in_cache(self, path): 363 for parent_path in self._cached_child_data: 364 # hard to split because it may not be in this system format 365 name = path[len(parent_path) :] 366 if name[0:1] in ["/", "\\"]: 367 name = name[1:] 368 369 if name in self._cached_child_data[parent_path]: 370 return True 371 372 return False 373 374 def select_path_if_visible(self, path, node_id=""): 375 for child_id in self.tree.get_children(node_id): 376 if self.tree.set(child_id, "path") == path: 377 self.tree.selection_set(child_id) 378 return 379 380 if self.tree.item(child_id, "open"): 381 self.select_path_if_visible(path, child_id) 382 383 def get_open_paths(self, node_id=ROOT_NODE_ID): 384 if self.tree.set(node_id, "kind") == "file": 385 return set() 386 387 elif node_id == ROOT_NODE_ID or self.tree.item(node_id, "open"): 388 result = {self.tree.set(node_id, "path")} 389 for child_id in self.tree.get_children(node_id): 390 result.update(self.get_open_paths(child_id)) 391 return result 392 393 else: 394 return set() 395 396 def invalidate_cache(self, paths=None): 397 if paths is None: 398 self._cached_child_data.clear() 399 else: 400 for path in paths: 401 if path in self._cached_child_data: 402 del self._cached_child_data[path] 403 404 def render_children_from_cache(self, node_id=""): 405 """This node is supposed to be a directory and 406 its contents needs to be shown and/or refreshed""" 407 path = self.tree.set(node_id, "path") 408 409 if path not in self._cached_child_data: 410 self.request_dirs_child_data(node_id, self.get_open_paths() | {path}) 411 # leave it as is for now, it will be updated later 412 return 413 414 children_data = self._cached_child_data[path] 415 416 if children_data in ["file", "missing"]: 417 # path used to be a dir but is now a file or does not exist 418 419 # if browser is focused into this path 420 if node_id == "": 421 self.show_error("Directory " + path + " does not exist anymore", node_id) 422 elif children_data == "missing": 423 self.tree.delete(node_id) 424 else: 425 assert children_data == "file" 426 self.tree.set_children(node_id) # clear the list of children 427 self.tree.item(node_id, open=False) 428 429 elif children_data is None: 430 raise RuntimeError("None data for %s" % path) 431 else: 432 fs_children_names = children_data.keys() 433 tree_children_ids = self.tree.get_children(node_id) 434 435 # recollect children 436 children = {} 437 438 # first the ones, which are present already in tree 439 for child_id in tree_children_ids: 440 name = self.tree.set(child_id, "name") 441 if name in fs_children_names: 442 children[name] = child_id 443 self.update_node_data(child_id, name, children_data[name]) 444 445 # add missing children 446 for name in fs_children_names: 447 if name not in children: 448 child_id = self.tree.insert(node_id, "end") 449 children[name] = child_id 450 self.tree.set(children[name], "path", self.join(path, name)) 451 self.update_node_data(child_id, name, children_data[name]) 452 453 def file_order(name): 454 # items in a folder should be ordered so that 455 # folders come first and names are ordered case insensitively 456 return ( 457 not children_data[name]["isdir"], # prefer directories 458 not ":" in name, # prefer drives 459 name.upper(), 460 name, 461 ) 462 463 # update tree 464 ids_sorted_by_name = list( 465 map(lambda key: children[key], sorted(children.keys(), key=file_order)) 466 ) 467 self.tree.set_children(node_id, *ids_sorted_by_name) 468 469 # recursively update open children 470 for child_id in ids_sorted_by_name: 471 if self.tree.item(child_id, "open"): 472 self.render_children_from_cache(child_id) 473 474 def show_error(self, msg, node_id=""): 475 if not node_id: 476 # clear tree 477 self.tree.set_children("") 478 479 err_id = self.tree.insert(node_id, "end") 480 self.tree.item(err_id, text=msg) 481 self.tree.set_children(node_id, err_id) 482 483 def clear_error(self): 484 "TODO:" 485 486 def update_node_data(self, node_id, name, data): 487 assert node_id != "" 488 489 path = self.tree.set(node_id, "path") 490 491 if data.get("modified"): 492 try: 493 # modification time is Unix epoch 494 time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(data["modified"]))) 495 except Exception: 496 time_str = "" 497 else: 498 time_str = "" 499 500 self.tree.set(node_id, "modified", time_str) 501 502 if data["isdir"]: 503 self.tree.set(node_id, "kind", "dir") 504 self.tree.set(node_id, "size", "") 505 506 # Ensure that expand button is visible 507 # unless we know it doesn't have children 508 children_ids = self.tree.get_children(node_id) 509 if ( 510 self.show_expand_buttons 511 and len(children_ids) == 0 512 and (path not in self._cached_child_data or self._cached_child_data[path]) 513 ): 514 self.tree.insert(node_id, "end", text=_dummy_node_text) 515 516 if path.endswith(":") or path.endswith(":\\"): 517 img = self.hard_drive_icon 518 else: 519 img = self.folder_icon 520 else: 521 self.tree.set(node_id, "kind", "file") 522 self.tree.set(node_id, "size", data["size"]) 523 524 # Make sure it doesn't have children 525 self.tree.set_children(node_id) 526 527 if ( 528 path.lower().endswith(".py") 529 or path.lower().endswith(".pyw") 530 or path.lower().endswith(".pyi") 531 ): 532 img = self.python_file_icon 533 elif self.should_open_name_in_thonny(name): 534 img = self.text_file_icon 535 else: 536 img = self.generic_file_icon 537 538 self.tree.set(node_id, "name", name) 539 self.tree.item(node_id, text=" " + data["label"], image=img) 540 541 def join(self, parent, child): 542 if parent == "": 543 if self.get_dir_separator() == "/": 544 return "/" + child 545 else: 546 return child 547 548 if parent.endswith(self.get_dir_separator()): 549 return parent + child 550 else: 551 return parent + self.get_dir_separator() + child 552 553 def get_dir_separator(self): 554 return os.path.sep 555 556 def on_double_click(self, event): 557 # TODO: don't act when the click happens below last item 558 path = self.get_selected_path() 559 kind = self.get_selected_kind() 560 name = self.get_selected_name() 561 if kind == "file": 562 if self.should_open_name_in_thonny(name): 563 self.open_file(path) 564 else: 565 self.open_path_with_system_app(path) 566 elif kind == "dir": 567 self.request_focus_into(path) 568 569 return "break" 570 571 def open_file(self, path): 572 pass 573 574 def open_path_with_system_app(self, path): 575 pass 576 577 def on_secondary_click(self, event): 578 node_id = self.tree.identify_row(event.y) 579 580 if node_id: 581 if node_id not in self.tree.selection(): 582 # replace current selection 583 self.tree.selection_set(node_id) 584 self.tree.focus(node_id) 585 else: 586 self.tree.selection_set() 587 self.path_bar.focus_set() 588 589 self.tree.update() 590 591 self.refresh_menu(context="item") 592 self.menu.tk_popup(event.x_root, event.y_root) 593 594 def post_button_menu(self): 595 self.refresh_menu(context="button") 596 self.menu.tk_popup( 597 self.menu_button.winfo_rootx(), 598 self.menu_button.winfo_rooty() + self.menu_button.winfo_height(), 599 ) 600 601 def refresh_menu(self, context): 602 self.menu.delete(0, "end") 603 self.add_first_menu_items(context) 604 self.menu.add_separator() 605 self.add_middle_menu_items(context) 606 self.menu.add_separator() 607 self.add_last_menu_items(context) 608 609 def is_active_browser(self): 610 return False 611 612 def add_first_menu_items(self, context): 613 if context == "item": 614 selected_path = self.get_selected_path() 615 selected_kind = self.get_selected_kind() 616 else: 617 selected_path = self.get_active_directory() 618 selected_kind = "dir" 619 620 if context == "button": 621 self.menu.add_command(label=tr("Refresh"), command=self.cmd_refresh_tree) 622 self.menu.add_command( 623 label=tr("Open in system file manager"), 624 command=lambda: self.open_path_with_system_app(selected_path), 625 ) 626 627 hidden_files_label = ( 628 tr("Hide hidden files") if show_hidden_files() else tr("Show hidden files") 629 ) 630 self.menu.add_command(label=hidden_files_label, command=self.toggle_hidden_files) 631 else: 632 if selected_kind == "dir": 633 self.menu.add_command( 634 label=tr("Focus into"), command=lambda: self.request_focus_into(selected_path) 635 ) 636 else: 637 self.menu.add_command( 638 label=tr("Open in Thonny"), command=lambda: self.open_file(selected_path) 639 ) 640 641 if self.is_active_browser(): 642 self.menu.add_command( 643 label=tr("Open in system default app"), 644 command=lambda: self.open_path_with_system_app(selected_path), 645 ) 646 647 if selected_kind == "file": 648 ext = self.get_extension_from_name(self.get_selected_name()) 649 self.menu.add_command( 650 label=tr("Configure %s files") % ext + "...", 651 command=lambda: self.open_extension_dialog(ext), 652 ) 653 654 def toggle_hidden_files(self): 655 get_workbench().set_option( 656 HIDDEN_FILES_OPTION, not get_workbench().get_option(HIDDEN_FILES_OPTION) 657 ) 658 self.refresh_tree() 659 660 def cmd_refresh_tree(self): 661 self.refresh_tree() 662 663 def open_extension_dialog(self, extension: str) -> None: 664 system_choice = tr("Open in system default app") 665 thonny_choice = tr("Open in Thonny's text editor") 666 667 current_index = ( 668 1 if get_workbench().get_option(get_file_handler_conf_key(extension)) == "thonny" else 0 669 ) 670 671 choice = ask_one_from_choices( 672 title=tr("Configure %s files") % extension, 673 question=tr( 674 "What to do with a %s file when you double-click it in Thonny's file browser?" 675 ) 676 % extension, 677 choices=[system_choice, thonny_choice], 678 initial_choice_index=current_index, 679 master=self.winfo_toplevel(), 680 ) 681 682 if not choice: 683 return 684 685 get_workbench().set_option( 686 get_file_handler_conf_key(extension), 687 "system" if choice == system_choice else "thonny", 688 ) 689 # update icons 690 self.refresh_tree() 691 692 def add_middle_menu_items(self, context): 693 if self.supports_trash(): 694 if running_on_windows(): 695 trash_label = tr("Move to Recycle Bin") 696 else: 697 trash_label = tr("Move to Trash") 698 self.menu.add_command(label=trash_label, command=self.move_to_trash) 699 else: 700 self.menu.add_command(label=tr("Delete"), command=self.delete) 701 702 if self.supports_directories(): 703 self.menu.add_command(label=tr("New directory") + "...", command=self.mkdir) 704 705 def add_last_menu_items(self, context): 706 self.menu.add_command(label=tr("Properties"), command=self.show_properties) 707 if context == "button": 708 self.menu.add_command(label=tr("Storage space"), command=self.show_fs_info) 709 710 def show_properties(self): 711 node_id = self.get_selected_node() 712 if node_id is None: 713 self.notify_missing_selection() 714 return 715 716 values = self.tree.set(node_id) 717 718 text = tr("Path") + ":\n " + values["path"] + "\n\n" 719 if values["kind"] == "dir": 720 title = tr("Directory properties") 721 else: 722 title = tr("File properties") 723 size_fmt_str = sizeof_fmt(int(values["size"])) 724 bytes_str = str(values["size"]) + " " + tr("bytes") 725 726 text += ( 727 tr("Size") 728 + ":\n " 729 + ( 730 bytes_str 731 if size_fmt_str.endswith(" B") 732 else size_fmt_str + " (" + bytes_str + ")" 733 ) 734 + "\n\n" 735 ) 736 737 if values["modified"].strip(): 738 text += tr("Modified") + ":\n " + values["modified"] + "\n\n" 739 740 messagebox.showinfo(title, text.strip(), master=self) 741 742 def refresh_tree(self, paths_to_invalidate=None): 743 self.invalidate_cache(paths_to_invalidate) 744 if self.winfo_ismapped(): 745 self.render_children_from_cache("") 746 747 if self.path_to_highlight: 748 self.select_path_if_visible(self.path_to_highlight) 749 self.path_to_highlight = None 750 751 def create_new_file(self): 752 selected_node_id = self.get_selected_node() 753 754 if selected_node_id: 755 selected_path = self.tree.set(selected_node_id, "path") 756 selected_kind = self.tree.set(selected_node_id, "kind") 757 758 if selected_kind == "dir": 759 parent_path = selected_path 760 else: 761 parent_id = self.tree.parent(selected_node_id) 762 parent_path = self.tree.set(parent_id, "path") 763 else: 764 parent_path = self.current_focus 765 766 name = ask_string( 767 "File name", "Provide filename", initial_value="", master=self.winfo_toplevel() 768 ) 769 770 if not name: 771 return None 772 773 path = self.join(parent_path, name) 774 775 if name in self._cached_child_data[parent_path]: 776 # TODO: ignore case in windows 777 messagebox.showerror("Error", "The file '" + path + "' already exists", master=self) 778 return self.create_new_file() 779 else: 780 self.open_file(path) 781 782 return path 783 784 def delete(self): 785 selection = self.get_selection_info(True) 786 if not selection: 787 return 788 789 confirmation = "Are you sure want to delete %s?" % selection["description"] 790 confirmation += "\n\nNB! Recycle bin won't be used (no way to undelete)!" 791 if "dir" in selection["kinds"]: 792 confirmation += "\n" + "Directories will be deleted with content." 793 794 if not messagebox.askyesno("Are you sure?", confirmation, master=self): 795 return 796 797 self.perform_delete(selection["paths"], tr("Deleting %s") % selection["description"]) 798 self.refresh_tree() 799 800 def move_to_trash(self): 801 assert self.supports_trash() 802 803 selection = self.get_selection_info(True) 804 if not selection: 805 return 806 807 trash = tr("Recycle Bin") if running_on_windows() else tr("Trash") 808 if not messagebox.askokcancel( 809 tr("Moving to %s") % trash, 810 tr("Move %s to %s?") % (selection["description"], trash), 811 icon="info", 812 master=self, 813 ): 814 return 815 816 self.perform_move_to_trash( 817 selection["paths"], tr("Moving %s to %s") % (selection["description"], trash) 818 ) 819 self.refresh_tree() 820 821 def supports_trash(self): 822 return False 823 824 def mkdir(self): 825 parent = self.get_selected_path() 826 if parent is None: 827 parent = self.current_focus 828 else: 829 if self.get_selected_kind() == "file": 830 # dirname does the right thing even if parent is Linux path and running on Windows 831 parent = os.path.dirname(parent) 832 833 name = ask_string( 834 tr("New directory"), 835 tr("Enter name for new directory under\n%s") % parent, 836 master=self.winfo_toplevel(), 837 ) 838 if not name or not name.strip(): 839 return 840 841 self.perform_mkdir(parent, name.strip()) 842 self.refresh_tree() 843 844 def perform_delete(self, paths, description): 845 raise NotImplementedError() 846 847 def perform_move_to_trash(self, paths, description): 848 raise NotImplementedError() 849 850 def supports_directories(self): 851 return True 852 853 def perform_mkdir(self, parent_dir, name): 854 raise NotImplementedError() 855 856 def notify_missing_selection(self): 857 messagebox.showerror( 858 tr("Nothing selected"), tr("Select an item and try again!"), master=self 859 ) 860 861 def should_open_name_in_thonny(self, name): 862 ext = self.get_extension_from_name(name) 863 return get_workbench().get_option(get_file_handler_conf_key(ext), "system") == "thonny" 864 865 866class BaseLocalFileBrowser(BaseFileBrowser): 867 def __init__(self, master, show_expand_buttons=True): 868 super().__init__(master, show_expand_buttons=show_expand_buttons) 869 get_workbench().bind("WindowFocusIn", self.on_window_focus_in, True) 870 get_workbench().bind("LocalFileOperation", self.on_local_file_operation, True) 871 872 def destroy(self): 873 super().destroy() 874 get_workbench().unbind("WindowFocusIn", self.on_window_focus_in) 875 get_workbench().unbind("LocalFileOperation", self.on_local_file_operation) 876 877 def request_dirs_child_data(self, node_id, paths): 878 self.cache_dirs_child_data(get_dirs_children_info(paths, show_hidden_files())) 879 self.render_children_from_cache(node_id) 880 881 def split_path(self, path): 882 parts = super().split_path(path) 883 if running_on_windows() and path.startswith("\\\\"): 884 # Don't split a network name! 885 sep = self.get_dir_separator() 886 for i in reversed(range(len(parts))): 887 prefix = sep.join(parts[: i + 1]) 888 if os.path.ismount(prefix): 889 return [prefix] + parts[i + 1 :] 890 891 # Could not find the prefix corresponding to mount 892 return [path] 893 else: 894 return parts 895 896 def open_file(self, path): 897 get_workbench().get_editor_notebook().show_file(path) 898 899 def open_path_with_system_app(self, path): 900 try: 901 open_with_default_app(path) 902 except Exception as e: 903 logger.error("Could not open %r in system app", path, exc_info=e) 904 messagebox.showerror( 905 "Error", 906 "Could not open '%s' in system app\nError: %s" % (path, e), 907 parent=self.winfo_toplevel(), 908 ) 909 910 def on_window_focus_in(self, event=None): 911 self.refresh_tree() 912 913 def on_local_file_operation(self, event): 914 if event["operation"] in ["save", "delete"]: 915 self.refresh_tree() 916 self.select_path_if_visible(event["path"]) 917 918 def request_fs_info(self, path): 919 if path == "": 920 self.notify_missing_selection() 921 else: 922 if not os.path.isdir(path): 923 path = os.path.dirname(path) 924 925 import shutil 926 927 self.present_fs_info(shutil.disk_usage(path)._asdict()) 928 929 def perform_delete(self, paths, description): 930 # Deprecated. moving to trash should be used instead 931 raise NotImplementedError() 932 933 def perform_move_to_trash(self, paths, description): 934 # TODO: do it with subprocess dialog 935 import send2trash 936 937 for path in paths: 938 send2trash.send2trash(path) 939 940 def perform_mkdir(self, parent_dir, name): 941 os.mkdir(os.path.join(parent_dir, name), mode=0o700) 942 943 def supports_trash(self): 944 try: 945 import send2trash # @UnusedImport 946 947 return True 948 except ImportError: 949 return False 950 951 952class BaseRemoteFileBrowser(BaseFileBrowser): 953 def __init__(self, master, show_expand_buttons=True): 954 super().__init__(master, show_expand_buttons=show_expand_buttons) 955 self.dir_separator = "/" 956 957 get_workbench().bind("get_dirs_children_info_response", self.update_dir_data, True) 958 get_workbench().bind("get_fs_info_response", self.present_fs_info, True) 959 get_workbench().bind("RemoteFileOperation", self.on_remote_file_operation, True) 960 961 def destroy(self): 962 super().destroy() 963 get_workbench().unbind("get_dirs_children_info_response", self.update_dir_data) 964 get_workbench().unbind("get_fs_info_response", self.present_fs_info) 965 get_workbench().unbind("RemoteFileOperation", self.on_remote_file_operation) 966 967 def get_root_text(self): 968 runner = get_runner() 969 if runner: 970 return runner.get_node_label() 971 972 return "Back-end" 973 974 def request_dirs_child_data(self, node_id, paths): 975 if get_runner(): 976 get_runner().send_command( 977 InlineCommand( 978 "get_dirs_children_info", 979 node_id=node_id, 980 paths=paths, 981 include_hidden=show_hidden_files(), 982 ) 983 ) 984 985 def request_fs_info(self, path): 986 if get_runner(): 987 get_runner().send_command(InlineCommand("get_fs_info", path=path)) 988 989 def get_dir_separator(self): 990 return self.dir_separator 991 992 def update_dir_data(self, msg): 993 if msg.get("error"): 994 self.show_error(msg["error"]) 995 else: 996 self.dir_separator = msg["dir_separator"] 997 self.cache_dirs_child_data(msg["data"]) 998 self.render_children_from_cache(msg["node_id"]) 999 1000 if self.path_to_highlight: 1001 self.select_path_if_visible(self.path_to_highlight) 1002 self.path_to_highlight = None 1003 1004 def open_file(self, path): 1005 get_workbench().get_editor_notebook().show_remote_file(path) 1006 1007 def open_path_with_system_app(self, path): 1008 messagebox.showinfo( 1009 "Not supported", 1010 "Opening remote files in system app is not supported.\n\n" 1011 + "Please download the file to a local directory and open it from there!", 1012 master=self, 1013 ) 1014 1015 def supports_directories(self): 1016 runner = get_runner() 1017 if not runner: 1018 return False 1019 proxy = runner.get_backend_proxy() 1020 if not proxy: 1021 return False 1022 return proxy.supports_remote_directories() 1023 1024 def on_remote_file_operation(self, event): 1025 path = event["path"] 1026 exists_in_cache = self.file_exists_in_cache(path) 1027 if ( 1028 event["operation"] == "save" 1029 and exists_in_cache 1030 or event["operation"] == "delete" 1031 and not exists_in_cache 1032 ): 1033 # No need to refresh 1034 return 1035 1036 if "/" in path: 1037 parent = path[: path.rfind("/")] 1038 if not parent: 1039 parent = "/" 1040 else: 1041 parent = "" 1042 1043 self.refresh_tree([parent]) 1044 self.path_to_highlight = path 1045 1046 def perform_delete(self, paths, description): 1047 get_runner().send_command_and_wait( 1048 InlineCommand("delete", paths=paths, description=description), 1049 dialog_title=tr("Deleting"), 1050 ) 1051 1052 def perform_mkdir(self, parent_dir, name): 1053 path = (parent_dir + self.get_dir_separator() + name).replace("//", "/") 1054 get_runner().send_command_and_wait( 1055 InlineCommand("mkdir", path=path), 1056 dialog_title=tr("Creating directory"), 1057 ) 1058 1059 def supports_trash(self): 1060 return get_runner().get_backend_proxy().supports_trash() 1061 1062 def request_focus_into(self, path): 1063 if not get_runner().ready_for_remote_file_operations(show_message=True): 1064 return False 1065 1066 # super().request_focus_into(path) 1067 1068 if not get_runner().supports_remote_directories(): 1069 assert path == "" 1070 self.focus_into(path) 1071 elif self.current_focus == path: 1072 # refreshes 1073 self.focus_into(path) 1074 else: 1075 self.request_new_focus(path) 1076 1077 return True 1078 1079 def request_new_focus(self, path): 1080 # Overridden in active browser 1081 self.focus_into(path) 1082 1083 def cmd_refresh_tree(self): 1084 if not get_runner().ready_for_remote_file_operations(show_message=True): 1085 return 1086 1087 super().cmd_refresh_tree() 1088 1089 1090class DialogRemoteFileBrowser(BaseRemoteFileBrowser): 1091 def __init__(self, master, dialog): 1092 super().__init__(master, show_expand_buttons=False) 1093 1094 self.dialog = dialog 1095 self.tree["show"] = ("tree", "headings") 1096 self.tree.configure(displaycolumns=(5,)) 1097 self.tree.configure(height=10) 1098 1099 def open_file(self, path): 1100 self.dialog.double_click_file(path) 1101 1102 def should_open_name_in_thonny(self, name): 1103 # In dialog, all file types are to be opened in Thonny 1104 return True 1105 1106 1107class BackendFileDialog(CommonDialog): 1108 def __init__(self, master, kind, initial_dir): 1109 super().__init__(master=master) 1110 self.result = None 1111 1112 self.updating_selection = False 1113 1114 self.kind = kind 1115 if kind == "open": 1116 self.title(tr("Open from %s") % get_runner().get_node_label()) 1117 else: 1118 assert kind == "save" 1119 self.title(tr("Save to %s") % get_runner().get_node_label()) 1120 1121 background = ttk.Frame(self) 1122 background.grid(row=0, column=0, sticky="nsew") 1123 self.columnconfigure(0, weight=1) 1124 self.rowconfigure(0, weight=1) 1125 1126 self.browser = DialogRemoteFileBrowser(background, self) 1127 self.browser.grid(row=0, column=0, columnspan=4, sticky="nsew", pady=20, padx=20) 1128 self.browser.configure(borderwidth=1, relief="groove") 1129 self.browser.tree.configure(selectmode="browse") 1130 1131 self.name_label = ttk.Label(background, text=tr("File name:")) 1132 self.name_label.grid(row=1, column=0, pady=(0, 20), padx=20, sticky="w") 1133 1134 self.name_var = create_string_var("") 1135 self.name_entry = ttk.Entry( 1136 background, textvariable=self.name_var, state="normal" if kind == "save" else "disabled" 1137 ) 1138 self.name_entry.grid(row=1, column=1, pady=(0, 20), padx=(0, 20), sticky="we") 1139 self.name_entry.bind("<KeyRelease>", self.on_name_edit, True) 1140 1141 self.ok_button = ttk.Button(background, text=tr("OK"), command=self.on_ok) 1142 self.ok_button.grid(row=1, column=2, pady=(0, 20), padx=(0, 20), sticky="e") 1143 1144 self.cancel_button = ttk.Button(background, text=tr("Cancel"), command=self.on_cancel) 1145 self.cancel_button.grid(row=1, column=3, pady=(0, 20), padx=(0, 20), sticky="e") 1146 1147 background.rowconfigure(0, weight=1) 1148 background.columnconfigure(1, weight=1) 1149 1150 self.bind("<Escape>", self.on_cancel, True) 1151 self.bind("<Return>", self.on_ok, True) 1152 self.protocol("WM_DELETE_WINDOW", self.on_cancel) 1153 1154 self.tree_select_handler_id = self.browser.tree.bind( 1155 "<<TreeviewSelect>>", self.on_tree_select, True 1156 ) 1157 1158 self.browser.request_focus_into(initial_dir) 1159 1160 self.name_entry.focus_set() 1161 1162 def on_ok(self, event=None): 1163 tree = self.browser.tree 1164 name = self.name_var.get() 1165 1166 if not name: 1167 messagebox.showerror(tr("Error"), tr("You need to select a file!"), master=self) 1168 return 1169 1170 for node_id in tree.get_children(""): 1171 if name and name == tree.set(node_id, "name"): 1172 break 1173 else: 1174 node_id = None 1175 1176 if node_id is not None: 1177 node_kind = tree.set(node_id, "kind") 1178 if node_kind != "file": 1179 messagebox.showerror(tr("Error"), tr("You need to select a file!"), master=self) 1180 return 1181 elif self.kind == "save": 1182 if not messagebox.askyesno( 1183 tr("Overwrite?"), tr("Do you want to overwrite '%s' ?") % name, master=self 1184 ): 1185 return 1186 1187 parent_path = tree.set("", "path") 1188 if parent_path == "" or parent_path.endswith("/"): 1189 self.result = parent_path + name 1190 else: 1191 self.result = parent_path + "/" + name 1192 1193 self.destroy() 1194 1195 def on_cancel(self, event=None): 1196 self.result = None 1197 self.destroy() 1198 1199 def on_tree_select(self, event=None): 1200 if self.updating_selection: 1201 return 1202 1203 if self.browser.get_selected_kind() == "file": 1204 name = self.browser.get_selected_name() 1205 if name: 1206 self.name_var.set(name) 1207 1208 def on_name_edit(self, event=None): 1209 self.updating_selection = True 1210 tree = self.browser.tree 1211 if self.tree_select_handler_id: 1212 tree.unbind("<<TreeviewSelect>>", self.tree_select_handler_id) 1213 self.tree_select_handler_id = None 1214 1215 name = self.name_var.get() 1216 for node_id in tree.get_children(""): 1217 if name == tree.set(node_id, "name"): 1218 tree.selection_add(node_id) 1219 else: 1220 tree.selection_remove(node_id) 1221 1222 self.updating_selection = False 1223 self.tree_select_handler_id = tree.bind("<<TreeviewSelect>>", self.on_tree_select, True) 1224 1225 def double_click_file(self, path): 1226 assert path.endswith(self.name_var.get()) 1227 self.on_ok() 1228 1229 1230class NodeChoiceDialog(CommonDialog): 1231 def __init__(self, master, prompt): 1232 super().__init__(master=master) 1233 self.result = None 1234 1235 self.title(prompt) 1236 1237 background = ttk.Frame(self) 1238 background.grid(row=0, column=0, sticky="nsew") 1239 self.columnconfigure(0, weight=1) 1240 self.rowconfigure(0, weight=1) 1241 1242 local_caption = get_local_files_root_text() 1243 remote_caption = get_runner().get_node_label() 1244 1245 button_width = max(len(local_caption), len(remote_caption)) + 10 1246 1247 self.local_button = ttk.Button( 1248 background, 1249 text=" \n" + local_caption + "\n ", 1250 width=button_width, 1251 command=self.on_local, 1252 ) 1253 self.local_button.grid(row=0, column=0, pady=20, padx=20) 1254 1255 self.remote_button = ttk.Button( 1256 background, 1257 text=" \n" + remote_caption + "\n ", 1258 width=button_width, 1259 command=self.on_remote, 1260 ) 1261 self.remote_button.grid(row=1, column=0, pady=(0, 20), padx=20) 1262 1263 self.local_button.focus_set() 1264 1265 self.bind("<Escape>", self.on_cancel, True) 1266 self.bind("<Return>", self.on_return, True) 1267 self.bind("<Down>", self.on_down, True) 1268 self.bind("<Up>", self.on_up, True) 1269 self.protocol("WM_DELETE_WINDOW", self.on_cancel) 1270 1271 def on_local(self, event=None): 1272 self.result = "local" 1273 self.destroy() 1274 1275 def on_remote(self, event=None): 1276 self.result = "remote" 1277 self.destroy() 1278 1279 def on_return(self, event=None): 1280 if self.focus_get() == self.local_button: 1281 self.on_local(event) 1282 elif self.focus_get() == self.remote_button: 1283 self.on_remote(event) 1284 1285 def on_down(self, event=None): 1286 if self.focus_get() == self.local_button: 1287 self.remote_button.focus_set() 1288 1289 def on_up(self, event=None): 1290 if self.focus_get() == self.remote_button: 1291 self.local_button.focus_set() 1292 1293 def on_cancel(self, event=None): 1294 self.result = None 1295 self.destroy() 1296 1297 1298def ask_backend_path(master, dialog_kind): 1299 proxy = get_runner().get_backend_proxy() 1300 if not proxy: 1301 return None 1302 1303 assert proxy.supports_remote_files() 1304 1305 dlg = BackendFileDialog(master, dialog_kind, proxy.get_cwd()) 1306 show_dialog(dlg, master) 1307 return dlg.result 1308 1309 1310def choose_node_for_file_operations(master, prompt): 1311 if get_runner().supports_remote_files(): 1312 dlg = NodeChoiceDialog(master, prompt) 1313 show_dialog(dlg, master) 1314 if dlg.result == "remote" and not get_runner().ready_for_remote_file_operations( 1315 show_message=True 1316 ): 1317 return None 1318 return dlg.result 1319 else: 1320 return "local" 1321 1322 1323def get_local_files_root_text(): 1324 global _LOCAL_FILES_ROOT_TEXT 1325 1326 if not _LOCAL_FILES_ROOT_TEXT: 1327 # translation can't be done in module load time 1328 _LOCAL_FILES_ROOT_TEXT = tr("This computer") 1329 1330 return _LOCAL_FILES_ROOT_TEXT 1331 1332 1333def open_with_default_app(path): 1334 if running_on_windows(): 1335 os.startfile(path) 1336 elif running_on_mac_os(): 1337 subprocess.run(["open", path]) 1338 else: 1339 subprocess.run(["xdg-open", path]) 1340 1341 1342def get_file_handler_conf_key(extension): 1343 return "file_default_handlers.%s" % extension 1344 1345 1346def show_hidden_files(): 1347 return get_workbench().get_option(HIDDEN_FILES_OPTION) 1348