rtfm-filemanager 4.10 → 5

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 (6) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +278 -166
  3. data/bin/rtfm +2063 -1657
  4. data/img/logo.png +0 -0
  5. data/img/rtfm-kb.png +0 -0
  6. metadata +22 -14
data/bin/rtfm CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
- # encoding: utf-8
3
- #
4
- # SCRIPT INFO
2
+ # frozen_string_literal: true
3
+
4
+ # SCRIPT INFO {{{1
5
5
  # Name: RTFM - Ruby Terminal File Manager
6
6
  # Language: Pure Ruby, best viewed in VIM
7
7
  # Author: Geir Isene <g@isene.com>
@@ -12,1326 +12,2007 @@
12
12
  # this software for any purpose. I make no guarantee about the
13
13
  # suitability of this software for any purpose and I am not liable
14
14
  # for any damages resulting from its use. Further, I am under no
15
- # obligation to maintain or extend this software. It is provided
15
+ # obligation to maintain or extend this software. It is provided
16
16
  # on an 'as is' basis without any expressed or implied warranty.
17
- @version = "4.10" # Adjustable top/bottom colors
18
-
19
- # PRELIMINARIES
20
- @help = <<HELPTEXT
21
- RTFM - Ruby Terminal File Manager (https://github.com/isene/RTFM)
22
-
23
- BASIC KEYS
24
- ? = Show this help text
25
- r = Refresh RTFM (recreates all windows. Use on terminal resize or when there is garbage somewhere)
26
- R = Reload configuration (~/.rtfm.conf)
27
- W = Write parameters to ~/.rtfm.conf
28
- (@lsall, @lslong, @lsorder, @lsinvert, @border, @width, @preview, @tagged, @marks)
29
- q = Quit
30
- Q = QUIT (without writing changes to the config file)
31
- v = Display RTFM version in bottom window/command bar
32
-
33
- MOTION
34
- j/DOWN = Go one item down in left pane (rounds to top)
35
- k/UP = Go one item up in left pane (rounds to bottom)
36
- h/LEFT = Go up one directory level
37
- l/RIGHT = Enter directory or open file (using run-mailcap or xdg-open)
38
- Use the key 'x' to force open using xdg-open (or run-mailcap) - used for opening html files
39
- in a browser rather than editing the file in your text editor
40
- PgDown = Go one page down in left pane
41
- PgUp = Go one page up in left pane
42
- END = Go to last item in left pane
43
- HOME = Go to first item in left pane
44
-
45
- JUMPING AND MARKS
46
- m = Mark current dir (persistent). Next letter is the name of the mark [a-zA-Z']
47
- The special mark "'" jumps to the last directory (makes toggling dirs easy)
48
- Press '-' and a letter to delete that mark
49
- M = Show marked items in right pane
50
- ' = Jump to mark (next letter is the name of the mark [a-zA-Z'])
51
- The 5 latest directories visited are stored in marks 1-5 (1 being the very latest)
52
- ~ = Jump to Home directory
53
- > = Follow symlink to the directory where the target resides
54
-
55
- SEARCHING
56
- / = Enter search string in bottom window to highlight matching items and jump to the first match
57
- \\ = Remove search pattern
58
- n = Jump to the next item matched by '/'
59
- N = Jump to the previous item matched by '/'
60
- g = Run 'grep' to show files that contains the MATCH in current directory
61
- L = Start 'locate' search for files, then use '#' to jump to desired line/directory
62
- Ctrl-l = Locate files via fzf from the current directory down
63
- (fuzzy file finder must be installed https://github.com/junegunn/fzf)
64
-
65
- TAGGING
66
- t = Tag item (toggles)
67
- Ctrl-t = Add items matching a pattern to list of tagged items (Ctrl-t and then . will tag all items)
68
- T = Show currently tagged items in right pane
69
- u = Untag all tagged items
70
-
71
- MANIPULATE ITEMS
72
- p = Put (copy) tagged items here
73
- P = PUT (move) tagged items here
74
- s = Create symlink to tagged items here
75
- d = Delete selected item and tagged items. Press 'y' to confirm
76
- c = Change/rename selected (adds command to bottom window)
77
- Ctrl-o = Change ownership to user:group of selected and tagged items
78
- Ctrl-p = Change permissions of selected and tagged items
79
- Format = rwxr-xr-x or 755 or rwx (applies the trio to user, group and others)
80
- z = Extract tagged zipped archive to current directory
81
- Z = Create zipped archive from tagged files/directories
82
-
83
- DIRECTORY VIEWS
84
- a = Show all (also hidden) items
85
- A = Show long info per item (show item attributes)
86
- o = Change the order/sorting of directories (circular toggle)
87
- i = Invert/reverse the sorting
88
- O = Show the Ordering in the bottom window (the full ls command)
89
- G = Show git status for current directory
90
- H = Do a cryptographic hash of the current directory with subdirs
91
- If a previous hash was made, compare and report if there has been any change
92
- I = Show OpenAI's description of the selected item and its content (if available)
93
- You must have installed the ruby-openai gem and added your openai secret key
94
- in the .rtfm.conf (add `@ai = "your-secret-openai-key") for this to work.
95
-
96
- RIGHT PANE
97
- ENTER = Refresh the right pane
98
- TAB = Next page of the preview (if doc long and ∇ in the bottom right)
99
- S-TAB = Previous page (if you have moved down the document first - ∆ in the top right)
100
- w = Change the width of the left/right panes (left pane ⇒ 20%, 30%, 40%, 50%, 60%)
101
- - = (Minus sign) Toggle preview in right pane (turn it off for faster traversing of directories)
102
- _ = (Underscore) Toggle preview of images in right pane
103
- b = Toggle syntax highlighting (and line numbering)
104
-
105
- ADDITIONAL COMMANDS
106
- f = Show only files in the left pane matching extension(s) (e.g. "txt" or "pdf,png,jpg")
107
- F = Show only files matching a pattern (Ruby Regex) (e.g. "abc" or "ab.+12(\w3)+")
108
- B = Toggle border
109
- : = Enter "command mode" in bottom window (press ENTER to execute, press Ctrl-G to escape)
110
- ; = Show command history in right pane
111
- y = Copy path of selected item to primary selection (for pasting with middle mouse button)
112
- Y = Copy path of selected item to clipboard
113
- Ctrl-y = Copy content of right pane to clipboard
114
- Ctrl-d = Create a new directory (a shortcut for ":mkdir ")
115
- Ctrl-n = Invoke navi (see https://github.com/denisidoro/navi) with any output in right window
116
- S = Show comprehensive System info (system, CPU, filesystem, latest dmesg messages)
117
-
118
- COPYRIGHT: Geir Isene, 2020-5. No rights reserved. See http://isene.com for more.
17
+ # Docs: Apart from the extensive documentation found on Github, you can
18
+ # get a great understanding of the code itself by simply sending
19
+ # or pasting this whole file into you favorite AI for coding with
20
+ # a prompt like this: "Help me understand every part of this code".
21
+ @version = '5.0' # A full rewrite using https://github.com/isene/rcurses
22
+
23
+ # BOOTSNAP {{{1
24
+ cache_dir = ENV.fetch('BOOTSNAP_CACHE_DIR', File.join(Dir.home, '.rtfm', 'bootsnap-cache'))
25
+ ENV['BOOTSNAP_CACHE_DIR'] = cache_dir
26
+ require 'fileutils'
27
+ FileUtils.mkdir_p(cache_dir)
28
+ require 'bootsnap/setup' # Speed up subsequent requires
29
+
30
+ # ENCODING {{{1
31
+ # encoding: utf-8
32
+
33
+ # PROFILER {{{1
34
+ #$start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ #$last_checkpoint = $start_time
36
+ #$checkpoints = []
37
+ #def checkpoint(label)
38
+ # now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
+ # delta_ms = ((now - $last_checkpoint) * 1000).round(2)
40
+ # total_ms = ((now - $start_time) * 1000).round(2)
41
+ # $checkpoints << "#{label.ljust(20)}: #{delta_ms} ms"
42
+ # $checkpoints.reject! { |line| line.start_with?("Total:") }
43
+ # $checkpoints << "Total: #{total_ms} ms"
44
+ # $last_checkpoint = now
45
+ #end
46
+
47
+ # LOAD LIBRARIES {{{1
48
+ begin
49
+ require 'rcurses'
50
+ class Object
51
+ include Rcurses
52
+ include Rcurses::Input
53
+ end
54
+ rescue StandardError => e
55
+ puts 'RTFM is built using rcurses (https://github.com/isene/rcurses). Install rcurses to run RTFM.'
56
+ exit 1
57
+ end
58
+ require 'tmpdir'
59
+ # Lazy-load to speed up startup
60
+ autoload :Shellwords, 'shellwords'
61
+ autoload :Timeout, 'timeout'
62
+ autoload :Open3, 'open3'
63
+ autoload :PTY, 'pty'
64
+ autoload :OpenAI, 'ruby/openai'
65
+ #checkpoint("Libraries loaded")
66
+
67
+ # FIX TERMINAL MESSAGE BLEED-THROUGH {{{1
68
+ LOG_PATH = File.join(Dir.tmpdir, 'rtfm.log')
69
+ MAX_LOG_SIZE = 128 * 1024
70
+ if File.exist?(LOG_PATH) && File.size(LOG_PATH) > MAX_LOG_SIZE
71
+ # keep just the last MAX_LOG_SIZE bytes
72
+ File.open(LOG_PATH, 'r+b') do |f|
73
+ f.seek(-MAX_LOG_SIZE, IO::SEEK_END)
74
+ tail = f.read
75
+ f.rewind
76
+ f.truncate(0)
77
+ f.write(tail)
78
+ end
79
+ end
80
+ logfile = File.open(LOG_PATH, 'a+')
81
+ logfile.sync = true
82
+ $stderr.reopen(logfile)
83
+ #checkpoint("Bleed-through fix")
84
+
85
+ # RCURSES CLASS EXTENSION {{{1
86
+ module Rcurses
87
+ # Add attributes, amend 'say' to set update to false
88
+ class Pane
89
+ attr_accessor :update, :locate
90
+ alias original_say say
91
+ def say(text)
92
+ original_say(text)
93
+ self.update = false
94
+ end
95
+ end
96
+ end
97
+
98
+ # CREATE DIRS & SET FILE CONSTS {{{1
99
+ RTFM_HOME = File.join(Dir.home, '.rtfm')
100
+ PLUGINS_DIR = File.join(RTFM_HOME, 'plugins')
101
+ TRASH_DIR = File.join(RTFM_HOME, 'trash')
102
+ [RTFM_HOME, PLUGINS_DIR, TRASH_DIR].each { |d| FileUtils.mkdir_p(d) }
103
+ PREVIEW_FILE = File.join(RTFM_HOME, 'preview.rb')
104
+ KEYS_FILE = File.join(RTFM_HOME, 'keys.rb') unless defined?(KEYS_FILE)
105
+ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
106
+ @plugin_errors = []
107
+
108
+ # SAVE TERMINAL {{{1
109
+ ORIG_STTY = `stty -g`.chomp
110
+
111
+ # HELP {{{1
112
+ @help = <<~HELPTEXT
113
+ RTFM - Ruby Terminal File Manager (https://github.com/isene/RTFM)
114
+
115
+ BASIC KEYS
116
+ ? = Show this help text
117
+ v = Display RTFM version (and latest Gem version) in bottom window/command bar
118
+ r = Refresh RTFM (recreates all windows. Use on terminal resize or when there is garbage somewhere)
119
+ R = Reload configuration (~/.rtfm/conf)
120
+ W = Write parameters to ~/.rtfm/conf: @marks, @hash, @history, @rubyhistory, @aihistory
121
+ @lslong, @lsall, @lsorder, @lsinvert, @width, @border, @preview, @trash
122
+ C = Show the current configuration in ~/.rtfm/conf
123
+ q = Quit (save basic configuration: @marks, @hash, @history, @rubyhistory, @aihistory)
124
+ Q = QUIT (without writing any changes to the config file)
125
+
126
+ LAYOUT
127
+ w = Change the width of the left/right panes (left pane ⇒ 20%, 30%, 40%, 50%, 60%)
128
+ B = Cycle border
129
+ - = (Minus sign) Toggle preview in right pane (turn it off for faster traversing of directories)
130
+ _ = (Underscore) Toggle preview of images in right pane
131
+ b = Toggle syntax highlighting (and line numbering)
132
+
133
+ MOTION
134
+ j/DOWN = Go one item down in left pane (rounds to top)
135
+ k/UP = Go one item up in left pane (rounds to bottom)
136
+ h/LEFT = Go up one directory level
137
+ l/RIGHT = Enter directory or open file (using run-mailcap or xdg-open)
138
+ Use the key 'x' to force open using xdg-open (or run-mailcap) - used for opening html files
139
+ in a browser rather than editing the file in your text editor
140
+ PgDown = Go one page down in left pane
141
+ PgUp = Go one page up in left pane
142
+ END = Go to last item in left pane
143
+ HOME = Go to first item in left pane
144
+
145
+ MARKS AND JUMPING
146
+ m = Mark current dir (persistent). Next letter is the name of the mark [a-zA-Z']
147
+ The special mark "'" jumps to the last directory (makes toggling dirs easy)
148
+ Press '-' and a letter to delete that mark. Mark '0' is the dir where RTFM was started.
149
+ Marks '1' - '5' are the past five directories visited.
150
+ M = Show marked items in right pane
151
+ ' = Jump to mark (next letter is the name of the mark [a-zA-Z'])
152
+ The 5 latest directories visited are stored in marks 1-5 (1 being the very latest)
153
+ ~ = Jump to Home directory
154
+ > = Follow symlink to the directory where the target resides
155
+
156
+ DIRECTORY VIEWS
157
+ a = Show all (also hidden) items
158
+ A = Show long info per item (show item attributes)
159
+ o = Change the order/sorting of directories (circular toggle)
160
+ i = Invert/reverse the sorting
161
+ O = Show the Ordering in the bottom window (the full ls command)
162
+
163
+ TAGGING
164
+ t = Tag item (toggles)
165
+ Ctrl-t = Add items matching a pattern to list of tagged items (Ctrl-t and then . will tag all items)
166
+ T = Show currently tagged items in right pane
167
+ u = Untag all tagged items
168
+
169
+ MANIPULATE ITEMS
170
+ p = Put (copy) tagged items here
171
+ P = PUT (move) tagged items here
172
+ c = Change/rename selected (adds command to bottom window)
173
+ s = Create symlink to tagged items here
174
+ d = Delete selected item and tagged items. Confirm with 'y'.
175
+ Moves items to trash directory (~/.rtfm/trash/) if @trash = true
176
+ D = Empty trash directory
177
+ Ctrl-d = Toggle use of trash directory
178
+ Ctrl-o = Change ownership to user:group of selected and tagged items
179
+ Ctrl-p = Change permissions of selected and tagged items
180
+ Format = rwxr-xr-x or 755 or rwx (applies the trio to user, group and others)
181
+
182
+ FILTER AND SEARCH
183
+ f = Show only files in the left pane matching extension(s) (e.g. "txt" or "pdf,png,jpg")
184
+ F = Show only files matching a pattern (Ruby Regex) (e.g. "abc" or "ab.+12(\w3)+")
185
+ Ctrl-f = Clear all filtering
186
+ / = Enter search string in bottom window to highlight matching items and jump to the first match
187
+ \\ = Remove search pattern
188
+ n = Jump to the next item matched by '/'
189
+ N = Jump to the previous item matched by '/'
190
+ g = Run 'grep' to show files that contains the MATCH in current directory
191
+ L = Start 'locate' search for files, then use '#' to jump to desired line/directory
192
+ Ctrl-l = Locate files via fzf from the current directory down
193
+ (fuzzy file finder must be installed https://github.com/junegunn/fzf)
194
+
195
+ ARCHIVES
196
+ z = Extract tagged zipped archive to current directory
197
+ Z = Create zipped archive from tagged files/directories
198
+
199
+ GIT/HASH/OPENAI
200
+ G = Show git status for current directory
201
+ H = Do a cryptographic hash of the current directory with subdirs
202
+ If a previous hash was made, compare and report if there has been any change
203
+ I = Show OpenAI's description of the selected item and its content (if available)
204
+ You must have installed the ruby-openai gem and added your openai secret key
205
+ in the ~/.rtfm/conf (add `@ai = "your-secret-openai-key") for this to work.
206
+ Ctrl-a = Start an OpenAI chat (the context window is kept until you exit RTFM)
207
+ The OpenAI agent is specialized in answering questions about cli, files and dirs
208
+
209
+ RIGHT PANE CONTROLS
210
+ ENTER = Refresh the right pane
211
+ S-RIGHT = One line down in the preview
212
+ S-LEFT = One line up in the preview
213
+ S-DOWN = Next page of the preview (if doc long and ∇ in the bottom right) (TAB does the same)
214
+ S-UP = Previous page (if you have moved down the document first - ∆ in the top right) (or S-TAB)
215
+
216
+ CLIPBOARD COPY
217
+ y = Copy path of selected item to primary selection (for pasting with middle mouse button)
218
+ Y = Copy path of selected item to clipboard
219
+ Ctrl-y = Copy content of right pane to clipboard
220
+ Turn off batcat syntax highlighting with 'b' for a clean copy of content
221
+
222
+ SYSTEM SHORTCUTS
223
+ S = Show comprehensive System info (system, CPU, filesystem, latest dmesg messages)
224
+ = = Create a new directory (a shortcut for ":mkdir ")
225
+ Ctrl-n = Invoke navi (see https://github.com/denisidoro/navi) with any output in right window
226
+
227
+ COMMAND MODE
228
+ : = Enter "command mode" in bottom window (press ENTER to execute, press ESC to escape)
229
+ Prefix the command with a '§' to force the program to run in interactive mode
230
+ Full screen TUI programs like htop, vim or any shell must run in interactive mode
231
+ ; = Show command history in right pane
232
+ + = Add program(s) to the list of full-UI interactive terminal programs
233
+
234
+ RUBY DEBUG MODE
235
+ @ = Enter Ruby mode to execute any Ruby command (ENTER to execute, ESC to escape)
236
+
237
+ COPYRIGHT: Geir Isene, 2025+. No rights reserved. See http://isene.com for more.
119
238
  HELPTEXT
120
- def firstrun
121
- @firstrun = <<~FIRSTRUN
122
239
 
123
- Welcome to RTFM - the Ruby Terminal File Manager. This help text is shown on the first run.
124
- Next time you run RTFM, you can launch it from your terminal with a one letter command: "r"
125
- When launched this way, RTFM will also exit in the directory you are currently in.
126
-
127
- To benefit fully from all the features, you need to install some auxilliary software.
128
- On Ubuntu Linux you would use "apt install":
129
-
130
- Basic requirments is "x11-utils" and "xdotool": apt install x11-utils xdotool
131
- Syntax highlighting of text uses "bat": apt install bat
132
- Viewing PDFs uses "pdftotext": apt install poppler-utils
133
- Viewing LibreOffice docs uses "odt2txt": apt install odt2txt
134
- Viewing MS docx uses "docx2txt": apt install docx2txt
135
- Viewing MS pptx uses "unzip": apt install unzip
136
- Viewing MS xlsx uses "ssconvert": apt install gnumeric
137
- Viewing MS doc/xls/ppt uses "catdoc, xls2csv and catppt": apt install catdoc
138
- Viewing Images uses "w3m and ImageMagick": apt install w3m imagemagick
139
- Viewing Video thumbnails uses "ffmpegthumbnailer" apt install ffmpegthumbnailer
140
-
141
- All of the above:
142
- apt install x11-utils xdotool bat poppler-utils odt2txt docx2txt unzip gnumeric catdoc w3m imagemagick ffmpegthumbnailer
143
-
144
- FIRSTRUN
145
-
146
- @shell = File.basename(`echo "$SHELL"`.chomp)
147
- if @shell == "fish"
148
- if not File.exist?(Dir.home+'/.config/fish/config.fish')
149
- @rtfmlaunch = <<RTFMFISHLAUNCH
150
- # This is the RTFM launcher (https://github.com/isene/RTFM) for fish
151
- function r {
152
- f=$(mktemp)
153
- (
154
- set +e
155
- rtfm "$f"
156
- code=$?
157
- if [ "$code" != 0 ]; then
158
- rm -f "$f"
159
- exit "$code"
160
- fi
161
- )
162
- code=$?
163
- if [ "$code" != 0 ]; then
164
- return "$code"
165
- fi
166
- d=$(<"$f")
167
- rm -f "$f"
168
- cd "$d"
169
- }
170
- RTFMFISHLAUNCH
171
- File.write(Dir.home+'/.config/fish/config.fish', @rtfmlaunch)
172
- `echo "source ~/.config/fish/config.fish" >> .#{@shell}rc`
240
+ # FIRST RUN # {{{1
241
+ def firstrun # {{{2
242
+ setup_launch_script
243
+ setup_config
244
+ load_config
245
+ setup_templates
246
+ display_welcome_message
247
+ getchr
248
+ rescue StandardError => e
249
+ errormsg('⚠ Errors during RTFM first-run.', e)
250
+ exit 1
251
+ end
252
+
253
+ def setup_launch_script # {{{2
254
+ shell_name = File.basename(ENV['SHELL'].to_s)
255
+ if shell_name == 'fish'
256
+ fish_cfg = File.join(Dir.home, '.config', 'fish', 'config.fish')
257
+ FileUtils.mkdir_p(File.dirname(fish_cfg))
258
+ line = 'function r; rtfm; cd (cat ~/.rtfm_last_dir); end # rtfm launcher'
259
+ File.write(fish_cfg, "#{line}\n", mode: 'a')
260
+ else
261
+ rc_file = File.join(Dir.home, ".#{shell_name}rc")
262
+ FileUtils.mkdir_p(File.dirname(rc_file))
263
+ line = 'r(){ rtfm; cd "$(cat ~/.rtfm_last_dir)"; } # rtfm launcher'
264
+ File.write(rc_file, "#{line}\n", mode: 'a')
265
+ end
266
+ end
267
+
268
+ def setup_config # {{{2
269
+ File.write CONFIG_FILE, <<~CONFIG
270
+ # Configuration file for RTFM (https://github.com/isene/RTFM)
271
+ # BASICS
272
+ @width = 2
273
+ @border = 1
274
+ @trash = true
275
+
276
+ # LS
277
+ @lslong = ""
278
+ @lsall = ""
279
+ @lsorder = ""
280
+ @lsinvert = ""
281
+
282
+ # RIGHT PANE
283
+ @runmailcap = true
284
+ @preview = true
285
+ @showimage = true
286
+
287
+ # TOP PANE
288
+ @topmatch = [["", 249]]
289
+
290
+ # INTERACTIVE
291
+ @interactive = "fzf,navi,top,htop,less,vi,vim,ncdu,sh,zsh,bash,fish"
292
+
293
+ # AI
294
+ @ai = "Replace_This_With_Your_OpenAI_Key"
295
+ @aimodel = "gpt-4o-mini"
296
+
297
+ # LISTS
298
+ @marks = {}
299
+ @hash = {}
300
+ @history = []
301
+ @rubyhistory = []
302
+ @aihistory = []
303
+ CONFIG
304
+ end
305
+
306
+ def setup_templates # {{{2
307
+ unless File.exist?(PREVIEW_FILE) # {{{3
308
+ File.write PREVIEW_FILE, <<~TEMPLATE
309
+ # ~/.rtfm/preview.rb
310
+ #
311
+ # Define one handler per line in the form:
312
+ #
313
+ # ext1, ext2, ext3 = command with @s placeholder
314
+ #
315
+ # @s will be replaced by the shell-escaped filename.
316
+ #
317
+ # Lines beginning with # or blank are ignored.
318
+ #
319
+ # Examples:
320
+ # # plain text, Ruby, Python, shell
321
+ # txt, rb, py, sh = bat -n --color=always @s
322
+ #
323
+ # # markdown via pandoc
324
+ # md = pandoc @s -t plain
325
+ #
326
+ # # PDFs
327
+ # pdf = pdftotext -f 1 -l 4 @s -
328
+ #
329
+ TEMPLATE
330
+ end
331
+
332
+ unless File.exist?(KEYS_FILE) # {{{3
333
+ File.write KEYS_FILE, <<~TEMPLATE
334
+ # ~/.rtfm/keys.rb
335
+ #
336
+ # Override or add key bindings simply by assigning into KEYMAP
337
+ # and defining the corresponding handler methods.
338
+ #
339
+ # Syntax:
340
+ # KEYMAP['X'] = :my_handler
341
+ #
342
+ # def my_handler(chr)
343
+ # # anything you like—use @pB, @pR, Dir.pwd, etc.
344
+ # @pB.say("You pressed X!")
345
+ # end
346
+ #
347
+ # Examples:
348
+ #
349
+ # # remap 'C' to show config
350
+ # KEYMAP['C'] = :show_config
351
+ #
352
+ # # add a new binding: 'Z'
353
+ # KEYMAP['Z'] = :zap_all
354
+ # def zap_all(_chr)
355
+ # @pB.say("ZAPPED!")
356
+ # end
357
+ TEMPLATE
358
+ end
359
+ end
360
+
361
+ def display_welcome_message # {{{2
362
+ # rubocop:disable Layout/IndentationWidth
363
+ @firstrun = "
364
+ Welcome to RTFM - the Ruby Terminal File Manager. This help text is shown on the first run.
365
+ Next time you run RTFM, you can launch it from your terminal with a one letter command: 'r'
366
+ When launched this way, RTFM will also exit in the directory you are currently in.\n".b.fg(226)
367
+ @firstrun += "
368
+ To benefit fully from all the features, you need to install some auxiliary software.
369
+ On Ubuntu Linux you would use 'apt install':\n".fg(230)
370
+ @firstrun += "
371
+ Basic requirements is 'x11-utils' and 'xdotool': apt install x11-utils xdotool
372
+
373
+ Viewers (you may install more sepcialised viewers later:
374
+ Syntax highlighting of text uses 'bat': apt install bat
375
+ Viewing Markdown uses 'pandoc': apt install pandoc
376
+ Viewing PDFs uses 'pdftotext': apt install poppler-utils
377
+ Viewing LibreOffice docs uses 'odt2txt': apt install odt2txt
378
+ Viewing MS docx uses 'docx2txt': apt install docx2txt
379
+ Viewing MS pptx uses 'unzip': apt install unzip
380
+ Viewing MS xlsx uses 'ssconvert': apt install gnumeric
381
+ Viewing MS doc/xls/ppt uses 'catdoc, xls2csv and catppt': apt install catdoc
382
+ Viewing Images uses 'w3m and ImageMagick': apt install w3m imagemagick
383
+ Viewing Video thumbnails uses 'ffmpegthumbnailer' apt install ffmpegthumbnailer\n".fg(195)
384
+ @firstrun += "
385
+ All of the above in one fell swoop:\n".fg(159)
386
+ @firstrun += "
387
+ sudo apt install ruby-full git libncurses-dev x11-utils xdotool bat pandoc poppler-utils odt2txt docx2txt unzip gnumeric catdoc w3m imagemagick ffmpegthumbnailer".b.fg(87)
388
+ @firstrun += "\n\n
389
+ You need to install the basic requirements and you should install the viewers before you...
390
+ ... hit any key to start RTFM. Hit ? inside RTFM to show the full help text. Enjoy :-)\n".fg(214)
391
+ # rubocop:enable Layout/IndentationWidth
392
+ puts @firstrun
393
+ end
394
+ #checkpoint("Preliminaries done")
395
+
396
+ # BASIC SETUP {{{1
397
+ ## Check for installed basic applications {{{2
398
+ def cmd?(cmd) # Helper function
399
+ system("which #{cmd} > /dev/null 2>&1")
400
+ end
401
+
402
+ if cmd?('/usr/lib/w3m/w3mimgdisplay')
403
+ @imgdisplay = '/usr/lib/w3m/w3mimgdisplay'
404
+ @showimage = true
405
+ else
406
+ @showimage = false
407
+ end
408
+ @showimage = false unless cmd?('xwininfo') && cmd?('xdotool')
409
+
410
+ @bat = cmd?('bat') ? 'bat' : 'batcat'
411
+
412
+ ## Set encoding for $stdin to utf-8 {{{2
413
+ $stdin.set_encoding(Encoding::UTF_8)
414
+
415
+ # INITIALIZE VARIABLES {{{1
416
+ ## These can be set by user in ~/.rtfm/conf
417
+ ### Saved on quit ('q')
418
+ @history = [] # Initialize the command line history array
419
+ @rubyhistory = [] # Initialize the command line history array for ruby commands
420
+ @aihistory = [] # Initialize the command line history array for AI chat
421
+ ### Saved on Write Config ('W')
422
+ @lslong = '' # Set short form ls (toggled by pressing "A")
423
+ @lsall = '' # Set "ls -a" to false (toggled by pressing "a" - sets it to "-a")
424
+ @lsorder = '' # Change the order/sorting by pressing 'o' (circular toggle)
425
+ @lsinvert = '' # Set to "-r" to reverse/invert sorting order
426
+ @width = 4 # Set width of the left pane to the default ⅓ of the terminal width
427
+ @border = 1 # Set initial border config; 0 = none, 1 = Right pane, 2 = both panes, 3 = Left pane
428
+ @preview = true # Preview in the Right pane
429
+ @trash = false # Delete files permanently rather than moving them to ~/.rtfm/trash
430
+ @interactive = 'fzf,navi,top,htop,less,vi,vim,ncdu,sh,zsh,bash,fish'
431
+ ### Not saved (but can be set in ~/.rtfm/conf)
432
+ @topcolor = 249 # Default Top pane bg color
433
+ @topmatch = [['', 249]] # Default Top pane bg color for matching directories
434
+ @bottomcolor = 236 # Default Bottom pane color
435
+ @searchcolor = 23 # Default color for Search pane at bottom
436
+ @cmdcolor = 18 # Default color for Command pane at bottom
437
+ @rubycolor = 52 # Default color for Ruby pane at bottom
438
+ @aicolor = 58
439
+ @lsbase = '--group-directories-first' # Basic ls setup
440
+ @lsuser = '' # Set this variable in ~/.rtfm/conf to any 'ls' switch you want to customize directory listings
441
+ @batuse = true # Use batcat for syntax highlighting
442
+ @runmailcap = false # Set to 'true' in ~/.rtfm/conf if you want to use run-mailcap instead of xdg-open
443
+ @aimodel = 'gpt-4o-mini' # The default OpenAI model, set another in ~/.rtfm/conf if you like
444
+
445
+ ## These are automatically written to ~/.rtfm/conf upon exit
446
+ @marks = {} # Initialize (book)marks hash
447
+ @hash = {} # Initialize the sha directory hashing
448
+
449
+ ## Do not set these in ~/.rtfm/conf
450
+ @tagged = [] # Initialize the tagged array - for collecting all tagged items
451
+ @directory = {} # Initialize the directory hash for remembering directories visited
452
+ @searched = '' # Initialize the active searched for items
453
+ @lsfiles = '' # File types to show (initially set to all file types) - not saved on exit
454
+ @lsmatch = '' # Files to match (initially set to matching all files) - not saved on exit
455
+ @index = 0 # Set chosen item to first on startup
456
+ @tagsize = 0 # Size (in MB) of tagged items
457
+ @navi = '' # Navi result when navi is invoked
458
+
459
+ # LOAD CONFIG {{{1
460
+ ## Get variables from config file (written back to ~/.rtfm/conf when exit via 'q')
461
+ def load_config
462
+ if File.exist?(CONFIG_FILE)
463
+ load(CONFIG_FILE)
464
+ @conf = File.read(CONFIG_FILE).dup
465
+ else
466
+ firstrun
467
+ end
468
+ rescue StandardError => e
469
+ errormsg("⚠ Errors while loading #{CONFIG_FILE}\nCheck your config file or delete it to remake in a fresh RTFM start.", e)
470
+ end
471
+ load_config
472
+ @marks['0'] = Dir.pwd # Original dir
473
+ @marks["'"] = Dir.pwd # Initial mark
474
+ #checkpoint("Vars/config loaded")
475
+
476
+ # Handle start dir {{{2
477
+ Dir.chdir(ARGV.shift) if ARGV[0] && File.directory?(ARGV[0])
478
+
479
+ # OPENAI SETUP {{{1
480
+ def chat_history # {{{2
481
+ @chat_history ||= [
482
+ { role: 'system',
483
+ content: 'You are a helpful assistant embedded in a terminal file manager. ' \
484
+ 'Answer questions about files, directories, or shell commands.' }
485
+ ]
486
+ end
487
+
488
+ def openai_client # {{{2
489
+ require 'ruby/openai' unless defined?(OpenAI)
490
+ @openai_client ||= OpenAI::Client.new(access_token: @ai)
491
+ end
492
+
493
+ # SET UP VIEWER SYSTEM {{{1
494
+ # rubocop:disable Style/StringLiterals
495
+ preview_specs = {
496
+ 'txt, rb, py, sh' => "#{@bat} -n --color=always @s",
497
+ 'md' => "pandoc @s -t plain",
498
+ 'pdf' => "pdftotext -f 1 -l 4 @s -",
499
+ 'odt, odp, odg, odc' => "odt2txt @s",
500
+ 'docx' => "docx2txt @s -",
501
+ 'xlsx' => "ssconvert -O 'separator= ' -T Gnumeric_stf:stf_assistant @s fd://1",
502
+ 'pptx' => "unzip -qc @s | ruby -e '$stdin.each_line{ |i| i.force_encoding(\"ISO-8859-1\").scan(/<a:t>(.+?)<\\/a:t>/).each{ |j| puts j } }'",
503
+ 'doc' => "catdoc @s 2>/dev/null",
504
+ 'xls' => "xls2csv @s 2>/dev/null",
505
+ 'ppt' => "catppt @s 2>/dev/null",
506
+ # images ⇒ nil => call showimage
507
+ 'png, jpg, jpeg, bmp, gif, webp, tif, tiff' => nil,
508
+ # video ⇒ nil, but detected via pattern below
509
+ 'mpg, mpeg, avi, mov, mkv, mp4' => nil
510
+ }
511
+ @imagefile ||= /\.(?:png|jpe?g|bmp|gif|webp|tiff?)$/i
512
+ @pdffile ||= /\.pdf$/i
513
+ # rubocop:enable Style/StringLiterals
514
+
515
+ # USER PLUGINS {{{1
516
+ # Merge in any user overrides from ~/.rtfm/preview.rb
517
+ if File.exist?(PREVIEW_FILE)
518
+ begin
519
+ File.readlines(PREVIEW_FILE).each_with_index do |line, idx|
520
+ line = line.strip
521
+ next if line.empty? || line.start_with?('#')
522
+
523
+ if line =~ /\A([0-9A-Za-z_,\s]+)\s*=\s*(.+)\z/
524
+ exts_str = $1.strip
525
+ cmd_str = $2.strip
526
+ preview_specs[exts_str] = cmd_str
527
+ else
528
+ @plugin_errors << "Invalid preview.rb line #{idx + 1}: #{line}"
529
+ end
530
+ end
531
+ rescue => e
532
+ @plugin_errors << "Error loading preview.rb: #{e.class}: #{e.message}"
533
+ end
534
+ end
535
+ # Compile into an array of [Regexp, template] for fast lookup
536
+ PREVIEW_HANDLERS = preview_specs.map do |exts_str, tmpl|
537
+ exts = exts_str.split(',').map(&:strip).map { |ext| Regexp.escape(ext) }.join('|')
538
+ [/\.#{exts}$/i, tmpl]
539
+ end
540
+ #checkpoint("Plugins loaded")
541
+
542
+ # KEY DISPATCH TABLE & HANDLERS {{{1
543
+ KEYMAP = { # {{{2
544
+ # BASIC KEYS {{{3
545
+ '?' => :show_help,
546
+ 'v' => :show_version,
547
+ 'r' => :refresh_all,
548
+ 'R' => :load_config,
549
+ 'C' => :show_config,
550
+ 'W' => :write_config,
551
+ 'q' => :quit_and_save,
552
+ 'Q' => :quit_no_save,
553
+
554
+ # LAYOUT {{{3
555
+ 'w' => :change_width,
556
+ 'B' => :toggle_border,
557
+ '-' => :toggle_preview,
558
+ '_' => :toggle_image,
559
+ 'b' => :toggle_syntax,
560
+
561
+ # MOTION {{{3
562
+ 'DOWN' => :move_down,
563
+ 'j' => :move_down,
564
+ 'C-DOWN' => :move_down,
565
+ 'UP' => :move_up,
566
+ 'k' => :move_up,
567
+ 'C-UP' => :move_up,
568
+ 'LEFT' => :move_left,
569
+ 'h' => :move_left,
570
+ 'C-LEFT' => :move_left,
571
+ 'RIGHT' => :move_right,
572
+ 'l' => :move_right,
573
+ 'C-RIGHT' => :move_right,
574
+ 'x' => :open_force,
575
+ 'PgDOWN' => :page_down,
576
+ 'PgUP' => :page_up,
577
+ 'END' => :go_last,
578
+ 'HOME' => :go_first,
579
+
580
+ # MARKS & JUMPING {{{3
581
+ 'm' => :set_mark,
582
+ 'M' => :show_marks,
583
+ "'" => :jump_to_mark,
584
+ '~' => :go_home,
585
+ '>' => :follow_symlink,
586
+
587
+ # DIRECTORY VIEWS {{{3
588
+ 'a' => :toggle_all,
589
+ 'A' => :toggle_long,
590
+ 'o' => :toggle_order,
591
+ 'i' => :toggle_invert,
592
+ 'O' => :show_ls_command,
593
+
594
+ # TAGGING {{{3
595
+ 't' => :tag_current,
596
+ 'C-T' => :tag_pattern,
597
+ 'T' => :show_tagged,
598
+ 'u' => :clear_tagged,
599
+
600
+ # MANIPULATE ITEMS {{{3
601
+ 'p' => :copy_items,
602
+ 'P' => :move_items,
603
+ 'c' => :rename_item,
604
+ 's' => :link_items,
605
+ 'd' => :delete_items,
606
+ 'D' => :empty_trash,
607
+ 'C-D' => :toggle_trash,
608
+ 'C-O' => :change_ownership,
609
+ 'C-P' => :change_permissions,
610
+
611
+ # FILTER AND SEARCH {{{3
612
+ 'f' => :filter_types,
613
+ 'F' => :filter_regex,
614
+ 'C-F' => :filter_clear,
615
+ '/' => :search_text,
616
+ '\\' => :clear_search,
617
+ 'n' => :search_next,
618
+ 'N' => :search_prev,
619
+ 'g' => :grep_current,
620
+ 'L' => :locate,
621
+ '#' => :jump_locate,
622
+ 'C-L' => :fzf_jump,
623
+
624
+ # ARCHIVES {{{3
625
+ 'z' => :unzip_items,
626
+ 'Z' => :zip_items,
627
+
628
+ # GIT/HASH/OPENAI {{{3
629
+ 'G' => :git_status,
630
+ 'H' => :hash_directory,
631
+ 'I' => :openai_description,
632
+ 'C-A' => :chat_mode,
633
+
634
+ # RIGHT PANE CONTROLS {{{3
635
+ 'ENTER' => :refresh_right,
636
+ 'S-DOWN' => :line_down_right,
637
+ 'S-UP' => :line_up_right,
638
+ 'S-RIGHT' => :page_down_right,
639
+ 'TAB' => :page_down_right,
640
+ 'S-LEFT' => :page_up_right,
641
+ 'S-TAB' => :page_up_right,
642
+
643
+ # CLIPBOARD COPY {{{3
644
+ 'y' => :copy_path,
645
+ 'Y' => :copy_path,
646
+ 'C-Y' => :copy_right,
647
+
648
+ # SYSTEM SHORTCUTS {{{3
649
+ 'S' => :system_info,
650
+ '=' => :make_directory,
651
+ 'C-N' => :navi_invoke,
652
+
653
+ # COMMAND MODE {{{3
654
+ ':' => :command_mode,
655
+ ';' => :show_history,
656
+ '+' => :add_interactive,
657
+
658
+ # RUBY MODE {{{3
659
+ '@' => :ruby_debug
660
+ }
661
+
662
+ # USER KEYS (override or extend KEYMAP) {{{2
663
+ if File.exist?(KEYS_FILE)
664
+ begin
665
+ load KEYS_FILE
666
+ rescue => e
667
+ @plugin_errors << "Error loading keys.rb: #{e.class}: #{e.message}"
668
+ end
669
+ end
670
+
671
+ # MAIN GETKEY FOR USER INPUT {{{2
672
+ def getkey # {{{3
673
+ chr = getchr(1)
674
+ return unless chr
675
+
676
+ showimage('clear') if @image
677
+ @image = false
678
+ if handler = KEYMAP[chr] # rubocop:disable Lint/AssignmentInCondition
679
+ m = method(handler)
680
+ m.arity == 1 ? m.call(chr) : m.call
681
+ end
682
+ rescue StandardError => e
683
+ errormsg('⚷ Error in getkey', e)
684
+ end
685
+
686
+ # BASIC KEYS {{{2
687
+ def show_help # {{{3
688
+ @pR.say(@help.fg(249))
689
+ end
690
+
691
+ def show_version # {{{3
692
+ @pB.say(" RTFM version = #{@version} (latest RubyGems version is #{Gem.latest_version_for('rtfm-filemanager').version} - https://github.com/isene/RTFM)".b)
693
+ end
694
+
695
+ def refresh_all # {{{3
696
+ refresh
697
+ end
698
+
699
+ def show_config
700
+ @pR.say('Configuration'.u.fg(254) + ":\n\n" + @conf.fg(249))
701
+ end
702
+
703
+ def write_config # {{{3
704
+ conf_write(all: true)
705
+ show_config
706
+ end
707
+
708
+ def quit_and_save # {{{3
709
+ shell("printf \"\e]0;#{Dir.pwd}\007\"")
710
+ conf_write
711
+ exit_rtfm
712
+ end
713
+
714
+ def quit_no_save # {{{3
715
+ shell("printf \"\e]0;#{Dir.pwd}\007\"")
716
+ exit_rtfm
717
+ end
718
+
719
+ # LAYOUT {{{2
720
+ def change_width # {{{3
721
+ @width += 1
722
+ @width = 2 if @width == 8
723
+ refresh
724
+ @pR.update = @pB.update = true
725
+ end
726
+
727
+ def toggle_border # {{{3
728
+ @border = (@border + 1) % 4
729
+ setborder
730
+ end
731
+
732
+ def toggle_preview # {{{3
733
+ @preview = !@preview
734
+ @pB.say("Preview = #{@preview ? 'On' : 'Off'}")
735
+ @pR.clear unless @preview
736
+ end
737
+
738
+ def toggle_image # {{{3
739
+ @showimage = !@showimage
740
+ @pB.say("Image preview = #{@showimage ? 'On' : 'Off'}")
741
+ getch
742
+ end
743
+
744
+ def toggle_syntax # {{{3
745
+ @batuse = !@batuse
746
+ @pB.say("Syntax highlighting = #{@batuse ? 'On' : 'Off'}")
747
+ @pR.update = true
748
+ end
749
+
750
+ # MOTION {{{2
751
+ def move_down # {{{3
752
+ @index = @index >= @max_index ? @min_index : @index + 1
753
+ @pR.update = @pB.update = true
754
+ end
755
+
756
+ def move_up # {{{3
757
+ @index = @index <= @min_index ? @max_index : @index - 1
758
+ @pR.update = @pB.update = true
759
+ end
760
+
761
+ def move_left # {{{3
762
+ old_dir = Dir.pwd
763
+ parent = File.dirname(old_dir)
764
+ child = File.basename(old_dir)
765
+ purels = command(
766
+ "ls #{Shellwords.escape(parent)} #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}"
767
+ ).pure.split("\n")
768
+ child_idx = purels.index(child) || 0
769
+ @directory[parent] = child_idx
770
+ mark_latest
771
+ Dir.chdir(parent)
772
+ @pL.update = @pR.update = @pB.update = true
773
+ end
774
+
775
+ def move_right # {{{3
776
+ @directory[Dir.pwd] = @index
777
+ mark_latest
778
+ open_selected
779
+ @index = @directory[Dir.pwd] || 0
780
+ @pB.update = true
781
+ end
782
+
783
+ def open_force # {{{3
784
+ @directory[Dir.pwd] = @index
785
+ mark_latest
786
+ open_selected(true)
787
+ @pB.update = true
788
+ end
789
+
790
+ def page_down # {{{3
791
+ @index += @pL.h - 2
792
+ @index = @max_index if @index > @max_index
793
+ @pR.update = @pB.update = true
794
+ end
795
+
796
+ def page_up # {{{3
797
+ @index -= @pL.h - 2
798
+ @index = @min_index if @index < @min_index
799
+ @pR.update = @pB.update = true
800
+ end
801
+
802
+ def go_last # {{{3
803
+ @index = @max_index
804
+ @pR.update = @pB.update = true
805
+ end
806
+
807
+ def go_first # {{{3
808
+ @index = @min_index
809
+ @pR.update = @pB.update = true
810
+ end
811
+
812
+ # MARKS & JUMPING {{{2
813
+ def set_mark # {{{3
814
+ marks_info
815
+ @pB.say("Set mark by pressing any letter. Delete mark by pressing '-' and the letter of the mark to remove.".fg(156))
816
+ m = getchr
817
+ return if m == 'ESC'
818
+
819
+ if m =~ /[\w']/
820
+ @marks[m] = Dir.pwd
821
+ elsif m == '-'
822
+ @pB.say('Press the letter of the mark to remove.'.fg(156))
823
+ r = getchr; @marks.delete(r)
824
+ end
825
+ marks_info
826
+ @pB.update = true
827
+ end
828
+
829
+ def show_marks # {{{3
830
+ marks_info
831
+ @pB.update = true
832
+ end
833
+
834
+ def jump_to_mark # {{{3
835
+ marks_info
836
+ m = getchr
837
+ if m =~ /[\w']/ && @marks[m]
838
+ @directory[Dir.pwd] = @index
839
+ dir_before = Dir.pwd
840
+ begin; Dir.chdir(@marks[m]); rescue; @pB.say(' No such directory'); end
841
+ mark_latest
842
+ @marks["'"] = dir_before
843
+ end
844
+ @pR.update = @pB.update = true
845
+ end
846
+
847
+ def go_home # {{{3
848
+ @directory[Dir.pwd] = @index
849
+ mark_latest
850
+ Dir.chdir
851
+ @pR.update = @pB.update = true
852
+ end
853
+
854
+ def follow_symlink # {{{3
855
+ @directory[Dir.pwd] = @index; mark_latest
856
+ if File.symlink?(@selected)
857
+ begin
858
+ target = File.readlink(@selected)
859
+ target = File.expand_path(target, File.dirname(@selected)) unless target.start_with?('/')
860
+ Dir.chdir(target)
861
+ rescue => e
862
+ @pB.say("Error following symlink: #{e}")
863
+ end
864
+ end
865
+ @pB.update = true
866
+ end
867
+
868
+ # DIRECTORY VIEWS {{{2
869
+ def toggle_all # {{{3
870
+ @lsall = @lsall.empty? ? '-a' : ''
871
+ @pR.update = @pB.update = true
872
+ end
873
+
874
+ def toggle_long # {{{3
875
+ @lslong = @lslong.empty? ? '-lh --time-style=long-iso' : ''
876
+ @pR.update = @pB.update = true
877
+ end
878
+
879
+ def toggle_order # {{{3
880
+ case @lsorder
881
+ when ''
882
+ @lsorder = '-S'; @pB.say(' Sorting by size')
883
+ when '-S'
884
+ @lsorder = '-t'; @pB.say(' Sorting by time')
885
+ when '-t'
886
+ @lsorder = '-X'; @pB.say(' Sorting by extension')
887
+ else
888
+ @lsorder = ''; @pB.say(' Normal sorting')
889
+ end
890
+ @pR.update = true; @orderchange = true
891
+ end
892
+
893
+ def toggle_invert # {{{3
894
+ @lsinvert = @lsinvert.empty? ? '-r' : ''
895
+ @pB.say(' Sorting inverted')
896
+ @pR.update = true; @orderchange = true
897
+ end
898
+
899
+ def show_ls_command # {{{3
900
+ @pB.say(" Full 'ls' command: ls #{@lsbase} #{@lslong} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}".gsub(/ +/, ' '))
901
+ @pB.update = false
902
+ end
903
+
904
+ # TAGGING {{{2
905
+ def tag_current # {{{3
906
+ item = @selected
907
+ if @tagged.include?(item)
908
+ @tagged.delete(item); @tagsize -= File.size(item) rescue 0
909
+ else
910
+ @tagged.push(item); @tagsize += File.size(item) rescue 0
911
+ end
912
+ @index = [@index + 1, (@files.size - 1)].min
913
+ @pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
914
+ @pB.update = false; @pR.update = true; @pL.update = true
915
+ end
916
+
917
+ def tag_pattern # {{{3
918
+ pat = @pB.ask('Tag pattern (ruby regex): ', '')
919
+ re = Regexp.new(pat)
920
+ matches = @files.grep(re).map { |t| File.join(Dir.pwd, t) }
921
+ matches.each do |f|
922
+ @tagsize += File.size(f) rescue nil
923
+ end
924
+ @tagged.concat(matches)
925
+ @tagged.uniq!
926
+ @pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
927
+ @pB.update = false
928
+ @pR.update = true
929
+ end
930
+
931
+ def show_tagged # {{{3
932
+ tagged_info
933
+ @pB.update = true
934
+ end
935
+
936
+ def clear_tagged # {{{3
937
+ @tagged = []
938
+ tagged_info
939
+ @pB.update = true
940
+ end
941
+
942
+ # MANIPULATE ITEMS {{{2
943
+ def copy_items # {{{3
944
+ copy_move_link('copy')
945
+ @pR.update = true
946
+ end
947
+
948
+ def move_items # {{{3
949
+ copy_move_link('move')
950
+ @pR.update = true
951
+ end
952
+
953
+ def rename_item # {{{3
954
+ basename = File.basename(@selected)
955
+ dir = File.dirname(@selected)
956
+ display = basename.length > 12 ? basename[0, 12] + '…' : basename
957
+ tpl = "mv \"#{display}\" \"#{basename}\""
958
+ cmd = @pCmd.ask(': ', tpl).pure
959
+ match = cmd.match(/mv\s+"[^"]+"\s+"([^"]+)"/)
960
+ new_basename = match ? match[1] : basename
961
+ old_esc = Shellwords.escape(@selected)
962
+ new_path = File.join(dir, new_basename)
963
+ new_esc = Shellwords.escape(new_path)
964
+ shellexec("mv #{old_esc} #{new_esc}")
965
+ dirlist
966
+ # point @selected and @index at the new name
967
+ @selected = new_path
968
+ new_idx = @files.index(new_basename)
969
+ @index = new_idx if new_idx
970
+ render
971
+ end
972
+
973
+ def link_items # {{{3
974
+ copy_move_link('link')
975
+ @pR.update = true
976
+ end
977
+
978
+ def delete_items # {{{3
979
+ tagged_info
980
+ @pR.text << "\n\n Selected:\n\n #{@selected}".fg(204).b
981
+ @pR.refresh
982
+ # choose wording based on @trash
983
+ action = @trash ? 'Move (to ~/.rtfm/trash)' : 'Delete'
984
+ past_action = @trash ? 'Moved' : 'Deleted'
985
+ @pB.say(" #{action} selected and tagged? (press 'y')")
986
+ if getchr == 'y'
987
+ # collect & escape every path
988
+ paths = (@tagged + [@selected]).uniq
989
+ esc = paths.map { |p| Shellwords.escape(p) }.join(' ')
990
+ if @trash
991
+ esc_trash = Shellwords.escape(TRASH_DIR)
992
+ command("mv -f #{esc} #{esc_trash}")
993
+ else
994
+ command("rm -rf #{esc}")
995
+ end
996
+ @tagged.clear
997
+ refresh_right
998
+ @pR.say("#{past_action} #{paths.size} items#{@trash ? " to #{TRASH_DIR}" : ''}".fg(204))
999
+ else
1000
+ @pB.update = true
1001
+ end
1002
+ end
1003
+
1004
+ def empty_trash # {{{3
1005
+ @pB.say(" Really empty Trash (~/.rtfm/trash)? (press 'y')")
1006
+ return unless getchr == 'y'
1007
+
1008
+ command("rm -rf #{TRASH_DIR}/*")
1009
+ @pB.say('Trash is now empty')
1010
+ render
1011
+ end
1012
+
1013
+ def toggle_trash # {{{3
1014
+ @trash = !@trash
1015
+ @pB.say("Trash (~/.rtfm/trash) = #{@trash ? 'On' : 'Off'}")
1016
+ end
1017
+
1018
+ def change_ownership # {{{3
1019
+ require 'etc'
1020
+ gnm = Etc.getgrgid(File.stat(@selected).gid).name
1021
+ unm = Etc.getpwuid(File.stat(@selected).uid).name
1022
+ ans = @pB.ask('Change ownership (user:group): ', "#{unm}:#{gnm}")
1023
+ user, group = ans.split(':')
1024
+ uid = Etc.getpwnam(user).uid
1025
+ gid = Etc.getgrnam(group).gid
1026
+ File.chown(uid, gid, @selected)
1027
+ @tagged.each { |t| File.chown(uid, gid, t) rescue nil }
1028
+ if user == unm && group == gnm
1029
+ @pB.say('No change in ownership')
1030
+ else
1031
+ @pB.say("Ownership changed to #{user}:#{group}")
1032
+ end
1033
+ end
1034
+
1035
+ def change_permissions # {{{3
1036
+ # strip leading “-” off e.g. "-rwxr-xr-x" → "rwxr-xr-x"
1037
+ default = @fileattr.split[1][1..]
1038
+ ans = @pB.ask('Permissions: ', default)
1039
+ mode = if ans =~ /^\d{3}$/ # "755"
1040
+ ans.to_i(8)
1041
+ elsif ans.length == 3 # "rwx" → "rwxrwxrwx"
1042
+ # compute the single octal digit
1043
+ digit = ans.chars.map do |c|
1044
+ (c == 'r' ? 4 : 0) +
1045
+ (c == 'w' ? 2 : 0) +
1046
+ (c == 'x' ? 1 : 0)
1047
+ end.sum
1048
+ # replicate for user, group, other
1049
+ (digit * 64) + (digit * 8) + digit
1050
+ elsif ans.length == 9 # "rwxr-xr-x"
1051
+ # split into three triads and compute each
1052
+ triads = ans.scan(/.{3}/)
1053
+ perms = triads.map do |tri|
1054
+ (tri[0] == 'r' ? 4 : 0) +
1055
+ (tri[1] == 'w' ? 2 : 0) +
1056
+ (tri[2] == 'x' ? 1 : 0)
1057
+ end
1058
+ (perms[0] * 64) + (perms[1] * 8) + perms[2]
1059
+ end
1060
+ if mode.nil?
1061
+ @pB.say('Invalid mode')
1062
+ else
1063
+ current = File.stat(@selected).mode & 0o777
1064
+ if mode == current
1065
+ @pB.say("No change needed (already #{mode.to_s(8)})")
1066
+ else
1067
+ File.chmod(mode, @selected)
1068
+ @tagged.each { |t| File.chmod(mode, t) rescue nil }
1069
+ @pB.say("Permissions changed to: #{mode.to_s(8)}")
1070
+ end
1071
+ end
1072
+ end
1073
+
1074
+ # FILTER AND SEARCH {{{2
1075
+ def filter_types # {{{3
1076
+ @lsfiles = @pB.ask('Filetype(s) to show: ', @lsfiles)
1077
+ @pR.update = @pB.update = true
1078
+ end
1079
+
1080
+ def filter_regex # {{{3
1081
+ @lsmatch = @pB.ask('Files match RegEx: ', @lsmatch)
1082
+ @pB.say(nil)
1083
+ @pR.update = @pB.update = true
1084
+ end
1085
+
1086
+ def filter_clear # {{{3
1087
+ @lsfiles = @lsmatch = ''
1088
+ @pB.say('All filtering cleared.')
1089
+ end
1090
+
1091
+ def search_text # {{{3
1092
+ @searched = @pSearch.ask('/ ', '')
1093
+ l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split
1094
+ m = l.each_index.select { |n| l[n] =~ /#{@searched}/ }
1095
+ @index = m.first unless m.empty?
1096
+ @index = 0 if @searched.empty?
1097
+ @pR.update = true; @pB.full_refresh
1098
+ end
1099
+
1100
+ def clear_search # {{{3
1101
+ @searched = ''
1102
+ end
1103
+
1104
+ def search_next # {{{3
1105
+ l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split
1106
+ m = l.each_index.select { |n| l[n] =~ /#{@searched}/ }
1107
+ i = m.find { |n| n > @index }
1108
+ @index = i || m.first
1109
+ @pR.update = true
1110
+ end
1111
+
1112
+ def search_prev # {{{3
1113
+ l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split
1114
+ m = l.each_index.select { |n| l[n] =~ /#{@searched}/ }.reverse
1115
+ i = m.find { |n| n < @index }
1116
+ @index = i || m.first
1117
+ @pR.update = true
1118
+ end
1119
+
1120
+ def grep_current # {{{3
1121
+ cmd = @pCmd.ask(': ', 'grep -s MATCH *')
1122
+ shellexec(cmd)
1123
+ end
1124
+
1125
+ def locate # {{{3
1126
+ cmd = @pCmd.ask(': ', 'locate ')
1127
+ cmd += " | #{@bat} -n --color=always"
1128
+ shellexec(cmd)
1129
+ @locate = true
1130
+ end
1131
+
1132
+ def jump_locate # {{{3
1133
+ @pB.update = true; return unless @locate
1134
+
1135
+ nr = @pB.ask('# ', '').to_i
1136
+ line = @pR.text.lines[nr - 1]
1137
+ unless line
1138
+ @pB.say('Error: No such file or directory.'); @pB.update = false; return
1139
+ end
1140
+ jump = line.pure[%r{/\S+}]
1141
+ dir = File.dirname(jump); tgt = File.basename(jump)
1142
+ @directory[Dir.pwd] = @index; mark_latest; Dir.chdir(dir); @dir_old = Dir.pwd
1143
+ l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split("\n")
1144
+ @index = l.index { |t| t == tgt }
1145
+ end
1146
+
1147
+ def fzf_jump # {{{ 3
1148
+ unless system('which fzf > /dev/null 2>&1')
1149
+ @pB.say(' fzf not installed – see https://github.com/junegunn/fzf')
1150
+ return
1151
+ end
1152
+ # 2) Prepare a temp file for the single-line selection
1153
+ tmp = File.join(Dir.tmpdir, 'rtfm_fzf_selection')
1154
+ # Launch fzf:
1155
+ # - stdin <- /dev/tty so fzf reads from terminal
1156
+ # - stdout -> tmp to capture only the chosen entry
1157
+ # - stderr -> /dev/tty so fzf can draw its full-screen UI
1158
+ system("fzf < /dev/tty > #{Shellwords.escape(tmp)} 2> /dev/tty")
1159
+ jump = File.exist?(tmp) ? File.read(tmp).chomp : ''
1160
+ File.delete(tmp)
1161
+ return if jump.empty?
1162
+
1163
+ dir = File.dirname(jump)
1164
+ tgt = File.basename(jump)
1165
+ @directory[Dir.pwd] = @index
1166
+ mark_latest
1167
+ Dir.chdir(dir)
1168
+ @dir_old = Dir.pwd
1169
+ l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split("\n")
1170
+ @index = l.index { |t| t == tgt } || 0
1171
+ @pL.update = @pR.update = @pB.update = true
1172
+ end
1173
+
1174
+ # ARCHIVES {{{2
1175
+ def unzip_items # {{{3
1176
+ first = @tagged.first
1177
+ tar = Shellwords.escape(first)
1178
+ cmd = @pCmd.ask('Command: ', "tar xfz #{tar}")
1179
+ shellexec(cmd)
1180
+ render
1181
+ end
1182
+
1183
+ def zip_items # {{{3
1184
+ arc = @pCmd.ask('Archive name: ', '')
1185
+ # escape archive base and all paths
1186
+ arc_esc = Shellwords.escape(arc)
1187
+ tagged_esc = @tagged.map { |p| Shellwords.escape(p) }.join(' ')
1188
+ cmd = @pCmd.ask('Command: ', "tar cfz #{arc_esc}.gz #{tagged_esc}")
1189
+ shellexec(cmd + ' 2>/dev/null')
1190
+ render
1191
+ end
1192
+
1193
+ # GIT/HASH/OPENAI {{{2
1194
+ def git_status # {{{3
1195
+ @pR.clear
1196
+ out, err = command('git status', return_both: true)
1197
+ @pR.text += err.fg(196) unless err.empty?
1198
+ @pR.text += out.fg(214) unless out.empty?
1199
+ @pR.full_refresh
1200
+ @pR.update = false
1201
+ @pB.update = true
1202
+ end
1203
+
1204
+ def hash_directory # {{{3
1205
+ dir = Shellwords.escape(Dir.pwd)
1206
+ @pB.say("Creating hash for #{dir}. May take a while…")
1207
+ hashcmd = <<~CMD.chomp
1208
+ (find #{dir} -type f -print0 |
1209
+ sort -z |
1210
+ xargs -0 sha1sum;
1211
+ find #{dir} \\( -type f -o -type d \\) -print0 |
1212
+ sort -z |
1213
+ xargs -0 stat -c '%n %a') |
1214
+ sha1sum |
1215
+ cut -c -40
1216
+ CMD
1217
+ hashdir = command(hashcmd, timeout: 300).chomp
1218
+ hashtime = DateTime.now.strftime('%Y-%m-%d %H:%M')
1219
+ if @hash.key?(Dir.pwd)
1220
+ old_time, old_hash = @hash[Dir.pwd]
1221
+ if old_hash == hashdir
1222
+ @pB.say(" Hash for #{Dir.pwd} has NOT changed since #{old_time} (#{hashdir})".fg(213))
1223
+ else
1224
+ @pB.say(" Hash for #{Dir.pwd} has CHANGED: #{old_time}→#{hashtime} (#{old_hash}→#{hashdir})".fg(213))
1225
+ @hash[Dir.pwd] = [hashtime, hashdir]
1226
+ end
1227
+ else
1228
+ @hash[Dir.pwd] = [hashtime, hashdir]
1229
+ @pB.say(" New hash for #{Dir.pwd}: #{hashtime}: #{hashdir}".fg(213))
1230
+ end
1231
+ @pR.update = true
1232
+ end
1233
+
1234
+ def openai_description # {{{3
1235
+ begin
1236
+ require 'ruby/openai'
1237
+ rescue LoadError
1238
+ @pB.say('To enable AI-descriptions: `gem install ruby-openai` and set @ai in ~/.rtfm/conf')
1239
+ return
1240
+ end
1241
+ unless @ai && !@ai.empty?
1242
+ @pB.say("Set your API key in ~/.rtfm/conf: @ai = 'your-secret-openai-key'")
1243
+ return
1244
+ end
1245
+ # Context
1246
+ path = File.join(Dir.pwd, @selected.to_s)
1247
+ is_dir = File.directory?(path)
1248
+ preview = @pR.text.pure.strip
1249
+ # Enhanced prompt
1250
+ parts = []
1251
+ parts << 'You are an expert terminal file manager assistant.'
1252
+ parts << (is_dir ? "Summarize the overall purpose and contents of this directory: #{path}." : "Summarize the purpose of this file: #{path}.")
1253
+ parts << 'Since this is source code, do a brief code review: highlight bugs, style issues, and improvements.' if !is_dir && path.match?(/\.(sh|rb|py|js|go|java|41)$/i)
1254
+ parts << 'Embedded Documentation Lookup: for any libraries, commands, or APIs used, include a one-sentence summary from their official docs.'
1255
+ parts << "Git-Aware Diff Explanation: summarize the most recent `git diff` touching #{path}, explaining what changed." if Dir.exist?(File.join(Dir.pwd, '.git'))
1256
+ parts << "Existing preview text: #{preview}" unless preview.empty?
1257
+ prompt = parts.join(' ')
1258
+ # Send to OpenAI
1259
+ client = OpenAI::Client.new(access_token: @ai)
1260
+ @pR.say('Thinking...'.fg(244))
1261
+ response = client.chat(
1262
+ parameters: {
1263
+ model: @aimodel,
1264
+ messages: [{ role: 'user', content: prompt }],
1265
+ max_tokens: 600
1266
+ }
1267
+ ) rescue nil
1268
+ answer = response&.dig('choices', 0, 'message', 'content') ||
1269
+ '⚠️ Error or empty response from OpenAI.'
1270
+ @pR.say(answer.fg(230))
1271
+ end
1272
+
1273
+ def chat_mode # {{{3
1274
+ unless defined?(OpenAI) && @ai && !@ai.empty?
1275
+ @pB.say("To make OpenAI work in RTFM, run `gem install ruby-openai` and add to ~/.rtfm/conf:\n @ai = 'your-secret-openai-key'")
1276
+ return
1277
+ end
1278
+
1279
+ @pB.clear; @pB.update = true
1280
+ question = @pAI.ask('Chat> ', '').strip
1281
+ return if question.empty?
1282
+
1283
+ chat_history << { role: 'user', content: question }
1284
+ @pR.say('Thinking...'.fg(230))
1285
+ reply = openai_client.chat(
1286
+ parameters: {
1287
+ model: @aimodel,
1288
+ messages: chat_history,
1289
+ max_tokens: 400
1290
+ }
1291
+ ) rescue nil
1292
+ answer = reply&.dig('choices', 0, 'message', 'content') ||
1293
+ '⚠️ API error or empty response'
1294
+ chat_history << { role: 'assistant', content: answer }
1295
+ @pR.say(answer.fg(230))
1296
+ @pB.clear; @pB.update = true
1297
+ end
1298
+
1299
+ # RIGHT PANE CONTROLS {{{2
1300
+ def refresh_right # {{{3
1301
+ render
1302
+ @pR.full_refresh
1303
+ @pR.update = @pB.update = true
1304
+ end
1305
+
1306
+ def line_down_right # {{{3
1307
+ @pR.linedown; @pB.update = true
1308
+ end
1309
+
1310
+ def line_up_right # {{{3
1311
+ @pR.lineup; @pB.update = true
1312
+ end
1313
+
1314
+ def page_down_right # {{{3
1315
+ @pR.pagedown; @pB.update = true
1316
+ end
1317
+
1318
+ def page_up_right # {{{3
1319
+ @pR.pageup; @pB.update = true
1320
+ end
1321
+
1322
+ # CLIPBOARD COPY {{{2
1323
+ def copy_path # {{{3
1324
+ if @selected
1325
+ clip = "xclip -selection #{_chr == 'Y' ? 'clipboard' : 'primary'} -in -loops 1"
1326
+ @pB.say(' Path copied')
1327
+ shell("echo -n '#{@selected}' | #{clip}")
1328
+ else
1329
+ @pB.say(' No selected item path to copy')
1330
+ end
1331
+ end
1332
+
1333
+ def copy_right # {{{3
1334
+ clip = 'xclip -selection clipboard'
1335
+ @pB.say(' Right pane copied to clipboard')
1336
+ # We drop the trailing & in the string itself…
1337
+ shell("echo -n #{Shellwords.escape(@pR.text.pure)} | #{clip} > /dev/null 2>&1", background: true)
1338
+ end
1339
+
1340
+ # SYSTEM SHORTCUTS {{{2
1341
+ def system_info # {{{3
1342
+ text = ''
1343
+ begin
1344
+ uname = `uname -o`.chomp + ' '
1345
+ uname += `uname -r`.chomp + ' '
1346
+ uname += `uname -v`.chomp + ' '
1347
+ uname += `uname -p`.chomp + ' '
1348
+ uname += `awk -F '"' '/PRETTY/ {print $2}' /etc/os-release` + "\n"
1349
+ text += uname.b.fg(253)
1350
+ host = `hostnamectl`
1351
+ chost = host.lines.map do |line|
1352
+ if line.include?(':')
1353
+ before, after = line.split(':', 2)
1354
+ "#{(before + ':').fg(253)}#{after.fg(111)}"
1355
+ else
1356
+ line
1357
+ end
1358
+ end.join
1359
+ text += chost + "\n"
1360
+ rescue # rubocop:disable Lint/SuppressedException
1361
+ end
1362
+ begin
1363
+ system = 'Shell & Terminal: ' + `echo $SHELL`.sub(%r{.*/}, '').chomp + ', ' + `echo $TERM`.chomp + ' '
1364
+ packages = `pacman -Q 2>/dev/null | wc -l`.chomp
1365
+ packages = `dpkg-query -l 2>/dev/null | grep -c '^.i'`.chomp if packages == '0'
1366
+ packages = 'Unrecognized' if packages == '0'
1367
+ cpu = 'CPUs = ' + `nproc`.chop + ' '
1368
+ cpuinfo = `lscpu`
1369
+ cpu += cpuinfo[/^.*Model name:\s*(.*)/, 1] + ' '
1370
+ cpu += 'Max: ' + cpuinfo[/^.*CPU max MHz:\s*(.*)/, 1].to_i.to_s + 'MHz '
1371
+ cpu += 'Min: ' + cpuinfo[/^.*CPU min MHz:\s*(.*)/, 1].to_i.to_s + "MHz\n\n"
1372
+ text += cpu.fg(154)
1373
+ system += 'Packages: ' + packages + "\n"
1374
+ system += 'Desktop: ' + `awk '/^DesktopNames/' /usr/share/xsessions/* | sed 's/DesktopNames=//g' | \\
1375
+ sed 's/\\;/\\n/g' | sed '/^$/d' | sort -u | sed ':a;N;$!ba;s/\\n/, /g'`.chomp + '/'
1376
+ system += `grep 'gtk-theme-name' ~/.config/gtk-3.0/* | sed 's/gtk-theme-name=//g' | \\
1377
+ sed 's/-/ /g'`.sub(/.*:/, '') + "\n"
1378
+ text += system.fg(251)
1379
+ rescue # rubocop:disable Lint/SuppressedException
1380
+ end
1381
+ begin
1382
+ mem = `free -h` + "\n"
1383
+ text += mem.fg(229)
1384
+ rescue # rubocop:disable Lint/SuppressedException
1385
+ end
1386
+ begin
1387
+ ps = `ps -eo comm,pid,user,pcpu,pmem,stat --sort -pcpu,-pmem | head` + "\n"
1388
+ text += ps.fg(195)
1389
+ rescue # rubocop:disable Lint/SuppressedException
1390
+ end
1391
+ begin
1392
+ disk = `df -H | head -8`
1393
+ text += disk.fg(172)
1394
+ rescue # rubocop:disable Lint/SuppressedException
1395
+ end
1396
+ begin
1397
+ dmesg = "\nDMESG (latest first):\n"
1398
+ dcmd = `dmesg 2>/dev/null | tail -6`.split("\n").sort.reverse.join("\n")
1399
+ dmesg += dcmd == '' ? "dmesg requires root, run 'sudo sysctl kernel.dmesg_restrict=0' if you need permission\n" : dcmd
1400
+ text += dmesg.fg(219)
1401
+ rescue # rubocop:disable Lint/SuppressedException
1402
+ end
1403
+ @pR.say(text)
1404
+ rescue
1405
+ @pR.say('Unable to show system info')
1406
+ end
1407
+
1408
+ def make_directory # {{{3
1409
+ cmd = @pCmd.ask(': ', 'mkdir ')
1410
+ shellexec(cmd + ' -p')
1411
+ end
1412
+
1413
+ def navi_invoke # {{{3
1414
+ @navi = `navi`
1415
+ rescue
1416
+ @pB.say(' navi not installed - see https://github.com/junegunn/fzf')
1417
+ end
1418
+
1419
+ # COMMAND MODE {{{2
1420
+ def command_mode # {{{3
1421
+ raw = @pCmd.ask('Shell command: ', '').strip
1422
+ @pB.clear; @pB.update = true
1423
+ return if raw.empty?
1424
+
1425
+ # Prefix override: §cmd = force interactive
1426
+ force = raw.start_with?('§')
1427
+ raw = raw[1..].strip if force
1428
+ # Expand @s / @t
1429
+ sel = Shellwords.escape(@selected.to_s)
1430
+ tg = @tagged.map { |p| Shellwords.escape(p) }.join(' ')
1431
+ cmd = raw.gsub('@s', sel).gsub('@t', tg)
1432
+ # Determine program name
1433
+ prog = Shellwords.split(cmd).first
1434
+ prog = File.basename(prog) if prog
1435
+ # Whitelist check against @interactive
1436
+ inter_list = (@interactive || '').split(',').map(&:strip)
1437
+ whitelist = prog && inter_list.include?(prog)
1438
+ # Magic PTY-peek (only if not forced or whitelisted)
1439
+ magic = false
1440
+ unless force || whitelist
1441
+ begin
1442
+ PTY.spawn(cmd) do |r, _w, pid|
1443
+ begin
1444
+ Timeout.timeout(0.1) do
1445
+ magic = r.readpartial(1024).include?("\e[?1049h")
1446
+ end
1447
+ rescue Timeout::Error
1448
+ # no quick data = assume non-TUI
1449
+ ensure
1450
+ Process.kill('TERM', pid) rescue nil
1451
+ Process.wait(pid) rescue nil
1452
+ end
1453
+ end
1454
+ rescue Errno::ENOENT, Errno::EIO
1455
+ # command not found or PTY closed immediately = non-TUI
173
1456
  end
174
- else
175
- if not File.exist?(Dir.home+'/.rtfm.launch')
176
- @rtfmlaunch = <<RTFMLAUNCH
177
- # This is the RTFM launcher (https://github.com/isene/RTFM) for bash/zsh
178
- function r {
179
- f=$(mktemp)
180
- (
181
- set +e
182
- rtfm "$f"
183
- code=$?
184
- if [ "$code" != 0 ]; then
185
- rm -f "$f"
186
- exit "$code"
187
- fi
188
- )
189
- code=$?
190
- if [ "$code" != 0 ]; then
191
- return "$code"
192
- fi
193
- d=$(<"$f")
194
- rm -f "$f"
195
- cd "$d"
196
- }
197
- RTFMLAUNCH
198
- File.write(Dir.home+'/.rtfm.launch', @rtfmlaunch)
199
- `echo "source ~/.rtfm.launch" >> .#{@shell}rc`
1457
+ end
1458
+ if force || whitelist || magic # Decide interactive vs non-interactive
1459
+ # Restore shell tty so Ctrl-C/D work
1460
+ system("stty #{ORIG_STTY} < /dev/tty")
1461
+ # Clear to top-left
1462
+ system('clear < /dev/tty > /dev/tty')
1463
+ Cursor.show
1464
+ # Spawn on real tty
1465
+ pid2 = Process.spawn(cmd,
1466
+ in: '/dev/tty',
1467
+ out: '/dev/tty',
1468
+ err: '/dev/tty')
1469
+ begin
1470
+ Process.wait(pid2)
1471
+ rescue Interrupt
1472
+ Process.kill('TERM', pid2) rescue nil
1473
+ retry
200
1474
  end
1475
+ # Restore raw/no-echo for RTFM
1476
+ system('stty raw -echo isig < /dev/tty')
1477
+ $stdin.raw!
1478
+ $stdin.echo = false
1479
+ Cursor.hide
1480
+ Rcurses.clear_screen
1481
+ refresh
1482
+ render
1483
+ else
1484
+ shellexec(cmd)
1485
+ @pR.refresh
1486
+ @pR.update = false
201
1487
  end
1488
+ end
202
1489
 
203
- puts @firstrun
204
- puts "\n... hit ENTER to start RTFM. Hit ? inside RTFM to show the help text. Enjoy :-)"
205
- $stdin.gets
1490
+ def show_history # {{{3
1491
+ @pR.say("Command history:\n\n" + @pCmd.history.reverse.join("\n"))
1492
+ @pB.update = true
206
1493
  end
207
- begin # BASIC SETUP
208
- require 'fileutils'
209
- require 'io/console'
210
- require 'io/wait'
211
- require 'date'
212
- require 'timeout'
213
- require 'curses'
214
- include Curses
215
1494
 
216
- firstrun unless File.exist?(Dir.home+'/.rtfm.conf')
1495
+ def add_interactive # {{{
1496
+ @interactive = @pB.ask('Add program to @interactive: '.fg(213), @interactive.fg(213))
1497
+ @pB.clear; @pB.update = true
1498
+ end
217
1499
 
218
- def cmd?(command)
219
- system("which #{command} > /dev/null 2>&1")
1500
+ # RUBY MODE {{{2
1501
+ def ruby_debug # {{{3
1502
+ require 'stringio'
1503
+ cmd = @pRuby.ask('Ruby command: ', '')
1504
+ @pR.text = "Command: #{cmd}\n\n".fg(205)
1505
+ original_stdout = $stdout
1506
+ original_stderr = $stderr
1507
+ stdout_captured = StringIO.new
1508
+ stderr_captured = StringIO.new
1509
+ $stdout = stdout_captured
1510
+ $stderr = stderr_captured
1511
+ begin
1512
+ eval(cmd) # rubocop:disable Security/Eval
1513
+ rescue Exception => e # rubocop:disable Lint/RescueException
1514
+ puts "Error: #{e.message}\n#{e.backtrace.join("\n")}"
1515
+ ensure
1516
+ $stdout = original_stdout
1517
+ $stderr = original_stderr
220
1518
  end
221
-
222
- if cmd?('/usr/lib/w3m/w3mimgdisplay')
223
- @imgdisplay = "/usr/lib/w3m/w3mimgdisplay"
224
- @showimage = true
1519
+ @pR.text += stdout_captured.string + "\n\n" + stderr_captured.string
1520
+ @pR.refresh
1521
+ @pR.update = false
1522
+ @pB.full_refresh
1523
+ end
1524
+
1525
+ # GENERIC FUNCTIONS {{{1
1526
+ def dirlist(left: true) # LIST DIRECTORIES {{{2
1527
+ @index ||= 0
1528
+ @index = @index.to_i
1529
+ if left
1530
+ dir = Dir.pwd
1531
+ width = @pL.w
225
1532
  else
226
- @showimage = false
227
- end
228
- @showimage = false unless (cmd?('xwininfo') and cmd?('xdotool'))
229
-
230
- cmd?('bat') ? @bat = "bat" : @bat = "batcat"
231
-
232
- $stdin.set_encoding(Encoding::UTF_8) # Set encoding for $stdin
233
-
234
- ## Curses setup
235
- Curses.init_screen
236
- Curses.start_color
237
- Curses.curs_set(0)
238
- Curses.noecho
239
- Curses.cbreak
240
- Curses.stdscr.keypad = true
241
-
242
- # INITIALIZE VARIABLES
243
- ## These can be set by user in .rtfm.conf
244
- @lsbase = "--group-directories-first" # Basic ls setup
245
- @lslong = false # Set short form ls (toggled by pressing "l")
246
- @lsall = "" # Set "ls -a" to false (toggled by pressing "a" - sets it to "-a")
247
- @lsorder = "" # Change the order/sorting by pressing 'o' (circular toggle)
248
- @lsinvert = "" # Set to "-r" to reverse/invert sorting order
249
- @lsuser = "" # Set this variable in .rtfm.conf to any 'ls' switch you want to customize directory listings
250
- @width = 4 # Set width of the left pane to the default ⅓ of the terminal width
251
- @history = [] # Initialize the command line history array
252
- @rubyhistory = [] # Initialize the command line history array for ruby commands
253
- @border = false
254
- @rubycolor = 52
255
- @cmdcolor = 18
256
- @topcolor = 249
257
- @topmatch = [["", 249]]
258
- @bottomcolor = 238
259
- @preview = true
260
- @runmailcap = false # Set to 'true' in .rtfm.conf if you want to use run-mailcap instead of xdg-open
261
- @batuse = true # Use batcat for syntax highlighting
262
- @aimodel = "gpt-4o-mini" # The default OpenAI model, set another in .rtfm.conf if you like
263
- ## These are automatically written on exit
264
- @marks = {} # Initialize (book)marks hash
265
- @hash = {} # Initialize the sha directory hashing
266
- @tagged = [] # Initialize the tagged array - for collecting all tagged items
267
- ## These should not be set by user in .rtfm.conf
268
- @directory = {} # Initialize the directory hash for remembering directories visited
269
- @searched = "" # Initialize the active searched for items
270
- @lsfiles = "" # File types to show (initially set to all file types) - not saved on exit
271
- @lsmatch = "" # Files to match (initially set to matching all files) - not saved on exit
272
- @index = 0 # Set chosen item to first on startup
273
- @cont = ""
274
- @tagsize = 0
275
- @navi = ""
276
- @marks["'"] = Dir.pwd
277
- ## File type recognizers
278
- @imagefile = /\.jpg$|\.JPG$|\.jpeg$|\.png$|\.bmp$|\.gif$|\.tif$|\.tiff$/
279
- @pptfile = /\.ppt$/
280
- @xlsfile = /\.xls$/
281
- @docfile = /\.doc$/
282
- @docxfile = /\.docx$/
283
- @xlsxfile = /\.xlsx$/
284
- @pptxfile = /\.pptx$/
285
- @oolofile = /\.odt$|\.odc$|\.odp$|\.odg$/
286
- @pdffile = /\.pdf$|\.ps$/
287
- ## Get variables from config file (written back to .rtf.conf upon exit via 'q')
288
- if File.exist?(Dir.home+'/.rtfm.conf')
289
- load(Dir.home+'/.rtfm.conf')
290
- end
291
- Dir.chdir(ARGV[0]) if ARGV[0] and Dir.exist?(ARGV[0]) and ARGV[0] !~ /\/tmp/
292
- end
293
- # CLASS EXTENSIONS
294
- class Curses::Window
295
- attr_accessor :fg, :bg, :attr, :text, :update, :pager, :pager_more, :pager_cmd, :locate, :nohistory
296
- # General extensions (see https://github.com/isene/Ruby-Curses-Class-Extension)
297
- def clr
298
- self.setpos(0, 0)
299
- self.maxy.times {self.deleteln()}
300
- self.refresh
301
- self.setpos(0, 0)
302
- end
303
- def fill # Fill window with color as set by :bg
304
- self.setpos(0, 0)
305
- self.bg = 0 if self.bg == nil
306
- self.fg = 255 if self.fg == nil
307
- init_pair(self.fg, self.fg, self.bg)
308
- blank = " " * self.maxx
309
- self.maxy.times {self.attron(color_pair(self.fg)) {self << blank}}
310
- self.refresh
311
- self.setpos(0, 0)
312
- end
313
- def write # Write context of :text to window with attributes :attr
314
- self.bg = 0 if self.bg == nil
315
- self.fg = 255 if self.fg == nil
316
- init_pair(self.fg, self.fg, self.bg)
317
- self.attr = 0 if self.attr == nil
318
- self.attron(color_pair(self.fg) | self.attr) { self << self.text }
319
- self.refresh
320
- end
321
- # RTFM specific extensions
322
- end
323
- # GENERIC FUNCTIONS
324
- def syntax_highlight(input) # SYNTAX HIGHLIGHTING FROM ANSI COLOR CODES
325
- color_ary = color_parse(input)
326
- color_ary.each do | pair |
327
- begin
328
- fg = pair[0].to_i
329
- atr = pair[1].to_i
330
- atr == 1 ? atr = Curses::A_BOLD : atr = 0
331
- text = pair[2]
332
- text.gsub!(/\t/, '')
333
- init_pair(fg, fg, 0)
334
- @w_r.attron(color_pair(fg) | atr) { @w_r << text }
335
- rescue
1533
+ dir = if @selected && File.directory?(@selected)
1534
+ File.symlink?(@selected) ? File.realpath(@selected) : @selected.to_s
1535
+ else
1536
+ File.dirname(@selected)
1537
+ end
1538
+ width = @pR.w
1539
+ end
1540
+ # Fetch plain names + colored lines
1541
+ purels = command("ls #{Shellwords.escape(dir)} #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").pure.split("\n")
1542
+ colorls = command("ls --color #{Shellwords.escape(dir)} #{@lsbase} #{@lsall} #{@lslong} #{@lsorder} #{@lsinvert} #{@lsuser}").split("\n")
1543
+ colorls.shift if colorls[0]&.strip == "#{dir}:"
1544
+ colorls.shift if colorls[0]&.match?(/^total/)
1545
+ if left && @orderchange # Keep the same @selected even when we re-sort
1546
+ basename = File.basename(@selected.to_s)
1547
+ new_idx = purels.index(basename)
1548
+ @index = new_idx if new_idx
1549
+ @orderchange = false
1550
+ end
1551
+ entries = purels.zip(colorls) # Zip into pairs and filter
1552
+ unless @lsfiles.empty? # filter by extension(s)
1553
+ exts = @lsfiles.split(',').map { |e| /\.#{Regexp.escape(e)}$/i }
1554
+ entries.select! { |name, _| exts.any? { |re| name =~ re } }
1555
+ end
1556
+ unless @lsmatch.empty? # filter by regex
1557
+ re = Regexp.new(@lsmatch)
1558
+ entries.select! { |name, _| name =~ re }
1559
+ end
1560
+ # Unzip, with safe defaults if nothing matched
1561
+ t = entries.transpose
1562
+ purels = t[0] || []
1563
+ ls = t[1] || []
1564
+ @files = purels if left
1565
+ # Update @selected & @fileattr for left pane
1566
+ if left && purels[@index]
1567
+ @selected = Dir.pwd + '/' + purels[@index]
1568
+ sfile = @selected.dup
1569
+ sfile += '/' if File.directory?(@selected)
1570
+ slsl_cmd = "ls -ldHlh --time-style=long-iso #{Shellwords.escape(sfile)}"
1571
+ slsl = command(slsl_cmd)
1572
+ a = slsl.split
1573
+ @fileattr = a.size >= 7 ? "#{a[2]}:#{a[3]} #{a[0]} #{a[4]} #{a[5]} #{a[6]}" : ''
1574
+ end
1575
+ # Map & decorate each colored line
1576
+ ls.map!.with_index do |el, i|
1577
+ n = el.clean_ansi
1578
+ n = n.shorten(width - 5).inject('…', -1) if n.pure.length > width - 6
1579
+ raw_name = (purels[i] || '').strip
1580
+ base = left ? Dir.pwd : dir
1581
+ fullpath = "#{base}/#{raw_name}"
1582
+ n = n.inject('@', -1) if File.symlink?(fullpath)
1583
+ n = n.inject('/', -1) if File.directory?(fullpath)
1584
+ n = n.bg(238) if !raw_name.empty? && raw_name.match(/#{@searched}/) && @searched != ''
1585
+ n = n.r if @tagged.include?(fullpath)
1586
+ if left
1587
+ n = (i == @index ? '→ ' + n.u : ' ' + n)
336
1588
  end
1589
+ n
337
1590
  end
1591
+ ls.join("\n")
338
1592
  end
339
- def color_parse(input) # PARSE ANSI COLOR SEQUENCES
340
- input.gsub!(/\e\[\d;38;5;(\d+);*(\d*)m/, '¤¤\1¤¤\2¤¤')
341
- input.gsub!(/\e\[38;5;(\d+);*(\d*)m/, '¤¤\1¤¤\2¤¤')
342
- input.gsub!(/\e\[\d;38;2;(\d+);*(\d*);\d*m/, '¤¤\1¤¤\2¤¤')
343
- input.gsub!(/\e\[38;2;(\d+);*(\d*);\d*m/, '¤¤\1¤¤\2¤¤')
344
- input.gsub!(/\e\[\d+;(\d+);*(\d*)m/, '¤¤\1¤¤\2¤¤')
345
- input.gsub!(/\e\[0m/, "")
346
- color_array = input.split("¤¤")
347
- color_array = color_array.drop(1)
348
- #color_array.map! { |x| x || 0 }
349
- output = color_array.each_slice(3).to_a
350
- return output
351
- end
352
- def getchr # PROCESS KEY PRESSES
353
- # Note: Curses.getch blanks out @w_t
354
- # @w_l.getch makes Curses::KEY_DOWN etc not work
355
- # Therefore resorting to the generic method
356
- c = $stdin.getch(min: 0, time: 3)
357
- case c
358
- when "\e" # ANSI escape sequences
359
- return "ESC" if $stdin.ready? == nil
360
- case $stdin.getc
361
- when '[' # CSI
362
- case $stdin.getc
363
- when 'A' then chr = "UP"
364
- when 'B' then chr = "DOWN"
365
- when 'C' then chr = "RIGHT"
366
- when 'D' then chr = "LEFT"
367
- when 'Z' then chr = "S-TAB"
368
- when '2' then chr = "INS" ; chr = "C-INS" if $stdin.getc == "^"
369
- when '3' then chr = "DEL" ; chr = "C-DEL" if $stdin.getc == "^"
370
- when '5' then chr = "PgUP" ; chr = "C-PgUP" if $stdin.getc == "^"
371
- when '6' then chr = "PgDOWN" ; chr = "C-PgDOWN" if $stdin.getc == "^"
372
- when '7' then chr = "HOME" ; chr = "C-HOME" if $stdin.getc == "^"
373
- when '8' then chr = "END" ; chr = "C-END" if $stdin.getc == "^"
374
- else chr = ""
375
- end
376
- when 'O' # Set Ctrl+ArrowKey equal to ArrowKey; May be used for other purposes in the future
377
- case $stdin.getc
378
- when 'a' then chr = "C-UP"
379
- when 'b' then chr = "C-DOWN"
380
- when 'c' then chr = "C-RIGHT"
381
- when 'd' then chr = "C-LEFT"
382
- else chr = ""
383
- end
384
- end
385
- when "", "" then chr = "BACK"
386
- when "" then chr = "C-C"
387
- when "" then chr = "C-D"
388
- when "" then chr = "C-E"
389
- when "" then chr = "C-G"
390
- when " " then chr = "C-K"
391
- when " " then chr = "C-L"
392
- when "" then chr = "C-N"
393
- when "" then chr = "C-O"
394
- when "" then chr = "C-P"
395
- when "" then chr = "C-T"
396
- when "" then chr = "C-Y"
397
- when "" then chr = "WBACK"
398
- when "" then chr = "LDEL"
399
- when "\r" then chr = "ENTER"
400
- when "\t" then chr = "TAB"
401
- when /[[:print:]]/ then chr = c
402
- else chr = ""
403
- end
404
- return chr
405
- end
406
- def main_getkey # GET KEY FROM USER
407
- dir = Dir.pwd
408
- chr = getchr
409
- case chr
410
- # BASIC KEYS
411
- when '?' # Show helptext in right window
412
- @w_r.fg = 249
413
- w_r_info(@help)
414
- @w_b.update = true
415
- when 'r' # Refresh all windows
416
- @break = true
417
- when 'R' # Reload .rtfm.conf
418
- if File.exist?(Dir.home+'/.rtfm.conf')
419
- load(Dir.home+'/.rtfm.conf')
420
- end
421
- w_b_info(" Config reloaded")
422
- when 'W' # Write all parameters to .rtfm.conf
423
- @write_conf_all = true
424
- conf_write
425
- @w_b.update = true
426
- when 'q' # Exit
427
- @tagged = []
428
- @write_conf = true
429
- exit 0
430
- when 'Q' # Exit without writing to .rtfm.conf
431
- @tagged = []
432
- system("printf \"\033]0;#{Dir.pwd}\007\"")
433
- @write_conf = false
434
- exit 0
435
- when 'v'
436
- w_b_info("RTFM version = #{@version} (latest RubyGems version is #{Gem.latest_version_for("rtfm-filemanager").version} - https://github.com/isene/RTFM)")
437
- # MOTION
438
- when 'DOWN', 'j', 'C-DOWN'
439
- var_resets
440
- @index = @index >= @max_index ? @min_index : @index + 1
441
- @w_r.update = true
442
- @w_b.update = true
443
- when 'UP', 'k', 'C-UP'
444
- var_resets
445
- @index = @index <= @min_index ? @max_index : @index - 1
446
- @w_r.update = true
447
- @w_b.update = true
448
- when 'LEFT', 'h', 'C-LEFT'
449
- var_resets
450
- cur_dir = Dir.pwd
451
- @directory[Dir.pwd] = @selected # Store this directory before leaving
452
- mark_latest
453
- Dir.chdir("..")
454
- @directory[Dir.pwd] = File.basename(cur_dir) unless @directory.key?(Dir.pwd)
455
- @w_r.update = true
456
- @w_b.update = true
457
- when 'RIGHT', 'l', 'C-RIGHT'
458
- var_resets
459
- @directory[Dir.pwd] = @selected # Store this directory before leaving
460
- mark_latest
461
- open_selected()
462
- @w_r.update = true
463
- @w_b.update = true
464
- when 'x' # Force open with file opener (used to open HTML files in browser)
465
- var_resets
466
- @directory[Dir.pwd] = @selected # Store this directory before leaving
467
- mark_latest
468
- open_selected(true)
469
- @w_r.update = true
470
- @w_b.update = true
471
- when 'PgDOWN'
472
- var_resets
473
- @index += @w_l.maxy - 2
474
- @index = @max_index if @index > @max_index
475
- @w_r.update = true
476
- @w_b.update = true
477
- when 'PgUP'
478
- var_resets
479
- @index -= @w_l.maxy - 2
480
- @index = @min_index if @index < @min_index
481
- @w_r.update = true
482
- @w_b.update = true
483
- when 'END'
484
- var_resets
485
- @index = @max_index
486
- @w_r.update = true
487
- @w_b.update = true
488
- when 'HOME'
489
- var_resets
490
- @index = @min_index
491
- @w_r.update = true
492
- @w_b.update = true
493
- # JUMPING AND MARKS
494
- when 'm' # Set mark
495
- marks_info
496
- m = $stdin.getc
497
- if m.match(/[\w']/)
498
- @marks[m] = Dir.pwd
499
- elsif m == "-"
500
- r = $stdin.getc
501
- @marks.delete(r)
502
- end
503
- marks_info
504
- @w_r.update = false
505
- @w_b.update = true
506
- when 'M' # Show marks
507
- marks_info
508
- @w_r.update = false
509
- @w_b.update = true
510
- when "'" # Jump to mark
511
- marks_info
512
- m = stdscr.getch.to_s
513
- if m.match(/[\w']/) and @marks[m]
514
- var_resets
515
- @directory[Dir.pwd] = @selected # Store this directory before leaving
516
- dir_before = Dir.pwd
517
- begin
518
- Dir.chdir(@marks[m])
519
- rescue
520
- w_b_info(" No such directory")
521
- end
522
- mark_latest
523
- @marks["'"] = dir_before
524
- end
525
- @w_r.update = true
526
- @w_b.update = true
527
- when '~' # Go to home dir
528
- var_resets
529
- @directory[Dir.pwd] = @selected # Store this directory before leaving
530
- mark_latest
531
- Dir.chdir
532
- @w_r.update = true
533
- @w_b.update = true
534
- when '>' # Follow symlink
535
- @directory[Dir.pwd] = @selected # Store this directory before leaving
536
- mark_latest
537
- if File.symlink?(@selected)
538
- begin
539
- Dir.chdir(File.dirname(File.readlink(@selected)))
540
- rescue
541
- end
542
- end
543
- @w_b.update = true
544
- # SEARCHING
545
- when '/' # Get search string to mark items that match the input
546
- @w_b.nohistory = true
547
- @searched = w_b_getstr("/ ", "")
548
- l = `ls 2>/dev/null #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}`.split
549
- m = l.each_index.select{|n| l[n] =~ /#{@searched}/}
550
- @index = m[0] unless m == []
551
- @index = 0 if @searched == ""
552
- @w_r.update = true
553
- when '\\' # Clear search string
554
- @searched = ""
555
- when 'n' # Jump to next occurence of search (after '/')
556
- l = `ls 2>/dev/null #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}`.split
557
- m = l.each_index.select{|n| l[n] =~ /#{@searched}/}
558
- i = m.find { |n| n > @index }
559
- if i == nil
560
- @index = m.first
561
- else
562
- @index = i
563
- end
564
- @w_r.update = true
565
- when 'N' # Jump to previous occurence of search (after '/')
566
- l = `ls 2>/dev/null #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}`.split
567
- m = l.each_index.select{|n| l[n] =~ /#{@searched}/}.reverse
568
- i = m.find { |n| n < @index }
569
- if i == nil
570
- @index = m.first
571
- else
572
- @index = i
573
- end
574
- @w_r.update = true
575
- when 'g' # Run 'grep' in the current directory
576
- cmd = w_b_getstr(": ", "grep -s MATCH *")
577
- w_b_exec(cmd)
578
- when 'L' # Run 'locate' and let user jump to a result (by '#')
579
- cmd = w_b_getstr(": ", "locate ")
580
- w_b_exec(cmd)
581
- @w_r.locate = true
582
- @w_b.update = true
583
- when '#' # Jump to the line number in list of matches to 'locate'
584
- if @w_r.locate
585
- jumpnr = w_b_getstr("# ", "").to_i
586
- jumpline = @w_r.text.lines[jumpnr - 1]
587
- jumpdir = jumpline[/\/[^\e]*/]
588
- unless Dir.exist?(jumpdir)
589
- @searched = File.basename(jumpdir)
590
- jumpdir = File.dirname(jumpdir)
591
- end
592
- @directory[Dir.pwd] = @selected # Store this directory before leaving
593
- mark_latest
594
- Dir.chdir(jumpdir)
595
- @w_r.pager = 0
596
- end
597
- @w_b.update = true
598
- when 'C-L' # fzf integration (https://github.com/junegunn/fzf)
599
- begin
600
- jump = `fzf`.chomp
601
- rescue
602
- w_b_info(" fzf not installed - see https://github.com/junegunn/fzf")
1593
+
1594
+ def render # RENDER ALL PANES {{{2
1595
+ # LEFT pane {{{3
1596
+ if @pL.update
1597
+ lefttext = @pL.text
1598
+ @pL.text = dirlist
1599
+ scrolloff = 3
1600
+ total = @files.size
1601
+ page = @pL.h
1602
+ if total <= page
1603
+ # If everything fits, always start from the very top
1604
+ @pL.ix = 0
1605
+ elsif @index - @pL.ix < scrolloff
1606
+ # If we're too close to the top of the pane, scroll up
1607
+ @pL.ix = [@index - scrolloff, 0].max
1608
+ elsif (@pL.ix + page - 1 - @index) < scrolloff
1609
+ # If we're too close to the bottom of the pane, scroll down
1610
+ max_off = total - page
1611
+ @pL.ix = [@index + scrolloff - page + 1, max_off].min
603
1612
  end
604
- jumpdir = File.dirname(jump)
605
- @searched = File.basename(jump)
606
- @directory[Dir.pwd] = @selected # Store this directory before leaving
607
- mark_latest
608
- Dir.chdir(jumpdir)
609
- @break = true
610
- # TAGGING
611
- when 't' # Add item to tagged list
612
- item = "\"#{Dir.pwd}/#{@selected}\""
613
- if @tagged.include?(item)
614
- @tagged.delete(item)
615
- @tagsize -= File.size(item[1...-1])
1613
+ @min_index = 0
1614
+ @max_index = total - 1
1615
+ @index = 0 if @index.negative?
1616
+ @index = @max_index if @index > @max_index
1617
+ @pL.refresh unless @pL.text == lefttext
1618
+ end
1619
+
1620
+ # RIGHT pane {{{3
1621
+ if @pR.update && @preview
1622
+ showimage('clear') if @image; @image = false
1623
+ righttext = @pR.text
1624
+ if @selected && File.directory?(@selected)
1625
+ @pR.text = dirlist(left: false)
616
1626
  else
617
- @tagged.push(item)
618
- @tagsize += File.size(item[1...-1])
1627
+ showcontent
619
1628
  end
620
- @index += 1
621
- @w_r.update = true
622
- @w_b.update = true
623
- when 'C-T' # Tag items matching a pettern
624
- @w_b.nohistory = true
625
- @tag = w_b_getstr("~ ", "")
626
- @w_r.update = true
627
- @w_b.update = true
628
- when 'T' # Show tagged list
629
- @w_r.fg = 196
630
- tagged_info
631
- @w_r.update = false
632
- @w_b.update = true
633
- when 'u' # Clear tagged list
634
- @tagged = []
635
- tagged_info
636
- @w_r.update = false
637
- @w_b.update = true
638
- # MANIPULATE ITEMS
639
- when 'p' # Copy tagged items here
640
- copy_move_link("copy")
641
- @w_r.update = true
642
- when 'P' # Move tagged items here
643
- copy_move_link("move")
644
- @w_r.update = true
645
- when 's' # Create symlink to tagged items here
646
- copy_move_link("link")
647
- @w_r.update = true
648
- when 'd' # Delete items tagged and @selected
649
- tagged_info
650
- w_b_info(" Delete selected and tagged? (press 'y' to delete)")
651
- begin
652
- @tagged.push("\"#{Dir.pwd}/#{@selected}\"")
653
- @tagged.uniq!
654
- deletes = @tagged.join(" ")
655
- if $stdin.getc == 'y'
656
- `rm -rf #{deletes} 2>/dev/null`
657
- items_number = @tagged.length
658
- @tagged = []
659
- w_b_info("Deleted #{items_number} items: #{deletes}")
660
- else
661
- @w_b.update = true
662
- end
663
- @w_r.update = true
664
- rescue StandardError => err
665
- w_b_info(err.to_s)
1629
+ @pR.full_refresh unless @pR.text == righttext || @image
1630
+ end
1631
+
1632
+ # TOP PANE {{{3
1633
+ if @pT.update
1634
+ toptext = @pT.text
1635
+ text = ' ' + ENV.fetch('USER') + '@' + `hostname 2>/dev/null`.chomp + ': '
1636
+ unless @selected.nil?
1637
+ text += @selected
1638
+ text += " → #{File.readlink(@selected)}" if File.symlink?(@selected)
666
1639
  end
667
- when 'c' # Change/rename selected @selected
668
- @selected.length > 12 ? source = @selected[0...12] + "…" : source = @selected
669
- cmd = w_b_getstr(": ", "mv \"#{source}\" \"#{@selected}\"")
670
- el1 = @selected_safe
671
- el2 = cmd.split(' "').last[0..-2]
672
- cmd = "mv #{el1} \"#{el2}\""
1640
+ # File attributes
1641
+ text += " (#{@fileattr})" if defined?(@fileattr)
1642
+ # Image or PDF metadata
673
1643
  begin
674
- if el1 == el2
675
- w_b_info(" Source and target are the same. No action done.")
676
- else
677
- w_b_exec(cmd + " 2>/dev/null")
1644
+ if @selected&.match(@imagefile)
1645
+ if cmd?('identify')
1646
+ meta = `identify #{Shellwords.escape(@selected)} \
1647
+ | awk '{printf " [%s %s %s %s] ", $3,$2,$5,$6}' 2>/dev/null`
1648
+ text += meta
1649
+ end
1650
+ elsif @selected&.match(@pdffile)
1651
+ info = `pdfinfo #{Shellwords.escape(@selected)} 2>/dev/null`
1652
+ pages = info[/^Pages:\s+(\d+)/, 1]
1653
+ size = info[/^Page size:.*\((.*)\)/, 1]
1654
+ text += " [#{pages} pages]" if pages
1655
+ text += " [#{size}]" if size
678
1656
  end
679
- rescue StandardError => err
680
- w_b_info(err.to_s)
681
- end
682
- @w_r.update = false
683
- when 'C-O' # Change ownerships
684
- require 'etc'
685
- gnm = Etc.getgrgid(File.stat(@selected).gid).name
686
- unm = Etc.getpwuid(File.stat(@selected).uid).name
687
- p = "Change ownership of selected"
688
- p += " and tagged" unless @tagged.empty?
689
- p += ". Selected ownership (user:group) = "
690
- own = w_b_getstr(p, "#{unm}:#{gnm}").split(":")
691
- gnm, unm = own[0], own[1]
692
- begin
693
- gid = Etc.getgrnam(gnm).gid.to_i
694
- uid = Etc.getpwnam(unm).uid.to_i
695
- File.chown(uid, gid, @selected)
696
- @tagged.each {|t| File.chown(uid, gid, t[1...-1])} unless @tagged.empty?
697
- w_b_info("Ownership changed to #{gnm}:#{unm}")
698
- rescue StandardError => err
699
- w_b_info(err.to_s)
700
- end
701
- when 'C-P' # Change permissions
702
- mode = @fspes[@index][1..9]
703
- p = "Change permissions of selected"
704
- p += " and tagged" unless @tagged.empty?
705
- p += ". Selected permissions = "
706
- mode = w_b_getstr(p, mode)
707
- mode = mode * 3 if mode.length == 3 and mode.to_i == 0
708
- if mode.length == 9 and mode.to_i == 0
709
- x = 0
710
- x += 400 if mode[0] == "r"
711
- x += 200 if mode[1] == "w"
712
- x += 100 if mode[2] == "x"
713
- x += 40 if mode[3] == "r"
714
- x += 20 if mode[4] == "w"
715
- x += 10 if mode[5] == "x"
716
- x += 4 if mode[6] == "r"
717
- x += 2 if mode[7] == "w"
718
- x += 1 if mode[8] == "x"
719
- mode = x
1657
+ rescue Errno::ENOENT, Errno::EACCES
1658
+ # ignore missing or permission errors
720
1659
  end
721
- if mode.to_s.length == 3 and mode.to_i != 0
722
- mode = mode.to_s.to_i(8)
1660
+ # Directory children count
1661
+ if @selected && Dir.exist?(@selected)
723
1662
  begin
724
- File.chmod(mode, @selected)
725
- @tagged.each {|t| File.chmod(mode, t[1...-1])} unless @tagged.empty?
726
- rescue StandardError => err
727
- w_b_info(err.to_s)
728
- end
729
- else
730
- w_b_info("Not a valid permissions mode. Nothing changed.")
731
- end
732
- when 'z' # Unzip selected archive file
733
- cmd = w_b_getstr("Command = ", "tar xfz #{@tagged.first}")
734
- begin
735
- w_b_exec(cmd + " 2>/dev/null")
736
- rescue StandardError => err
737
- w_b_info(err.to_s)
738
- end
739
- when 'Z' # Create archive file
740
- arc = w_b_getstr("Archive name: ", "")
741
- cmd = w_b_getstr("Command = ", "tar cfz #{arc}.gz #{@tagged.join(" ")}")
742
- begin
743
- w_b_exec(cmd + " 2>/dev/null")
744
- rescue StandardError => err
745
- w_b_info(err.to_s)
746
- end
747
- # DIRECTORY VIEWS
748
- when 'a' # Show all items
749
- @lsall == "" ? @lsall = "-a" : @lsall = ""
750
- @w_r.update = true
751
- @w_b.update = true
752
- when 'A' # Show all info for all items
753
- @lslong = !@lslong
754
- @w_r.update = true
755
- @w_b.update = true
756
- when 'o' # Circular toggle the order/sorting of directory views
757
- case @lsorder
758
- when ""
759
- @lsorder = "-S"
760
- w_b_info(" Sorting by size, largest first")
761
- when "-S"
762
- @lsorder = "-t"
763
- w_b_info(" Sorting by modification time")
764
- when "-t"
765
- @lsorder = "-X"
766
- w_b_info(" Sorting by extension (alphabetically)")
767
- when "-X"
768
- @lsorder = ""
769
- w_b_info(" Normal sorting")
770
- end
771
- @w_r.update = true
772
- @orderchange = true
773
- when 'i' # Invert the order/sorting of directory views
774
- case @lsinvert
775
- when ""
776
- @lsinvert = "-r"
777
- w_b_info(" Sorting inverted")
778
- when "-r"
779
- @lsinvert = ""
780
- w_b_info(" Sorting NOT inverted")
781
- end
782
- @w_r.update = true
783
- @orderchange = true
784
- when 'O' # Show the Ordering in the bottom window (the full ls command)
785
- w_b_info(" Full 'ls' command: ls <@s> #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}")
786
- when 'G' # Git status for selected item or current dir
787
- @w_r.fg = 214
788
- if File.exist?(".git")
789
- w_r_info(`git status 2>/dev/null`)
790
- else
791
- w_r_info("This is not a git repository.")
792
- end
793
- @w_r.update = false
794
- @w_b.update = true
795
- when 'H' # Compare with previous hash status or write hash status if no existing hash
796
- @w_r.fg = 213
797
- hashcmd = "\(find #{Dir.pwd} -type f -print0 | sort -z | xargs -0 sha1sum; find #{Dir.pwd}"\
798
- " \\( -type f -o -type d \\) -print0 | sort -z | xargs -0 stat -c '%n %a'\) | sha1sum | cut -c -40"
799
- begin
800
- hashdir = `#{hashcmd}`.chomp
801
- rescue StandardError => e
802
- w_r_info("Error: #{e.inspect}")
803
- end
804
- hashtime = DateTime.now.strftime "%Y-%m-%d %H:%M"
805
- if @hash.include?(Dir.pwd)
806
- if @hash[Dir.pwd][1] == hashdir
807
- w_b_info(" Hash for #{Dir.pwd} has NOT changed since #{hashtime} (#{hashdir})")
808
- else
809
- w_b_info(" Hash for #{Dir.pwd} has CHANGED since #{hashtime} (#{@hash[Dir.pwd][1]} -> #{hashdir})")
810
- @hash[Dir.pwd] = [hashtime, hashdir]
811
- end
812
- else
813
- hashtime = DateTime.now.strftime "%Y-%m-%d %H:%M"
814
- @hash[Dir.pwd] = [hashtime, hashdir]
815
- w_b_info(" New hash for #{Dir.pwd}: #{hashtime}: #{hashdir}")
816
- end
817
- @w_r.update = true
818
- @w_b.update = false
819
- when 'I' # OpenAI integration
820
- if @ai
821
- openai
822
- else
823
- w_b_info("No OpenAI key in config file. Add `@ai = 'your-secret-openai-key'` to .rtfm.conf")
824
- end
825
- # RIGHT PANE
826
- when 'ENTER' # Refresh right pane
827
- @w_r.fill # First clear the window, then clear any previously showing image
828
- image_show("clear") if @image; @image = false
829
- @w_r.update = true
830
- @w_b.update = true
831
- when 'TAB' # Start paging/Down one page
832
- if @w_r.pager == 1 and @w_r.pager_cmd != ""
833
- @w_r.text = `#{@w_r.pager_cmd} 2>/dev/null`
834
- end
835
- if @w_r.pager_more
836
- @w_r.pager += 1
837
- pager_show
838
- end
839
- @w_b.update = true
840
- when 'S-TAB' # Up one page
841
- if @w_r.pager > 1
842
- @w_r.pager -= 1
843
- pager_show
844
- end
845
- @w_b.update = true
846
- when 'w' # Change width of left/right panes
847
- @width += 1
848
- @width = 2 if @width == 7
849
- @break = true
850
- @w_r.update = true
851
- @w_b.update = true
852
- when '-' # Toggle content view in right pane
853
- @preview = !@preview
854
- @preview ? p = "On" : p = "Off"
855
- w_b_info("Preview = " + p)
856
- getch
857
- @break = true
858
- when '_' # Toggle image view
859
- @showimage = !@showimage
860
- @showimage ? i = "On" : i = "Off"
861
- w_b_info("Image preview = " + i)
862
- getch
863
- @break = true
864
- when 'b' # Toggle syntax highlighting (via bat/batcat)
865
- @batuse = !@batuse
866
- @break = true
867
- # ADDITIONAL COMMANDS
868
- when 'f' # Filter out filetypes not matching @lsfiles
869
- loop do
870
- @lsfiles = w_b_getstr("Filetype(s) to show: ", @lsfiles)
871
- break if @lsfiles.match(/^$|\w+(,\w+)*$/)
872
- end
873
- w_b_info(nil)
874
- when 'F' # Filter out files not matching @lsmatch
875
- @lsmatch = w_b_getstr("Files will match RegEx: ", @lsmatch)
876
- w_b_info(nil)
877
- when 'B' # Toggle borders
878
- @border = !@border
879
- @break = true
880
- when ':' # Enter "command mode" in the bottom window - tries to execute the given command
881
- @w_r.nohistory = false
882
- cmd = w_b_getstr(": ", "")
883
- w_b_exec(cmd)
884
- @w_b.update = true
885
- when ';' # Show command history
886
- w_r_info("Command history (latest on top):\n\n" + @history.join("\n"))
887
- @w_b.update = true
888
- when 'y', 'Y' # Copy path of selected item
889
- if @selected == nil
890
- w_b_info(" No selected item path to copy")
891
- else
892
- path = Dir.pwd + "/" + @selected
893
- if chr == 'Y'
894
- clip = "xclip -selection clipboard"
895
- w_b_info(" Path copied to clipboard")
896
- else
897
- clip = "xclip"
898
- w_b_info(" Path copied to primary selection (paste with middle mouse button)")
1663
+ count = Dir.children(@selected).count
1664
+ text += " [#{count} items]"
1665
+ rescue Errno::EACCES
1666
+ text += ' [Denied]'
899
1667
  end
900
- system("echo -n '#{path}' | #{clip}")
901
1668
  end
902
- when 'C-Y' # Copy right pane to clipboard
903
- clip = "xclip -selection clipboard"
904
- @cont.gsub!(/ ¤¤\d+¤¤\d*¤¤/, '')
905
- @cont.gsub!(/^¤¤\d+¤¤\d*¤¤ */, '')
906
- @cont.gsub!(/¤¤\d+¤¤\d*¤¤/, '')
907
- @cont.gsub!(/ /, '')
908
- @cont.gsub!(/ (\d)/, '\1')
909
- @cont.gsub!(/\[(\d+;)+\d+m/, '')
910
- @cont = @cont.inspect
911
- @cont.gsub!('\"', '"')
912
- @cont = @cont [1...-1]
913
- w_b_info(" Right pane copied to clipboard")
914
- system("echo -n '#{@cont}' | #{clip}")
915
- when 'S' # Show comprehensive system info
916
- sysinfo
917
- when 'C-D' # Create new directory (shortcut for ":mkdir ")
918
- cmd = w_b_getstr(": ", "mkdir ")
919
- w_b_exec(cmd + " -p")
920
- when 'C-N' # navi integration (https://github.com/denisidoro/navi)
921
- begin
922
- @navi = `navi`
923
- rescue
924
- w_b_info(" navi not installed - see https://github.com/denisidoro/navi")
925
- end
926
- @break = true
927
- when '@' # Enter "Ruby debug"
928
- @w_b.nohistory = false
929
- cmd = w_b_getstr("◆ ", "", true)
930
- @w_b.clr
931
- @w_b.refresh
932
- @w_b.update = true
933
- @w_r.clr
934
- w_r_info("Command: #{cmd}\n")
935
- begin
936
- eval(cmd).to_s
937
- rescue Exception => err
938
- w_r_info("Error: #{err.inspect}")
939
- end
940
- @w_r.update = false
1669
+ @pT.text = text.b
1670
+ @pT.bg = @topmatch.find { |name, _| name.empty? || Dir.pwd.include?(name) }&.last
1671
+ @pT.refresh unless @pT.text == toptext
941
1672
  end
942
- if @w_r.update == true
943
- @w_r.locate = false
944
- @w_r.pager = 0
945
- @w_r.pager_more = false
946
- end
947
- begin
948
- @w_r.update = true if dir != Dir.pwd
949
- rescue
1673
+
1674
+ # BOTTOM pane {{{3
1675
+ if @pB.update
1676
+ bottomtext = @pB.text
1677
+ info = ': for command (use @s for selected item, @t for tagged items) - press ? for help'
1678
+ info = " Showing only files matching '#{@lsmatch}'".fg(130).u if @lsmatch != ''
1679
+ info = " Showing only file type '#{@lsfiles}'".fg(129).u if @lsfiles != ''
1680
+ info = " Showing only file types '#{@lsfiles}'".fg(129).u if @lsfiles =~ /,/
1681
+ info += " and only files matching '#{@lsmatch}'".fg(129).u if @lsfiles != '' && @lsmatch != ''
1682
+ @pB.text = info
1683
+ @pB.refresh unless @pB.text == bottomtext
950
1684
  end
951
1685
  end
952
- def conf_write
953
- if File.exist?(Dir.home+'/.rtfm.conf')
954
- conf = File.read(Dir.home+'/.rtfm.conf')
955
- else
956
- conf = ""
957
- end
958
- conf.sub!(/^@marks.*{.*}\n/, "")
959
- conf += "@marks = #{@marks}\n"
960
- conf.sub!(/^@hash.*{.*}\n/, "")
961
- conf += "@hash = #{@hash}\n"
962
- conf.sub!(/^@tagged.*\[.*\]\n/, "")
963
- conf += "@tagged = #{@tagged}\n"
964
- conf.sub!(/^@history.*\[.*\]\n/, "")
965
- conf += "@history = #{@history}\n"
966
- conf.sub!(/^@rubyhistory.*\[.*\]\n/, "")
967
- conf += "@rubyhistory = #{@rubyhistory}\n"
968
- if @write_conf_all
969
- conf.sub!(/^@lslong.*\n/, "")
970
- conf += "@lslong = #{@lslong}\n"
971
- conf.sub!(/^@lsall.*\n/, "")
972
- conf += "@lsall = \"#{@lsall}\"\n"
973
- conf.sub!(/^@lsorder.*\n/, "")
974
- conf += "@lsorder = \"#{@lsorder}\"\n"
975
- conf.sub!(/^@lsinvert.*\n/, "")
976
- conf += "@lsinvert = \"#{@lsinvert}\"\n"
977
- conf.sub!(/^@width.*\n/, "")
978
- conf += "@width = #{@width}\n"
979
- conf.sub!(/^@border.*\n/, "")
980
- conf += "@border = #{@border}\n"
981
- conf.sub!(/^@preview.*\n/, "")
982
- conf += "@preview = #{@preview}\n"
983
- conf.sub!(/^@showimage.*\n/, "")
984
- conf += "@showimage = #{@showimage}\n"
985
- @w_r.fg = 249
986
- w_r_info("Configuration written to .rtfm.conf:\n\n" + conf)
987
- end
988
- File.write(Dir.home+'/.rtfm.conf', conf)
989
- end
990
- def ansifix(text) # Format [[fg, attr, text]]
991
- output = ""
992
- text.each do |e|
993
- ansi = "\e[38;5;#{e[0]}"
994
- ansi += ";1" if e[1] == 1
995
- ansi += "m"
996
- output += ansi + e[2].gsub(/\n/, "\n#{ansi}")
997
- end
998
- return output
999
- end
1000
- def mark_latest
1001
- @marks["5"] = @marks["4"]
1002
- @marks["4"] = @marks["3"]
1003
- @marks["3"] = @marks["2"]
1004
- @marks["2"] = @marks["1"]
1005
- @marks["1"] = @marks["'"]
1006
- @marks["'"] = Dir.pwd
1686
+
1687
+ def refresh # {{{2
1688
+ @p0.w = @w
1689
+ @p0.h = @h
1690
+ @p0.clear
1691
+ @pT.w = @w
1692
+ @pT.clear; @pT.update = true
1693
+ @pB.w = @w
1694
+ @pB.y = @h
1695
+ @pB.clear; @pB.update = true
1696
+ @pL.w = (@w - 4) * @width / 10
1697
+ @pL.h = @h - 4
1698
+ @pR.x = @pL.w + 4
1699
+ @pR.w = @w - @pL.w - 4
1700
+ @pR.h = @h - 4
1701
+ @pL.clear; @pL.update = true
1702
+ @pR.clear; @pR.update = true
1703
+ @pCmd.y = @h
1704
+ @pCmd.w = @w
1705
+ @pRuby.y = @h
1706
+ @pRuby.w = @w
1007
1707
  end
1008
- def get_files(win) # The core of the directory listings
1009
- ls_cmd = "ls 2>/dev/null #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}" # Get files in current directory
1010
- ls_cmd += @selected_safe if win == "right"
1011
- @cfiles = `#{ls_cmd} --color`.split("\n")
1012
- @files = @cfiles.map {|f| f.sub(/^.*\d+m(.+)\e\[0m/, '\1').gsub(/\e\[K/, "")}
1013
- ls_cmd += " -H " if win == "right"
1014
- ls_cmd += %q[ -lh --time-style="long-iso" | awk '{printf "%s%s%s%11s%6s%6s", $1, " " $3, ":" $4,$6,$7,$5"\n"}']
1015
- @fspes = `#{ls_cmd}`.split("\n").drop(1)
1016
- if @lsfiles != "" or @lsmatch != ""
1017
- lsf = @lsfiles.split(",").map! {|e| e.strip}
1018
- dir_cmd = "ls 2>/dev/null -d "
1019
- dir_cmd += @selected_safe + "/" if win == "right"
1020
- dir_cmd += "*/"
1021
- dirs = `#{dir_cmd}`.split("/\n")
1022
- dirs.map!{|d| d.sub!(/.*\//, '')} if win == "right"
1023
- @files = @files - dirs
1024
- @fspes = @fspes - dirs
1025
- @files.select! {|f| lsf.any? {|l| File.extname(f) == ".#{l}"}} if @lsfiles != ""
1026
- @fspes.select! {|f| lsf.any? {|l| File.extname(f) == ".#{l}"}} if @lsfiles != ""
1027
- @files.select! {|f| f =~ /#{@lsmatch}/} if @lsmatch != ""
1028
- @fspes.select! {|f| f =~ /#{@lsmatch}/} if @lsmatch != ""
1029
- @files = dirs + @files
1030
- @fspes = dirs + @fspes
1031
- @w_r.update = true
1032
- end
1033
- end
1034
- # TOP WINDOW FUNCTIONS
1035
- def w_t_info # SHOW INFO IN @w_t
1036
- text = " " + ENV['USER'].to_s + "@" + `hostname 2>/dev/null`.to_s.chop + ": " + Dir.pwd + "/"
1037
- unless @selected == nil
1038
- text += @selected
1039
- text += " → #{File.readlink(@selected)}" if File.symlink?(@selected)
1040
- end
1041
- begin
1042
- text += " (#{@fspes[@index]})"
1043
- rescue
1044
- end
1045
- begin
1046
- if @selected.match(@imagefile)
1047
- text += `identify #{@selected_safe} | awk '{printf " [%s %s %s %s] ", $3,$2,$5,$6}' 2>/dev/null` if cmd?('identify')
1048
- elsif @selected.match(@pdffile)
1049
- info = `pdfinfo #{@selected_safe} 2>/dev/null`
1050
- text += " [" + info.match(/Pages:.*?(\d+)/)[1]
1051
- text += " " + info.match(/Page size:.*\((.*)\)/)[1] + " pages] "
1052
- end
1053
- rescue
1708
+
1709
+ def setborder # {{{2
1710
+ case @border
1711
+ when 0
1712
+ @pL.border = false
1713
+ @pR.border = false
1714
+ when 1
1715
+ @pL.border = false
1716
+ @pR.border = true
1717
+ when 2
1718
+ @pL.border = true
1719
+ @pR.border = true
1720
+ when 3
1721
+ @pL.border = true
1722
+ @pR.border = false
1054
1723
  end
1055
- if Dir.exist?(@selected.to_s)
1056
- begin
1057
- text += " [" + Dir.glob(@selected+"/*").count.to_s + " " + Dir.children(@selected).count.to_s + "]"
1058
- rescue
1059
- text += " [Denied]"
1724
+ refresh
1725
+ end
1726
+
1727
+ def errormsg(msg, err) # {{{2
1728
+ text = "#{msg}: #{err.class} – #{err.message}\n\n".fg(196)
1729
+ err.backtrace.each { |ln| text += " #{ln}\n" } # Full backtrace
1730
+ text += "\nPlease report error as an RTFM issue: https://github.com/isene/RTFM/issues"
1731
+ @pR.say(text)
1732
+ end
1733
+
1734
+ def shell(cmd, background: false, err: nil) # {{{2
1735
+ # if caller passed err: filename, use that, otherwise fall back to the default log in /tmp
1736
+ tmp = err || File.join(Dir.tmpdir, 'rtfm_err.log')
1737
+ # build the full command, redirecting stderr to tmp
1738
+ full = if background
1739
+ "#{cmd} 2>#{tmp} &"
1740
+ else
1741
+ "#{cmd} 2>#{tmp}"
1742
+ end
1743
+ system(full)
1744
+ if File.exist?(tmp) # if anything was written, show it in @pR
1745
+ sleep 0.1
1746
+ err_text = File.read(tmp)
1747
+ unless err_text.strip.empty?
1748
+ @pR.say(err_text.fg(196))
1749
+ @pR.update = false
1060
1750
  end
1751
+ File.delete(tmp)
1061
1752
  end
1062
- text = text[1..(@w_t.maxx - 3)] + "…" if text.length + 3 > @w_t.maxx
1063
- text += " " * (@w_t.maxx - text.length) if text.length < @w_t.maxx
1064
- @w_t.clr
1065
- @w_t.text = text
1066
- @w_t.write
1067
- end
1068
- # LEFT WINDOW FUNCTIONS
1069
- def list_dir(active) # LIST CONTENT OF A DIRECTORY (BOTH active AND RIGHT WINDOWS)
1070
- ix = 0; t = 0
1071
- if active
1072
- win = @w_l
1073
- ix = @index - @w_l.maxy/2 if @index > @w_l.maxy/2 and @files.size > @w_l.maxy - 1
1074
- else
1075
- win = @w_r
1076
- @cont = ""
1077
- end
1078
- while ix < @files.size and t < win.maxy do
1079
- str = @files[ix]
1080
- cstr = @cfiles[ix]
1081
- active ? str_path = str : str_path = "#{@selected}/#{str}"
1082
- begin # Add items matching @tag to @tagged
1083
- if str.match(/#{@tag}/) and @tag != false
1084
- @tagged.push("\"#{Dir.pwd}/#{str}\"")
1085
- @tagged.uniq!
1753
+ nil
1754
+ end
1755
+
1756
+ def shellexec(cmd, timeout: 10) # {{{2
1757
+ out, err = command(cmd, timeout: timeout, return_both: true)
1758
+ @pR.say(err.fg(196)) unless err.empty?
1759
+ @pR.say(out) unless out.empty?
1760
+ @pB.full_refresh
1761
+ end
1762
+
1763
+ def command(cmd, timeout: 5, return_both: false) # {{{2
1764
+ # Run a shell command with timeout, drain pipes concurrently,
1765
+ # and optionally return both stdout+stderr instead of auto-saying stderr
1766
+ #
1767
+ # @param cmd [String] the command to run via `bash -c`
1768
+ # @param timeout [Numeric, nil] seconds to wait (nil = wait forever)
1769
+ # @param return_both [Boolean] if true, return [stdout, stderr] instead of auto-saying
1770
+ # @return [String] stdout (when return_both: false)
1771
+ # @return [Array<String,String>] [stdout, stderr] (when return_both: true)
1772
+ cmd_array = ['bash', '-c', cmd]
1773
+ out_buf = String.new
1774
+ err_buf = String.new
1775
+ begin
1776
+ Open3.popen3(*cmd_array) do |_stdin, stdout, stderr, wait_thr|
1777
+ pid = wait_thr.pid
1778
+ # Drain both pipes in background threads
1779
+ out_reader = Thread.new { out_buf << stdout.read until stdout.eof? }
1780
+ err_reader = Thread.new { err_buf << stderr.read until stderr.eof? }
1781
+ if timeout
1782
+ unless wait_thr.join(timeout)
1783
+ # Timed out → kill everything
1784
+ Process.kill('TERM', pid) rescue nil
1785
+ sleep 0.1
1786
+ Process.kill('KILL', pid) rescue nil
1787
+ Process.wait(pid) rescue nil
1788
+ out_reader.kill
1789
+ err_reader.kill
1790
+ showimage('clear') if @image
1791
+ @pR.say('Error: Command timed out.'.fg(196))
1792
+ return return_both ? ['', "Error: Command timed out.\n"] : ''
1793
+ end
1794
+ else
1795
+ wait_thr.join
1086
1796
  end
1087
- rescue
1797
+ # Ensure we've captured all output
1798
+ out_reader.join
1799
+ err_reader.join
1088
1800
  end
1089
- fg = 7; bold = 0; bg = 0 # Set default color
1090
- fl = color_parse(cstr)
1091
- fg, bold = fl[0][0].to_i, fl[0][1].to_i unless fl[0] == nil
1092
- init_pair(fg, fg, bg)
1093
- file_marker = color_pair(fg)
1094
- file_marker = file_marker | Curses::A_BOLD if bold == 1
1095
- if ix == @index and active
1096
- @w_l << "→ "
1097
- file_marker = file_marker | Curses::A_UNDERLINE
1098
- wixy = win.cury
1801
+ if return_both
1802
+ [out_buf, err_buf]
1099
1803
  else
1100
- active ? @w_l << " " : @w_r << " "
1804
+ @pR.say(err_buf.fg(196)) unless err_buf.empty?
1805
+ out_buf
1101
1806
  end
1102
- file_marker = file_marker | Curses::A_REVERSE if @tagged.include?("\"#{Dir.pwd}/#{str_path}\"")
1103
- file_marker = file_marker | Curses::A_BLINK if str.match(/#{@searched}/) and @searched != ""
1104
- File.directory?(str_path) ? dir = "/" : dir = ""
1105
- File.symlink?(str_path) ? link = "@" : link = ""
1106
- str = @fspes[ix] + " " + str if @lslong
1107
- if str.length > win.maxx - 4
1108
- base_name = File.basename(str, ".*")
1109
- base_length = base_name.length
1110
- ext_name = File.extname(str)
1111
- ext_length = ext_name.length
1112
- nbl = win.maxx - 6 - ext_length # nbl: new_base_length
1113
- nbl -= 1 if dir != "" # Account for one extra character
1114
- nbl -= 1 if link != "" # Account for one extra character
1115
- str = base_name[0..nbl] + "…" + ext_name
1116
- end
1117
- if !active and ix == win.maxy - 1 # Add indicator of more at bottom @w_r list
1118
- win << " ..."
1119
- return
1120
- end
1121
- str += link + dir
1122
- @cont += str + "\n" unless active # Adds to content in right win for copying
1123
- win.attron(file_marker) { win << str } # Implement color/bold to the item
1124
- win.clrtoeol
1125
- win << "\n"
1126
- ix += 1; t += 1
1127
- end
1128
- (win.maxy - win.cury).times {win.deleteln()} # Clear to bottom of window
1129
- if active
1130
- init_pair(242, 242, 0)
1131
- if @index > @w_l.maxy/2
1132
- @w_l.setpos(0, @w_l.maxx - 1)
1133
- @w_l.attron(color_pair(242) | Curses::A_DIM) { @w_l << "∆" }
1134
- end
1135
- if @files.length > @w_l.maxy - 1 and @files.length > @index + @w_l.maxy/2 - 1
1136
- @w_l.setpos(@w_l.maxy - 2, @w_l.maxx - 1)
1137
- @w_l.attron(color_pair(242) | Curses::A_DIM) { @w_l << "∇" }
1138
- end
1139
- end
1140
- end
1141
- def open_selected(html = nil) # OPEN SELECTED ITEM (when pressing RIGHT)
1142
- if File.directory?(@selected) # Rescue for permission error
1143
- begin
1144
- mark_latest
1145
- Dir.chdir(@selected)
1146
- rescue
1807
+ rescue IOError => e
1808
+ msg = "Error: Stream closed #{e.message}\n"
1809
+ if return_both
1810
+ ['', msg]
1811
+ else
1812
+ showimage('clear') if @image
1813
+ @pR.say(msg.fg(196))
1814
+ ''
1147
1815
  end
1148
- else
1149
- begin
1150
- if File.read(@selected).force_encoding("UTF-8").valid_encoding? and not html
1151
- system("exec $EDITOR #{@selected_safe}")
1152
- else
1153
- if @runmailcap
1154
- system("run-mailcap #{@selected_safe} 2>/dev/null &")
1155
- else
1156
- system("xdg-open #{@selected_safe} 2>/dev/null &")
1157
- end
1158
- end
1159
- @break = true
1160
- rescue
1816
+ rescue => e
1817
+ msg = "Error: #{e.message}\n#{e.backtrace.join("\n")}\n"
1818
+ if return_both
1819
+ ['', msg]
1820
+ else
1821
+ @pR.say(msg.fg(196))
1822
+ ''
1161
1823
  end
1162
1824
  end
1163
1825
  end
1164
- def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS (COPY IF "keep == true")
1826
+
1827
+ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
1165
1828
  @tagged.uniq!
1166
- @tagged.each do | item |
1167
- item = item[1..-2]
1168
- dest = Dir.pwd
1169
- dest += "/" + File.basename(item)
1170
- dest += "1" if File.exist?(dest)
1829
+ @tagged.each do |item|
1830
+ dest = File.join(Dir.pwd, File.basename(item))
1831
+ dest += '1' if File.exist?(dest)
1171
1832
  while File.exist?(dest)
1833
+ # Replace the last character (presumed to be a digit) by incrementing it
1172
1834
  dest = dest.chop + (dest[-1].to_i + 1).to_s
1173
1835
  end
1174
1836
  begin
1175
1837
  case type
1176
- when "copy"
1838
+ when 'copy'
1177
1839
  FileUtils.cp_r(item, dest)
1178
- w_b_info(" Item(s) copied here.")
1179
- when "move"
1840
+ @pB.say(' Item(s) copied here.')
1841
+ when 'move'
1180
1842
  FileUtils.mv(item, dest)
1181
- w_b_info(" Item(s) moved here.")
1182
- when "link"
1843
+ @pB.say(' Item(s) moved here.')
1844
+ when 'link'
1183
1845
  FileUtils.ln_s(item, dest)
1184
- w_b_info(" Item(s) symlinked here.")
1846
+ @pB.say(' Item(s) symlinked here.')
1185
1847
  end
1186
- rescue Exception => err
1187
- w_b_info(err.to_s)
1848
+ rescue => e
1849
+ @pB.say(e.to_s)
1188
1850
  end
1189
1851
  end
1190
1852
  @tagged = []
1853
+ render
1191
1854
  end
1192
- # RIGHT WINDOW FUNCTIONS
1193
- def w_r_show # SHOW CONTENTS IN THE RIGHT WINDOW
1194
- if @w_r.update
1195
- @w_r.clr # First clear the window, then clear any previously showing image
1196
- image_show("clear") if @image; @image = false
1197
- end
1198
- begin # Determine the specific programs to open/show content
1199
- if @w_r.pager > 0
1200
- pager_show
1201
- elsif File.directory?(@selected)
1202
- get_files("right")
1203
- list_dir(false)
1204
- # TEXT
1205
- elsif File.read(@selected).force_encoding("UTF-8").valid_encoding? and @w_r.pager == 0
1206
- if @batuse
1207
- begin # View the file as text if it is utf-8
1208
- @w_r.pager_cmd = "#{@bat} -n --color=always #{@selected_safe} 2>/dev/null"
1209
- @w_r.text = `#{@bat} -n --color=always --line-range :#{@w_r.maxy} #{@selected_safe} 2>/dev/null`
1210
- pager_start
1211
- syntax_highlight(@w_r.text)
1212
- rescue
1213
- @w_r.pager_cmd = "cat #{@selected_safe} 2>/dev/null"
1214
- w_r_doc
1215
- end
1216
- else
1217
- @w_r.pager_cmd = "cat #{@selected_safe} 2>/dev/null"
1218
- w_r_doc
1219
- end
1220
- # PDF
1221
- elsif @selected.match(@pdffile) and @w_r.pager == 0
1222
- @w_r.pager_cmd = "pdftotext #{@selected_safe} - 2>/dev/null | less"
1223
- @w_r.text = `pdftotext -f 1 -l 4 #{@selected_safe} - 2>/dev/null`
1224
- pager_start
1225
- @w_r << @w_r.text
1226
- # OPEN/LIBREOFFICE
1227
- elsif @selected.match(@oolofile) and @w_r.pager == 0
1228
- @w_r.pager_cmd = "odt2txt #{@selected_safe} 2>/dev/null"
1229
- w_r_doc
1230
- # MS DOCX
1231
- elsif @selected.match(@docxfile) and @w_r.pager == 0
1232
- @w_r.pager_cmd = "docx2txt #{@selected_safe} - 2>/dev/null"
1233
- w_r_doc
1234
- # MS XLSX
1235
- elsif @selected.match(@xlsxfile) and @w_r.pager == 0
1236
- @w_r.pager_cmd = "ssconvert -O 'separator= ' -T Gnumeric_stf:stf_assistant #{@selected_safe} fd://1 2>/dev/null"
1237
- w_r_doc
1238
- # MS PPTX
1239
- elsif @selected.match(@pptxfile) and @w_r.pager == 0
1240
- @w_r.pager_cmd = %Q[unzip -qc #{@selected_safe} | ruby -e '$stdin.each_line { |i| i.force_encoding("ISO-8859-1").scan(/<a:t>(.+?)<\\/a:t>/).each { |j| puts(j) } }' 2>/dev/null]
1241
- w_r_doc
1242
- # MS DOC
1243
- elsif @selected.match(@docfile) and @w_r.pager == 0
1244
- @w_r.pager_cmd = "catdoc #{@selected_safe} 2>/dev/null"
1245
- w_r_doc
1246
- # MS XLS
1247
- elsif @selected.match(@xlsfile) and @w_r.pager == 0
1248
- @w_r.pager_cmd = "xls2csv #{@selected_safe} 2>/dev/null"
1249
- w_r_doc
1250
- # MS PPT
1251
- elsif @selected.match(@pptfile) and @w_r.pager == 0
1252
- @w_r.pager_cmd = "catppt #{@selected_safe} 2>/dev/null"
1253
- w_r_doc
1254
- # IMAGES
1255
- elsif @selected.match(@imagefile)
1256
- image_show(@selected_safe)
1257
- @image = true
1258
- # VIDEOS (THUMBNAILS)
1259
- elsif @selected.match(/\.mpg$|\.mpeg$|\.avi$|\.mov$|\.mkv$|\.mp4$/)
1260
- begin
1261
- tmpfile = "/tmp/" + File.basename(@selected_safe,".*")
1262
- `ffmpegthumbnailer -s 1200 -i #{@selected_safe} -o /tmp/rtfm_video_tn.jpg 2>/dev/null`
1263
- image_show("/tmp/rtfm_video_tn.jpg")
1264
- @image = true
1265
- rescue
1266
- end
1855
+
1856
+ def mark_latest # UPDATE MARKS LIST {{{2
1857
+ @marks['5'] = @marks['4']
1858
+ @marks['4'] = @marks['3']
1859
+ @marks['3'] = @marks['2']
1860
+ @marks['2'] = @marks['1']
1861
+ @marks['1'] = @marks["'"]
1862
+ @marks["'"] = Dir.pwd
1863
+ end
1864
+
1865
+ def conf_write(all: false) # WRITE TO ~/.rtfm/conf {{{2
1866
+ @conf = @conf.dup # work on a mutable copy
1867
+ assignments = {
1868
+ 'marks' => "@marks = #{@marks}",
1869
+ 'hash' => "@hash = #{@hash}",
1870
+ 'history' => "@history = #{@pCmd.history.reverse.uniq.reverse.last(40)}",
1871
+ 'rubyhistory' => "@rubyhistory = #{@pRuby.history.reverse.uniq.reverse.last(40)}",
1872
+ 'aihistory' => "@aihistory = #{@pAI.history.reverse.uniq.reverse.last(40)}"
1873
+ }
1874
+ if all
1875
+ assignments.merge!(
1876
+ 'lslong' => "@lslong = '#{@lslong}'",
1877
+ 'lsall' => "@lsall = '#{@lsall}'",
1878
+ 'lsorder' => "@lsorder = '#{@lsorder}'",
1879
+ 'lsinvert' => "@lsinvert = '#{@lsinvert}'",
1880
+ 'width' => "@width = #{@width}",
1881
+ 'border' => "@border = #{@border}",
1882
+ 'preview' => "@preview = #{@preview}",
1883
+ 'trash' => "@trash = #{@trash}",
1884
+ 'interactive' => "@interactive = '#{@interactive}'"
1885
+ )
1886
+ end
1887
+ assignments.each do |var, line| # For each var, replace its line or append if missing
1888
+ regex = /^@#{Regexp.escape(var)}\b.*$/
1889
+ if @conf.match?(regex)
1890
+ @conf.sub!(regex, line)
1891
+ else
1892
+ @conf << "\n" unless @conf.end_with?("\n")
1893
+ @conf << line << "\n"
1267
1894
  end
1268
- rescue
1269
1895
  end
1270
- pager_add_markers # Add page markers, up and/or down
1271
- @w_r.update = false
1272
- @w_r.refresh
1896
+ show_config
1897
+ @pB.say('Configuration written to ~/.rtfm/conf')
1898
+ File.write(CONFIG_FILE, @conf)
1273
1899
  end
1274
- def w_r_doc # GET FULL CONTENT TO PAGE
1275
- @w_r.text = `#{@w_r.pager_cmd} 2>/dev/null`
1276
- pager_start
1277
- @w_r << @w_r.text
1278
- end
1279
- def w_r_info(info) # SHOW INFO IN THE RIGHT WINDOW
1900
+
1901
+ def exit_rtfm # CLEAN EXIT {{{2
1902
+ # If invoked with a stub filename, write out our cwd
1903
+ if ARGV[0]
1904
+ begin
1905
+ File.write(ARGV[0], Dir.pwd)
1906
+ rescue StandardError # rubocop:disable Lint/SuppressedException
1907
+ end
1908
+ end
1909
+ # Restore terminal state
1910
+ $stdin.cooked!
1911
+ $stdin.echo = true
1912
+ Rcurses.clear_screen
1913
+ Cursor.show
1914
+ # Also record for the 'r' launcher
1915
+ last = File.join(Dir.home, '.rtfm_last_dir')
1280
1916
  begin
1281
- image_show("clear") if @image; @image = false
1282
- @w_r.clr
1283
- @w_r.refresh
1284
- w_r_width = Curses.cols - (Curses.cols * @width / 10) - 2
1285
- info.gsub!(/(.{1,#{w_r_width}})( +|$\n?)|(.{1,#{w_r_width}})/, "\\1\\3\n")
1286
- @w_r.text = info
1287
- @w_r.pager_cmd = ""
1288
- pager_start
1289
- pager_show
1290
- @w_r.update = false
1291
- rescue
1917
+ File.write(last, Dir.pwd)
1918
+ rescue StandardError
1919
+ # ignore write errors
1292
1920
  end
1921
+ exit(0)
1293
1922
  end
1294
- def marks_info # SHOW MARKS IN RIGHT WINDOW
1295
- @w_r.fg = 183
1296
- @marks = @marks.sort.to_h
1297
- info = "MARKS:\n"
1298
- unless @marks.empty?
1299
- @marks.each do |mark, dir|
1300
- info += "#{mark} = #{dir}\n"
1923
+
1924
+ def log(msg) # LOG TO LOGFILE {{{2
1925
+ File.write(LOG_PATH, "#{Time.now.iso8601} #{msg}\n", mode: 'a')
1926
+ end
1927
+
1928
+ # RIGHT PANE FUNCTIONS {{{1
1929
+ def showcommand(cmd) # Helper function for showcontent {{{2
1930
+ c = command(cmd)
1931
+ return if c == ''
1932
+
1933
+ @pR.say(c)
1934
+ end
1935
+
1936
+ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
1937
+ if @pR.update
1938
+ showimage('clear') if @image
1939
+ @image = false
1940
+ @pR.clear
1941
+ end
1942
+ begin
1943
+ if @selected && File.directory?(@selected)
1944
+ @pR.say(dirlist(left: false))
1945
+ else # Look up first matching handler
1946
+ entry = PREVIEW_HANDLERS.find { |re, _| re.match?(@selected) }
1947
+ pattern, tmpl = entry || [nil, nil]
1948
+ if tmpl # Command-based preview
1949
+ escaped = Shellwords.escape(@selected)
1950
+ if !@batuse && tmpl.match?(/\b#{@bat}\b/)
1951
+ showcommand("cat #{escaped}")
1952
+ else
1953
+ cmd = tmpl.gsub('@s', escaped)
1954
+ showcommand(cmd)
1955
+ end
1956
+ elsif pattern # Nil template → image or video
1957
+ case @selected
1958
+ when /\.(?:png|jpe?g|bmp|gif|webp|tiff?)$/i
1959
+ showimage(Shellwords.escape(@selected))
1960
+ @image = true
1961
+ when /\.(?:mpg|mpeg|avi|mov|mkv|mp4)$/i
1962
+ tn = '/tmp/rtfm_video_tn.jpg'
1963
+ showcommand("ffmpegthumbnailer -s 1200 -i #{Shellwords.escape(@selected)} -o #{Shellwords.escape(tn)} 2>/dev/null")
1964
+ showimage(tn)
1965
+ @image = true
1966
+ else
1967
+ @pR.say("No preview available for #{@selected}")
1968
+ end
1969
+ else # Fallback: treat as text if UTF‑8
1970
+ text = File.read(@selected).force_encoding('UTF-8') rescue ''
1971
+ if text.valid_encoding?
1972
+ if @batuse
1973
+ begin
1974
+ showcommand("#{@bat} -n --color=always #{Shellwords.escape(@selected)}")
1975
+ rescue
1976
+ showcommand("cat #{Shellwords.escape(@selected)}")
1977
+ end
1978
+ else
1979
+ showcommand("cat #{Shellwords.escape(@selected)}")
1980
+ end
1981
+ else
1982
+ @pR.say("No preview available for #{@selected}")
1983
+ end
1984
+ end
1301
1985
  end
1302
- else
1303
- info += "(none)"
1986
+ rescue StandardError => e
1987
+ errormsg("⚠ Error while previewing #{@selected}", e)
1304
1988
  end
1305
- w_r_info(info)
1306
- end
1307
- def tagged_info # SHOW THE LIST OF TAGGED ITEMS IN @w_r
1308
- info = "TAGGED: (#{@tagged.size} items)\n"
1309
- @tagged.empty? ? info += "(None)" : info += @tagged.join("\n")
1310
- w_r_info(info)
1989
+ @pR.update = false
1311
1990
  end
1312
- def image_show(image)# SHOW THE SELECTED IMAGE IN THE RIGHT WINDOW
1313
- # Pass "clear" to clear the window for previous image
1991
+
1992
+ def showimage(image) # SHOW THE SELECTED IMAGE IN THE RIGHT WINDOW {{{2
1993
+ # Pass 'clear' to clear the window for previous image
1314
1994
  return unless @showimage
1995
+
1315
1996
  begin
1316
1997
  terminfo = `xwininfo -id $(xdotool getactivewindow 2>/dev/null) 2>/dev/null`
1317
1998
  term_w = terminfo.match(/Width: (\d+)/)[1].to_i
1318
1999
  term_h = terminfo.match(/Height: (\d+)/)[1].to_i
1319
- char_w = term_w / Curses.cols
1320
- char_h = term_h / Curses.lines
1321
- img_x = char_w * (Curses.cols * @width / 10 + 1)
2000
+ char_w = term_w / @w
2001
+ char_h = term_h / @h
2002
+ img_x = char_w * @pR.x
1322
2003
  img_y = char_h * 2
1323
- img_max_w = char_w * (Curses.cols - Curses.cols * @width / 10 - 2)
1324
- img_max_h = char_h * (Curses.lines - 4)
1325
- if image == "clear"
2004
+ img_max_w = char_w * (@pR.w - 1)
2005
+ img_max_h = char_h * (@pR.h - 1)
2006
+ if image == 'clear'
1326
2007
  `clear`
1327
2008
  img_x -= char_w
1328
2009
  img_max_w += char_w + 2
1329
2010
  img_max_h += 2
1330
2011
  `echo "6;#{img_x};#{img_y};#{img_max_w};#{img_max_h};\n4;\n3;" | #{@imgdisplay} 2>/dev/null`
1331
2012
  else
1332
- img_w,img_h = `identify -format "%[fx:w]x%[fx:h]" #{image} 2>/dev/null`.split('x')
1333
- img_w = img_w.to_i
1334
- img_h = img_h.to_i
2013
+ img_w, img_h = `identify -format "%[fx:w]x%[fx:h]" #{image} 2>/dev/null`.split('x')
2014
+ img_w = img_w.to_i
2015
+ img_h = img_h.to_i
1335
2016
  if img_w > img_max_w
1336
2017
  img_h = img_h * img_max_w / img_w
1337
2018
  img_w = img_max_w
@@ -1343,433 +2024,158 @@ def image_show(image)# SHOW THE SELECTED IMAGE IN THE RIGHT WINDOW
1343
2024
  `echo "0;1;#{img_x};#{img_y};#{img_w};#{img_h};;;;;\"#{image}\"\n4;\n3;" | #{@imgdisplay} 2>/dev/null`
1344
2025
  end
1345
2026
  rescue
1346
- @w_r.clr
1347
- @w_r << "Error showing image"
2027
+ @pR.text = 'Error showing image'
1348
2028
  end
1349
2029
  end
1350
- def pager_start # START PAGING
1351
- @w_r.pager = 1
1352
- if @w_r.text.lines.count > @w_r.maxy - 2
1353
- @w_r.pager_more = true
1354
- end
1355
- @cont = @w_r.text # For copying right win
1356
- end
1357
- def pager_show # SHOW THE CURRENT PAGE CONTENT
1358
- @w_r.setpos(0,0)
1359
- beg_l = (@w_r.pager - 1) * (@w_r.maxy - 5)
1360
- end_l = beg_l + @w_r.maxy - 2
1361
- input = @w_r.text.lines[beg_l..end_l].join() + "\n"
1362
- input.lines.count > @w_r.maxy - 2 ? @w_r.pager_more = true : @w_r.pager_more = false
1363
- if @w_r.text.match(/^\e\[/)
1364
- syntax_highlight(input)
2030
+
2031
+ def marks_info # SHOW MARKS IN RIGHT WINDOW {{{2
2032
+ @marks = @marks.sort.to_h
2033
+ info = ' ' + 'MARKS'.u + ":\n\n"
2034
+ if @marks.empty?
2035
+ info += ' (none)'
1365
2036
  else
1366
- if @w_r.fg == nil
1367
- init_pair(255, 255, 0)
1368
- @w_r.fg = 255
1369
- else
1370
- init_pair(@w_r.fg, @w_r.fg, 0)
2037
+ @marks.each do |mark, dir|
2038
+ info += " #{mark} = #{dir}\n"
2039
+ info += "\n" if mark == "'"
2040
+ info += "\n" if mark == '5'
1371
2041
  end
1372
- @w_r.attr = 0 if @w_r.attr == nil
1373
- @w_r.attron(color_pair(@w_r.fg) | @w_r.attr) { @w_r << input }
1374
2042
  end
1375
- @cont = input # For copying right win
1376
- (@w_r.maxy - @w_r.cury).times {@w_r.deleteln()} # Clear to bottom of window
1377
- pager_add_markers
1378
- @w_r.refresh
2043
+ @pR.say(info.fg(156))
2044
+ end
2045
+
2046
+ def tagged_info # SHOW THE LIST OF TAGGED ITEMS IN @pR {{{2
2047
+ info = ' ' + "TAGGED (#{@tagged.size} items, #{(@tagsize.to_f / 1_000_000).round(2)}MB)".u + ":\n\n"
2048
+ info += @tagged.empty? ? ' (None)' : ' ' + @tagged.join("\n ")
2049
+ info += "\n\n " + 'Selected'.u + ":\n " + @selected.b
2050
+ @pR.say(info.fg(204))
1379
2051
  end
1380
- def pager_add_markers # ADD MARKERS TOP/RIGHT & BOTTOM/RIGHT TO SHOW PAGING AS RELEVANT
1381
- if @w_r.pager > 1
1382
- @w_r.setpos(0, @w_r.maxx - 2)
1383
- @w_r << " ∆"
2052
+
2053
+ def open_selected(html = nil) # OPEN SELECTED ITEM (when pressing RIGHT or via open_force) {{{2
2054
+ require 'tmpdir' unless Dir.respond_to?(:tmpdir)
2055
+ if File.directory?(@selected) # Dir? just cd into it
2056
+ mark_latest
2057
+ Dir.chdir(@selected) rescue nil
2058
+ return
1384
2059
  end
1385
- if @w_r.pager_more
1386
- @w_r.setpos(@w_r.maxy - 1, @w_r.maxx - 2)
1387
- @w_r << " ∇"
2060
+ tmpfile = File.join(Dir.tmpdir, 'rtfm_err.log')
2061
+ paths = (@tagged + [@selected]).uniq
2062
+ if html # html mode - open in HTML-browser
2063
+ esc = paths.map { |p| Shellwords.escape(p) }.join(' ')
2064
+ shell("xdg-open #{esc} &", err: tmpfile)
2065
+ Rcurses.clear_screen; refresh; render
2066
+ if File.exist?(tmpfile)
2067
+ sleep 0.5
2068
+ err = File.read(tmpfile)
2069
+ showimage('clear') if @image
2070
+ @pR.say(err.fg(196))
2071
+ File.delete(tmpfile)
2072
+ end
2073
+ return
1388
2074
  end
1389
- end
1390
- def var_resets # RESET PAGER VARIABLES
1391
- @pager = 0
1392
- @pager_more = false
1393
- @pager_cmd = ""
1394
- @info = false
1395
- end
1396
- def openai # INTERFACE TO OPENAI
1397
- begin
1398
- require "ruby/openai"
1399
- rescue
1400
- w_b_info("To make openai work in RTFM, you need to do `gem install ruby-openai` and add this to your .rtfm.conf: $ai = 'your-secret-openai-key'")
2075
+ if File.read(@selected).force_encoding('UTF-8').valid_encoding? # Pure text
2076
+ system("exec $EDITOR #{Shellwords.escape(@selected)}")
2077
+ Rcurses.clear_screen
2078
+ refresh
2079
+ render
2080
+ return
1401
2081
  end
1402
-
1403
- client = OpenAI::Client.new(access_token: @ai)
2082
+ if @runmailcap # Open with run-mailcap or xdg-open
2083
+ arg = paths.map { |p| Shellwords.escape(p) }.join(' ')
2084
+ shell("run-mailcap #{arg} &", err: tmpfile)
2085
+ else
2086
+ shell("xdg-open #{Shellwords.escape(@selected)} &", err: tmpfile)
2087
+ end
2088
+ # Clean up
2089
+ Rcurses.clear_screen; refresh; render
2090
+ if File.exist?(tmpfile)
2091
+ sleep 0.5
2092
+ err = File.read(tmpfile)
2093
+ showimage('clear') if @image
2094
+ @pR.say(err.fg(196))
2095
+ File.delete(tmpfile)
2096
+ end
2097
+ end
1404
2098
 
1405
- c = @w_r.text
1406
- c = "" unless c
1407
- @w_r.fg = 214
1408
- w_r_info("OpenAI description...")
2099
+ # MAIN PROGRAM {{{1
2100
+ ## Get terminal size {{{2
2101
+ @h, @w = IO.console.winsize
1409
2102
 
1410
- f = Dir.pwd + "/" + @selected
1411
- p = "What is this "
1412
- File.directory?(@selected) ? p += "directory: " : p+= "file: "
1413
- p += "#{f}? "
1414
- p += "Give a brief summary of its content: " + c unless File.directory?(@selected) and c == ""
2103
+ ## Create panes {{{2
2104
+ # rubocop:disable Naming/VariableName
2105
+ # p = Pane.new( x, y, width, height, fg, bg)
2106
+ @p0 = Pane.new( 1, 1, @w, @h, 0, 0)
2107
+ @pT = Pane.new( 1, 1, @w, 1, 0, @topcolor)
2108
+ @pB = Pane.new( 1, @h, @w, 1, 252, @bottomcolor)
2109
+ @pL = Pane.new( 2, 3, (@w - 4)*@width/10, @h - 4, 15, 0)
2110
+ @pR = Pane.new(@pL.w + 4, 3, @w - @pL.w - 4, @h - 4, 255, 0)
2111
+ ## Create special panes
2112
+ @pCmd = Pane.new( 1, @h, @w, 1, 255, @cmdcolor)
2113
+ @pSearch = Pane.new( 1, @h, @w, 1, 255, @searchcolor)
2114
+ @pRuby = Pane.new( 1, @h, @w, 1, 255, @rubycolor)
2115
+ @pAI = Pane.new( 1, @h, @w, 1, 255, @aicolor)
2116
+ # rubocop:enable Naming/VariableName
2117
+ #checkpoint("Panes created")
1415
2118
 
1416
- begin
1417
- response = client.chat(parameters: { model: @aimodel, messages: [{ role: "user", content: p }], max_tokens: 400 })
1418
- text = "OpenAI description:\n" + response["choices"][0]["message"]["content"]
1419
- rescue
1420
- text = "Error retrieving OpenAI request.\n\n"
1421
- text += "Check your connection and @ai secret key (in .rtfm.conf).\n\n"
1422
- text += response.to_s
1423
- end
2119
+ ## Set pane properties {{{2
2120
+ @pT.update = true
2121
+ @pL.update = true
2122
+ @pR.update = true
2123
+ @pB.update = true
2124
+ @pSearch.record = true
2125
+ @pCmd.record = true
2126
+ @pCmd.history = @history
2127
+ @pRuby.record = true
2128
+ @pRuby.history = @rubyhistory
2129
+ @pAI.record = true
2130
+ @pAI.history = @aihistory
2131
+
2132
+ # Report plugin errors {{{2
2133
+ @pR.say("Plugin load errors:\n" + @plugin_errors.join("\n").fg(196)) if @plugin_errors.any?
1424
2134
 
1425
- @w_r.fg = 229
1426
- w_r_info(text)
2135
+ ## Set the borders {{{2
2136
+ setborder
2137
+
2138
+ ## Catch change in terminal resize, redraw {{{2
2139
+ Signal.trap('WINCH') do
2140
+ @h, @w = IO.console.winsize
2141
+ @pT.update = @pL.update = @pR.update = @pB.update = true
2142
+ refresh
2143
+ render
1427
2144
  end
1428
- def sysinfo
1429
- begin
1430
- @w_r.clr
1431
- @w_r.pager_cmd = ""
1432
- uname = `uname -o`.chomp + " "
1433
- uname += `uname -r`.chomp + " "
1434
- uname += `uname -v`.chomp + " "
1435
- uname += `uname -p`.chomp + " "
1436
- uname += `awk -F '"' '/PRETTY/ {print $2}' /etc/os-release` + "\n"
1437
- text = [[253, 1, uname]]
1438
- system = "Shell & Terminal: " + `echo $SHELL`.sub(/.*\//, '').chomp + ", " + `echo $TERM`.chomp + " "
1439
- packages = `pacman -Q 2>/dev/null | wc -l`.chomp
1440
- packages = `dpkg-query -l 2>/dev/null | grep -c '^.i'`.chomp if packages == "0"
1441
- packages = "Unrecognized" if packages == "0"
1442
- system += "Packages: " + packages + "\n"
1443
- system += "Desktop: " + `awk '/^DesktopNames/' /usr/share/xsessions/* | sed 's/DesktopNames=//g' | \\
1444
- sed 's/\\;/\\n/g' | sed '/^$/d' | sort -u | sed ':a;N;$!ba;s/\\n/, /g'`.chomp + "/"
1445
- system += `grep 'gtk-theme-name' ~/.config/gtk-3.0/* | sed 's/gtk-theme-name=//g' | \\
1446
- sed 's/-/ /g'`.sub(/.*:/, '') + "\n"
1447
- text += [[251, 0, system]]
1448
- cpu = "CPUs = " + `nproc`.chop + " "
1449
- cpuinfo = `lscpu`
1450
- cpu += cpuinfo[/^.*Model name:\s*(.*)/, 1] + " "
1451
- cpu += "Max: " + cpuinfo[/^.*CPU max MHz:\s*(.*)/, 1].to_i.to_s + "MHz "
1452
- cpu += "Min: " + cpuinfo[/^.*CPU min MHz:\s*(.*)/, 1].to_i.to_s + "MHz\n\n"
1453
- text += [[154, 0, cpu]]
1454
- mem = `free -h` + "\n"
1455
- text += [[229, 0, mem]]
1456
- ps = `ps -eo comm,pid,user,pcpu,pmem,stat --sort -pcpu,-pmem | head` + "\n"
1457
- text += [[195, 0, ps]]
1458
- disk = `df -H | head -8`
1459
- text += [[172, 0, disk]]
1460
- dmesg = "\nDMESG (latest first):\n"
1461
- dcmd = `dmesg 2>/dev/null | tail -6`.split("\n").sort.reverse.join("\n")
1462
- dcmd != "" ? dmesg += dcmd : dmesg += "dmesg requires root, run 'sudo sysctl kernel.dmesg_restrict=0' if you need permission\n"
1463
- text += [[219, 0, dmesg]]
1464
- w_r_info(ansifix(text))
2145
+
2146
+ ## Set terminal to raw w/o echo {{{2
2147
+ $stdin.raw!
2148
+ $stdin.echo = false
2149
+
2150
+ ## One-time flush {{{2
2151
+ $stdin.getc while $stdin.wait_readable(0)
2152
+
2153
+ ## THE LOOP {{{2
2154
+ #checkpoint("Program started")
2155
+ loop do
2156
+ @dir_old = Dir.pwd
2157
+ render
2158
+ getkey
2159
+ begin # If cwd was deleted externally, jump home
2160
+ Dir.pwd
1465
2161
  rescue
1466
- w_r_info("Unable to show system info")
1467
- end
1468
- end
1469
- # BOTTOM WINDOW FUNCTIONS
1470
- def w_b_info(info) # SHOW INFO IN @W_B
1471
- @w_b.clr
1472
- @w_b.fg, @w_b.bg = 250, @bottomcolor
1473
- if info == nil
1474
- info = ": for command (use @s for selected item, @t for tagged items) - press ? for help"
1475
- info = " Showing only files matching '#{@lsmatch}'" if @lsmatch != ""
1476
- info = " Showing only file type '#{@lsfiles}'" if @lsfiles != ""
1477
- info = " Showing only file types '#{@lsfiles}'" if @lsfiles =~ /,/
1478
- info += " and only files matching '#{@lsmatch}'" if @lsfiles != "" and @lsmatch != ""
1479
- info = " Tagged #{@tagged.size} files (#{(@tagsize.to_f/1000000).round(2)}MB)" unless @tagged.empty?
1480
- @w_b.fg, @w_b.bg = 250, 88 if @lsfiles != ""
1481
- @w_b.fg, @w_b.bg = 250, 21 if @lsmatch != ""
1482
- @w_b.fg, @w_b.bg = 250, 55 if @lsfiles != "" and @lsmatch != ""
1483
- end
1484
- info = info[1..(@w_b.maxx - 3)] + "…" if info.length + 3 > @w_b.maxx
1485
- info += " " * (@w_b.maxx - info.length) if info.length < @w_b.maxx
1486
- @w_b.text = info
1487
- @w_b.write
1488
- @w_b.update = false
1489
- end
1490
- def w_b_getstr(pretext, text, ruby=false) # A SIMPLE READLINE-LIKE ROUTINE
1491
- Curses.curs_set(1)
1492
- Curses.echo
1493
- stk = 0
1494
- chr = ""
1495
- if ruby
1496
- @rubyhistory.insert(stk, text)
1497
- @history_copy = @rubyhistory.map(&:clone)
1498
- else
1499
- @history.insert(stk, text)
1500
- @history_copy = @history.map(&:clone)
1501
- end
1502
- pos = @history_copy[stk].length
1503
- while chr != "ENTER"
1504
- @w_b.setpos(0,0)
1505
- if ruby
1506
- init_pair(250, 250, @rubycolor)
1507
- else
1508
- init_pair(250, 250, @cmdcolor)
1509
- end
1510
- text = pretext + @history_copy[stk]
1511
- text += " " * (@w_b.maxx - text.length) if text.length < @w_b.maxx
1512
- @w_b.attron(color_pair(250)) { @w_b << text }
1513
- @w_b.setpos(0,pretext.length + pos)
1514
- @w_b.refresh
1515
- chr = getchr
1516
- if chr == 'C-G' or chr == 'C-C'
1517
- Curses.curs_set(0)
1518
- Curses.noecho
1519
- @w_b.update = true
1520
- return ""
1521
- end
1522
- case chr
1523
- when 'UP'
1524
- unless @w_b.nohistory
1525
- unless stk == @history_copy.length - 1
1526
- stk += 1
1527
- pos = @history_copy[stk].length
1528
- end
1529
- end
1530
- when 'DOWN'
1531
- unless @w_b.nohistory
1532
- unless stk == 0
1533
- stk -= 1
1534
- pos = @history_copy[stk].length
1535
- end
1536
- end
1537
- when 'RIGHT'
1538
- pos += 1 unless pos > @history_copy[stk].length
1539
- when 'LEFT'
1540
- pos -= 1 unless pos == 0
1541
- when 'HOME'
1542
- pos = 0
1543
- when 'END'
1544
- pos = @history_copy[stk].length
1545
- when 'DEL'
1546
- @history_copy[stk][pos] = ""
1547
- when 'BACK'
1548
- unless pos == 0
1549
- pos -= 1
1550
- @history_copy[stk][pos] = ""
1551
- end
1552
- when 'WBACK'
1553
- unless pos == 0
1554
- until @history_copy[stk][pos - 1] == " " or pos == 0
1555
- pos -= 1
1556
- @history_copy[stk][pos] = ""
1557
- end
1558
- if @history_copy[stk][pos - 1] == " "
1559
- pos -= 1
1560
- @history_copy[stk][pos] = ""
1561
- end
1562
- end
1563
- when 'LDEL'
1564
- @history_copy[stk] = ""
1565
- pos = 0
1566
- when 'TAB' # Tab completion of dirs and files
1567
- p1 = pos - 1
1568
- c = @history_copy[stk][0..(p1)].sub(/^.* /, '')
1569
- p0 = p1 - c.length
1570
- compl = File.expand_path(c)
1571
- compl += "/" if Dir.exist?(compl)
1572
- clist = Dir.glob(compl + "*")
1573
- unless compl == clist[0].to_s and clist.length == 1
1574
- if clist.length == 1
1575
- compl = clist[0].to_s
1576
- else
1577
- ix = clist.find_index(compl)
1578
- ix = 0 if ix == nil
1579
- sel_item = ""
1580
- begin
1581
- Curses.curs_set(0)
1582
- Curses.noecho
1583
- @w_r.clr
1584
- @w_r << "Completion list:\n\n"
1585
- clist.each.with_index do |item, index|
1586
- if index == ix
1587
- @w_r.attron(Curses::A_BLINK) { @w_r << item }
1588
- sel_item = item
1589
- else
1590
- @w_r << item
1591
- end
1592
- @w_r << "\n"
1593
- end
1594
- @w_r.refresh
1595
- ix == clist.length ? ix = 0 : ix += 1
1596
- end while getchr == 'TAB'
1597
- compl = sel_item
1598
- @w_r.clr
1599
- Curses.curs_set(1)
1600
- Curses.echo
1601
- end
1602
- end
1603
- @history_copy[stk].sub!(c,compl)
1604
- pos = pos - c.length + compl.length
1605
- when /^.$/
1606
- @history_copy[stk].insert(pos,chr)
1607
- pos += 1
1608
- end
1609
- while $stdin.ready?
1610
- chr = $stdin.getc
1611
- @history_copy[stk].insert(pos,chr)
1612
- pos += 1
1613
- end
1614
- end
1615
- curstr = @history_copy[stk]
1616
- @history_copy.shift if @w_b.nohistory
1617
- ruby ? @rubyhistory.insert(0, @history_copy[stk]) : @history.insert(0, @history_copy[stk])
1618
- unless @w_b.nohistory
1619
- @history.uniq!
1620
- @history.compact!
1621
- @history.delete("")
1622
- @rubyhistory.uniq!
1623
- @rubyhistory.compact!
1624
- @rubyhistory.delete("")
1625
- end
1626
- Curses.curs_set(0)
1627
- Curses.noecho
1628
- return curstr
1629
- end
1630
- def w_b_exec(cmd) # EXECUTE COMMAND FROM @W_B
1631
- # Subsitute any '@s' with the selected item, @t with tagged items
1632
- # 'rm @s' deletes the selected item, 'rm @t' deletes tagged items
1633
- return if cmd == ""
1634
- @s = "\"#{Dir.pwd}/#{@selected}\""
1635
- cmd.gsub!(/@s/, @s)
1636
- @t = @tagged.join(" ")
1637
- cmd.gsub!(/@t/, @t)
1638
- if cmd.match(/^cd /)
1639
- cmd.sub!(/^cd (\S*).*/, '\1')
1640
- Dir.chdir(cmd) if Dir.exist?(cmd)
1641
- return
2162
+ Dir.chdir
1642
2163
  end
1643
- begin
1644
- status = Timeout::timeout(15) {
1645
- @w_r.clr
1646
- begin
1647
- @w_r.pager_cmd = "#{cmd} 2>&1 | #{@bat} -n --color=always"
1648
- @w_r.text = %x(#{@w_r.pager_cmd})
1649
- rescue
1650
- @w_r.pager_cmd = "#{cmd} 2>&1"
1651
- @w_r.text = %x(#{@w_r.pager_cmd})
1652
- end
1653
- unless @w_r.text == "" or @w_r.text == nil
1654
- pager_start
1655
- pager_show
1656
- @w_r.update = false
1657
- else
1658
- @w_r.update = true
1659
- end
1660
- }
1661
- rescue
1662
- w_b_info(" Failed to execute command (#{cmd})")
2164
+ @index = @directory[Dir.pwd] || 0 if Dir.pwd != @dir_old # If we cd'd, restore index
2165
+ unless @navi.empty?
2166
+ command(@navi)
2167
+ @navi = ''
1663
2168
  end
2169
+ system("printf \"\033]0;RTFM: #{Dir.pwd}\007\"") # Update window title
2170
+ rescue StandardError => e
2171
+ errormsg('⚠ Internal Error', e)
1664
2172
  end
1665
2173
 
1666
- # MAIN PROGRAM
1667
- loop do # OUTER LOOP - CATCHING REFRESHES VIA 'r'
1668
- @break = false # Initialize @break variable (set if user hits 'r')
1669
- @image = false # Set the image flag to false (set if image is displayed in @w_r)
1670
- @tag = false # Set pattern tagging to nothing
1671
- @orderchange = false
1672
- begin # Create the four windows/panels
1673
- if @border
1674
- Curses.stdscr.bg = 236 # Use for borders
1675
- Curses.stdscr.fill
1676
- else
1677
- Curses.stdscr.clear
1678
- Curses.stdscr.refresh
1679
- end
1680
- maxx = Curses.cols
1681
- maxy = Curses.lines
1682
- # Curses::Window.new(h,w,y,x)
1683
- @w_t = Curses::Window.new(1, maxx, 0, 0)
1684
- @w_b = Curses::Window.new(1, maxx, maxy - 1, 0)
1685
- @w_l = Curses::Window.new(maxy - 3, (maxx * @width / 10) - 1, 2, 0)
1686
- @w_r = Curses::Window.new(maxy - 4, maxx - (maxx * @width / 10), 2, maxx * @width / 10)
1687
- @w_p = Curses::Window.new(1, maxx - (maxx * @width / 10), maxy - 2, maxx * @width / 10)
1688
- @w_p.fg, @w_p.bg = 255, 0
1689
- @w_p.refresh
1690
- @w_t.fg, @w_t.bg = 232, @topcolor
1691
- @w_t.attr = Curses::A_BOLD
1692
- @w_b.fg, @w_b.bg = 250, @bottomcolor
1693
- @w_b.update = true
1694
- @w_r.update = true
1695
- @w_r.pager = 0
1696
- @w_r.pager_more = false
1697
- dir_old = Dir.pwd
1698
- lsall_old = @lsall
1699
- unless @tagged.empty?
1700
- tagged_info
1701
- @w_r.update = false
1702
- end
1703
- loop do # INNER, CORE LOOP
1704
- begin # Jump to home dir if current dir is externally removed
1705
- Dir.pwd
1706
- rescue
1707
- Dir.chdir
1708
- end
1709
- get_files("left")
1710
- if Dir.pwd != dir_old
1711
- if @directory.key?(Dir.pwd)
1712
- @selected = @directory[Dir.pwd]
1713
- @index = @files.index(@selected)
1714
- else
1715
- @index = 0
1716
- end
1717
- end
1718
- dir_old = Dir.pwd
1719
- @w_t.bg = @topmatch.find { |name, _| name == "" || dir_old.include?(name) }&.last
1720
- @index = 0 if @index == nil
1721
- index_old = @index
1722
- if @orderchange # Change in ordering must be handled
1723
- @index = @files.index(@selected)
1724
- @orderchange = false
1725
- end
1726
- @index = @files.index(@selected) if @lsall != lsall_old # Change in showing all items must be handled
1727
- @index = index_old if @files.index(@selected) == nil # If item no longer is shown
1728
- @min_index = 0
1729
- @max_index = @files.size - 1
1730
- @index = @max_index if @index > @max_index # If deleted many items
1731
- @index = 0 if @index < 0
1732
- @selected = @files[@index] # Get text of selected item
1733
- sel_old = @selected_safe
1734
- @selected_safe = "\"#{@selected}\"" # Make it safe for commands
1735
- system("printf \"\033]0;RTFM: #{Dir.pwd}\007\"") # Set Window title to path
1736
- w_t_info # Top window (info line)
1737
- @w_l.setpos(0,0)
1738
- list_dir(true)
1739
- @w_l.refresh
1740
- # Bottom window (command line) Before @w_r to avoid image dropping out on startup
1741
- w_b_info(nil) if @w_b.update
1742
- # Left and right windows (browser & content viewer)
1743
- if @w_r.update and @preview
1744
- w_r_show
1745
- @w_r.fg = 255
1746
- end
1747
- unless @navi == ""
1748
- w_r_info(@navi)
1749
- @w_r.update = false
1750
- @navi = ""
1751
- end
1752
- Curses.curs_set(1); Curses.curs_set(0) # Clear residual cursor from editing files
1753
- @tag = false # Clear tag pattern
1754
- lsall_old = @lsall
1755
- main_getkey # Get key from user
1756
- break if Curses.cols != maxx or Curses.lines != maxy # break on terminal resize
1757
- break if @break # Break to outer loop, redrawing windows, if user hit 'r'
1758
- end
1759
- rescue StandardError => err # Throw error nicely
1760
- w_r_info(err)
1761
- @w_r.update = false
1762
- ensure # On exit: close curses, clear terminal
1763
- @write_conf_all = false
1764
- conf_write if @write_conf # Write marks to config file
1765
- image_show("clear")
1766
- close_screen
1767
- # If launched via the script "r", return current dir and "r" will cd to that
1768
- begin
1769
- File.write(ARGV[0], Dir.pwd) if ARGV[0] and ARGV[0].match(/\/tmp/)
1770
- rescue
1771
- end
1772
- end
2174
+ at_exit do # Always restore terminal state on quit (or fatal)
2175
+ $stdin.cooked!
2176
+ $stdin.echo = true
2177
+ Rcurses.clear_screen
2178
+ Cursor.show
1773
2179
  end
1774
2180
 
1775
- # vim: set sw=2 sts=2 et fdm=syntax fdn=2 fcs=fold\:\ :
2181
+ # vim: set sw=2 sts=2 et fdm=marker fdn=2 fcs=fold\:\ :