filecon 0.5.1 → 0.6.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: 244a74bf9a48f8731545f30724284976ac03efaea2dd99696d29f6756b952e76
4
- data.tar.gz: 161554ce9508b8ff066710d0757f339cadc4f46dde7f54ac28218509e82055b3
3
+ metadata.gz: e644e2e7cc6f76bb646d0738e9d83789a558696c7341a6c6a9cd3a749c1adc7b
4
+ data.tar.gz: a2d69517f4a4b7d9fc5af621440e9ca3a0bfaaf1be8987801b643725079f6256
5
5
  SHA512:
6
- metadata.gz: 8409685b7f649c3f4b10b3a0a7eed1ef1ddac5052a374763238865c19ba821b6b5b1918f2d262d39e2b6721acc9def64fffc57bffa79e6763e94a0c0ee944fb5
7
- data.tar.gz: 3af13651f23eda4eb2d74c2011402a959aeb981c31b0533af338c310a340ef2f0ad002d71870b254b92f5b964152c595f1a98e8a594f5f0597a6197384d92710
6
+ metadata.gz: 3603c6f4aada4a2c8ff06c6e0528f6f862c6bf8e929deba8920b959c818abc21bd8c979bf1ab51795692d1922cdc81fc5aa0be239d492bb8c90fec94eefee389
7
+ data.tar.gz: d4014f8166788f03d46576c195b5d4b5cb539c1ca70f28c71171db0ec196d00210a8c5f75cecc9b35f84e090eff3762913ec4ac926af75e6a9fab10de18a2a77
data/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [vx.y.z] — __DATE__
4
4
 
5
+ ## [v0.6.0] — 2026-10-11
6
+
7
+ ### Changed
8
+ - Help panels use round borders joined by T-pieces at the seam
9
+ - All shell strings run under `bash -c` (never `sh`) through one `shell` helper; aliases and rc files do not resolve anywhere. Startup aborts cleanly without bash
10
+ - Help headers render from the configured `header:` color and command names blue from inline palette codes, which survive rebuilds and resizes under paint-once text
11
+ - Typr widget colors are configurable from the bundled config: `border:`, `header:`, `question:`, `answer:`, `selected:`, `alternate:`, `hints:`, and `search:` join the color mapping with per-key inline docs; every color key is documented and column-aligned (pinned by spec). All widget borders (help panels, pickers, conflict grids, error popups, `=` viewer) render from the configured `border:` color instead of hardcoded pairs
12
+ - Config is the authority on color: the `conflict:`, `help:`, and `help_border:` keys are gone (conflict uses the base `black` background, help titles use palette yellow inline with the keybinding-list header from `header:`, help borders use `border:`) and all color fallbacks are removed. Help command names render blue; help documents bash completion, recursive listing, history alt-edit, and all config locations
13
+ - Conflict picker is a single filling column, so no view shows through its rows
14
+ - Conflict popups use round borders on the base black background with all foregrounds kept; errors use the `error:` pair (dark red by default)
15
+ - Command execution from open_file, execute, and cmd_history runs through one `dispatch` (record except `!cd`, refresh, error popups); `run` drops its unused integer form. Per-prefix output: `|` detaches stdin and shows the first line, if any, then detaches; leftover pipe output is drained in a background thread so chatty detached commands survive instead of dying on SIGPIPE, (the pipe format spawns with `in: File::NULL`, so interactive children get EOF instead of the shared keyboard) and shows the first line, if any, then detaches. Stale home configs keep the old `open()` pipe format with shared stdin until reseeded, `+` detaches immediately, `=` streams live into the viewer, `!` results show with a keypress pause. The `regex:` config key is gone; only the `e[x]ecute` prompt tints (`:shell`). Requires typr `~> 1.8`
16
+ - Layered configuration — bundled defaults, then `/etc/filecon/config.yaml` (read if present, never written), then the user file; fresh user files are seeded as a commented template overriding nothing, and an explicit null deletes an inherited key. Existing user files keep working untouched. A layer file that parses to a non-mapping raises immediately naming the file
17
+ - Token blinking routes through `mode_code` instead of inline escapes (byte-identical output)
18
+ - Empty command results stay silent: no status line, no keypress pause
19
+ - Copy/move runs through rsync with its own progress repainted on the bottom line only (`--progress` stdout is piped, never scrolls); stderr is collected and shown in a borderless popup with the configurable `error:` colors at the end of the transfer; falls back to `FileUtils` where rsync is unavailable
20
+
21
+ ### Removed
22
+ - Bundled `+` prefix: no static terminal default is correct ($TERM names terminfo, not binaries); fresh seeds write the detected line, stale installs reseed by deleting the user file
23
+
24
+ ### Added
25
+ - First-run seeding writes a live `+` prefix with the detected terminal (first of kitty, foot, alacritty -e, urxvt -e, xterm -e, konsole -e, gnome-terminal --, ghostty -e, xfce4-terminal -e, wezterm start --, terminator -e, st -e found on PATH, else `$TERMINAL`), so windowed commands work out of the box (falls back to a literal `$TERMINAL` reference when nothing is detected); the `+` format no longer holds the window open
26
+ - Archive list/extract actions for 7z (`x-7z-compressed`) and rar (`vnd.rar`); `-y`/`-o+` keep extracts non-interactive under detached stdin
27
+
28
+ ### Fixed
29
+ - Terminal-window commands wrap the body in `bash -c`, so hashbang-less scripts run under terminals that exec directly instead of failing with exec format error
30
+
5
31
  ## [v0.5.1] — 2026-09-30
6
32
 
7
33
  ### Changed
data/README.md CHANGED
@@ -9,7 +9,7 @@ Filecon is a file and command manager that lives between terminal and shell, bui
9
9
  Filecon is designed to be **intuitive**, **complete**, **powerful**, and **fast**. The whole interface is built around that principle:
10
10
 
11
11
  - **Hint navigation** — every visible row carries a number hint (1–9, 0); pressing it acts on that row immediately, no cursor movement needed.
12
- - **Live search in popups** — command and history popups open in `/` search mode; type to distill the list, then pick one of the few remaining results with a number. return select the first. auto selection of a single filter result.
12
+ - **Live search in popups** — popups open in `/` search mode; type to distill the list, then pick one of the few remaining results with a number. return key selects the first. auto selection of a single filter result.
13
13
  - **Frequency-ordered history** — directory and command history are ranked by use count, so the most likely choice is always near the top.
14
14
 
15
15
  Filecon runs on Typr, whose self-contained terminal layer ships its own precompiled terminfo key table and MIME-type database in the gem — no ncurses or external tooling required — so Filecon works on any raw, ANSI-capable terminal (including Termux).
@@ -33,6 +33,8 @@ Filecon runs on Typr, whose self-contained terminal layer ships its own precompi
33
33
  gem install filecon
34
34
  ```
35
35
 
36
+ Requires `bash` (all shell commands run under `bash -c`, never `sh`; aliases and rc files do not resolve — full commands and bash syntax only) and `rsync` for transfers.
37
+
36
38
  ## Usage
37
39
 
38
40
  ```bash
@@ -85,9 +87,17 @@ Command and history popups open in `/` search mode. Type a few characters to **d
85
87
 
86
88
  ### Configuration
87
89
 
88
- Config is stored at `~/.config/filecon/config.yaml` (YAML). On first run the bundled, fully documented `config.yaml` is copied there. It includes:
90
+ Config is layered, lowest to highest precedence: the bundled defaults,
91
+ `/etc/filecon/config.yaml` (system-wide, read if present, never written),
92
+ then `~/.config/filecon/config.yaml` (yours). On first run the user file is
93
+ created from the bundled config with every setting commented out except a
94
+ live + prefix with your detected terminal — a documented template that
95
+ overrides nothing else until edited, so new defaults
96
+ flow through on upgrade. Set only what you change; an explicit empty value
97
+ deletes an inherited key. It includes:
89
98
 
90
99
  - Color settings (named colors, 8-bit palette, RGB triplets, base-palette overrides) — including the `colors.commands` pair used by the command picker and status line
100
+ - The `e[x]ecute` prompt always tints with the `:shell` scheme (first word red, `--options` blue); the `=` output viewer uses `:generic`
91
101
  - Command definitions per MIME type, with execution-mode prefixes
92
102
  - View configurations (columns that run shell commands)
93
103
  - History limits
data/bin/filecon CHANGED
@@ -2,16 +2,17 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "tmpdir"
5
- require "yaml"
6
5
  require_relative "../lib/filecon"
7
6
 
8
7
  log = nil
9
8
  begin
9
+ abort "filecon requires bash" unless system("bash", "--version", out: File::NULL, err: File::NULL)
10
10
  config_dir = File.expand_path("~/.config/filecon")
11
11
  config_path = File.join(config_dir, "config.yaml")
12
+ bundled_path = File.join(__dir__, "../config.yaml")
12
13
  unless File.exist?(config_path)
13
14
  FileUtils.mkpath config_dir
14
- FileUtils.cp File.join(__dir__, "../config.yaml"), config_path
15
+ File.write config_path, Filecon.config_template(File.read(bundled_path))
15
16
  end
16
17
  state_dir = File.expand_path("~/.local/share/filecon")
17
18
  FileUtils.mkpath state_dir
@@ -23,7 +24,7 @@ begin
23
24
 
24
25
  parse = ->(p) { File.readlines(p).map { |l| s = l.chomp.split(",", 2); [s[0].to_i, s[1]] } rescue [] }
25
26
 
26
- config = YAML.safe_load(File.read(config_path), permitted_classes: [Symbol]) || {}
27
+ config = Filecon.load_config(bundled_path, "/etc/filecon/config.yaml", config_path)
27
28
 
28
29
  Typr.init config["colors"]["default"]
29
30
  Typr.clear
data/config.yaml CHANGED
@@ -2,28 +2,23 @@
2
2
  # Filecon Configuration
3
3
  # =============================================================================
4
4
  #
5
- # This is the default configuration file for Filecon, a file and command manager that lives between terminal and shell.
6
- #
7
- # All settings are optional. Filecon will use sensible defaults if values
8
- # are not specified.
5
+ # All settings are optional. Defaults are commented out.
9
6
  #
10
7
  # =============================================================================
11
8
 
12
9
  # Maximum number of history entries to retain.
13
- # Older entries beyond this limit are automatically pruned.
14
10
  max_history: 10000
15
11
 
12
+ # most frequent entries to lead history lists before most recent entries
16
13
  top_history: 6
14
+
17
15
  # When true, active filters also hide directories that don't match.
18
16
  filter_dirs: true
19
17
 
20
-
21
18
  # =============================================================================
22
19
  # COLORS
23
20
  # =============================================================================
24
21
  #
25
- # Customize the color scheme for different file types in the browser view.
26
- #
27
22
  # Color values can be:
28
23
  # - Named colors:
29
24
  # Basic: black, red, green, yellow, blue, magenta, cyan, white, grey
@@ -53,8 +48,7 @@ filter_dirs: true
53
48
  # dir_history directory history picker: [name, path] columns
54
49
  # cmd_history command history picker: [prefix, command] columns
55
50
  # top_history top-history field background color
56
- # help help text title color
57
- # help_border help text border: [fg, bg]
51
+ # error error popup: [fg, bg]
58
52
  #
59
53
  # Usage:
60
54
  # Foreground color only: "blue"
@@ -63,7 +57,7 @@ filter_dirs: true
63
57
  # =============================================================================
64
58
 
65
59
  colors:
66
- # Base palette (comment out to use terminal colors)
60
+ # Base palette (comment out in bundled config to use terminal colors)
67
61
  black: [21, 21, 21]
68
62
  red: [172, 65, 66]
69
63
  green: [126, 141, 80]
@@ -73,24 +67,30 @@ colors:
73
67
  cyan: [125, 213, 207]
74
68
  white: [208, 208, 208]
75
69
 
76
- default: [white, black]
77
- commands: [green, red]
70
+ default: [white, black] # Global default foreground/background [fg, bg]
71
+ border: [white, black] # Widget borders
72
+ header: [black, grey50] # Column/panel headers
73
+ question: green # Prompt question text
74
+ answer: yellow # Typed input text
75
+ selected: grey25 # Highlighted / selected row background
76
+ alternate: grey10 # Alternating row background in lists
77
+ hints: [white, black] # Hint numbers/digits
78
+ commands: [green, red] # Command picker and status line: [name, args] colors
78
79
  types:
79
80
  image: blue
80
81
  audio: green
81
82
  video: cyan
82
83
  directory: yellow
83
84
  executable: red
84
- actions:
85
+ actions: # Named action colors: filter, sort, views
85
86
  filter: red
86
87
  sort: blue
87
88
  views: green
88
- dir_history: [yellow, brown]
89
- cmd_history: [red, blue]
90
- top_history: grey20
91
- help: yellow
92
- help_border: [white, grey10]
93
-
89
+ dir_history: [yellow, brown] # Directory history picker: [name, path] columns
90
+ cmd_history: [red, blue] # Command history picker: [prefix, command] columns
91
+ top_history: grey20 # Top-history field background color
92
+ search: grey20 # Search-match highlight (text viewer)
93
+ error: [white, dark_red] # Error popup: [fg, bg]
94
94
 
95
95
  # =============================================================================
96
96
  # PREFIXES
@@ -100,17 +100,16 @@ colors:
100
100
  # a format string. %s is replaced with the command body.
101
101
  #
102
102
  # Format strings starting with ! are evaluated as Ruby and their result is
103
- # read; every other format is a terminal window, started in its own process
104
- # group through the shell (so $TERM and friends expand from the environment)
105
- # and left running while the browser keeps going.
103
+ # read; every other format runs under bash. Aliases and rc files do not
104
+ # resolve anywhere; use full commands and bash syntax.
106
105
  #
107
106
  # =============================================================================
108
107
 
109
108
  prefixes:
110
109
  "!": "!StringIO.new((%s).to_s)"
111
- "|": "!open('|%s 2>&1')"
112
- "+": "$TERM --hold %s"
113
- "=": "!Text.new(top: 1, right:-1, bottom:-2, header: '%s', input: open('|%s 2>&1')).pick :none"
110
+ "|": "!IO.popen('%s 2>&1', 'r', in: File::NULL)"
111
+ "=": "!Text.new(top: 1, right:-1, bottom:-2, header: '%s', colors: {regex: :generic}, input: open('|%s 2>&1')).pick :none"
112
+ "-": "!Text.new(top: 1, right:-1, bottom:-2, header: '%s', input: open('|%s 2>&1')).pick :none"
114
113
 
115
114
  # =============================================================================
116
115
  # BINDINGS
@@ -118,13 +117,10 @@ prefixes:
118
117
  #
119
118
  # Key bindings for the status bar. Each binding maps a label (with the key
120
119
  # character in brackets) to either `!method` — a method call, dispatched
121
- # directly — or any prefixed command, which is substituted with `build` and run
122
- # like a command from the file list (`%name` resolves to the current directory).
120
+ # directly — or any prefixed command, which is run
121
+ # like any command (`%name` resolves to the current directory).
123
122
  # Commands from bindings are never added to the command history.
124
123
  #
125
- # Only printable single characters work as key bindings.
126
- # Non-printable keys (arrows, escape, return, etc.) are hardcoded.
127
- #
128
124
  # =============================================================================
129
125
 
130
126
  bindings:
@@ -151,9 +147,7 @@ bindings:
151
147
  # COMMANDS
152
148
  # =============================================================================
153
149
  #
154
- # Define commands available for different file types. Each file type (MIME type
155
- # category) maps to a set of named commands that can be executed on files of
156
- # that type.
150
+ # Define commands for different MIME types.
157
151
  #
158
152
  # SUBSTITUTION VARIABLES:
159
153
  #
@@ -186,21 +180,18 @@ bindings:
186
180
 
187
181
  commands:
188
182
  default:
189
- # copy: "|rsync --progress %name %dir"
190
183
  info: "=file %name"
191
- symlink: "ln -s %name %dir"
192
184
  copy: "!transfer(:copy, %name, %dir)"
193
185
  move: "!transfer(:move, %name, %dir)"
194
- # copy_shell: "|cp -R %name %dir"
195
- # move_shell: "|mv %name %dir"
196
186
  rename: "|mv %name %str"
197
187
  delete: "|rm -rf %name"
188
+ symlink: "ln -s %name %dir"
198
189
  open_with: "|%cmd %name %str"
199
- # open_with_terminal: "+%cmd %name %str"
190
+ open_in_terminal: "+%cmd %name %str"
200
191
  xdg_open: "|xdg-open %name"
201
192
 
202
193
  executable:
203
- whatis: "=whatis %name"
194
+ whatis: "|whatis %name"
204
195
  help: "=%name --help"
205
196
  manual: "+man %name"
206
197
  run: "|%name %str"
@@ -217,14 +208,9 @@ commands:
217
208
  new_file: "|touch %name/%str"
218
209
  new_dir: "|mkdir %name/%str"
219
210
  shell: "+$SHELL "
220
- # shell: "+$SHELL -c 'cd %name && exec $SHELL'"
221
- # zsh: "+zsh -c 'cd %name && exec zsh'"
222
211
  disk_use: "|du -hs %name | cut -wf1"
223
212
  play: "+mpv %name"
224
213
 
225
- # text:
226
- # count: "|wc -l %name | cut -wf1"
227
-
228
214
  troff:
229
215
  manual: "+man %name"
230
216
 
@@ -251,7 +237,7 @@ commands:
251
237
  "x-python":
252
238
  run: "+python3 %name"
253
239
 
254
- "x PERL":
240
+ "x-perl":
255
241
  run: "+perl %name"
256
242
 
257
243
  "x-shellscript":
@@ -278,9 +264,9 @@ commands:
278
264
  "x-c++":
279
265
  run: "+g++ %name && ./a.out"
280
266
 
281
- # markdown:
282
- # view: "+glow %name"
283
- # edit: "+micro %name"
267
+ markdown:
268
+ view: "=glow %name"
269
+ edit: "+micro %name"
284
270
 
285
271
  json:
286
272
  view: "=cat %name | jq ."
@@ -305,6 +291,14 @@ commands:
305
291
  list: "=tar -tf %name"
306
292
  extract: "|tar -xf %name -C %dir"
307
293
 
294
+ "x-7z-compressed":
295
+ list: "=7z l %name"
296
+ extract: "|7z x -y %name -o%dir"
297
+
298
+ "vnd.rar":
299
+ list: "=unrar l %name"
300
+ extract: "|unrar x -o+ %name %dir"
301
+
308
302
  "epub+zip":
309
303
  view: "+folio %name"
310
304
 
@@ -325,7 +319,6 @@ commands:
325
319
  # View values can be:
326
320
  # - true Use the built-in column for this field
327
321
  # - A string Command to execute (with %name substitution)
328
- # - A symbol Reference to a system field (e.g., :size, :type, :subtype)
329
322
  #
330
323
  # Prefix ! to evaluate as Ruby
331
324
  #
data/filecon.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH FILECON 1 "2026-09-30" "filecon 0.5.1" "User Commands"
1
+ .TH FILECON 1 "2026-10-11" "filecon 0.6.0" "User Commands"
2
2
  .SH NAME
3
3
  filecon \- a file and command manager that lives between terminal and shell
4
4
  .SH SYNOPSIS
@@ -93,12 +93,23 @@ first, then the remaining entries in most-recent order. The
93
93
  setting in config.yaml (default 10) controls how many frequent entries lead
94
94
  the list.
95
95
  .SH CONFIGURATION
96
- On first run, the default
96
+ Configuration is layered, lowest to highest precedence: the bundled
97
97
  .B config.yaml
98
- (YAML format) is copied from the installation directory to
99
- .BR ~/.config/filecon/ .
98
+ in the installation directory,
99
+ .B /etc/filecon/config.yaml
100
+ (system-wide, read if present, never written),
101
+ then the user file below.
102
+ On first run a documented template is written to
103
+ .BR ~/.config/filecon/config.yaml :
104
+ the bundled defaults with every setting commented out except a live +
105
+ prefix with your detected terminal, overriding nothing else until edited.
106
+ Set only what you change; an explicit empty
107
+ value deletes an inherited key.
100
108
  .SS Config File Locations
101
109
  .TP
110
+ .B /etc/filecon/config.yaml
111
+ System-wide overrides (optional)
112
+ .TP
102
113
  .B ~/.config/filecon/config.yaml
103
114
  Main configuration (colors, commands, views)
104
115
  .TP
@@ -1,3 +1,3 @@
1
1
  module Filecon
2
- VERSION = "0.5.1"
2
+ VERSION = "0.6.0"
3
3
  end
data/lib/filecon.rb CHANGED
@@ -6,9 +6,55 @@ end
6
6
 
7
7
  require "fileutils"
8
8
  require "find"
9
+ require "yaml"
10
+ require "tempfile"
9
11
 
10
12
  require_relative "filecon/version"
11
13
 
14
+ module Filecon
15
+
16
+ # Layered configuration: bundled defaults < system (/etc) < user (~/.config).
17
+ # Merges +over+ into +base+: hashes recurse, scalars and arrays replace, an
18
+ # explicit null deletes an inherited key, absent keys fall through.
19
+ def self.layer base, over
20
+ over.each do |k, v|
21
+ if v.nil? and base.key?(k) then base.delete(k)
22
+ elsif v.is_a?(Hash) and base[k].is_a?(Hash) then layer(base[k], v)
23
+ else base[k] = v end
24
+ end
25
+ base
26
+ end
27
+
28
+ # Load and merge configuration layers in order; missing files are skipped.
29
+ # Every present layer must parse to a mapping; anything else raises naming
30
+ # the file, so a broken layer fails loud instead of failing deep in the
31
+ # merge or silently booting defaults.
32
+ def self.load_config *paths
33
+ paths.filter_map { |p|
34
+ next unless p and File.exist?(p)
35
+ cfg = YAML.safe_load(File.read(p), permitted_classes: [Symbol])
36
+ raise "#{p} must contain a YAML mapping, got #{cfg.class}" unless cfg.nil? or cfg.is_a?(Hash)
37
+ cfg
38
+ }.reduce({}) { |merged, cfg| layer(merged, cfg || {}) }
39
+ end
40
+
41
+ # Documented template: every setting commented out, docs kept — except one
42
+ # live + prefix with the detected terminal, so windowed commands work out
43
+ # of the box. Parses to just that override until edited. -e flags ride on
44
+ # the entries that need them; the format itself stays shared.
45
+ def self.config_template text, terminal = nil
46
+ on_path = ->(t) { ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |d| File.executable?(File.join(d, t.split.first)) } }
47
+ terminal ||= ["kitty", "foot", "alacritty -e", "urxvt -e", "xterm -e",
48
+ "konsole -e", "gnome-terminal --", "ghostty -e", "xfce4-terminal -e",
49
+ "wezterm start --", "terminator -e", "st -e"].find { |e| on_path.(e) } || "$TERMINAL"
50
+ body = text.gsub(/^([ \t]*)(?=\S)(?!#)/m, '\1# ')
51
+ stanza = "# Terminal for + commands, detected on first run (edit freely).\nprefixes:\n \"+\": \"#{terminal} bash -c '%s'\"\n\n"
52
+ anchor = "# filter_dirs: true\n"
53
+ body.include?(anchor) ? body.sub(anchor) { |m| m + stanza } : stanza + body
54
+ end
55
+
56
+ end
57
+
12
58
  class Filecon::App < Typr::Browser
13
59
  include Typr
14
60
 
@@ -59,19 +105,9 @@ class Filecon::App < Typr::Browser
59
105
  def execute
60
106
  cmd = @user.cli("> ", redraw: method(:show),
61
107
  history: @cmd_history.map { |_, cmd| cmd },
62
- prefixes: @prefixes&.keys&.join)
108
+ prefixes: @prefixes&.keys&.join, regex: :shell)
63
109
  return unless cmd
64
- record :cmd, cmd unless cmd[0..2] == '!cd'
65
- io = run cmd
66
- if io and @prefixes&.[](cmd[0].to_s)&.start_with? '!'
67
- output = io.read.to_s.scrub.strip
68
- nc = (@colors[:commands] || [:green, :red]).first
69
- @user.show "> #{color_code(nc)}#{cmd[1..-1]}#{color_code(@colors[:default])} : #{output.lines.last}"
70
- show
71
- Typr.read_key
72
- else
73
- show
74
- end
110
+ dispatch cmd
75
111
  end
76
112
 
77
113
  def executables; cd ENV["PATH"].split(":") end
@@ -102,7 +138,7 @@ class Filecon::App < Typr::Browser
102
138
  StringIO.new eval( built[1..-1] ).to_s
103
139
  else
104
140
  cmd = built[0] == ?| ? built : ?| + built
105
- open cmd + " 2>&1"
141
+ shell(cmd + " 2>&1")
106
142
  end
107
143
  cell = io.read(width).to_s.scrub.strip
108
144
  value = coerce_type cell
@@ -142,7 +178,7 @@ class Filecon::App < Typr::Browser
142
178
  bottom: [ pop + commands.count - 1, Typr.height ].min,
143
179
  input:commands, border: :light, format: [:min, :max],
144
180
  borders: { top: nil }, alternate: false,
145
- colors: {columns: @colors[:commands] || [:green, :red] } )
181
+ colors: {columns: @colors[:commands], border: @colors[:border]} )
146
182
  return unless cmd = picker.pick( :row, column: 0 )
147
183
  alt = picker.modifier == :alt
148
184
  commands[ cmd ]
@@ -157,23 +193,7 @@ class Filecon::App < Typr::Browser
157
193
  cmd = build(edited, id, pair.first)
158
194
  return unless cmd
159
195
  end
160
- if cmd[0] == ?|
161
- display = cmd[1..-1]
162
- record :cmd, cmd
163
- io = run( cmd )
164
- output = io.read.to_s.scrub.strip
165
- nc, ac = @colors[:commands] || [:green, :red]
166
- @user.show "> #{color_code(nc)}#{pair.first}#{color_code(ac)}:#{display}" \
167
- "#{color_code(@colors[:default])} : #{output.lines.last}"
168
- show
169
- Typr.read_key
170
- else
171
- record :cmd, cmd unless cmd[0..2] == '!cd'
172
- run( cmd )
173
- end
174
- Array(id).sort.reverse.each { |i| refresh_row i } if id && cmd[0..2] != '!cd' && @directory == dir
175
- @selected[:rows] = [] if id && @directory != dir
176
- show #unless cmd[0] == ?|
196
+ dispatch cmd, name: pair.first, id: id, dir: dir
177
197
  end
178
198
 
179
199
  def refresh_row id
@@ -215,6 +235,7 @@ class Filecon::App < Typr::Browser
215
235
  [path, File.join(dest, File.basename(src), path[(src.length + 1)..-1])] unless path == src }
216
236
  }
217
237
  policy, done, skipped, failed = nil, 0, 0, 0
238
+ err = Tempfile.new("filecon-rsync")
218
239
  pairs.each_with_index do |(src, dst), i|
219
240
  @user.show "#{op} #{i + 1}/#{pairs.size}: #{File.basename(src)}"
220
241
  exists = File.exist?( dst ) || File.symlink?( dst )
@@ -238,13 +259,52 @@ class Filecon::App < Typr::Browser
238
259
  dst = action if action.is_a?( String )
239
260
  if File.directory?( src ) and !File.symlink?( src )
240
261
  FileUtils.mkdir_p( dst )
241
- elsif op == :move
242
- FileUtils.mv( src, dst )
262
+ done += 1
243
263
  else
244
- FileUtils.cp( src, dst )
264
+ if @has_rsync.nil?
265
+ begin
266
+ pid = Process.spawn("rsync", "--version", out: File::NULL, err: File::NULL)
267
+ Process.wait(pid)
268
+ @has_rsync = $?.success?
269
+ rescue Errno::ENOENT
270
+ @has_rsync = false
271
+ end
272
+ end
273
+ if @has_rsync
274
+ FileUtils.mkdir_p(File.dirname(dst))
275
+ cmd = ["rsync", "-a", "--progress", src, dst]
276
+ cmd.insert(2, "--remove-source-files") if op == :move
277
+ begin
278
+ rd, wr = IO.pipe
279
+ pid = Process.spawn(*cmd, out: wr, err: err.path)
280
+ wr.close
281
+ seg, shown = nil, nil
282
+ begin
283
+ loop do
284
+ rd.readpartial(4096).split(/[\r\n]/).each do |line|
285
+ seg = line unless line.empty?
286
+ end
287
+ if seg != shown
288
+ shown = seg
289
+ @user.show "#{op} #{i + 1}/#{pairs.size}: #{shown}"
290
+ end
291
+ end
292
+ rescue EOFError
293
+ end
294
+ rd.close
295
+ Process.wait(pid)
296
+ ok = $?.success?
297
+ rescue Errno::ENOENT
298
+ @has_rsync = false
299
+ end
300
+ end
301
+ unless @has_rsync
302
+ if op == :move then FileUtils.mv(src, dst) else FileUtils.cp(src, dst) end
303
+ ok = true
304
+ end
305
+ if ok then done += 1 else failed += 1 end
306
+ end
245
307
  end
246
- done += 1
247
- end
248
308
  rescue SystemCallError
249
309
  failed += 1
250
310
  end
@@ -255,6 +315,14 @@ class Filecon::App < Typr::Browser
255
315
  end
256
316
  tally = "#{op}: #{done} done, #{skipped} skipped" + ( failed > 0 ? ", #{failed} failed" : "" )
257
317
  @user.show tally
318
+ errs = File.read(err.path).strip
319
+ err.close!
320
+ unless errs.empty?
321
+ Text.new(left: 0.15, right: 0.85, top: 0.3, bottom: 0.7,
322
+ header: "rsync errors", input: StringIO.new(errs),
323
+ colors: { default: @colors[:error], border: @colors[:border] },
324
+ alternate: false).pick(:none)
325
+ end
258
326
  show
259
327
  Typr.read_key
260
328
  tally
@@ -263,12 +331,16 @@ class Filecon::App < Typr::Browser
263
331
  def resolve_conflict src, dst
264
332
  Typr.drain_input
265
333
  info_top = top + 1
266
- info = Grid.new(left:left+1, top:info_top, right:-1, bottom:info_top+2, header:"file exists. overwrite?", input:[["from", File.basename(src), (File.size(src) rescue 0), (File.mtime(src).to_i rescue 0)], ["to", File.basename(dst), (File.size(dst) rescue 0), (File.mtime(dst).to_i rescue 0)]], format:[:min,:min,:min,:min], border: :light, alternate:false, procs:{2=>:magnitudes, 3=>proc{|s| Time.at(s).strftime("%Y-%m-%d %H:%M:%S") rescue "?" }}, colors:{columns:@colors[:commands] || [:green, :red]})
334
+ bg = [Array(@colors[:default])[0], @colors[:black]]
335
+ info = Grid.new(left:left+1, top:info_top, bottom:info_top+2, margin:' ', header:"file exists. overwrite?", input:[["from", File.basename(src), (File.size(src) rescue 0), (File.mtime(src).to_i rescue 0)], ["to", File.basename(dst), (File.size(dst) rescue 0), (File.mtime(dst).to_i rescue 0)]], border: :round, alternate:false, procs:{2=>:magnitudes, 3=>proc{|s| Time.at(s).strftime("%Y-%m-%d %H:%M:%S") rescue "?" }}, colors:{columns:@colors[:commands], default: bg, border: @colors[:border]})
267
336
  act_top = info.bottom + 2
268
337
  choices = %w[skip overwrite older smaller size_differs rename]
269
- picker = Grid.new(left:left+1, top:act_top, right:-1, bottom:[act_top+6, Typr.height].min, header:"alt-number remembers choice", input:choices, format:[:min], border: :light, alternate:false, colors:{columns:@colors[:commands] || [:green, :red]})
338
+ picker = Grid.new(left:left+1, top:act_top, bottom:[act_top+6, Typr.height].min, header:"alt-number remembers choice", input:choices, format:[:max], border: :round, alternate:false, colors:{columns:@colors[:commands], default: bg, border: @colors[:border]})
339
+ picker.borders[:top]=' '
340
+ picker.borders[:top_right]='╭'
341
+ picker.borders[:top_left]='│'
270
342
  info.show
271
- idx = picker.pick(:row, column:0)
343
+ idx = picker.pick(:row)
272
344
  show
273
345
  return [:abort, nil] unless idx.is_a?(Integer)
274
346
  mods = picker.modifier
@@ -283,7 +355,7 @@ class Filecon::App < Typr::Browser
283
355
  end
284
356
 
285
357
  def build str, id, name=nil
286
- nc, ac = @colors[:commands] || [:green, :red]
358
+ nc, ac = @colors[:commands]
287
359
  reset = color_code( @colors[:default] )
288
360
  prefix = name ? "#{color_code(nc)}#{name}#{color_code(ac)}:" : ""
289
361
  cmd = ""
@@ -297,7 +369,7 @@ class Filecon::App < Typr::Browser
297
369
  @data[id][NAME].gsub(esc){ |m| "\\#{m}" } }.join(sep) :
298
370
  @data[id][NAME].gsub(esc){ |m| "\\#{m}" } )+?"
299
371
  when '%dir', '%file', '%cmd'
300
- @user.show "> #{prefix}#{cmd}\e[5m#{tok}\e[25m#{post}#{reset}"
372
+ @user.show "> #{prefix}#{cmd}#{mode_code(:slow)}#{tok}#{mode_code(:steady)}#{post}#{reset}"
301
373
  result = pick( { '%dir' => :directory, '%file' => :file, '%cmd' => :cmd }[tok] )
302
374
  return unless result
303
375
  esc = cmd[0] == ?! ? /[\\"$#]/ : /[\\"$`]/
@@ -314,19 +386,90 @@ class Filecon::App < Typr::Browser
314
386
  return cmd
315
387
  end
316
388
 
389
+ # Run +cmd+ (a full shell string) under bash, never sh. Accepts the
390
+ # same mode/opts as IO.popen; a leading | marker (Kernel#open heritage)
391
+ # is stripped since the array form needs none.
392
+ def shell cmd, mode="r", **opts
393
+ IO.popen(["bash", "-c", cmd.sub(/\A\|/, "")], mode, **opts)
394
+ end
395
+
317
396
  def run cmd=nil
318
397
  return unless cmd
319
- cmd = @cmd_history[cmd][1] if cmd.is_a? Integer
320
398
  Typr.flush_overtype
321
399
  fmt = @prefixes&.[](cmd[0].to_s)
322
400
  if fmt
323
401
  body = cmd[1..-1]
324
402
  body = body.gsub(/[\\']/) { |m| "\\#{m}" } if fmt =~ /'[^']*%s/
325
403
  result = fmt.gsub('%s') { body }
326
- result.start_with?('!') ? eval(result[1..-1]) : IO.popen(result, pgroup: true)
404
+ result.start_with?('!') ? eval(result[1..-1]) : shell(result, pgroup: true)
327
405
  else
328
- IO.popen(cmd + " 2>&1")
406
+ shell(cmd + " 2>&1", "r", in: File::NULL)
407
+ end
408
+ end
409
+
410
+ # Run a command line from any source — open_file, execute, history.
411
+ # Records history (except !cd), shows pipe and eval results with a
412
+ # keypress pause, reports errors in a popup, and refreshes rows picked
413
+ # from the listing. Returns the run result (or nil).
414
+ def dispatch cmd, name: nil, id: nil, dir: nil
415
+ return unless cmd
416
+ record :cmd, cmd unless cmd[0..2] == '!cd'
417
+ begin
418
+ io = run cmd unless cmd[0] == ?=
419
+ rescue => e
420
+ errs = e.message + "\n" + e.backtrace.first(3).join("\n")
421
+ Text.new(left: 0.15, right: 0.85, top: 0.3, bottom: 0.7,
422
+ header: "command error", input: StringIO.new(errs),
423
+ colors: { default: @colors[:error], border: @colors[:border] },
424
+ alternate: false).pick(:none)
425
+ return
426
+ end
427
+ if cmd[0] == ?=
428
+ body = cmd[1..-1]
429
+ text = Text.new(top: 1, right: -1, bottom: -2, header: body,
430
+ colors: { regex: :generic, border: @colors[:border] }, input: "")
431
+ io = shell("#{body} 2>&1", "r", in: File::NULL)
432
+ begin
433
+ loop do
434
+ chunk = io.readpartial(4096)
435
+ text << chunk
436
+ text.show
437
+ end
438
+ rescue EOFError
439
+ end
440
+ io.close
441
+ text.pick(:none)
442
+ elsif cmd[0] == ?|
443
+ line = io.gets
444
+ if line
445
+ nc, ac = @colors[:commands]
446
+ head = name ? "#{color_code(nc)}#{name}#{color_code(ac)}:" : color_code(nc)
447
+ @user.show "> #{head}#{cmd[1..-1]}#{color_code(@colors[:default])} : #{line.strip}"
448
+ end
449
+ elsif io and io.respond_to?(:read) and
450
+ @prefixes&.[](cmd[0].to_s)&.start_with? '!'
451
+ output = io.read.to_s.scrub.strip
452
+ unless output.empty?
453
+ nc, ac = @colors[:commands]
454
+ head = name ? "#{color_code(nc)}#{name}#{color_code(ac)}:" : color_code(nc)
455
+ @user.show "> #{head}#{cmd[1..-1]}#{color_code(@colors[:default])} : #{output.lines.last}"
456
+ show
457
+ Typr.read_key
458
+ end
329
459
  end
460
+ if io.is_a?(IO) and !io.closed?
461
+ Thread.new(io) do |pipe|
462
+ begin
463
+ loop { pipe.readpartial(16384) }
464
+ rescue EOFError, IOError
465
+ end
466
+ pipe.close rescue nil
467
+ end
468
+ end
469
+ Array(id).sort.reverse.each { |i| refresh_row i } if id && cmd[0..2] != '!cd' && @directory == dir
470
+ @selected[:rows] = [] if id && @directory != dir
471
+ show
472
+ io
330
473
  end
331
474
 
332
475
  def cmd_history; history @cmd_history, :cmd end
@@ -346,12 +489,12 @@ class Filecon::App < Typr::Browser
346
489
  [File.basename(e), e]
347
490
  }
348
491
 
349
- fc = is_cmd ? (@colors[:cmd_history] || [:red, :blue]) : (@colors[:dir_history] || [:yellow, :brown])
492
+ fc = is_cmd ? (@colors[:cmd_history]) : (@colors[:dir_history])
350
493
  fields = {}
351
494
  ordered.each_with_index { |(cnt, e), i|
352
495
  next unless top_set[e]
353
- fields[[i,0]] = [fc[0], @colors[:top_history] || :grey20]
354
- fields[[i,1]] = [fc[1], @colors[:top_history] || :grey20]
496
+ fields[[i,0]] = [fc[0], @colors[:top_history]]
497
+ fields[[i,1]] = [fc[1], @colors[:top_history]]
355
498
  }
356
499
 
357
500
  picker = popup(input: pairs, format:[:min,:max],
@@ -368,12 +511,10 @@ class Filecon::App < Typr::Browser
368
511
  entry = build(edited, nil)
369
512
  return unless entry
370
513
  end
371
- record type, entry
372
-
373
514
  if is_cmd
374
- begin; run entry; rescue => e; Text.new(top: 2, right:-1, bottom:-2, border: :light,
375
- input: StringIO.new(e.message + "\n" + e.backtrace.first(3).join("\n"))).pick :none; end
515
+ dispatch entry
376
516
  else
517
+ record type, entry
377
518
  unless Dir.exist? entry
378
519
  @user.show "no such directory: #{entry}"; Typr.read_key; return
379
520
  end
@@ -461,7 +602,11 @@ class Filecon::App < Typr::Browser
461
602
 
462
603
  def help
463
604
  grid = @user.list
464
- y = color_code (@colors[:help] || :yellow)
605
+ grid.border = :round
606
+ grid.colors[:header] = @colors[:header]
607
+ grid.colors[:border] = @colors[:border]
608
+ y = color_code @colors[:yellow]
609
+ b = color_code @colors[:blue]
465
610
  d = color_code @colors[:default]
466
611
  text = Text.new( input: "\n#{y}FILECON#{d} - a file and command manager that lives between terminal and shell\n\n" \
467
612
  "#{y}HINTS:#{d}\n press a number (1-9,0) to open that file with its default action.\n" \
@@ -470,19 +615,23 @@ class Filecon::App < Typr::Browser
470
615
  " Mark files with m, then Return opens their command list.\n" \
471
616
  " TAB cycles pages of hints.\n\n" \
472
617
  "#{y}COMMANDS:#{d}\n" \
473
- " [e]xecutables browse all executables on your PATH\n" \
474
- " e[x]ecute type a command line and run it in the shell\n" \
475
- " [R]ecurse expand the current directory tree (subdirectories listed inline)\n" \
476
- " [d]irectories jump to a recently visited directory\n" \
477
- " [c]ommands re-run a recent command\n\n" \
618
+ " #{b}[e]xecutables#{d} browse all executables on your PATH\n" \
619
+ " #{b}e[x]ecute#{d} type a command line and run it in the shell\n" \
620
+ " (TAB completes from bash)\n" \
621
+ " #{b}[R]ecurse#{d} expand the listing to every file below (with paths)\n" \
622
+ " #{b}[d]irectories#{d} jump to a recently visited directory\n" \
623
+ " #{b}[c]ommands#{d} re-run a recent command\n\n" \
478
624
  " Directory and command history popups list the most frequently used\n" \
479
625
  " entries first, then the rest in most-recent order. The top_history\n" \
480
626
  " setting in config.yaml (default 10) controls how many most-used entries\n" \
481
- " lead the list.\n\n" \
482
- "#{y}CONFIG:#{d}\n ~/.config/filecon/config.yaml\n",
627
+ " lead the list. In command history, Alt+number edits the picked entry instead of running it.\n\n" \
628
+ "#{y}CONFIG:#{d}\n ~/.config/filecon/config.yaml\n /etc/filecon/config.yaml\n" \
629
+ " (bundled defaults ship with the gem; delete your file to reseed its template)\n",
483
630
  top: 2, right: -2, bottom: -3,
484
- colors: { border: @colors[:help_border] || [:white, :grey10] },
485
- border: :light )
631
+ colors: { border: @colors[:border] },
632
+ border: :round )
633
+ text.borders[:top_left] = '┬'
634
+ text.borders[:bottom_left] = '┴'
486
635
  text.left = -> { grid.right + 2 }
487
636
  loop do
488
637
  grid.show
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: filecon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kilian Reitmayr
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-09-30 00:00:00.000000000 Z
10
+ date: 2026-10-11 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: typr
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '1.6'
18
+ version: '1.8'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '1.6'
25
+ version: '1.8'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: unicode-display_width
28
28
  requirement: !ruby/object:Gem::Requirement