N/A
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,9 +1,3 @@
|
|||||||
## v2 - latest fix
|
|
||||||
|
|
||||||
- Fixed `Ctrl+C` exit handling so the Bash wrapper reliably changes to the directory where TUI was closed, including when Bash `set -e` is enabled.
|
|
||||||
- Action commands are evaluated as complete Bash commands, so `&&` and `||` chains work as they do directly in the terminal.
|
|
||||||
- Updated the example ZIP action to demonstrate success/failure prompts with `&&` / `||`.
|
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v2.1
|
## v2.1
|
||||||
@@ -30,17 +24,9 @@
|
|||||||
- Added `ls --color`-style colors for directories, symlinks, executables, archives and common image files.
|
- Added `ls --color`-style colors for directories, symlinks, executables, archives and common image files.
|
||||||
- Marked paths remain visible as marked when returning to their directory.
|
- Marked paths remain visible as marked when returning to their directory.
|
||||||
|
|
||||||
### v2 – cursor and block selection update
|
## 2.0.7
|
||||||
- Cursor can now move to the empty line immediately below the last object.
|
|
||||||
- `Ctrl+K K` now behaves like joe: the block end is the row immediately above the cursor.
|
|
||||||
- On the bottom empty line, `Ctrl+K K` therefore selects through the last object.
|
|
||||||
|
|
||||||
## v2 update
|
- Added `tui -v` / `tui --version` for verifying which installed version is running.
|
||||||
- The bottom empty row now visibly shows the cursor.
|
- Fixed Ctrl+C exit so the parent Bash process changes to the directory where TUI was closed.
|
||||||
- When TUI exits normally, the shell integration changes the parent Bash shell to the directory where TUI was closed.
|
- TUI now uses a separate exit status for normal Ctrl+C exit versus shell-action handoff.
|
||||||
|
- Shell actions are read into an array before execution, so commands containing `read`, `&&`, and `||` receive the real terminal stdin and behave like normal Bash commands.
|
||||||
## v2 fix – interactive shell commands and exit directory
|
|
||||||
|
|
||||||
- Fixed interactive action commands: the command loop no longer redirects stdin from the command file.
|
|
||||||
- Commands such as `unzip -l <path> && read -rp "Enter..." || read -rp "FAILED..."` now receive input from the real terminal.
|
|
||||||
- The final TUI directory is always applied to the parent Bash shell when TUI exits, including Ctrl+C/SIGINT.
|
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -94,7 +94,8 @@ keys:
|
|||||||
actions:
|
actions:
|
||||||
list-zip:
|
list-zip:
|
||||||
command:
|
command:
|
||||||
command: 'unzip -l <path> && read -rp "Enter to continue.." || read -rp "FAILED - Enter to continue..."'
|
- unzip -l <path>
|
||||||
|
- read -rp "Tryck Enter för att fortsätta..."
|
||||||
|
|
||||||
move:
|
move:
|
||||||
command: mv -v <path> <dir>
|
command: mv -v <path> <dir>
|
||||||
@@ -164,42 +165,44 @@ not clear the already marked paths.
|
|||||||
|
|
||||||
When an action is triggered, TUI writes the generated commands to a temporary
|
When an action is triggered, TUI writes the generated commands to a temporary
|
||||||
file and exits with a private handoff status. The `tui()` Bash function then
|
file and exits with a private handoff status. The `tui()` Bash function then
|
||||||
executes them in the parent Bash process.
|
executes them.
|
||||||
|
|
||||||
When TUI is closed with `Ctrl+C`, the wrapper changes the parent Bash shell to
|
For example:
|
||||||
the directory where TUI was actually closed.
|
|
||||||
|
|
||||||
Action commands are handed to Bash as complete commands, so normal shell
|
|
||||||
operators such as `&&` and `||` work exactly as they do at the terminal. For
|
|
||||||
example:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
actions:
|
actions:
|
||||||
list-zip:
|
list-zip:
|
||||||
command: 'unzip -l <path> && read -rp "Enter to continue.." || read -rp "FAILED - Enter to continue..."'
|
|
||||||
```
|
|
||||||
|
|
||||||
The complete command is evaluated once. If `unzip` succeeds, the success
|
|
||||||
`read` is executed. If `unzip` fails, the failure `read` is executed.
|
|
||||||
|
|
||||||
For actions containing several separate commands, use a YAML list:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
actions:
|
|
||||||
example:
|
|
||||||
command:
|
command:
|
||||||
- command-one <path>
|
- unzip -l <path>
|
||||||
- command-two <path>
|
- read -rp "Tryck Enter för att fortsätta..."
|
||||||
```
|
```
|
||||||
|
|
||||||
TUI is **not redrawn between commands belonging to the same action**. Each
|
The flow is:
|
||||||
command is inserted into Bash history. If a separate command fails, the
|
|
||||||
remaining commands in that action are not executed; TUI prints the exit code
|
|
||||||
and waits before returning to TUI.
|
|
||||||
|
|
||||||
The shell wrapper uses `pushd`/`popd` so action commands run in the directory
|
```text
|
||||||
shown by TUI without permanently changing the user's Bash directory during
|
TUI
|
||||||
the action.
|
↓
|
||||||
|
unzip -l archive.zip
|
||||||
|
↓
|
||||||
|
read -rp "Tryck Enter..."
|
||||||
|
↓
|
||||||
|
TUI
|
||||||
|
```
|
||||||
|
|
||||||
|
TUI is **not redrawn between commands belonging to the same action**.
|
||||||
|
|
||||||
|
Every command is also inserted into Bash history. If one command fails, the
|
||||||
|
remaining commands in that action are not executed. TUI prints the exit code
|
||||||
|
and waits for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tryck Enter för att fortsätta...
|
||||||
|
```
|
||||||
|
|
||||||
|
before returning to TUI, so an error cannot disappear immediately.
|
||||||
|
|
||||||
|
The shell wrapper uses `pushd`/`popd` so the command runs in the directory
|
||||||
|
shown by TUI without permanently changing the user's Bash working directory.
|
||||||
|
|
||||||
## Security note
|
## Security note
|
||||||
|
|
||||||
@@ -225,8 +228,7 @@ tui
|
|||||||
|
|
||||||
The example configuration is in `tui/example.tui`.
|
The example configuration is in `tui/example.tui`.
|
||||||
|
|
||||||
### Cursor and block selection
|
### Version
|
||||||
|
|
||||||
There is an empty cursor position immediately below the last object in a directory. This makes range selection behave like a text editor.
|
Use `tui -v` or `tui --version` to verify the installed TUI version.
|
||||||
|
|
||||||
`Ctrl+K K` follows joe-style semantics: the end of the selection is the row immediately above the cursor. Thus, when the cursor is on the empty line below the final object, `Ctrl+K K` includes the final object.
|
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ keys:
|
|||||||
|
|
||||||
actions:
|
actions:
|
||||||
list-zip:
|
list-zip:
|
||||||
command: 'unzip -l <path> && read -rp "Enter to continue.." || read -rp "FAILED - Enter to continue..."'
|
command:
|
||||||
|
- unzip -l <path>
|
||||||
|
- read -rp "Tryck Enter för att fortsätta..."
|
||||||
copy:
|
copy:
|
||||||
command: cp -v <path> <dir>copy_<name>
|
command: cp -v <path> <dir>copy_<name>
|
||||||
move:
|
move:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ tui() {
|
|||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
-a|--all) show_all=1 ;;
|
-a|--all) show_all=1 ;;
|
||||||
|
-v|--version) python3 "$tui_python" --version; return $? ;;
|
||||||
*) tui_dir="$arg" ;;
|
*) tui_dir="$arg" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -25,6 +26,7 @@ tui() {
|
|||||||
local rc
|
local rc
|
||||||
local cmd
|
local cmd
|
||||||
local tui_cwd
|
local tui_cwd
|
||||||
|
local -a tui_commands
|
||||||
|
|
||||||
command_file="$(mktemp)"
|
command_file="$(mktemp)"
|
||||||
cwd_file="$(mktemp)"
|
cwd_file="$(mktemp)"
|
||||||
@@ -33,59 +35,43 @@ tui() {
|
|||||||
: > "$command_file"
|
: > "$command_file"
|
||||||
: > "$cwd_file"
|
: > "$cwd_file"
|
||||||
|
|
||||||
# Use an if/else wrapper so this remains safe when the caller has
|
|
||||||
# `set -e` enabled. A Ctrl+C from the child must still return control
|
|
||||||
# here so we can cd to the TUI's final directory.
|
|
||||||
if (( show_all )); then
|
if (( show_all )); then
|
||||||
if TUI_COMMAND_FILE="$command_file" \
|
TUI_COMMAND_FILE="$command_file" \
|
||||||
TUI_CWD_FILE="$cwd_file" \
|
TUI_CWD_FILE="$cwd_file" \
|
||||||
python3 "$tui_python" -a "$tui_dir"; then
|
python3 "$tui_python" -a "$tui_dir"
|
||||||
rc=0
|
|
||||||
else
|
|
||||||
rc=$?
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
if TUI_COMMAND_FILE="$command_file" \
|
TUI_COMMAND_FILE="$command_file" \
|
||||||
TUI_CWD_FILE="$cwd_file" \
|
TUI_CWD_FILE="$cwd_file" \
|
||||||
python3 "$tui_python" "$tui_dir"; then
|
python3 "$tui_python" "$tui_dir"
|
||||||
rc=0
|
|
||||||
else
|
|
||||||
rc=$?
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
rc=$?
|
||||||
|
|
||||||
tui_cwd="$(<"$cwd_file")"
|
tui_cwd="$(<"$cwd_file")"
|
||||||
|
|
||||||
if [[ -z "$tui_cwd" || ! -d "$tui_cwd" ]]; then
|
# Ctrl+C exits TUI. Apply the last directory in the parent Bash
|
||||||
if (( rc != 42 )); then
|
# process, then return to the caller.
|
||||||
rm -f "$command_file" "$cwd_file"
|
if (( rc == 43 )); then
|
||||||
return "$rc"
|
if [[ -n "$tui_cwd" && -d "$tui_cwd" ]]; then
|
||||||
|
cd -- "$tui_cwd" || {
|
||||||
|
rm -f "$command_file" "$cwd_file"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
|
rm -f "$command_file" "$cwd_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( rc != 42 )); then
|
||||||
|
rm -f "$command_file" "$cwd_file"
|
||||||
|
return "$rc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$tui_cwd" || ! -d "$tui_cwd" ]]; then
|
||||||
echo "tui: invalid TUI working directory" >&2
|
echo "tui: invalid TUI working directory" >&2
|
||||||
rm -f "$command_file" "$cwd_file"
|
rm -f "$command_file" "$cwd_file"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Any exit other than SHELL_HANDOFF means TUI is finished. Always
|
|
||||||
# change the parent Bash shell to the directory where TUI was
|
|
||||||
# actually closed. This includes Ctrl+C (status 130).
|
|
||||||
if (( rc != 42 )); then
|
|
||||||
# The TUI writes its actual final directory in cwd_file in its
|
|
||||||
# finally block. Always apply it to the parent Bash shell before
|
|
||||||
# handling the exit status, including SIGINT/Ctrl+C.
|
|
||||||
if cd "$tui_cwd"; then
|
|
||||||
rm -f "$command_file" "$cwd_file"
|
|
||||||
else
|
|
||||||
printf 'tui: cannot cd to final directory: %s\n' "$tui_cwd" >&2
|
|
||||||
rm -f "$command_file" "$cwd_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if (( rc == 130 )); then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return "$rc"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Execute all commands belonging to one TUI action before starting
|
# Execute all commands belonging to one TUI action before starting
|
||||||
# TUI again. Each command remains a normal Bash command and gets its
|
# TUI again. Each command remains a normal Bash command and gets its
|
||||||
# own history entry. If a command fails, stop the action and pause so
|
# own history entry. If a command fails, stop the action and pause so
|
||||||
@@ -95,10 +81,6 @@ tui() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Do not redirect stdin while executing commands: commands may need
|
|
||||||
# interactive input (for example `read`, `joe`, or `less`). Read the
|
|
||||||
# command list into an array first, then let each command inherit the
|
|
||||||
# real terminal stdin.
|
|
||||||
mapfile -t tui_commands < "$command_file"
|
mapfile -t tui_commands < "$command_file"
|
||||||
for cmd in "${tui_commands[@]}"; do
|
for cmd in "${tui_commands[@]}"; do
|
||||||
[[ -z "$cmd" ]] && continue
|
[[ -z "$cmd" ]] && continue
|
||||||
|
|||||||
57
tui/tui.py
57
tui/tui.py
@@ -6,7 +6,9 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
TUI_VERSION = "2.0.7"
|
||||||
SHELL_HANDOFF = 42
|
SHELL_HANDOFF = 42
|
||||||
|
TUI_EXIT = 43
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
@@ -179,7 +181,7 @@ class FileBrowser:
|
|||||||
self.entries = entries
|
self.entries = entries
|
||||||
|
|
||||||
# Cursor 0 is always the synthetic parent entry.
|
# Cursor 0 is always the synthetic parent entry.
|
||||||
self.cursor = min(self.cursor, len(self.entries) + 1)
|
self.cursor = min(self.cursor, len(self.entries))
|
||||||
|
|
||||||
# Marks are global absolute paths, so they survive directory changes.
|
# Marks are global absolute paths, so they survive directory changes.
|
||||||
# Drop marks only for objects that no longer exist.
|
# Drop marks only for objects that no longer exist.
|
||||||
@@ -237,20 +239,15 @@ class FileBrowser:
|
|||||||
if not self.entries:
|
if not self.entries:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Like joe's Ctrl-K K, the end of the block is the line immediately
|
if self.cursor == 0:
|
||||||
# above the cursor. The bottom empty line therefore selects the last
|
|
||||||
# object in the directory, while standing on an object selects the
|
|
||||||
# object immediately above it.
|
|
||||||
end = self.cursor - 2
|
|
||||||
if end < 0:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# K sets/replaces only the end endpoint. The start endpoint is kept.
|
# K sets/replaces only the end endpoint. The start endpoint is kept.
|
||||||
self.selection_end = end
|
self.selection_end = self.current_entry_index()
|
||||||
self._update_selection()
|
self._update_selection()
|
||||||
|
|
||||||
def move(self, delta):
|
def move(self, delta):
|
||||||
self.cursor = max(0, min(len(self.entries) + 1, self.cursor + delta))
|
self.cursor = max(0, min(len(self.entries), self.cursor + delta))
|
||||||
|
|
||||||
def enter(self):
|
def enter(self):
|
||||||
if self.cursor == 0:
|
if self.cursor == 0:
|
||||||
@@ -426,7 +423,7 @@ def draw(stdscr, browser, status=""):
|
|||||||
|
|
||||||
for row in range(visible):
|
for row in range(visible):
|
||||||
index = first + row
|
index = first + row
|
||||||
if index > len(browser.entries) + 1:
|
if index > len(browser.entries):
|
||||||
break
|
break
|
||||||
|
|
||||||
if index == 0:
|
if index == 0:
|
||||||
@@ -434,21 +431,13 @@ def draw(stdscr, browser, status=""):
|
|||||||
text = "../" if entry is not None else "./"
|
text = "../" if entry is not None else "./"
|
||||||
attrs = entry_color(entry) | curses.A_BOLD
|
attrs = entry_color(entry) | curses.A_BOLD
|
||||||
is_marked = False
|
is_marked = False
|
||||||
elif index <= len(browser.entries):
|
else:
|
||||||
entry = browser.entries[index - 1]
|
entry = browser.entries[index - 1]
|
||||||
is_marked = entry.resolve() in browser.marked
|
is_marked = entry.resolve() in browser.marked
|
||||||
attrs = entry_color(entry)
|
attrs = entry_color(entry)
|
||||||
if is_marked:
|
if is_marked:
|
||||||
attrs |= curses.A_REVERSE
|
attrs |= curses.A_REVERSE
|
||||||
text = entry.name + ("/" if entry.is_dir() else "")
|
text = entry.name + ("/" if entry.is_dir() else "")
|
||||||
else:
|
|
||||||
# One empty line after the final object, matching the cursor range.
|
|
||||||
# A literal space is needed here: curses does not draw attributes
|
|
||||||
# for an empty string, so the cursor would otherwise be invisible.
|
|
||||||
entry = None
|
|
||||||
is_marked = False
|
|
||||||
attrs = curses.A_NORMAL
|
|
||||||
text = " "
|
|
||||||
|
|
||||||
if index == browser.cursor:
|
if index == browser.cursor:
|
||||||
attrs |= curses.A_UNDERLINE
|
attrs |= curses.A_UNDERLINE
|
||||||
@@ -504,18 +493,6 @@ def run(stdscr, start_dir=".", show_all=False):
|
|||||||
|
|
||||||
status = ""
|
status = ""
|
||||||
|
|
||||||
def save_cwd():
|
|
||||||
cwd_file = os.environ.get("TUI_CWD_FILE")
|
|
||||||
if cwd_file:
|
|
||||||
try:
|
|
||||||
Path(cwd_file).write_text(str(browser.path), encoding="utf-8")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Always leave the shell wrapper with the directory in which TUI was
|
|
||||||
# actually closed, not merely the directory of the last action.
|
|
||||||
save_cwd()
|
|
||||||
|
|
||||||
def configured_action(key):
|
def configured_action(key):
|
||||||
if key == curses.KEY_UP:
|
if key == curses.KEY_UP:
|
||||||
return browser.resolve_key_action(["up"], browser.selected())
|
return browser.resolve_key_action(["up"], browser.selected())
|
||||||
@@ -593,12 +570,21 @@ def run(stdscr, start_dir=".", show_all=False):
|
|||||||
return SHELL_HANDOFF
|
return SHELL_HANDOFF
|
||||||
if result is False:
|
if result is False:
|
||||||
return
|
return
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return TUI_EXIT
|
||||||
finally:
|
finally:
|
||||||
save_cwd()
|
cwd_file = os.environ.get("TUI_CWD_FILE")
|
||||||
|
if cwd_file:
|
||||||
|
try:
|
||||||
|
Path(cwd_file).write_text(str(browser.path), encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("-v", "--version", action="version",
|
||||||
|
version=f"tui {TUI_VERSION}")
|
||||||
parser.add_argument("-a", "--all", action="store_true",
|
parser.add_argument("-a", "--all", action="store_true",
|
||||||
help="show hidden files and directories")
|
help="show hidden files and directories")
|
||||||
parser.add_argument("directory", nargs="?", default=".")
|
parser.add_argument("directory", nargs="?", default=".")
|
||||||
@@ -607,12 +593,9 @@ def main():
|
|||||||
try:
|
try:
|
||||||
result = curses.wrapper(run, args.directory, args.all)
|
result = curses.wrapper(run, args.directory, args.all)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
# Keep a distinct status so the Bash wrapper can reliably distinguish
|
return TUI_EXIT
|
||||||
# Ctrl+C from a normal action handoff. The current directory has
|
|
||||||
# already been written by run()'s finally block.
|
|
||||||
return 130
|
|
||||||
|
|
||||||
return SHELL_HANDOFF if result == SHELL_HANDOFF else 0
|
return result if result in (SHELL_HANDOFF, TUI_EXIT) else 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user