rtfm-filemanager 8.1.3 → 8.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e58685e75cad90ce6073efe92abe1fe018bf0371060e652dd86ccf6e7e41d252
4
- data.tar.gz: 6fd3b54ac0729a9111aedad4c48b3e50c6b9467547d62e49c14bc22015c58d13
3
+ metadata.gz: ef7c28629f7646f129d96e76afd871a7ab6030e0adb9983055a247ccc3992ecc
4
+ data.tar.gz: b1c7f85c145f532f5e3175c5fb2f4642d134149d147fc06b4a26389f25d1f42c
5
5
  SHA512:
6
- metadata.gz: 26cb3b44a3b43f90c326050c18c0809b4d689be8610a38c9c591c5bc0519cb197988fd6b63305d0620ab7dddd3f00ae848ed2f5716b166c31d2dd8eab0237719
7
- data.tar.gz: 76a5d98f72158d0cad178cf5b7c0d71e5ea7bea5bc51d06d2e23c2cbe3342e81e22508681f614f80f247e26394d299bf87765539e35b31461c27131c92a587db
6
+ metadata.gz: 9207a4adaeed8a14f3a7a78a70fafdcba2318635605406168679aefe6f2e859cedc7c4053ebcf0d08f610415874df41f061fedad1fd41955cbcc7ae1eb2206a9
7
+ data.tar.gz: 1c8b70f11a2436b5beedebf1b0a510395e62e93a99ed1050192f381097693816731864f46c86a22eeebb8139ae9bb09367625c01ad9856142cffb2b271fdb53d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to RTFM will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [8.2.0] - 2026-03-15
9
+
10
+ ### Added
11
+ - **Plugin system** - Plugins in `~/.rtfm/plugins/` are auto-loaded on startup. Each plugin is a `.rb` file with metadata comments (`@name`, `@description`, `@key`). Disabled plugins use `.rb.off` extension
12
+ - **Plugin manager** (`V` key) - Enable/disable plugins live without restarting RTFM. Press `?` on any plugin to view its built-in help text
13
+ - **Settings plugin** (`C` key) - Interactive editor for settings without dedicated keys: trash mode, run-mailcap, interactive programs, ls flags, OpenAI config, and all pane colors with live preview
14
+ - **Git plugin** (`Ctrl-G`) - Quick git menu: status, diff, commit+push, and log, all from within RTFM
15
+ - **Bookmarks plugin** (`F6`) - Unlimited directory bookmarks with fuzzy filtering, complementing the built-in single-letter marks
16
+ - **Notes plugin** (`F5`) - Attach text notes to any file or directory, stored in `~/.rtfm/notes/`
17
+ - **Opener plugin** (`RIGHT`/`l`) - Define custom programs for specific file extensions via a simple hash
18
+
8
19
  ## [8.1.3] - 2026-03-13
9
20
 
10
21
  ### Fixed
data/README.md CHANGED
@@ -92,10 +92,10 @@ After first run, use `r` command to launch RTFM and exit into your current direc
92
92
  - **Navi integration** - Interactive command cheatsheets
93
93
 
94
94
  ### Developer Features
95
- - **Plugin architecture** - Custom preview handlers and key bindings
95
+ - **Plugin system** - Live enable/disable plugins with built-in manager (`V` key)
96
+ - **Example plugins** - Settings editor, git, bookmarks, notes, custom openers
96
97
  - **Ruby debug mode** - Execute arbitrary Ruby in context
97
98
  - **Command history** - Preserved across sessions
98
- - **Extensible** - Clean plugin API
99
99
 
100
100
  ---
101
101
 
@@ -168,6 +168,7 @@ For complete reference: `man rtfm` or press `?` in RTFM
168
168
  | `q` | Quit (save config) |
169
169
  | `Q` | Quit (don't save) |
170
170
  | `r` | Refresh display |
171
+ | `V` | Plugin manager |
171
172
 
172
173
  ### Navigation
173
174
 
@@ -359,10 +360,12 @@ Configuration stored in `~/.rtfm/conf`
359
360
 
360
361
  | Action | Command |
361
362
  |--------|---------|
362
- | View config | Press `C` |
363
+ | View config | Press `C` (or interactive editor with Settings plugin) |
363
364
  | Save config | Press `W` |
364
365
  | Reload config | Press `R` |
365
366
 
367
+ **Tip:** Install the Settings plugin for an interactive settings editor with live color preview. See [Plugins](#plugins).
368
+
366
369
  ### Common Settings
367
370
 
368
371
  ```ruby
@@ -492,50 +495,75 @@ The chat maintains context throughout your RTFM session, so follow-up questions
492
495
 
493
496
  ## Plugins
494
497
 
495
- RTFM supports two types of plugins in `~/.rtfm/plugins/`:
498
+ RTFM has a plugin system with live enable/disable. Plugins live in `~/.rtfm/plugins/` as `.rb` files and are auto-loaded on startup.
496
499
 
497
- ### 1. Preview Handlers (`preview.rb`)
500
+ ### Plugin Manager (`V` key)
498
501
 
499
- Add custom file type previews:
502
+ Press `V` to open the built-in plugin manager:
503
+ - See all available plugins with their status (ON/OFF)
504
+ - Press `ENTER` to toggle a plugin on or off (takes effect immediately)
505
+ - Press `?` to view the plugin's built-in help text
506
+ - No restart required
500
507
 
501
- ```ruby
502
- # ~/.rtfm/plugins/preview.rb
508
+ ### Example Plugins
503
509
 
504
- # Syntax: ext1, ext2 = command with @s placeholder
505
- # @s is replaced with shell-escaped filename
510
+ RTFM ships with five example plugins in the `examples/` directory. Copy any of them to `~/.rtfm/plugins/` to activate:
506
511
 
507
- # Examples:
508
- txt, log = bat -n --color=always @s
509
- md = pandoc @s -t plain
510
- pdf = pdftotext -f 1 -l 4 @s -
511
- json = jq . @s
512
+ ```bash
513
+ cp examples/settings.rb ~/.rtfm/plugins/
514
+ cp examples/git.rb ~/.rtfm/plugins/
515
+ cp examples/bookmarks.rb ~/.rtfm/plugins/
516
+ cp examples/notes.rb ~/.rtfm/plugins/
517
+ cp examples/opener.rb ~/.rtfm/plugins/
512
518
  ```
513
519
 
514
- ### 2. Custom Key Bindings (`keys.rb`)
520
+ | Plugin | Key | Description |
521
+ |--------|-----|-------------|
522
+ | **Settings** | `C` | Interactive editor for settings without dedicated keys (colors, trash, interactive programs, OpenAI config). Color changes apply in real-time. Overrides the default config viewer |
523
+ | **Git** | `Ctrl-G` | Git operations menu: status, diff, commit+push, log. Output shown in right pane |
524
+ | **Bookmarks** | `F6` | Unlimited directory bookmarks with fuzzy filtering. Add, delete, and jump to bookmarked directories. Complements the built-in single-letter marks (m/') |
525
+ | **Notes** | `F5` | Attach text notes to any file or directory. View, edit, or delete notes. Stored in `~/.rtfm/notes/` |
526
+ | **Opener** | `RIGHT`/`l` | Custom file openers by extension. Configure a hash of extension-to-command mappings (e.g., `.hl` files open in hyperlist) |
527
+
528
+ ### Writing Your Own Plugins
515
529
 
516
- Add or override key bindings:
530
+ A plugin is a simple Ruby file with metadata comments and KEYMAP bindings:
517
531
 
518
532
  ```ruby
519
- # ~/.rtfm/plugins/keys.rb
533
+ # @name: My Plugin
534
+ # @description: What it does
535
+ # @key: X
536
+
537
+ KEYMAP['X'] = :my_action
538
+
539
+ # Optional: register help text shown by ? in plugin manager
540
+ PLUGIN_HELP['My Plugin'] = <<~HELP
541
+ This is the help text for my plugin.
542
+ Shown when pressing ? in the plugin manager.
543
+ HELP
544
+
545
+ def my_action
546
+ clear_image
547
+ @pR.update = true
548
+ @pR.say("Hello from my plugin!")
549
+ end
550
+ ```
520
551
 
521
- # Add new key binding
522
- KEYMAP['Z'] = :my_custom_action
552
+ Plugins can also be defined in `~/.rtfm/keys.rb` for personal bindings that don't need the enable/disable mechanism.
523
553
 
524
- def my_custom_action(_chr)
525
- @pB.say("Custom action triggered!")
526
- # Use @pL, @pR, @selected, etc.
527
- end
554
+ ### Preview Handlers (`preview.rb`)
528
555
 
529
- # Git commit shortcut example
530
- KEYMAP['C-G'] = :git_commit
556
+ Custom file type previews are configured separately in `~/.rtfm/preview.rb`:
531
557
 
532
- def git_commit
533
- message = @pCmd.ask('Commit message: ', '')
534
- shellexec("git add . && git commit -m '#{message}' && git push")
535
- end
558
+ ```ruby
559
+ # Syntax: ext1, ext2 = command with @s placeholder
560
+ txt, log = bat -n --color=always @s
561
+ md = pandoc @s -t plain
562
+ pdf = pdftotext -f 1 -l 4 @s -
563
+ json = jq . @s
536
564
  ```
537
565
 
538
- ### Available Variables
566
+ ### Available Variables for Plugin Authors
539
567
 
540
568
  | Variable | Description |
541
569
  |----------|-------------|
@@ -543,22 +571,33 @@ end
543
571
  | `@pL` | Left pane (file list) |
544
572
  | `@pR` | Right pane (preview) |
545
573
  | `@pB` | Bottom pane (status) |
546
- | `@pCmd` | Command prompt |
574
+ | `@pCmd` | Command prompt (use `.ask(prompt, default)` for input) |
547
575
  | `@selected` | Currently selected file/dir |
548
576
  | `@tagged` | Array of tagged items |
549
577
  | `@external_program_running` | Set true when launching TUI programs |
578
+ | `PLUGIN_HELP` | Hash to register help text (keyed by plugin name) |
550
579
 
551
580
  ### Plugin Helper Functions
552
581
 
553
582
  ```ruby
554
- # Capture command output
583
+ # Capture command output as string
555
584
  output = command("ls -la", timeout: 5)
556
585
 
557
- # Run command, show errors
558
- shell("mv file1 file2", background: false)
559
-
560
- # Run and show both stdout/stderr in right pane
586
+ # Run command interactively (full terminal)
561
587
  shellexec("grep -r pattern .")
588
+
589
+ # Read a keypress
590
+ chr = getchr
591
+
592
+ # Clear any displayed image
593
+ clear_image
594
+
595
+ # Text formatting
596
+ "text".fg(112) # foreground color (0-255)
597
+ "text".bg(236) # background color
598
+ "text".b # bold
599
+ "text".u # underline
600
+ "text".r # reverse
562
601
  ```
563
602
 
564
603
  ---
@@ -650,6 +689,11 @@ Best image experience with: kitty, urxvt, xterm, mlterm, foot
650
689
 
651
690
  ## Latest Updates
652
691
 
692
+ ### Version 8.2 Highlights
693
+
694
+ - **Plugin system with live enable/disable** - Plugins in `~/.rtfm/plugins/` are auto-loaded on startup. Toggle them on and off at runtime with the plugin manager (`V` key), no restart needed. Each plugin can register help text viewable with `?`.
695
+ - **Five example plugins** - Settings editor, git operations, directory bookmarks, file notes, and custom file openers. Copy from `examples/` to `~/.rtfm/plugins/` to use.
696
+
653
697
  ### Version 8.1 Highlights
654
698
 
655
699
  - **File picker mode** - `rtfm --pick=/path/to/output.txt` launches RTFM as a file selector. Browse and tag files normally with `t`, then quit with `q`. Tagged file paths are written one per line to the output file. Enables integration with email clients, upload tools, and other applications that need a file selection dialog.
data/bin/rtfm CHANGED
@@ -18,7 +18,7 @@
18
18
  # get a great understanding of the code itself by simply sending
19
19
  # or pasting this whole file into you favorite AI for coding with
20
20
  # a prompt like this: "Help me understand every part of this code".
21
- @version = '8.1.0' # File picker mode (--pick) for integration with other tools
21
+ @version = '8.2.0' # Plugin system with live enable/disable and built-in plugin manager
22
22
 
23
23
  # SAVE & STORE TERMINAL {{{1
24
24
  ORIG_STTY = `stty -g`.chomp
@@ -173,6 +173,7 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
173
173
  W = Write parameters to ~/.rtfm/conf: @marks, @hash, @history, @rubyhistory, @aihistory, @sshhistory
174
174
  @lslong, @lsall, @lsorder, @lsinvert, @width, @border, @preview, @trash
175
175
  C = Show the current configuration in ~/.rtfm/conf
176
+ V = Plugin manager (enable/disable plugins from ~/.rtfm/plugins/)
176
177
  q = Quit (save basic configuration: @marks, @hash, @history, @rubyhistory, @aihistory, @sshhistory)
177
178
  Q = QUIT (without writing any changes to the config file)
178
179
 
@@ -928,6 +929,7 @@ KEYMAP = { # {{{2
928
929
  'R' => :load_config,
929
930
  'C' => :show_config,
930
931
  'W' => :write_config,
932
+ 'V' => :plugin_manager,
931
933
  'q' => :quit_and_save,
932
934
  'Q' => :quit_no_save,
933
935
 
@@ -1078,6 +1080,78 @@ if File.exist?(KEYS_FILE)
1078
1080
  end
1079
1081
  end
1080
1082
 
1083
+ # PLUGIN SYSTEM {{{2
1084
+ # Plugins live in ~/.rtfm/plugins/ as .rb files.
1085
+ # Each plugin should start with metadata comments:
1086
+ # # @name: My Plugin
1087
+ # # @description: What it does
1088
+ # # @key: X (optional, documents which key it binds)
1089
+ # Disabled plugins have .rb.off extension.
1090
+
1091
+ @plugins = {} # name => { file:, enabled:, meta:, saved_keys: }
1092
+ PLUGIN_HELP = {} # name => help string (registered by each plugin)
1093
+
1094
+ def parse_plugin_meta(file)
1095
+ meta = { name: File.basename(file).sub(/\.rb(\.off)?$/, ''), description: '', key: '' }
1096
+ File.foreach(file) do |line|
1097
+ break unless line.start_with?('#')
1098
+ meta[:name] = $1.strip if line =~ /^#\s*@name:\s*(.+)/
1099
+ meta[:description] = $1.strip if line =~ /^#\s*@description:\s*(.+)/
1100
+ meta[:key] = $1.strip if line =~ /^#\s*@key:\s*(.+)/
1101
+ end
1102
+ meta
1103
+ end
1104
+
1105
+ def load_plugin(name)
1106
+ p = @plugins[name]
1107
+ return unless p
1108
+ snapshot = KEYMAP.dup
1109
+ begin
1110
+ load p[:file].sub(/\.off$/, '')
1111
+ # Track which KEYMAP entries changed
1112
+ changed = {}
1113
+ KEYMAP.each { |k, v| changed[k] = snapshot[k] if snapshot[k] != v }
1114
+ snapshot.each { |k, v| changed[k] = v unless KEYMAP.key?(k) }
1115
+ p[:saved_keys] = changed
1116
+ p[:enabled] = true
1117
+ rescue => e
1118
+ @plugin_errors << "Error loading plugin #{name}: #{e.class}: #{e.message}"
1119
+ end
1120
+ end
1121
+
1122
+ def unload_plugin(name)
1123
+ p = @plugins[name]
1124
+ return unless p
1125
+ (p[:saved_keys] || {}).each do |k, original|
1126
+ original ? KEYMAP[k] = original : KEYMAP.delete(k)
1127
+ end
1128
+ p[:enabled] = false
1129
+ end
1130
+
1131
+ def toggle_plugin(name)
1132
+ p = @plugins[name]
1133
+ return unless p
1134
+ base = p[:file].sub(/\.off$/, '')
1135
+ off = base + '.off'
1136
+ if p[:enabled]
1137
+ unload_plugin(name)
1138
+ File.rename(base, off) if File.exist?(base)
1139
+ p[:file] = off
1140
+ else
1141
+ File.rename(off, base) if File.exist?(off)
1142
+ p[:file] = base
1143
+ load_plugin(name)
1144
+ end
1145
+ end
1146
+
1147
+ # Scan and load enabled plugins
1148
+ Dir.glob(File.join(PLUGINS_DIR, '*.rb{,.off}')).sort.each do |f|
1149
+ meta = parse_plugin_meta(f)
1150
+ enabled = !f.end_with?('.off')
1151
+ @plugins[meta[:name]] = { file: f, enabled: false, meta: meta, saved_keys: {} }
1152
+ load_plugin(meta[:name]) if enabled
1153
+ end
1154
+
1081
1155
  # MAIN GETKEY FOR USER INPUT {{{2
1082
1156
  def getkey # {{{3
1083
1157
  chr = getchr(1)
@@ -1197,6 +1271,59 @@ def show_config
1197
1271
  @pR.say('Configuration'.u.fg(254) + ":\n\n" + @conf.fg(249))
1198
1272
  end
1199
1273
 
1274
+ def plugin_manager # {{{3
1275
+ clear_image
1276
+ names = @plugins.keys.sort
1277
+ if names.empty?
1278
+ @pR.say("Plugins".u.fg(254) + "\n\nNo plugins found in ~/.rtfm/plugins/\n\nPlace .rb files there to make them available.")
1279
+ return
1280
+ end
1281
+ sel = 0
1282
+ loop do
1283
+ lines = []
1284
+ lines << "Plugins".b.fg(254)
1285
+ lines << ""
1286
+ names.each_with_index do |name, i|
1287
+ p = @plugins[name]
1288
+ status = p[:enabled] ? " ON ".bg(22).fg(255) : " OFF".bg(52).fg(255)
1289
+ key_info = p[:meta][:key].empty? ? '' : " [#{p[:meta][:key]}]".fg(240)
1290
+ desc = p[:meta][:description].empty? ? '' : " #{p[:meta][:description]}".fg(249)
1291
+ line = "#{status} #{name}#{key_info}#{desc}"
1292
+ line = i == sel ? line.u : line
1293
+ lines << line
1294
+ end
1295
+ lines << ""
1296
+ lines << "j/k:move ENTER:toggle ?:help q:close".fg(240)
1297
+ @pR.update = true
1298
+ @pR.say(lines.join("\n"))
1299
+
1300
+ chr = getchr
1301
+ case chr
1302
+ when 'q', 'ESC'
1303
+ break
1304
+ when 'j', 'DOWN'
1305
+ sel = (sel + 1) % names.size
1306
+ when 'k', 'UP'
1307
+ sel = (sel - 1) % names.size
1308
+ when 'ENTER', 'RIGHT', 'LEFT'
1309
+ toggle_plugin(names[sel])
1310
+ when '?'
1311
+ name = names[sel]
1312
+ help = PLUGIN_HELP[name]
1313
+ if help
1314
+ @pR.update = true
1315
+ @pR.say(name.b.fg(254) + " help\n\n" + help)
1316
+ getchr
1317
+ else
1318
+ @pB.say("No help available for #{name}.")
1319
+ end
1320
+ end
1321
+ end
1322
+ @pR.update = true
1323
+ refresh
1324
+ render
1325
+ end
1326
+
1200
1327
  def write_config # {{{3
1201
1328
  conf_write(all: true)
1202
1329
  show_config
@@ -0,0 +1,137 @@
1
+ # @name: Bookmarks
2
+ # @description: Quick directory bookmarks with fuzzy jump
3
+ # @key: F6
4
+
5
+ BOOKMARKS_FILE = File.expand_path('~/.rtfm/bookmarks.txt')
6
+
7
+ KEYMAP['F6'] = :bookmark_menu
8
+
9
+ PLUGIN_HELP['Bookmarks'] = <<~HELP
10
+ Quick directory bookmarks with fuzzy filtering.
11
+
12
+ Press F6 to open the bookmark manager.
13
+
14
+ #{"Commands:".b}
15
+ a Add current directory to bookmarks
16
+ d Delete selected bookmark
17
+ j/k Navigate up/down
18
+ ENTER Jump to selected directory
19
+ / Filter bookmarks (fuzzy search)
20
+ q/ESC Close
21
+
22
+ #{"Difference from marks:".b}
23
+ Marks (m/') are single-letter slots (a-z) that
24
+ save a directory path for quick recall. You get
25
+ 26 slots max, and must remember which letter
26
+ maps to which directory.
27
+
28
+ Bookmarks are an unlimited named list of
29
+ directories with fuzzy filtering. Better for
30
+ large collections of frequently visited paths.
31
+ They persist in ~/.rtfm/bookmarks.txt.
32
+ HELP
33
+
34
+ def load_bookmarks
35
+ return [] unless File.exist?(BOOKMARKS_FILE)
36
+ File.readlines(BOOKMARKS_FILE).map(&:strip).reject(&:empty?)
37
+ end
38
+
39
+ def save_bookmarks(bookmarks)
40
+ File.write(BOOKMARKS_FILE, bookmarks.join("\n") + "\n")
41
+ end
42
+
43
+ def bookmark_menu
44
+ clear_image
45
+
46
+ sel = 0
47
+ filter = nil
48
+
49
+ loop do
50
+ bookmarks = load_bookmarks
51
+ visible = filter ? bookmarks.select { |b| b.downcase.include?(filter.downcase) } : bookmarks
52
+
53
+ lines = []
54
+ lines << "Bookmarks".b.fg(254)
55
+ lines << "filter: #{filter}".fg(240) if filter
56
+ lines << ""
57
+
58
+ if visible.empty?
59
+ lines << (filter ? "No matches." : "No bookmarks yet.").fg(240)
60
+ else
61
+ # Find common prefix to dim shared parts
62
+ common = ""
63
+ if visible.size > 1
64
+ parts = visible.map { |b| b.split('/') }
65
+ min_len = parts.map(&:size).min
66
+ min_len.times do |i|
67
+ break unless parts.all? { |p| p[i] == parts[0][i] }
68
+ common += parts[0][i] + '/'
69
+ end
70
+ end
71
+
72
+ visible.each_with_index do |path, i|
73
+ idx = bookmarks.index(path)
74
+ prefix = common.empty? ? "" : path[0, common.length].fg(240)
75
+ suffix = common.empty? ? path.fg(112) : path[common.length..].fg(112)
76
+ line = "#{idx.to_s.rjust(2).fg(240)} #{prefix}#{suffix}"
77
+ line = i == sel ? line.u : line
78
+ lines << line
79
+ end
80
+ end
81
+
82
+ lines << ""
83
+ lines << "a".b.fg(112) + ":add " + "d".b.fg(112) + ":del " +
84
+ "/".b.fg(112) + ":filter " + "ENTER".b.fg(112) + ":jump " +
85
+ "q".b.fg(112) + ":close"
86
+
87
+ @pR.update = true
88
+ @pR.say(lines.join("\n"))
89
+
90
+ chr = getchr
91
+ case chr
92
+ when 'q', 'ESC'
93
+ break
94
+ when 'j', 'DOWN'
95
+ sel = visible.empty? ? 0 : (sel + 1) % visible.size
96
+ when 'k', 'UP'
97
+ sel = visible.empty? ? 0 : (sel - 1) % visible.size
98
+ when 'a'
99
+ cwd = Dir.pwd
100
+ unless bookmarks.include?(cwd)
101
+ bookmarks << cwd
102
+ save_bookmarks(bookmarks)
103
+ @pB.say("Bookmarked: #{cwd}")
104
+ else
105
+ @pB.say("Already bookmarked.")
106
+ end
107
+ when 'd'
108
+ unless visible.empty?
109
+ path = visible[sel]
110
+ bookmarks.delete(path)
111
+ save_bookmarks(bookmarks)
112
+ sel = [sel, visible.size - 2].max
113
+ sel = 0 if sel < 0
114
+ @pB.say("Removed: #{path}")
115
+ end
116
+ when 'ENTER'
117
+ unless visible.empty?
118
+ path = visible[sel]
119
+ if Dir.exist?(path)
120
+ Dir.chdir(path)
121
+ @pB.say("Jumped to: #{path}")
122
+ break
123
+ else
124
+ @pB.say("Directory not found: #{path}")
125
+ end
126
+ end
127
+ when '/'
128
+ input = @pCmd.ask('Filter: ', filter || '')
129
+ filter = input.strip.empty? ? nil : input.strip
130
+ sel = 0
131
+ end
132
+ end
133
+
134
+ @pR.update = true
135
+ refresh
136
+ render
137
+ end
data/examples/git.rb ADDED
@@ -0,0 +1,73 @@
1
+ # @name: Git
2
+ # @description: Git operations (status, commit, push, diff)
3
+ # @key: C-G
4
+
5
+ KEYMAP['C-G'] = :git_menu
6
+
7
+ PLUGIN_HELP['Git'] = <<~HELP
8
+ Quick git operations for the current directory.
9
+
10
+ Press Ctrl-G to open the git menu.
11
+
12
+ #{"Commands:".b}
13
+ s Show git status
14
+ d Show git diff
15
+ c Stage all, commit (prompts for message), push
16
+ l Show last 20 commits (git log --oneline)
17
+ q Close menu
18
+
19
+ All output displays in the right pane.
20
+ The commit command runs interactively so you
21
+ can see push progress and any errors.
22
+ HELP
23
+
24
+ def git_menu
25
+ clear_image
26
+
27
+ loop do
28
+ lines = []
29
+ lines << "Git".b.fg(254)
30
+ lines << ""
31
+ lines << "s".b.fg(112) + " git status"
32
+ lines << "d".b.fg(112) + " git diff"
33
+ lines << "c".b.fg(112) + " git add + commit + push"
34
+ lines << "l".b.fg(112) + " git log (last 20)"
35
+ lines << ""
36
+ lines << "q/ESC: close".fg(240)
37
+
38
+ @pR.update = true
39
+ @pR.say(lines.join("\n"))
40
+
41
+ chr = getchr
42
+ case chr
43
+ when 's'
44
+ output = command("git status 2>&1")
45
+ @pR.update = true
46
+ @pR.say("git status".b.fg(254) + "\n\n" + output)
47
+ when 'd'
48
+ output = command("git diff 2>&1")
49
+ output = "No changes." if output.strip.empty?
50
+ @pR.update = true
51
+ @pR.say("git diff".b.fg(254) + "\n\n" + output)
52
+ when 'c'
53
+ message = @pCmd.ask('Commit message: ', '')
54
+ if message.strip.empty?
55
+ @pB.say("Aborted: empty commit message.")
56
+ next
57
+ end
58
+ @pB.say("Committing and pushing...")
59
+ shellexec("git add . && git commit -m '#{message}' && git push", timeout: 20)
60
+ @pB.full_refresh
61
+ when 'l'
62
+ output = command("git log --oneline -20 2>&1")
63
+ @pR.update = true
64
+ @pR.say("git log".b.fg(254) + "\n\n" + output)
65
+ when 'q', 'ESC'
66
+ break
67
+ end
68
+ end
69
+
70
+ @pR.update = true
71
+ refresh
72
+ render
73
+ end
data/examples/notes.rb ADDED
@@ -0,0 +1,92 @@
1
+ # @name: Notes
2
+ # @description: Attach notes to files/directories, shown in right pane
3
+ # @key: F5
4
+
5
+ require 'fileutils'
6
+
7
+ NOTES_DIR = File.expand_path('~/.rtfm/notes')
8
+ FileUtils.mkdir_p(NOTES_DIR)
9
+
10
+ KEYMAP['F5'] = :toggle_note
11
+
12
+ PLUGIN_HELP['Notes'] = <<~HELP
13
+ Attach text notes to any file or directory.
14
+
15
+ Press F5 on any selected item to view, create,
16
+ edit, or delete its note.
17
+
18
+ #{"How it works:".b}
19
+ If no note exists, you're prompted to create one.
20
+ If a note exists, it shows in the right pane
21
+ with options to edit or delete.
22
+
23
+ #{"Commands (when viewing a note):".b}
24
+ e Edit the note
25
+ d Delete the note
26
+ q Close
27
+
28
+ #{"Difference from marks:".b}
29
+ Marks (m/') are single-letter bookmarks for
30
+ quick directory jumping. Notes are free-text
31
+ annotations attached to specific files or
32
+ directories, useful for reminders, TODO items,
33
+ or documentation.
34
+
35
+ Notes are stored in ~/.rtfm/notes/ as text files.
36
+ HELP
37
+
38
+ def note_path_for(file)
39
+ File.join(NOTES_DIR, file.gsub('/', '%') + '.txt')
40
+ end
41
+
42
+ def toggle_note
43
+ clear_image
44
+ file = @selected
45
+ npath = note_path_for(file)
46
+ has_note = File.exist?(npath)
47
+
48
+ if has_note
49
+ note = File.read(npath)
50
+ loop do
51
+ lines = []
52
+ lines << "Note for:".b.fg(254)
53
+ lines << File.basename(file).fg(112)
54
+ lines << ""
55
+ lines << note
56
+ lines << ""
57
+ lines << "e".b.fg(112) + " edit note"
58
+ lines << "d".b.fg(112) + " delete note"
59
+ lines << "q/ESC: close".fg(240)
60
+
61
+ @pR.update = true
62
+ @pR.say(lines.join("\n"))
63
+
64
+ chr = getchr
65
+ case chr
66
+ when 'e'
67
+ input = @pCmd.ask('Note: ', note)
68
+ unless input.strip.empty?
69
+ File.write(npath, input)
70
+ note = input
71
+ @pB.say('Note updated.')
72
+ end
73
+ when 'd'
74
+ File.delete(npath)
75
+ @pB.say('Note deleted.')
76
+ break
77
+ when 'q', 'ESC'
78
+ break
79
+ end
80
+ end
81
+ else
82
+ input = @pCmd.ask('Note: ', '')
83
+ unless input.strip.empty?
84
+ File.write(npath, input)
85
+ @pB.say('Note saved.')
86
+ end
87
+ end
88
+
89
+ @pR.update = true
90
+ refresh
91
+ render
92
+ end
@@ -0,0 +1,87 @@
1
+ # @name: Opener
2
+ # @description: Custom file openers by extension (override default open behavior)
3
+ # @key: RIGHT/l (overrides move_right)
4
+
5
+ require 'shellwords'
6
+
7
+ # User-configurable openers hash:
8
+ # Extension => command string (use %f for file path)
9
+ PLUGIN_HELP['Opener'] = <<~HELP
10
+ Custom file openers by extension.
11
+
12
+ Overrides RIGHT/l to check selected files against
13
+ a list of custom openers before using the default
14
+ open behavior.
15
+
16
+ #{"Configuration:".b}
17
+ Edit the CUSTOM_OPENERS hash in the plugin file
18
+ (~/.rtfm/plugins/opener.rb):
19
+
20
+ CUSTOM_OPENERS = {
21
+ '.md' => 'glow %f',
22
+ '.pdf' => 'zathura %f',
23
+ '.hl' => 'hyperlist %f',
24
+ }
25
+
26
+ Use %f as placeholder for the file path.
27
+
28
+ #{"How it works:".b}
29
+ When you press RIGHT or l on a file, the plugin
30
+ checks if its extension matches any entry in
31
+ CUSTOM_OPENERS. If yes, it launches that program
32
+ interactively. If no match, the normal RTFM
33
+ open behavior is used.
34
+ HELP
35
+
36
+ CUSTOM_OPENERS = {
37
+ # '.md' => 'glow %f', # Example: markdown viewer
38
+ # '.pdf' => 'zathura %f', # Example: PDF viewer
39
+ # '.csv' => 'visidata %f', # Example: CSV explorer
40
+ # '.hl' => 'hyperlist %f', # Example: HyperList files
41
+ }
42
+
43
+ def custom_open
44
+ # Check if the selected file matches any registered extension
45
+ ext = CUSTOM_OPENERS.keys.find { |e| @selected.end_with?(e) }
46
+
47
+ unless ext
48
+ # No custom opener, fall back to original move_right
49
+ move_right
50
+ return
51
+ end
52
+
53
+ cmd = CUSTOM_OPENERS[ext].gsub('%f', Shellwords.escape(@selected))
54
+
55
+ # Launch the program interactively (same pattern as RTFM's interactive handling)
56
+ @external_program_running = true
57
+ system("stty #{ORIG_STTY} < /dev/tty")
58
+ system('stty sane < /dev/tty')
59
+ system('clear < /dev/tty > /dev/tty')
60
+ Cursor.show
61
+
62
+ pid = Process.spawn(cmd,
63
+ in: '/dev/tty',
64
+ out: '/dev/tty',
65
+ err: '/dev/tty')
66
+ begin
67
+ Process.wait(pid)
68
+ rescue Interrupt
69
+ Process.kill('TERM', pid) rescue nil
70
+ retry
71
+ ensure
72
+ @external_program_running = false
73
+ end
74
+
75
+ # Restore RTFM's terminal state
76
+ system('stty raw -echo isig < /dev/tty')
77
+ $stdin.raw!
78
+ $stdin.echo = false
79
+ Cursor.hide
80
+ Rcurses.clear_screen
81
+ refresh
82
+ render
83
+ end
84
+
85
+ KEYMAP['RIGHT'] = :custom_open
86
+ KEYMAP['l'] = :custom_open
87
+ KEYMAP['C-RIGHT'] = :custom_open
@@ -0,0 +1,186 @@
1
+ # @name: Settings
2
+ # @description: Interactive settings editor (colors, toggles, paths)
3
+ # @key: C
4
+
5
+ KEYMAP['C'] = :show_settings
6
+
7
+ PLUGIN_HELP['Settings'] = <<~HELP
8
+ Interactive editor for RTFM settings that don't
9
+ have their own dedicated keys.
10
+
11
+ Overrides the default 'C' (show config) key.
12
+
13
+ #{"Navigation:".b}
14
+ j/k or UP/DOWN Navigate settings
15
+ LEFT/RIGHT Toggle booleans, adjust colors +-1
16
+ H/L Adjust colors +-10
17
+ ENTER Edit text fields or type color number
18
+ q/ESC Save and close
19
+
20
+ #{"Settings included:".b}
21
+ Trash mode, run-mailcap, interactive programs,
22
+ custom ls flags, OpenAI key/model, and all
23
+ pane colors (top, bottom, search, command,
24
+ ruby, AI, SSH bars).
25
+
26
+ Changes are saved to ~/.rtfm/conf on close.
27
+ Color changes apply immediately.
28
+ HELP
29
+
30
+ def show_settings
31
+ clear_image
32
+
33
+ # Settings definitions: [label, variable, type, options]
34
+ # Types: :bool, :cycle, :int_range, :text
35
+ settings = [
36
+ ['Trash (move to trash)', :@trash, :bool],
37
+ ['Use run-mailcap', :@runmailcap, :bool],
38
+ ['Interactive programs', :@interactive, :text],
39
+ ['Custom ls flags', :@lsuser, :text],
40
+ ['OpenAI API key', :@ai, :text_masked],
41
+ ['OpenAI model', :@aimodel, :text],
42
+ ['Top bar color', :@topcolor, :color],
43
+ ['Bottom bar color', :@bottomcolor, :color],
44
+ ['Search bar color', :@searchcolor, :color],
45
+ ['Command bar color', :@cmdcolor, :color],
46
+ ['Ruby bar color', :@rubycolor, :color],
47
+ ['AI bar color', :@aicolor, :color],
48
+ ['SSH bar color', :@sshcolor, :color],
49
+ ]
50
+
51
+ sel = 0
52
+ label_w = settings.map { |s| s[0].length }.max + 2
53
+
54
+ loop do
55
+ # Build display
56
+ lines = []
57
+ lines << "Settings".b.fg(254)
58
+ lines << ""
59
+
60
+ settings.each_with_index do |(label, var, type, _opts), i|
61
+ val = instance_variable_get(var)
62
+ display = case type
63
+ when :bool
64
+ val ? "Yes" : "No"
65
+ when :color
66
+ swatch = " #{val} ".bg(val.to_i).fg(val.to_i > 128 ? 0 : 255)
67
+ "#{swatch} #{val}"
68
+ when :text_masked
69
+ val.to_s.length > 8 ? val.to_s[0..3] + "..." + val.to_s[-4..] : val.to_s
70
+ else
71
+ val.to_s
72
+ end
73
+
74
+ pad = label_w - label.length
75
+ line = "#{label}#{' ' * pad}#{display}"
76
+ line = i == sel ? line.u : line
77
+ lines << line
78
+ end
79
+
80
+ lines << ""
81
+ lines << "j/k:move LEFT/RIGHT:change ENTER:edit q:close".fg(240)
82
+
83
+ @pR.update = true
84
+ @pR.say(lines.join("\n"))
85
+
86
+ chr = getchr
87
+ case chr
88
+ when 'q', 'ESC'
89
+ break
90
+ when 'j', 'DOWN'
91
+ sel = (sel + 1) % settings.size
92
+ when 'k', 'UP'
93
+ sel = (sel - 1) % settings.size
94
+ when 'RIGHT', 'LEFT', 'ENTER'
95
+ label, var, type, _opts = settings[sel]
96
+ val = instance_variable_get(var)
97
+
98
+ case type
99
+ when :bool
100
+ instance_variable_set(var, !val)
101
+ when :color
102
+ delta = chr == 'RIGHT' ? 1 : chr == 'LEFT' ? -1 : 0
103
+ if chr == 'ENTER'
104
+ input = @pCmd.ask("#{label} (0-255): ", val.to_s).strip
105
+ new_val = input.to_i
106
+ instance_variable_set(var, new_val.clamp(0, 255))
107
+ else
108
+ instance_variable_set(var, (val.to_i + delta).clamp(0, 255))
109
+ end
110
+ # Apply color changes immediately
111
+ apply_color(var)
112
+ when :text, :text_masked
113
+ input = @pCmd.ask("#{label}: ", val.to_s)
114
+ instance_variable_set(var, input)
115
+ end
116
+ when 'H'
117
+ # Jump -10 for color settings
118
+ _label, var, type, _opts = settings[sel]
119
+ if type == :color
120
+ val = instance_variable_get(var)
121
+ instance_variable_set(var, (val.to_i - 10).clamp(0, 255))
122
+ apply_color(var)
123
+ end
124
+ when 'L'
125
+ # Jump +10 for color settings
126
+ _label, var, type, _opts = settings[sel]
127
+ if type == :color
128
+ val = instance_variable_get(var)
129
+ instance_variable_set(var, (val.to_i + 10).clamp(0, 255))
130
+ apply_color(var)
131
+ end
132
+ end
133
+ end
134
+
135
+ # Save all settings to config
136
+ save_settings(settings)
137
+ @pR.update = true
138
+ refresh
139
+ render
140
+ end
141
+
142
+ def apply_color(var)
143
+ case var
144
+ when :@topcolor
145
+ @pT.bg = @topcolor
146
+ @pT.update = true
147
+ when :@bottomcolor
148
+ @pB.bg = @bottomcolor
149
+ @pB.update = true
150
+ when :@searchcolor
151
+ @pSearch.bg = @searchcolor
152
+ when :@cmdcolor
153
+ @pCmd.bg = @cmdcolor
154
+ when :@rubycolor
155
+ @pRuby.bg = @rubycolor
156
+ when :@aicolor
157
+ @pAI.bg = @aicolor
158
+ when :@sshcolor
159
+ @pSsh.bg = @sshcolor
160
+ end
161
+ end
162
+
163
+ def save_settings(settings)
164
+ @conf = @conf.dup
165
+ settings.each do |_label, var, type, _opts|
166
+ val = instance_variable_get(var)
167
+ name = var.to_s.sub('@', '')
168
+ line = case type
169
+ when :bool
170
+ "@#{name} = #{val}"
171
+ when :color
172
+ "@#{name} = #{val}"
173
+ when :text, :text_masked
174
+ "@#{name} = '#{val}'"
175
+ end
176
+ regex = /^@#{Regexp.escape(name)}\b.*$/
177
+ if @conf.match?(regex)
178
+ @conf.sub!(regex, line)
179
+ else
180
+ @conf << "\n" unless @conf.end_with?("\n")
181
+ @conf << line << "\n"
182
+ end
183
+ end
184
+ File.write(CONFIG_FILE, @conf)
185
+ @pB.say('Settings saved to ~/.rtfm/conf')
186
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rtfm-filemanager
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.1.3
4
+ version: 8.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-13 00:00:00.000000000 Z
11
+ date: 2026-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rcurses
@@ -66,13 +66,12 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '7.4'
69
- description: 'RTFM v8.0: Browse and modify archives as virtual directories (extract,
70
- delete, add, move), async background file operations, scrollable diff viewer with
71
- side-by-side mode. A full featured terminal browser with syntax highlighted files,
69
+ description: 'A full featured terminal file manager with syntax highlighted files,
72
70
  images shown in the terminal, videos thumbnailed, etc. Features include remote SSH/SFTP
73
71
  browsing, interactive SSH shell, comprehensive undo system, OpenAI integration,
74
- bookmarks, and much more. RTFM is one of the most feature-packed terminal file managers.
75
- v8.1: File picker mode (--pick) for integration with other tools.'
72
+ bookmarks, archive browsing, and much more. v8.2: Plugin system with live enable/disable,
73
+ built-in plugin manager (V key), and example plugins (settings editor, git operations,
74
+ bookmarks, notes, custom file openers).'
76
75
  email: g@isene.com
77
76
  executables:
78
77
  - rtfm
@@ -89,7 +88,12 @@ files:
89
88
  - docs/plugins.md
90
89
  - docs/remote-browsing.md
91
90
  - docs/troubleshooting.md
91
+ - examples/bookmarks.rb
92
+ - examples/git.rb
93
+ - examples/notes.rb
94
+ - examples/opener.rb
92
95
  - examples/rtfm.conf
96
+ - examples/settings.rb
93
97
  - img/logo.png
94
98
  - img/rtfm-kb.png
95
99
  - man/rtfm.1