skilleter-thingy 0.0.40__py3-none-any.whl → 0.0.42__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.

Potentially problematic release.


This version of skilleter-thingy might be problematic. Click here for more details.

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