Repository: micropython/micropython
Pull request #17112: Add `mpremote fs tree` command to show a tree of the device's files.
Author: login: Josverl
Labels: name: tools
description: Relates to tools/ directory in source, or other tooling
Created: 2025-04-10T17:06:00Z | Merged: 2025-05-17T14:30:37Z

### Summary
This PR adds a new command to `mpremote` that displays the file system of a connected device in a tree format.

While working on mpremote rm -r I developed a desire to see a tree-view of files on the device.
I build a PoC for this and [asked for feedback](https://github.com/orgs/micropython/discussions/17104) 
where there appears to be a common need for this.

The  `mpremote fs tree` command :
 - Shows treeview from current path or specified path
 -  uses the graph chars ("├── ", "└── ") ( not configurable)
 - options: 
    - -v/--verbose adds the serial device name to the top of the tree
    - -s/--size add a (human readable) size to the files. ( not using -h as that )

This PR also fixes a limitation where `ls` can be run without specifying a path, but this would result in an error if an option was specified.
As `tree` also uses the same logic I wanted to fix this for both commands.

**Example tree view:**
```
$ mpremote resume tree -sv /
tree :/
:/ on /dev/ttyACM0
├── [  10B] a.py
├── [ 1.9K] b.py
├── backup
│   ├── [  10B] a.py
│   ├── [ 1.9K] b.py
│   ├── foo
│   │   ├── [   0B] aa.py
│   │   └── [   0B] ba.py
│   └── lib
│       ├── [   0B] bar.py
│       └── [   0B] foo.py
├── [   0B] bar.py
├── foo
│   ├── [   0B] aa.py
│   └── [   0B] ba.py
├── [   0B] foo.py
├── lib
│   ├── [   0B] bar.py
│   └── [   0B] foo.py
└── ramdisk
    ├── [   0B] bar.py
    ├── [   0B] foo.py
    └── lib
        ├── [   0B] lib_bar.py
        └── [   0B] lib_foo.py
```

### Testing

Automated tests for valid and error cases have been added, but can only be verified on unix based systems.
The -v option cannot be tested reliably with the `.exp` output approach as devices can,  and will,  be assigned to different port names.  So this has been omitted from automated tests.

Note that there is now 3x duplication of the `ramdisk.py` script across multiple test scripts. 
It would be best to reduce this to a single file and use it in all tests.

Manual testing:
| port | WSL2/ubuntu| Windows|
|--------|--------|--------|
| stm32| PASS | PASS |
| samd | PASS | PASS |
| esp32| PASS | PASS | 

### Documentation

Documentation and samples for the `fs tree` command have been added to the mpremote doc page.
A few _sub sections_ have been added to the **fs section**  to improve readability, 
A few paragraphs have been moved to a more appropriate location to better fit this structure.







Diff:
diff --git a/docs/reference/mpremote.rst b/docs/reference/mpremote.rst
index 32ca5c246a7a4..bee008c637319 100644
--- a/docs/reference/mpremote.rst
+++ b/docs/reference/mpremote.rst
@@ -234,6 +234,7 @@ The full list of supported commands are:
   - ``rmdir <dirs...>`` to remove directories on the device
   - ``touch <file..>`` to create the files (if they don't already exist)
   - ``sha256sum <file..>`` to calculate the SHA256 sum of files
+  - ``tree [-vsh] <dirs...>`` to print a tree of the given directories
 
   The ``cp`` command uses a convention where a leading ``:`` represents a remote
   path. Without a leading ``:`` means a local path. This is based on the
@@ -264,6 +265,13 @@ The full list of supported commands are:
     There is no supported way to undelete files removed by ``mpremote rm -r :``.
     Please use with caution.
 
+  The ``tree`` command will print a tree of the given directories.
+  Using the ``--size/-s`` option will print the size of each file, or use
+  ``--human/-h`` to use a more human readable format.
+  Note: Directory size is only printed when a non-zero size is reported by the device's filesystem.
+  The ``-v`` option  can be used to include the name of the serial device in
+  the output.
+
   All other commands implicitly assume the path is a remote path, but the ``:``
   can be optionally used for clarity.
 
diff --git a/tools/mpremote/mpremote/commands.py b/tools/mpremote/mpremote/commands.py
index 452384728ab71..428600baf4375 100644
--- a/tools/mpremote/mpremote/commands.py
+++ b/tools/mpremote/mpremote/commands.py
@@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimals=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimals}f}{unit}" if unit != 'B' else f"{int(size)}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            size_str = ""
+            # most MicroPython filesystems don't support st_size on directories, reduce clutter
+            if entry.st_size > 0 or not is_dir:
+                if args.size:
+                    size_str = f"[{entry.st_size:>9}]  "
+                elif args.human:
+                    size_str = f"[{human_size(entry.st_size):>6}]  "
+            print(f"{prefix}{connector}{size_str}{entry.name}")
+            if is_dir:
+                _tree_recursive(
+                    _remote_path_join(path, entry.name),
+                    prefix + ("    " if i == len(entries) - 1 else "│   "),
+                )
+
+    if not path or path == ".":
+        path = state.transport.exec("import os;print(os.getcwd())").strip().decode("utf-8")
+    if not (path == "." or state.transport.fs_isdir(path)):
+        raise CommandError(f"tree: '{path}' is not a directory")
+    if args.verbose:
+        print(f":{path} on {state.transport.device_name}")
+    else:
+        print(f":{path}")
+    _tree_recursive(path)
+
+
 def do_filesystem(state, args):
     state.ensure_raw_repl()
     state.did_action()
@@ -361,8 +404,8 @@ def do_filesystem(state, args):
         # leading ':' if the user included them.
         paths = [path[1:] if path.startswith(":") else path for path in paths]
 
-    # ls implicitly lists the cwd.
-    if command == "ls" and not paths:
+    # ls and tree implicitly lists the cwd.
+    if command in ("ls", "tree") and not paths:
         paths = [""]
 
     try:
@@ -404,6 +447,8 @@ def do_filesystem(state, args):
                     )
                 else:
                     do_filesystem_cp(state, path, cp_dest, len(paths) > 1, not args.force)
+            elif command == "tree":
+                do_filesystem_tree(state, path, args)
     except OSError as er:
         raise CommandError("{}: {}: {}.".format(command, er.strerror, os.strerror(er.errno)))
     except TransportError as er:
diff --git a/tools/mpremote/mpremote/main.py b/tools/mpremote/mpremote/main.py
index b30a1a21354cc..f8c913d26d194 100644
--- a/tools/mpremote/mpremote/main.py
+++ b/tools/mpremote/mpremote/main.py
@@ -181,7 +181,11 @@ def argparse_rtc():
 
 
 def argparse_filesystem():
-    cmd_parser = argparse.ArgumentParser(description="execute filesystem commands on the device")
+    cmd_parser = argparse.ArgumentParser(
+        description="execute filesystem commands on the device",
+        add_help=False,
+    )
+    cmd_parser.add_argument("--help", action="help", help="show this help message and exit")
     _bool_flag(cmd_parser, "recursive", "r", False, "recursive (for cp and rm commands)")
     _bool_flag(
         cmd_parser,
@@ -197,10 +201,26 @@ def argparse_filesystem():
         None,
         "enable verbose output (defaults to True for all commands except cat)",
     )
+    size_group = cmd_parser.add_mutually_exclusive_group()
+    size_group.add_argument(
+        "--size",
+        "-s",
+        default=False,
+        action="store_true",
+        help="show file size in bytes(tree command only)",
+    )
+    size_group.add_argument(
+        "--human",
+        "-h",
+        default=False,
+        action="store_true",
+        help="show file size in a more human readable way (tree command only)",
+    )
+
     cmd_parser.add_argument(
         "command",
         nargs=1,
-        help="filesystem command (e.g. cat, cp, sha256sum, ls, rm, rmdir, touch)",
+        help="filesystem command (e.g. cat, cp, sha256sum, ls, rm, rmdir, touch, tree)",
     )
     cmd_parser.add_argument("path", nargs="+", help="local and remote paths")
     return cmd_parser
@@ -355,6 +375,7 @@ def argparse_none(description):
     "rmdir": "fs rmdir",
     "sha256sum": "fs sha256sum",
     "touch": "fs touch",
+    "tree": "fs tree",
     # Disk used/free.
     "df": [
         "exec",
@@ -552,8 +573,13 @@ def main():
                 command_args = remaining_args
                 extra_args = []
 
-            # Special case: "fs ls" allowed have no path specified.
-            if cmd == "fs" and len(command_args) == 1 and command_args[0] == "ls":
+            # Special case: "fs ls" and "fs tree" can have only options and no path specified.
+            if (
+                cmd == "fs"
+                and len(command_args) >= 1
+                and command_args[0] in ("ls", "tree")
+                and sum(1 for a in command_args if not a.startswith('-')) == 1
+            ):
                 command_args.append("")
 
             # Use the command-specific argument parser.
diff --git a/tools/mpremote/tests/test_filesystem.sh b/tools/mpremote/tests/test_filesystem.sh
index a29015e987268..13c5394f71c88 100755
--- a/tools/mpremote/tests/test_filesystem.sh
+++ b/tools/mpremote/tests/test_filesystem.sh
@@ -237,3 +237,6 @@ echo -----
 # try to delete existing folder in mounted filesystem
 $MPREMOTE mount "${TMP}" + rm -rv :package || echo "expect error"
 echo -----
+# fs without command should raise error
+$MPREMOTE fs 2>/dev/null || echo "expect error: $?"
+echo -----
diff --git a/tools/mpremote/tests/test_filesystem.sh.exp b/tools/mpremote/tests/test_filesystem.sh.exp
index 3d9d0fe9ae8fa..63411580b756e 100644
--- a/tools/mpremote/tests/test_filesystem.sh.exp
+++ b/tools/mpremote/tests/test_filesystem.sh.exp
@@ -272,3 +272,5 @@ rm :package
 mpremote: rm -r not permitted on /remote directory
 expect error
 -----
+expect error: 2
+-----
diff --git a/tools/mpremote/tests/test_fs_tree.sh b/tools/mpremote/tests/test_fs_tree.sh
new file mode 100755
index 0000000000000..d7fc433ae6a15
--- /dev/null
+++ b/tools/mpremote/tests/test_fs_tree.sh
@@ -0,0 +1,114 @@
+#!/bin/bash
+set -e
+
+# Creates a RAM disk big enough to hold two copies of the test directory
+# structure.
+cat << EOF > "${TMP}/ramdisk.py"
+class RAMBlockDev:
+    def __init__(self, block_size, num_blocks):
+        self.block_size = block_size
+        self.data = bytearray(block_size * num_blocks)
+
+    def readblocks(self, block_num, buf):
+        for i in range(len(buf)):
+            buf[i] = self.data[block_num * self.block_size + i]
+
+    def writeblocks(self, block_num, buf):
+        for i in range(len(buf)):
+            self.data[block_num * self.block_size + i] = buf[i]
+
+    def ioctl(self, op, arg):
+        if op == 4: # get number of blocks
+            return len(self.data) // self.block_size
+        if op == 5: # get block size
+            return self.block_size
+
+import os
+
+bdev = RAMBlockDev(512, 50)
+os.VfsFat.mkfs(bdev)
+os.mount(bdev, '/ramdisk')
+os.chdir('/ramdisk')
+EOF
+
+# setup 
+echo -----
+$MPREMOTE run "${TMP}/ramdisk.py"
+$MPREMOTE resume ls
+
+echo -----
+echo "empty tree"
+$MPREMOTE resume tree :
+
+echo -----
+$MPREMOTE resume touch :a.py + touch :b.py  
+$MPREMOTE resume mkdir :foo + touch :foo/aa.py + touch :foo/ba.py
+
+echo "small tree - :" 
+$MPREMOTE resume tree :
+
+echo -----
+echo "no path" 
+$MPREMOTE resume tree 
+
+echo -----
+echo "path = '.'" 
+$MPREMOTE resume tree .
+
+echo -----
+echo "path = ':.'" 
+$MPREMOTE resume tree :.
+
+
+echo -----
+echo "multiple trees" 
+$MPREMOTE resume mkdir :bar + touch :bar/aaa.py + touch :bar/bbbb.py
+$MPREMOTE resume mkdir :bar/baz + touch :bar/baz/aaa.py + touch :bar/baz/bbbb.py
+$MPREMOTE resume mkdir :bar/baz/quux + touch :bar/baz/quux/aaa.py + touch :bar/baz/quux/bbbb.py
+$MPREMOTE resume mkdir :bar/baz/quux/xen + touch :bar/baz/quux/xen/aaa.py
+
+$MPREMOTE resume tree
+
+echo -----
+echo single path
+$MPREMOTE resume tree :foo
+
+echo -----
+echo "multiple paths" 
+$MPREMOTE resume tree :foo :bar
+
+echo -----
+echo "subtree" 
+$MPREMOTE resume tree bar/baz
+
+echo -----
+echo mountpoint
+$MPREMOTE resume tree :/ramdisk
+
+echo -----
+echo non-existent folder : error
+$MPREMOTE resume tree :not_there || echo "expect error: $?"
+
+echo -----
+echo file : error 
+$MPREMOTE resume tree :a.py || echo "expect error: $?"
+
+echo -----
+echo "tree -s :"
+mkdir -p "${TMP}/data"
+dd if=/dev/zero of="${TMP}/data/file1.txt" bs=1 count=20 > /dev/null 2>&1
+dd if=/dev/zero of="${TMP}/data/file2.txt" bs=1 count=204 > /dev/null 2>&1
+dd if=/dev/zero of="${TMP}/data/file3.txt" bs=1 count=1096 > /dev/null 2>&1
+dd if=/dev/zero of="${TMP}/data/file4.txt" bs=1 count=2192 > /dev/null 2>&1
+
+$MPREMOTE resume cp -r "${TMP}/data" :
+$MPREMOTE resume tree -s :
+echo -----
+echo "tree -s"
+$MPREMOTE resume tree -s
+echo -----
+$MPREMOTE resume tree --human :
+echo -----
+$MPREMOTE resume tree -s --human : || echo "expect error: $?"
+echo -----
+
diff --git a/tools/mpremote/tests/test_fs_tree.sh.exp b/tools/mpremote/tests/test_fs_tree.sh.exp
new file mode 100644
index 0000000000000..9a67883b1c87f
--- /dev/null
+++ b/tools/mpremote/tests/test_fs_tree.sh.exp
@@ -0,0 +1,225 @@
+-----
+ls :
+-----
+empty tree
+tree :
+:/ramdisk
+-----
+touch :a.py
+touch :b.py
+mkdir :foo
+touch :foo/aa.py
+touch :foo/ba.py
+small tree - :
+tree :
+:/ramdisk
+├── a.py
+├── b.py
+└── foo
+    ├── aa.py
+    └── ba.py
+-----
+no path
+tree :
+:/ramdisk
+├── a.py
+├── b.py
+└── foo
+    ├── aa.py
+    └── ba.py
+-----
+path = '.'
+tree :.
+:/ramdisk
+├── a.py
+├── b.py
+└── foo
+    ├── aa.py
+    └── ba.py
+-----
+path = ':.'
+tree :.
+:/ramdisk
+├── a.py
+├── b.py
+└── foo
+    ├── aa.py
+    └── ba.py
+-----
+multiple trees
+mkdir :bar
+touch :bar/aaa.py
+touch :bar/bbbb.py
+mkdir :bar/baz
+touch :bar/baz/aaa.py
+touch :bar/baz/bbbb.py
+mkdir :bar/baz/quux
+touch :bar/baz/quux/aaa.py
+touch :bar/baz/quux/bbbb.py
+mkdir :bar/baz/quux/xen
+touch :bar/baz/quux/xen/aaa.py
+tree :
+:/ramdisk
+├── a.py
+├── b.py
+├── bar
+│   ├── aaa.py
+│   ├── baz
+│   │   ├── aaa.py
+│   │   ├── bbbb.py
+│   │   └── quux
+│   │       ├── aaa.py
+│   │       ├── bbbb.py
+│   │       └── xen
+│   │           └── aaa.py
+│   └── bbbb.py
+└── foo
+    ├── aa.py
+    └── ba.py
+-----
+single path
+tree :foo
+:foo
+├── aa.py
+└── ba.py
+-----
+multiple paths
+tree :foo
+:foo
+├── aa.py
+└── ba.py
+tree :bar
+:bar
+├── aaa.py
+├── baz
+│   ├── aaa.py
+│   ├── bbbb.py
+│   └── quux
+│       ├── aaa.py
+│       ├── bbbb.py
+│       └── xen
+│           └── aaa.py
+└── bbbb.py
+-----
+subtree
+tree :bar/baz
+:bar/baz
+├── aaa.py
+├── bbbb.py
+└── quux
+    ├── aaa.py
+    ├── bbbb.py
+    └── xen
+        └── aaa.py
+-----
+mountpoint
+tree :/ramdisk
+:/ramdisk
+├── a.py
+├── b.py
+├── bar
+│   ├── aaa.py
+│   ├── baz
+│   │   ├── aaa.py
+│   │   ├── bbbb.py
+│   │   └── quux
+│   │       ├── aaa.py
+│   │       ├── bbbb.py
+│   │       └── xen
+│   │           └── aaa.py
+│   └── bbbb.py
+└── foo
+    ├── aa.py
+    └── ba.py
+-----
+non-existent folder : error
+tree :not_there
+mpremote: tree: 'not_there' is not a directory
+expect error: 1
+-----
+file : error
+tree :a.py
+mpremote: tree: 'a.py' is not a directory
+expect error: 1
+-----
+tree -s :
+cp ${TMP}/data :
+tree :
+:/ramdisk
+├── [        0]  a.py
+├── [        0]  b.py
+├── bar
+│   ├── [        0]  aaa.py
+│   ├── baz
+│   │   ├── [        0]  aaa.py
+│   │   ├── [        0]  bbbb.py
+│   │   └── quux
+│   │       ├── [        0]  aaa.py
+│   │       ├── [        0]  bbbb.py
+│   │       └── xen
+│   │           └── [        0]  aaa.py
+│   └── [        0]  bbbb.py
+├── data
+│   ├── [       20]  file1.txt
+│   ├── [      204]  file2.txt
+│   ├── [     1096]  file3.txt
+│   └── [     2192]  file4.txt
+└── foo
+    ├── [        0]  aa.py
+    └── [        0]  ba.py
+-----
+tree -s
+tree :
+:/ramdisk
+├── [        0]  a.py
+├── [        0]  b.py
+├── bar
+│   ├── [        0]  aaa.py
+│   ├── baz
+│   │   ├── [        0]  aaa.py
+│   │   ├── [        0]  bbbb.py
+│   │   └── quux
+│   │       ├── [        0]  aaa.py
+│   │       ├── [        0]  bbbb.py
+│   │       └── xen
+│   │           └── [        0]  aaa.py
+│   └── [        0]  bbbb.py
+├── data
+│   ├── [       20]  file1.txt
+│   ├── [      204]  file2.txt
+│   ├── [     1096]  file3.txt
+│   └── [     2192]  file4.txt
+└── foo
+    ├── [        0]  aa.py
+    └── [        0]  ba.py
+-----
+tree :
+:/ramdisk
+├── [     0]  a.py
+├── [     0]  b.py
+├── bar
+│   ├── [     0]  aaa.py
+│   ├── baz
+│   │   ├── [     0]  aaa.py
+│   │   ├── [     0]  bbbb.py
+│   │   └── quux
+│   │       ├── [     0]  aaa.py
+│   │       ├── [     0]  bbbb.py
+│   │       └── xen
+│   │           └── [     0]  aaa.py
+│   └── [     0]  bbbb.py
+├── data
+│   ├── [    20]  file1.txt
+│   ├── [   204]  file2.txt
+│   ├── [  1.1K]  file3.txt
+│   └── [  2.1K]  file4.txt
+└── foo
+    ├── [     0]  aa.py
+    └── [     0]  ba.py
+-----
+usage: fs [--help] [--recursive | --no-recursive] [--force | --no-force]
+          [--verbose | --no-verbose] [--size | --human]
+          command path [path ...] ...
+fs: error: argument --human/-h: not allowed with argument --size/-s
+expect error: 2
+-----


Reviews:
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T07:02:41Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T09:09:32Z
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T14:01:37Z
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T02:06:01Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T09:23:25Z
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T11:57:11Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T12:17:25Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T12:18:55Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T12:48:33Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T13:58:29Z
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T00:16:55Z
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T00:22:45Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T01:05:28Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T01:19:55Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T01:25:08Z
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T03:38:13Z
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T12:59:55Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T15:08:12Z
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T04:37:14Z
user: login: Josverl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T19:08:04Z
user: login: dpgeorge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T01:33:22Z
user: login: dpgeorge
body: Thanks for updating.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-17T14:21:46Z

Review comments:
diff_hunk: @@ -234,6 +234,9 @@ The full list of supported commands are:
   - ``rmdir <dirs...>`` to remove directories on the device
   - ``touch <file..>`` to create the files (if they don't already exist)
   - ``sha256sum <file..>`` to calculate the SHA256 sum of files
+  - ``tree [-vs] <dirs...>`` to print a tree of the given directories
path: docs/reference/mpremote.rst
user: login: dpgeorge
body: Please also document the `-h` option.
created_at: 2025-04-26T07:00:44Z
author_association: MEMBER
diff_hunk: @@ -197,10 +197,25 @@ def argparse_filesystem():
         None,
         "enable verbose output (defaults to True for all commands except cat)",
     )
+    size_group = cmd_parser.add_mutually_exclusive_group()
+    size_group.add_argument(
+        "--size",
+        "-s",
+        default=False,
+        action="store_true",
+        help="show file size in bytes(tree command only)",
+    )
+    size_group.add_argument(
+        "--human",
path: tools/mpremote/mpremote/main.py
user: login: dpgeorge
body: I suggest allowing this to take the short form `-h` as well as the long form, to match unix tree.
created_at: 2025-04-26T07:01:42Z
author_association: MEMBER
diff_hunk: @@ -552,8 +568,12 @@ def main():
                 command_args = remaining_args
                 extra_args = []
 
-            # Special case: "fs ls" allowed have no path specified.
-            if cmd == "fs" and len(command_args) == 1 and command_args[0] == "ls":
+            # Special case: "fs ls" and "fs tree" allowed have no path specified.
+            if (
+                cmd == "fs"
+                and command_args[0] in ["ls", "tree"]
path: tools/mpremote/mpremote/main.py
user: login: dpgeorge
body: Please use a tuple for `("ls", "tree")`.
created_at: 2025-04-26T07:02:25Z
author_association: MEMBER
diff_hunk: @@ -197,10 +197,25 @@ def argparse_filesystem():
         None,
         "enable verbose output (defaults to True for all commands except cat)",
     )
+    size_group = cmd_parser.add_mutually_exclusive_group()
+    size_group.add_argument(
+        "--size",
+        "-s",
+        default=False,
+        action="store_true",
+        help="show file size in bytes(tree command only)",
+    )
+    size_group.add_argument(
+        "--human",
path: tools/mpremote/mpremote/main.py
user: login: Josverl
body: I tried initially, but that conflicts with the existing `-h/--help` of mpremote fs

I can change that, but that may be a breaking change to some of the wrapper modules although I think the risk is low.
Also the docs state clearly that it should not be done/ depended upon.

created_at: 2025-04-26T09:09:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -197,10 +197,25 @@ def argparse_filesystem():
         None,
         "enable verbose output (defaults to True for all commands except cat)",
     )
+    size_group = cmd_parser.add_mutually_exclusive_group()
+    size_group.add_argument(
+        "--size",
+        "-s",
+        default=False,
+        action="store_true",
+        help="show file size in bytes(tree command only)",
+    )
+    size_group.add_argument(
+        "--human",
path: tools/mpremote/mpremote/main.py
user: login: dpgeorge
body: Oh, right.  The `-h`/`--help` options are added automatically to all the subcommands...

I think it's fair enough to repurpose `-h` for whatever the subcommand needs, in this case it would be shorthand for `--human`.  Then there's always the long form `--help` to get help if the user needs that.
created_at: 2025-04-26T14:01:37Z
author_association: MEMBER
diff_hunk: @@ -552,8 +573,12 @@ def main():
                 command_args = remaining_args
                 extra_args = []
 
-            # Special case: "fs ls" allowed have no path specified.
-            if cmd == "fs" and len(command_args) == 1 and command_args[0] == "ls":
+            # Special case: "fs ls" and "fs tree" allowed have no path specified.
+            if (
+                cmd == "fs"
+                and command_args[0] in ("ls", "tree")
path: tools/mpremote/mpremote/main.py
user: login: dpgeorge
body: I think you need to leave the `len(command_args) == 1` logic in, otherwise at the moment doing `mpremote fs` will raise an exception (before, it would print the help message).
created_at: 2025-04-27T01:56:41Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    # connectors = ("|-- ", "+-- ")
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            if args.size and not is_dir:
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: Following unix tree, I think the size should be printed even for directories.  At the very least, this makes the directories and files line up correctly in the output.  And for some filesystems it might actually give useful information.
created_at: 2025-04-27T01:58:05Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    # connectors = ("|-- ", "+-- ")
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            if args.size and not is_dir:
+                print(f"{prefix}{connector}[{entry.st_size:>12}] {entry.name}")
+            elif args.human and not is_dir:
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: Same as above, I suggest to print the size even for directories.
created_at: 2025-04-27T01:58:17Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    # connectors = ("|-- ", "+-- ")
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            if args.size and not is_dir:
+                print(f"{prefix}{connector}[{entry.st_size:>12}] {entry.name}")
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: I think the width of the field for the size could be a bit smaller.  Maybe 9 characters?  That's enough for 999MB file sizes.  It'll still work for sizes larger than that (which I suspect will be very rare) but the formatting will be a bit off.
created_at: 2025-04-27T01:59:50Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    # connectors = ("|-- ", "+-- ")
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            if args.size and not is_dir:
+                print(f"{prefix}{connector}[{entry.st_size:>12}] {entry.name}")
+            elif args.human and not is_dir:
+                print(f"{prefix}{connector}[{human_size(entry.st_size):>8s}] {entry.name}")
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: As above, the width here could probably be less.  I think 6 is enough to fit everything (without a space between the number and unit, see above).
created_at: 2025-04-27T02:02:42Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: Unix tree doesn't have a space between the number and unit, so maybe it doesn't need it here either?

Also, this won't work on boards without floating point support.  I think it's worth making it work on such boards.  Considering you only want 1 decimal place, you could multiply `size` by 10 to start with, then do `size // 10` and `size % 10` to format it.  Also, arguably you don't need the decimal if the integer part is >= 100 (only need 3 significant figures).
created_at: 2025-04-27T02:05:45Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
path: tools/mpremote/mpremote/commands.py
user: login: Josverl
body: > Also, this won't work on boards without floating point support.

This computation is not executed on the MCU though, or is the intent for the host to lack FP capabilities?
created_at: 2025-04-27T09:23:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: > This computation is not executed on the MCU though, or is the intent for the host to lack FO capabilities?

Ah, you're right, this does not execute on the target MCU.

So, it's OK to use floats here.
created_at: 2025-04-27T11:57:11Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    # connectors = ("|-- ", "+-- ")
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            if args.size and not is_dir:
+                print(f"{prefix}{connector}[{entry.st_size:>12}] {entry.name}")
+            elif args.human and not is_dir:
path: tools/mpremote/mpremote/commands.py
user: login: Josverl
body: does work reliably across ports ?
I did not know for sure so I left it out , especially as `tree --help` in Ubuntu actually states : _"-s            Print the size in bytes of each file."_
but , it actually does print the size in Ubutu though ...

Ill add it in and test it across a few ports for consistency 
-=-=-
I gave it a quick test 
| port| reported folder size |
|--------|--------|
| samd| 0 |
| esp32 | 0|
| stm32| 0| 
| rp2 | 0 | 

underlying tree uses `fs_listdir()` that uses uPy's  `os.ilistdir()` for which the docs state :

> Some platforms may return a 4-tuple that includes the entry’s size. For file entries, size is an integer representing the size of the file or -1 if unknown. Its meaning is currently undefined for directory entries.

Possible solutions : 
 1. For directory entries show the size only if it is > 0 ; this allows for future improvements to vfs / ilistdir : 
 2. In order to report a directory size the host will need to build a tree, and add up the total file sizes. That is possible but will need a change to the tree render algorithm 

My preference is option 1)
 
PS: 
doing a quick test I find that `os.stat()` does returns inconsistent data, assuming that  `os.stat('folder')[6]` would match Cpython's st_size. 

It does not. Possibly related - [the limited docs ](https://docs.micropython.org/en/latest/library/os.html#os.stat)
Cpython:
```py
>>> import os
>>> os.stat("ports/zephyr/boards")
os.stat_result(st_mode=16877, st_ino=72960, st_dev=2080, st_nlink=2, st_uid=1000, st_gid=1000, st_size=4096, st_atime=1745701367, st_mtime=1745612494, st_ctime=1745612494)
```
micropython
```sh
mpremote resume cp -rv ~/micropython/ports/zephyr/boards :
mpremote exec "import os;print(os.stat('boards'));print(os.stat('boards'));print(os.stat('boards'))"
#PICO2
(16384, 0, 0, 0, 0, 0, 268539903, 0, 0, 0)
(16384, 0, 0, 0, 0, 0, 1, 0, 0, 0)
(16384, 0, 0, 0, 0, 0, 1, 0, 0, 0)
#SAMD51
jos@josverl-sb5:~/micropython$ mpremote exec "import os;print(os.stat('boards'));print(os.stat('boards'));print(os.stat('boards'))"
(16384, 0, 0, 0, 0, 0, 0, 0, 0, 0)
(16384, 0, 0, 0, 0, 0, 1, 0, 0, 0)
(16384, 0, 0, 0, 0, 0, 1, 0, 0, 0)
```   

created_at: 2025-04-27T12:17:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -552,8 +573,12 @@ def main():
                 command_args = remaining_args
                 extra_args = []
 
-            # Special case: "fs ls" allowed have no path specified.
-            if cmd == "fs" and len(command_args) == 1 and command_args[0] == "ls":
+            # Special case: "fs ls" and "fs tree" allowed have no path specified.
+            if (
+                cmd == "fs"
+                and command_args[0] in ("ls", "tree")
path: tools/mpremote/mpremote/main.py
user: login: Josverl
body: indeed - there was no test case for that so I never tried.
creating a test_basic.sh and adding to that
created_at: 2025-04-27T12:18:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    # connectors = ("|-- ", "+-- ")
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            if args.size and not is_dir:
+                print(f"{prefix}{connector}[{entry.st_size:>12}] {entry.name}")
path: tools/mpremote/mpremote/commands.py
user: login: Josverl
body: I have been tinkering with the width as well. 
personally I think the space improves readability , but if the goal is full consistence with linux/ubuntu -h size should be 4 characters, but 6 makes more sense to allow for one decimal of precision

| format| unix tree | mpremote tree | notes|
|--------|--------|-------|---|
| --size | 12 | 9 | smaller files on micropython
| --human | 4 | 6| allow for one decimal `[ 13.8K]`

<details><summary>Details</summary>
<p>


```
$ tree ports/zephyr/boards/ -h
[4.0K]  ports/zephyr/boards/
├── [  46]  96b_carbon.conf
├── [ 163]  bbc_microbit_v2.conf
├── [  97]  beagleconnect_freedom.conf
├── [  77]  disco_l475_iot1.conf
├── [ 264]  frdm_k64f.conf
├── [ 122]  frdm_kw41z.conf
├── [ 206]  mimxrt1050_evk.conf
├── [  33]  mimxrt685_evk_cm33.conf
├── [ 273]  nrf52840dk_nrf52840.conf
├── [ 146]  nucleo_h743zi.conf
├── [ 467]  nucleo_h743zi.overlay
├── [ 131]  nucleo_wb55rg.conf
├── [ 172]  qemu_cortex_m3.conf
├── [ 172]  qemu_x86.conf
├── [  81]  reel_board.conf
└── [  50]  rv32m1_vega_ri5cy.conf

0 directories, 16 files
(.venv) (.venv) jos@josverl-sb5:~/micropython$ tree ports/zephyr/boards/ -s
[       4096]  ports/zephyr/boards/
├── [         46]  96b_carbon.conf
├── [        163]  bbc_microbit_v2.conf
├── [         97]  beagleconnect_freedom.conf
├── [         77]  disco_l475_iot1.conf
├── [        264]  frdm_k64f.conf
├── [        122]  frdm_kw41z.conf
├── [        206]  mimxrt1050_evk.conf
├── [         33]  mimxrt685_evk_cm33.conf
├── [        273]  nrf52840dk_nrf52840.conf
├── [        146]  nucleo_h743zi.conf
├── [        467]  nucleo_h743zi.overlay
├── [        131]  nucleo_wb55rg.conf
├── [        172]  qemu_cortex_m3.conf
├── [        172]  qemu_x86.conf
├── [         81]  reel_board.conf
└── [         50]  rv32m1_vega_ri5cy.conf
....
│   │   └── [4.0K]  upstream
│   │       ├── [4.0K]  bin
│   │       │   ├── [   8]  clang -> clang-21
│   │       │   ├── [   5]  clang++ -> clang
│   │       │   ├── [130M]  clang-21
│   │       │   ├── [ 79M]  clang-scan-deps
│   │       │   ├── [ 72M]  lld
│   │       │   ├── [  15]  llvm-addr2line -> llvm-symbolizer
│   │       │   ├── [9.6M]  llvm-ar
│   │       │   ├── [1.1M]  llvm-cxxfilt
│   │       │   ├── [9.4M]  llvm-dwarfdump
│   │       │   ├── [ 33M]  llvm-dwp
│   │       │   ├── [ 11M]  llvm-nm
│   │       │   ├── [6.0M]  llvm-objcopy
│   │       │   ├── [ 17M]  llvm-objdump
│   │       │   ├── [   7]  llvm-ranlib -> llvm-ar
│   │       │   ├── [9.1M]  llvm-readobj
│   │       │   ├── [5.3M]  llvm-size
│   │       │   ├── [791K]  llvm-strings
│   │       │   ├── [  12]  llvm-strip -> llvm-objcopy
│   │       │   ├── [7.2M]  llvm-symbolizer
```

</p>
</details> 

created_at: 2025-04-27T12:48:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    # connectors = ("|-- ", "+-- ")
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            if args.size and not is_dir:
path: tools/mpremote/mpremote/commands.py
user: login: Josverl
body: see below comment.
Changed code to improve alignment , but suppress showing folder sizes <= 0 for both -s and -h   
created_at: 2025-04-27T13:58:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -334,6 +334,49 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimal_places=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimal_places}f} {unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    # connectors = ("|-- ", "+-- ")
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            if args.size and not is_dir:
+                print(f"{prefix}{connector}[{entry.st_size:>12}] {entry.name}")
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: > if the goal is full consistence with linux/ubuntu -h

We don't need full consistency with the output from unix tree.  But following unix tree is a good place to start, then we can tweak to suit MicroPython's use cases.  9 for --size and 6 for --human looks good to me.
created_at: 2025-04-28T00:16:55Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,50 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimals=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimals}f}{unit}" if unit != 'B' else f"{int(size)} {unit}"
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: Did you intend to add the unit `B` in the case of bytes?  I think it can be left out, no unit means bytes.
created_at: 2025-04-28T00:21:00Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,50 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimals=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimals}f}{unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            size_str = ""
+            # most micropyhon filesystems don't support st_size on folders - reduce clutter
+            if args.size:
+                if entry.st_size > 0 or not is_dir:
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: You could move this if check outside, to make it a bit simpler:
```py
if entry.st_size > 0 or not is_dir:
    if args.size:
        ...
    elif args.human:
        ...
```
created_at: 2025-04-28T00:21:46Z
author_association: MEMBER
diff_hunk: @@ -361,8 +405,8 @@ def do_filesystem(state, args):
         # leading ':' if the user included them.
         paths = [path[1:] if path.startswith(":") else path for path in paths]
 
-    # ls implicitly lists the cwd.
-    if command == "ls" and not paths:
+    # ls and tree implicitly lists the cwd.
+    if command in ["ls", "tree"] and not paths:
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: Please use tuple `("ls", "tree")`.
created_at: 2025-04-28T00:22:18Z
author_association: MEMBER
diff_hunk: @@ -334,6 +334,50 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimals=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimals}f}{unit}" if unit != 'B' else f"{int(size)} {unit}"
+
+
+def do_filesystem_tree(state, path, args):
+    """Print a tree of the device's filesystem starting at path."""
+    connectors = ("├── ", "└── ")
+
+    def _tree_recursive(path, prefix=""):
+        entries = state.transport.fs_listdir(path)
+        entries.sort(key=lambda e: e.name)
+        for i, entry in enumerate(entries):
+            connector = connectors[1] if i == len(entries) - 1 else connectors[0]
+            is_dir = entry.st_mode & 0x4000  # Directory
+            size_str = ""
+            # most micropyhon filesystems don't support st_size on folders - reduce clutter
+            if args.size:
+                if entry.st_size > 0 or not is_dir:
path: tools/mpremote/mpremote/commands.py
user: login: Josverl
body: The B for Bytes is actually supressed later so ot is not in the output
Indeed simpler not to add it in the first place
created_at: 2025-04-28T01:05:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -334,6 +334,50 @@ def do_filesystem_recursive_rm(state, path, args):
             print(f"removed: '{path}'")
 
 
+def human_size(size, decimals=1):
+    for unit in ['B', 'K', 'M', 'G', 'T']:
+        if size < 1024.0 or unit == 'T':
+            break
+        size /= 1024.0
+    return f"{size:.{decimals}f}{unit}" if unit != 'B' else f"{int(size)} {unit}"
path: tools/mpremote/mpremote/commands.py
user: login: Josverl
body: No the intent was indeed to drop it.
created_at: 2025-04-28T01:19:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -361,8 +405,8 @@ def do_filesystem(state, args):
         # leading ':' if the user included them.
         paths = [path[1:] if path.startswith(":") else path for path in paths]
 
-    # ls implicitly lists the cwd.
-    if command == "ls" and not paths:
+    # ls and tree implicitly lists the cwd.
+    if command in ["ls", "tree"] and not paths:
path: tools/mpremote/mpremote/commands.py
user: login: Josverl
body: I'd swear I had done that before.
Sorry to make you repeat yourself.
created_at: 2025-04-28T01:25:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -361,8 +405,8 @@ def do_filesystem(state, args):
         # leading ':' if the user included them.
         paths = [path[1:] if path.startswith(":") else path for path in paths]
 
-    # ls implicitly lists the cwd.
-    if command == "ls" and not paths:
+    # ls and tree implicitly lists the cwd.
+    if command in ["ls", "tree"] and not paths:
path: tools/mpremote/mpremote/commands.py
user: login: dpgeorge
body: You did already do that, on a separate line :smile: 
created_at: 2025-04-28T03:38:12Z
author_association: MEMBER
diff_hunk: @@ -552,8 +573,12 @@ def main():
                 command_args = remaining_args
                 extra_args = []
 
-            # Special case: "fs ls" allowed have no path specified.
-            if cmd == "fs" and len(command_args) == 1 and command_args[0] == "ls":
+            # Special case: "fs ls" and "fs tree" allowed have no path specified.
+            if (
+                cmd == "fs"
+                and command_args[0] in ("ls", "tree")
path: tools/mpremote/mpremote/main.py
user: login: dpgeorge
body: For some reason this line is added but commented out :joy: 
created_at: 2025-04-29T12:59:55Z
author_association: MEMBER
diff_hunk: @@ -552,8 +573,12 @@ def main():
                 command_args = remaining_args
                 extra_args = []
 
-            # Special case: "fs ls" allowed have no path specified.
-            if cmd == "fs" and len(command_args) == 1 and command_args[0] == "ls":
+            # Special case: "fs ls" and "fs tree" allowed have no path specified.
+            if (
+                cmd == "fs"
+                and command_args[0] in ("ls", "tree")
path: tools/mpremote/mpremote/main.py
user: login: Josverl
body: Disabled it to test the test,
then never re-enabled it 🙃
created_at: 2025-05-02T15:08:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,6 +234,9 @@ The full list of supported commands are:
   - ``rmdir <dirs...>`` to remove directories on the device
   - ``touch <file..>`` to create the files (if they don't already exist)
   - ``sha256sum <file..>`` to calculate the SHA256 sum of files
+  - ``tree [-vsh] <dirs...>`` to print a tree of the given directories
+  
+  *mpremote fs cp*
path: docs/reference/mpremote.rst
user: login: dpgeorge
body: I'm not sure these sub-sub-headings make this section any clearer / easier to read.  They are rendered in italics and not easy to spot.

I also don't think it's necessary to have an example output of the tree command.  No other command has example output (only example usage of the command).

I think it's enough to just add a single paragraph about tree (eg after the bit about rm).
created_at: 2025-05-07T04:35:38Z
author_association: MEMBER
diff_hunk: @@ -245,6 +248,20 @@ The full list of supported commands are:
   ``mpremote fs cp :main.py main.py`` copies ``main.py`` from the device back
   to the current directory.
 
+  .. code-block:: bash
+
+      $ mpremote fs cp main.py :main.py + repl
path: docs/reference/mpremote.rst
user: login: dpgeorge
body: This example should go below, the bit documenting the `+` character.

I think it would be better to just add a single paragraph for tree and not reorder anything else.
created_at: 2025-05-07T04:36:20Z
author_association: MEMBER
diff_hunk: @@ -264,24 +281,37 @@ The full list of supported commands are:
     There is no supported way to undelete files removed by ``mpremote rm -r :``.
     Please use with caution.
 
-  All other commands implicitly assume the path is a remote path, but the ``:``
-  can be optionally used for clarity.
+  *mpremote fs tree*
 
-  All of the filesystem sub-commands take multiple path arguments, so if there
-  is another command in the sequence, you must use ``+`` to terminate the
-  arguments, e.g.
+  The ``tree`` command will print a tree of the given directories. 
+  Using the ``--size/-s`` option will print the size of each file, or use 
+  ``--human/-h`` to use a more human readable format. 
+  Note: Directory size is only printed when a non-zero size is reported by the device's filesystem.
+  The ``-v`` option  can be used to include the name of the serial device in 
+  the output.
 
   .. code-block:: bash
 
-      $ mpremote fs cp main.py :main.py + repl
+        $ mpremote tree -hv
+        tree :
+        : /on /dev/ttyACM0
+        ├── [   90B] config.py
+        ├── data
+        │   └── [   122K] norwegian.log
+        ├── lib
+        │   ├── [  2.4K] parrot.py
+        │   └── [  3.4K] worker.py
+        ├── [  800B] main.py
+        └── [  2.5K] readme.md
 
-  This will copy the file to the device then enter the REPL. The ``+`` prevents
-  ``"repl"`` being interpreted as a path.
+  *other fs commands*
 
-  The ``cp`` command supports the ``-r`` option to make a recursive copy.  By
-  default ``cp`` will skip copying files to the remote device if the SHA256 hash
-  of the source and destination file matches.  To force a copy regardless of the
-  hash use the ``-f`` option.
+  All other commands implicitly assume the path is a remote path, but the ``:``
+  can be optionally used for clarity.
+
+  All of the filesystem sub-commands take multiple path arguments, so if there
+  is another command in the sequence, you must use ``+`` to terminate the
+  arguments, e.g.
path: docs/reference/mpremote.rst
user: login: dpgeorge
body: This "eg" is missing its example code.  As mentioned above, this would be fixed by not reordering the file, just add a paragraph about tree.
created_at: 2025-05-07T04:36:54Z
author_association: MEMBER
diff_hunk: @@ -264,24 +281,37 @@ The full list of supported commands are:
     There is no supported way to undelete files removed by ``mpremote rm -r :``.
     Please use with caution.
 
-  All other commands implicitly assume the path is a remote path, but the ``:``
-  can be optionally used for clarity.
+  *mpremote fs tree*
 
-  All of the filesystem sub-commands take multiple path arguments, so if there
-  is another command in the sequence, you must use ``+`` to terminate the
-  arguments, e.g.
+  The ``tree`` command will print a tree of the given directories. 
+  Using the ``--size/-s`` option will print the size of each file, or use 
+  ``--human/-h`` to use a more human readable format. 
+  Note: Directory size is only printed when a non-zero size is reported by the device's filesystem.
+  The ``-v`` option  can be used to include the name of the serial device in 
+  the output.
 
   .. code-block:: bash
 
-      $ mpremote fs cp main.py :main.py + repl
+        $ mpremote tree -hv
+        tree :
+        : /on /dev/ttyACM0
+        ├── [   90B] config.py
+        ├── data
+        │   └── [   122K] norwegian.log
+        ├── lib
+        │   ├── [  2.4K] parrot.py
+        │   └── [  3.4K] worker.py
+        ├── [  800B] main.py
+        └── [  2.5K] readme.md
path: docs/reference/mpremote.rst
user: login: dpgeorge
body: As mentioned above, I don't think this example is needed.
created_at: 2025-05-07T04:37:11Z
author_association: MEMBER
diff_hunk: @@ -234,6 +234,9 @@ The full list of supported commands are:
   - ``rmdir <dirs...>`` to remove directories on the device
   - ``touch <file..>`` to create the files (if they don't already exist)
   - ``sha256sum <file..>`` to calculate the SHA256 sum of files
+  - ``tree [-vsh] <dirs...>`` to print a tree of the given directories
+  
+  *mpremote fs cp*
path: docs/reference/mpremote.rst
user: login: Josverl
body: I'm in conflict on some of your comments .
- the example can go , no issue there 
I think that mpremote is under documented, and that the doc is hard to follow in places.
I tried to improve that by adding the sub headings, but indeed italics is not too clear.

As mpremote bundles a lot of different functionality I was considering that some parts , such as the `fs` command , and perhaps the `mip` commands are better documented in their own, linked, reference page. Similar to the structure for modules and Classes.

While the fs commands are probably obvious to a linux user and need no further explanation, I still find (as someone reared on Windows)  need to lookup detailed syntaxes, and when they are not in the mpremote docs , I need to know to search for a man page and mentally filter that. 

That would allow for some more room , without getting very long pages , allow to add some more detail about often used commands.

Perhaps such a restructure should be part of a different PR , or I can give it a try in a isolated commit.

created_at: 2025-05-08T19:08:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,6 +234,9 @@ The full list of supported commands are:
   - ``rmdir <dirs...>`` to remove directories on the device
   - ``touch <file..>`` to create the files (if they don't already exist)
   - ``sha256sum <file..>`` to calculate the SHA256 sum of files
+  - ``tree [-vsh] <dirs...>`` to print a tree of the given directories
+  
+  *mpremote fs cp*
path: docs/reference/mpremote.rst
user: login: dpgeorge
body: > I think that mpremote is under documented, and that the doc is hard to follow in places.
>       I tried to improve that by adding the sub headings, but indeed italics is not too clear.

OK, that's a fair comment.

Let's improve the documentation in a separate PR.  Trying to do that here means possibly blocking this PR for a lot longer.

So I suggest to just add the one-liner `tree [-vsh] <dirs...>` here (as you currently have it), and a single extra paragraph about how `fs tree` works.  Then that's good enough to start with.

Another PR can expand and improve this documentation.
created_at: 2025-05-09T01:33:22Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-10T17:08:12Z
body: ## [Codecov](https://app.codecov.io/gh/micropython/micropython/pull/17112?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=micropython) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.54%. Comparing base [(`ecbbc51`)](https://app.codecov.io/gh/micropython/micropython/commit/ecbbc512b2083c472f7bec45e8fae521e7af22aa?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=micropython) to head [(`e39243c`)](https://app.codecov.io/gh/micropython/micropython/commit/e39243c3820c193b97f34ff033c9a598e40277a7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=micropython).
> Report is 3 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master   #17112   +/-   ##
=======================================
  Coverage   98.54%   98.54%           
=======================================
  Files         169      169           
  Lines       21897    21897           
=======================================
  Hits        21579    21579           
  Misses        318      318           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/micropython/micropython/pull/17112?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=micropython).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=micropython).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-10T17:15:33Z
body: Code size report:

```
   bare-arm:    +0 +0.000% 
minimal x86:    +0 +0.000% 
   unix x64:    +0 +0.000% standard
      stm32:    +0 +0.000% PYBV10
     mimxrt:    +0 +0.000% TEENSY40
        rp2:    +0 +0.000% RPI_PICO_W
       samd:    +0 +0.000% ADAFRUIT_ITSYBITSY_M4_EXPRESS
  qemu rv32:    +0 +0.000% VIRT_RV32

```

author_association: NONE
user: login: dpgeorge
created_at: 2025-04-24T12:50:15Z
body: This is great, thank you!

> * -s/--size add a (human readable) size to the files. ( not using -h as that )

Honestly, I find non-human-readable sizes, ie just byte sizes, more readable.  Because the size/length of the number shows how big the file is, and it's much easier to spot small/big files when `-h` is *not* used.

So, maybe add a `-h` option that needs to be explicitly specified, to match the unix `tree` command?
author_association: MEMBER
user: login: Josverl
created_at: 2025-04-24T16:43:10Z
body: Should be no problem to add both -s and -h
author_association: CONTRIBUTOR
user: login: Josverl
created_at: 2025-04-25T21:10:17Z
body: mpremote tree now takes either `--size/-s` or `--human` und uses the corresponding size format.
<details><summary>Details</summary>
<p>

```sh
$ mpremote tree -s
tree :
:/
└── boards
    ├── [          46] 96b_carbon.conf
    ├── [         163] bbc_microbit_v2.conf
    ├── [          97] beagleconnect_freedom.conf
    ├── [          77] disco_l475_iot1.conf
    ├── [         264] frdm_k64f.conf
    ├── [         122] frdm_kw41z.conf
    ├── [         206] mimxrt1050_evk.conf
    ├── [          33] mimxrt685_evk_cm33.conf
    ├── [         273] nrf52840dk_nrf52840.conf
    ├── [         146] nucleo_h743zi.conf
    ├── [         467] nucleo_h743zi.overlay
    ├── [         131] nucleo_wb55rg.conf
    ├── [         172] qemu_cortex_m3.conf
    ├── [         172] qemu_x86.conf
    ├── [          81] reel_board.conf
    └── [          50] rv32m1_vega_ri5cy.conf

$ mpremote tree --human
tree :
:/
└── boards
    ├── [    46 B] 96b_carbon.conf
    ├── [   163 B] bbc_microbit_v2.conf
    ├── [    97 B] beagleconnect_freedom.conf
    ├── [    77 B] disco_l475_iot1.conf
    ├── [   264 B] frdm_k64f.conf
    ├── [   122 B] frdm_kw41z.conf
    ├── [   206 B] mimxrt1050_evk.conf
    ├── [    33 B] mimxrt685_evk_cm33.conf
    ├── [   273 B] nrf52840dk_nrf52840.conf
    ├── [   146 B] nucleo_h743zi.conf
    ├── [   467 B] nucleo_h743zi.overlay
    ├── [   131 B] nucleo_wb55rg.conf
    ├── [   172 B] qemu_cortex_m3.conf
    ├── [   172 B] qemu_x86.conf
    ├── [    81 B] reel_board.conf
    └── [    50 B] rv32m1_vega_ri5cy.conf

```

</p>
</details> 
author_association: CONTRIBUTOR
user: login: Josverl
created_at: 2025-04-26T21:43:40Z
body: - [x] updated and rebased
author_association: CONTRIBUTOR
user: login: Josverl
created_at: 2025-04-27T14:53:56Z
body: @dpgeorge , 
on the whole I do not like the esthetics an usability of identing the directories / folders with no size reported by `ilistdir()` ( 100% in my testing) 

I propose to implement **option 1)** below

### 1) not outlined - no []
```
│   ├── [      172]qemu_cortex_m3.conf
│   ├── [      172]qemu_x86.conf
│   ├── [       81]reel_board.conf
│   └── [       50]rv32m1_vega_ri5cy.conf
├── foo
│   ├── [        0]aa.py
│   └── [        0]ba.py
├── [        0]foo.py
└── lib
    ├── aioble
    │   ├── [      602]__init__.mpy
    │   ├── [     2465]central.mpy
    │   ├── [     4116]client.mpy
    │   ├── [      763]core.mpy
```

### 2) outlined - no []
```
│   ├── [      172]qemu_cortex_m3.conf
│   ├── [      172]qemu_x86.conf
│   ├── [       81]reel_board.conf
│   └── [       50]rv32m1_vega_ri5cy.conf
├──            foo
│   ├── [        0]aa.py
│   └── [        0]ba.py
├── [        0]foo.py
└──            lib
    ├──            aioble
    │   ├── [      602]__init__.mpy
    │   ├── [     2465]central.mpy

```
### 3) outlined , [] 
```
│   ├── [      172]qemu_x86.conf
│   ├── [       81]reel_board.conf
│   └── [       50]rv32m1_vega_ri5cy.conf
├── [           ]foo
│   ├── [        0]aa.py
│   └── [        0]ba.py
├── [        0]foo.py
└── [           ]lib
    ├── [           ]aioble
    │   ├── [      602]__init__.mpy
    │   ├── [     2465]central.mpy
    │   ├── [     4116]client.mpy
    │   ├── [      763]core.mpy
```
### 4) outlined , [ 0] 

```
│   ├── [      172]qemu_cortex_m3.conf
│   ├── [      172]qemu_x86.conf
│   ├── [       81]reel_board.conf
│   └── [       50]rv32m1_vega_ri5cy.conf
├── [        0]foo
│   ├── [        0]aa.py
│   └── [        0]ba.py
├── [        0]foo.py
└── [        0]lib
    ├── [        0]aioble
    │   ├── [      602]__init__.mpy
    │   ├── [     2465]central.mpy
    │   ├── [     4116]client.mpy
```

author_association: CONTRIBUTOR
user: login: mattytrentini
created_at: 2025-04-28T01:19:24Z
body: > @dpgeorge , on the whole I do not like the esthetics an usability of identing the directories / folders with no size reported by `ilistdir()` ( 100% in my testing)
> 
> I propose to implement **option 1)** below

FWIW I prefer **option 1)** too. 

A small request though: Could you add two spaces after the closing square bracket (as unix tree does)? Dramatically improves readability IMO.

I prefer the human readable output, though I note that it's different to unix tree (which is always 4 chars, I believe). 

In any case, thanks @Josverl! This is a handy addition.
author_association: CONTRIBUTOR
user: login: Josverl
created_at: 2025-04-28T01:43:31Z
body: @mattytrentini This was the reasoning 
| format| unix tree | mpremote tree | notes|
|--------|--------|-------|---|
| --size | 12 | 9 | smaller files on micropython
| --human | 4 | 6| allow for one decimal `[ 13.8K]` (for those smaller files) 

And one additional space coming up 


author_association: CONTRIBUTOR
user: login: dpgeorge
created_at: 2025-05-06T01:02:36Z
body: Testing the latest version, doing `mpremote tree` it works without specifying a path.  But doing `mpremote tree -s` it doesn't work, it requires an explicit path.  Is that intentional, and maybe something that can be fixed?
author_association: MEMBER
user: login: Josverl
created_at: 2025-05-06T09:27:25Z
body: Thanks for testing all the edge cases.

The [`and len(command_args) == 1` ](https://github.com/Josverl/micropython/blob/1c365473054dfc0c3c8879a03584e7e4f48993e3/tools/mpremote/mpremote/main.py#L579)  needed to be `>= 1` when using options without a path.

Added a test case for that as well.
author_association: CONTRIBUTOR
user: login: dpgeorge
created_at: 2025-05-07T04:37:50Z
body: Thanks for updating the code, it works well now.

The last thing to do is finalise the docs changes.
author_association: MEMBER
user: login: Josverl
created_at: 2025-05-16T20:31:24Z
body: @dpgeorge , just FYI, the docs have been reversed to the simpler format you requested 
author_association: CONTRIBUTOR
user: login: dpgeorge
created_at: 2025-05-17T14:31:00Z
body: This is a good feature, I'll be using it a lot!
author_association: MEMBER