f2-commander 0.1.0__py3-none-any.whl

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.
f2/__init__.py ADDED
File without changes
f2/app.py ADDED
@@ -0,0 +1,387 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2024 Timur Rubeko
6
+
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ from functools import partial
11
+ from importlib.metadata import version
12
+
13
+ from send2trash import send2trash
14
+ from textual import on, work
15
+ from textual.app import App, ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.command import DiscoveryHit, Hit, Provider
18
+ from textual.containers import Horizontal
19
+ from textual.reactive import reactive
20
+ from textual.widgets import Footer
21
+
22
+ from .commands import Command
23
+ from .config import config, set_user_has_accepted_license, user_has_accepted_license
24
+ from .shell import editor, shell, viewer
25
+ from .widgets.dialogs import InputDialog, StaticDialog, Style
26
+ from .widgets.filelist import FileList
27
+ from .widgets.panel import Panel
28
+
29
+
30
+ class F2AppCommands(Provider):
31
+ @property
32
+ def all_commands(self):
33
+ app_commands = [(self.app, cmd) for cmd in self.app.BINDINGS_AND_COMMANDS]
34
+ flist = self.app.active_filelist
35
+ flist_commands = [(flist, cmd) for cmd in flist.BINDINGS_AND_COMMANDS]
36
+ return app_commands + flist_commands
37
+
38
+ def _fmt_help(self, cmd):
39
+ if cmd.binding_key is not None:
40
+ return f"[{cmd.binding_key}]\n{cmd.description}\n"
41
+ else:
42
+ return f"{cmd.description}\n"
43
+
44
+ async def search(self, query: str):
45
+ matcher = self.matcher(query)
46
+ for node, cmd in self.all_commands:
47
+ score = matcher.match(cmd.name)
48
+ if score > 0:
49
+ yield Hit(
50
+ score,
51
+ matcher.highlight(cmd.name),
52
+ partial(node.run_action, cmd.action),
53
+ help=self._fmt_help(cmd),
54
+ )
55
+
56
+ async def discover(self):
57
+ for node, cmd in self.all_commands:
58
+ yield DiscoveryHit(
59
+ cmd.name,
60
+ partial(node.run_action, cmd.action),
61
+ help=self._fmt_help(cmd),
62
+ )
63
+
64
+
65
+ class F2Commander(App):
66
+ CSS_PATH = "tcss/main.tcss"
67
+ BINDINGS_AND_COMMANDS = [
68
+ Command(
69
+ "swap_panels",
70
+ "Swap panels",
71
+ "Swap left and right panels",
72
+ "ctrl+w",
73
+ ),
74
+ Command(
75
+ "same_location",
76
+ "Same location in other panel",
77
+ "Open the same location in the other (inactive) panel",
78
+ "ctrl+s",
79
+ ),
80
+ Command(
81
+ "change_left_panel",
82
+ "Left panel",
83
+ "Change the left panel type",
84
+ "ctrl+e",
85
+ ),
86
+ Command(
87
+ "change_right_panel",
88
+ "Right panel",
89
+ "Change the right panel type",
90
+ "ctrl+r",
91
+ ),
92
+ Command(
93
+ "toggle_hidden",
94
+ "Togghle hidden",
95
+ "Show or hide hidden files",
96
+ "h",
97
+ ),
98
+ Command(
99
+ "toggle_dirs_first",
100
+ "Toggle dirs first",
101
+ "Show directories first or ordered among files",
102
+ None,
103
+ ),
104
+ Command(
105
+ "toggle_order_case_sensitive",
106
+ "Toggle case sensitive name order",
107
+ "Whether name ordering is case sensitive or not",
108
+ None,
109
+ ),
110
+ Command(
111
+ "toggle_dark",
112
+ "Toggle theme",
113
+ "Switch between dark and light themes",
114
+ None,
115
+ ),
116
+ Command(
117
+ "about",
118
+ "About",
119
+ "Information about this software",
120
+ None,
121
+ ),
122
+ ]
123
+ BINDINGS = [
124
+ Binding("?", "help", "Help"),
125
+ Binding("v", "view", "View"),
126
+ Binding("e", "edit", "Edit"),
127
+ Binding("c", "copy", "Copy"),
128
+ Binding("m", "move", "Move"),
129
+ Binding("d", "delete", "Delete"),
130
+ Binding("ctrl+n", "mkdir", "New dir"),
131
+ Binding("x", "shell", "Shell"),
132
+ # FIXME: following exists only for discoverability, remove when textual does it
133
+ Binding("ctrl+\\", "do_nothing", "Command Palette"),
134
+ Binding("q", "quit_confirm", "Quit"),
135
+ ] + [
136
+ Binding(cmd.binding_key, cmd.action, cmd.description, show=False)
137
+ for cmd in BINDINGS_AND_COMMANDS
138
+ if cmd.binding_key is not None
139
+ ] # type: ignore
140
+ COMMANDS = {F2AppCommands}
141
+
142
+ show_hidden = reactive(config.show_hidden)
143
+ dirs_first = reactive(config.dirs_first)
144
+ order_case_sensitive = reactive(config.order_case_sensitive)
145
+ swapped = reactive(False)
146
+
147
+ def compose(self) -> ComposeResult:
148
+ self.panels_container = Horizontal()
149
+ self.panel_left = Panel("left", id="left")
150
+ self.panel_right = Panel("right", id="right")
151
+ with self.panels_container:
152
+ yield self.panel_left
153
+ yield self.panel_right
154
+ footer = Footer()
155
+ footer.compact = True
156
+ footer.ctrl_to_caret = False
157
+ footer.upper_case_keys = True
158
+ yield footer
159
+
160
+ def action_toggle_hidden(self):
161
+ self.show_hidden = not self.show_hidden
162
+
163
+ def watch_show_hidden(self, old: bool, new: bool):
164
+ self.left.show_hidden = new
165
+ self.right.show_hidden = new
166
+ config.show_hidden = new
167
+
168
+ def action_toggle_dirs_first(self):
169
+ self.dirs_first = not self.dirs_first
170
+
171
+ # TODO: save default value to user options, restore on start
172
+ def watch_dirs_first(self, old: bool, new: bool):
173
+ self.left.dirs_first = new
174
+ self.right.dirs_first = new
175
+ config.dirs_first = new
176
+
177
+ def action_toggle_order_case_sensitive(self):
178
+ self.order_case_sensitive = not self.order_case_sensitive
179
+
180
+ # TODO: save default value to user options, restore on start
181
+ def watch_order_case_sensitive(self, old: bool, new: bool):
182
+ self.left.order_case_sensitive = new
183
+ self.right.order_case_sensitive = new
184
+ config.order_case_sensitive = new
185
+
186
+ def action_swap_panels(self):
187
+ self.swapped = not self.swapped
188
+
189
+ def watch_swapped(self, old: bool, new: bool):
190
+ if new:
191
+ self.panels_container.move_child(self.panel_left, after=self.panel_right)
192
+ else:
193
+ self.panels_container.move_child(self.panel_left, before=self.panel_right)
194
+
195
+ def action_same_location(self):
196
+ self.inactive_filelist.path = self.active_filelist.path
197
+
198
+ def action_change_left_panel(self):
199
+ # TODO: after swap this "right"
200
+ # FIXME: there is no left/right at all? Panel A and panel B instead?
201
+ self.panel_left.action_change_panel()
202
+
203
+ def action_change_right_panel(self):
204
+ self.panel_right.action_change_panel()
205
+
206
+ @property
207
+ def left(self):
208
+ return self.query_one("#left > *")
209
+
210
+ @property
211
+ def right(self):
212
+ return self.query_one("#right > *")
213
+
214
+ # FIXME: left/right are not necessarily FileList; make Optional and handle None
215
+ @property
216
+ def active_filelist(self) -> FileList:
217
+ return self.left if self.left.active else self.right
218
+
219
+ @property
220
+ def inactive_filelist(self) -> FileList:
221
+ return self.right if self.left.active else self.left
222
+
223
+ @work
224
+ async def on_mount(self, event):
225
+ if not user_has_accepted_license():
226
+ self.action_about()
227
+
228
+ @on(FileList.Selected)
229
+ def on_file_selected(self, event: FileList.Selected):
230
+ for c in self.query("Panel > *"):
231
+ if hasattr(c, "on_other_panel_selected"):
232
+ c.on_other_panel_selected(event.path)
233
+
234
+ def action_view(self):
235
+ src = self.active_filelist.cursor_path
236
+ if src.is_file():
237
+ viewer_cmd = viewer(or_editor=True)
238
+ if viewer_cmd is not None:
239
+ with self.app.suspend():
240
+ completed_process = subprocess.run(viewer_cmd + [str(src)])
241
+ exit_code = completed_process.returncode
242
+ if exit_code != 0:
243
+ msg = f"Viewer exited with an error ({exit_code})"
244
+ self.push_screen(StaticDialog.warning("Warning", msg))
245
+ else:
246
+ self.push_screen(StaticDialog.error("Error", "No viewer found!"))
247
+
248
+ def action_edit(self):
249
+ src = self.active_filelist.cursor_path
250
+ if src.is_file():
251
+ editor_cmd = editor()
252
+ if editor_cmd is not None:
253
+ with self.app.suspend():
254
+ completed_process = subprocess.run(editor_cmd + [str(src)])
255
+ exit_code = completed_process.returncode
256
+ if exit_code != 0:
257
+ msg = f"Editor exited with an error ({exit_code})"
258
+ self.push_screen(StaticDialog.warning("Error", msg))
259
+ else:
260
+ self.push_screen(StaticDialog.error("Error", "No editor found!"))
261
+
262
+ def action_copy(self):
263
+ sources = self.active_filelist.selected_paths()
264
+ dst = self.inactive_filelist.path
265
+
266
+ def on_copy(result: str | None):
267
+ if result is not None:
268
+ for src in sources:
269
+ if src.is_dir():
270
+ shutil.copytree(src, os.path.join(result, src.name))
271
+ else:
272
+ shutil.copy2(src, result)
273
+ # FIXME: broken abstraction, at least have a function to reset it?
274
+ self.active_filelist.selection = set()
275
+ self.active_filelist.update_listing()
276
+ self.inactive_filelist.update_listing()
277
+
278
+ msg = (
279
+ f"Copy {sources[0].name} to"
280
+ if len(sources) == 1
281
+ else f"Copy {len(sources)} selected entries to"
282
+ )
283
+ self.push_screen(
284
+ InputDialog(title=msg, value=str(dst), btn_ok="Copy"),
285
+ on_copy,
286
+ )
287
+
288
+ def action_move(self):
289
+ sources = self.active_filelist.selected_paths()
290
+ dst = self.inactive_filelist.path
291
+
292
+ def on_move(result: str | None):
293
+ if result is not None:
294
+ for src in sources:
295
+ shutil.move(src, result)
296
+ self.active_filelist.selection = set()
297
+ self.active_filelist.update_listing()
298
+ self.inactive_filelist.update_listing()
299
+
300
+ msg = (
301
+ f"Move {sources[0].name} to"
302
+ if len(sources) == 1
303
+ else f"Move {len(sources)} selected entries to"
304
+ )
305
+ self.push_screen(
306
+ InputDialog(title=msg, value=str(dst), btn_ok="Move"),
307
+ on_move,
308
+ )
309
+
310
+ def action_delete(self):
311
+ paths = self.active_filelist.selected_paths()
312
+
313
+ def on_delete(result: bool):
314
+ if result:
315
+ for path in paths:
316
+ send2trash(path)
317
+ self.active_filelist.selection = set()
318
+ self.active_filelist.update_listing()
319
+
320
+ msg = (
321
+ f"This will move {paths[0].name} to Trash"
322
+ if len(paths) == 1
323
+ else f"This will move {len(paths)} selected entries to Trash"
324
+ )
325
+ self.push_screen(
326
+ StaticDialog(
327
+ title="Delete?",
328
+ message=msg,
329
+ btn_ok="Delete",
330
+ style=Style.DANGER,
331
+ ),
332
+ on_delete,
333
+ )
334
+
335
+ def action_mkdir(self):
336
+ def on_mkdir(result: str | None):
337
+ if result is not None:
338
+ new_dir_path = self.active_filelist.path / result
339
+ new_dir_path.mkdir(parents=True, exist_ok=True)
340
+ self.active_filelist.update_listing()
341
+
342
+ self.push_screen(
343
+ InputDialog("New directory", btn_ok="Create"),
344
+ on_mkdir,
345
+ )
346
+
347
+ def action_shell(self):
348
+ shell_cmd = shell()
349
+ if shell_cmd is not None:
350
+ with self.app.suspend():
351
+ completed_process = subprocess.run(
352
+ shell_cmd,
353
+ cwd=self.active_filelist.path,
354
+ )
355
+ self.active_filelist.update_listing()
356
+ self.inactive_filelist.update_listing()
357
+ exit_code = completed_process.returncode
358
+ if exit_code != 0:
359
+ msg = f"Shell exited with an error ({exit_code})"
360
+ self.push_screen(StaticDialog.warning("Warning", msg))
361
+ else:
362
+ self.push_screen(StaticDialog.error("Error", "No shell found!"))
363
+
364
+ def action_quit_confirm(self):
365
+ def on_confirm(result: bool):
366
+ if result:
367
+ self.exit()
368
+
369
+ self.push_screen(StaticDialog("Quit?"), on_confirm)
370
+
371
+ def action_about(self):
372
+ def on_dismiss(result):
373
+ set_user_has_accepted_license()
374
+
375
+ title = f"F2 Commander {version('f2-commander')}"
376
+ msg = (
377
+ 'This application is provided "as is", without warranty of any kind.\n'
378
+ "This application is licensed under the Mozilla Public License, v. 2.0.\n"
379
+ "You can find a copy of the license at https://mozilla.org/MPL/2.0/"
380
+ )
381
+ self.push_screen(StaticDialog.info(title, msg, classes="large"), on_dismiss)
382
+
383
+ def action_help(self):
384
+ self.panel_right.panel_type = "help"
385
+
386
+ def action_do_nothing(self):
387
+ pass
f2/commands.py ADDED
@@ -0,0 +1,13 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2024 Timur Rubeko
6
+
7
+
8
+ class Command:
9
+ def __init__(self, action, name, description, binding_key=None):
10
+ self.action = action
11
+ self.name = name
12
+ self.description = description
13
+ self.binding_key = binding_key
f2/config.py ADDED
@@ -0,0 +1,69 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2024 Timur Rubeko
6
+
7
+ import ast
8
+ from pathlib import Path
9
+
10
+ import dotenv
11
+ from platformdirs import user_config_dir
12
+
13
+
14
+ def config_root() -> Path:
15
+ """Path to the directory that hosts all configuration files"""
16
+
17
+ root_dir = Path(user_config_dir("f2commander"))
18
+ if not root_dir.exists():
19
+ root_dir.mkdir()
20
+ return root_dir
21
+
22
+
23
+ def user_config_path() -> Path:
24
+ """Path to the file with user's application config"""
25
+
26
+ config_path = config_root() / "user.env"
27
+ if not config_path.exists():
28
+ config_path.touch()
29
+ return config_path
30
+
31
+
32
+ # FIXME: current Config + InstantConfigAttr implementation is straightforward, but
33
+ # obviously inefficient -> find a good middle ground between the two
34
+
35
+
36
+ class InstantConfigAttr:
37
+ """A descriptor that looks up and saves the values from/to the user config"""
38
+
39
+ def __init__(self, default):
40
+ self._default = default
41
+ self._conf_path = user_config_path()
42
+
43
+ def __set_name__(self, owner, name):
44
+ self._name = name
45
+
46
+ def __get__(self, obj, type):
47
+ value = dotenv.get_key(self._conf_path, self._name)
48
+ return ast.literal_eval(value) if value is not None else self._default
49
+
50
+ def __set__(self, obj, value):
51
+ dotenv.set_key(user_config_path(), self._name, repr(value), quote_mode="auto")
52
+
53
+
54
+ class Config:
55
+ dirs_first = InstantConfigAttr(True)
56
+ order_case_sensitive = InstantConfigAttr(True)
57
+ show_hidden = InstantConfigAttr(False)
58
+
59
+
60
+ config = Config()
61
+
62
+
63
+ def user_has_accepted_license():
64
+ """Whether user has accepted the license or not yet"""
65
+ return (config_root() / "user_has_accepted_license").is_file()
66
+
67
+
68
+ def set_user_has_accepted_license():
69
+ (config_root() / "user_has_accepted_license").touch()
f2/fs.py ADDED
@@ -0,0 +1,114 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2024 Timur Rubeko
6
+
7
+ import fnmatch
8
+ import os
9
+ import stat
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+
14
+ @dataclass
15
+ class DirList:
16
+ file_count: int
17
+ dir_count: int
18
+ total_size: int
19
+ entries: list["DirEntry"]
20
+
21
+
22
+ @dataclass
23
+ class DirEntry:
24
+ name: str
25
+ size: int
26
+ mtime: float
27
+ is_file: bool
28
+ is_dir: bool
29
+ is_link: bool
30
+ is_hidden: bool
31
+ is_executable: bool
32
+
33
+ @classmethod
34
+ def from_path(cls, p: Path) -> "DirEntry":
35
+ statinfo = p.lstat()
36
+ return DirEntry(
37
+ name=p.name,
38
+ size=statinfo.st_size,
39
+ mtime=statinfo.st_mtime,
40
+ is_file=stat.S_ISREG(statinfo.st_mode),
41
+ is_dir=stat.S_ISDIR(statinfo.st_mode),
42
+ is_link=stat.S_ISLNK(statinfo.st_mode),
43
+ is_hidden=is_hidden(p, statinfo),
44
+ is_executable=is_executable(statinfo),
45
+ )
46
+
47
+
48
+ def has_hidden_attribute(statinfo: os.stat_result) -> bool:
49
+ if not hasattr(statinfo, "st_file_attributes"):
50
+ return False
51
+ if not hasattr(stat, "FILE_ATTRIBUTE_HIDDEN"):
52
+ return False
53
+ return bool(
54
+ statinfo.st_file_attributes & stat.FILE_ATTRIBUTE_HIDDEN # type: ignore
55
+ )
56
+
57
+
58
+ def has_hidden_flag(statinfo: os.stat_result) -> bool:
59
+ if not hasattr(stat, "UF_HIDDEN") or not hasattr(statinfo, "st_flags"):
60
+ return False
61
+ return bool(statinfo.st_flags & stat.UF_HIDDEN) # type: ignore
62
+
63
+
64
+ def is_hidden(path: Path, statinfo: os.stat_result) -> bool:
65
+ return (
66
+ path.name.startswith(".")
67
+ or has_hidden_attribute(statinfo)
68
+ or has_hidden_flag(statinfo)
69
+ )
70
+
71
+
72
+ def is_executable(statinfo: os.stat_result) -> bool:
73
+ mode = statinfo.st_mode
74
+ return stat.S_ISREG(mode) and bool(mode & stat.S_IXUSR)
75
+
76
+
77
+ def list_dir(
78
+ path: Path,
79
+ include_up_dir: bool = True,
80
+ include_hidden: bool = True,
81
+ glob_expression: str | None = None,
82
+ ) -> DirList:
83
+ if not path.is_dir():
84
+ raise ValueError(f"{path} is not a directory")
85
+
86
+ total_size = 0
87
+ file_count = 0
88
+ dir_count = 0
89
+ entries = []
90
+
91
+ if include_up_dir and path.parent != path:
92
+ up = DirEntry.from_path(path)
93
+ up.name = ".."
94
+ entries.append(up)
95
+
96
+ for child in path.iterdir():
97
+ entry = DirEntry.from_path(child)
98
+ if glob_expression and not fnmatch.fnmatch(entry.name, glob_expression):
99
+ continue
100
+ if entry.is_hidden and not include_hidden:
101
+ continue
102
+ entries.append(entry)
103
+ total_size += entry.size
104
+ if entry.is_file:
105
+ file_count += 1
106
+ elif entry.is_dir:
107
+ dir_count += 1
108
+
109
+ return DirList(
110
+ file_count=file_count,
111
+ dir_count=dir_count,
112
+ total_size=total_size,
113
+ entries=entries,
114
+ )
f2/main.py ADDED
@@ -0,0 +1,12 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2024 Timur Rubeko
6
+
7
+ from .app import F2Commander
8
+
9
+
10
+ def main():
11
+ app = F2Commander()
12
+ app.run()
f2/shell.py ADDED
@@ -0,0 +1,65 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ #
5
+ # Copyright (c) 2024 Timur Rubeko
6
+
7
+ import os
8
+ import platform
9
+ import shlex
10
+ import shutil
11
+ from typing import List
12
+
13
+
14
+ def editor() -> List[str] | None:
15
+ """Try to find an editor. Returns a command as a splitted list of arguments,
16
+ or None if no viable alternative is found. Prefers $EDITOR when possible."""
17
+
18
+ editor = os.getenv("EDITOR")
19
+ if editor:
20
+ parts = shlex.split(editor)
21
+ if shutil.which(parts[0]):
22
+ return parts
23
+
24
+ for cmd in ("vi", "nano", "edit"):
25
+ if shutil.which(cmd):
26
+ return [cmd]
27
+
28
+ return None
29
+
30
+
31
+ def viewer(or_editor: bool = True) -> List[str] | None:
32
+ """Try to find a viewer. Returns a command as a splitted list of arguments,
33
+ or None if no viable alternative is found. Use editor if no viewer is found."""
34
+
35
+ for cmd in ("less", "more"):
36
+ if shutil.which(cmd):
37
+ return [cmd]
38
+
39
+ return editor() if or_editor else None
40
+
41
+
42
+ def shell() -> List[str] | None:
43
+ """Try to find a shell executable. Returns a command as a splitted list of
44
+ arguments, or None if no viable alternative is found."""
45
+
46
+ for cmd in ("zsh", "fish", "bash", "sh", "powershell.ext", "cmd.exe"):
47
+ if shutil.which(cmd):
48
+ return [cmd]
49
+
50
+ return None
51
+
52
+
53
+ def native_open() -> List[str] | None:
54
+ """Returns a generic 'file opener' relevant for the current OS, or None if none
55
+ is known for the use OS."""
56
+
57
+ match platform.system():
58
+ case "Linux":
59
+ return ["xdg-open"]
60
+ case "Darwin":
61
+ return ["open"]
62
+ case "Windows":
63
+ return ["start"]
64
+ case _:
65
+ return None