rtfm-filemanager 4.09 → 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 +286 -165
  3. data/bin/rtfm +2063 -1653
  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,1322 +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.09" # Made path not lowercase for coloring
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
- @topmatch = [["", 249]]
255
- @preview = true
256
- @runmailcap = false # Set to 'true' in .rtfm.conf if you want to use run-mailcap instead of xdg-open
257
- @batuse = true # Use batcat for syntax highlighting
258
- @aimodel = "gpt-4o-mini" # The default OpenAI model, set another in .rtfm.conf if you like
259
- ## These are automatically written on exit
260
- @marks = {} # Initialize (book)marks hash
261
- @hash = {} # Initialize the sha directory hashing
262
- @tagged = [] # Initialize the tagged array - for collecting all tagged items
263
- ## These should not be set by user in .rtfm.conf
264
- @directory = {} # Initialize the directory hash for remembering directories visited
265
- @searched = "" # Initialize the active searched for items
266
- @lsfiles = "" # File types to show (initially set to all file types) - not saved on exit
267
- @lsmatch = "" # Files to match (initially set to matching all files) - not saved on exit
268
- @index = 0 # Set chosen item to first on startup
269
- @cont = ""
270
- @tagsize = 0
271
- @navi = ""
272
- @marks["'"] = Dir.pwd
273
- ## File type recognizers
274
- @imagefile = /\.jpg$|\.JPG$|\.jpeg$|\.png$|\.bmp$|\.gif$|\.tif$|\.tiff$/
275
- @pptfile = /\.ppt$/
276
- @xlsfile = /\.xls$/
277
- @docfile = /\.doc$/
278
- @docxfile = /\.docx$/
279
- @xlsxfile = /\.xlsx$/
280
- @pptxfile = /\.pptx$/
281
- @oolofile = /\.odt$|\.odc$|\.odp$|\.odg$/
282
- @pdffile = /\.pdf$|\.ps$/
283
- ## Get variables from config file (written back to .rtf.conf upon exit via 'q')
284
- if File.exist?(Dir.home+'/.rtfm.conf')
285
- load(Dir.home+'/.rtfm.conf')
286
- end
287
- Dir.chdir(ARGV[0]) if ARGV[0] and Dir.exist?(ARGV[0]) and ARGV[0] !~ /\/tmp/
288
- end
289
- # CLASS EXTENSIONS
290
- class Curses::Window
291
- attr_accessor :fg, :bg, :attr, :text, :update, :pager, :pager_more, :pager_cmd, :locate, :nohistory
292
- # General extensions (see https://github.com/isene/Ruby-Curses-Class-Extension)
293
- def clr
294
- self.setpos(0, 0)
295
- self.maxy.times {self.deleteln()}
296
- self.refresh
297
- self.setpos(0, 0)
298
- end
299
- def fill # Fill window with color as set by :bg
300
- self.setpos(0, 0)
301
- self.bg = 0 if self.bg == nil
302
- self.fg = 255 if self.fg == nil
303
- init_pair(self.fg, self.fg, self.bg)
304
- blank = " " * self.maxx
305
- self.maxy.times {self.attron(color_pair(self.fg)) {self << blank}}
306
- self.refresh
307
- self.setpos(0, 0)
308
- end
309
- def write # Write context of :text to window with attributes :attr
310
- self.bg = 0 if self.bg == nil
311
- self.fg = 255 if self.fg == nil
312
- init_pair(self.fg, self.fg, self.bg)
313
- self.attr = 0 if self.attr == nil
314
- self.attron(color_pair(self.fg) | self.attr) { self << self.text }
315
- self.refresh
316
- end
317
- # RTFM specific extensions
318
- end
319
- # GENERIC FUNCTIONS
320
- def syntax_highlight(input) # SYNTAX HIGHLIGHTING FROM ANSI COLOR CODES
321
- color_ary = color_parse(input)
322
- color_ary.each do | pair |
323
- begin
324
- fg = pair[0].to_i
325
- atr = pair[1].to_i
326
- atr == 1 ? atr = Curses::A_BOLD : atr = 0
327
- text = pair[2]
328
- text.gsub!(/\t/, '')
329
- init_pair(fg, fg, 0)
330
- @w_r.attron(color_pair(fg) | atr) { @w_r << text }
331
- 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)
332
1588
  end
1589
+ n
333
1590
  end
1591
+ ls.join("\n")
334
1592
  end
335
- def color_parse(input) # PARSE ANSI COLOR SEQUENCES
336
- input.gsub!(/\e\[\d;38;5;(\d+);*(\d*)m/, '¤¤\1¤¤\2¤¤')
337
- input.gsub!(/\e\[38;5;(\d+);*(\d*)m/, '¤¤\1¤¤\2¤¤')
338
- input.gsub!(/\e\[\d;38;2;(\d+);*(\d*);\d*m/, '¤¤\1¤¤\2¤¤')
339
- input.gsub!(/\e\[38;2;(\d+);*(\d*);\d*m/, '¤¤\1¤¤\2¤¤')
340
- input.gsub!(/\e\[\d+;(\d+);*(\d*)m/, '¤¤\1¤¤\2¤¤')
341
- input.gsub!(/\e\[0m/, "")
342
- color_array = input.split("¤¤")
343
- color_array = color_array.drop(1)
344
- #color_array.map! { |x| x || 0 }
345
- output = color_array.each_slice(3).to_a
346
- return output
347
- end
348
- def getchr # PROCESS KEY PRESSES
349
- # Note: Curses.getch blanks out @w_t
350
- # @w_l.getch makes Curses::KEY_DOWN etc not work
351
- # Therefore resorting to the generic method
352
- c = $stdin.getch(min: 0, time: 3)
353
- case c
354
- when "\e" # ANSI escape sequences
355
- return "ESC" if $stdin.ready? == nil
356
- case $stdin.getc
357
- when '[' # CSI
358
- case $stdin.getc
359
- when 'A' then chr = "UP"
360
- when 'B' then chr = "DOWN"
361
- when 'C' then chr = "RIGHT"
362
- when 'D' then chr = "LEFT"
363
- when 'Z' then chr = "S-TAB"
364
- when '2' then chr = "INS" ; chr = "C-INS" if $stdin.getc == "^"
365
- when '3' then chr = "DEL" ; chr = "C-DEL" if $stdin.getc == "^"
366
- when '5' then chr = "PgUP" ; chr = "C-PgUP" if $stdin.getc == "^"
367
- when '6' then chr = "PgDOWN" ; chr = "C-PgDOWN" if $stdin.getc == "^"
368
- when '7' then chr = "HOME" ; chr = "C-HOME" if $stdin.getc == "^"
369
- when '8' then chr = "END" ; chr = "C-END" if $stdin.getc == "^"
370
- else chr = ""
371
- end
372
- when 'O' # Set Ctrl+ArrowKey equal to ArrowKey; May be used for other purposes in the future
373
- case $stdin.getc
374
- when 'a' then chr = "C-UP"
375
- when 'b' then chr = "C-DOWN"
376
- when 'c' then chr = "C-RIGHT"
377
- when 'd' then chr = "C-LEFT"
378
- else chr = ""
379
- end
380
- end
381
- when "", "" then chr = "BACK"
382
- when "" then chr = "C-C"
383
- when "" then chr = "C-D"
384
- when "" then chr = "C-E"
385
- when "" then chr = "C-G"
386
- when " " then chr = "C-K"
387
- when " " then chr = "C-L"
388
- when "" then chr = "C-N"
389
- when "" then chr = "C-O"
390
- when "" then chr = "C-P"
391
- when "" then chr = "C-T"
392
- when "" then chr = "C-Y"
393
- when "" then chr = "WBACK"
394
- when "" then chr = "LDEL"
395
- when "\r" then chr = "ENTER"
396
- when "\t" then chr = "TAB"
397
- when /[[:print:]]/ then chr = c
398
- else chr = ""
399
- end
400
- return chr
401
- end
402
- def main_getkey # GET KEY FROM USER
403
- dir = Dir.pwd
404
- chr = getchr
405
- case chr
406
- # BASIC KEYS
407
- when '?' # Show helptext in right window
408
- @w_r.fg = 249
409
- w_r_info(@help)
410
- @w_b.update = true
411
- when 'r' # Refresh all windows
412
- @break = true
413
- when 'R' # Reload .rtfm.conf
414
- if File.exist?(Dir.home+'/.rtfm.conf')
415
- load(Dir.home+'/.rtfm.conf')
416
- end
417
- w_b_info(" Config reloaded")
418
- when 'W' # Write all parameters to .rtfm.conf
419
- @write_conf_all = true
420
- conf_write
421
- @w_b.update = true
422
- when 'q' # Exit
423
- @tagged = []
424
- @write_conf = true
425
- exit 0
426
- when 'Q' # Exit without writing to .rtfm.conf
427
- @tagged = []
428
- system("printf \"\033]0;#{Dir.pwd}\007\"")
429
- @write_conf = false
430
- exit 0
431
- when 'v'
432
- w_b_info("RTFM version = #{@version} (latest RubyGems version is #{Gem.latest_version_for("rtfm-filemanager").version} - https://github.com/isene/RTFM)")
433
- # MOTION
434
- when 'DOWN', 'j', 'C-DOWN'
435
- var_resets
436
- @index = @index >= @max_index ? @min_index : @index + 1
437
- @w_r.update = true
438
- @w_b.update = true
439
- when 'UP', 'k', 'C-UP'
440
- var_resets
441
- @index = @index <= @min_index ? @max_index : @index - 1
442
- @w_r.update = true
443
- @w_b.update = true
444
- when 'LEFT', 'h', 'C-LEFT'
445
- var_resets
446
- cur_dir = Dir.pwd
447
- @directory[Dir.pwd] = @selected # Store this directory before leaving
448
- mark_latest
449
- Dir.chdir("..")
450
- @directory[Dir.pwd] = File.basename(cur_dir) unless @directory.key?(Dir.pwd)
451
- @w_r.update = true
452
- @w_b.update = true
453
- when 'RIGHT', 'l', 'C-RIGHT'
454
- var_resets
455
- @directory[Dir.pwd] = @selected # Store this directory before leaving
456
- mark_latest
457
- open_selected()
458
- @w_r.update = true
459
- @w_b.update = true
460
- when 'x' # Force open with file opener (used to open HTML files in browser)
461
- var_resets
462
- @directory[Dir.pwd] = @selected # Store this directory before leaving
463
- mark_latest
464
- open_selected(true)
465
- @w_r.update = true
466
- @w_b.update = true
467
- when 'PgDOWN'
468
- var_resets
469
- @index += @w_l.maxy - 2
470
- @index = @max_index if @index > @max_index
471
- @w_r.update = true
472
- @w_b.update = true
473
- when 'PgUP'
474
- var_resets
475
- @index -= @w_l.maxy - 2
476
- @index = @min_index if @index < @min_index
477
- @w_r.update = true
478
- @w_b.update = true
479
- when 'END'
480
- var_resets
481
- @index = @max_index
482
- @w_r.update = true
483
- @w_b.update = true
484
- when 'HOME'
485
- var_resets
486
- @index = @min_index
487
- @w_r.update = true
488
- @w_b.update = true
489
- # JUMPING AND MARKS
490
- when 'm' # Set mark
491
- marks_info
492
- m = $stdin.getc
493
- if m.match(/[\w']/)
494
- @marks[m] = Dir.pwd
495
- elsif m == "-"
496
- r = $stdin.getc
497
- @marks.delete(r)
498
- end
499
- marks_info
500
- @w_r.update = false
501
- @w_b.update = true
502
- when 'M' # Show marks
503
- marks_info
504
- @w_r.update = false
505
- @w_b.update = true
506
- when "'" # Jump to mark
507
- marks_info
508
- m = stdscr.getch.to_s
509
- if m.match(/[\w']/) and @marks[m]
510
- var_resets
511
- @directory[Dir.pwd] = @selected # Store this directory before leaving
512
- dir_before = Dir.pwd
513
- begin
514
- Dir.chdir(@marks[m])
515
- rescue
516
- w_b_info(" No such directory")
517
- end
518
- mark_latest
519
- @marks["'"] = dir_before
520
- end
521
- @w_r.update = true
522
- @w_b.update = true
523
- when '~' # Go to home dir
524
- var_resets
525
- @directory[Dir.pwd] = @selected # Store this directory before leaving
526
- mark_latest
527
- Dir.chdir
528
- @w_r.update = true
529
- @w_b.update = true
530
- when '>' # Follow symlink
531
- @directory[Dir.pwd] = @selected # Store this directory before leaving
532
- mark_latest
533
- if File.symlink?(@selected)
534
- begin
535
- Dir.chdir(File.dirname(File.readlink(@selected)))
536
- rescue
537
- end
538
- end
539
- @w_b.update = true
540
- # SEARCHING
541
- when '/' # Get search string to mark items that match the input
542
- @w_b.nohistory = true
543
- @searched = w_b_getstr("/ ", "")
544
- l = `ls 2>/dev/null #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}`.split
545
- m = l.each_index.select{|n| l[n] =~ /#{@searched}/}
546
- @index = m[0] unless m == []
547
- @index = 0 if @searched == ""
548
- @w_r.update = true
549
- when '\\' # Clear search string
550
- @searched = ""
551
- when 'n' # Jump to next occurence of search (after '/')
552
- l = `ls 2>/dev/null #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}`.split
553
- m = l.each_index.select{|n| l[n] =~ /#{@searched}/}
554
- i = m.find { |n| n > @index }
555
- if i == nil
556
- @index = m.first
557
- else
558
- @index = i
559
- end
560
- @w_r.update = true
561
- when 'N' # Jump to previous occurence of search (after '/')
562
- l = `ls 2>/dev/null #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}`.split
563
- m = l.each_index.select{|n| l[n] =~ /#{@searched}/}.reverse
564
- i = m.find { |n| n < @index }
565
- if i == nil
566
- @index = m.first
567
- else
568
- @index = i
569
- end
570
- @w_r.update = true
571
- when 'g' # Run 'grep' in the current directory
572
- cmd = w_b_getstr(": ", "grep -s MATCH *")
573
- w_b_exec(cmd)
574
- when 'L' # Run 'locate' and let user jump to a result (by '#')
575
- cmd = w_b_getstr(": ", "locate ")
576
- w_b_exec(cmd)
577
- @w_r.locate = true
578
- @w_b.update = true
579
- when '#' # Jump to the line number in list of matches to 'locate'
580
- if @w_r.locate
581
- jumpnr = w_b_getstr("# ", "").to_i
582
- jumpline = @w_r.text.lines[jumpnr - 1]
583
- jumpdir = jumpline[/\/[^\e]*/]
584
- unless Dir.exist?(jumpdir)
585
- @searched = File.basename(jumpdir)
586
- jumpdir = File.dirname(jumpdir)
587
- end
588
- @directory[Dir.pwd] = @selected # Store this directory before leaving
589
- mark_latest
590
- Dir.chdir(jumpdir)
591
- @w_r.pager = 0
592
- end
593
- @w_b.update = true
594
- when 'C-L' # fzf integration (https://github.com/junegunn/fzf)
595
- begin
596
- jump = `fzf`.chomp
597
- rescue
598
- 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
599
1612
  end
600
- jumpdir = File.dirname(jump)
601
- @searched = File.basename(jump)
602
- @directory[Dir.pwd] = @selected # Store this directory before leaving
603
- mark_latest
604
- Dir.chdir(jumpdir)
605
- @break = true
606
- # TAGGING
607
- when 't' # Add item to tagged list
608
- item = "\"#{Dir.pwd}/#{@selected}\""
609
- if @tagged.include?(item)
610
- @tagged.delete(item)
611
- @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)
612
1626
  else
613
- @tagged.push(item)
614
- @tagsize += File.size(item[1...-1])
1627
+ showcontent
615
1628
  end
616
- @index += 1
617
- @w_r.update = true
618
- @w_b.update = true
619
- when 'C-T' # Tag items matching a pettern
620
- @w_b.nohistory = true
621
- @tag = w_b_getstr("~ ", "")
622
- @w_r.update = true
623
- @w_b.update = true
624
- when 'T' # Show tagged list
625
- @w_r.fg = 196
626
- tagged_info
627
- @w_r.update = false
628
- @w_b.update = true
629
- when 'u' # Clear tagged list
630
- @tagged = []
631
- tagged_info
632
- @w_r.update = false
633
- @w_b.update = true
634
- # MANIPULATE ITEMS
635
- when 'p' # Copy tagged items here
636
- copy_move_link("copy")
637
- @w_r.update = true
638
- when 'P' # Move tagged items here
639
- copy_move_link("move")
640
- @w_r.update = true
641
- when 's' # Create symlink to tagged items here
642
- copy_move_link("link")
643
- @w_r.update = true
644
- when 'd' # Delete items tagged and @selected
645
- tagged_info
646
- w_b_info(" Delete selected and tagged? (press 'y' to delete)")
647
- begin
648
- @tagged.push("\"#{Dir.pwd}/#{@selected}\"")
649
- @tagged.uniq!
650
- deletes = @tagged.join(" ")
651
- if $stdin.getc == 'y'
652
- `rm -rf #{deletes} 2>/dev/null`
653
- items_number = @tagged.length
654
- @tagged = []
655
- w_b_info("Deleted #{items_number} items: #{deletes}")
656
- else
657
- @w_b.update = true
658
- end
659
- @w_r.update = true
660
- rescue StandardError => err
661
- 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)
662
1639
  end
663
- when 'c' # Change/rename selected @selected
664
- @selected.length > 12 ? source = @selected[0...12] + "…" : source = @selected
665
- cmd = w_b_getstr(": ", "mv \"#{source}\" \"#{@selected}\"")
666
- el1 = @selected_safe
667
- el2 = cmd.split(' "').last[0..-2]
668
- cmd = "mv #{el1} \"#{el2}\""
1640
+ # File attributes
1641
+ text += " (#{@fileattr})" if defined?(@fileattr)
1642
+ # Image or PDF metadata
669
1643
  begin
670
- if el1 == el2
671
- w_b_info(" Source and target are the same. No action done.")
672
- else
673
- 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
674
1656
  end
675
- rescue StandardError => err
676
- w_b_info(err.to_s)
677
- end
678
- @w_r.update = false
679
- when 'C-O' # Change ownerships
680
- require 'etc'
681
- gnm = Etc.getgrgid(File.stat(@selected).gid).name
682
- unm = Etc.getpwuid(File.stat(@selected).uid).name
683
- p = "Change ownership of selected"
684
- p += " and tagged" unless @tagged.empty?
685
- p += ". Selected ownership (user:group) = "
686
- own = w_b_getstr(p, "#{unm}:#{gnm}").split(":")
687
- gnm, unm = own[0], own[1]
688
- begin
689
- gid = Etc.getgrnam(gnm).gid.to_i
690
- uid = Etc.getpwnam(unm).uid.to_i
691
- File.chown(uid, gid, @selected)
692
- @tagged.each {|t| File.chown(uid, gid, t[1...-1])} unless @tagged.empty?
693
- w_b_info("Ownership changed to #{gnm}:#{unm}")
694
- rescue StandardError => err
695
- w_b_info(err.to_s)
696
- end
697
- when 'C-P' # Change permissions
698
- mode = @fspes[@index][1..9]
699
- p = "Change permissions of selected"
700
- p += " and tagged" unless @tagged.empty?
701
- p += ". Selected permissions = "
702
- mode = w_b_getstr(p, mode)
703
- mode = mode * 3 if mode.length == 3 and mode.to_i == 0
704
- if mode.length == 9 and mode.to_i == 0
705
- x = 0
706
- x += 400 if mode[0] == "r"
707
- x += 200 if mode[1] == "w"
708
- x += 100 if mode[2] == "x"
709
- x += 40 if mode[3] == "r"
710
- x += 20 if mode[4] == "w"
711
- x += 10 if mode[5] == "x"
712
- x += 4 if mode[6] == "r"
713
- x += 2 if mode[7] == "w"
714
- x += 1 if mode[8] == "x"
715
- mode = x
1657
+ rescue Errno::ENOENT, Errno::EACCES
1658
+ # ignore missing or permission errors
716
1659
  end
717
- if mode.to_s.length == 3 and mode.to_i != 0
718
- mode = mode.to_s.to_i(8)
1660
+ # Directory children count
1661
+ if @selected && Dir.exist?(@selected)
719
1662
  begin
720
- File.chmod(mode, @selected)
721
- @tagged.each {|t| File.chmod(mode, t[1...-1])} unless @tagged.empty?
722
- rescue StandardError => err
723
- w_b_info(err.to_s)
724
- end
725
- else
726
- w_b_info("Not a valid permissions mode. Nothing changed.")
727
- end
728
- when 'z' # Unzip selected archive file
729
- cmd = w_b_getstr("Command = ", "tar xfz #{@tagged.first}")
730
- begin
731
- w_b_exec(cmd + " 2>/dev/null")
732
- rescue StandardError => err
733
- w_b_info(err.to_s)
734
- end
735
- when 'Z' # Create archive file
736
- arc = w_b_getstr("Archive name: ", "")
737
- cmd = w_b_getstr("Command = ", "tar cfz #{arc}.gz #{@tagged.join(" ")}")
738
- begin
739
- w_b_exec(cmd + " 2>/dev/null")
740
- rescue StandardError => err
741
- w_b_info(err.to_s)
742
- end
743
- # DIRECTORY VIEWS
744
- when 'a' # Show all items
745
- @lsall == "" ? @lsall = "-a" : @lsall = ""
746
- @w_r.update = true
747
- @w_b.update = true
748
- when 'A' # Show all info for all items
749
- @lslong = !@lslong
750
- @w_r.update = true
751
- @w_b.update = true
752
- when 'o' # Circular toggle the order/sorting of directory views
753
- case @lsorder
754
- when ""
755
- @lsorder = "-S"
756
- w_b_info(" Sorting by size, largest first")
757
- when "-S"
758
- @lsorder = "-t"
759
- w_b_info(" Sorting by modification time")
760
- when "-t"
761
- @lsorder = "-X"
762
- w_b_info(" Sorting by extension (alphabetically)")
763
- when "-X"
764
- @lsorder = ""
765
- w_b_info(" Normal sorting")
766
- end
767
- @w_r.update = true
768
- @orderchange = true
769
- when 'i' # Invert the order/sorting of directory views
770
- case @lsinvert
771
- when ""
772
- @lsinvert = "-r"
773
- w_b_info(" Sorting inverted")
774
- when "-r"
775
- @lsinvert = ""
776
- w_b_info(" Sorting NOT inverted")
777
- end
778
- @w_r.update = true
779
- @orderchange = true
780
- when 'O' # Show the Ordering in the bottom window (the full ls command)
781
- w_b_info(" Full 'ls' command: ls <@s> #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}")
782
- when 'G' # Git status for selected item or current dir
783
- @w_r.fg = 214
784
- if File.exist?(".git")
785
- w_r_info(`git status 2>/dev/null`)
786
- else
787
- w_r_info("This is not a git repository.")
788
- end
789
- @w_r.update = false
790
- @w_b.update = true
791
- when 'H' # Compare with previous hash status or write hash status if no existing hash
792
- @w_r.fg = 213
793
- hashcmd = "\(find #{Dir.pwd} -type f -print0 | sort -z | xargs -0 sha1sum; find #{Dir.pwd}"\
794
- " \\( -type f -o -type d \\) -print0 | sort -z | xargs -0 stat -c '%n %a'\) | sha1sum | cut -c -40"
795
- begin
796
- hashdir = `#{hashcmd}`.chomp
797
- rescue StandardError => e
798
- w_r_info("Error: #{e.inspect}")
799
- end
800
- hashtime = DateTime.now.strftime "%Y-%m-%d %H:%M"
801
- if @hash.include?(Dir.pwd)
802
- if @hash[Dir.pwd][1] == hashdir
803
- w_b_info(" Hash for #{Dir.pwd} has NOT changed since #{hashtime} (#{hashdir})")
804
- else
805
- w_b_info(" Hash for #{Dir.pwd} has CHANGED since #{hashtime} (#{@hash[Dir.pwd][1]} -> #{hashdir})")
806
- @hash[Dir.pwd] = [hashtime, hashdir]
807
- end
808
- else
809
- hashtime = DateTime.now.strftime "%Y-%m-%d %H:%M"
810
- @hash[Dir.pwd] = [hashtime, hashdir]
811
- w_b_info(" New hash for #{Dir.pwd}: #{hashtime}: #{hashdir}")
812
- end
813
- @w_r.update = true
814
- @w_b.update = false
815
- when 'I' # OpenAI integration
816
- if @ai
817
- openai
818
- else
819
- w_b_info("No OpenAI key in config file. Add `@ai = 'your-secret-openai-key'` to .rtfm.conf")
820
- end
821
- # RIGHT PANE
822
- when 'ENTER' # Refresh right pane
823
- @w_r.fill # First clear the window, then clear any previously showing image
824
- image_show("clear") if @image; @image = false
825
- @w_r.update = true
826
- @w_b.update = true
827
- when 'TAB' # Start paging/Down one page
828
- if @w_r.pager == 1 and @w_r.pager_cmd != ""
829
- @w_r.text = `#{@w_r.pager_cmd} 2>/dev/null`
830
- end
831
- if @w_r.pager_more
832
- @w_r.pager += 1
833
- pager_show
834
- end
835
- @w_b.update = true
836
- when 'S-TAB' # Up one page
837
- if @w_r.pager > 1
838
- @w_r.pager -= 1
839
- pager_show
840
- end
841
- @w_b.update = true
842
- when 'w' # Change width of left/right panes
843
- @width += 1
844
- @width = 2 if @width == 7
845
- @break = true
846
- @w_r.update = true
847
- @w_b.update = true
848
- when '-' # Toggle content view in right pane
849
- @preview = !@preview
850
- @preview ? p = "On" : p = "Off"
851
- w_b_info("Preview = " + p)
852
- getch
853
- @break = true
854
- when '_' # Toggle image view
855
- @showimage = !@showimage
856
- @showimage ? i = "On" : i = "Off"
857
- w_b_info("Image preview = " + i)
858
- getch
859
- @break = true
860
- when 'b' # Toggle syntax highlighting (via bat/batcat)
861
- @batuse = !@batuse
862
- @break = true
863
- # ADDITIONAL COMMANDS
864
- when 'f' # Filter out filetypes not matching @lsfiles
865
- loop do
866
- @lsfiles = w_b_getstr("Filetype(s) to show: ", @lsfiles)
867
- break if @lsfiles.match(/^$|\w+(,\w+)*$/)
868
- end
869
- w_b_info(nil)
870
- when 'F' # Filter out files not matching @lsmatch
871
- @lsmatch = w_b_getstr("Files will match RegEx: ", @lsmatch)
872
- w_b_info(nil)
873
- when 'B' # Toggle borders
874
- @border = !@border
875
- @break = true
876
- when ':' # Enter "command mode" in the bottom window - tries to execute the given command
877
- @w_r.nohistory = false
878
- cmd = w_b_getstr(": ", "")
879
- w_b_exec(cmd)
880
- @w_b.update = true
881
- when ';' # Show command history
882
- w_r_info("Command history (latest on top):\n\n" + @history.join("\n"))
883
- @w_b.update = true
884
- when 'y', 'Y' # Copy path of selected item
885
- if @selected == nil
886
- w_b_info(" No selected item path to copy")
887
- else
888
- path = Dir.pwd + "/" + @selected
889
- if chr == 'Y'
890
- clip = "xclip -selection clipboard"
891
- w_b_info(" Path copied to clipboard")
892
- else
893
- clip = "xclip"
894
- 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]'
895
1667
  end
896
- system("echo -n '#{path}' | #{clip}")
897
1668
  end
898
- when 'C-Y' # Copy right pane to clipboard
899
- clip = "xclip -selection clipboard"
900
- @cont.gsub!(/ ¤¤\d+¤¤\d*¤¤/, '')
901
- @cont.gsub!(/^¤¤\d+¤¤\d*¤¤ */, '')
902
- @cont.gsub!(/¤¤\d+¤¤\d*¤¤/, '')
903
- @cont.gsub!(/ /, '')
904
- @cont.gsub!(/ (\d)/, '\1')
905
- @cont.gsub!(/\[(\d+;)+\d+m/, '')
906
- @cont = @cont.inspect
907
- @cont.gsub!('\"', '"')
908
- @cont = @cont [1...-1]
909
- w_b_info(" Right pane copied to clipboard")
910
- system("echo -n '#{@cont}' | #{clip}")
911
- when 'S' # Show comprehensive system info
912
- sysinfo
913
- when 'C-D' # Create new directory (shortcut for ":mkdir ")
914
- cmd = w_b_getstr(": ", "mkdir ")
915
- w_b_exec(cmd + " -p")
916
- when 'C-N' # navi integration (https://github.com/denisidoro/navi)
917
- begin
918
- @navi = `navi`
919
- rescue
920
- w_b_info(" navi not installed - see https://github.com/denisidoro/navi")
921
- end
922
- @break = true
923
- when '@' # Enter "Ruby debug"
924
- @w_b.nohistory = false
925
- cmd = w_b_getstr("◆ ", "", true)
926
- @w_b.clr
927
- @w_b.refresh
928
- @w_b.update = true
929
- @w_r.clr
930
- w_r_info("Command: #{cmd}\n")
931
- begin
932
- eval(cmd).to_s
933
- rescue Exception => err
934
- w_r_info("Error: #{err.inspect}")
935
- end
936
- @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
937
1672
  end
938
- if @w_r.update == true
939
- @w_r.locate = false
940
- @w_r.pager = 0
941
- @w_r.pager_more = false
942
- end
943
- begin
944
- @w_r.update = true if dir != Dir.pwd
945
- 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
946
1684
  end
947
1685
  end
948
- def conf_write
949
- if File.exist?(Dir.home+'/.rtfm.conf')
950
- conf = File.read(Dir.home+'/.rtfm.conf')
951
- else
952
- conf = ""
953
- end
954
- conf.sub!(/^@marks.*{.*}\n/, "")
955
- conf += "@marks = #{@marks}\n"
956
- conf.sub!(/^@hash.*{.*}\n/, "")
957
- conf += "@hash = #{@hash}\n"
958
- conf.sub!(/^@tagged.*\[.*\]\n/, "")
959
- conf += "@tagged = #{@tagged}\n"
960
- conf.sub!(/^@history.*\[.*\]\n/, "")
961
- conf += "@history = #{@history}\n"
962
- conf.sub!(/^@rubyhistory.*\[.*\]\n/, "")
963
- conf += "@rubyhistory = #{@rubyhistory}\n"
964
- if @write_conf_all
965
- conf.sub!(/^@lslong.*\n/, "")
966
- conf += "@lslong = #{@lslong}\n"
967
- conf.sub!(/^@lsall.*\n/, "")
968
- conf += "@lsall = \"#{@lsall}\"\n"
969
- conf.sub!(/^@lsorder.*\n/, "")
970
- conf += "@lsorder = \"#{@lsorder}\"\n"
971
- conf.sub!(/^@lsinvert.*\n/, "")
972
- conf += "@lsinvert = \"#{@lsinvert}\"\n"
973
- conf.sub!(/^@width.*\n/, "")
974
- conf += "@width = #{@width}\n"
975
- conf.sub!(/^@border.*\n/, "")
976
- conf += "@border = #{@border}\n"
977
- conf.sub!(/^@preview.*\n/, "")
978
- conf += "@preview = #{@preview}\n"
979
- conf.sub!(/^@showimage.*\n/, "")
980
- conf += "@showimage = #{@showimage}\n"
981
- @w_r.fg = 249
982
- w_r_info("Configuration written to .rtfm.conf:\n\n" + conf)
983
- end
984
- File.write(Dir.home+'/.rtfm.conf', conf)
985
- end
986
- def ansifix(text) # Format [[fg, attr, text]]
987
- output = ""
988
- text.each do |e|
989
- ansi = "\e[38;5;#{e[0]}"
990
- ansi += ";1" if e[1] == 1
991
- ansi += "m"
992
- output += ansi + e[2].gsub(/\n/, "\n#{ansi}")
993
- end
994
- return output
995
- end
996
- def mark_latest
997
- @marks["5"] = @marks["4"]
998
- @marks["4"] = @marks["3"]
999
- @marks["3"] = @marks["2"]
1000
- @marks["2"] = @marks["1"]
1001
- @marks["1"] = @marks["'"]
1002
- @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
1003
1707
  end
1004
- def get_files(win) # The core of the directory listings
1005
- ls_cmd = "ls 2>/dev/null #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}" # Get files in current directory
1006
- ls_cmd += @selected_safe if win == "right"
1007
- @cfiles = `#{ls_cmd} --color`.split("\n")
1008
- @files = @cfiles.map {|f| f.sub(/^.*\d+m(.+)\e\[0m/, '\1').gsub(/\e\[K/, "")}
1009
- ls_cmd += " -H " if win == "right"
1010
- ls_cmd += %q[ -lh --time-style="long-iso" | awk '{printf "%s%s%s%11s%6s%6s", $1, " " $3, ":" $4,$6,$7,$5"\n"}']
1011
- @fspes = `#{ls_cmd}`.split("\n").drop(1)
1012
- if @lsfiles != "" or @lsmatch != ""
1013
- lsf = @lsfiles.split(",").map! {|e| e.strip}
1014
- dir_cmd = "ls 2>/dev/null -d "
1015
- dir_cmd += @selected_safe + "/" if win == "right"
1016
- dir_cmd += "*/"
1017
- dirs = `#{dir_cmd}`.split("/\n")
1018
- dirs.map!{|d| d.sub!(/.*\//, '')} if win == "right"
1019
- @files = @files - dirs
1020
- @fspes = @fspes - dirs
1021
- @files.select! {|f| lsf.any? {|l| File.extname(f) == ".#{l}"}} if @lsfiles != ""
1022
- @fspes.select! {|f| lsf.any? {|l| File.extname(f) == ".#{l}"}} if @lsfiles != ""
1023
- @files.select! {|f| f =~ /#{@lsmatch}/} if @lsmatch != ""
1024
- @fspes.select! {|f| f =~ /#{@lsmatch}/} if @lsmatch != ""
1025
- @files = dirs + @files
1026
- @fspes = dirs + @fspes
1027
- @w_r.update = true
1028
- end
1029
- end
1030
- # TOP WINDOW FUNCTIONS
1031
- def w_t_info # SHOW INFO IN @w_t
1032
- text = " " + ENV['USER'].to_s + "@" + `hostname 2>/dev/null`.to_s.chop + ": " + Dir.pwd + "/"
1033
- unless @selected == nil
1034
- text += @selected
1035
- text += " → #{File.readlink(@selected)}" if File.symlink?(@selected)
1036
- end
1037
- begin
1038
- text += " (#{@fspes[@index]})"
1039
- rescue
1040
- end
1041
- begin
1042
- if @selected.match(@imagefile)
1043
- text += `identify #{@selected_safe} | awk '{printf " [%s %s %s %s] ", $3,$2,$5,$6}' 2>/dev/null` if cmd?('identify')
1044
- elsif @selected.match(@pdffile)
1045
- info = `pdfinfo #{@selected_safe} 2>/dev/null`
1046
- text += " [" + info.match(/Pages:.*?(\d+)/)[1]
1047
- text += " " + info.match(/Page size:.*\((.*)\)/)[1] + " pages] "
1048
- end
1049
- 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
1050
1723
  end
1051
- if Dir.exist?(@selected.to_s)
1052
- begin
1053
- text += " [" + Dir.glob(@selected+"/*").count.to_s + " " + Dir.children(@selected).count.to_s + "]"
1054
- rescue
1055
- 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
1056
1750
  end
1751
+ File.delete(tmp)
1057
1752
  end
1058
- text = text[1..(@w_t.maxx - 3)] + "…" if text.length + 3 > @w_t.maxx
1059
- text += " " * (@w_t.maxx - text.length) if text.length < @w_t.maxx
1060
- @w_t.clr
1061
- @w_t.text = text
1062
- @w_t.write
1063
- end
1064
- # LEFT WINDOW FUNCTIONS
1065
- def list_dir(active) # LIST CONTENT OF A DIRECTORY (BOTH active AND RIGHT WINDOWS)
1066
- ix = 0; t = 0
1067
- if active
1068
- win = @w_l
1069
- ix = @index - @w_l.maxy/2 if @index > @w_l.maxy/2 and @files.size > @w_l.maxy - 1
1070
- else
1071
- win = @w_r
1072
- @cont = ""
1073
- end
1074
- while ix < @files.size and t < win.maxy do
1075
- str = @files[ix]
1076
- cstr = @cfiles[ix]
1077
- active ? str_path = str : str_path = "#{@selected}/#{str}"
1078
- begin # Add items matching @tag to @tagged
1079
- if str.match(/#{@tag}/) and @tag != false
1080
- @tagged.push("\"#{Dir.pwd}/#{str}\"")
1081
- @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
1082
1796
  end
1083
- rescue
1797
+ # Ensure we've captured all output
1798
+ out_reader.join
1799
+ err_reader.join
1084
1800
  end
1085
- fg = 7; bold = 0; bg = 0 # Set default color
1086
- fl = color_parse(cstr)
1087
- fg, bold = fl[0][0].to_i, fl[0][1].to_i unless fl[0] == nil
1088
- init_pair(fg, fg, bg)
1089
- file_marker = color_pair(fg)
1090
- file_marker = file_marker | Curses::A_BOLD if bold == 1
1091
- if ix == @index and active
1092
- @w_l << "→ "
1093
- file_marker = file_marker | Curses::A_UNDERLINE
1094
- wixy = win.cury
1801
+ if return_both
1802
+ [out_buf, err_buf]
1095
1803
  else
1096
- active ? @w_l << " " : @w_r << " "
1804
+ @pR.say(err_buf.fg(196)) unless err_buf.empty?
1805
+ out_buf
1097
1806
  end
1098
- file_marker = file_marker | Curses::A_REVERSE if @tagged.include?("\"#{Dir.pwd}/#{str_path}\"")
1099
- file_marker = file_marker | Curses::A_BLINK if str.match(/#{@searched}/) and @searched != ""
1100
- File.directory?(str_path) ? dir = "/" : dir = ""
1101
- File.symlink?(str_path) ? link = "@" : link = ""
1102
- str = @fspes[ix] + " " + str if @lslong
1103
- if str.length > win.maxx - 4
1104
- base_name = File.basename(str, ".*")
1105
- base_length = base_name.length
1106
- ext_name = File.extname(str)
1107
- ext_length = ext_name.length
1108
- nbl = win.maxx - 6 - ext_length # nbl: new_base_length
1109
- nbl -= 1 if dir != "" # Account for one extra character
1110
- nbl -= 1 if link != "" # Account for one extra character
1111
- str = base_name[0..nbl] + "…" + ext_name
1112
- end
1113
- if !active and ix == win.maxy - 1 # Add indicator of more at bottom @w_r list
1114
- win << " ..."
1115
- return
1116
- end
1117
- str += link + dir
1118
- @cont += str + "\n" unless active # Adds to content in right win for copying
1119
- win.attron(file_marker) { win << str } # Implement color/bold to the item
1120
- win.clrtoeol
1121
- win << "\n"
1122
- ix += 1; t += 1
1123
- end
1124
- (win.maxy - win.cury).times {win.deleteln()} # Clear to bottom of window
1125
- if active
1126
- init_pair(242, 242, 0)
1127
- if @index > @w_l.maxy/2
1128
- @w_l.setpos(0, @w_l.maxx - 1)
1129
- @w_l.attron(color_pair(242) | Curses::A_DIM) { @w_l << "∆" }
1130
- end
1131
- if @files.length > @w_l.maxy - 1 and @files.length > @index + @w_l.maxy/2 - 1
1132
- @w_l.setpos(@w_l.maxy - 2, @w_l.maxx - 1)
1133
- @w_l.attron(color_pair(242) | Curses::A_DIM) { @w_l << "∇" }
1134
- end
1135
- end
1136
- end
1137
- def open_selected(html = nil) # OPEN SELECTED ITEM (when pressing RIGHT)
1138
- if File.directory?(@selected) # Rescue for permission error
1139
- begin
1140
- mark_latest
1141
- Dir.chdir(@selected)
1142
- 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
+ ''
1143
1815
  end
1144
- else
1145
- begin
1146
- if File.read(@selected).force_encoding("UTF-8").valid_encoding? and not html
1147
- system("exec $EDITOR #{@selected_safe}")
1148
- else
1149
- if @runmailcap
1150
- system("run-mailcap #{@selected_safe} 2>/dev/null &")
1151
- else
1152
- system("xdg-open #{@selected_safe} 2>/dev/null &")
1153
- end
1154
- end
1155
- @break = true
1156
- 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
+ ''
1157
1823
  end
1158
1824
  end
1159
1825
  end
1160
- 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
1161
1828
  @tagged.uniq!
1162
- @tagged.each do | item |
1163
- item = item[1..-2]
1164
- dest = Dir.pwd
1165
- dest += "/" + File.basename(item)
1166
- 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)
1167
1832
  while File.exist?(dest)
1833
+ # Replace the last character (presumed to be a digit) by incrementing it
1168
1834
  dest = dest.chop + (dest[-1].to_i + 1).to_s
1169
1835
  end
1170
1836
  begin
1171
1837
  case type
1172
- when "copy"
1838
+ when 'copy'
1173
1839
  FileUtils.cp_r(item, dest)
1174
- w_b_info(" Item(s) copied here.")
1175
- when "move"
1840
+ @pB.say(' Item(s) copied here.')
1841
+ when 'move'
1176
1842
  FileUtils.mv(item, dest)
1177
- w_b_info(" Item(s) moved here.")
1178
- when "link"
1843
+ @pB.say(' Item(s) moved here.')
1844
+ when 'link'
1179
1845
  FileUtils.ln_s(item, dest)
1180
- w_b_info(" Item(s) symlinked here.")
1846
+ @pB.say(' Item(s) symlinked here.')
1181
1847
  end
1182
- rescue Exception => err
1183
- w_b_info(err.to_s)
1848
+ rescue => e
1849
+ @pB.say(e.to_s)
1184
1850
  end
1185
1851
  end
1186
1852
  @tagged = []
1853
+ render
1187
1854
  end
1188
- # RIGHT WINDOW FUNCTIONS
1189
- def w_r_show # SHOW CONTENTS IN THE RIGHT WINDOW
1190
- if @w_r.update
1191
- @w_r.clr # First clear the window, then clear any previously showing image
1192
- image_show("clear") if @image; @image = false
1193
- end
1194
- begin # Determine the specific programs to open/show content
1195
- if @w_r.pager > 0
1196
- pager_show
1197
- elsif File.directory?(@selected)
1198
- get_files("right")
1199
- list_dir(false)
1200
- # TEXT
1201
- elsif File.read(@selected).force_encoding("UTF-8").valid_encoding? and @w_r.pager == 0
1202
- if @batuse
1203
- begin # View the file as text if it is utf-8
1204
- @w_r.pager_cmd = "#{@bat} -n --color=always #{@selected_safe} 2>/dev/null"
1205
- @w_r.text = `#{@bat} -n --color=always --line-range :#{@w_r.maxy} #{@selected_safe} 2>/dev/null`
1206
- pager_start
1207
- syntax_highlight(@w_r.text)
1208
- rescue
1209
- @w_r.pager_cmd = "cat #{@selected_safe} 2>/dev/null"
1210
- w_r_doc
1211
- end
1212
- else
1213
- @w_r.pager_cmd = "cat #{@selected_safe} 2>/dev/null"
1214
- w_r_doc
1215
- end
1216
- # PDF
1217
- elsif @selected.match(@pdffile) and @w_r.pager == 0
1218
- @w_r.pager_cmd = "pdftotext #{@selected_safe} - 2>/dev/null | less"
1219
- @w_r.text = `pdftotext -f 1 -l 4 #{@selected_safe} - 2>/dev/null`
1220
- pager_start
1221
- @w_r << @w_r.text
1222
- # OPEN/LIBREOFFICE
1223
- elsif @selected.match(@oolofile) and @w_r.pager == 0
1224
- @w_r.pager_cmd = "odt2txt #{@selected_safe} 2>/dev/null"
1225
- w_r_doc
1226
- # MS DOCX
1227
- elsif @selected.match(@docxfile) and @w_r.pager == 0
1228
- @w_r.pager_cmd = "docx2txt #{@selected_safe} - 2>/dev/null"
1229
- w_r_doc
1230
- # MS XLSX
1231
- elsif @selected.match(@xlsxfile) and @w_r.pager == 0
1232
- @w_r.pager_cmd = "ssconvert -O 'separator= ' -T Gnumeric_stf:stf_assistant #{@selected_safe} fd://1 2>/dev/null"
1233
- w_r_doc
1234
- # MS PPTX
1235
- elsif @selected.match(@pptxfile) and @w_r.pager == 0
1236
- @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]
1237
- w_r_doc
1238
- # MS DOC
1239
- elsif @selected.match(@docfile) and @w_r.pager == 0
1240
- @w_r.pager_cmd = "catdoc #{@selected_safe} 2>/dev/null"
1241
- w_r_doc
1242
- # MS XLS
1243
- elsif @selected.match(@xlsfile) and @w_r.pager == 0
1244
- @w_r.pager_cmd = "xls2csv #{@selected_safe} 2>/dev/null"
1245
- w_r_doc
1246
- # MS PPT
1247
- elsif @selected.match(@pptfile) and @w_r.pager == 0
1248
- @w_r.pager_cmd = "catppt #{@selected_safe} 2>/dev/null"
1249
- w_r_doc
1250
- # IMAGES
1251
- elsif @selected.match(@imagefile)
1252
- image_show(@selected_safe)
1253
- @image = true
1254
- # VIDEOS (THUMBNAILS)
1255
- elsif @selected.match(/\.mpg$|\.mpeg$|\.avi$|\.mov$|\.mkv$|\.mp4$/)
1256
- begin
1257
- tmpfile = "/tmp/" + File.basename(@selected_safe,".*")
1258
- `ffmpegthumbnailer -s 1200 -i #{@selected_safe} -o /tmp/rtfm_video_tn.jpg 2>/dev/null`
1259
- image_show("/tmp/rtfm_video_tn.jpg")
1260
- @image = true
1261
- rescue
1262
- 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"
1263
1894
  end
1264
- rescue
1265
1895
  end
1266
- pager_add_markers # Add page markers, up and/or down
1267
- @w_r.update = false
1268
- @w_r.refresh
1896
+ show_config
1897
+ @pB.say('Configuration written to ~/.rtfm/conf')
1898
+ File.write(CONFIG_FILE, @conf)
1269
1899
  end
1270
- def w_r_doc # GET FULL CONTENT TO PAGE
1271
- @w_r.text = `#{@w_r.pager_cmd} 2>/dev/null`
1272
- pager_start
1273
- @w_r << @w_r.text
1274
- end
1275
- 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')
1276
1916
  begin
1277
- image_show("clear") if @image; @image = false
1278
- @w_r.clr
1279
- @w_r.refresh
1280
- w_r_width = Curses.cols - (Curses.cols * @width / 10) - 2
1281
- info.gsub!(/(.{1,#{w_r_width}})( +|$\n?)|(.{1,#{w_r_width}})/, "\\1\\3\n")
1282
- @w_r.text = info
1283
- @w_r.pager_cmd = ""
1284
- pager_start
1285
- pager_show
1286
- @w_r.update = false
1287
- rescue
1917
+ File.write(last, Dir.pwd)
1918
+ rescue StandardError
1919
+ # ignore write errors
1288
1920
  end
1921
+ exit(0)
1289
1922
  end
1290
- def marks_info # SHOW MARKS IN RIGHT WINDOW
1291
- @w_r.fg = 183
1292
- @marks = @marks.sort.to_h
1293
- info = "MARKS:\n"
1294
- unless @marks.empty?
1295
- @marks.each do |mark, dir|
1296
- 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
1297
1985
  end
1298
- else
1299
- info += "(none)"
1986
+ rescue StandardError => e
1987
+ errormsg("⚠ Error while previewing #{@selected}", e)
1300
1988
  end
1301
- w_r_info(info)
1302
- end
1303
- def tagged_info # SHOW THE LIST OF TAGGED ITEMS IN @w_r
1304
- info = "TAGGED: (#{@tagged.size} items)\n"
1305
- @tagged.empty? ? info += "(None)" : info += @tagged.join("\n")
1306
- w_r_info(info)
1989
+ @pR.update = false
1307
1990
  end
1308
- def image_show(image)# SHOW THE SELECTED IMAGE IN THE RIGHT WINDOW
1309
- # 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
1310
1994
  return unless @showimage
1995
+
1311
1996
  begin
1312
1997
  terminfo = `xwininfo -id $(xdotool getactivewindow 2>/dev/null) 2>/dev/null`
1313
1998
  term_w = terminfo.match(/Width: (\d+)/)[1].to_i
1314
1999
  term_h = terminfo.match(/Height: (\d+)/)[1].to_i
1315
- char_w = term_w / Curses.cols
1316
- char_h = term_h / Curses.lines
1317
- 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
1318
2003
  img_y = char_h * 2
1319
- img_max_w = char_w * (Curses.cols - Curses.cols * @width / 10 - 2)
1320
- img_max_h = char_h * (Curses.lines - 4)
1321
- 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'
1322
2007
  `clear`
1323
2008
  img_x -= char_w
1324
2009
  img_max_w += char_w + 2
1325
2010
  img_max_h += 2
1326
2011
  `echo "6;#{img_x};#{img_y};#{img_max_w};#{img_max_h};\n4;\n3;" | #{@imgdisplay} 2>/dev/null`
1327
2012
  else
1328
- img_w,img_h = `identify -format "%[fx:w]x%[fx:h]" #{image} 2>/dev/null`.split('x')
1329
- img_w = img_w.to_i
1330
- 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
1331
2016
  if img_w > img_max_w
1332
2017
  img_h = img_h * img_max_w / img_w
1333
2018
  img_w = img_max_w
@@ -1339,433 +2024,158 @@ def image_show(image)# SHOW THE SELECTED IMAGE IN THE RIGHT WINDOW
1339
2024
  `echo "0;1;#{img_x};#{img_y};#{img_w};#{img_h};;;;;\"#{image}\"\n4;\n3;" | #{@imgdisplay} 2>/dev/null`
1340
2025
  end
1341
2026
  rescue
1342
- @w_r.clr
1343
- @w_r << "Error showing image"
2027
+ @pR.text = 'Error showing image'
1344
2028
  end
1345
2029
  end
1346
- def pager_start # START PAGING
1347
- @w_r.pager = 1
1348
- if @w_r.text.lines.count > @w_r.maxy - 2
1349
- @w_r.pager_more = true
1350
- end
1351
- @cont = @w_r.text # For copying right win
1352
- end
1353
- def pager_show # SHOW THE CURRENT PAGE CONTENT
1354
- @w_r.setpos(0,0)
1355
- beg_l = (@w_r.pager - 1) * (@w_r.maxy - 5)
1356
- end_l = beg_l + @w_r.maxy - 2
1357
- input = @w_r.text.lines[beg_l..end_l].join() + "\n"
1358
- input.lines.count > @w_r.maxy - 2 ? @w_r.pager_more = true : @w_r.pager_more = false
1359
- if @w_r.text.match(/^\e\[/)
1360
- 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)'
1361
2036
  else
1362
- if @w_r.fg == nil
1363
- init_pair(255, 255, 0)
1364
- @w_r.fg = 255
1365
- else
1366
- 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'
1367
2041
  end
1368
- @w_r.attr = 0 if @w_r.attr == nil
1369
- @w_r.attron(color_pair(@w_r.fg) | @w_r.attr) { @w_r << input }
1370
2042
  end
1371
- @cont = input # For copying right win
1372
- (@w_r.maxy - @w_r.cury).times {@w_r.deleteln()} # Clear to bottom of window
1373
- pager_add_markers
1374
- @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))
1375
2051
  end
1376
- def pager_add_markers # ADD MARKERS TOP/RIGHT & BOTTOM/RIGHT TO SHOW PAGING AS RELEVANT
1377
- if @w_r.pager > 1
1378
- @w_r.setpos(0, @w_r.maxx - 2)
1379
- @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
1380
2059
  end
1381
- if @w_r.pager_more
1382
- @w_r.setpos(@w_r.maxy - 1, @w_r.maxx - 2)
1383
- @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
1384
2074
  end
1385
- end
1386
- def var_resets # RESET PAGER VARIABLES
1387
- @pager = 0
1388
- @pager_more = false
1389
- @pager_cmd = ""
1390
- @info = false
1391
- end
1392
- def openai # INTERFACE TO OPENAI
1393
- begin
1394
- require "ruby/openai"
1395
- rescue
1396
- 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
1397
2081
  end
1398
-
1399
- 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
1400
2098
 
1401
- c = @w_r.text
1402
- c = "" unless c
1403
- @w_r.fg = 214
1404
- w_r_info("OpenAI description...")
2099
+ # MAIN PROGRAM {{{1
2100
+ ## Get terminal size {{{2
2101
+ @h, @w = IO.console.winsize
1405
2102
 
1406
- f = Dir.pwd + "/" + @selected
1407
- p = "What is this "
1408
- File.directory?(@selected) ? p += "directory: " : p+= "file: "
1409
- p += "#{f}? "
1410
- 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")
1411
2118
 
1412
- begin
1413
- response = client.chat(parameters: { model: @aimodel, messages: [{ role: "user", content: p }], max_tokens: 400 })
1414
- text = "OpenAI description:\n" + response["choices"][0]["message"]["content"]
1415
- rescue
1416
- text = "Error retrieving OpenAI request.\n\n"
1417
- text += "Check your connection and @ai secret key (in .rtfm.conf).\n\n"
1418
- text += response.to_s
1419
- 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?
1420
2134
 
1421
- @w_r.fg = 229
1422
- 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
1423
2144
  end
1424
- def sysinfo
1425
- begin
1426
- @w_r.clr
1427
- @w_r.pager_cmd = ""
1428
- uname = `uname -o`.chomp + " "
1429
- uname += `uname -r`.chomp + " "
1430
- uname += `uname -v`.chomp + " "
1431
- uname += `uname -p`.chomp + " "
1432
- uname += `awk -F '"' '/PRETTY/ {print $2}' /etc/os-release` + "\n"
1433
- text = [[253, 1, uname]]
1434
- system = "Shell & Terminal: " + `echo $SHELL`.sub(/.*\//, '').chomp + ", " + `echo $TERM`.chomp + " "
1435
- packages = `pacman -Q 2>/dev/null | wc -l`.chomp
1436
- packages = `dpkg-query -l 2>/dev/null | grep -c '^.i'`.chomp if packages == "0"
1437
- packages = "Unrecognized" if packages == "0"
1438
- system += "Packages: " + packages + "\n"
1439
- system += "Desktop: " + `awk '/^DesktopNames/' /usr/share/xsessions/* | sed 's/DesktopNames=//g' | \\
1440
- sed 's/\\;/\\n/g' | sed '/^$/d' | sort -u | sed ':a;N;$!ba;s/\\n/, /g'`.chomp + "/"
1441
- system += `grep 'gtk-theme-name' ~/.config/gtk-3.0/* | sed 's/gtk-theme-name=//g' | \\
1442
- sed 's/-/ /g'`.sub(/.*:/, '') + "\n"
1443
- text += [[251, 0, system]]
1444
- cpu = "CPUs = " + `nproc`.chop + " "
1445
- cpuinfo = `lscpu`
1446
- cpu += cpuinfo[/^.*Model name:\s*(.*)/, 1] + " "
1447
- cpu += "Max: " + cpuinfo[/^.*CPU max MHz:\s*(.*)/, 1].to_i.to_s + "MHz "
1448
- cpu += "Min: " + cpuinfo[/^.*CPU min MHz:\s*(.*)/, 1].to_i.to_s + "MHz\n\n"
1449
- text += [[154, 0, cpu]]
1450
- mem = `free -h` + "\n"
1451
- text += [[229, 0, mem]]
1452
- ps = `ps -eo comm,pid,user,pcpu,pmem,stat --sort -pcpu,-pmem | head` + "\n"
1453
- text += [[195, 0, ps]]
1454
- disk = `df -H | head -8`
1455
- text += [[172, 0, disk]]
1456
- dmesg = "\nDMESG (latest first):\n"
1457
- dcmd = `dmesg 2>/dev/null | tail -6`.split("\n").sort.reverse.join("\n")
1458
- dcmd != "" ? dmesg += dcmd : dmesg += "dmesg requires root, run 'sudo sysctl kernel.dmesg_restrict=0' if you need permission\n"
1459
- text += [[219, 0, dmesg]]
1460
- 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
1461
2161
  rescue
1462
- w_r_info("Unable to show system info")
1463
- end
1464
- end
1465
- # BOTTOM WINDOW FUNCTIONS
1466
- def w_b_info(info) # SHOW INFO IN @W_B
1467
- @w_b.clr
1468
- @w_b.fg, @w_b.bg = 250, 238
1469
- if info == nil
1470
- info = ": for command (use @s for selected item, @t for tagged items) - press ? for help"
1471
- info = " Showing only files matching '#{@lsmatch}'" if @lsmatch != ""
1472
- info = " Showing only file type '#{@lsfiles}'" if @lsfiles != ""
1473
- info = " Showing only file types '#{@lsfiles}'" if @lsfiles =~ /,/
1474
- info += " and only files matching '#{@lsmatch}'" if @lsfiles != "" and @lsmatch != ""
1475
- info = " Tagged #{@tagged.size} files (#{(@tagsize.to_f/1000000).round(2)}MB)" unless @tagged.empty?
1476
- @w_b.fg, @w_b.bg = 250, 88 if @lsfiles != ""
1477
- @w_b.fg, @w_b.bg = 250, 21 if @lsmatch != ""
1478
- @w_b.fg, @w_b.bg = 250, 55 if @lsfiles != "" and @lsmatch != ""
1479
- end
1480
- info = info[1..(@w_b.maxx - 3)] + "…" if info.length + 3 > @w_b.maxx
1481
- info += " " * (@w_b.maxx - info.length) if info.length < @w_b.maxx
1482
- @w_b.text = info
1483
- @w_b.write
1484
- @w_b.update = false
1485
- end
1486
- def w_b_getstr(pretext, text, ruby=false) # A SIMPLE READLINE-LIKE ROUTINE
1487
- Curses.curs_set(1)
1488
- Curses.echo
1489
- stk = 0
1490
- chr = ""
1491
- if ruby
1492
- @rubyhistory.insert(stk, text)
1493
- @history_copy = @rubyhistory.map(&:clone)
1494
- else
1495
- @history.insert(stk, text)
1496
- @history_copy = @history.map(&:clone)
1497
- end
1498
- pos = @history_copy[stk].length
1499
- while chr != "ENTER"
1500
- @w_b.setpos(0,0)
1501
- if ruby
1502
- init_pair(250, 250, 52)
1503
- else
1504
- init_pair(250, 250, 22)
1505
- end
1506
- text = pretext + @history_copy[stk]
1507
- text += " " * (@w_b.maxx - text.length) if text.length < @w_b.maxx
1508
- @w_b.attron(color_pair(250)) { @w_b << text }
1509
- @w_b.setpos(0,pretext.length + pos)
1510
- @w_b.refresh
1511
- chr = getchr
1512
- if chr == 'C-G' or chr == 'C-C'
1513
- Curses.curs_set(0)
1514
- Curses.noecho
1515
- @w_b.update = true
1516
- return ""
1517
- end
1518
- case chr
1519
- when 'UP'
1520
- unless @w_b.nohistory
1521
- unless stk == @history_copy.length - 1
1522
- stk += 1
1523
- pos = @history_copy[stk].length
1524
- end
1525
- end
1526
- when 'DOWN'
1527
- unless @w_b.nohistory
1528
- unless stk == 0
1529
- stk -= 1
1530
- pos = @history_copy[stk].length
1531
- end
1532
- end
1533
- when 'RIGHT'
1534
- pos += 1 unless pos > @history_copy[stk].length
1535
- when 'LEFT'
1536
- pos -= 1 unless pos == 0
1537
- when 'HOME'
1538
- pos = 0
1539
- when 'END'
1540
- pos = @history_copy[stk].length
1541
- when 'DEL'
1542
- @history_copy[stk][pos] = ""
1543
- when 'BACK'
1544
- unless pos == 0
1545
- pos -= 1
1546
- @history_copy[stk][pos] = ""
1547
- end
1548
- when 'WBACK'
1549
- unless pos == 0
1550
- until @history_copy[stk][pos - 1] == " " or pos == 0
1551
- pos -= 1
1552
- @history_copy[stk][pos] = ""
1553
- end
1554
- if @history_copy[stk][pos - 1] == " "
1555
- pos -= 1
1556
- @history_copy[stk][pos] = ""
1557
- end
1558
- end
1559
- when 'LDEL'
1560
- @history_copy[stk] = ""
1561
- pos = 0
1562
- when 'TAB' # Tab completion of dirs and files
1563
- p1 = pos - 1
1564
- c = @history_copy[stk][0..(p1)].sub(/^.* /, '')
1565
- p0 = p1 - c.length
1566
- compl = File.expand_path(c)
1567
- compl += "/" if Dir.exist?(compl)
1568
- clist = Dir.glob(compl + "*")
1569
- unless compl == clist[0].to_s and clist.length == 1
1570
- if clist.length == 1
1571
- compl = clist[0].to_s
1572
- else
1573
- ix = clist.find_index(compl)
1574
- ix = 0 if ix == nil
1575
- sel_item = ""
1576
- begin
1577
- Curses.curs_set(0)
1578
- Curses.noecho
1579
- @w_r.clr
1580
- @w_r << "Completion list:\n\n"
1581
- clist.each.with_index do |item, index|
1582
- if index == ix
1583
- @w_r.attron(Curses::A_BLINK) { @w_r << item }
1584
- sel_item = item
1585
- else
1586
- @w_r << item
1587
- end
1588
- @w_r << "\n"
1589
- end
1590
- @w_r.refresh
1591
- ix == clist.length ? ix = 0 : ix += 1
1592
- end while getchr == 'TAB'
1593
- compl = sel_item
1594
- @w_r.clr
1595
- Curses.curs_set(1)
1596
- Curses.echo
1597
- end
1598
- end
1599
- @history_copy[stk].sub!(c,compl)
1600
- pos = pos - c.length + compl.length
1601
- when /^.$/
1602
- @history_copy[stk].insert(pos,chr)
1603
- pos += 1
1604
- end
1605
- while $stdin.ready?
1606
- chr = $stdin.getc
1607
- @history_copy[stk].insert(pos,chr)
1608
- pos += 1
1609
- end
1610
- end
1611
- curstr = @history_copy[stk]
1612
- @history_copy.shift if @w_b.nohistory
1613
- ruby ? @rubyhistory.insert(0, @history_copy[stk]) : @history.insert(0, @history_copy[stk])
1614
- unless @w_b.nohistory
1615
- @history.uniq!
1616
- @history.compact!
1617
- @history.delete("")
1618
- @rubyhistory.uniq!
1619
- @rubyhistory.compact!
1620
- @rubyhistory.delete("")
1621
- end
1622
- Curses.curs_set(0)
1623
- Curses.noecho
1624
- return curstr
1625
- end
1626
- def w_b_exec(cmd) # EXECUTE COMMAND FROM @W_B
1627
- # Subsitute any '@s' with the selected item, @t with tagged items
1628
- # 'rm @s' deletes the selected item, 'rm @t' deletes tagged items
1629
- return if cmd == ""
1630
- @s = "\"#{Dir.pwd}/#{@selected}\""
1631
- cmd.gsub!(/@s/, @s)
1632
- @t = @tagged.join(" ")
1633
- cmd.gsub!(/@t/, @t)
1634
- if cmd.match(/^cd /)
1635
- cmd.sub!(/^cd (\S*).*/, '\1')
1636
- Dir.chdir(cmd) if Dir.exist?(cmd)
1637
- return
2162
+ Dir.chdir
1638
2163
  end
1639
- begin
1640
- status = Timeout::timeout(15) {
1641
- @w_r.clr
1642
- begin
1643
- @w_r.pager_cmd = "#{cmd} 2>&1 | #{@bat} -n --color=always"
1644
- @w_r.text = %x(#{@w_r.pager_cmd})
1645
- rescue
1646
- @w_r.pager_cmd = "#{cmd} 2>&1"
1647
- @w_r.text = %x(#{@w_r.pager_cmd})
1648
- end
1649
- unless @w_r.text == "" or @w_r.text == nil
1650
- pager_start
1651
- pager_show
1652
- @w_r.update = false
1653
- else
1654
- @w_r.update = true
1655
- end
1656
- }
1657
- rescue
1658
- 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 = ''
1659
2168
  end
2169
+ system("printf \"\033]0;RTFM: #{Dir.pwd}\007\"") # Update window title
2170
+ rescue StandardError => e
2171
+ errormsg('⚠ Internal Error', e)
1660
2172
  end
1661
2173
 
1662
- # MAIN PROGRAM
1663
- loop do # OUTER LOOP - CATCHING REFRESHES VIA 'r'
1664
- @break = false # Initialize @break variable (set if user hits 'r')
1665
- @image = false # Set the image flag to false (set if image is displayed in @w_r)
1666
- @tag = false # Set pattern tagging to nothing
1667
- @orderchange = false
1668
- begin # Create the four windows/panels
1669
- if @border
1670
- Curses.stdscr.bg = 236 # Use for borders
1671
- Curses.stdscr.fill
1672
- else
1673
- Curses.stdscr.clear
1674
- Curses.stdscr.refresh
1675
- end
1676
- maxx = Curses.cols
1677
- maxy = Curses.lines
1678
- # Curses::Window.new(h,w,y,x)
1679
- @w_t = Curses::Window.new(1, maxx, 0, 0)
1680
- @w_b = Curses::Window.new(1, maxx, maxy - 1, 0)
1681
- @w_l = Curses::Window.new(maxy - 3, (maxx * @width / 10) - 1, 2, 0)
1682
- @w_r = Curses::Window.new(maxy - 4, maxx - (maxx * @width / 10), 2, maxx * @width / 10)
1683
- @w_p = Curses::Window.new(1, maxx - (maxx * @width / 10), maxy - 2, maxx * @width / 10)
1684
- @w_p.fg, @w_p.bg = 255, 0
1685
- @w_p.refresh
1686
- @w_t.fg, @w_t.bg = 232, 249
1687
- @w_t.attr = Curses::A_BOLD
1688
- @w_b.fg, @w_b.bg = 250, 238
1689
- @w_b.update = true
1690
- @w_r.update = true
1691
- @w_r.pager = 0
1692
- @w_r.pager_more = false
1693
- dir_old = Dir.pwd
1694
- lsall_old = @lsall
1695
- unless @tagged.empty?
1696
- tagged_info
1697
- @w_r.update = false
1698
- end
1699
- loop do # INNER, CORE LOOP
1700
- begin # Jump to home dir if current dir is externally removed
1701
- Dir.pwd
1702
- rescue
1703
- Dir.chdir
1704
- end
1705
- get_files("left")
1706
- if Dir.pwd != dir_old
1707
- if @directory.key?(Dir.pwd)
1708
- @selected = @directory[Dir.pwd]
1709
- @index = @files.index(@selected)
1710
- else
1711
- @index = 0
1712
- end
1713
- end
1714
- dir_old = Dir.pwd
1715
- @w_t.bg = @topmatch.find { |name, _| name == "" || dir_old.include?(name) }&.last
1716
- @index = 0 if @index == nil
1717
- index_old = @index
1718
- if @orderchange # Change in ordering must be handled
1719
- @index = @files.index(@selected)
1720
- @orderchange = false
1721
- end
1722
- @index = @files.index(@selected) if @lsall != lsall_old # Change in showing all items must be handled
1723
- @index = index_old if @files.index(@selected) == nil # If item no longer is shown
1724
- @min_index = 0
1725
- @max_index = @files.size - 1
1726
- @index = @max_index if @index > @max_index # If deleted many items
1727
- @index = 0 if @index < 0
1728
- @selected = @files[@index] # Get text of selected item
1729
- sel_old = @selected_safe
1730
- @selected_safe = "\"#{@selected}\"" # Make it safe for commands
1731
- system("printf \"\033]0;RTFM: #{Dir.pwd}\007\"") # Set Window title to path
1732
- w_t_info # Top window (info line)
1733
- @w_l.setpos(0,0)
1734
- list_dir(true)
1735
- @w_l.refresh
1736
- # Bottom window (command line) Before @w_r to avoid image dropping out on startup
1737
- w_b_info(nil) if @w_b.update
1738
- # Left and right windows (browser & content viewer)
1739
- if @w_r.update and @preview
1740
- w_r_show
1741
- @w_r.fg = 255
1742
- end
1743
- unless @navi == ""
1744
- w_r_info(@navi)
1745
- @w_r.update = false
1746
- @navi = ""
1747
- end
1748
- Curses.curs_set(1); Curses.curs_set(0) # Clear residual cursor from editing files
1749
- @tag = false # Clear tag pattern
1750
- lsall_old = @lsall
1751
- main_getkey # Get key from user
1752
- break if Curses.cols != maxx or Curses.lines != maxy # break on terminal resize
1753
- break if @break # Break to outer loop, redrawing windows, if user hit 'r'
1754
- end
1755
- rescue StandardError => err # Throw error nicely
1756
- w_r_info(err)
1757
- @w_r.update = false
1758
- ensure # On exit: close curses, clear terminal
1759
- @write_conf_all = false
1760
- conf_write if @write_conf # Write marks to config file
1761
- image_show("clear")
1762
- close_screen
1763
- # If launched via the script "r", return current dir and "r" will cd to that
1764
- begin
1765
- File.write(ARGV[0], Dir.pwd) if ARGV[0] and ARGV[0].match(/\/tmp/)
1766
- rescue
1767
- end
1768
- 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
1769
2179
  end
1770
2180
 
1771
- # 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\:\ :