| 🛟 Updated. 28d5985 k33g 15h ago | 1 | # filetree |
| 2 | |
| 3 | A project's files as an expandable tree, and the widget that shows one. |
| 4 | |
| 5 | Depends on `ui` for its place on screen and `theme` for its colours, and on nothing above it. `app` opens and owns the window; this package knows nothing about menus, buffers or language servers. |
| 6 | |
| 7 | ## Two halves |
| 8 | |
| 9 | **`Tree` and `Node`** — the model. It reads directories, remembers what is open, and flattens itself into the rows a widget would paint. No tcell, no `ui`, no screen: it is tested by making files and comparing values, which is where most of this package's tests are. |
| 10 | |
| 11 | **`View`** — a `ui.Widget`. It draws the rows, walks them with the keyboard and the mouse, and hands a chosen file's path to whoever set `OnOpen`. |
| 12 | |
| 13 | ## What it shows |
| 14 | |
| 15 | Directories first, then files, each group sorted by name. **`.git` is the only thing hidden.** |
| 16 | |
| 17 | That is deliberately not the Open dialog's rule, which hides every dot-entry. `.turbo-go/settings.toml` is a file the editor asks people to edit, and `.gitignore` and `.qlty/` belong to the project too — hiding them would make the editor's own configuration unreachable from the editor's own file browser. `.git` differs in kind rather than spelling: nothing in it is meant to be opened by hand, and it holds enough objects to bury everything else. |
| 18 | |
| 19 | ## Reading is lazy, refreshing is not a walk |
| 20 | |
| 21 | A directory is read the first time it is expanded. Opening a tree on a large project costs one listing. |
| 22 | |
| 23 | `Refresh` re-reads **only the directories that have already been read**, so refreshing costs what is on screen rather than a walk of the project. It keeps the shape: a directory that was open stays open, one that has gone takes its branch with it, and the widget puts the highlight back on the same entry — or on the nearest remaining row when that entry is gone. |
| 24 | |
| 25 | There is no filesystem watching, and no dependency for one. The caller picks the moments: `app` refreshes after a save, and the view refreshes itself on `F5` or `Ctrl-R`. |
| 26 | |
| 27 | ## Theme keys |
| 28 | |
| 29 | `tree.text`, `tree.directory`, `tree.selected`, `tree.unfocused`. |
| 30 | |
| 31 | These could not be borrowed from the `list.*` group, and the reason is worth keeping: `list.selected` is coloured to stand out against a **dialog**. In `turbo-classic` it is white on navy while `window.body` is navy — a tree in a window would have highlighted its selected row in the colour it sits on. `TestTheHighlightIsVisibleInEveryShippedTheme` holds every shipped theme to 64 channel values between `tree.text` and `tree.selected`. |
| 32 | |
| 33 | ## Public API |
| 34 | |
| 35 | | Name | What it does | |
| 36 | | --- | --- | |
| 37 | | `New(root string) (*Tree, error)` | Reads a directory; errors on a file or a path that cannot be read | |
| 38 | | `(*Tree) Root() *Node` | The directory the tree is rooted at | |
| 39 | | `(*Tree) Rows() []Row` | The visible lines, top to bottom; the root is not one of them | |
| 40 | | `(*Tree) Refresh()` | Re-reads what has been read, keeping the shape | |
| 41 | | `Row{Node, Depth}` | One line and how deep it sits | |
| 42 | | `(*Node) Name/Path/IsDir/Expanded/Children` | What one entry is | |
| 43 | | `(*Node) Expand/Collapse/Toggle` | Opening and closing a directory | |
| 44 | | `NewView(root string) (*View, error)` | Starts a tree and returns the widget showing it | |
| 45 | | `(*View) Title() string` | The project's directory name, for the window | |
| 46 | | `(*View) Root() string` | The absolute path it is rooted at | |
| 47 | | `(*View) Selected() *Node` | The highlighted entry, or nil | |
| 48 | | `(*View) Choose()` | Act on the highlight: open a file, toggle a directory | |
| 49 | | `(*View) Refresh()` | Re-read, keeping the highlight where it was | |
| 50 | | `(*View) OnOpen func(path string)` | Called with a chosen **file's** path; a directory never reaches it | |
| 51 | | `(*View) Draw/SetBounds/HandleKey/HandleMouse` | The `ui` widget contract | |
| 52 | |
| 53 | ```go |
| 54 | view, err := filetree.NewView(".") |
| 55 | if err != nil { |
| 56 | return err |
| 57 | } |
| 58 | view.OnOpen = func(path string) { editor.Open(path) } |
| 59 | window := ui.NewWindow(view.Title(), view) |
| 60 | ``` |
| 61 | |
| 62 | ## Keys |
| 63 | |
| 64 | `↑` `↓` `PgUp` `PgDn` `Home` `End` move. `→` expands a closed directory and otherwise steps to the next row; `←` collapses an open one and otherwise steps out to its parent — so the two together walk the shape and neither is ever a no-op. `Enter` chooses. `F5` and `Ctrl-R` refresh. |
| 65 | |
| 66 | The parent is found as *the nearest row above with a smaller depth*, which is what the eye reads as "the folder this is in" and needs no parent pointer on the nodes. |
| 67 | |
| 68 | ## Tests |
| 69 | |
| 70 | ```sh |
| 71 | make test |
| 72 | go test ./filetree/ |
| 73 | ``` |
| 74 | |
| 75 | The model tests build real directories under `t.TempDir()`; the view tests draw onto a `tcell.SimulationScreen` and assert on the picture a terminal would show. |