tree-copy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tree_copy-0.1.0/LICENSE +21 -0
- tree_copy-0.1.0/PKG-INFO +85 -0
- tree_copy-0.1.0/README.md +71 -0
- tree_copy-0.1.0/pyproject.toml +22 -0
- tree_copy-0.1.0/setup.cfg +4 -0
- tree_copy-0.1.0/tree_copy.egg-info/PKG-INFO +85 -0
- tree_copy-0.1.0/tree_copy.egg-info/SOURCES.txt +10 -0
- tree_copy-0.1.0/tree_copy.egg-info/dependency_links.txt +1 -0
- tree_copy-0.1.0/tree_copy.egg-info/entry_points.txt +2 -0
- tree_copy-0.1.0/tree_copy.egg-info/requires.txt +5 -0
- tree_copy-0.1.0/tree_copy.egg-info/top_level.txt +1 -0
- tree_copy-0.1.0/tree_copy.py +499 -0
tree_copy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tzafrir Rehan
|
|
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.
|
tree_copy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tree-copy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keyboard-driven file tree sidebar for tmux
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: textual==8.0.0
|
|
10
|
+
Requires-Dist: watchdog>=4.0
|
|
11
|
+
Provides-Extra: serve
|
|
12
|
+
Requires-Dist: textual-serve>=1.1; extra == "serve"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# tree-copy
|
|
16
|
+
|
|
17
|
+
A keyboard-driven file tree sidebar for tmux, built with [Textual](https://github.com/Textualize/textual).
|
|
18
|
+
|
|
19
|
+
Browse your project, jump between directories, copy paths, and preview files — without leaving the terminal.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- Navigate the file tree with arrow keys
|
|
24
|
+
- Jump between sibling directories with `Shift+↑/↓`
|
|
25
|
+
- Copy relative or absolute paths to clipboard
|
|
26
|
+
- Preview files with [glow](https://github.com/charmbracelet/glow) (falls back to `less`)
|
|
27
|
+
- Edit files with `$TREE_COPY_EDITOR` (falls back to nano → vi)
|
|
28
|
+
- Root folder stays open (non-collapsible)
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Python 3.10+
|
|
33
|
+
- [glow](https://github.com/charmbracelet/glow) *(optional, recommended for file preview — falls back to `less`)*
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install tree-copy
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or from source:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
tree-copy [directory] # defaults to current directory
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
As a togglable tmux sidebar, add to `~/.tmux.conf`:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
bind-key e run-shell " \
|
|
57
|
+
PANE=$(tmux show-option -wqv @tree-copy-pane); \
|
|
58
|
+
if [ -n \"$PANE\" ] && tmux list-panes -F '#{pane_id}' | grep -q \"^$PANE\$\"; then \
|
|
59
|
+
tmux kill-pane -t \"$PANE\"; \
|
|
60
|
+
tmux set-option -w @tree-copy-pane ''; \
|
|
61
|
+
else \
|
|
62
|
+
NEW_PANE=$(tmux split-window -hbP -l 35 -F '#{pane_id}' \"tree-copy '#{pane_current_path}'\"); \
|
|
63
|
+
tmux set-option -w @tree-copy-pane \"$NEW_PANE\"; \
|
|
64
|
+
fi"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`prefix + e` opens the sidebar; pressing it again closes it. State (expanded folders, cursor position) is saved automatically and restored on next open.
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Keybindings
|
|
71
|
+
|
|
72
|
+
| Key | Action |
|
|
73
|
+
|-----|--------|
|
|
74
|
+
| `↑` / `↓` | Navigate |
|
|
75
|
+
| `Shift+↑` / `Shift+↓` | Jump between sibling directories; moves to parent at bounds |
|
|
76
|
+
| `Enter` / `Space` | Toggle directory open/close |
|
|
77
|
+
| `o` | Preview file (glow if available, else less) |
|
|
78
|
+
| `e` | Edit file (`$TREE_COPY_EDITOR`, else nano, else vi) |
|
|
79
|
+
| `c` | Copy relative path to clipboard |
|
|
80
|
+
| `C` | Copy absolute path to clipboard |
|
|
81
|
+
| `q` / `Esc` | Quit |
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# tree-copy
|
|
2
|
+
|
|
3
|
+
A keyboard-driven file tree sidebar for tmux, built with [Textual](https://github.com/Textualize/textual).
|
|
4
|
+
|
|
5
|
+
Browse your project, jump between directories, copy paths, and preview files — without leaving the terminal.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Navigate the file tree with arrow keys
|
|
10
|
+
- Jump between sibling directories with `Shift+↑/↓`
|
|
11
|
+
- Copy relative or absolute paths to clipboard
|
|
12
|
+
- Preview files with [glow](https://github.com/charmbracelet/glow) (falls back to `less`)
|
|
13
|
+
- Edit files with `$TREE_COPY_EDITOR` (falls back to nano → vi)
|
|
14
|
+
- Root folder stays open (non-collapsible)
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Python 3.10+
|
|
19
|
+
- [glow](https://github.com/charmbracelet/glow) *(optional, recommended for file preview — falls back to `less`)*
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install tree-copy
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or from source:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
tree-copy [directory] # defaults to current directory
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
As a togglable tmux sidebar, add to `~/.tmux.conf`:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bind-key e run-shell " \
|
|
43
|
+
PANE=$(tmux show-option -wqv @tree-copy-pane); \
|
|
44
|
+
if [ -n \"$PANE\" ] && tmux list-panes -F '#{pane_id}' | grep -q \"^$PANE\$\"; then \
|
|
45
|
+
tmux kill-pane -t \"$PANE\"; \
|
|
46
|
+
tmux set-option -w @tree-copy-pane ''; \
|
|
47
|
+
else \
|
|
48
|
+
NEW_PANE=$(tmux split-window -hbP -l 35 -F '#{pane_id}' \"tree-copy '#{pane_current_path}'\"); \
|
|
49
|
+
tmux set-option -w @tree-copy-pane \"$NEW_PANE\"; \
|
|
50
|
+
fi"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`prefix + e` opens the sidebar; pressing it again closes it. State (expanded folders, cursor position) is saved automatically and restored on next open.
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
## Keybindings
|
|
57
|
+
|
|
58
|
+
| Key | Action |
|
|
59
|
+
|-----|--------|
|
|
60
|
+
| `↑` / `↓` | Navigate |
|
|
61
|
+
| `Shift+↑` / `Shift+↓` | Jump between sibling directories; moves to parent at bounds |
|
|
62
|
+
| `Enter` / `Space` | Toggle directory open/close |
|
|
63
|
+
| `o` | Preview file (glow if available, else less) |
|
|
64
|
+
| `e` | Edit file (`$TREE_COPY_EDITOR`, else nano, else vi) |
|
|
65
|
+
| `c` | Copy relative path to clipboard |
|
|
66
|
+
| `C` | Copy absolute path to clipboard |
|
|
67
|
+
| `q` / `Esc` | Quit |
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tree-copy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Keyboard-driven file tree sidebar for tmux"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["textual==8.0.0", "watchdog>=4.0"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
serve = ["textual-serve>=1.1"]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
tree-copy = "tree_copy:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
py-modules = ["tree_copy"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tree-copy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keyboard-driven file tree sidebar for tmux
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: textual==8.0.0
|
|
10
|
+
Requires-Dist: watchdog>=4.0
|
|
11
|
+
Provides-Extra: serve
|
|
12
|
+
Requires-Dist: textual-serve>=1.1; extra == "serve"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# tree-copy
|
|
16
|
+
|
|
17
|
+
A keyboard-driven file tree sidebar for tmux, built with [Textual](https://github.com/Textualize/textual).
|
|
18
|
+
|
|
19
|
+
Browse your project, jump between directories, copy paths, and preview files — without leaving the terminal.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- Navigate the file tree with arrow keys
|
|
24
|
+
- Jump between sibling directories with `Shift+↑/↓`
|
|
25
|
+
- Copy relative or absolute paths to clipboard
|
|
26
|
+
- Preview files with [glow](https://github.com/charmbracelet/glow) (falls back to `less`)
|
|
27
|
+
- Edit files with `$TREE_COPY_EDITOR` (falls back to nano → vi)
|
|
28
|
+
- Root folder stays open (non-collapsible)
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Python 3.10+
|
|
33
|
+
- [glow](https://github.com/charmbracelet/glow) *(optional, recommended for file preview — falls back to `less`)*
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install tree-copy
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or from source:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
tree-copy [directory] # defaults to current directory
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
As a togglable tmux sidebar, add to `~/.tmux.conf`:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
bind-key e run-shell " \
|
|
57
|
+
PANE=$(tmux show-option -wqv @tree-copy-pane); \
|
|
58
|
+
if [ -n \"$PANE\" ] && tmux list-panes -F '#{pane_id}' | grep -q \"^$PANE\$\"; then \
|
|
59
|
+
tmux kill-pane -t \"$PANE\"; \
|
|
60
|
+
tmux set-option -w @tree-copy-pane ''; \
|
|
61
|
+
else \
|
|
62
|
+
NEW_PANE=$(tmux split-window -hbP -l 35 -F '#{pane_id}' \"tree-copy '#{pane_current_path}'\"); \
|
|
63
|
+
tmux set-option -w @tree-copy-pane \"$NEW_PANE\"; \
|
|
64
|
+
fi"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`prefix + e` opens the sidebar; pressing it again closes it. State (expanded folders, cursor position) is saved automatically and restored on next open.
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Keybindings
|
|
71
|
+
|
|
72
|
+
| Key | Action |
|
|
73
|
+
|-----|--------|
|
|
74
|
+
| `↑` / `↓` | Navigate |
|
|
75
|
+
| `Shift+↑` / `Shift+↓` | Jump between sibling directories; moves to parent at bounds |
|
|
76
|
+
| `Enter` / `Space` | Toggle directory open/close |
|
|
77
|
+
| `o` | Preview file (glow if available, else less) |
|
|
78
|
+
| `e` | Edit file (`$TREE_COPY_EDITOR`, else nano, else vi) |
|
|
79
|
+
| `c` | Copy relative path to clipboard |
|
|
80
|
+
| `C` | Copy absolute path to clipboard |
|
|
81
|
+
| `q` / `Esc` | Quit |
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tree_copy.py
|
|
5
|
+
tree_copy.egg-info/PKG-INFO
|
|
6
|
+
tree_copy.egg-info/SOURCES.txt
|
|
7
|
+
tree_copy.egg-info/dependency_links.txt
|
|
8
|
+
tree_copy.egg-info/entry_points.txt
|
|
9
|
+
tree_copy.egg-info/requires.txt
|
|
10
|
+
tree_copy.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tree_copy
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env python3.10
|
|
2
|
+
"""
|
|
3
|
+
tree_sidebar.py — file tree sidebar for tmux
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 tree_sidebar.py [directory]
|
|
7
|
+
|
|
8
|
+
Keys:
|
|
9
|
+
↑ / ↓ Navigate items
|
|
10
|
+
Shift+↑ / ↓ Jump between sibling directories (moves to parent when out of bounds)
|
|
11
|
+
Enter Toggle directory open/close
|
|
12
|
+
o Open file with glow (pager)
|
|
13
|
+
c Copy relative path to clipboard
|
|
14
|
+
C Copy absolute path to clipboard
|
|
15
|
+
q / Escape Quit
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Iterable
|
|
25
|
+
|
|
26
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
27
|
+
from textual.binding import Binding
|
|
28
|
+
from textual.screen import Screen
|
|
29
|
+
from textual.widgets import DirectoryTree, Footer
|
|
30
|
+
|
|
31
|
+
from watchdog.events import FileSystemEventHandler
|
|
32
|
+
from watchdog.observers import Observer
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FileTree(DirectoryTree):
|
|
36
|
+
BINDINGS = [
|
|
37
|
+
Binding("enter", "select_cursor", "Toggle", show=True),
|
|
38
|
+
Binding("space", "select_cursor", "Toggle", show=True),
|
|
39
|
+
Binding("shift+up", "jump_prev_dir", "Prev dir", show=True),
|
|
40
|
+
Binding("shift+down", "jump_next_dir", "Next dir", show=True),
|
|
41
|
+
Binding("o", "open_glow", "Open (glow)", show=True),
|
|
42
|
+
Binding("e", "edit_nano", "Edit (nano)", show=True),
|
|
43
|
+
Binding("c", "copy_rel_path", "Copy rel", show=True),
|
|
44
|
+
Binding("C", "copy_abs_path", "Copy abs", show=True),
|
|
45
|
+
Binding("z", "zoom_pane", "Zoom", show=True),
|
|
46
|
+
Binding("q", "quit_app", "Quit", show=True),
|
|
47
|
+
Binding("escape", "quit_app", "Quit", show=False),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Helpers
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _node_path(self, node) -> Path | None:
|
|
55
|
+
"""Safely extract Path from a tree node regardless of Textual version."""
|
|
56
|
+
if node is None or node.data is None:
|
|
57
|
+
return None
|
|
58
|
+
data = node.data
|
|
59
|
+
if isinstance(data, Path):
|
|
60
|
+
return data
|
|
61
|
+
if hasattr(data, "path"):
|
|
62
|
+
return Path(data.path)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def _copy(self, text: str) -> None:
|
|
66
|
+
"""Write text to the system clipboard (cross-platform) and tmux buffer."""
|
|
67
|
+
import os, platform
|
|
68
|
+
data = text.encode()
|
|
69
|
+
copied = False
|
|
70
|
+
|
|
71
|
+
if os.environ.get("WSL_DISTRO_NAME"): # WSL2
|
|
72
|
+
copied = self._run_copy(["clip.exe"], data)
|
|
73
|
+
elif platform.system() == "Darwin": # macOS
|
|
74
|
+
copied = self._run_copy(["pbcopy"], data)
|
|
75
|
+
elif os.environ.get("WAYLAND_DISPLAY"): # Linux Wayland
|
|
76
|
+
copied = self._run_copy(["wl-copy"], data)
|
|
77
|
+
elif os.environ.get("DISPLAY"): # Linux X11
|
|
78
|
+
copied = (self._run_copy(["xclip", "-selection", "clipboard"], data)
|
|
79
|
+
or self._run_copy(["xsel", "--clipboard", "--input"], data))
|
|
80
|
+
|
|
81
|
+
if not copied: # tmux-only fallback
|
|
82
|
+
self._run_copy(["tmux", "set-buffer", text.encode()], data)
|
|
83
|
+
|
|
84
|
+
# Always also set tmux buffer (convenient for paste-into-pane)
|
|
85
|
+
try:
|
|
86
|
+
subprocess.run(["tmux", "set-buffer", text], capture_output=True)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _run_copy(cmd: list, data: bytes) -> bool:
|
|
92
|
+
try:
|
|
93
|
+
subprocess.run(cmd, input=data, check=True, capture_output=True)
|
|
94
|
+
return True
|
|
95
|
+
except Exception:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
def _sibling_dirs(self, node) -> list:
|
|
99
|
+
"""Return all directory siblings at the same level as node."""
|
|
100
|
+
if node.parent is None:
|
|
101
|
+
return []
|
|
102
|
+
return [
|
|
103
|
+
c for c in node.parent.children
|
|
104
|
+
if (p := self._node_path(c)) and p.is_dir()
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Actions
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _is_web(self) -> bool:
|
|
112
|
+
try:
|
|
113
|
+
from textual.drivers.web_driver import WebDriver
|
|
114
|
+
return isinstance(self.app._driver, WebDriver)
|
|
115
|
+
except Exception:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _find_viewer() -> list[str]:
|
|
120
|
+
"""Return the best available file viewer command."""
|
|
121
|
+
import shutil
|
|
122
|
+
if shutil.which("glow"):
|
|
123
|
+
return ["glow", "-p"]
|
|
124
|
+
return ["less"]
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _find_editor() -> list[str]:
|
|
128
|
+
"""Return editor from TREE_COPY_EDITOR env, falling back to nano or vi."""
|
|
129
|
+
import shutil
|
|
130
|
+
custom = os.environ.get("TREE_COPY_EDITOR")
|
|
131
|
+
if custom:
|
|
132
|
+
return custom.split()
|
|
133
|
+
for ed in ("nano", "vi"):
|
|
134
|
+
if shutil.which(ed):
|
|
135
|
+
return [ed]
|
|
136
|
+
return ["vi"]
|
|
137
|
+
|
|
138
|
+
def action_open_glow(self) -> None:
|
|
139
|
+
path = self._node_path(self.cursor_node)
|
|
140
|
+
if path and path.is_file():
|
|
141
|
+
if self._is_web():
|
|
142
|
+
self.app.notify("Not available in browser mode", severity="warning")
|
|
143
|
+
return
|
|
144
|
+
with self.app.suspend():
|
|
145
|
+
subprocess.run([*self._find_viewer(), str(path)])
|
|
146
|
+
|
|
147
|
+
def action_edit_nano(self) -> None:
|
|
148
|
+
path = self._node_path(self.cursor_node)
|
|
149
|
+
if path and path.is_file():
|
|
150
|
+
if self._is_web():
|
|
151
|
+
self.app.notify("Not available in browser mode", severity="warning")
|
|
152
|
+
return
|
|
153
|
+
with self.app.suspend():
|
|
154
|
+
subprocess.run([*self._find_editor(), str(path)])
|
|
155
|
+
|
|
156
|
+
def action_copy_rel_path(self) -> None:
|
|
157
|
+
path = self._node_path(self.cursor_node)
|
|
158
|
+
if not path:
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
text = str(path.relative_to(Path.cwd()))
|
|
162
|
+
except ValueError:
|
|
163
|
+
text = str(path)
|
|
164
|
+
self._copy(text)
|
|
165
|
+
self.app.notify(f"Copied: {text}")
|
|
166
|
+
|
|
167
|
+
def action_copy_abs_path(self) -> None:
|
|
168
|
+
path = self._node_path(self.cursor_node)
|
|
169
|
+
if not path:
|
|
170
|
+
return
|
|
171
|
+
text = str(path.resolve())
|
|
172
|
+
self._copy(text)
|
|
173
|
+
self.app.notify(f"Copied: {text}")
|
|
174
|
+
|
|
175
|
+
def action_select_cursor(self) -> None:
|
|
176
|
+
node = self.cursor_node
|
|
177
|
+
# Root node (parent is None): never allow collapsing
|
|
178
|
+
if node is not None and node.parent is None and node.is_expanded:
|
|
179
|
+
return
|
|
180
|
+
super().action_select_cursor()
|
|
181
|
+
|
|
182
|
+
def action_zoom_pane(self) -> None:
|
|
183
|
+
if os.environ.get("TMUX"):
|
|
184
|
+
subprocess.run(["tmux", "resize-pane", "-Z"], capture_output=True)
|
|
185
|
+
|
|
186
|
+
def action_quit_app(self) -> None:
|
|
187
|
+
self.app.exit()
|
|
188
|
+
|
|
189
|
+
def action_jump_prev_dir(self) -> None:
|
|
190
|
+
node = self.cursor_node
|
|
191
|
+
if node is None:
|
|
192
|
+
return
|
|
193
|
+
dirs = self._sibling_dirs(node)
|
|
194
|
+
path = self._node_path(node)
|
|
195
|
+
is_dir = path is not None and path.is_dir()
|
|
196
|
+
|
|
197
|
+
if not dirs:
|
|
198
|
+
# No sibling dirs — move up to parent
|
|
199
|
+
if node.parent:
|
|
200
|
+
self.move_cursor(node.parent)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
if is_dir and node in dirs:
|
|
204
|
+
idx = dirs.index(node)
|
|
205
|
+
if idx > 0:
|
|
206
|
+
self.move_cursor(dirs[idx - 1])
|
|
207
|
+
else:
|
|
208
|
+
# Already at first sibling dir — go to parent
|
|
209
|
+
if node.parent:
|
|
210
|
+
self.move_cursor(node.parent)
|
|
211
|
+
else:
|
|
212
|
+
# On a file: find the nearest dir *before* this node among siblings
|
|
213
|
+
all_children = list(node.parent.children)
|
|
214
|
+
cur_pos = all_children.index(node)
|
|
215
|
+
prev_dirs = [d for d in dirs if all_children.index(d) < cur_pos]
|
|
216
|
+
if prev_dirs:
|
|
217
|
+
self.move_cursor(prev_dirs[-1])
|
|
218
|
+
elif node.parent:
|
|
219
|
+
self.move_cursor(node.parent)
|
|
220
|
+
|
|
221
|
+
def action_jump_next_dir(self) -> None:
|
|
222
|
+
node = self.cursor_node
|
|
223
|
+
if node is None:
|
|
224
|
+
return
|
|
225
|
+
dirs = self._sibling_dirs(node)
|
|
226
|
+
path = self._node_path(node)
|
|
227
|
+
is_dir = path is not None and path.is_dir()
|
|
228
|
+
|
|
229
|
+
if not dirs:
|
|
230
|
+
if node.parent:
|
|
231
|
+
self.move_cursor(node.parent)
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
if is_dir and node in dirs:
|
|
235
|
+
idx = dirs.index(node)
|
|
236
|
+
if idx < len(dirs) - 1:
|
|
237
|
+
self.move_cursor(dirs[idx + 1])
|
|
238
|
+
else:
|
|
239
|
+
# Already at last sibling dir — go to parent
|
|
240
|
+
if node.parent:
|
|
241
|
+
self.move_cursor(node.parent)
|
|
242
|
+
else:
|
|
243
|
+
all_children = list(node.parent.children)
|
|
244
|
+
cur_pos = all_children.index(node)
|
|
245
|
+
next_dirs = [d for d in dirs if all_children.index(d) > cur_pos]
|
|
246
|
+
if next_dirs:
|
|
247
|
+
self.move_cursor(next_dirs[0])
|
|
248
|
+
elif node.parent:
|
|
249
|
+
self.move_cursor(node.parent)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class SidebarApp(App):
|
|
253
|
+
CSS = """
|
|
254
|
+
FileTree {
|
|
255
|
+
width: 1fr;
|
|
256
|
+
height: 1fr;
|
|
257
|
+
border: none;
|
|
258
|
+
scrollbar-gutter: stable;
|
|
259
|
+
}
|
|
260
|
+
Footer {
|
|
261
|
+
height: 1;
|
|
262
|
+
}
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
# Dirs to ignore when watching (noise with no useful signal)
|
|
266
|
+
_WATCH_IGNORE = {"__pycache__", ".git", ".mypy_cache", ".ruff_cache", "node_modules"}
|
|
267
|
+
|
|
268
|
+
_STATE_FILE = Path.home() / ".local" / "share" / "tree-copy" / "state.json"
|
|
269
|
+
|
|
270
|
+
def __init__(self, root: Path) -> None:
|
|
271
|
+
super().__init__()
|
|
272
|
+
self.root = root
|
|
273
|
+
self._pending_paths: set[Path] = set()
|
|
274
|
+
self._refresh_timer = None
|
|
275
|
+
self._observer: Observer | None = None
|
|
276
|
+
self._restore_expanded: set[str] = set()
|
|
277
|
+
self._saved_cursor: str | None = None
|
|
278
|
+
self._load_state()
|
|
279
|
+
|
|
280
|
+
# ------------------------------------------------------------------
|
|
281
|
+
# State persistence
|
|
282
|
+
# ------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def _load_state(self) -> None:
|
|
285
|
+
try:
|
|
286
|
+
data = json.loads(self._STATE_FILE.read_text())
|
|
287
|
+
saved = data.get(str(self.root), {})
|
|
288
|
+
self._restore_expanded = set(saved.get("expanded", []))
|
|
289
|
+
self._saved_cursor = saved.get("cursor")
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
def _save_state(self) -> None:
|
|
294
|
+
try:
|
|
295
|
+
tree = self.query_one(FileTree)
|
|
296
|
+
expanded: list[str] = []
|
|
297
|
+
|
|
298
|
+
def walk(node):
|
|
299
|
+
if node.is_expanded:
|
|
300
|
+
p = tree._node_path(node)
|
|
301
|
+
if p:
|
|
302
|
+
expanded.append(str(p))
|
|
303
|
+
for child in node.children:
|
|
304
|
+
walk(child)
|
|
305
|
+
|
|
306
|
+
walk(tree.root)
|
|
307
|
+
cursor_path = tree._node_path(tree.cursor_node)
|
|
308
|
+
|
|
309
|
+
data: dict = {}
|
|
310
|
+
try:
|
|
311
|
+
data = json.loads(self._STATE_FILE.read_text())
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
data[str(self.root)] = {
|
|
316
|
+
"expanded": expanded,
|
|
317
|
+
"cursor": str(cursor_path) if cursor_path else None,
|
|
318
|
+
}
|
|
319
|
+
self._STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
self._STATE_FILE.write_text(json.dumps(data, indent=2))
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
def on_tree_node_expanded(self, event) -> None:
|
|
325
|
+
"""Cascade expansion to restore saved state."""
|
|
326
|
+
if not self._restore_expanded:
|
|
327
|
+
return
|
|
328
|
+
# Give DirectoryTree a moment to populate the node's children
|
|
329
|
+
self.set_timer(0.1, lambda: self._expand_children(event.node))
|
|
330
|
+
|
|
331
|
+
def _expand_children(self, node) -> None:
|
|
332
|
+
tree = self.query_one(FileTree)
|
|
333
|
+
for child in node.children:
|
|
334
|
+
p = tree._node_path(child)
|
|
335
|
+
if p and str(p) in self._restore_expanded:
|
|
336
|
+
child.expand()
|
|
337
|
+
|
|
338
|
+
def _restore_state(self) -> None:
|
|
339
|
+
"""Kick off cascade expansion from root's already-loaded children."""
|
|
340
|
+
tree = self.query_one(FileTree)
|
|
341
|
+
for child in tree.root.children:
|
|
342
|
+
p = tree._node_path(child)
|
|
343
|
+
if p and str(p) in self._restore_expanded:
|
|
344
|
+
child.expand()
|
|
345
|
+
if self._saved_cursor:
|
|
346
|
+
self.set_timer(1.2, self._restore_cursor)
|
|
347
|
+
|
|
348
|
+
def _restore_cursor(self) -> None:
|
|
349
|
+
tree = self.query_one(FileTree)
|
|
350
|
+
target = Path(self._saved_cursor)
|
|
351
|
+
|
|
352
|
+
def walk(node):
|
|
353
|
+
if tree._node_path(node) == target:
|
|
354
|
+
tree.move_cursor(node)
|
|
355
|
+
return True
|
|
356
|
+
for child in node.children:
|
|
357
|
+
if walk(child):
|
|
358
|
+
return True
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
walk(tree.root)
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
# Lifecycle
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
def on_mount(self) -> None:
|
|
368
|
+
app = self
|
|
369
|
+
|
|
370
|
+
class Handler(FileSystemEventHandler):
|
|
371
|
+
def on_any_event(self, event):
|
|
372
|
+
src = Path(event.src_path)
|
|
373
|
+
if any(part in app._WATCH_IGNORE for part in src.parts):
|
|
374
|
+
return
|
|
375
|
+
changed = src if src.is_dir() else src.parent
|
|
376
|
+
app.call_from_thread(app._queue_refresh, changed)
|
|
377
|
+
if hasattr(event, "dest_path") and event.dest_path:
|
|
378
|
+
dest = Path(event.dest_path)
|
|
379
|
+
dest_dir = dest if dest.is_dir() else dest.parent
|
|
380
|
+
app.call_from_thread(app._queue_refresh, dest_dir)
|
|
381
|
+
|
|
382
|
+
self._observer = Observer()
|
|
383
|
+
self._observer.schedule(Handler(), str(self.root), recursive=True)
|
|
384
|
+
self._observer.start()
|
|
385
|
+
|
|
386
|
+
signal.signal(signal.SIGTERM, lambda s, f: (self._cleanup_marker(), sys.exit(0)))
|
|
387
|
+
self.set_interval(30, self._save_state)
|
|
388
|
+
self.set_timer(0.5, self._restore_state)
|
|
389
|
+
self._create_marker()
|
|
390
|
+
|
|
391
|
+
def on_unmount(self) -> None:
|
|
392
|
+
self._save_state()
|
|
393
|
+
self._cleanup_marker()
|
|
394
|
+
if self._observer:
|
|
395
|
+
self._observer.stop()
|
|
396
|
+
self._observer.join()
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _marker_path() -> Path | None:
|
|
400
|
+
pane = os.environ.get("TMUX_PANE")
|
|
401
|
+
return Path(f"/tmp/tree-copy-{pane}") if pane else None
|
|
402
|
+
|
|
403
|
+
def _create_marker(self) -> None:
|
|
404
|
+
m = self._marker_path()
|
|
405
|
+
if m:
|
|
406
|
+
m.touch()
|
|
407
|
+
|
|
408
|
+
def _cleanup_marker(self) -> None:
|
|
409
|
+
m = self._marker_path()
|
|
410
|
+
if m:
|
|
411
|
+
try:
|
|
412
|
+
m.unlink(missing_ok=True)
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
def _queue_refresh(self, path: Path) -> None:
|
|
417
|
+
"""Debounce filesystem events and batch reloads (runs on main thread)."""
|
|
418
|
+
self._pending_paths.add(path)
|
|
419
|
+
if self._refresh_timer is not None:
|
|
420
|
+
self._refresh_timer.stop()
|
|
421
|
+
self._refresh_timer = self.set_timer(0.3, self._flush_refresh)
|
|
422
|
+
|
|
423
|
+
def _flush_refresh(self) -> None:
|
|
424
|
+
"""Reload tree nodes for all queued changed paths."""
|
|
425
|
+
tree = self.query_one(FileTree)
|
|
426
|
+
for path in self._pending_paths:
|
|
427
|
+
node = self._find_node(tree, path)
|
|
428
|
+
if node is not None and node.is_expanded:
|
|
429
|
+
tree.reload_node(node)
|
|
430
|
+
self._pending_paths.clear()
|
|
431
|
+
self._refresh_timer = None
|
|
432
|
+
|
|
433
|
+
def _find_node(self, tree: "FileTree", path: Path):
|
|
434
|
+
"""Walk loaded tree nodes to find the one matching path."""
|
|
435
|
+
def walk(node):
|
|
436
|
+
if tree._node_path(node) == path:
|
|
437
|
+
return node
|
|
438
|
+
for child in node.children:
|
|
439
|
+
found = walk(child)
|
|
440
|
+
if found:
|
|
441
|
+
return found
|
|
442
|
+
return None
|
|
443
|
+
return walk(tree.root)
|
|
444
|
+
|
|
445
|
+
_HIDDEN_COMMANDS = {"Screenshot", "Maximize", "Minimize"}
|
|
446
|
+
|
|
447
|
+
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
448
|
+
for cmd in super().get_system_commands(screen):
|
|
449
|
+
if cmd.title not in self._HIDDEN_COMMANDS:
|
|
450
|
+
yield cmd
|
|
451
|
+
|
|
452
|
+
def compose(self) -> ComposeResult:
|
|
453
|
+
yield FileTree(self.root)
|
|
454
|
+
yield Footer()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def main() -> None:
|
|
458
|
+
import argparse
|
|
459
|
+
parser = argparse.ArgumentParser(
|
|
460
|
+
prog="tree-copy",
|
|
461
|
+
description="Keyboard-driven file tree sidebar for tmux.",
|
|
462
|
+
)
|
|
463
|
+
parser.add_argument(
|
|
464
|
+
"directory",
|
|
465
|
+
nargs="?",
|
|
466
|
+
default=".",
|
|
467
|
+
help="Root directory to browse (default: current directory)",
|
|
468
|
+
)
|
|
469
|
+
parser.add_argument(
|
|
470
|
+
"--serve",
|
|
471
|
+
action="store_true",
|
|
472
|
+
help="Serve the app in a browser via textual-serve",
|
|
473
|
+
)
|
|
474
|
+
parser.add_argument(
|
|
475
|
+
"--port",
|
|
476
|
+
type=int,
|
|
477
|
+
default=8000,
|
|
478
|
+
help="Port for --serve mode (default: 8000)",
|
|
479
|
+
)
|
|
480
|
+
args = parser.parse_args()
|
|
481
|
+
root = Path(args.directory).resolve()
|
|
482
|
+
if not root.is_dir():
|
|
483
|
+
parser.error(f"{root} is not a directory")
|
|
484
|
+
|
|
485
|
+
if args.serve:
|
|
486
|
+
try:
|
|
487
|
+
from textual_serve.server import Server
|
|
488
|
+
except ImportError:
|
|
489
|
+
parser.error("textual-serve is required: pip install textual-serve")
|
|
490
|
+
cmd = f"{sys.executable} {Path(__file__).resolve()} {root}"
|
|
491
|
+
server = Server(command=cmd, host="localhost", port=args.port)
|
|
492
|
+
print(f"Serving at http://localhost:{args.port}")
|
|
493
|
+
server.serve()
|
|
494
|
+
else:
|
|
495
|
+
SidebarApp(root).run()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
if __name__ == "__main__":
|
|
499
|
+
main()
|