filecon 0.5.1 → 0.7.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: 8a432bd710dcfce4c261fb29a0ae260e1114e9b47c6c191fd746542b6579282e
4
+ data.tar.gz: fe87db05c3626b48bf4f7986998cec818973038bf1217f493afd2cbc342ad058
5
5
  SHA512:
6
- metadata.gz: 8409685b7f649c3f4b10b3a0a7eed1ef1ddac5052a374763238865c19ba821b6b5b1918f2d262d39e2b6721acc9def64fffc57bffa79e6763e94a0c0ee944fb5
7
- data.tar.gz: 3af13651f23eda4eb2d74c2011402a959aeb981c31b0533af338c310a340ef2f0ad002d71870b254b92f5b964152c595f1a98e8a594f5f0597a6197384d92710
6
+ metadata.gz: 13b3822013820db54e0aa2a3e85a132caad88448e0ee50e5a31c5e7b10ecfeabb90b0fdabbf1ce410cc503f20b2df9efa26534e867cc16758bbe3d80ada2cf40
7
+ data.tar.gz: d5989090b1e00c0e9aca4dfebe27ae64c2793d22de99146d68489deac175ac433e511e9e3a53b3c7c52812e4b34e65afb02d7473d16fcaac017f683b3988e387
data/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## [vx.y.z] — __DATE__
4
4
 
5
+ ## [v0.7.0] — 2026-10-12
6
+
7
+ ### Changed
8
+ - Requires typr `~> 1.9` — the `!narrow` binding calls `Browser#narrow`, which does not exist in 1.8.x
9
+ - Dropped the two `Typr.flush_overtype` calls (custom view rebuild, command spawn) — typr's overtype guard now lives entirely behind `Grid#pick` selections, so there is nothing for filecon to arm or flush; keys typed during slow rebuilds count as intentional input at the next read
10
+ - `|` commands keep running after filecon exits: pipe writers spawn in their own process group (immune to the exit-time hangup) and the pipe is handed to a detached null-drainer, so writers never hit a closed pipe either
11
+ - Windowed (`+`) commands send the emulator's stderr to /dev/null, so terminal chatter (kitty parse errors, keyboard-protocol notices) no longer prints over the view; the inner command keeps its own terminal for its own errors
12
+
13
+ ## [v0.6.0] — 2026-10-11
14
+
15
+ ### Changed
16
+ - Bottom-bar live narrowing is labeled `[/]:narrow` and wired to `!narrow` (was `[/]:search` / `!search`), matching what it does; `search` now means only find-in-text
17
+ - Help panels use round borders joined by T-pieces at the seam
18
+ - 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
19
+ - 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
20
+ - 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
21
+ - 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
22
+ - Conflict picker is a single filling column, so no view shows through its rows
23
+ - Conflict popups use round borders on the base black background with all foregrounds kept; errors use the `error:` pair (dark red by default)
24
+ - 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`
25
+ - 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
26
+ - Token blinking routes through `mode_code` instead of inline escapes (byte-identical output)
27
+ - Empty command results stay silent: no status line, no keypress pause
28
+ - 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
29
+
30
+ ### Removed
31
+ - 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
32
+
33
+ ### Added
34
+ - 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
35
+ - Archive list/extract actions for 7z (`x-7z-compressed`) and rar (`vnd.rar`); `-y`/`-o+` keep extracts non-interactive under detached stdin
36
+
37
+ ### Fixed
38
+ - 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
39
+
5
40
  ## [v0.5.1] — 2026-09-30
6
41
 
7
42
  ### 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 narrowing in popups** — popups open in `/` narrow mode; type to narrow 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).
@@ -18,7 +18,7 @@ Filecon runs on Typr, whose self-contained terminal layer ships its own precompi
18
18
 
19
19
  - **Directory browsing with MIME-type detection** — files are typed from a packaged MIME database and colored accordingly, with optional `file --mime-type` magic detection for unknowns
20
20
  - **Fast navigation** — hint numbers (1–9, 0) open rows directly; TAB pages hints beyond the first ten
21
- - **Default `[/]:search` in popups** — command and history popups open in search mode: start typing to distill the list down to the matches, then press a number hint to pick from the few remaining results. The listing itself searches the same way
21
+ - **Default `[/]:narrow` in popups** — command and history popups open in narrow mode: start typing to narrow the list down to the matches, then press a number hint to pick from the few remaining results. The listing itself narrows the same way
22
22
  - **Mouse support** — click a row to open it with its default action, right-click to open its command list, click a binding on the bottom bar or a filter/sort/views column to run that action; the mouse wheel scrolls the listing (pointer-only mode is planned)
23
23
  - **Command execution modes** — commands run in one of four ways, chosen by prefix: `|` status line, `=` text viewer, `+` new terminal window, `!` Ruby eval; per-MIME-type command sets with `%name`, `%dir`, `%file`, `%cmd`, `%str` substitution
24
24
  - **Customizable column views** — view columns run shell commands with `%name` substitution (content preview, char/word/line counts, type info, disk usage, man-page descriptions)
@@ -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
@@ -81,13 +83,21 @@ Click a row to open it with its default action, right-click (or Alt+click) to op
81
83
 
82
84
  ### Popups and search
83
85
 
84
- Command and history popups open in `/` search mode. Type a few characters to **distill** the list down to the matching entries; when only a handful remain, press the number hint of the one you want. Large lists can be narrowed in a keystroke or two. The main listing searches the same way when you press `/`.
86
+ Command and history popups open in `/` narrow mode. Type a few characters to **narrow** the list down to the matching entries; when only a handful remain, press the number hint of the one you want. Large lists can be narrowed in a keystroke or two. The main listing narrows the same way when you press `/`.
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
@@ -38,24 +33,6 @@ filter_dirs: true
38
33
  # comment these lines out (or delete them) to use
39
34
  # the terminal colors instead
40
35
  #
41
- # Color settings passed to Typr spaces:
42
- # default global default foreground/background [fg, bg]
43
- # border widget borders
44
- # header column/panel headers
45
- # question prompt question text
46
- # answer typed input text
47
- # selected highlighted / selected row background
48
- # alternate alternating row background in lists
49
- # hints hint numbers/digits
50
- # actions named action colors: filter, sort, views
51
- # commands command picker and status line: [name, args] colors
52
- # search search-match highlight (text viewer)
53
- # dir_history directory history picker: [name, path] columns
54
- # cmd_history command history picker: [prefix, command] columns
55
- # top_history top-history field background color
56
- # help help text title color
57
- # help_border help text border: [fg, bg]
58
- #
59
36
  # Usage:
60
37
  # Foreground color only: "blue"
61
38
  # Foreground + background: ["white", "black"]
@@ -63,7 +40,7 @@ filter_dirs: true
63
40
  # =============================================================================
64
41
 
65
42
  colors:
66
- # Base palette (comment out to use terminal colors)
43
+ # Base palette (comment out in bundled config to use terminal colors)
67
44
  black: [21, 21, 21]
68
45
  red: [172, 65, 66]
69
46
  green: [126, 141, 80]
@@ -73,24 +50,30 @@ colors:
73
50
  cyan: [125, 213, 207]
74
51
  white: [208, 208, 208]
75
52
 
76
- default: [white, black]
77
- commands: [green, red]
53
+ default: [white, black] # Global default foreground/background [fg, bg]
54
+ border: [white, black] # Widget borders
55
+ header: [black, grey50] # Column/panel headers
56
+ question: green # Prompt question text
57
+ answer: yellow # Typed input text
58
+ selected: grey25 # Highlighted / selected row background
59
+ alternate: grey10 # Alternating row background in lists
60
+ hints: [white, black] # Hint numbers/digits
61
+ commands: [green, red] # Command picker and status line: [name, args] colors
78
62
  types:
79
63
  image: blue
80
64
  audio: green
81
65
  video: cyan
82
66
  directory: yellow
83
67
  executable: red
84
- actions:
68
+ actions: # Named action colors: filter, sort, views
85
69
  filter: red
86
70
  sort: blue
87
71
  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
-
72
+ dir_history: [yellow, brown] # Directory history picker: [name, path] columns
73
+ cmd_history: [red, blue] # Command history picker: [prefix, command] columns
74
+ top_history: grey20 # Top-history field background color
75
+ search: grey20 # Search-match highlight (text viewer)
76
+ error: [white, dark_red] # Error popup: [fg, bg]
94
77
 
95
78
  # =============================================================================
96
79
  # PREFIXES
@@ -100,17 +83,16 @@ colors:
100
83
  # a format string. %s is replaced with the command body.
101
84
  #
102
85
  # 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.
86
+ # read; every other format runs under bash. Aliases and rc files do not
87
+ # resolve anywhere; use full commands and bash syntax.
106
88
  #
107
89
  # =============================================================================
108
90
 
109
91
  prefixes:
110
92
  "!": "!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"
93
+ "|": "!IO.popen('%s 2>&1', 'r', in: File::NULL, pgroup: true)"
94
+ "=": "!Text.new(top: 1, right:-1, bottom:-2, header: '%s', colors: {regex: :generic}, input: open('|%s 2>&1')).pick :none"
95
+ "-": "!Text.new(top: 1, right:-1, bottom:-2, header: '%s', input: open('|%s 2>&1')).pick :none"
114
96
 
115
97
  # =============================================================================
116
98
  # BINDINGS
@@ -118,18 +100,15 @@ prefixes:
118
100
  #
119
101
  # Key bindings for the status bar. Each binding maps a label (with the key
120
102
  # 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).
103
+ # directly — or any prefixed command, which is run
104
+ # like any command (`%name` resolves to the current directory).
123
105
  # Commands from bindings are never added to the command history.
124
106
  #
125
- # Only printable single characters work as key bindings.
126
- # Non-printable keys (arrows, escape, return, etc.) are hardcoded.
127
- #
128
107
  # =============================================================================
129
108
 
130
109
  bindings:
131
110
  "[h]elp": "!help"
132
- "[/]:search": "!search"
111
+ "[/]:narrow": "!narrow"
133
112
  "[f]ilter": "!filter_view"
134
113
  "[s]ort": "!sort_view"
135
114
  "[v]iews": "!views"
@@ -151,9 +130,7 @@ bindings:
151
130
  # COMMANDS
152
131
  # =============================================================================
153
132
  #
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.
133
+ # Define commands for different MIME types.
157
134
  #
158
135
  # SUBSTITUTION VARIABLES:
159
136
  #
@@ -186,21 +163,18 @@ bindings:
186
163
 
187
164
  commands:
188
165
  default:
189
- # copy: "|rsync --progress %name %dir"
190
166
  info: "=file %name"
191
- symlink: "ln -s %name %dir"
192
167
  copy: "!transfer(:copy, %name, %dir)"
193
168
  move: "!transfer(:move, %name, %dir)"
194
- # copy_shell: "|cp -R %name %dir"
195
- # move_shell: "|mv %name %dir"
196
169
  rename: "|mv %name %str"
197
170
  delete: "|rm -rf %name"
171
+ symlink: "ln -s %name %dir"
198
172
  open_with: "|%cmd %name %str"
199
- # open_with_terminal: "+%cmd %name %str"
173
+ open_in_terminal: "+%cmd %name %str"
200
174
  xdg_open: "|xdg-open %name"
201
175
 
202
176
  executable:
203
- whatis: "=whatis %name"
177
+ whatis: "|whatis %name"
204
178
  help: "=%name --help"
205
179
  manual: "+man %name"
206
180
  run: "|%name %str"
@@ -217,14 +191,9 @@ commands:
217
191
  new_file: "|touch %name/%str"
218
192
  new_dir: "|mkdir %name/%str"
219
193
  shell: "+$SHELL "
220
- # shell: "+$SHELL -c 'cd %name && exec $SHELL'"
221
- # zsh: "+zsh -c 'cd %name && exec zsh'"
222
194
  disk_use: "|du -hs %name | cut -wf1"
223
195
  play: "+mpv %name"
224
196
 
225
- # text:
226
- # count: "|wc -l %name | cut -wf1"
227
-
228
197
  troff:
229
198
  manual: "+man %name"
230
199
 
@@ -251,7 +220,7 @@ commands:
251
220
  "x-python":
252
221
  run: "+python3 %name"
253
222
 
254
- "x PERL":
223
+ "x-perl":
255
224
  run: "+perl %name"
256
225
 
257
226
  "x-shellscript":
@@ -278,9 +247,9 @@ commands:
278
247
  "x-c++":
279
248
  run: "+g++ %name && ./a.out"
280
249
 
281
- # markdown:
282
- # view: "+glow %name"
283
- # edit: "+micro %name"
250
+ markdown:
251
+ view: "=glow %name"
252
+ edit: "+micro %name"
284
253
 
285
254
  json:
286
255
  view: "=cat %name | jq ."
@@ -305,6 +274,14 @@ commands:
305
274
  list: "=tar -tf %name"
306
275
  extract: "|tar -xf %name -C %dir"
307
276
 
277
+ "x-7z-compressed":
278
+ list: "=7z l %name"
279
+ extract: "|7z x -y %name -o%dir"
280
+
281
+ "vnd.rar":
282
+ list: "=unrar l %name"
283
+ extract: "|unrar x -o+ %name %dir"
284
+
308
285
  "epub+zip":
309
286
  view: "+folio %name"
310
287
 
@@ -325,7 +302,6 @@ commands:
325
302
  # View values can be:
326
303
  # - true Use the built-in column for this field
327
304
  # - A string Command to execute (with %name substitution)
328
- # - A symbol Reference to a system field (e.g., :size, :type, :subtype)
329
305
  #
330
306
  # Prefix ! to evaluate as Ruby
331
307
  #
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-12" "filecon 0.7.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.7.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
@@ -93,7 +129,6 @@ class Filecon::App < Typr::Browser
93
129
  super
94
130
  return
95
131
  end
96
- Typr.flush_overtype
97
132
  data = @data.map.with_index{ |row, id| row + view.values.map{ |cmd|
98
133
  next unless cmd.is_a?(String)
99
134
  built = build cmd, id
@@ -102,7 +137,7 @@ class Filecon::App < Typr::Browser
102
137
  StringIO.new eval( built[1..-1] ).to_s
103
138
  else
104
139
  cmd = built[0] == ?| ? built : ?| + built
105
- open cmd + " 2>&1"
140
+ shell(cmd + " 2>&1")
106
141
  end
107
142
  cell = io.read(width).to_s.scrub.strip
108
143
  value = coerce_type cell
@@ -142,7 +177,7 @@ class Filecon::App < Typr::Browser
142
177
  bottom: [ pop + commands.count - 1, Typr.height ].min,
143
178
  input:commands, border: :light, format: [:min, :max],
144
179
  borders: { top: nil }, alternate: false,
145
- colors: {columns: @colors[:commands] || [:green, :red] } )
180
+ colors: {columns: @colors[:commands], border: @colors[:border]} )
146
181
  return unless cmd = picker.pick( :row, column: 0 )
147
182
  alt = picker.modifier == :alt
148
183
  commands[ cmd ]
@@ -157,23 +192,7 @@ class Filecon::App < Typr::Browser
157
192
  cmd = build(edited, id, pair.first)
158
193
  return unless cmd
159
194
  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] == ?|
195
+ dispatch cmd, name: pair.first, id: id, dir: dir
177
196
  end
178
197
 
179
198
  def refresh_row id
@@ -215,6 +234,7 @@ class Filecon::App < Typr::Browser
215
234
  [path, File.join(dest, File.basename(src), path[(src.length + 1)..-1])] unless path == src }
216
235
  }
217
236
  policy, done, skipped, failed = nil, 0, 0, 0
237
+ err = Tempfile.new("filecon-rsync")
218
238
  pairs.each_with_index do |(src, dst), i|
219
239
  @user.show "#{op} #{i + 1}/#{pairs.size}: #{File.basename(src)}"
220
240
  exists = File.exist?( dst ) || File.symlink?( dst )
@@ -238,13 +258,52 @@ class Filecon::App < Typr::Browser
238
258
  dst = action if action.is_a?( String )
239
259
  if File.directory?( src ) and !File.symlink?( src )
240
260
  FileUtils.mkdir_p( dst )
241
- elsif op == :move
242
- FileUtils.mv( src, dst )
261
+ done += 1
243
262
  else
244
- FileUtils.cp( src, dst )
263
+ if @has_rsync.nil?
264
+ begin
265
+ pid = Process.spawn("rsync", "--version", out: File::NULL, err: File::NULL)
266
+ Process.wait(pid)
267
+ @has_rsync = $?.success?
268
+ rescue Errno::ENOENT
269
+ @has_rsync = false
270
+ end
271
+ end
272
+ if @has_rsync
273
+ FileUtils.mkdir_p(File.dirname(dst))
274
+ cmd = ["rsync", "-a", "--progress", src, dst]
275
+ cmd.insert(2, "--remove-source-files") if op == :move
276
+ begin
277
+ rd, wr = IO.pipe
278
+ pid = Process.spawn(*cmd, out: wr, err: err.path)
279
+ wr.close
280
+ seg, shown = nil, nil
281
+ begin
282
+ loop do
283
+ rd.readpartial(4096).split(/[\r\n]/).each do |line|
284
+ seg = line unless line.empty?
285
+ end
286
+ if seg != shown
287
+ shown = seg
288
+ @user.show "#{op} #{i + 1}/#{pairs.size}: #{shown}"
289
+ end
290
+ end
291
+ rescue EOFError
292
+ end
293
+ rd.close
294
+ Process.wait(pid)
295
+ ok = $?.success?
296
+ rescue Errno::ENOENT
297
+ @has_rsync = false
298
+ end
299
+ end
300
+ unless @has_rsync
301
+ if op == :move then FileUtils.mv(src, dst) else FileUtils.cp(src, dst) end
302
+ ok = true
303
+ end
304
+ if ok then done += 1 else failed += 1 end
305
+ end
245
306
  end
246
- done += 1
247
- end
248
307
  rescue SystemCallError
249
308
  failed += 1
250
309
  end
@@ -255,6 +314,14 @@ class Filecon::App < Typr::Browser
255
314
  end
256
315
  tally = "#{op}: #{done} done, #{skipped} skipped" + ( failed > 0 ? ", #{failed} failed" : "" )
257
316
  @user.show tally
317
+ errs = File.read(err.path).strip
318
+ err.close!
319
+ unless errs.empty?
320
+ Text.new(left: 0.15, right: 0.85, top: 0.3, bottom: 0.7,
321
+ header: "rsync errors", input: StringIO.new(errs),
322
+ colors: { default: @colors[:error], border: @colors[:border] },
323
+ alternate: false).pick(:none)
324
+ end
258
325
  show
259
326
  Typr.read_key
260
327
  tally
@@ -263,12 +330,16 @@ class Filecon::App < Typr::Browser
263
330
  def resolve_conflict src, dst
264
331
  Typr.drain_input
265
332
  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]})
333
+ bg = [Array(@colors[:default])[0], @colors[:black]]
334
+ 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
335
  act_top = info.bottom + 2
268
336
  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]})
337
+ 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]})
338
+ picker.borders[:top]=' '
339
+ picker.borders[:top_right]='╭'
340
+ picker.borders[:top_left]='│'
270
341
  info.show
271
- idx = picker.pick(:row, column:0)
342
+ idx = picker.pick(:row)
272
343
  show
273
344
  return [:abort, nil] unless idx.is_a?(Integer)
274
345
  mods = picker.modifier
@@ -283,7 +354,7 @@ class Filecon::App < Typr::Browser
283
354
  end
284
355
 
285
356
  def build str, id, name=nil
286
- nc, ac = @colors[:commands] || [:green, :red]
357
+ nc, ac = @colors[:commands]
287
358
  reset = color_code( @colors[:default] )
288
359
  prefix = name ? "#{color_code(nc)}#{name}#{color_code(ac)}:" : ""
289
360
  cmd = ""
@@ -297,7 +368,7 @@ class Filecon::App < Typr::Browser
297
368
  @data[id][NAME].gsub(esc){ |m| "\\#{m}" } }.join(sep) :
298
369
  @data[id][NAME].gsub(esc){ |m| "\\#{m}" } )+?"
299
370
  when '%dir', '%file', '%cmd'
300
- @user.show "> #{prefix}#{cmd}\e[5m#{tok}\e[25m#{post}#{reset}"
371
+ @user.show "> #{prefix}#{cmd}#{mode_code(:slow)}#{tok}#{mode_code(:steady)}#{post}#{reset}"
301
372
  result = pick( { '%dir' => :directory, '%file' => :file, '%cmd' => :cmd }[tok] )
302
373
  return unless result
303
374
  esc = cmd[0] == ?! ? /[\\"$#]/ : /[\\"$`]/
@@ -314,19 +385,90 @@ class Filecon::App < Typr::Browser
314
385
  return cmd
315
386
  end
316
387
 
388
+ # Run +cmd+ (a full shell string) under bash, never sh. Accepts the
389
+ # same mode/opts as IO.popen; a leading | marker (Kernel#open heritage)
390
+ # is stripped since the array form needs none.
391
+ def shell cmd, mode="r", **opts
392
+ IO.popen(["bash", "-c", cmd.sub(/\A\|/, "")], mode, **opts)
393
+ end
394
+
317
395
  def run cmd=nil
318
396
  return unless cmd
319
- cmd = @cmd_history[cmd][1] if cmd.is_a? Integer
320
- Typr.flush_overtype
321
397
  fmt = @prefixes&.[](cmd[0].to_s)
322
398
  if fmt
323
399
  body = cmd[1..-1]
324
400
  body = body.gsub(/[\\']/) { |m| "\\#{m}" } if fmt =~ /'[^']*%s/
325
401
  result = fmt.gsub('%s') { body }
326
- result.start_with?('!') ? eval(result[1..-1]) : IO.popen(result, pgroup: true)
402
+ result.start_with?('!') ? eval(result[1..-1]) : shell(result + " 2>/dev/null", pgroup: true)
327
403
  else
328
- IO.popen(cmd + " 2>&1")
404
+ shell(cmd + " 2>&1", "r", in: File::NULL)
405
+ end
406
+ end
407
+
408
+ # Run a command line from any source — open_file, execute, history.
409
+ # Records history (except !cd), shows pipe and eval results with a
410
+ # keypress pause, reports errors in a popup, and refreshes rows picked
411
+ # from the listing. Returns the run result (or nil).
412
+ def dispatch cmd, name: nil, id: nil, dir: nil
413
+ return unless cmd
414
+ record :cmd, cmd unless cmd[0..2] == '!cd'
415
+ begin
416
+ io = run cmd unless cmd[0] == ?=
417
+ rescue => e
418
+ errs = e.message + "\n" + e.backtrace.first(3).join("\n")
419
+ Text.new(left: 0.15, right: 0.85, top: 0.3, bottom: 0.7,
420
+ header: "command error", input: StringIO.new(errs),
421
+ colors: { default: @colors[:error], border: @colors[:border] },
422
+ alternate: false).pick(:none)
423
+ return
424
+ end
425
+ if cmd[0] == ?=
426
+ body = cmd[1..-1]
427
+ text = Text.new(top: 1, right: -1, bottom: -2, header: body, margin: ' ',
428
+ colors: { regex: :generic, border: @colors[:border] }, input: "")
429
+ io = shell("#{body} 2>&1", "r", in: File::NULL)
430
+ begin
431
+ loop do
432
+ chunk = io.readpartial(4096)
433
+ text << chunk
434
+ text.show
435
+ end
436
+ rescue EOFError
437
+ end
438
+ io.close
439
+ text.pick(:none)
440
+ elsif cmd[0] == ?|
441
+ line = io.gets
442
+ if line
443
+ nc, ac = @colors[:commands]
444
+ head = name ? "#{color_code(nc)}#{name}#{color_code(ac)}:" : color_code(nc)
445
+ @user.show "> #{head}#{cmd[1..-1]}#{color_code(@colors[:default])} : #{line.strip}"
446
+ end
447
+ Process.detach(Process.spawn("cat", in: io, out: File::NULL, pgroup: true, close_others: true)) rescue nil
448
+ elsif io and io.respond_to?(:read) and
449
+ @prefixes&.[](cmd[0].to_s)&.start_with? '!'
450
+ output = io.read.to_s.scrub.strip
451
+ unless output.empty?
452
+ nc, ac = @colors[:commands]
453
+ head = name ? "#{color_code(nc)}#{name}#{color_code(ac)}:" : color_code(nc)
454
+ @user.show "> #{head}#{cmd[1..-1]}#{color_code(@colors[:default])} : #{output.lines.last}"
455
+ show
456
+ Typr.read_key
457
+ end
329
458
  end
459
+ if io.is_a?(IO) and !io.closed?
460
+ Thread.new(io) do |pipe|
461
+ begin
462
+ loop { pipe.readpartial(16384) }
463
+ rescue EOFError, IOError
464
+ end
465
+ pipe.close rescue nil
466
+ end
467
+ end
468
+ Array(id).sort.reverse.each { |i| refresh_row i } if id && cmd[0..2] != '!cd' && @directory == dir
469
+ @selected[:rows] = [] if id && @directory != dir
470
+ show
471
+ io
330
472
  end
331
473
 
332
474
  def cmd_history; history @cmd_history, :cmd end
@@ -346,12 +488,12 @@ class Filecon::App < Typr::Browser
346
488
  [File.basename(e), e]
347
489
  }
348
490
 
349
- fc = is_cmd ? (@colors[:cmd_history] || [:red, :blue]) : (@colors[:dir_history] || [:yellow, :brown])
491
+ fc = is_cmd ? (@colors[:cmd_history]) : (@colors[:dir_history])
350
492
  fields = {}
351
493
  ordered.each_with_index { |(cnt, e), i|
352
494
  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]
495
+ fields[[i,0]] = [fc[0], @colors[:top_history]]
496
+ fields[[i,1]] = [fc[1], @colors[:top_history]]
355
497
  }
356
498
 
357
499
  picker = popup(input: pairs, format:[:min,:max],
@@ -368,12 +510,10 @@ class Filecon::App < Typr::Browser
368
510
  entry = build(edited, nil)
369
511
  return unless entry
370
512
  end
371
- record type, entry
372
-
373
513
  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
514
+ dispatch entry
376
515
  else
516
+ record type, entry
377
517
  unless Dir.exist? entry
378
518
  @user.show "no such directory: #{entry}"; Typr.read_key; return
379
519
  end
@@ -461,7 +601,11 @@ class Filecon::App < Typr::Browser
461
601
 
462
602
  def help
463
603
  grid = @user.list
464
- y = color_code (@colors[:help] || :yellow)
604
+ grid.border = :round
605
+ grid.colors[:header] = @colors[:header]
606
+ grid.colors[:border] = @colors[:border]
607
+ y = color_code @colors[:yellow]
608
+ b = color_code @colors[:blue]
465
609
  d = color_code @colors[:default]
466
610
  text = Text.new( input: "\n#{y}FILECON#{d} - a file and command manager that lives between terminal and shell\n\n" \
467
611
  "#{y}HINTS:#{d}\n press a number (1-9,0) to open that file with its default action.\n" \
@@ -470,19 +614,23 @@ class Filecon::App < Typr::Browser
470
614
  " Mark files with m, then Return opens their command list.\n" \
471
615
  " TAB cycles pages of hints.\n\n" \
472
616
  "#{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" \
617
+ " #{b}[e]xecutables#{d} browse all executables on your PATH\n" \
618
+ " #{b}e[x]ecute#{d} type a command line and run it in the shell\n" \
619
+ " (TAB completes from bash)\n" \
620
+ " #{b}[R]ecurse#{d} expand the listing to every file below (with paths)\n" \
621
+ " #{b}[d]irectories#{d} jump to a recently visited directory\n" \
622
+ " #{b}[c]ommands#{d} re-run a recent command\n\n" \
478
623
  " Directory and command history popups list the most frequently used\n" \
479
624
  " entries first, then the rest in most-recent order. The top_history\n" \
480
625
  " 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",
626
+ " lead the list. In command history, Alt+number edits the picked entry instead of running it.\n\n" \
627
+ "#{y}CONFIG:#{d}\n ~/.config/filecon/config.yaml\n /etc/filecon/config.yaml\n" \
628
+ " (bundled defaults ship with the gem; delete your file to reseed its template)\n",
483
629
  top: 2, right: -2, bottom: -3,
484
- colors: { border: @colors[:help_border] || [:white, :grey10] },
485
- border: :light )
630
+ colors: { border: @colors[:border] },
631
+ border: :round )
632
+ text.borders[:top_left] = '┬'
633
+ text.borders[:bottom_left] = '┴'
486
634
  text.left = -> { grid.right + 2 }
487
635
  loop do
488
636
  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.7.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-12 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.9'
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.9'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: unicode-display_width
28
28
  requirement: !ruby/object:Gem::Requirement