├── pyproject.toml ├── LICENSE ├── README.md ├── .gitignore ├── uv.lock └── tree.py /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "terminal-tree" 3 | version = "0.1.5" 4 | description = "Experimental file navigator for the terminal" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "textual>=0.85.2", 9 | "rich>=13.9.4" 10 | ] 11 | 12 | [project.scripts] 13 | terminal-tree = "tree:run" 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Will McGugan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | An experimental filesystem navigator for the terminal, built with [Textual](https://github.com/textualize/textual) 3 | 4 | 5 | https://github.com/user-attachments/assets/de4c9bab-4cfa-4295-bd2e-450df855ef0d 6 | 7 | This could form the basis of a file manager / picker. 8 | For now, consider it a UI experiment. 9 | 10 | PS Its a [single file](https://github.com/willmcgugan/terminal-tree/blob/main/tree.py). 11 | 12 | ## Installing 13 | 14 | This project isn't on Pypi or other package manager, but thanks to the sorcery that is [uv](https://docs.astral.sh/uv/guides/tools/) you can try it out with the following command: 15 | 16 | ``` 17 | uvx --from git+https://github.com/willmcgugan/terminal-tree.git --python 3.12 -q terminal-tree 18 | ``` 19 | 20 | Tested in macOS only at this point. Chances are very high it works on Linux. Slightly lower chance (but non-zero) that it works on Windows. 21 | 22 | ## Tree navigation 23 | 24 | ![tree_navigator](https://github.com/user-attachments/assets/52705568-4d1b-47e5-9d5b-d7bfe8ad509e) 25 | 26 | A directory tree that may be navigated by the keyboard or mouse. 27 | 28 | ## File preview 29 | 30 | ![file_preview](https://github.com/user-attachments/assets/79d2d351-abca-45f6-82b2-5c7a82fef316) 31 | 32 | Some text file-types may be displayed with syntax highlighting in a preview panel. 33 | 34 | This preview panel may be maximized from the command palette. 35 | 36 | ## Path completion and validation 37 | 38 | ![path_complete](https://github.com/user-attachments/assets/6ae4a414-9b4d-4b5d-812a-fdb8ddf3381c) 39 | 40 | Hit `g` to edit the current path. 41 | 42 | The path will auto-complete as you type. Press `right` to accept the auto-completion. 43 | 44 | The path is also validated as you type. Invalid (non directory) paths are highlighted in red, or green if it is a valid path. 45 | 46 | ## Path components 47 | 48 | ![path_select](https://github.com/user-attachments/assets/6310badf-a5ba-43fc-a8fd-97cce69ad161) 49 | 50 | 51 | You can also click on a path component to navigate to a parent directory. 52 | 53 | ## No issues please 54 | 55 | I don't know is this will become a standalone tool, or be folded back in to [Textual](https://github.com/textualize/textual). 56 | 57 | If you are interested in this project, please fork it. let me know if you do anything interesting with it! 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .envrc 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.12" 3 | 4 | [[package]] 5 | name = "linkify-it-py" 6 | version = "2.0.3" 7 | source = { registry = "https://pypi.org/simple" } 8 | dependencies = [ 9 | { name = "uc-micro-py" }, 10 | ] 11 | sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946 } 12 | wheels = [ 13 | { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820 }, 14 | ] 15 | 16 | [[package]] 17 | name = "markdown-it-py" 18 | version = "3.0.0" 19 | source = { registry = "https://pypi.org/simple" } 20 | dependencies = [ 21 | { name = "mdurl" }, 22 | ] 23 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 24 | wheels = [ 25 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 26 | ] 27 | 28 | [package.optional-dependencies] 29 | linkify = [ 30 | { name = "linkify-it-py" }, 31 | ] 32 | plugins = [ 33 | { name = "mdit-py-plugins" }, 34 | ] 35 | 36 | [[package]] 37 | name = "mdit-py-plugins" 38 | version = "0.4.2" 39 | source = { registry = "https://pypi.org/simple" } 40 | dependencies = [ 41 | { name = "markdown-it-py" }, 42 | ] 43 | sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542 } 44 | wheels = [ 45 | { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316 }, 46 | ] 47 | 48 | [[package]] 49 | name = "mdurl" 50 | version = "0.1.2" 51 | source = { registry = "https://pypi.org/simple" } 52 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 53 | wheels = [ 54 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 55 | ] 56 | 57 | [[package]] 58 | name = "platformdirs" 59 | version = "4.3.6" 60 | source = { registry = "https://pypi.org/simple" } 61 | sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } 62 | wheels = [ 63 | { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, 64 | ] 65 | 66 | [[package]] 67 | name = "pygments" 68 | version = "2.18.0" 69 | source = { registry = "https://pypi.org/simple" } 70 | sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } 71 | wheels = [ 72 | { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, 73 | ] 74 | 75 | [[package]] 76 | name = "rich" 77 | version = "13.9.4" 78 | source = { registry = "https://pypi.org/simple" } 79 | dependencies = [ 80 | { name = "markdown-it-py" }, 81 | { name = "pygments" }, 82 | ] 83 | sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } 84 | wheels = [ 85 | { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, 86 | ] 87 | 88 | [[package]] 89 | name = "terminal-tree" 90 | version = "0.1.4" 91 | source = { virtual = "." } 92 | dependencies = [ 93 | { name = "rich" }, 94 | { name = "textual" }, 95 | ] 96 | 97 | [package.metadata] 98 | requires-dist = [ 99 | { name = "rich", specifier = ">=13.9.4" }, 100 | { name = "textual", specifier = ">=0.85.2" }, 101 | ] 102 | 103 | [[package]] 104 | name = "textual" 105 | version = "0.85.2" 106 | source = { registry = "https://pypi.org/simple" } 107 | dependencies = [ 108 | { name = "markdown-it-py", extra = ["linkify", "plugins"] }, 109 | { name = "platformdirs" }, 110 | { name = "rich" }, 111 | { name = "typing-extensions" }, 112 | ] 113 | sdist = { url = "https://files.pythonhosted.org/packages/71/69/8b2c90ef5863b67f2adb067772b259412130a10c7080e1fede39c6245f73/textual-0.85.2.tar.gz", hash = "sha256:2a416995c49d5381a81d0a6fd23925cb0e3f14b4f239ed05f35fa3c981bb1df2", size = 1462599 } 114 | wheels = [ 115 | { url = "https://files.pythonhosted.org/packages/e9/f0/29bd25c7cd53f2b53bc0205a936b5d3a37c88a70bb91037c939d313d8462/textual-0.85.2-py3-none-any.whl", hash = "sha256:9ccdeb6b8a6a0ff72d497f714934f2e524f2eb67783b459fb08b1339ee537dc0", size = 614939 }, 116 | ] 117 | 118 | [[package]] 119 | name = "typing-extensions" 120 | version = "4.12.2" 121 | source = { registry = "https://pypi.org/simple" } 122 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 123 | wheels = [ 124 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 125 | ] 126 | 127 | [[package]] 128 | name = "uc-micro-py" 129 | version = "1.0.3" 130 | source = { registry = "https://pypi.org/simple" } 131 | sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043 } 132 | wheels = [ 133 | { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229 }, 134 | ] 135 | -------------------------------------------------------------------------------- /tree.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "textual>=0.85.2", 4 | # "rich>=13.9.4", 5 | # ] 6 | # /// 7 | 8 | 9 | import asyncio 10 | import grp 11 | import itertools 12 | import mimetypes 13 | import pwd 14 | import threading 15 | from dataclasses import dataclass 16 | from datetime import datetime 17 | from pathlib import Path 18 | from stat import filemode 19 | 20 | from rich import filesize 21 | from rich.highlighter import Highlighter 22 | from rich.syntax import Syntax 23 | from rich.text import Text 24 | from textual import events, on, work 25 | from textual.app import App, ComposeResult 26 | from textual.binding import Binding 27 | from textual.cache import LRUCache 28 | from textual.containers import Horizontal, ScrollableContainer 29 | from textual.message import Message 30 | from textual.reactive import reactive, var 31 | from textual.screen import ModalScreen 32 | from textual.suggester import Suggester 33 | from textual.validation import ValidationResult, Validator 34 | from textual.widgets import DirectoryTree, Footer, Input, Label, Static, Tree 35 | from textual.widgets.directory_tree import DirEntry 36 | from textual.worker import get_current_worker 37 | 38 | 39 | class DirectoryHighlighter(Highlighter): 40 | """Highlights directories in green, anything else in red. 41 | 42 | This is a [Rich highlighter](https://rich.readthedocs.io/en/latest/highlighting.html), 43 | which can stylize Text based on dynamic criteria. 44 | 45 | Here we are highlighting valid directory paths in green, and invalid directory paths in red. 46 | 47 | """ 48 | 49 | def highlight(self, text: Text) -> None: 50 | path = Path(text.plain).expanduser().resolve() 51 | if path.is_dir(): 52 | text.stylize("green") 53 | else: 54 | text.stylize("red") 55 | 56 | 57 | class DirectoryValidator(Validator): 58 | """Validate a string is a valid directory path. 59 | 60 | This is a Textual [Validator](https://textual.textualize.io/widgets/input/#validating-input) used by 61 | the input widget. 62 | 63 | """ 64 | 65 | def validate(self, value: str) -> ValidationResult: 66 | path = Path(value).expanduser().resolve() 67 | if path.is_dir(): 68 | return self.success() 69 | else: 70 | return self.failure("Directory required", value) 71 | 72 | 73 | class ListDirCache: 74 | """A cache for listing a directory (not a Rich / Textual object). 75 | 76 | This class is responsible for listing directories, and caching the results. 77 | 78 | Listing a directory is a blocking operation, which is why we defer the work to a thread. 79 | 80 | """ 81 | 82 | def __init__(self) -> None: 83 | self._cache: LRUCache[tuple[str, int], list[Path]] = LRUCache(100) 84 | self._lock = threading.Lock() 85 | 86 | async def listdir(self, path: Path, size: int) -> list[Path]: 87 | cache_key = (str(path), size) 88 | 89 | def iterdir_thread(path: Path) -> list[Path]: 90 | """Run iterdir in a thread. 91 | 92 | Returns: 93 | A list of paths. 94 | """ 95 | return list(itertools.islice(path.iterdir(), size)) 96 | 97 | with self._lock: 98 | if cache_key in self._cache: 99 | paths = self._cache[cache_key] 100 | else: 101 | paths = await asyncio.to_thread(iterdir_thread, path) 102 | self._cache[cache_key] = paths 103 | return paths 104 | 105 | 106 | class DirectorySuggester(Suggester): 107 | """Suggest a directory. 108 | 109 | This is a [Suggester](https://textual.textualize.io/api/suggester/#textual.suggester.Suggester) instance, 110 | used by the Input widget to suggest auto-completions. 111 | 112 | """ 113 | 114 | def __init__(self) -> None: 115 | self._cache = ListDirCache() 116 | super().__init__() 117 | 118 | async def get_suggestion(self, value: str) -> str | None: 119 | """Suggest the first matching directory.""" 120 | 121 | try: 122 | path = Path(value) 123 | name = path.name 124 | 125 | children = await self._cache.listdir( 126 | path.expanduser() if path.is_dir() else path.parent.expanduser(), 100 127 | ) 128 | possible_paths = [ 129 | f"{sibling_path}/" 130 | for sibling_path in children 131 | if sibling_path.name.lower().startswith(name.lower()) 132 | and sibling_path.is_dir() 133 | ] 134 | if possible_paths: 135 | possible_paths.sort(key=str.__len__) 136 | suggestion = possible_paths[0] 137 | 138 | if "~" in value: 139 | home = str(Path("~").expanduser()) 140 | suggestion = suggestion.replace(home, "~", 1) 141 | return suggestion 142 | 143 | except FileNotFoundError: 144 | pass 145 | return None 146 | 147 | 148 | class PathComponent(Label): 149 | """Clickable component in a path. 150 | 151 | A simple widget that displays text with a hover effect, that sends 152 | a message when clicked. 153 | 154 | """ 155 | 156 | DEFAULT_CSS = """ 157 | PathComponent { 158 | &:hover { text-style: reverse; } 159 | } 160 | """ 161 | 162 | def on_click(self, event: events.Click) -> None: 163 | self.post_message(PathNavigator.NewPath(Path(self.name or ""))) 164 | 165 | 166 | class InfoBar(Horizontal): 167 | """A widget to display information regarding a file, such as user / size / modification date.""" 168 | 169 | DEFAULT_CSS = """ 170 | InfoBar { 171 | margin: 0 1; 172 | height: 1; 173 | dock: bottom; 174 | .error { color: ansi_bright_red; } 175 | .mode { color: ansi_red; } 176 | .user-name { color: ansi_green; } 177 | .group-name { color: ansi_yellow; } 178 | .file-size { 179 | color: ansi_magenta; 180 | text-style: bold; 181 | } 182 | .modified-time { color: ansi_cyan; } 183 | Label { margin: 0 1 0 0; } 184 | } 185 | """ 186 | 187 | path: reactive[Path] = reactive(Path, recompose=True) 188 | 189 | @staticmethod 190 | def datetime_to_ls_format(date_time: datetime) -> str: 191 | """Convert a datetime object to a string format similar to ls -la output.""" 192 | if date_time.year == datetime.now().year: 193 | # For dates in the current year, use format: "day month HH:MM" 194 | return date_time.strftime("%d %b %H:%M") 195 | else: 196 | # For dates not in the current year, use format: "day month year" 197 | return date_time.strftime("%d %b %Y") 198 | 199 | def compose(self) -> ComposeResult: 200 | try: 201 | stat = self.path.stat() 202 | except Exception: 203 | yield Label("failed to get file info", classes="error") 204 | else: 205 | user_name = pwd.getpwuid(stat.st_uid).pw_name 206 | group_name = grp.getgrgid(stat.st_gid).gr_name 207 | modified_time = datetime.fromtimestamp(stat.st_mtime) 208 | 209 | yield Label(filemode(stat.st_mode), classes="mode") 210 | yield Label(user_name, classes="user-name") 211 | yield Label(group_name, classes="group-name") 212 | yield Label( 213 | self.datetime_to_ls_format(modified_time), classes="modified-time" 214 | ) 215 | if not self.path.is_dir(): 216 | label = Label(filesize.decimal(stat.st_size), classes="file-size") 217 | label.tooltip = f"{stat.st_size} bytes" 218 | yield label 219 | 220 | 221 | class PathDisplay(Horizontal): 222 | """A widget to display the path at the top of the UI. 223 | 224 | Not just simple text, this consists of clickable path components. 225 | 226 | """ 227 | 228 | DEFAULT_CSS = """ 229 | PathDisplay { 230 | layout: horizontal; 231 | height: 1; 232 | dock: top; 233 | align: center top; 234 | text-style: bold; 235 | color: ansi_green; 236 | .separator { margin: 0 0; } 237 | } 238 | """ 239 | 240 | path: reactive[Path] = reactive(Path, recompose=True) 241 | 242 | def compose(self) -> ComposeResult: 243 | path = self.path.resolve().absolute() 244 | 245 | yield Label("📁 ", classes="separator") 246 | components = str(path).split("/") 247 | root_component = PathComponent("/", name="/") 248 | root_component.tooltip = "/" 249 | yield root_component 250 | for index, component in enumerate(components, 1): 251 | partial_path = "/".join(components[:index]) 252 | component_label = PathComponent(component, name=partial_path) 253 | component_label.tooltip = partial_path 254 | yield component_label 255 | if index > 1 and index < len(components): 256 | yield Label("/", classes="separator") 257 | 258 | 259 | class PathScreen(ModalScreen[str | None]): 260 | """A [Modal screen](https://textual.textualize.io/guide/screens/#modal-screens) containing an editable path. 261 | 262 | This is displayed when the user summons the "goto" functionality. 263 | 264 | As a modal screen, it is displayed on top of the previous screen, but only the widgets 265 | her will be usable. 266 | 267 | """ 268 | 269 | BINDINGS = [("escape", "dismiss", "cancel")] 270 | 271 | CSS = """ 272 | PathScreen { 273 | align: center top; 274 | Horizontal { 275 | margin-left: 1; 276 | height: 1; 277 | dock: top; 278 | } 279 | Input { 280 | padding: 0 1; 281 | border: none !important; 282 | height: 1; 283 | &>.input--placeholder, &>.input--suggestion { 284 | text-style: dim not bold !important; 285 | color: ansi_default; 286 | } 287 | &.-valid { 288 | text-style: bold; 289 | color: ansi_green; 290 | } 291 | &.-invalid { 292 | text-style: bold; 293 | color: ansi_red; 294 | } 295 | } 296 | } 297 | """ 298 | 299 | def __init__(self, path: str) -> None: 300 | super().__init__() 301 | self.path = path.rstrip("/") + "/" 302 | 303 | def compose(self) -> ComposeResult: 304 | with Horizontal(): 305 | yield Label("📂") 306 | # The validator and suggester instances pack a lot of functionality in to this input. 307 | yield Input( 308 | value=self.path, 309 | validators=[DirectoryValidator()], 310 | suggester=DirectorySuggester(), 311 | classes="-ansi-colors", 312 | ) 313 | yield (footer := Footer(classes="-ansi-colors")) 314 | footer.compact = True 315 | 316 | @on(Input.Submitted) 317 | def on_input_submitted(self, event: Input.Submitted) -> None: 318 | """If the user submits the input (with enter), we return the value of the input to the caller.""" 319 | self.dismiss(event.input.value) 320 | 321 | def action_dismiss(self): 322 | """If the user dismisses the screen with the escape key, we return None to the caller.""" 323 | self.dismiss(None) 324 | 325 | 326 | class PreviewWindow(ScrollableContainer): 327 | """Widget to show a preview of a file. 328 | 329 | A scrollable container that contains a [Rich Syntax](https://rich.readthedocs.io/en/latest/syntax.html) object 330 | which highlights and formats text. 331 | 332 | """ 333 | 334 | ALLOW_MAXIMIZE = True 335 | DEFAULT_CSS = """ 336 | PreviewWindow { 337 | width: 1fr; 338 | height: 1fr; 339 | border: heavy blank; 340 | overflow-y: scroll; 341 | &:focus { border: heavy ansi_blue; } 342 | #content { width: auto; } 343 | &.-preview-unavailable { 344 | overflow: auto; 345 | hatch: right ansi_black; 346 | align: center middle; 347 | text-style: bold; 348 | color: ansi_red; 349 | } 350 | } 351 | """ 352 | DEFAULT_CLASSES = "-ansi-scrollbar" 353 | 354 | path: var[Path] = var(Path) 355 | 356 | @work(exclusive=True) 357 | async def update_syntax(self, path: Path) -> None: 358 | """Update the preview in a worker. 359 | 360 | A worker runs the code in a concurrent asyncio Task. 361 | 362 | Args: 363 | path: A Path to the file to get the content for. 364 | """ 365 | worker = get_current_worker() 366 | content = self.query_one("#content", Static) 367 | if path.is_file(): 368 | _file_type, encoding = mimetypes.guess_type(str(path)) 369 | 370 | # A text file, we can attempt to syntax highlight it 371 | def read_lines() -> list[str] | None: 372 | """A function to read lines from path in a thread.""" 373 | try: 374 | with open(path, "rt", encoding=encoding or "utf-8") as text_file: 375 | return text_file.readlines(1024 * 32) 376 | except Exception: 377 | # We could be more precise with error handling here, but for now 378 | # we will treat all errors as fails. 379 | return None 380 | 381 | # Read the lines in a thread so as not to pause the UI 382 | lines = await asyncio.to_thread(read_lines) 383 | if lines is None: 384 | self.call_later(content.update, "Preview not available") 385 | self.add_class("-preview-unavailable") 386 | return 387 | 388 | if worker.is_cancelled: 389 | return 390 | 391 | code = "".join(lines) 392 | lexer = Syntax.guess_lexer(str(path), code) 393 | try: 394 | syntax = Syntax( 395 | code, 396 | lexer, 397 | word_wrap=False, 398 | indent_guides=True, 399 | line_numbers=True, 400 | theme="ansi_light", 401 | ) 402 | except Exception: 403 | return 404 | content.update(syntax) 405 | self.remove_class("-preview-unavailable") 406 | 407 | def watch_path(self, path: Path) -> None: 408 | self.update_syntax(path) 409 | 410 | def compose(self) -> ComposeResult: 411 | yield Static("", id="content") 412 | 413 | 414 | class PathNavigator(Horizontal): 415 | """The top-level widget, containing the directory tree and preview window.""" 416 | 417 | DEFAULT_CSS = """ 418 | PathNavigator { 419 | height: auto; 420 | max-height: 100%; 421 | DirectoryTree { 422 | height: auto; 423 | max-height: 100%; 424 | width: 1fr; 425 | border: heavy blank; 426 | &:focus { border: heavy ansi_blue; } 427 | } 428 | PreviewWindow { display: None; } 429 | &.-show-preview { 430 | PreviewWindow { display: block; } 431 | } 432 | } 433 | 434 | """ 435 | 436 | BINDINGS = [ 437 | Binding("r", "reload", "reload", tooltip="Refresh tree from filesystem"), 438 | Binding("g", "goto", "go to", tooltip="Go to a new root path"), 439 | Binding("p", "toggle_preview", "preview", tooltip="Toggle the preview pane"), 440 | ] 441 | 442 | path: reactive[Path] = reactive(Path) 443 | show_preview: reactive[bool] = reactive(False) 444 | 445 | @dataclass 446 | class NewPath(Message): 447 | """Message sent when the path is updated.""" 448 | 449 | path: Path 450 | 451 | def __init__(self, path: Path) -> None: 452 | super().__init__() 453 | self.path = path 454 | 455 | def validate_path(self, path: Path) -> Path: 456 | """Called to validate the path reactive.""" 457 | return path.expanduser().resolve() 458 | 459 | def on_mount(self) -> None: 460 | self.post_message(PathNavigator.NewPath(self.path)) 461 | 462 | def watch_show_preview(self, show_preview: bool) -> None: 463 | self.set_class(show_preview, "-show-preview") 464 | 465 | @on(Tree.NodeHighlighted) 466 | def on_node_highlighted(self, event: Tree.NodeHighlighted[DirEntry]) -> None: 467 | if event.node.data is not None: 468 | self.query_one(InfoBar).path = event.node.data.path 469 | self.query_one(PreviewWindow).path = event.node.data.path 470 | 471 | @on(NewPath) 472 | def on_new_path(self, event: NewPath) -> None: 473 | event.stop() 474 | if not event.path.is_dir(): 475 | self.notify( 476 | f"'{self.path}' is not a directory", 477 | title="Change Directory", 478 | severity="error", 479 | ) 480 | else: 481 | self.path = event.path 482 | self.query_one(DirectoryTree).path = event.path 483 | self.query_one(PathDisplay).path = event.path 484 | 485 | def compose(self) -> ComposeResult: 486 | yield PathDisplay() 487 | tree = DirectoryTree(self.path, classes="-ansi -ansi-scrollbar") 488 | tree.guide_depth = 3 489 | tree.show_root = False 490 | tree.center_scroll = True 491 | yield tree 492 | yield PreviewWindow() 493 | yield InfoBar() 494 | 495 | async def action_reload(self) -> None: 496 | tree = self.query_one(DirectoryTree) 497 | if tree.cursor_node is None: 498 | await tree.reload() 499 | self.notify("👍 Reloaded directory contents", title="Directory") 500 | else: 501 | reload_node = tree.cursor_node.parent 502 | assert reload_node is not None and reload_node.data is not None 503 | path = reload_node.data.path 504 | await tree.reload_node(reload_node) 505 | self.notify(f"👍 Reloaded {str(path)!r}", title="Reload") 506 | 507 | @work 508 | async def action_goto(self) -> None: 509 | """Action to goto a new path. 510 | 511 | This is a worker, because we want to wait on another screen without pausing the event loop. 512 | 513 | Without the "@work" decorator, the UI would be frozen. 514 | 515 | """ 516 | new_path = await self.app.push_screen_wait(PathScreen(str(self.path))) 517 | if new_path is not None: 518 | self.post_message(PathNavigator.NewPath(Path(new_path))) 519 | 520 | async def action_toggle_preview(self) -> None: 521 | self.show_preview = not self.show_preview 522 | self.screen.minimize() 523 | 524 | 525 | class NavigatorApp(App): 526 | """The App class. 527 | 528 | Most app's (like this one) don't contain a great deal of functionality. 529 | They exist to provide CSS, and to create the initial UI. 530 | 531 | """ 532 | 533 | CSS = """ 534 | Screen { 535 | height: auto; 536 | max-height: 80vh; 537 | border: none; 538 | Footer { margin: 0 1 !important; } 539 | &.-maximized-view { 540 | height: 100vh; 541 | hatch: right ansi_black; 542 | } 543 | .-maximized { margin: 1 2; } 544 | } 545 | """ 546 | ALLOW_IN_MAXIMIZED_VIEW = "" 547 | INLINE_PADDING = 0 548 | 549 | def compose(self) -> ComposeResult: 550 | yield PathNavigator(Path("~/")) 551 | footer = Footer(classes="-ansi-colors") 552 | footer.compact = True 553 | yield footer 554 | 555 | def on_mount(self) -> None: 556 | """Highlight the first line of the directory tree on startup.""" 557 | self.query_one(DirectoryTree).cursor_line = 0 558 | 559 | 560 | def run(): 561 | """A function to run the app.""" 562 | # We want ANSI color rather than truecolor. 563 | app = NavigatorApp(ansi_color=True) 564 | # Running inline will display the app below the prompt, rather than go fullscreen. 565 | app.run(inline=True) 566 | 567 | 568 | if __name__ == "__main__": 569 | run() 570 | --------------------------------------------------------------------------------