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