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 +0 -0
- f2/app.py +387 -0
- f2/commands.py +13 -0
- f2/config.py +69 -0
- f2/fs.py +114 -0
- f2/main.py +12 -0
- f2/shell.py +65 -0
- f2/tcss/main.tcss +99 -0
- f2/widgets/dialogs.py +170 -0
- f2/widgets/filelist.py +502 -0
- f2/widgets/help.py +113 -0
- f2/widgets/panel.py +52 -0
- f2/widgets/preview.py +31 -0
- f2_commander-0.1.0.dist-info/LICENSE +374 -0
- f2_commander-0.1.0.dist-info/METADATA +250 -0
- f2_commander-0.1.0.dist-info/RECORD +18 -0
- f2_commander-0.1.0.dist-info/WHEEL +4 -0
- f2_commander-0.1.0.dist-info/entry_points.txt +3 -0
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
|