commandertui 1.0.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.
Files changed (39) hide show
  1. commandertui-1.0.0/LICENSE +21 -0
  2. commandertui-1.0.0/PKG-INFO +191 -0
  3. commandertui-1.0.0/README.md +173 -0
  4. commandertui-1.0.0/commandertui/__init__.py +1 -0
  5. commandertui-1.0.0/commandertui/app.py +40 -0
  6. commandertui-1.0.0/commandertui/ascii_font.py +27 -0
  7. commandertui-1.0.0/commandertui/bookmarks.py +133 -0
  8. commandertui-1.0.0/commandertui/boot.py +114 -0
  9. commandertui-1.0.0/commandertui/cli.py +22 -0
  10. commandertui-1.0.0/commandertui/compare_screen.py +146 -0
  11. commandertui-1.0.0/commandertui/diff.py +61 -0
  12. commandertui-1.0.0/commandertui/executor.py +134 -0
  13. commandertui-1.0.0/commandertui/main_screen.py +211 -0
  14. commandertui-1.0.0/commandertui/modals.py +250 -0
  15. commandertui-1.0.0/commandertui/models.py +79 -0
  16. commandertui-1.0.0/commandertui/plan.py +50 -0
  17. commandertui-1.0.0/commandertui/progress_screen.py +137 -0
  18. commandertui-1.0.0/commandertui/run_queue.py +83 -0
  19. commandertui-1.0.0/commandertui/scanner.py +115 -0
  20. commandertui-1.0.0/commandertui/sync.py +149 -0
  21. commandertui-1.0.0/commandertui/theme.py +94 -0
  22. commandertui-1.0.0/commandertui/trash.py +49 -0
  23. commandertui-1.0.0/commandertui/widgets.py +134 -0
  24. commandertui-1.0.0/commandertui.egg-info/PKG-INFO +191 -0
  25. commandertui-1.0.0/commandertui.egg-info/SOURCES.txt +37 -0
  26. commandertui-1.0.0/commandertui.egg-info/dependency_links.txt +1 -0
  27. commandertui-1.0.0/commandertui.egg-info/entry_points.txt +2 -0
  28. commandertui-1.0.0/commandertui.egg-info/requires.txt +2 -0
  29. commandertui-1.0.0/commandertui.egg-info/top_level.txt +1 -0
  30. commandertui-1.0.0/pyproject.toml +39 -0
  31. commandertui-1.0.0/setup.cfg +4 -0
  32. commandertui-1.0.0/tests/test_app_smoke.py +129 -0
  33. commandertui-1.0.0/tests/test_bookmarks.py +73 -0
  34. commandertui-1.0.0/tests/test_diff.py +60 -0
  35. commandertui-1.0.0/tests/test_executor.py +79 -0
  36. commandertui-1.0.0/tests/test_plan.py +50 -0
  37. commandertui-1.0.0/tests/test_scanner.py +78 -0
  38. commandertui-1.0.0/tests/test_sync.py +130 -0
  39. commandertui-1.0.0/tests/test_widgets.py +36 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Humberto Barchini
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,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: commandertui
3
+ Version: 1.0.0
4
+ Summary: Dual-pane retro terminal file manager: compare, sync, copy and move folders
5
+ Author: Humberto Barchini
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/juniorbarchini-oss/commandertui
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: System :: Filesystems
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: textual>=8.0
16
+ Requires-Dist: send2trash>=1.8
17
+ Dynamic: license-file
18
+
19
+ # CommanderTUI
20
+
21
+ Dual-pane terminal file manager: compare two folders, sync them, or move
22
+ files/folders between them (and to USB/SMB/any mounted path) — with a
23
+ phosphor-green 80s-terminal look and a short modem-style boot animation.
24
+
25
+ Status: **v1.0.0** — 40/40 tests passing. MIT licensed.
26
+
27
+ ---
28
+
29
+ ## Why this exists
30
+
31
+ A safe, simple alternative to heavier dual-pane file managers. It was
32
+ written after an earlier attempt of the same idea had to be scrapped for
33
+ running an unauthenticated local HTTP server. This project deliberately:
34
+
35
+ - **Never opens a network port.** Pure terminal UI (Textual), no HTTP server,
36
+ no webview.
37
+ - **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
38
+ (`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
39
+ that order).
40
+ - **Never overwrites in place.** Copies stream into a hidden `.tmp` file next
41
+ to the destination and are committed with `os.replace` — a crash or
42
+ Ctrl+C never leaves a half-written file.
43
+ - **Never guesses on a conflict.** Two-way sync copies each side's exclusive
44
+ files automatically, but a file changed on *both* sides is always shown to
45
+ you, never auto-resolved.
46
+
47
+ ## Install
48
+
49
+ **Arch Linux / Omarchy.** An AUR package is on the way (`yay -S commandertui`).
50
+ Until then, build it from the `PKGBUILD` in this repo. `makepkg` pulls in
51
+ everything it needs:
52
+
53
+ ```bash
54
+ git clone https://github.com/juniorbarchini-oss/commandertui.git
55
+ cd commandertui/packaging/arch
56
+ makepkg -si
57
+ ```
58
+
59
+ Remove it with `sudo pacman -R commandertui`.
60
+
61
+ **Other distros.** It is a regular Python package (Python 3.11+):
62
+
63
+ ```bash
64
+ pipx install git+https://github.com/juniorbarchini-oss/commandertui.git
65
+ ```
66
+
67
+ **From a source checkout** (development):
68
+
69
+ ```bash
70
+ python3 -m venv .venv && .venv/bin/pip install -e . -r requirements-dev.txt
71
+ bin/commandertui [left-dir] [right-dir]
72
+ ```
73
+
74
+ ## Run
75
+
76
+ | Command | What it does |
77
+ |---|---|
78
+ | `cmdrtui` | Opens it in its own terminal window. This is also what the **CommanderTUI** app-launcher entry runs |
79
+ | `commandertui` | Runs it inside the terminal you are in |
80
+ | `commandertui DIR1 DIR2` | Opens with those two folders side by side |
81
+
82
+ `cmdrtui` uses your desktop's default terminal (`xdg-terminal-exec`). If that
83
+ isn't available it tries foot, alacritty, kitty and ghostty. The window
84
+ always gets the app-id/class `commandertui`, so a window-manager rule can
85
+ float it. It needs about 1174x637 px to fit every key hint. On Hyprland:
86
+
87
+ ```ini
88
+ # hyprland.conf
89
+ windowrulev2 = float, class:^(commandertui)$
90
+ windowrulev2 = size 1174 637, class:^(commandertui)$
91
+ windowrulev2 = center, class:^(commandertui)$
92
+ ```
93
+
94
+ ```lua
95
+ -- Omarchy Lua config (~/.config/hypr/hyprland.lua)
96
+ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } })
97
+ ```
98
+
99
+ ## Keys
100
+
101
+ | Key | Action |
102
+ |---|---|
103
+ | `Tab` | Switch active pane |
104
+ | `↑`/`↓`, `Enter` | Navigate, open directory |
105
+ | `Backspace` | Go to parent directory |
106
+ | `Space` | Mark/unmark item under cursor |
107
+ | `F5` | Copy marked (or item under cursor) to the other pane |
108
+ | `F6` | Move marked to the other pane |
109
+ | `F7` | Create a folder in the active pane |
110
+ | `F8` / `Delete` | Delete marked (to trash) |
111
+ | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
112
+ | `h` | Show/hide hidden (dot) files in both panes |
113
+ | `p` | Places: jump to a mounted drive (USB/SMB/rclone) or saved bookmark |
114
+ | `c` | Compare: full recursive diff between the two panes' current folders |
115
+ | `r` | Refresh both panes |
116
+ | `q` | Quit |
117
+
118
+ While a copy/move runs, the progress window shows two bars (current file
119
+ and whole job, by bytes) plus speed in Mb/s and time left. `Esc` asks to
120
+ cancel: finished files stay, the file in progress is discarded (never left
121
+ half-written), and a move never removes a source it didn't fully copy.
122
+
123
+ **Places** lists only what is mounted right now: USB drives
124
+ (`/run/media`, `/media`, `/mnt` — checked with `ismount`, so an empty
125
+ on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
126
+ mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
127
+
128
+ Inside **Compare**:
129
+
130
+ | Key | Action |
131
+ |---|---|
132
+ | `m` | Mirror: make right identical to left |
133
+ | `u` | Update: copy new/changed left → right, never deletes |
134
+ | `b` | Two-way: propagate each side's exclusive changes; conflicts (changed on both sides) are listed, never guessed |
135
+ | `Space` | Mark a row for manual handling |
136
+ | `c` | Copy marked rows in whichever direction they diverge |
137
+ | `Esc` | Back to the panes |
138
+
139
+ Every bulk or destructive action shows a plain-language summary (count of
140
+ operations, total bytes) and requires `y` to confirm.
141
+
142
+ ## Architecture
143
+
144
+ ```
145
+ commandertui/
146
+ ├── models.py # FileEntry, DiffEntry, OperationQueue, QueuedOp
147
+ ├── scanner.py # os.scandir recursive tree walk
148
+ ├── diff.py # pure comparison: left-only / right-only / modified / identical
149
+ ├── plan.py # diff -> OperationQueue (mirror / update / two-way)
150
+ ├── sync.py # atomic copy/move (streamed, tmp+replace), checksum verify
151
+ ├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
152
+ ├── executor.py # runs an OperationQueue against the filesystem
153
+ ├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
154
+ ├── theme.py # green/amber phosphor palettes
155
+ ├── boot.py # startup "modem" reveal animation
156
+ ├── widgets.py # FilePanel: one pane's directory listing
157
+ ├── modals.py # Confirm / Places / Input / Size / Message screens
158
+ ├── progress_screen.py # two-bar transfer progress + cancel
159
+ ├── run_queue.py # confirm -> run queue in a worker thread
160
+ ├── compare_screen.py # full diff view + bulk/manual sync actions
161
+ ├── app.py # CommanderApp: wires panes + keybindings
162
+ └── cli.py # argument parsing, entrypoint
163
+ ```
164
+
165
+ ## Tests
166
+
167
+ ```bash
168
+ pip install -r requirements-dev.txt
169
+ pytest
170
+ black commandertui tests # formatter, line length set in pyproject.toml
171
+ ```
172
+
173
+ Engine (scanner/diff/plan/sync/executor) is fully unit tested against a real
174
+ filesystem (`tmp_path`). The UI has smoke tests via Textual's `Pilot`
175
+ (boot sequence dismisses, pane switching, mark→copy→confirm end to end).
176
+
177
+ ## Not in scope
178
+
179
+ No file preview, no deduplication engine, no desktop GUI wrapper — those are
180
+ different problems.
181
+
182
+ ## Credits
183
+
184
+ Designed and tested by Humberto Barchini. The code was written together
185
+ with [Claude](https://www.anthropic.com/claude) (Anthropic) through
186
+ [Claude Code](https://claude.com/claude-code): **Claude Sonnet 5** built the
187
+ first version, **Claude Opus 5.5** did most of v0.2 and the v1.0 packaging.
188
+
189
+ ## License
190
+
191
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,173 @@
1
+ # CommanderTUI
2
+
3
+ Dual-pane terminal file manager: compare two folders, sync them, or move
4
+ files/folders between them (and to USB/SMB/any mounted path) — with a
5
+ phosphor-green 80s-terminal look and a short modem-style boot animation.
6
+
7
+ Status: **v1.0.0** — 40/40 tests passing. MIT licensed.
8
+
9
+ ---
10
+
11
+ ## Why this exists
12
+
13
+ A safe, simple alternative to heavier dual-pane file managers. It was
14
+ written after an earlier attempt of the same idea had to be scrapped for
15
+ running an unauthenticated local HTTP server. This project deliberately:
16
+
17
+ - **Never opens a network port.** Pure terminal UI (Textual), no HTTP server,
18
+ no webview.
19
+ - **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
20
+ (`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
21
+ that order).
22
+ - **Never overwrites in place.** Copies stream into a hidden `.tmp` file next
23
+ to the destination and are committed with `os.replace` — a crash or
24
+ Ctrl+C never leaves a half-written file.
25
+ - **Never guesses on a conflict.** Two-way sync copies each side's exclusive
26
+ files automatically, but a file changed on *both* sides is always shown to
27
+ you, never auto-resolved.
28
+
29
+ ## Install
30
+
31
+ **Arch Linux / Omarchy.** An AUR package is on the way (`yay -S commandertui`).
32
+ Until then, build it from the `PKGBUILD` in this repo. `makepkg` pulls in
33
+ everything it needs:
34
+
35
+ ```bash
36
+ git clone https://github.com/juniorbarchini-oss/commandertui.git
37
+ cd commandertui/packaging/arch
38
+ makepkg -si
39
+ ```
40
+
41
+ Remove it with `sudo pacman -R commandertui`.
42
+
43
+ **Other distros.** It is a regular Python package (Python 3.11+):
44
+
45
+ ```bash
46
+ pipx install git+https://github.com/juniorbarchini-oss/commandertui.git
47
+ ```
48
+
49
+ **From a source checkout** (development):
50
+
51
+ ```bash
52
+ python3 -m venv .venv && .venv/bin/pip install -e . -r requirements-dev.txt
53
+ bin/commandertui [left-dir] [right-dir]
54
+ ```
55
+
56
+ ## Run
57
+
58
+ | Command | What it does |
59
+ |---|---|
60
+ | `cmdrtui` | Opens it in its own terminal window. This is also what the **CommanderTUI** app-launcher entry runs |
61
+ | `commandertui` | Runs it inside the terminal you are in |
62
+ | `commandertui DIR1 DIR2` | Opens with those two folders side by side |
63
+
64
+ `cmdrtui` uses your desktop's default terminal (`xdg-terminal-exec`). If that
65
+ isn't available it tries foot, alacritty, kitty and ghostty. The window
66
+ always gets the app-id/class `commandertui`, so a window-manager rule can
67
+ float it. It needs about 1174x637 px to fit every key hint. On Hyprland:
68
+
69
+ ```ini
70
+ # hyprland.conf
71
+ windowrulev2 = float, class:^(commandertui)$
72
+ windowrulev2 = size 1174 637, class:^(commandertui)$
73
+ windowrulev2 = center, class:^(commandertui)$
74
+ ```
75
+
76
+ ```lua
77
+ -- Omarchy Lua config (~/.config/hypr/hyprland.lua)
78
+ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } })
79
+ ```
80
+
81
+ ## Keys
82
+
83
+ | Key | Action |
84
+ |---|---|
85
+ | `Tab` | Switch active pane |
86
+ | `↑`/`↓`, `Enter` | Navigate, open directory |
87
+ | `Backspace` | Go to parent directory |
88
+ | `Space` | Mark/unmark item under cursor |
89
+ | `F5` | Copy marked (or item under cursor) to the other pane |
90
+ | `F6` | Move marked to the other pane |
91
+ | `F7` | Create a folder in the active pane |
92
+ | `F8` / `Delete` | Delete marked (to trash) |
93
+ | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
94
+ | `h` | Show/hide hidden (dot) files in both panes |
95
+ | `p` | Places: jump to a mounted drive (USB/SMB/rclone) or saved bookmark |
96
+ | `c` | Compare: full recursive diff between the two panes' current folders |
97
+ | `r` | Refresh both panes |
98
+ | `q` | Quit |
99
+
100
+ While a copy/move runs, the progress window shows two bars (current file
101
+ and whole job, by bytes) plus speed in Mb/s and time left. `Esc` asks to
102
+ cancel: finished files stay, the file in progress is discarded (never left
103
+ half-written), and a move never removes a source it didn't fully copy.
104
+
105
+ **Places** lists only what is mounted right now: USB drives
106
+ (`/run/media`, `/media`, `/mnt` — checked with `ismount`, so an empty
107
+ on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
108
+ mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
109
+
110
+ Inside **Compare**:
111
+
112
+ | Key | Action |
113
+ |---|---|
114
+ | `m` | Mirror: make right identical to left |
115
+ | `u` | Update: copy new/changed left → right, never deletes |
116
+ | `b` | Two-way: propagate each side's exclusive changes; conflicts (changed on both sides) are listed, never guessed |
117
+ | `Space` | Mark a row for manual handling |
118
+ | `c` | Copy marked rows in whichever direction they diverge |
119
+ | `Esc` | Back to the panes |
120
+
121
+ Every bulk or destructive action shows a plain-language summary (count of
122
+ operations, total bytes) and requires `y` to confirm.
123
+
124
+ ## Architecture
125
+
126
+ ```
127
+ commandertui/
128
+ ├── models.py # FileEntry, DiffEntry, OperationQueue, QueuedOp
129
+ ├── scanner.py # os.scandir recursive tree walk
130
+ ├── diff.py # pure comparison: left-only / right-only / modified / identical
131
+ ├── plan.py # diff -> OperationQueue (mirror / update / two-way)
132
+ ├── sync.py # atomic copy/move (streamed, tmp+replace), checksum verify
133
+ ├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
134
+ ├── executor.py # runs an OperationQueue against the filesystem
135
+ ├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
136
+ ├── theme.py # green/amber phosphor palettes
137
+ ├── boot.py # startup "modem" reveal animation
138
+ ├── widgets.py # FilePanel: one pane's directory listing
139
+ ├── modals.py # Confirm / Places / Input / Size / Message screens
140
+ ├── progress_screen.py # two-bar transfer progress + cancel
141
+ ├── run_queue.py # confirm -> run queue in a worker thread
142
+ ├── compare_screen.py # full diff view + bulk/manual sync actions
143
+ ├── app.py # CommanderApp: wires panes + keybindings
144
+ └── cli.py # argument parsing, entrypoint
145
+ ```
146
+
147
+ ## Tests
148
+
149
+ ```bash
150
+ pip install -r requirements-dev.txt
151
+ pytest
152
+ black commandertui tests # formatter, line length set in pyproject.toml
153
+ ```
154
+
155
+ Engine (scanner/diff/plan/sync/executor) is fully unit tested against a real
156
+ filesystem (`tmp_path`). The UI has smoke tests via Textual's `Pilot`
157
+ (boot sequence dismisses, pane switching, mark→copy→confirm end to end).
158
+
159
+ ## Not in scope
160
+
161
+ No file preview, no deduplication engine, no desktop GUI wrapper — those are
162
+ different problems.
163
+
164
+ ## Credits
165
+
166
+ Designed and tested by Humberto Barchini. The code was written together
167
+ with [Claude](https://www.anthropic.com/claude) (Anthropic) through
168
+ [Claude Code](https://claude.com/claude-code): **Claude Sonnet 5** built the
169
+ first version, **Claude Opus 5.5** did most of v0.2 and the v1.0 packaging.
170
+
171
+ ## License
172
+
173
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from textual.app import App
6
+
7
+ from .boot import BootScreen
8
+ from .main_screen import MainScreen
9
+ from .theme import css_for
10
+
11
+
12
+ class CommanderApp(App):
13
+ """Owns only the screen stack. All pane behavior lives in MainScreen --
14
+ see its docstring for why that split matters for key bindings.
15
+ """
16
+
17
+ CSS = css_for("green")
18
+
19
+ def __init__(self, left_path: str | None = None, right_path: str | None = None) -> None:
20
+ super().__init__()
21
+ self.left_path = os.path.abspath(left_path or os.path.expanduser("~"))
22
+ self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
23
+
24
+ def on_mount(self) -> None:
25
+ self.push_screen(MainScreen(self.left_path, self.right_path))
26
+ self.push_screen(BootScreen())
27
+
28
+ # Convenience passthroughs so existing tests/tools that poke at the app
29
+ # directly (e.g. app._panel("left")) keep working without knowing about
30
+ # the MainScreen split.
31
+ def _panel(self, side: str):
32
+ return self.main_screen._panel(side)
33
+
34
+ @property
35
+ def main_screen(self) -> MainScreen:
36
+ return next(s for s in self.screen_stack if isinstance(s, MainScreen))
37
+
38
+ @property
39
+ def active_side(self) -> str:
40
+ return self.main_screen.active_side
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ # A minimal 5x5 block-letter font, just enough for "COMMANDER". Handwritten
4
+ # once here rather than pulled from a figlet dependency -- it's nine glyphs,
5
+ # not worth a library.
6
+ _FONT: dict[str, list[str]] = {
7
+ "A": [" ### ", "# #", "#####", "# #", "# #"],
8
+ "C": [" ####", "# ", "# ", "# ", " ####"],
9
+ "D": ["#### ", "# #", "# #", "# #", "#### "],
10
+ "E": ["#####", "# ", "#### ", "# ", "#####"],
11
+ "M": ["# #", "## ##", "# # #", "# #", "# #"],
12
+ "N": ["# #", "## #", "# # #", "# ##", "# #"],
13
+ "O": [" ### ", "# #", "# #", "# #", " ### "],
14
+ "R": ["#### ", "# #", "#### ", "# # ", "# #"],
15
+ }
16
+
17
+ BLOCK = "█" # solid block, swapped in for the '#' placeholders above
18
+
19
+
20
+ def banner(word: str, gap: int = 1) -> str:
21
+ """Render `word` as 5 lines of block letters."""
22
+ letters = [_FONT[ch] for ch in word.upper()]
23
+ sep = " " * gap
24
+ rows = []
25
+ for row_index in range(5):
26
+ rows.append(sep.join(letter[row_index] for letter in letters).replace("#", BLOCK))
27
+ return "\n".join(rows)
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+
7
+ CONFIG_DIR = os.path.expanduser("~/.config/commandertui")
8
+ BOOKMARKS_FILE = os.path.join(CONFIG_DIR, "bookmarks.json")
9
+
10
+ # Where Linux desktops mount removable/network drives.
11
+ MOUNT_ROOTS = ("/run/media", "/media", "/mnt")
12
+
13
+ # Nautilus (and GNOME apps generally) mount network shares -- SMB, FTP,
14
+ # SFTP -- through GVFS rather than udisks2, so they never show up under
15
+ # MOUNT_ROOTS. They land here instead, one FUSE directory per connection.
16
+ GVFS_ROOT = f"/run/user/{os.getuid()}/gvfs"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Place:
21
+ label: str
22
+ path: str
23
+ kind: str # "mounted" | "saved" | "home"
24
+
25
+
26
+ def detect_mounted() -> list[Place]:
27
+ places: list[Place] = []
28
+ for root in MOUNT_ROOTS:
29
+ if not os.path.isdir(root):
30
+ continue
31
+ try:
32
+ for user_dir in os.scandir(root):
33
+ if not user_dir.is_dir():
34
+ continue
35
+ # /run/media/<user>/<Label> vs /mnt/<Label> (no user subdir)
36
+ candidates = list(os.scandir(user_dir.path)) if root == "/run/media" else [user_dir]
37
+ for c in candidates:
38
+ # An on-demand mount point (e.g. /mnt/i7server) exists as
39
+ # an empty directory even when nothing is mounted there --
40
+ # os.path.ismount() is what actually tells them apart.
41
+ if c.is_dir() and os.path.ismount(c.path):
42
+ places.append(Place(label=c.name, path=c.path, kind="mounted"))
43
+ except PermissionError:
44
+ continue
45
+ return places
46
+
47
+
48
+ def _label_for_gvfs_entry(name: str) -> str:
49
+ """GVFS names a mount after its full connection URI, e.g.
50
+ 'smb-share:server=192.168.1.10,share=Media' -- turn that into something
51
+ a human would recognize, falling back to the raw name if it's a shape
52
+ we don't specifically parse (sftp, ftp, google-drive, ...).
53
+ """
54
+ _scheme, _, rest = name.partition(":")
55
+ params = dict(pair.split("=", 1) for pair in rest.split(",") if "=" in pair)
56
+ share = params.get("share")
57
+ server = params.get("server") or params.get("host")
58
+ if share and server:
59
+ return f"{share} @ {server}"
60
+ return server or name
61
+
62
+
63
+ def detect_network_mounts() -> list[Place]:
64
+ if not os.path.isdir(GVFS_ROOT):
65
+ return []
66
+ try:
67
+ return [
68
+ Place(label=_label_for_gvfs_entry(entry.name), path=entry.path, kind="network")
69
+ for entry in os.scandir(GVFS_ROOT)
70
+ if entry.is_dir()
71
+ ]
72
+ except PermissionError:
73
+ return []
74
+
75
+
76
+ PROC_MOUNTS = "/proc/mounts"
77
+
78
+
79
+ def _unescape_mount_path(path: str) -> str:
80
+ # /proc/mounts octal-escapes whitespace and backslashes in paths.
81
+ return path.replace("\\040", " ").replace("\\011", "\t").replace("\\012", "\n").replace("\\134", "\\")
82
+
83
+
84
+ def detect_home_mounts() -> list[Place]:
85
+ """FUSE mounts inside $HOME, e.g. rclone's ~/365 and ~/GoogleDrive.
86
+ Read from /proc/mounts so only what is live right now shows up."""
87
+ home = os.path.expanduser("~")
88
+ try:
89
+ with open(PROC_MOUNTS) as f:
90
+ lines = f.readlines()
91
+ except OSError:
92
+ return []
93
+ places: list[Place] = []
94
+ for line in lines:
95
+ parts = line.split()
96
+ if len(parts) < 3 or not parts[2].startswith("fuse."):
97
+ continue
98
+ path = _unescape_mount_path(parts[1])
99
+ if path.startswith(home + os.sep):
100
+ places.append(Place(label=os.path.basename(path), path=path, kind=parts[2][5:]))
101
+ return places
102
+
103
+
104
+ def load_saved() -> list[Place]:
105
+ if not os.path.isfile(BOOKMARKS_FILE):
106
+ return []
107
+ try:
108
+ with open(BOOKMARKS_FILE) as f:
109
+ data = json.load(f)
110
+ return [Place(label=b["label"], path=b["path"], kind="saved") for b in data]
111
+ except Exception:
112
+ return []
113
+
114
+
115
+ def save_bookmark(label: str, path: str) -> None:
116
+ os.makedirs(CONFIG_DIR, exist_ok=True)
117
+ saved = load_saved()
118
+ saved = [b for b in saved if b.path != path]
119
+ saved.append(Place(label=label, path=path, kind="saved"))
120
+ with open(BOOKMARKS_FILE, "w") as f:
121
+ json.dump([{"label": b.label, "path": b.path} for b in saved], f, indent=2)
122
+
123
+
124
+ def remove_bookmark(path: str) -> None:
125
+ saved = [b for b in load_saved() if b.path != path]
126
+ os.makedirs(CONFIG_DIR, exist_ok=True)
127
+ with open(BOOKMARKS_FILE, "w") as f:
128
+ json.dump([{"label": b.label, "path": b.path} for b in saved], f, indent=2)
129
+
130
+
131
+ def all_places() -> list[Place]:
132
+ home = Place(label="Home", path=os.path.expanduser("~"), kind="home")
133
+ return [home] + detect_mounted() + detect_network_mounts() + detect_home_mounts() + load_saved()