skilleter-thingy 0.3.14__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.
Files changed (39) hide show
  1. skilleter_thingy/__init__.py +0 -0
  2. skilleter_thingy/addpath.py +107 -0
  3. skilleter_thingy/console_colours.py +63 -0
  4. skilleter_thingy/ffind.py +535 -0
  5. skilleter_thingy/ggit.py +88 -0
  6. skilleter_thingy/ggrep.py +155 -0
  7. skilleter_thingy/git_br.py +186 -0
  8. skilleter_thingy/git_ca.py +147 -0
  9. skilleter_thingy/git_cleanup.py +297 -0
  10. skilleter_thingy/git_co.py +227 -0
  11. skilleter_thingy/git_common.py +68 -0
  12. skilleter_thingy/git_hold.py +162 -0
  13. skilleter_thingy/git_parent.py +84 -0
  14. skilleter_thingy/git_retag.py +67 -0
  15. skilleter_thingy/git_review.py +1450 -0
  16. skilleter_thingy/git_update.py +398 -0
  17. skilleter_thingy/git_wt.py +72 -0
  18. skilleter_thingy/gitcmp_helper.py +328 -0
  19. skilleter_thingy/gitprompt.py +293 -0
  20. skilleter_thingy/linecount.py +154 -0
  21. skilleter_thingy/multigit.py +915 -0
  22. skilleter_thingy/py_audit.py +133 -0
  23. skilleter_thingy/remdir.py +127 -0
  24. skilleter_thingy/rpylint.py +98 -0
  25. skilleter_thingy/strreplace.py +82 -0
  26. skilleter_thingy/test.py +34 -0
  27. skilleter_thingy/tfm.py +948 -0
  28. skilleter_thingy/tfparse.py +101 -0
  29. skilleter_thingy/trimpath.py +82 -0
  30. skilleter_thingy/venv_create.py +47 -0
  31. skilleter_thingy/venv_template.py +47 -0
  32. skilleter_thingy/xchmod.py +124 -0
  33. skilleter_thingy/yamlcheck.py +89 -0
  34. skilleter_thingy-0.3.14.dist-info/METADATA +606 -0
  35. skilleter_thingy-0.3.14.dist-info/RECORD +39 -0
  36. skilleter_thingy-0.3.14.dist-info/WHEEL +5 -0
  37. skilleter_thingy-0.3.14.dist-info/entry_points.txt +31 -0
  38. skilleter_thingy-0.3.14.dist-info/licenses/LICENSE +619 -0
  39. skilleter_thingy-0.3.14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,948 @@
1
+ #! /usr/bin/env python3
2
+
3
+ ################################################################################
4
+ """ Thingy file manager
5
+
6
+ Copyright (C) 2022 John Skilleter
7
+
8
+ Stuff not working:
9
+
10
+ BUG: 1. If both panes showing same directory, non-current pane doesn't update if current one changes
11
+ BUG: 2. Allows an attempt to move/copy directory into its own subdirectory
12
+ BUG: 2. If pane showing a subdirectory and parent is deleted in other pane nothing updates
13
+ BUG: 3. Inefficient redraw (better, but could be better, although curses hides most of the inefficiencies)
14
+ BUG: 4. Many untrapped exceptions (e.g. deleting stuff)
15
+ BUG: 6. Slightly odd scrolling at page top/bottom
16
+
17
+ Stuff that's been fixed but not fully tested:
18
+
19
+ FIXED: 1. If a tagged file is hidden by a filter it shouldn't be processed by move/copy/delete - check this
20
+
21
+ DONE:
22
+ DONE: 1. If you exit a directory via Backspace then re-enter it hasn't saved the current position
23
+ DONE: 1. Implement inotify as background thread as it is far too slow to run everytime we process a keypress
24
+ DONE: 1. When changing direcrories, save current location in current directory and return there when we return to the same directory
25
+ DONE: 2. Copy (file or directory)
26
+ DONE: 2. Move (file or directory)
27
+ DONE: 2. Use inotify to check for updates and update panes
28
+ DONE: 3. Directory delete - with warning if directory not empty) and/or count of total number of files
29
+
30
+ CONSIDERED, BUT WON'T DO:
31
+ NOPE: 3. Look at lfm source code to see how it does things (check licence for compatibility first) - Have looked at feature list and 'borrowed' some
32
+ NOPE: 8. Multiple panes (fixed at 2 to start with)
33
+ NOPE: 9. GUI version
34
+
35
+ TODO:
36
+ TODO: 1. Config file - keyboard config, override apps used to open particular file types
37
+ TODO: 2. '>' marker on current file or don't use background colours except for current file
38
+ TODO: 2. Scrolling help window
39
+ TODO: 2. Wildcard tagging/untagging
40
+ TODO: 2. ^R - redraw
41
+ TODO: 3. '.' show/hide hidden files
42
+ TODO: 3. '=' sets other pane to same directory as current one
43
+ TODO: 3. Allow copy/move to overwrite files (prompt first, with Y/N/A)
44
+ TODO: 3. Configurable shortcuts
45
+ TODO: 3. Create directory
46
+ TODO: 3. Menu for things like sort order
47
+ TODO: 3. Search - name or content
48
+ TODO: 3. Sort by date, size, owner, group, cheese preference, etc.
49
+ TODO: 3. Tagging - invert selection
50
+ TODO: 3. Use inotify and cache file data, or at least use inotify and update when current directory changes
51
+ TODO: 3. Use pygments/pymentize for syntax highlighting in file viewer
52
+ TODO: 3. When moving between panes move to same line on screen in new pane if possible
53
+ TODO: 4. Bookmarks for directories
54
+ TODO: 4. F12 = file info = show permissions, times, etc. in pop-up
55
+ TODO: 4. File list - proper formatting, nicer dates/times, support uid, gid, mode, size adjusted for KiB/MiB/GiB and with commas
56
+ TODO: 4. File search/grep
57
+ TODO: 4. Highlight current pane - draw border around it?
58
+ TODO: 4. Main loop is inefficient - we call filemanager.show_file_list() on every event
59
+ TODO: 4. Menu system with menu bar and pull-down menus
60
+ TODO: 4. Rename
61
+ TODO: 4. Tab to switch panes, left arrow to move up a directory level, right arrow to move into directory under cursor (if it is one)
62
+ TODO: 4. Treat symlinks to directories as directories not files
63
+ TODO: 4. ^F+string = go to first entry matching 'string'
64
+ TODO: 4. ^N = go to next ^F/^S match
65
+ TODO: 4. ^S+strign = go to first entry starting with 'string'
66
+ TODO: 4. ^U - Swap panes
67
+ TODO: 5. Built-in editor/viewer for text files (can use for help)
68
+ TODO: 5. Create symlink
69
+ TODO: 5. F11 = directory info = show number of files, size
70
+ TODO: 5. Option to echo cwd on exit so shell can pick up current location as pwd
71
+ TODO: 5. Scroll bar or per-pane status line showing current file index and number of files
72
+ TODO: 5. Shell window - enter command and run it in current tab
73
+ TODO: 5. Status at bottom of pane, tagged, hiddenm, total files & directories
74
+ TODO: 5. Verify it works in 16-colour terminal
75
+ TODO: 6. Config file - saves bookmarks, current keyboard config
76
+ TODO: 6. Configuration load/save - global, not directory-specific
77
+ TODO: 6. Control file details visible (user/group/size/permissions/times)
78
+ TODO: 6. F12 window with edit option to change permissions, owner
79
+ TODO: 6. Consider splitting TFM out from skilleter-thingy as a separate project; I don't think there's any overlap with the rest of skilleter-thingy
80
+ TODO: 6. File viewer
81
+ TODO: 6. File/directory info
82
+ TODO: 6. More command line parameters & options (directrories, search order, filters)
83
+ TODO: 6. Open/close tab
84
+ TODO: 6. Pickle and unpickle state
85
+ TODO: 6. Save settings in config file - bookmarks, key configuration, colours, etc.
86
+ TODO: 6. Search for file, wildcards, show list, select and move to directory
87
+ TODO: 6. Swap panes
88
+ TODO: 6. Synchronised scrolling across multiple panes
89
+ TODO: 6. Tree view
90
+ TODO: 6. Tree view for navigation
91
+ TODO: 6. Update progress when copying/moving/deleting
92
+ TODO: 7. Configurable colours
93
+ TODO: 7. Navigation by partial name (press prefix then start typing name and it moves to matching file)
94
+ TODO: 7. Use pads for panes (might be expensive in directories with lots of files)
95
+ TODO: 8. Allow copy/move to merge directories
96
+ TODO: 8. Better help
97
+ TODO: 8. Chmod/chown
98
+ TODO: 8. Directory bookmarks with selection menu
99
+ TODO: 8. Special environment vars in shell window - e.g. $:d for current directory $:od for current directory in other pane, $:f - current file $:of current file in other pane
100
+ TODO: 8. Touch file
101
+ TODO: 8. Trying to suspend tfm, run a command in the current shell and resume it doesn't work since I added threads for keyboard and inotify
102
+ TODO: 9. Git commands
103
+ TODO: 9. Mouse support
104
+ TODO: 9. multigit integration
105
+
106
+ """
107
+ ################################################################################
108
+
109
+ import os
110
+ import sys
111
+ import argparse
112
+ import curses
113
+ import subprocess
114
+ import shutil
115
+ from collections import defaultdict
116
+ import threading
117
+ import queue
118
+
119
+ from skilleter_modules import popup
120
+ from skilleter_modules import tfm_pane
121
+
122
+ ################################################################################
123
+ # Colour pair codes
124
+
125
+ COLOUR_NORMAL = 1
126
+ COLOUR_STATUS = 2
127
+ COLOUR_BACKGROUND = 3
128
+ COLOUR_WARNING = 4
129
+
130
+ RESERVED_COLOURS = 5
131
+
132
+ # TODO: Find a better way of sharing colours with the tfm_pane class
133
+
134
+ PANE_COLOURS = {'normal': 1, 'status': 2, 'background': 3, 'warning': 4, 'reserved_colours': 5}
135
+
136
+ # Version - used to update old pickle data
137
+
138
+ VERSION = 1
139
+
140
+ # Minimum console window size for functionality
141
+
142
+ MIN_CONSOLE_WIDTH = 64
143
+ MIN_CONSOLE_HEIGHT = 16
144
+
145
+ # Number of panes (TODO: Make this variable, rather than fixed)
146
+
147
+ NUM_PANES = 2
148
+
149
+ # Function key labels
150
+
151
+ FN_KEY_FN = ('Help', 'View', 'Search', 'Edit', 'Copy', 'Move', 'Mkdir', 'Delete', 'Rename', 'Quit')
152
+
153
+ ################################################################################
154
+
155
+ class FileManagerError(BaseException):
156
+ """ Exception for the application """
157
+
158
+ def __init__(self, msg, status=1):
159
+ super().__init__(msg)
160
+ self.msg = msg
161
+ self.status = status
162
+
163
+ ################################################################################
164
+
165
+ def error(msg, status=1):
166
+ """ Report an error """
167
+
168
+ sys.stderr.write('%s\n' % msg)
169
+ sys.exit(status)
170
+
171
+ ################################################################################
172
+
173
+ def in_directory(root, entry):
174
+ """ Return True if a directory lies within another """
175
+
176
+ return os.path.commonpath([root, entry]) == root
177
+
178
+ ################################################################################
179
+
180
+ def pickle_filename(working_tree):
181
+ """ Return the name of the pickle file for this working tree """
182
+
183
+ pickle_dir = os.path.join(os.environ['HOME'], '.config', 'tfm')
184
+
185
+ if not os.path.isdir(pickle_dir):
186
+ os.mkdir(pickle_dir)
187
+
188
+ pickle_file = working_tree.replace('/', '~')
189
+
190
+ return os.path.join(pickle_dir, pickle_file)
191
+
192
+ ################################################################################
193
+
194
+ def read_input(prompt):
195
+ """ Read input from the user """
196
+
197
+ win = curses.newwin(3, 60, 3, 10)
198
+ win.attron(curses.color_pair(COLOUR_STATUS))
199
+ win.box()
200
+
201
+ win.addstr(1, 1, prompt)
202
+ curses.curs_set(2)
203
+ win.refresh()
204
+ curses.echo()
205
+ text = win.getstr().decode(encoding='utf-8')
206
+ curses.noecho()
207
+ curses.curs_set(0)
208
+
209
+ return text
210
+
211
+ ################################################################################
212
+
213
+ def keyboard_wait(self):
214
+ """Thread to wait for keypresses and post them onto the event queue"""
215
+
216
+ while True:
217
+ keypress = self.screen.getch()
218
+ self.event_queue.put(('key', keypress))
219
+
220
+ ################################################################################
221
+
222
+ class FileManager():
223
+ """ Review function as a class """
224
+
225
+ def __init__(self, args):
226
+ """ Initialisation """
227
+
228
+ _ = args
229
+
230
+ # Move to the top-level directory in the working tree
231
+
232
+ self.init_key_despatch_table()
233
+
234
+ # Initialise the screen
235
+
236
+ self.screen = curses.initscr()
237
+
238
+ # Configure the colours, set the background & hide the cursor
239
+
240
+ self.init_colors()
241
+
242
+ # See if we have saved state for this repo
243
+
244
+ self.load_state()
245
+
246
+ # Create the queue for keyboard, file events
247
+
248
+ self.event_queue = queue.Queue()
249
+
250
+ # Start the keyboard thread
251
+
252
+ keyboard_thread = threading.Thread(target=keyboard_wait, args=(self, ), daemon=True)
253
+ keyboard_thread.start()
254
+
255
+ # Create the panes
256
+
257
+ self.panes = []
258
+
259
+ for i in range(NUM_PANES):
260
+ self.panes.append(tfm_pane.Pane(i, NUM_PANES, PANE_COLOURS, self.event_queue))
261
+
262
+ self.current_pane = self.panes[0]
263
+ self.pane_index = 0
264
+
265
+ self.searchstring = None
266
+
267
+ # Directory history
268
+
269
+ self.directory_history = defaultdict(str)
270
+
271
+ # Get the current console dimensions
272
+
273
+ self.update_console_size()
274
+
275
+ self.finished = False
276
+
277
+ ################################################################################
278
+
279
+ def init_key_despatch_table(self):
280
+ """ Initialise the keyboard despatch table """
281
+
282
+ # The table is indexed by the keycode and contains help and a reference to the
283
+ # function that is called when the key is pressed. For clarity, all the function
284
+ # names are prefixed with '__key_'.
285
+ # Note that the function key definitions should match FN_KEY_FN
286
+
287
+ self.key_despatch_table = \
288
+ {
289
+ curses.KEY_RESIZE: {'function': self.key_console_resize},
290
+
291
+ curses.KEY_UP: {'key': 'UP', 'help': 'Move up 1 line', 'function': self.key_move_up},
292
+ curses.KEY_DOWN: {'key': 'DOWN', 'help': 'Move down 1 line', 'function': self.key_move_down},
293
+ curses.KEY_LEFT: {'key': 'LEFT', 'help': 'Move to previous pane', 'function': self.key_switch_previous_pane},
294
+ curses.KEY_RIGHT: {'key': 'RIGHT', 'help': 'Move to next pane', 'function': self.key_switch_next_pane},
295
+ curses.KEY_NPAGE: {'key': 'PGDN', 'help': 'Move down by a page', 'function': self.key_move_page_down},
296
+ ord('\t'): {'key': 'TAB', 'help': 'Switch panes', 'function': self.key_switch_next_pane},
297
+ curses.KEY_PPAGE: {'key': 'PGUP', 'help': 'Move up by a page', 'function': self.key_move_page_up},
298
+ curses.KEY_END: {'key': 'END', 'help': 'Move to the end of the file list', 'function': self.key_move_end},
299
+ curses.KEY_HOME: {'key': 'HOME', 'help': 'Move to the top of the file list', 'function': self.key_move_top},
300
+ curses.KEY_IC: {'key': 'INS', 'help': 'Tag file/directory', 'function': self.key_tag},
301
+ curses.KEY_DC: {'key': 'DEL', 'help': 'Delete current or tagged files/directories', 'function': self.key_delete},
302
+ curses.KEY_BACKSPACE: {'key': 'BACKSPACE', 'help': 'Move to the parent directory', 'function': self.key_parent},
303
+
304
+ curses.KEY_F1: {'key': 'F1', 'help': 'Show help', 'function': self.key_show_help},
305
+ curses.KEY_F2: {'key': 'F2', 'help': 'View file', 'function': self.key_view_file},
306
+ curses.KEY_F3: {'key': 'F3', 'help': 'Search for next match', 'function': self.key_search_again},
307
+ curses.KEY_F4: {'key': 'F4', 'help': 'Edit file', 'function': self.key_edit_file},
308
+ curses.KEY_F5: {'key': 'F5', 'help': 'Copy', 'function': self.key_copy},
309
+ curses.KEY_F6: {'key': 'F6', 'help': 'Move', 'function': self.key_move},
310
+ curses.KEY_F7: {'key': 'F7', 'help': 'MkDir', 'function': self.key_mkdir},
311
+ curses.KEY_F8: {'key': 'F8', 'help': 'Delete', 'function': self.key_delete},
312
+ curses.KEY_F9: {'key': 'F9', 'help': 'Rename', 'function': self.key_rename},
313
+ curses.KEY_F10: {'key': 'F10', 'help': 'Quit', 'function': self.key_quit_review},
314
+
315
+ ord('\n'): {'key': 'ENTER', 'help': 'Change directory', 'function': self.key_open_file_or_directory},
316
+ ord(' '): {'key': 'SPACE', 'help': 'Show file details', 'function': self.key_show_file_info},
317
+ ord('/'): {'help': 'Search', 'function': self.key_search_file},
318
+ ord('F'): {'help': 'Show only files matching a wildcard', 'function': self.key_filter_in},
319
+ ord('f'): {'help': 'Hide files matching a wildcard', 'function': self.key_filter_out},
320
+ ord('.'): {'help': 'Toggle display of hidden files', 'function': self.key_toggle_hidden},
321
+ ord('m'): {'help': 'Tag files that match a wildcard', 'function': self.key_wild_tag},
322
+ ord('M'): {'help': 'Untag files that match a wildcard', 'function': self.key_wild_untag},
323
+ ord('c'): {'help': 'Clear tags', 'function': self.key_clear_tags},
324
+ ord('R'): {'help': 'Reload', 'function': self.key_reload_changes_and_reset},
325
+ ord('q'): {'help': 'Quit', 'function': self.key_quit_review},
326
+ ord('$'): {'help': 'Open shell at location of current file', 'function': self.key_open_shell},
327
+ ord('e'): {'help': 'Edit the current file', 'function': self.key_edit_file},
328
+ ord('v'): {'help': 'View the current file', 'function': self.key_view_file},
329
+ ord('s'): {'help': 'Cycle sort order', 'function': self.key_cycle_sort},
330
+ ord('S'): {'help': 'Reverse sort order', 'function': self.key_reverse_sort},
331
+ }
332
+
333
+ ################################################################################
334
+
335
+ def save_state(self):
336
+ """ Save the current state (normally called on exit) """
337
+
338
+ pass
339
+
340
+ ################################################################################
341
+
342
+ def load_state(self):
343
+ """ Unpickle saved state if it exists """
344
+
345
+ pass
346
+
347
+ ################################################################################
348
+
349
+ def init_colors(self):
350
+ """ Set up the colours and initialise the display """
351
+
352
+ curses.start_color()
353
+ curses.use_default_colors()
354
+
355
+ curses.init_color(15, 1000, 1000, 1000)
356
+
357
+ if os.getenv('THINGY_DARK_MODE'):
358
+ curses.init_pair(COLOUR_NORMAL, 15, curses.COLOR_BLACK)
359
+ curses.init_pair(COLOUR_STATUS, 15, curses.COLOR_GREEN)
360
+ curses.init_pair(COLOUR_BACKGROUND, 15, curses.COLOR_BLACK)
361
+ curses.init_pair(COLOUR_WARNING, 15, curses.COLOR_RED)
362
+ else:
363
+ curses.init_pair(COLOUR_NORMAL, curses.COLOR_BLACK, 15)
364
+ curses.init_pair(COLOUR_STATUS, 15, curses.COLOR_GREEN)
365
+ curses.init_pair(COLOUR_BACKGROUND, curses.COLOR_BLACK, 15)
366
+ curses.init_pair(COLOUR_WARNING, 15, curses.COLOR_RED)
367
+
368
+ self.screen.bkgdset(' ', curses.color_pair(COLOUR_BACKGROUND))
369
+
370
+ curses.curs_set(0)
371
+
372
+ # Clear and refresh the screen for a blank canvas
373
+
374
+ self.screen.clear()
375
+ self.screen.refresh()
376
+
377
+ ################################################################################
378
+
379
+ def centre_text(self, y_pos, color, text):
380
+ """ Centre text """
381
+
382
+ if len(text) >= self.width:
383
+ output = text[:self.width - 1]
384
+ else:
385
+ output = text
386
+
387
+ x_pos = max(0, (self.width - len(output)) // 2)
388
+
389
+ self.screen.attron(color)
390
+ self.screen.hline(y_pos, 0, ' ', self.width)
391
+ self.screen.addstr(y_pos, x_pos, output)
392
+ self.screen.attroff(color)
393
+
394
+ ################################################################################
395
+
396
+ def draw_screen(self):
397
+ """ Draw the review screen """
398
+
399
+ # Render status bar
400
+
401
+ status_bar = self.current_pane.sort_type_msg()
402
+
403
+ # TODO: Each pane needs a status bar with this info on
404
+ # if self.out_filter or self.in_filter:
405
+ # status_bar = '%s, active filters: %s' % (status_bar, self.filter_description())
406
+
407
+ self.centre_text(self.status_y, curses.color_pair(COLOUR_STATUS), status_bar)
408
+
409
+ title_bar = 'Thingy File Manager'
410
+
411
+ self.centre_text(0, curses.color_pair(COLOUR_STATUS), title_bar)
412
+
413
+ fn_width = self.width // 10
414
+
415
+ self.screen.move(self.status_y + 1, 0)
416
+ self.screen.clrtoeol()
417
+
418
+ for fn_key in range(1, 11):
419
+ self.screen.attron(curses.color_pair(COLOUR_STATUS))
420
+ self.screen.addstr(self.status_y + 1, (fn_key - 1) * fn_width, f'F{fn_key}')
421
+ self.screen.attron(curses.color_pair(COLOUR_BACKGROUND))
422
+ self.screen.addstr(self.status_y + 1, (fn_key - 1) * fn_width + 3, f'{FN_KEY_FN[fn_key-1]}')
423
+
424
+ self.screen.refresh()
425
+
426
+ ################################################################################
427
+
428
+ def update_console_size(self):
429
+ """ Get current screen size and set up locations in the display """
430
+
431
+ self.height, self.width = self.screen.getmaxyx()
432
+
433
+ self.status_y = self.height - 2
434
+
435
+ for pane in self.panes:
436
+ pane.set_pane_coords(1, 0, self.height - 3, self.width)
437
+
438
+ if self.width < MIN_CONSOLE_WIDTH or self.height < MIN_CONSOLE_HEIGHT:
439
+ raise FileManagerError('Console window is too small!')
440
+
441
+ ################################################################################
442
+
443
+ def run_external_command(self, cmd):
444
+ """ Run an external command, with the current directory being that of the
445
+ current file and shutting down curses before running the command
446
+ then restarting it """
447
+
448
+ # Run the command in a separate xterm
449
+
450
+ cmd = ['xterm', '-e'] + cmd
451
+
452
+ try:
453
+ subprocess.run(cmd, cwd=self.current_pane.get_current_dir(), check=True)
454
+ except subprocess.CalledProcessError as exc:
455
+ return_code = exc.returncode
456
+ else:
457
+ return_code = 0
458
+
459
+ # Reload in case we've missed something
460
+
461
+ for pane in self.panes:
462
+ pane.reload_changes()
463
+
464
+ self.key_console_resize()
465
+
466
+ if return_code:
467
+ with popup.PopUp(self.screen, f'Command returned status={return_code}', COLOUR_STATUS):
468
+ pass
469
+
470
+ ################################################################################
471
+
472
+ def move_to_dir(self, dirname):
473
+ """Move to a different directory, tracking directory history"""
474
+
475
+ current_dir = self.current_pane.get_current_dir()
476
+
477
+ self.directory_history[current_dir] = os.path.basename(self.current_pane.get_current_file()['name'])
478
+ new_dir = os.path.abspath(os.path.join(current_dir, dirname))
479
+
480
+ self.current_pane.set_current_dir(new_dir)
481
+ self.current_pane.reload_changes()
482
+ self.current_pane.move_to_file(self.directory_history[new_dir])
483
+
484
+ ################################################################################
485
+
486
+ def key_console_resize(self):
487
+ """ Update the screen size variables when the console window is resized """
488
+
489
+ self.update_console_size()
490
+ self.draw_screen()
491
+ self.screen.refresh()
492
+
493
+ ################################################################################
494
+
495
+ def key_show_help(self):
496
+ """ Show help information in a pop-up window """
497
+
498
+ # Compile list of keyboard functions
499
+
500
+ helpinfo = []
501
+
502
+ for key in self.key_despatch_table:
503
+ if 'help' in self.key_despatch_table[key]:
504
+ if 'key' in self.key_despatch_table[key]:
505
+ keyname = self.key_despatch_table[key]['key']
506
+ else:
507
+ keyname = chr(key)
508
+
509
+ helpinfo.append('%-5s - %s' % (keyname, self.key_despatch_table[key]['help']))
510
+
511
+ helptext = '\n'.join(helpinfo)
512
+
513
+ with popup.PopUp(self.screen, helptext, COLOUR_STATUS, waitkey=True, centre=False):
514
+ pass
515
+
516
+ ################################################################################
517
+
518
+ def key_show_file_info(self):
519
+ """ TODO: Show information about the current file in a pop-up window """
520
+
521
+ pass
522
+
523
+ ################################################################################
524
+
525
+ def key_switch_next_pane(self):
526
+ """ Switch to the next pane """
527
+
528
+ self.pane_index = (self.pane_index + 1) % NUM_PANES
529
+ self.current_pane = self.panes[self.pane_index]
530
+
531
+ ################################################################################
532
+
533
+ def get_next_pane(self):
534
+ """ Return the handle of the next pane """
535
+
536
+ next_pane_index = (self.pane_index + 1) % NUM_PANES
537
+
538
+ return self.panes[next_pane_index]
539
+
540
+ ################################################################################
541
+
542
+ def key_switch_previous_pane(self):
543
+ """ Switch to the previous pane """
544
+
545
+ self.pane_index = (self.pane_index - 1) % NUM_PANES
546
+ self.current_pane = self.panes[self.pane_index]
547
+
548
+ ################################################################################
549
+
550
+ def key_open_file_or_directory(self):
551
+ """ Open the current file or change directory """
552
+
553
+ current_file = self.current_pane.get_current_file()
554
+
555
+ if current_file['isdir']:
556
+ self.move_to_dir(current_file['name'])
557
+ else:
558
+ # TODO: Needs to work on Mac and Windows (well, Mac, anyway!)
559
+ self.run_external_command(['xdg-open', current_file['name']])
560
+ # self.open_file(self.filtered_file_indices[self.current]['name'])
561
+
562
+ ################################################################################
563
+
564
+ def key_parent(self):
565
+ """ Move to the parent directory """
566
+
567
+ self.move_to_dir('..')
568
+
569
+ ################################################################################
570
+
571
+ def key_delete(self):
572
+ """ Delete the current or tagged files or directories """
573
+
574
+ files_to_delete = self.current_pane.get_tagged_files()
575
+
576
+ with popup.PopUp(self.screen, f'Deleting {len(files_to_delete)} files...', COLOUR_STATUS):
577
+ for entry in files_to_delete:
578
+ if entry['isdir']:
579
+ shutil.rmtree(entry['name'])
580
+ else:
581
+ os.unlink(entry['name'])
582
+
583
+ self.current_pane.update_files()
584
+
585
+ ################################################################################
586
+
587
+ def key_move(self):
588
+ """ Move the current or tagged files to the directory in the next pane """
589
+
590
+ next_pane = self.get_next_pane()
591
+
592
+ files_to_move = self.current_pane.get_tagged_files()
593
+
594
+ current_dir = self.current_pane.get_current_dir()
595
+
596
+ destination_dir = next_pane.get_current_dir()
597
+
598
+ if current_dir == destination_dir:
599
+ with popup.PopUp(self.screen, 'Source and destination directories are the same', COLOUR_WARNING):
600
+ pass
601
+ else:
602
+ for entry in files_to_move:
603
+ destination = os.path.join(destination_dir, os.path.basename(entry['name']))
604
+ if os.path.exists(destination):
605
+ name = os.path.basename(entry['name'])
606
+
607
+ with popup.PopUp(self.screen, f'{name} already exists in the destination directory', COLOUR_WARNING):
608
+ pass
609
+ return
610
+
611
+ with popup.PopUp(self.screen, f'Moving {len(files_to_move)} files/directories', COLOUR_STATUS):
612
+ for entry in files_to_move:
613
+ shutil.move(entry['name'], os.path.join(destination_dir, os.path.basename(entry['name'])))
614
+
615
+ self.current_pane.update_files()
616
+ next_pane.update_files()
617
+
618
+ ################################################################################
619
+
620
+ def key_copy(self):
621
+ """ Copy the current or tagged files to the directory in the next pane """
622
+
623
+ next_pane = self.get_next_pane()
624
+
625
+ files_to_copy = self.current_pane.get_tagged_files()
626
+
627
+ current_dir = self.current_pane.get_current_dir()
628
+
629
+ destination_dir = next_pane.get_current_dir()
630
+
631
+ if current_dir == destination_dir:
632
+ with popup.PopUp(self.screen, 'Source and destination directories are the same', COLOUR_WARNING):
633
+ pass
634
+ else:
635
+ for entry in files_to_copy:
636
+ destination = os.path.join(destination_dir, os.path.basename(entry['name']))
637
+ if os.path.exists(destination):
638
+ name = os.path.basename(entry['name'])
639
+
640
+ with popup.PopUp(self.screen, f'{name} already exists in the destination directory', COLOUR_WARNING):
641
+ pass
642
+ return
643
+
644
+ with popup.PopUp(self.screen, f'Copying {len(files_to_copy)} files/directories', COLOUR_STATUS):
645
+ for entry in files_to_copy:
646
+ if entry['isdir']:
647
+ shutil.copytree(entry['name'], os.path.join(destination_dir, os.path.basename(entry['name'])))
648
+ else:
649
+ shutil.copy2(entry['name'], destination_dir)
650
+
651
+ self.current_pane.update_files()
652
+ next_pane.update_files()
653
+
654
+ ################################################################################
655
+
656
+ def key_rename(self):
657
+ """ Rename current or tagged files or directories """
658
+
659
+ # TODO
660
+ pass
661
+
662
+ ################################################################################
663
+
664
+ def key_quit_review(self):
665
+ """ Quit """
666
+
667
+ self.finished = True
668
+
669
+ ################################################################################
670
+
671
+ def key_edit_file(self):
672
+ """ Edit the current file """
673
+
674
+ editor = os.environ.get('EDITOR', 'vim')
675
+ self.run_external_command([editor, os.path.basename(self.current_pane.get_current_file()['name'])])
676
+
677
+ ################################################################################
678
+
679
+ def key_view_file(self):
680
+ """ Edit the current file
681
+ TODO: Write internal file viewer """
682
+
683
+ pager = os.environ.get('TFM_PAGER', os.environ.get('PAGER', 'more'))
684
+ self.run_external_command([pager, os.path.basename(self.current_pane.get_current_file()['name'])])
685
+
686
+ ################################################################################
687
+
688
+ def key_mkdir(self):
689
+ """ TODO: mkdir """
690
+ pass
691
+
692
+ ################################################################################
693
+
694
+ def key_wild_tag(self):
695
+ """ TODO: """
696
+ pass
697
+
698
+ ################################################################################
699
+
700
+ def key_wild_untag(self):
701
+ """ TODO: """
702
+ pass
703
+
704
+ ################################################################################
705
+
706
+ def key_clear_tags(self):
707
+ """ Untag everything """
708
+
709
+ self.current_pane.untag()
710
+
711
+ ################################################################################
712
+
713
+ def key_open_shell(self):
714
+ """ Open a shell in the same directory as the current file
715
+ """
716
+
717
+ self.run_external_command([os.getenv('SHELL')])
718
+ self.update_console_size()
719
+
720
+ ################################################################################
721
+
722
+ def key_reload_changes_and_reset(self):
723
+ """ Reload changes and reset the review status of each file,
724
+ the current file and unhide reviewed files """
725
+
726
+ with popup.PopUp(self.screen, 'Reload changes & reset reviewed status', COLOUR_STATUS):
727
+ self.current_pane.clear_filters()
728
+ self.current_pane.update_files()
729
+ self.current_pane.move_top()
730
+
731
+ ################################################################################
732
+
733
+ def key_search_file(self):
734
+ """ Prompt for a search string and find a match """
735
+
736
+ self.searchstring = '*' + read_input('Search for: ') + '*'
737
+
738
+ self.current_pane.search_match(self.searchstring)
739
+
740
+ ################################################################################
741
+
742
+ def key_search_again(self):
743
+ """ Prompt for a search string if none defined then search """
744
+
745
+ if self.searchstring:
746
+ self.current_pane.search_next_match()
747
+ else:
748
+ self.key_search_file()
749
+
750
+ ################################################################################
751
+
752
+ def key_filter_out(self):
753
+ """ Hide files matching a wildcard """
754
+
755
+ filter_out = read_input('Hide files matching: ')
756
+
757
+ if filter_out:
758
+ self.current_pane.filter_out(filter_out)
759
+
760
+ ################################################################################
761
+
762
+ def key_filter_in(self):
763
+ """ Only show files matching a wildcard """
764
+
765
+ filter_in = read_input('Only show files matching: ')
766
+
767
+ if filter_in:
768
+ self.current_pane.filter_in(filter_in)
769
+
770
+ ################################################################################
771
+
772
+ def key_clear_filters(self):
773
+ """ Clear filters """
774
+
775
+ with popup.PopUp(self.screen, 'Cleared all filters', COLOUR_STATUS):
776
+ self.current_pane.clear_filters()
777
+
778
+ ################################################################################
779
+
780
+ def key_toggle_hidden(self):
781
+ """ Toggle display of hidden files """
782
+
783
+ current_state = self.current_pane.get_hidden_visibility()
784
+ self.current_pane.set_hidden_visibility(not current_state)
785
+
786
+ ################################################################################
787
+
788
+ def key_move_down(self):
789
+ """ Move down 1 line """
790
+
791
+ self.current_pane.move(1)
792
+
793
+ ################################################################################
794
+
795
+ def key_move_up(self):
796
+ """ Move up 1 line """
797
+
798
+ self.current_pane.move(-1)
799
+
800
+ ################################################################################
801
+
802
+ def key_move_page_down(self):
803
+ """ Move down by a page """
804
+
805
+ self.current_pane.move_page_down()
806
+
807
+ ################################################################################
808
+
809
+ def key_move_page_up(self):
810
+ """ Move up by a page """
811
+
812
+ self.current_pane.move_page_up()
813
+
814
+ ################################################################################
815
+
816
+ def key_move_top(self):
817
+ """ Move to the top of the file list """
818
+
819
+ self.current_pane.move_top()
820
+
821
+ ################################################################################
822
+
823
+ def key_move_end(self):
824
+ """ Move to the end of the file list """
825
+
826
+ self.current_pane.move_end()
827
+
828
+ ################################################################################
829
+
830
+ def key_cycle_sort(self):
831
+ """ Cycle through the various sort options """
832
+
833
+ self.current_pane.set_sort_order(1)
834
+
835
+ ################################################################################
836
+
837
+ def key_reverse_sort(self):
838
+ """ Reverse the current sort order """
839
+
840
+ self.current_pane.reverse_sort_order()
841
+
842
+ ################################################################################
843
+
844
+ def key_tag(self):
845
+ """ Tag/Untag the current file """
846
+
847
+ self.current_pane.tag_current()
848
+ self.key_move_down()
849
+
850
+ ################################################################################
851
+
852
+ def done(self):
853
+ """ Quit """
854
+
855
+ return self.finished
856
+
857
+ ################################################################################
858
+
859
+ def handle_keypress(self, keypress):
860
+ """ Handle a key press """
861
+
862
+ if keypress in self.key_despatch_table:
863
+ self.key_despatch_table[keypress]['function']()
864
+
865
+ # Keep the current entry in range
866
+
867
+ self.current_pane.constrain_display_parameters()
868
+
869
+ ################################################################################
870
+
871
+ def show_file_list(self):
872
+ """ Show all file lists """
873
+
874
+ for pane in self.panes:
875
+ pane.show_file_list(pane == self.current_pane)
876
+
877
+ ################################################################################
878
+
879
+ def event(self):
880
+ """Wait for, and return the next event from the event queue"""
881
+
882
+ return self.event_queue.get()
883
+
884
+ ################################################################################
885
+
886
+ def parse_command_line():
887
+ """ Parse the command line, return the arguments """
888
+
889
+ # TODO: Options and arguments
890
+
891
+ parser = argparse.ArgumentParser(description='Console file manager')
892
+
893
+ parser.add_argument('--pudb', action='store_true', help='Invoke pudb debugger over Telnet')
894
+
895
+ args = parser.parse_args()
896
+
897
+ args.paths = None
898
+
899
+ return args
900
+
901
+ ################################################################################
902
+
903
+ def main(screen, args):
904
+ """ Parse the command line and run the review """
905
+
906
+ filemanager = FileManager(args)
907
+
908
+ filemanager.draw_screen()
909
+
910
+ while not filemanager.done():
911
+ filemanager.show_file_list()
912
+
913
+ event, data = filemanager.event()
914
+
915
+ if event == 'key':
916
+ filemanager.handle_keypress(data)
917
+ elif event == 'inotify':
918
+ filemanager.panes[data].reload_changes()
919
+
920
+ filemanager.save_state()
921
+
922
+ ################################################################################
923
+
924
+ def tfm():
925
+ """Main function"""
926
+
927
+ try:
928
+ command_args = parse_command_line()
929
+
930
+ if command_args.pudb:
931
+ from pudb.remote import set_trace
932
+ set_trace(term_size=(190, 45))
933
+
934
+ curses.wrapper(main, command_args)
935
+
936
+ except KeyboardInterrupt:
937
+ sys.exit(1)
938
+
939
+ except BrokenPipeError:
940
+ sys.exit(2)
941
+
942
+ except FileManagerError as exc:
943
+ print(exc.msg)
944
+
945
+ ################################################################################
946
+
947
+ if __name__ == '__main__':
948
+ tfm()