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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ tree-copy = tree_copy:main
@@ -0,0 +1,5 @@
1
+ textual==8.0.0
2
+ watchdog>=4.0
3
+
4
+ [serve]
5
+ textual-serve>=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()