diff --git a/AGENTS.md b/AGENTS.md index cdf1991..653bc1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ assets remain responsibilities of release CI. ``` src/ + atomic_file.rs same-directory atomic replacement with permission retention main.rs module wiring cli.rs clap command definitions config.rs TOML config + default paths (RECALL_CONFIG override) @@ -97,7 +98,9 @@ src/ tui/ app.rs App state + key handling ui.rs ratatui rendering - mod.rs terminal setup (renders to stderr) + event loop + runtime.rs shared raw-mode / alternate-screen terminal lifecycle + config/ interactive configuration state, effects, and rendering + mod.rs history TUI event loop (renders to stderr) commands/ one module per CLI command; optional import adapters live import/ below import.rs and must not leak into core models/config shell/recall.zsh embedded shell integrations (include_str!) @@ -183,6 +186,25 @@ for editing (exit 0); `Ctrl+Enter` exits with code 2, which tells the widget to execute immediately (`Ctrl+E` is the fallback where `Ctrl+Enter` is not distinguishable, e.g. Windows Terminal). +### Configuration TUI + +- Bare `recall config` opens the settings TUI; `path`, `show`, and `default` + remain non-interactive. Keep `RECALL_CONFIG` resolution identical for reads, + writes, and path display. +- `ConfigStore` reloads the active TOML before every mutation, validates the + complete effective config, and replaces it atomically. Preserve comments, + unrelated keys, permissions, newline style, BOM, and symlink targets. Never + overwrite a malformed or unsupported config. +- TOML is the source of truth for UI preferences. The legacy + `ui.list_width_pct` SQLite setting is read only for one-time migration and is + deleted only after the TOML write succeeds. +- Configuration UI state and rendering must stay testable without a live + terminal. Add PTY coverage for input and terminal restoration; keep a ConPTY + smoke test for Windows. +- Shell integration actions call the same setup inspection and apply functions + as `recall setup`. Do not duplicate profile rendering or marker handling in + the TUI. + ### Key invariants - The end marker must be emitted in-band before the prompt, otherwise the next @@ -212,7 +234,7 @@ distinguishable, e.g. Windows Terminal). - PowerShell: capture `$?` and `$LASTEXITCODE` before any statement changes them, then restore them before rendering the user's prompt. The integrations guard against double-loading and only install when PSReadLine is present. - `Set-PSReadLineKeyHandler` binds a single chord. + PSReadLine multi-stroke shortcuts use a comma-separated chord string. - Output is stored zstd-compressed; a truncated plain-text projection lives in `output_text` for FTS. Keep the two in sync. - DB access uses WAL + `busy_timeout`; multiple processes may write. diff --git a/Cargo.lock b/Cargo.lock index 30dbbfb..a7ae254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2105,6 +2105,7 @@ dependencies = [ "sysinfo", "tar", "toml", + "toml_edit", "ulid", "unicode-width", "windows-sys 0.60.2", @@ -2922,6 +2923,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -3607,6 +3621,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "winreg" diff --git a/Cargo.toml b/Cargo.toml index 9b7da05..1a77e5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ ulid = { version = "3.0.0", features = ["serde"] } unicode-width = "0.2.2" zstd = "0.14.0" zip = { version = "8.2.0", default-features = false, features = ["deflate"] } +toml_edit = "0.25.15" [target.'cfg(unix)'.dependencies] libc = "0.2.182" diff --git a/README.md b/README.md index 89a3bbe..c309608 100644 --- a/README.md +++ b/README.md @@ -196,11 +196,11 @@ The key is configured in `~/.config/recall/config.toml` as a semantic name that search_key = "alt-r" # alt-r, ctrl-t, or a two-stroke sequence "ctrl-x ctrl-r" ``` -Supported forms are `alt-`, `ctrl-`, or a space-separated -sequence such as `"ctrl-x ctrl-r"`. The default is `alt-r`. After editing the -file, reopen the shell (or re-run `eval "$(recall init zsh)"`) to apply it. -PSReadLine binds a single chord, so on Windows a two-stroke sequence uses its -first key. +Supported forms are `alt-`, `ctrl-`, `ctrl-space`, or a +space-separated sequence such as `"ctrl-x ctrl-r"`. The default is `alt-r`. +Run `recall config` to record and validate a shortcut interactively. Reopen the +shell after changing it; PowerShell sequences are passed to PSReadLine as a +comma-separated chord. #### macOS: the Option key @@ -297,6 +297,7 @@ recall search --cmd-only # print the selection (used by the zsh widget) recall setup [shell] # configure automatic capture and shell hooks recall doctor # diagnose config, databases and clipboard recall prune # drop output older than the retention window +recall config # open the interactive settings TUI recall config path|show|default recall uuid ``` @@ -309,8 +310,29 @@ recall uuid > database is stored, which command output is recorded, secrets filtering, > retention, and clipboard behavior. The defaults work out of the box, but a > deliberate configuration helps avoid retaining noisy or sensitive output. -> Use `recall config path`, `recall config show`, and `recall config default` -> to locate the file, inspect the active settings, and view a complete template. +> Run `recall config` to review capture rules before recording sensitive output. +> `recall config path`, `recall config show`, and `recall config default` remain +> available to locate the file, inspect the active settings, and print a template. + +### Interactive configuration + +```sh +recall config +``` + +The settings TUI manages the search shortcut, secret and interactive-command +filtering, command/output exclusion regexes, display preferences, and shell +integration. Changes are validated and saved atomically as you make them; +existing TOML comments and unrelated settings are retained. Shortcut changes +take effect in a new shell. + +- `Tab` / `Shift+Tab`: change category +- `Up` / `Down`: choose a setting +- `Left` / `Right`: adjust display values or timestamp presets +- `Space`: toggle a setting +- `Enter`: edit, record, or apply +- `a` / `e` / `d`: add, edit, or delete a custom capture rule +- `F1`: help; `Esc` / `q`: back or quit `~/.config/recall/config.toml` on Linux/macOS and `%APPDATA%\recall\config.toml` on Windows (all fields optional; @@ -338,6 +360,8 @@ backend = "auto" # auto | arboard | osc52 | wl-copy | xclip | xsel [ui] search_key = "alt-r" # key that opens recall (alt-r, ctrl-t, "ctrl-x ctrl-r") list_width_pct = 42 # initial list pane width; resizing in the TUI persists +preview_lines = 4 # output lines shown for each result +date_format = "%Y-%m-%d %H:%M:%S" ``` ## How it works diff --git a/README.zh-CN.md b/README.zh-CN.md index 23f16a1..9196035 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -171,10 +171,10 @@ recall init pwsh | Out-String | Invoke-Expression search_key = "alt-r" # 可选 alt-r、ctrl-t,或组合键 "ctrl-x ctrl-r" ``` -支持的写法有 `alt-<字母>`、`ctrl-<字母>`,或用空格分隔的组合键(如 -`"ctrl-x ctrl-r"`)。默认是 `alt-r`。修改后重开 shell(或重新执行 -`eval "$(recall init zsh)"`)即可生效。PSReadLine 只绑定单个 chord,因此在 -Windows 上组合键只会取其第一个键。 +支持的写法有 `alt-<字母>`、`ctrl-<字母>`、`ctrl-space`,或用空格分隔的组合键 +(如 `"ctrl-x ctrl-r"`)。默认是 `alt-r`。运行 `recall config` 可以直接录制并 +验证快捷键;修改后重开 shell 即可生效。PowerShell 会把多段组合转换为 PSReadLine +使用的逗号分隔 chord。 #### macOS 的 Option 键 @@ -262,6 +262,7 @@ recall search --cmd-only # 打印选择结果(供 shell 组件使用) recall setup [shell] # 配置自动捕获和 shell hooks recall doctor # 诊断配置、数据库和剪贴板 recall prune # 清理超过保留期的输出 +recall config # 打开交互式设置界面 recall config path|show|default recall uuid ``` @@ -272,9 +273,27 @@ recall uuid > > 启用代理前,请务必检查配置文件。它直接决定数据库的存储位置、哪些命令输出会被 > 记录、密钥过滤、数据保留期限和剪贴板行为。默认配置可以直接运行,但主动配置能 -> 避免长期保存大量无用输出或敏感内容。可使用 `recall config path`、 -> `recall config show` 和 `recall config default` 分别查看配置路径、当前生效配置和 -> 完整配置模板。 +> 避免长期保存大量无用输出或敏感内容。运行 `recall config` 可交互检查捕获规则; +> `recall config path`、`recall config show` 和 `recall config default` 仍可分别查看 +> 配置路径、当前生效配置和完整配置模板。 + +### 交互式配置 + +```sh +recall config +``` + +设置界面可以管理搜索快捷键、敏感信息与交互式命令过滤、命令/输出排除正则、显示 +偏好以及 Shell 集成。每项修改都会先验证,再原子保存;已有 TOML 注释和无关字段会 +被保留。快捷键需要打开新 Shell 后生效。 + +- `Tab` / `Shift+Tab`:切换分类 +- `↑` / `↓`:选择设置 +- `←` / `→`:调整显示数值或时间格式预设 +- `Space`:切换开关 +- `Enter`:编辑、录制或应用 +- `a` / `e` / `d`:新增、编辑或删除自定义捕获规则 +- `F1`:帮助;`Esc` / `q`:返回或退出 Linux/macOS 为 `~/.config/recall/config.toml`,Windows 为 `%APPDATA%\recall\config.toml`(所有字段均可选;`recall config default` @@ -301,6 +320,8 @@ backend = "auto" # auto | arboard | osc52 | wl-copy | xclip | xsel [ui] search_key = "alt-r" # 打开 recall 的按键(alt-r、ctrl-t、"ctrl-x ctrl-r") list_width_pct = 42 # 列表面板初始宽度;在 TUI 中调整后会持久化 +preview_lines = 4 # 每条结果显示的输出预览行数 +date_format = "%Y-%m-%d %H:%M:%S" ``` ## 工作原理 diff --git a/docs/shell-integration.md b/docs/shell-integration.md new file mode 100644 index 0000000..be5747e --- /dev/null +++ b/docs/shell-integration.md @@ -0,0 +1,122 @@ +# Shell integration settings + +The **Shell integration** category in `recall config` controls how recall is +loaded when a new interactive shell starts. It modifies the shell startup file; +it does not modify the terminal emulator's configuration. + +Use `Tab` or `Shift+Tab` to select **Shell integration**, then use the up and +down arrow keys to select a row. Press `Enter` on an action to apply it. + +## Status fields + +The first four rows describe the detected configuration and the shell that is +currently running the settings interface. + +### Detected shell + +The shell for which recall is showing and editing integration settings, such as +`zsh`, `bash`, `fish`, or `pwsh`. recall normally derives this from the user's +login shell or the current platform's shell discovery rules. + +### Startup file + +The file that recall will update for the detected shell. Common examples are +`~/.zshrc`, `~/.bashrc`, `~/.config/fish/config.fish`, and the PowerShell +profile. The CLI command `recall setup --profile ` remains available for +advanced setups that use a different file. + +### Current setup + +The integration found in the startup file: + +- **Automatic capture**: new interactive shells start through the recall PTY + proxy and load recall's hooks. Commands, metadata, and command output are + captured automatically. +- **Hooks only**: recall's hooks are loaded, but the shell is not automatically + wrapped by the PTY proxy. Command metadata is recorded, while full output is + captured only inside a shell started with `recall shell`. +- **Legacy automatic setup** or **Legacy hooks setup**: recall found an older + installer-managed configuration. Applying either current mode migrates it to + the current managed format. +- **Unmanaged hooks**: the startup file contains a user-written `recall init` + command. recall recognizes and reuses it instead of adding another copy. +- **Not configured**: no recall integration was found in the startup file. +- **Malformed recall markers**: a managed block is incomplete or damaged. + recall will not edit the file automatically because doing so could overwrite + user configuration. Repair or remove the damaged markers manually before + applying a mode again. +- **Unavailable** or **Error**: recall could not determine or safely inspect the + startup file. The accompanying message describes the reason. + +### Runtime + +This row describes the current shell process, not merely the startup file: + +- **hooks On** means recall's shell hooks are loaded in this shell. +- **proxy On** means this shell is running inside `recall shell`, so full output + capture is active. + +It is normal to see **Automatic capture** with **proxy Off** immediately after +changing the setting: the current shell started before the change. Open a new +terminal or start a new shell to activate automatic capture. Similarly, +removing integration does not unload hooks that are already running; it affects +new shells. + +## Actions + +### Use automatic capture + +This is the recommended default. recall adds two managed blocks to the startup +file: + +1. A lightweight block near the top starts `recall shell` for a supported + interactive TTY. +2. A block near the end loads recall's hooks after the user's prompt, theme, and + line editor configuration. + +The proxied child shell skips the first block, so the user's startup file is +loaded once rather than recursively. Non-interactive shells do not start the +proxy. To bypass automatic capture for a particular new shell, set +`RECALL_PROXY=0` before starting it. + +The equivalent CLI command is: + +```sh +recall setup --mode auto +``` + +### Use hooks only + +This installs the search shortcut and command metadata hooks without launching +the proxy automatically. Choose this when automatic output capture is not +wanted for every terminal. Run `recall shell` manually whenever full command +output should be captured. + +The equivalent CLI command is: + +```sh +recall setup --mode hooks +``` + +### Remove integration + +This removes only blocks managed by recall and asks for confirmation first. +Other startup-file content is preserved. A user-written, unmanaged +`recall init` command is not deleted automatically. + +The equivalent CLI command is: + +```sh +recall setup --remove +``` + +## Which mode should I choose? + +| Mode | Search shortcut | Command metadata | Full output by default | +| --- | --- | --- | --- | +| Automatic capture | Yes | Yes | Yes | +| Hooks only | Yes | Yes | No; run `recall shell` when needed | +| Removed / not configured | No | No | No | + +Review the command and output exclusion rules in the **Capture rules** category +before enabling automatic capture for sensitive work. diff --git a/src/atomic_file.rs b/src/atomic_file.rs new file mode 100644 index 0000000..45f3703 --- /dev/null +++ b/src/atomic_file.rs @@ -0,0 +1,144 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result, anyhow}; + +pub(crate) fn resolve_target(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => fs::canonicalize(path) + .with_context(|| format!("resolving file symlink {}", path.display())), + Ok(_) => Ok(path.to_path_buf()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(path.to_path_buf()), + Err(err) => Err(err).with_context(|| format!("inspecting {}", path.display())), + } +} + +/// Replace a file atomically with bytes written to a temporary sibling. +/// Existing permissions are retained when the destination already exists. +pub(crate) fn replace(path: &Path, bytes: &[u8], label: &str) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("file has no parent: {}", path.display()))?; + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + let permissions = fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()); + + let mut temp_path = None; + for attempt in 0..100 { + let candidate = parent.join(format!( + ".recall-{label}-{}-{attempt}.tmp", + std::process::id() + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(mut file) => { + file.write_all(bytes)?; + file.sync_all()?; + if let Some(permissions) = permissions.clone() { + fs::set_permissions(&candidate, permissions)?; + } + temp_path = Some(candidate); + break; + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err).context("creating a temporary file"), + } + } + let temp_path = temp_path.ok_or_else(|| anyhow!("could not create a temporary file"))?; + replace_file(&temp_path, path).inspect_err(|_| { + let _ = fs::remove_file(&temp_path); + }) +} + +#[cfg(not(windows))] +fn replace_file(from: &Path, to: &Path) -> Result<()> { + fs::rename(from, to).with_context(|| format!("replacing {}", to.display())) +} + +#[cfg(windows)] +fn replace_file(from: &Path, to: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let from: Vec = from.as_os_str().encode_wide().chain(Some(0)).collect(); + let to: Vec = to.as_os_str().encode_wide().chain(Some(0)).collect(); + let result = unsafe { + MoveFileExW( + from.as_ptr(), + to.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()).context("replacing the file"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_file(name: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!( + "recall-atomic-test-{}-{}-{name}", + std::process::id(), + ulid::Ulid::generate() + )); + fs::create_dir_all(&dir).unwrap(); + (dir.join("file"), dir) + } + + #[test] + fn replaces_existing_content() { + let (path, dir) = temp_file("replace"); + fs::write(&path, b"before").unwrap(); + replace(&path, b"after", "test").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"after"); + fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn preserves_permissions() { + use std::os::unix::fs::PermissionsExt; + + let (path, dir) = temp_file("permissions"); + fs::write(&path, b"before").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + replace(&path, b"after", "test").unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn resolves_a_symlink_without_replacing_it() { + use std::os::unix::fs::symlink; + + let (link, dir) = temp_file("symlink"); + let target = dir.join("target"); + fs::write(&target, b"before").unwrap(); + symlink(&target, &link).unwrap(); + let resolved = resolve_target(&link).unwrap(); + replace(&resolved, b"after", "test").unwrap(); + assert!( + fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(fs::read(&target).unwrap(), b"after"); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/cli.rs b/src/cli.rs index e3c6620..d0f1fb9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,7 +30,7 @@ pub enum Command { Prune, /// Download and install the latest stable release. Update(UpdateArgs), - /// Show or locate the configuration file. + /// Configure recall interactively, or show and locate its configuration. Config(ConfigArgs), /// Print a fresh recall id. Uuid, @@ -149,7 +149,7 @@ pub enum ImportSource { #[derive(Debug, Args)] pub struct ConfigArgs { #[command(subcommand)] - pub action: ConfigAction, + pub action: Option, } #[derive(Debug, Args)] @@ -204,4 +204,19 @@ mod tests { std::path::PathBuf::from("profile.ps1") ); } + + #[test] + fn config_accepts_interactive_and_legacy_actions() { + let cli = Cli::try_parse_from(["recall", "config"]).unwrap(); + let Some(Command::Config(args)) = cli.command else { + panic!("expected config command"); + }; + assert!(args.action.is_none()); + + let cli = Cli::try_parse_from(["recall", "config", "show"]).unwrap(); + let Some(Command::Config(args)) = cli.command else { + panic!("expected config command"); + }; + assert!(matches!(args.action, Some(ConfigAction::Show))); + } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6aa52c5..b63f04f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,11 +3,13 @@ mod import; mod init; mod proxy; mod record; -mod setup; +pub(crate) mod setup; mod shell; mod update; -use anyhow::Result; +use std::io::IsTerminal; + +use anyhow::{Result, bail}; use crate::cli::{Cli, Command, ConfigAction, ConfigArgs, SearchArgs}; use crate::config::Config; @@ -45,14 +47,22 @@ pub fn new_id() -> String { fn config(args: ConfigArgs) -> Result<()> { match args.action { - ConfigAction::Path => { + None => { + if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { + bail!( + "interactive configuration requires a terminal; use `recall config show`, `path`, or `default`" + ); + } + crate::tui::config::run(Config::load()?)?; + } + Some(ConfigAction::Path) => { println!("{}", Config::config_path().display()); } - ConfigAction::Show => { + Some(ConfigAction::Show) => { let cfg = Config::load()?; print!("{}", toml::to_string_pretty(&cfg)?); } - ConfigAction::Default => { + Some(ConfigAction::Default) => { let cfg = Config::default(); print!("{}", toml::to_string_pretty(&cfg)?); } diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 1b4c908..f2acc32 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,9 +1,9 @@ -use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; +use crate::atomic_file; use crate::cli::{SetupArgs, SetupMode}; use crate::shell::Shell; use crate::util; @@ -17,7 +17,7 @@ const INTEGRATION_NO_EOL_END: &str = "# <<< recall setup integration-no-eol <<<" const LEGACY_START: &str = "# >>> recall installer >>>"; const LEGACY_END: &str = "# <<< recall installer <<<"; -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ManagedSetup { Auto, Hooks, @@ -28,7 +28,7 @@ pub(crate) enum ManagedSetup { Invalid, } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct ProfileInspection { pub(crate) setup: ManagedSetup, pub(crate) init_count: usize, @@ -49,31 +49,29 @@ struct ProfileText { } pub fn run(args: SetupArgs) -> Result<()> { - let shell = resolve_shell(args.shell.as_deref())?; - let profile = args - .profile - .or_else(|| shell.rc_path()) - .ok_or_else(|| anyhow!("could not determine the startup file for {}", shell.name))?; + let result = apply(args.shell.as_deref(), args.profile, args.mode, args.remove)?; if args.remove { - let changed = update_profile(&profile, shell, args.mode, true)?; println!( "shell setup: {} ({})", - profile.display(), - if changed { "removed" } else { "not present" } + result.profile.display(), + if result.changed { + "removed" + } else { + "not present" + } ); return Ok(()); } - let changed = update_profile(&profile, shell, args.mode, false)?; println!( "shell setup: {} ({}; {})", - profile.display(), + result.profile.display(), match args.mode { SetupMode::Auto => "automatic output capture", SetupMode::Hooks => "hooks only", }, - if changed { + if result.changed { "updated" } else { "already current" @@ -87,8 +85,36 @@ pub fn run(args: SetupArgs) -> Result<()> { Ok(()) } +pub(crate) struct SetupResult { + pub(crate) profile: PathBuf, + pub(crate) changed: bool, +} + +pub(crate) fn apply( + shell_name: Option<&str>, + profile: Option, + mode: SetupMode, + remove: bool, +) -> Result { + let shell = resolve_shell(shell_name)?; + let profile = profile + .or_else(|| shell.rc_path()) + .ok_or_else(|| anyhow!("could not determine the startup file for {}", shell.name))?; + let changed = update_profile(&profile, shell, mode, remove)?; + Ok(SetupResult { profile, changed }) +} + +pub(crate) fn inspect_shell(shell_name: &str) -> Result<(PathBuf, ProfileInspection)> { + let shell = resolve_shell(Some(shell_name))?; + let profile = shell + .rc_path() + .ok_or_else(|| anyhow!("could not determine the startup file for {}", shell.name))?; + let inspection = inspect_profile(&profile)?; + Ok((profile, inspection)) +} + pub(crate) fn inspect_profile(path: &Path) -> Result { - let path = resolve_profile_target(path)?; + let path = atomic_file::resolve_target(path)?; let profile = read_profile(&path)?; Ok(inspect_text(&profile.text)) } @@ -143,7 +169,7 @@ fn resolve_shell(name: Option<&str>) -> Result<&'static Shell> { } fn update_profile(path: &Path, shell: &Shell, mode: SetupMode, remove: bool) -> Result { - let write_path = resolve_profile_target(path)?; + let write_path = atomic_file::resolve_target(path)?; let existing = read_profile(&write_path)?; let cleaned = remove_managed_blocks(&existing.text)?; let rendered = if remove { @@ -164,16 +190,6 @@ fn update_profile(path: &Path, shell: &Shell, mode: SetupMode, remove: bool) -> Ok(true) } -fn resolve_profile_target(path: &Path) -> Result { - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => fs::canonicalize(path) - .with_context(|| format!("resolving startup file symlink {}", path.display())), - Ok(_) => Ok(path.to_path_buf()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(path.to_path_buf()), - Err(err) => Err(err).with_context(|| format!("inspecting {}", path.display())), - } -} - fn read_profile(path: &Path) -> Result { let bytes = match fs::read(path) { Ok(bytes) => bytes, @@ -414,72 +430,8 @@ fn quote_powershell(value: &str) -> String { } fn write_profile(path: &Path, original: &ProfileText, text: &str) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| anyhow!("startup file has no parent: {}", path.display()))?; - fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; let bytes = encode_text(text, original.encoding, original.newline); - let permissions = fs::metadata(path) - .ok() - .map(|metadata| metadata.permissions()); - - let mut temp_path = None; - for attempt in 0..100 { - let candidate = parent.join(format!( - ".recall-setup-{}-{attempt}.tmp", - std::process::id() - )); - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&candidate) - { - Ok(mut file) => { - file.write_all(&bytes)?; - file.sync_all()?; - if let Some(permissions) = permissions.clone() { - fs::set_permissions(&candidate, permissions)?; - } - temp_path = Some(candidate); - break; - } - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(err) => return Err(err).context("creating a temporary startup file"), - } - } - let temp_path = - temp_path.ok_or_else(|| anyhow!("could not create a temporary startup file"))?; - replace_file(&temp_path, path).inspect_err(|_| { - let _ = fs::remove_file(&temp_path); - })?; - Ok(()) -} - -#[cfg(not(windows))] -fn replace_file(from: &Path, to: &Path) -> Result<()> { - fs::rename(from, to).with_context(|| format!("replacing {}", to.display())) -} - -#[cfg(windows)] -fn replace_file(from: &Path, to: &Path) -> Result<()> { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{ - MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, - }; - - let from: Vec = from.as_os_str().encode_wide().chain(Some(0)).collect(); - let to: Vec = to.as_os_str().encode_wide().chain(Some(0)).collect(); - let result = unsafe { - MoveFileExW( - from.as_ptr(), - to.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if result == 0 { - return Err(std::io::Error::last_os_error()).context("replacing the startup file"); - } - Ok(()) + atomic_file::replace(path, &bytes, "setup") } #[cfg(test)] @@ -619,6 +571,22 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + #[test] + fn shared_apply_api_updates_an_explicit_profile() { + let (path, dir) = temp_profile("shared-api"); + fs::write(&path, "echo user\n").unwrap(); + + let result = apply(Some("zsh"), Some(path.clone()), SetupMode::Hooks, false).unwrap(); + + assert_eq!(result.profile, path); + assert!(result.changed); + assert_eq!( + inspect_profile(&result.profile).unwrap().setup, + ManagedSetup::Hooks + ); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn preserves_a_missing_final_newline() { let (path, dir) = temp_profile("no-final-newline"); diff --git a/src/config.rs b/src/config.rs index 09af0f3..f3ca35b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,10 @@ use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; +use toml_edit::{Array, DocumentMut, Item, Table, Value}; + +use crate::atomic_file; /// Top-level configuration, loaded from `~/.config/recall/config.toml`. /// @@ -134,20 +137,15 @@ impl Default for Ui { } impl Config { - /// Path of the config file. + /// Path of the active config file. `RECALL_CONFIG` overrides the default. pub fn config_path() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("recall") - .join("config.toml") + config_path_from(std::env::var_os("RECALL_CONFIG")) } /// Load configuration from the default path, falling back to defaults if /// the file does not exist. `RECALL_CONFIG` overrides the path. pub fn load() -> Result { - let path = std::env::var_os("RECALL_CONFIG") - .map(PathBuf::from) - .unwrap_or_else(Self::config_path); + let path = Self::config_path(); if !path.exists() { return Ok(Self::default()); } @@ -155,9 +153,12 @@ impl Config { } pub fn load_from(path: &Path) -> Result { - let text = std::fs::read_to_string(path) - .with_context(|| format!("reading config {}", path.display()))?; - Self::parse(&text).with_context(|| format!("parsing config {}", path.display())) + let bytes = + std::fs::read(path).with_context(|| format!("reading config {}", path.display()))?; + let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(&bytes); + let text = std::str::from_utf8(bytes) + .with_context(|| format!("decoding config {} as UTF-8", path.display()))?; + Self::parse(text).with_context(|| format!("parsing config {}", path.display())) } fn parse(text: &str) -> Result { @@ -171,6 +172,109 @@ impl Config { } } +/// Lossless, atomic mutations of the active TOML configuration. +pub(crate) struct ConfigStore { + path: PathBuf, +} + +impl ConfigStore { + pub(crate) fn active() -> Self { + Self { + path: Config::config_path(), + } + } + + #[cfg(test)] + pub(crate) fn at(path: PathBuf) -> Self { + Self { path } + } + + pub(crate) fn set_bool(&self, section: &str, key: &str, value: bool) -> Result { + self.set_value(section, key, Value::from(value)) + } + + pub(crate) fn set_integer(&self, section: &str, key: &str, value: i64) -> Result { + self.set_value(section, key, Value::from(value)) + } + + pub(crate) fn set_string(&self, section: &str, key: &str, value: &str) -> Result { + self.set_value(section, key, Value::from(value)) + } + + pub(crate) fn set_strings( + &self, + section: &str, + key: &str, + values: &[String], + ) -> Result { + let mut array = Array::new(); + for value in values { + array.push(value.as_str()); + } + self.set_value(section, key, Value::Array(array)) + } + + fn set_value(&self, section: &str, key: &str, value: Value) -> Result { + let write_path = atomic_file::resolve_target(&self.path)?; + let original = match std::fs::read(&write_path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + toml::to_string_pretty(&Config::default())?.into_bytes() + } + Err(err) => { + return Err(err) + .with_context(|| format!("reading config {}", write_path.display())); + } + }; + let (bom, bytes) = original + .strip_prefix(&[0xef, 0xbb, 0xbf]) + .map_or((false, original.as_slice()), |bytes| (true, bytes)); + let text = std::str::from_utf8(bytes) + .with_context(|| format!("decoding config {} as UTF-8", write_path.display()))?; + let crlf = text.contains("\r\n"); + let normalized = if crlf { + text.replace("\r\n", "\n") + } else { + text.to_string() + }; + let mut document = normalized + .parse::() + .with_context(|| format!("parsing config {}", write_path.display()))?; + let item = document + .entry(section) + .or_insert_with(|| Item::Table(Table::new())); + let table = item + .as_table_mut() + .ok_or_else(|| anyhow!("config section `{section}` is not a table"))?; + table[key] = Item::Value(value); + + let rendered = document.to_string(); + let config = Config::parse(&rendered) + .with_context(|| format!("validating config {}", write_path.display()))?; + let rendered = if crlf { + rendered.replace('\n', "\r\n") + } else { + rendered + }; + let mut output = Vec::with_capacity(rendered.len() + usize::from(bom) * 3); + if bom { + output.extend_from_slice(&[0xef, 0xbb, 0xbf]); + } + output.extend_from_slice(rendered.as_bytes()); + atomic_file::replace(&write_path, &output, "config")?; + Ok(config) + } +} + +fn config_path_from(override_path: Option) -> PathBuf { + override_path.map(PathBuf::from).unwrap_or_else(|| { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("recall") + .join("config.toml") + }) +} + pub fn default_data_dir() -> PathBuf { dirs::data_local_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -181,6 +285,16 @@ pub fn default_data_dir() -> PathBuf { mod tests { use super::*; + fn temp_config(name: &str) -> (PathBuf, PathBuf) { + let dir = std::env::temp_dir().join(format!( + "recall-config-test-{}-{}-{name}", + std::process::id(), + ulid::Ulid::generate() + )); + std::fs::create_dir_all(&dir).unwrap(); + (dir.join("config.toml"), dir) + } + #[test] fn parses_exclude_output_patterns() { let config: Config = toml::from_str( @@ -243,4 +357,109 @@ mod tests { assert_eq!(config.general.max_output_bytes, 2048); assert!(!toml::to_string(&config).unwrap().contains("atuin_db_path")); } + + #[test] + fn config_path_prefers_override() { + let path = config_path_from(Some(std::ffi::OsString::from("custom/config.toml"))); + assert_eq!(path, PathBuf::from("custom/config.toml")); + } + + #[test] + fn config_store_preserves_comments_and_external_changes() { + let (path, dir) = temp_config("comments"); + std::fs::write( + &path, + "# keep me\n[ui]\nsearch_key = \"alt-r\" # inline\n\n[proxy]\nshell = \"zsh\"\n", + ) + .unwrap(); + let store = ConfigStore::at(path.clone()); + + store.set_integer("ui", "preview_lines", 7).unwrap(); + let mut external = std::fs::read_to_string(&path).unwrap(); + external.push_str("# added elsewhere\n"); + std::fs::write(&path, external).unwrap(); + store.set_bool("proxy", "secrets_filter", false).unwrap(); + + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.contains("# keep me")); + assert!(saved.contains("search_key = \"alt-r\" # inline")); + assert!(saved.contains("# added elsewhere")); + assert!(saved.contains("preview_lines = 7")); + assert!(saved.contains("secrets_filter = false")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn config_store_preserves_crlf_and_bom() { + let (path, dir) = temp_config("crlf-bom"); + let mut bytes = vec![0xef, 0xbb, 0xbf]; + bytes.extend_from_slice(b"[ui]\r\nsearch_key = \"alt-r\"\r\n"); + std::fs::write(&path, bytes).unwrap(); + + ConfigStore::at(path.clone()) + .set_string("ui", "search_key", "ctrl-t") + .unwrap(); + + let saved = std::fs::read(&path).unwrap(); + assert!(saved.starts_with(&[0xef, 0xbb, 0xbf])); + let text = std::str::from_utf8(&saved[3..]).unwrap(); + assert!(text.contains("search_key = \"ctrl-t\"\r\n")); + assert!(!text.replace("\r\n", "").contains('\n')); + assert_eq!(Config::load_from(&path).unwrap().ui.search_key, "ctrl-t"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn config_store_updates_a_symlink_target() { + use std::os::unix::fs::symlink; + + let (link, dir) = temp_config("symlink"); + let target = dir.join("managed-config.toml"); + std::fs::write(&target, "[ui]\nsearch_key = \"alt-r\"\n").unwrap(); + symlink(&target, &link).unwrap(); + + ConfigStore::at(link.clone()) + .set_string("ui", "search_key", "ctrl-t") + .unwrap(); + + assert!( + std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(Config::load_from(&target).unwrap().ui.search_key, "ctrl-t"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn config_store_refuses_invalid_toml_without_modifying_it() { + let (path, dir) = temp_config("invalid"); + let original = b"[ui\ninvalid"; + std::fs::write(&path, original).unwrap(); + + assert!( + ConfigStore::at(path.clone()) + .set_bool("proxy", "secrets_filter", false) + .is_err() + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn config_store_writes_string_arrays() { + let (path, dir) = temp_config("arrays"); + let store = ConfigStore::at(path.clone()); + let config = store + .set_strings( + "proxy", + "exclude_output", + &["^docker logs".to_string(), "^tail -f".to_string()], + ) + .unwrap(); + assert_eq!(config.proxy.exclude_output, ["^docker logs", "^tail -f"]); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/src/db/queries.rs b/src/db/queries.rs index 53cafdd..1ef90e2 100644 --- a/src/db/queries.rs +++ b/src/db/queries.rs @@ -224,6 +224,7 @@ pub fn get_setting(conn: &Connection, key: &str) -> Result> { } /// Persist a key/value setting, replacing any previous value. +#[cfg(test)] pub fn set_setting(conn: &Connection, key: &str, value: &str) -> Result<()> { conn.execute( "INSERT INTO settings (key, value) VALUES (?1, ?2) @@ -234,6 +235,13 @@ pub fn set_setting(conn: &Connection, key: &str, value: &str) -> Result<()> { Ok(()) } +/// Delete a persisted setting after it has been migrated elsewhere. +pub fn delete_setting(conn: &Connection, key: &str) -> Result<()> { + conn.execute("DELETE FROM settings WHERE key = ?1", params![key]) + .context("deleting setting")?; + Ok(()) +} + pub fn count(conn: &Connection) -> Result { Ok(conn.query_row("SELECT COUNT(*) FROM blocks", [], |row| row.get(0))?) } @@ -339,6 +347,8 @@ mod tests { .as_deref(), Some("60") ); + delete_setting(&db.conn, "ui.list_width_pct").unwrap(); + assert_eq!(get_setting(&db.conn, "ui.list_width_pct").unwrap(), None); } #[test] diff --git a/src/main.rs b/src/main.rs index 308eb54..9f431c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod atomic_file; mod capture; mod cli; mod clipboard; diff --git a/src/shell.rs b/src/shell.rs index bd718dc..473abdd 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -27,8 +27,10 @@ pub(crate) enum HistoryFormat { struct KeyEncoding { alt_prefix: &'static str, ctrl_prefix: &'static str, + ctrl_space: &'static str, ctrl_uppercase: bool, max_chords: usize, + chord_separator: &'static str, } pub(crate) struct Shell { @@ -51,8 +53,10 @@ static SHELLS: &[Shell] = &[ key_encoding: Some(KeyEncoding { alt_prefix: "^[", ctrl_prefix: "^", + ctrl_space: "^@", ctrl_uppercase: true, max_chords: usize::MAX, + chord_separator: "", }), history_root: Some(HistoryRoot::Home), history_tail: &[".zsh_history"], @@ -66,8 +70,10 @@ static SHELLS: &[Shell] = &[ key_encoding: Some(KeyEncoding { alt_prefix: "\\e", ctrl_prefix: "\\C-", + ctrl_space: "\\C-@", ctrl_uppercase: false, max_chords: usize::MAX, + chord_separator: "", }), history_root: Some(HistoryRoot::Home), history_tail: &[".bash_history"], @@ -81,8 +87,10 @@ static SHELLS: &[Shell] = &[ key_encoding: Some(KeyEncoding { alt_prefix: "\\e", ctrl_prefix: "\\c", + ctrl_space: "\\c@", ctrl_uppercase: false, max_chords: usize::MAX, + chord_separator: "", }), history_root: Some(HistoryRoot::Data), history_tail: &["fish", "fish_history"], @@ -96,8 +104,10 @@ static SHELLS: &[Shell] = &[ key_encoding: Some(KeyEncoding { alt_prefix: "Alt+", ctrl_prefix: "Ctrl+", + ctrl_space: "Ctrl+Spacebar", ctrl_uppercase: false, - max_chords: 1, + max_chords: 2, + chord_separator: ",", }), history_root: Some(HistoryRoot::PowerShellData), history_tail: &["PSReadLine", "ConsoleHost_history.txt"], @@ -111,8 +121,10 @@ static SHELLS: &[Shell] = &[ key_encoding: Some(KeyEncoding { alt_prefix: "Alt+", ctrl_prefix: "Ctrl+", + ctrl_space: "Ctrl+Spacebar", ctrl_uppercase: false, - max_chords: 1, + max_chords: 2, + chord_separator: ",", }), history_root: Some(HistoryRoot::PowerShellData), history_tail: &["PSReadLine", "ConsoleHost_history.txt"], @@ -192,7 +204,10 @@ impl Shell { }; let mut out = String::new(); - for key in keys.iter().take(encoding.max_chords) { + for (index, key) in keys.iter().take(encoding.max_chords).enumerate() { + if index > 0 { + out.push_str(encoding.chord_separator); + } match key { Key::Alt(ch) => { out.push_str(encoding.alt_prefix); @@ -206,11 +221,18 @@ impl Shell { ch.to_ascii_lowercase() }); } + Key::CtrlSpace => out.push_str(encoding.ctrl_space), } } out } + pub(crate) fn semantic_search_key(&self, spec: &str) -> Option { + let keys = parse_key_sequence(spec)?; + let encoding = self.key_encoding?; + (keys.len() <= encoding.max_chords).then(|| self.search_key(spec)) + } + pub(crate) fn history_path(&self) -> Option { let home = dirs::home_dir(); let data = std::env::var_os("XDG_DATA_HOME") @@ -276,18 +298,24 @@ struct HistoryDirs<'a> { enum Key { Alt(char), Ctrl(char), + CtrlSpace, } fn parse_key_sequence(spec: &str) -> Option> { let mut keys = Vec::new(); for token in spec.split_whitespace() { let (modifier, rest) = token.split_once('-')?; + let modifier = modifier.to_ascii_lowercase(); + if matches!(modifier.as_str(), "ctrl" | "control") && rest.eq_ignore_ascii_case("space") { + keys.push(Key::CtrlSpace); + continue; + } let mut chars = rest.chars(); let ch = chars.next()?; if chars.next().is_some() || !ch.is_ascii_alphabetic() { return None; } - keys.push(match modifier.to_ascii_lowercase().as_str() { + keys.push(match modifier.as_str() { "alt" | "meta" | "option" => Key::Alt(ch), "ctrl" | "control" => Key::Ctrl(ch), _ => return None, @@ -324,7 +352,7 @@ mod tests { Shell::from_name("pwsh") .unwrap() .search_key("ctrl-x ctrl-r"), - "Ctrl+x" + "Ctrl+x,Ctrl+r" ); assert_eq!( Shell::from_name("zsh").unwrap().search_key("ctrl-x ctrl-r"), @@ -346,6 +374,22 @@ mod tests { Shell::from_name("powershell").unwrap().search_key("alt-r"), "Alt+r" ); + assert_eq!( + Shell::from_name("zsh").unwrap().search_key("ctrl-space"), + "^@" + ); + assert_eq!( + Shell::from_name("bash").unwrap().search_key("ctrl-space"), + "\\C-@" + ); + assert_eq!( + Shell::from_name("fish").unwrap().search_key("ctrl-space"), + "\\c@" + ); + assert_eq!( + Shell::from_name("pwsh").unwrap().search_key("ctrl-space"), + "Ctrl+Spacebar" + ); } #[test] diff --git a/src/tui/app.rs b/src/tui/app.rs index ca43214..400dab0 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -4,7 +4,7 @@ use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::clipboard::Clipboard; -use crate::config::Config; +use crate::config::{Config, ConfigStore}; use crate::db::{Db, queries}; use crate::model::Block; @@ -56,10 +56,11 @@ pub struct App { pub status_is_error: bool, pub should_quit: bool, pub show_help: bool, + config_store: ConfigStore, } impl App { - pub fn new(config: Config, cmd_only: bool, initial_query: Option) -> Result { + pub fn new(mut config: Config, cmd_only: bool, initial_query: Option) -> Result { let db = Db::open(&config.general.db_path)?; if config.retention.auto_prune && config.retention.retention_days > 0 { let _ = queries::prune( @@ -69,11 +70,8 @@ impl App { ); } let clipboard = crate::clipboard::ClipboardChain::detect(&config.clipboard); - let list_width_pct = queries::get_setting(&db.conn, LIST_WIDTH_SETTING) - .ok() - .flatten() - .and_then(|value| value.parse::().ok()) - .unwrap_or(config.ui.list_width_pct); + let config_store = ConfigStore::active(); + let (list_width_pct, migration_error) = migrate_list_width(&db, &mut config, &config_store); let mut app = Self { db, config, @@ -94,7 +92,11 @@ impl App { status_is_error: false, should_quit: false, show_help: false, + config_store, }; + if let Some(error) = migration_error { + app.set_error(error); + } app.refresh(); Ok(app) } @@ -256,9 +258,16 @@ impl App { if next == self.list_width_pct { return; } - self.list_width_pct = next; - match queries::set_setting(&self.db.conn, LIST_WIDTH_SETTING, &next.to_string()) { - Ok(()) => self.set_status(format!("list width {next}%")), + match self + .config_store + .set_integer("ui", "list_width_pct", i64::from(next)) + { + Ok(config) => { + self.config = config; + self.list_width_pct = next; + let _ = queries::delete_setting(&self.db.conn, LIST_WIDTH_SETTING); + self.set_status(format!("list width {next}%")); + } Err(err) => self.set_error(format!("failed to save layout: {err}")), } } @@ -368,6 +377,43 @@ impl App { } } +fn migrate_list_width(db: &Db, config: &mut Config, store: &ConfigStore) -> (u16, Option) { + let legacy = match queries::get_setting(&db.conn, LIST_WIDTH_SETTING) { + Ok(Some(value)) => match value.parse::() { + Ok(value) => clamp_list_width(value), + Err(err) => { + return ( + clamp_list_width(config.ui.list_width_pct), + Some(format!("invalid legacy list width `{value}`: {err}")), + ); + } + }, + Ok(None) => return (clamp_list_width(config.ui.list_width_pct), None), + Err(err) => { + return ( + clamp_list_width(config.ui.list_width_pct), + Some(format!("failed to read legacy list width: {err}")), + ); + } + }; + + match store.set_integer("ui", "list_width_pct", i64::from(legacy)) { + Ok(updated) => { + *config = updated; + let error = queries::delete_setting(&db.conn, LIST_WIDTH_SETTING) + .err() + .map(|err| format!("list width migrated, but legacy cleanup failed: {err}")); + (legacy, error) + } + Err(err) => ( + legacy, + Some(format!( + "using legacy list width because config migration failed: {err}" + )), + ), + } +} + fn clamp_list_width(pct: u16) -> u16 { pct.clamp(LIST_WIDTH_MIN, LIST_WIDTH_MAX) } @@ -423,7 +469,7 @@ mod tests { } } - fn test_app(blocks: &[Block]) -> (App, Arc>>) { + fn test_app(blocks: &[Block]) -> (App, Arc>>, std::path::PathBuf) { let db = Db::open_in_memory().unwrap(); for block in blocks { queries::insert(&db.conn, block).unwrap(); @@ -432,6 +478,18 @@ mod tests { let clipboard = TestClipboard { copied: copied.clone(), }; + let dir = std::env::temp_dir().join(format!( + "recall-search-ui-test-{}-{}", + std::process::id(), + ulid::Ulid::generate() + )); + std::fs::create_dir_all(&dir).unwrap(); + let config_path = dir.join("config.toml"); + std::fs::write( + &config_path, + toml::to_string_pretty(&Config::default()).unwrap(), + ) + .unwrap(); let mut app = App { db, config: Config::default(), @@ -452,9 +510,10 @@ mod tests { status_is_error: false, should_quit: false, show_help: false, + config_store: ConfigStore::at(config_path), }; app.refresh(); - (app, copied) + (app, copied, dir) } #[test] @@ -463,7 +522,7 @@ mod tests { block("old", "first command", "first output", 100), block("new", "second command", "second output", 200), ]; - let (mut app, copied) = test_app(&blocks); + let (mut app, copied, dir) = test_app(&blocks); app.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)); app.move_selection(1); @@ -474,28 +533,35 @@ mod tests { copied.lock().unwrap().as_deref(), Some("$ first command\nfirst output\n\n$ second command\nsecond output") ); + std::fs::remove_dir_all(dir).unwrap(); } #[test] fn arrows_resize_and_persist_list_width() { - let (mut app, _) = test_app(&[block("one", "command", "output", 100)]); + let (mut app, _, dir) = test_app(&[block("one", "command", "output", 100)]); app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); assert_eq!(app.list_width_pct, 47); assert_eq!( - queries::get_setting(&app.db.conn, LIST_WIDTH_SETTING) - .unwrap() - .as_deref(), - Some("47") + queries::get_setting(&app.db.conn, LIST_WIDTH_SETTING).unwrap(), + None ); app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); assert_eq!(app.list_width_pct, 42); + assert_eq!( + Config::load_from(&dir.join("config.toml")) + .unwrap() + .ui + .list_width_pct, + 42 + ); + std::fs::remove_dir_all(dir).unwrap(); } #[test] fn list_width_is_clamped() { - let (mut app, _) = test_app(&[]); + let (mut app, _, dir) = test_app(&[]); app.list_width_pct = LIST_WIDTH_MIN; app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); @@ -503,16 +569,74 @@ mod tests { app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); assert_eq!(app.list_width_pct, LIST_WIDTH_MIN + LIST_WIDTH_STEP); + std::fs::remove_dir_all(dir).unwrap(); } #[test] fn q_does_not_leave_detail_or_quit() { - let (mut app, _) = test_app(&[block("one", "command", "output", 100)]); + let (mut app, _, dir) = test_app(&[block("one", "command", "output", 100)]); app.focus = Focus::Detail; app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); assert_eq!(app.focus, Focus::Detail); assert!(!app.should_quit); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn migrates_legacy_width_only_after_config_save() { + let db = Db::open_in_memory().unwrap(); + queries::set_setting(&db.conn, LIST_WIDTH_SETTING, "60").unwrap(); + let dir = std::env::temp_dir().join(format!( + "recall-width-migration-test-{}-{}", + std::process::id(), + ulid::Ulid::generate() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.toml"); + std::fs::write(&path, toml::to_string_pretty(&Config::default()).unwrap()).unwrap(); + let store = ConfigStore::at(path.clone()); + let mut config = Config::default(); + + let (width, error) = migrate_list_width(&db, &mut config, &store); + + assert_eq!(width, 60); + assert!(error.is_none()); + assert_eq!(config.ui.list_width_pct, 60); + assert_eq!( + queries::get_setting(&db.conn, LIST_WIDTH_SETTING).unwrap(), + None + ); + assert_eq!(Config::load_from(&path).unwrap().ui.list_width_pct, 60); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn keeps_legacy_width_when_config_save_fails() { + let db = Db::open_in_memory().unwrap(); + queries::set_setting(&db.conn, LIST_WIDTH_SETTING, "55").unwrap(); + let dir = std::env::temp_dir().join(format!( + "recall-width-failure-test-{}-{}", + std::process::id(), + ulid::Ulid::generate() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.toml"); + std::fs::write(&path, "[invalid").unwrap(); + let store = ConfigStore::at(path); + let mut config = Config::default(); + + let (width, error) = migrate_list_width(&db, &mut config, &store); + + assert_eq!(width, 55); + assert!(error.is_some()); + assert_eq!( + queries::get_setting(&db.conn, LIST_WIDTH_SETTING) + .unwrap() + .as_deref(), + Some("55") + ); + std::fs::remove_dir_all(dir).unwrap(); } } diff --git a/src/tui/config/app.rs b/src/tui/config/app.rs new file mode 100644 index 0000000..bb3ebac --- /dev/null +++ b/src/tui/config/app.rs @@ -0,0 +1,977 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::path::PathBuf; + +use crate::cli::SetupMode; +use crate::commands::setup::{self, ManagedSetup}; +use crate::config::{Config, ConfigStore}; +use crate::shell::Shell; + +pub(super) const RECALL_COMMAND: &str = r"^\s*recall\b"; +const PASSWORD_COMMANDS: &str = r"^\s*(?:pass|gopass|op|bw)\b"; +const CONTAINER_LOGS: &str = r"^\s*(?:docker|kubectl)\s+logs\b"; +const TAIL_FOLLOW: &str = r"^\s*tail\s+-f\b"; +const FFMPEG: &str = r"^\s*ffmpeg\b"; +const DATE_FORMATS: [&str; 3] = ["%Y-%m-%d %H:%M:%S", "%H:%M:%S", "%b %d %H:%M"]; +const PREVIEW_MIN: i64 = 0; +const PREVIEW_MAX: i64 = 20; +const LIST_WIDTH_MIN: i64 = 15; +const LIST_WIDTH_MAX: i64 = 85; +const LIST_WIDTH_STEP: i64 = 5; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Category { + Keybinding, + Capture, + Appearance, + Shell, +} + +impl Category { + pub(crate) const ALL: [Self; 4] = [ + Self::Keybinding, + Self::Capture, + Self::Appearance, + Self::Shell, + ]; + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Keybinding => "Keybinding", + Self::Capture => "Capture rules", + Self::Appearance => "Appearance", + Self::Shell => "Shell integration", + } + } + + pub(crate) fn row_count(self) -> usize { + match self { + Self::Keybinding => 1, + Self::Capture => 4, + Self::Appearance => 3, + Self::Shell => 7, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RuleTarget { + Command, + Output, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Modal { + KeyRecorder { + chords: Vec, + }, + DateEditor { + input: String, + }, + RemoveSetup { + profile: PathBuf, + }, + RuleEditor { + target: RuleTarget, + index: Option, + input: String, + }, + DeleteRule { + target: RuleTarget, + index: usize, + pattern: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CaptureItem { + Secrets, + Interactive, + Preset { + label: &'static str, + target: RuleTarget, + pattern: &'static str, + }, + Add(RuleTarget), + Rule { + target: RuleTarget, + index: usize, + pattern: String, + }, +} + +pub(crate) struct App { + pub(crate) config: Config, + pub(crate) category: usize, + pub(crate) selected: usize, + pub(crate) should_quit: bool, + pub(crate) show_help: bool, + pub(crate) status: Option, + pub(crate) status_is_error: bool, + pub(crate) modal: Option, + pub(crate) settings_state: ratatui::widgets::ListState, + pub(crate) shell_name: String, + pub(crate) shell_profile: Option, + pub(crate) shell_setup: Option, + pub(crate) shell_error: Option, + store: ConfigStore, +} + +impl App { + pub(crate) fn new(config: Config) -> Self { + let shell_name = crate::util::login_shell(); + let (shell_profile, shell_setup, shell_error) = inspect_shell(&shell_name); + let needs_shell_attention = + !matches!(shell_setup, Some(ManagedSetup::Auto | ManagedSetup::Hooks)); + Self { + config, + category: if needs_shell_attention { 3 } else { 0 }, + selected: if needs_shell_attention { 4 } else { 0 }, + should_quit: false, + show_help: false, + status: None, + status_is_error: false, + modal: None, + settings_state: ratatui::widgets::ListState::default(), + shell_name, + shell_profile, + shell_setup, + shell_error, + store: ConfigStore::active(), + } + } + + pub(crate) fn current_category(&self) -> Category { + Category::ALL[self.category] + } + + pub(crate) fn handle_key(&mut self, key: KeyEvent) { + if self.modal.is_some() { + self.handle_modal_key(key); + return; + } + if key.code == KeyCode::F(1) { + self.show_help = !self.show_help; + return; + } + if self.show_help { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { + self.show_help = false; + } + return; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Tab => self.move_category(1), + KeyCode::BackTab => self.move_category(-1), + KeyCode::Up => self.move_row(-1), + KeyCode::Down => self.move_row(1), + KeyCode::Left => self.adjust_appearance(-1), + KeyCode::Right => self.adjust_appearance(1), + KeyCode::Char(' ') | KeyCode::Enter => self.activate_selected(), + KeyCode::Char('a') if self.current_category() == Category::Capture => { + self.add_rule_for_selection() + } + KeyCode::Char('e') if self.current_category() == Category::Capture => { + self.edit_selected_rule() + } + KeyCode::Char('d') if self.current_category() == Category::Capture => { + self.delete_selected_rule() + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + _ => {} + } + } + + fn move_category(&mut self, delta: isize) { + let len = Category::ALL.len() as isize; + self.category = (self.category as isize + delta).rem_euclid(len) as usize; + self.selected = 0; + self.status = None; + } + + fn move_row(&mut self, delta: isize) { + let len = self.row_count() as isize; + self.selected = (self.selected as isize + delta).rem_euclid(len) as usize; + } + + pub(crate) fn row_count(&self) -> usize { + match self.current_category() { + Category::Capture => self.capture_items().len(), + category => category.row_count(), + } + } + + pub(crate) fn capture_items(&self) -> Vec { + let presets = [ + CaptureItem::Preset { + label: "Recall commands", + target: RuleTarget::Command, + pattern: RECALL_COMMAND, + }, + CaptureItem::Preset { + label: "Password managers", + target: RuleTarget::Command, + pattern: PASSWORD_COMMANDS, + }, + CaptureItem::Preset { + label: "Container logs output", + target: RuleTarget::Output, + pattern: CONTAINER_LOGS, + }, + CaptureItem::Preset { + label: "tail -f output", + target: RuleTarget::Output, + pattern: TAIL_FOLLOW, + }, + CaptureItem::Preset { + label: "ffmpeg output", + target: RuleTarget::Output, + pattern: FFMPEG, + }, + ]; + let mut items = vec![CaptureItem::Secrets, CaptureItem::Interactive]; + items.extend(presets); + items.push(CaptureItem::Add(RuleTarget::Command)); + items.extend( + self.config + .proxy + .exclude + .iter() + .enumerate() + .filter(|(_, pattern)| !is_preset(pattern)) + .map(|(index, pattern)| CaptureItem::Rule { + target: RuleTarget::Command, + index, + pattern: pattern.clone(), + }), + ); + items.push(CaptureItem::Add(RuleTarget::Output)); + items.extend( + self.config + .proxy + .exclude_output + .iter() + .enumerate() + .filter(|(_, pattern)| !is_preset(pattern)) + .map(|(index, pattern)| CaptureItem::Rule { + target: RuleTarget::Output, + index, + pattern: pattern.clone(), + }), + ); + items + } + + pub(crate) fn preset_enabled(&self, target: RuleTarget, pattern: &str) -> bool { + rules(&self.config, target) + .iter() + .any(|rule| rule == pattern) + } + + fn activate_selected(&mut self) { + if self.current_category() == Category::Keybinding { + self.modal = Some(Modal::KeyRecorder { chords: Vec::new() }); + self.status = None; + return; + } + if self.current_category() == Category::Appearance { + if self.selected == 2 { + self.modal = Some(Modal::DateEditor { + input: self.config.ui.date_format.clone(), + }); + self.status = None; + } + return; + } + if self.current_category() == Category::Shell { + match self.selected { + 4 => self.apply_shell_setup(SetupMode::Auto, false), + 5 => self.apply_shell_setup(SetupMode::Hooks, false), + 6 => { + if let Some(profile) = self.shell_profile.clone() { + self.modal = Some(Modal::RemoveSetup { profile }); + } else { + self.set_error("cannot determine this shell's startup file".to_string()); + } + } + _ => self.set_error("select a setup action below".to_string()), + } + return; + } + if self.current_category() != Category::Capture { + return; + } + let Some(item) = self.capture_items().get(self.selected).cloned() else { + return; + }; + match item { + CaptureItem::Secrets => self.toggle_bool("secrets_filter"), + CaptureItem::Interactive => self.toggle_bool("mark_interactive"), + CaptureItem::Preset { + target: _, pattern, .. + } if pattern == RECALL_COMMAND => self + .set_error("Recall commands are always skipped to prevent recursion".to_string()), + CaptureItem::Preset { + target, pattern, .. + } => self.toggle_preset(target, pattern), + CaptureItem::Add(target) => self.open_rule_editor(target, None, String::new()), + CaptureItem::Rule { + target, + index, + pattern, + } => self.open_rule_editor(target, Some(index), pattern), + } + } + + fn toggle_bool(&mut self, key: &str) { + let next = match key { + "secrets_filter" => !self.config.proxy.secrets_filter, + "mark_interactive" => !self.config.proxy.mark_interactive, + _ => return, + }; + match self.store.set_bool("proxy", key, next) { + Ok(config) => { + self.config = config; + self.set_saved(format!( + "{key} {}", + if next { "enabled" } else { "disabled" } + )); + } + Err(err) => self.set_error(format!("failed to save {key}: {err}")), + } + } + + fn toggle_preset(&mut self, target: RuleTarget, pattern: &str) { + let mut next = rules(&self.config, target).to_vec(); + if let Some(index) = next.iter().position(|rule| rule == pattern) { + next.remove(index); + } else { + next.push(pattern.to_string()); + } + self.save_rules(target, next, "capture preset updated"); + } + + fn add_rule_for_selection(&mut self) { + let target = self + .capture_items() + .get(self.selected) + .and_then(capture_target) + .unwrap_or(RuleTarget::Command); + self.open_rule_editor(target, None, String::new()); + } + + fn edit_selected_rule(&mut self) { + let Some(CaptureItem::Rule { + target, + index, + pattern, + }) = self.capture_items().get(self.selected).cloned() + else { + self.set_error("select a custom rule to edit".to_string()); + return; + }; + self.open_rule_editor(target, Some(index), pattern); + } + + fn delete_selected_rule(&mut self) { + let Some(CaptureItem::Rule { + target, + index, + pattern, + }) = self.capture_items().get(self.selected).cloned() + else { + self.set_error("select a custom rule to delete".to_string()); + return; + }; + self.modal = Some(Modal::DeleteRule { + target, + index, + pattern, + }); + } + + fn open_rule_editor(&mut self, target: RuleTarget, index: Option, input: String) { + self.modal = Some(Modal::RuleEditor { + target, + index, + input, + }); + } + + fn handle_modal_key(&mut self, key: KeyEvent) { + let Some(mut modal) = self.modal.take() else { + return; + }; + match &mut modal { + Modal::KeyRecorder { chords } => match key.code { + KeyCode::Esc => {} + KeyCode::Enter if chords.is_empty() => { + self.set_error("press a Ctrl or Alt shortcut first".to_string()); + self.modal = Some(modal); + } + KeyCode::Enter => { + let spec = chords.join(" "); + self.save_search_key(&spec); + } + KeyCode::Backspace => { + chords.pop(); + self.modal = Some(modal); + } + _ => match semantic_chord(key) { + Some(chord) if chords.len() < 2 => { + chords.push(chord); + self.status = None; + self.modal = Some(modal); + } + Some(_) => { + self.set_error("a shortcut can contain at most two chords".to_string()); + self.modal = Some(modal); + } + None => { + self.set_error( + "use Ctrl/Alt with a letter, or press Ctrl+Space".to_string(), + ); + self.modal = Some(modal); + } + }, + }, + Modal::DateEditor { input } => match key.code { + KeyCode::Esc => {} + KeyCode::Enter if input.is_empty() => { + self.set_error("date format cannot be empty".to_string()); + self.modal = Some(modal); + } + KeyCode::Enter if !valid_date_format(input) => { + self.set_error("invalid strftime date format".to_string()); + self.modal = Some(modal); + } + KeyCode::Enter => { + let format = input.clone(); + self.save_date_format(&format, true); + if self.status_is_error { + self.modal = Some(modal); + } + } + KeyCode::Backspace => { + input.pop(); + self.modal = Some(modal); + } + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + input.push(character); + self.modal = Some(modal); + } + _ => self.modal = Some(modal), + }, + Modal::RemoveSetup { profile: _ } => match key.code { + KeyCode::Enter | KeyCode::Char('y') => { + self.apply_shell_setup(SetupMode::Auto, true); + } + KeyCode::Esc | KeyCode::Char('n') => {} + _ => self.modal = Some(modal), + }, + Modal::RuleEditor { + target, + index, + input, + } => match key.code { + KeyCode::Esc => {} + KeyCode::Enter => { + let input = input.trim().to_string(); + if input.is_empty() { + self.set_error("regular expression cannot be empty".to_string()); + self.modal = Some(modal); + } else if let Err(err) = regex::Regex::new(&input) { + self.set_error(format!("invalid regular expression: {err}")); + self.modal = Some(modal); + } else { + let mut next = rules(&self.config, *target).to_vec(); + match *index { + Some(index) if index < next.len() => next[index] = input, + Some(_) => self.set_error("rule no longer exists".to_string()), + None => next.push(input), + } + if index.is_none() || index.is_some_and(|index| index < next.len()) { + self.save_rules(*target, next, "capture rule saved"); + } + } + } + KeyCode::Backspace => { + input.pop(); + self.modal = Some(modal); + } + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + input.push(character); + self.modal = Some(modal); + } + _ => self.modal = Some(modal), + }, + Modal::DeleteRule { + target, + index, + pattern: _, + } => match key.code { + KeyCode::Enter | KeyCode::Char('y') => { + let mut next = rules(&self.config, *target).to_vec(); + if *index < next.len() { + next.remove(*index); + self.save_rules(*target, next, "capture rule deleted"); + self.selected = self.selected.min(self.row_count().saturating_sub(1)); + } else { + self.set_error("rule no longer exists".to_string()); + } + } + KeyCode::Esc | KeyCode::Char('n') => {} + _ => self.modal = Some(modal), + }, + } + } + + fn save_rules(&mut self, target: RuleTarget, rules: Vec, status: &str) { + let key = match target { + RuleTarget::Command => "exclude", + RuleTarget::Output => "exclude_output", + }; + match self.store.set_strings("proxy", key, &rules) { + Ok(config) => { + self.config = config; + self.set_saved(status.to_string()); + } + Err(err) => self.set_error(format!("failed to save capture rules: {err}")), + } + } + + fn save_search_key(&mut self, spec: &str) { + let Some(shell) = Shell::from_command(&self.shell_name) else { + self.set_error(format!( + "cannot validate shortcuts for unsupported shell {}", + self.shell_name + )); + self.modal = Some(Modal::KeyRecorder { + chords: spec.split_whitespace().map(str::to_string).collect(), + }); + return; + }; + if shell.semantic_search_key(spec).is_none() { + self.set_error(format!("{spec} is not supported by {}", shell.name)); + self.modal = Some(Modal::KeyRecorder { + chords: spec.split_whitespace().map(str::to_string).collect(), + }); + return; + } + match self.store.set_string("ui", "search_key", spec) { + Ok(config) => { + self.config = config; + self.set_saved("search shortcut saved; open a new shell to use it".to_string()); + } + Err(err) => { + self.set_error(format!("failed to save search shortcut: {err}")); + self.modal = Some(Modal::KeyRecorder { + chords: spec.split_whitespace().map(str::to_string).collect(), + }); + } + } + } + + fn adjust_appearance(&mut self, direction: i64) { + if self.current_category() != Category::Appearance { + return; + } + match self.selected { + 0 => { + let next = (self.config.ui.preview_lines as i64 + direction) + .clamp(PREVIEW_MIN, PREVIEW_MAX); + if next != self.config.ui.preview_lines as i64 { + self.save_integer("preview_lines", next, format!("preview lines {next}")); + } + } + 1 => { + let next = (self.config.ui.list_width_pct as i64 + direction * LIST_WIDTH_STEP) + .clamp(LIST_WIDTH_MIN, LIST_WIDTH_MAX); + if next != self.config.ui.list_width_pct as i64 { + self.save_integer("list_width_pct", next, format!("list width {next}%")); + } + } + 2 => { + let current = DATE_FORMATS + .iter() + .position(|format| *format == self.config.ui.date_format); + let next = match (current, direction.is_negative()) { + (Some(index), false) => (index + 1) % DATE_FORMATS.len(), + (Some(0), true) | (None, true) => DATE_FORMATS.len() - 1, + (Some(index), true) => index - 1, + (None, false) => 0, + }; + self.save_date_format(DATE_FORMATS[next], false); + } + _ => {} + } + } + + fn save_integer(&mut self, key: &str, value: i64, status: String) { + match self.store.set_integer("ui", key, value) { + Ok(config) => { + self.config = config; + self.set_saved(status); + } + Err(err) => self.set_error(format!("failed to save {key}: {err}")), + } + } + + fn save_date_format(&mut self, format: &str, custom: bool) { + match self.store.set_string("ui", "date_format", format) { + Ok(config) => { + self.config = config; + self.set_saved(if custom { + "custom timestamp format saved".to_string() + } else { + "timestamp preset saved".to_string() + }); + } + Err(err) => self.set_error(format!("failed to save timestamp format: {err}")), + } + } + + fn apply_shell_setup(&mut self, mode: SetupMode, remove: bool) { + match setup::apply(Some(&self.shell_name), None, mode, remove) { + Ok(result) => { + let changed = result.changed; + self.refresh_shell_setup(); + self.set_saved(if remove { + if changed { + "shell integration removed".to_string() + } else { + "shell integration was not present".to_string() + } + } else { + format!( + "{} shell setup {}", + match mode { + SetupMode::Auto => "automatic", + SetupMode::Hooks => "hooks-only", + }, + if changed { "saved" } else { "already current" } + ) + }); + } + Err(err) => { + self.refresh_shell_setup(); + self.set_error(format!("failed to update shell setup: {err}")); + } + } + } + + fn refresh_shell_setup(&mut self) { + let (profile, setup, error) = inspect_shell(&self.shell_name); + self.shell_profile = profile; + self.shell_setup = setup; + self.shell_error = error; + } + + fn set_saved(&mut self, status: String) { + self.status = Some(status); + self.status_is_error = false; + } + + fn set_error(&mut self, status: String) { + self.status = Some(status); + self.status_is_error = true; + } +} + +fn inspect_shell(shell_name: &str) -> (Option, Option, Option) { + match setup::inspect_shell(shell_name) { + Ok((profile, inspection)) => (Some(profile), Some(inspection.setup), None), + Err(err) => (None, None, Some(err.to_string())), + } +} + +fn semantic_chord(key: KeyEvent) -> Option { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + if ctrl == alt + || key + .modifiers + .intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META) + { + return None; + } + if ctrl && matches!(key.code, KeyCode::Null | KeyCode::Char(' ' | '@')) { + return Some("ctrl-space".to_string()); + } + let KeyCode::Char(character) = key.code else { + return None; + }; + character.is_ascii_alphabetic().then(|| { + format!( + "{}-{}", + if ctrl { "ctrl" } else { "alt" }, + character.to_ascii_lowercase() + ) + }) +} + +pub(crate) fn valid_date_format(format: &str) -> bool { + !format.is_empty() + && !chrono::format::StrftimeItems::new(format) + .any(|item| matches!(item, chrono::format::Item::Error)) +} + +pub(crate) fn date_preview(format: &str) -> String { + if valid_date_format(format) { + crate::util::format_time(crate::util::now_ns(), format) + } else { + "Invalid format".to_string() + } +} + +fn rules(config: &Config, target: RuleTarget) -> &[String] { + match target { + RuleTarget::Command => &config.proxy.exclude, + RuleTarget::Output => &config.proxy.exclude_output, + } +} + +fn is_preset(pattern: &str) -> bool { + [ + RECALL_COMMAND, + PASSWORD_COMMANDS, + CONTAINER_LOGS, + TAIL_FOLLOW, + FFMPEG, + ] + .contains(&pattern) +} + +fn capture_target(item: &CaptureItem) -> Option { + match item { + CaptureItem::Preset { target, .. } + | CaptureItem::Add(target) + | CaptureItem::Rule { target, .. } => Some(*target), + CaptureItem::Secrets | CaptureItem::Interactive => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn persisted_app(name: &str) -> (App, PathBuf, PathBuf) { + let dir = std::env::temp_dir().join(format!( + "recall-config-ui-test-{}-{}-{name}", + std::process::id(), + ulid::Ulid::generate() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.toml"); + let config = Config::default(); + std::fs::write(&path, toml::to_string_pretty(&config).unwrap()).unwrap(); + let mut app = App::new(config); + app.category = 0; + app.selected = 0; + app.shell_name = "zsh".to_string(); + app.store = ConfigStore::at(path.clone()); + (app, path, dir) + } + + #[test] + fn navigates_categories_and_rows() { + let mut app = App::new(Config::default()); + app.category = 0; + app.selected = 0; + app.handle_key(key(KeyCode::BackTab)); + assert_eq!(app.current_category(), Category::Shell); + app.handle_key(key(KeyCode::Up)); + assert_eq!(app.selected, 6); + app.handle_key(key(KeyCode::Tab)); + assert_eq!(app.current_category(), Category::Keybinding); + assert_eq!(app.selected, 0); + } + + #[test] + fn help_closes_before_the_dashboard() { + let mut app = App::new(Config::default()); + app.handle_key(key(KeyCode::F(1))); + app.handle_key(key(KeyCode::Esc)); + assert!(!app.show_help); + assert!(!app.should_quit); + app.handle_key(key(KeyCode::Esc)); + assert!(app.should_quit); + } + + #[test] + fn capture_items_separate_presets_from_custom_rules() { + let mut config = Config::default(); + config.proxy.exclude.push("^private".to_string()); + config.proxy.exclude_output.push(FFMPEG.to_string()); + config.proxy.exclude_output.push("^large".to_string()); + let app = App::new(config); + let items = app.capture_items(); + + assert!(items.iter().any(|item| matches!( + item, + CaptureItem::Rule { pattern, .. } if pattern == "^private" + ))); + assert!(items.iter().any(|item| matches!( + item, + CaptureItem::Rule { pattern, .. } if pattern == "^large" + ))); + assert!(!items.iter().any(|item| matches!( + item, + CaptureItem::Rule { pattern, .. } if pattern == FFMPEG + ))); + } + + #[test] + fn invalid_rule_stays_in_editor() { + let mut app = App::new(Config::default()); + app.modal = Some(Modal::RuleEditor { + target: RuleTarget::Command, + index: None, + input: "[".to_string(), + }); + app.handle_key(key(KeyCode::Enter)); + assert!(matches!(app.modal, Some(Modal::RuleEditor { .. }))); + assert!(app.status_is_error); + } + + #[test] + fn capture_changes_are_saved_immediately() { + let (mut app, path, dir) = persisted_app("capture-save"); + app.category = 1; + + app.selected = 0; + app.handle_key(key(KeyCode::Char(' '))); + assert!(!Config::load_from(&path).unwrap().proxy.secrets_filter); + + app.selected = 3; + app.handle_key(key(KeyCode::Char(' '))); + assert!( + Config::load_from(&path) + .unwrap() + .proxy + .exclude + .contains(&PASSWORD_COMMANDS.to_string()) + ); + + app.modal = Some(Modal::RuleEditor { + target: RuleTarget::Output, + index: None, + input: "^large".to_string(), + }); + app.handle_key(key(KeyCode::Enter)); + assert_eq!( + Config::load_from(&path).unwrap().proxy.exclude_output, + ["^large"] + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn recall_command_rule_cannot_be_disabled() { + let (mut app, path, dir) = persisted_app("required-recall-rule"); + app.category = 1; + app.selected = 2; + + app.handle_key(key(KeyCode::Char(' '))); + + assert!(app.status_is_error); + assert!( + Config::load_from(&path) + .unwrap() + .proxy + .exclude + .contains(&RECALL_COMMAND.to_string()) + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn records_two_chords_and_saves_semantic_key() { + let (mut app, path, dir) = persisted_app("search-key"); + app.handle_key(key(KeyCode::Enter)); + app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)); + app.handle_key(KeyEvent::new(KeyCode::Null, KeyModifiers::CONTROL)); + app.handle_key(key(KeyCode::Enter)); + + assert!(app.modal.is_none()); + assert_eq!( + Config::load_from(&path).unwrap().ui.search_key, + "ctrl-x ctrl-space" + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn rejects_unmodified_and_third_chords() { + let mut app = App::new(Config::default()); + app.modal = Some(Modal::KeyRecorder { chords: Vec::new() }); + app.handle_key(key(KeyCode::Char('r'))); + assert!(app.status_is_error); + app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)); + app.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); + app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(app.status.as_deref().unwrap().contains("at most two")); + assert!(matches!( + app.modal, + Some(Modal::KeyRecorder { ref chords }) if chords.len() == 2 + )); + } + + #[test] + fn appearance_controls_save_each_change() { + let (mut app, path, dir) = persisted_app("appearance"); + app.category = 2; + + app.selected = 0; + app.handle_key(key(KeyCode::Right)); + assert_eq!(Config::load_from(&path).unwrap().ui.preview_lines, 5); + + app.selected = 1; + app.handle_key(key(KeyCode::Right)); + assert_eq!(Config::load_from(&path).unwrap().ui.list_width_pct, 47); + + app.selected = 2; + app.handle_key(key(KeyCode::Right)); + assert_eq!(Config::load_from(&path).unwrap().ui.date_format, "%H:%M:%S"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn invalid_date_format_stays_in_editor() { + let mut app = App::new(Config::default()); + app.modal = Some(Modal::DateEditor { + input: "%Q".to_string(), + }); + app.handle_key(key(KeyCode::Enter)); + assert!(matches!(app.modal, Some(Modal::DateEditor { .. }))); + assert!(app.status_is_error); + assert!(!valid_date_format("%Q")); + assert!(valid_date_format("%Y-%m-%d")); + } + + #[test] + fn shell_setup_defaults_to_attention_when_not_configured() { + let mut app = App::new(Config::default()); + app.shell_setup = Some(ManagedSetup::None); + app.category = 3; + app.selected = 4; + assert_eq!(app.current_category(), Category::Shell); + assert_eq!(app.row_count(), 7); + } +} diff --git a/src/tui/config/mod.rs b/src/tui/config/mod.rs new file mode 100644 index 0000000..44f647d --- /dev/null +++ b/src/tui/config/mod.rs @@ -0,0 +1,81 @@ +mod app; +mod ui; + +use std::time::Duration; + +use anyhow::Result; +use crossterm::event::{self, Event, KeyEventKind}; + +use crate::config::Config; +use crate::tui::runtime::TerminalGuard; +use app::App; + +pub(crate) fn run(config: Config) -> Result<()> { + let mut app = App::new(config); + let mut guard = TerminalGuard::enter()?; + let result = event_loop(guard.terminal(), &mut app); + guard.leave(); + result +} + +fn event_loop( + terminal: &mut ratatui::Terminal, + app: &mut App, +) -> Result<()> { + loop { + terminal.draw(|frame| ui::draw(frame, app))?; + if event::poll(Duration::from_millis(200))? + && let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + handle_key_for_size(app, key, crossterm::terminal::size()?); + } + if app.should_quit { + return Ok(()); + } + } +} + +fn handle_key_for_size(app: &mut App, key: crossterm::event::KeyEvent, size: (u16, u16)) { + let exit_key = matches!( + key.code, + crossterm::event::KeyCode::Esc | crossterm::event::KeyCode::Char('q') + ) || key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + && key.code == crossterm::event::KeyCode::Char('c'); + if size.0 < 72 || size.1 < 20 { + app.should_quit |= exit_key; + } else { + app.handle_key(key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + #[test] + fn small_terminal_ignores_actions_but_allows_exit() { + let mut app = App::new(crate::config::Config::default()); + app.category = 1; + app.selected = 0; + let original = app.config.proxy.secrets_filter; + + handle_key_for_size( + &mut app, + KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), + (60, 15), + ); + assert_eq!(app.config.proxy.secrets_filter, original); + assert!(!app.should_quit); + + handle_key_for_size( + &mut app, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + (60, 15), + ); + assert!(app.should_quit); + } +} diff --git a/src/tui/config/ui.rs b/src/tui/config/ui.rs new file mode 100644 index 0000000..0d33ca4 --- /dev/null +++ b/src/tui/config/ui.rs @@ -0,0 +1,426 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap}; + +use super::app::{App, CaptureItem, Category, Modal, RuleTarget}; + +pub(crate) fn draw(frame: &mut Frame, app: &mut App) { + if frame.area().width < 72 || frame.area().height < 20 { + frame.render_widget( + Paragraph::new( + "recall config needs a terminal of at least 72x20\n\nPress q or Esc to exit", + ) + .block(Block::bordered().title(" recall config ")) + .wrap(Wrap { trim: false }), + frame.area(), + ); + return; + } + + let areas = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(frame.area()); + frame.render_widget( + Paragraph::new("Configure capture, search, and shell integration safely") + .block(Block::bordered().title(" recall config ")), + areas[0], + ); + + let panes = Layout::horizontal([Constraint::Length(24), Constraint::Min(0)]).split(areas[1]); + draw_categories(frame, app, panes[0]); + draw_settings(frame, app, panes[1]); + + let status_style = if app.status_is_error { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + let navigation_controls = Line::from(vec![ + Span::styled("Tab / Shift+Tab", Style::default().fg(Color::Cyan)), + Span::raw(" change category "), + Span::styled("↑/↓", Style::default().fg(Color::Cyan)), + Span::raw(" select setting"), + ]); + let action_controls = Line::from(vec![ + Span::styled("Space", Style::default().fg(Color::Cyan)), + Span::raw(" toggle "), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::raw(" edit/apply "), + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::raw(" quit "), + Span::styled("F1", Style::default().fg(Color::Cyan)), + Span::raw(" help"), + ]); + let status = Line::styled(app.status.as_deref().unwrap_or("Ready"), status_style); + frame.render_widget( + Paragraph::new(vec![navigation_controls, action_controls, status]), + areas[2], + ); + + if app.show_help { + draw_help(frame); + } + if let Some(modal) = &app.modal { + draw_modal(frame, modal); + } +} + +fn draw_categories(frame: &mut Frame, app: &App, area: Rect) { + let items = Category::ALL + .iter() + .enumerate() + .map(|(index, category)| { + let marker = if index == app.category { "› " } else { " " }; + ListItem::new(format!("{marker}{}", category.label())).style(if index == app.category { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }) + }) + .collect::>(); + frame.render_widget( + List::new(items).block(Block::bordered().title(" Categories [Tab] ")), + area, + ); +} + +fn draw_settings(frame: &mut Frame, app: &mut App, area: Rect) { + let rows = setting_rows(app); + let items = rows + .into_iter() + .enumerate() + .map(|(index, (label, value))| { + let marker = if index == app.selected { "›" } else { " " }; + ListItem::new(Line::from(vec![ + Span::styled( + format!("{marker} {label:<22}"), + if index == app.selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ), + Span::raw(value), + ])) + }) + .collect::>(); + app.settings_state.select(Some(app.selected)); + frame.render_stateful_widget( + List::new(items) + .block(Block::bordered().title(format!(" {} ", app.current_category().label()))), + area, + &mut app.settings_state, + ); +} + +fn setting_rows(app: &App) -> Vec<(String, String)> { + match app.current_category() { + Category::Keybinding => keybinding_rows(app), + Category::Capture => capture_rows(app), + Category::Appearance => vec![ + ( + "Preview lines".to_string(), + app.config.ui.preview_lines.to_string(), + ), + ( + "List pane width".to_string(), + format!("{}%", app.config.ui.list_width_pct), + ), + ( + "Timestamp format".to_string(), + format!( + "{} · {}", + app.config.ui.date_format, + super::app::date_preview(&app.config.ui.date_format) + ), + ), + ], + Category::Shell => vec![ + ("Detected shell".to_string(), app.shell_name.clone()), + ( + "Startup file".to_string(), + app.shell_profile + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "Unavailable".to_string()), + ), + ("Current setup".to_string(), shell_setup_label(app)), + ( + "Runtime".to_string(), + format!( + "hooks {} · proxy {}", + on_off(std::env::var_os("RECALL_SESSION").is_some()), + on_off(std::env::var_os("RECALL_PROXY_ACTIVE").is_some()) + ), + ), + ("Use automatic capture".to_string(), "Apply".to_string()), + ("Use hooks only".to_string(), "Apply".to_string()), + ("Remove integration".to_string(), "Confirm…".to_string()), + ], + } +} + +fn shell_setup_label(app: &App) -> String { + if let Some(error) = &app.shell_error { + return format!("Error: {error}"); + } + match app.shell_setup { + Some(crate::commands::setup::ManagedSetup::Auto) => "Automatic capture".to_string(), + Some(crate::commands::setup::ManagedSetup::Hooks) => "Hooks only".to_string(), + Some(crate::commands::setup::ManagedSetup::LegacyAuto) => { + "Legacy automatic setup".to_string() + } + Some(crate::commands::setup::ManagedSetup::LegacyHooks) => "Legacy hooks setup".to_string(), + Some(crate::commands::setup::ManagedSetup::UnmanagedHooks) => "Unmanaged hooks".to_string(), + Some(crate::commands::setup::ManagedSetup::None) => "Not configured".to_string(), + Some(crate::commands::setup::ManagedSetup::Invalid) => { + "Malformed recall markers — repair manually".to_string() + } + None => "Unavailable".to_string(), + } +} + +fn keybinding_rows(app: &App) -> Vec<(String, String)> { + let mut rows = vec![( + "Search shortcut".to_string(), + app.config.ui.search_key.clone(), + )]; + for (name, label) in [ + ("zsh", "zsh"), + ("bash", "bash"), + ("fish", "fish"), + ("pwsh", "PowerShell"), + ] { + let value = crate::shell::Shell::from_name(name) + .and_then(|shell| shell.semantic_search_key(&app.config.ui.search_key)) + .map(|encoded| format!("Compatible · {encoded}")) + .unwrap_or_else(|| "Custom / not validated".to_string()); + rows.push((format!(" {label}"), value)); + } + rows +} + +fn capture_rows(app: &App) -> Vec<(String, String)> { + app.capture_items() + .into_iter() + .map(|item| match item { + CaptureItem::Secrets => ( + "Secret filtering".to_string(), + on_off(app.config.proxy.secrets_filter), + ), + CaptureItem::Interactive => ( + "Interactive commands".to_string(), + on_off(app.config.proxy.mark_interactive), + ), + CaptureItem::Preset { + label, + target, + pattern, + } => { + let value = if pattern == super::app::RECALL_COMMAND { + "[x] Required".to_string() + } else { + checkbox(app.preset_enabled(target, pattern)) + }; + (label.to_string(), value) + } + CaptureItem::Add(RuleTarget::Command) => { + ("+ Command rule".to_string(), "Enter to add".to_string()) + } + CaptureItem::Add(RuleTarget::Output) => { + ("+ Output rule".to_string(), "Enter to add".to_string()) + } + CaptureItem::Rule { + target: RuleTarget::Command, + pattern, + .. + } => ("Command rule".to_string(), pattern), + CaptureItem::Rule { + target: RuleTarget::Output, + pattern, + .. + } => ("Output rule".to_string(), pattern), + }) + .collect() +} + +fn on_off(value: bool) -> String { + if value { "On" } else { "Off" }.to_string() +} + +fn checkbox(value: bool) -> String { + if value { "[x]" } else { "[ ]" }.to_string() +} + +fn draw_modal(frame: &mut Frame, modal: &Modal) { + let area = centered_rect(72, 40, frame.area()); + frame.render_widget(Clear, area); + match modal { + Modal::KeyRecorder { chords } => { + let value = if chords.is_empty() { + "Waiting for a shortcut…".to_string() + } else { + chords.join(" then ") + }; + frame.render_widget( + Paragraph::new(format!( + "{value}\n\nPress one or two Ctrl/Alt chords.\nEnter save · Backspace remove · Esc cancel" + )) + .block(Block::bordered().title(" Record search shortcut ")) + .wrap(Wrap { trim: false }), + area, + ); + } + Modal::DateEditor { input } => { + frame.render_widget( + Paragraph::new(format!( + "{input}\n\nPreview: {}\n\nEnter save · Esc cancel", + super::app::date_preview(input) + )) + .block(Block::bordered().title(" Custom timestamp format ")) + .wrap(Wrap { trim: false }), + area, + ); + } + Modal::RemoveSetup { profile } => { + frame.render_widget( + Paragraph::new(format!( + "Remove recall-managed setup from this startup file?\n\n{}\n\nEnter/y confirm · Esc/n cancel", + profile.display() + )) + .block(Block::bordered().title(" Remove shell integration ")) + .wrap(Wrap { trim: false }), + area, + ); + } + Modal::RuleEditor { + target, + index, + input, + } => { + let action = if index.is_some() { "Edit" } else { "Add" }; + let kind = match target { + RuleTarget::Command => "command exclusion", + RuleTarget::Output => "output exclusion", + }; + frame.render_widget( + Paragraph::new(format!( + "{input}\n\nEnter save · Esc cancel\nThe expression must be a valid Rust regular expression." + )) + .block(Block::bordered().title(format!(" {action} {kind} "))) + .wrap(Wrap { trim: false }), + area, + ); + } + Modal::DeleteRule { pattern, .. } => { + frame.render_widget( + Paragraph::new(format!( + "Delete this custom rule?\n\n{pattern}\n\nEnter/y confirm · Esc/n cancel" + )) + .block(Block::bordered().title(" Delete capture rule ")) + .wrap(Wrap { trim: false }), + area, + ); + } + } +} + +fn draw_help(frame: &mut Frame) { + let area = centered_rect(64, 70, frame.area()); + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new( + "Tab / Shift+Tab Change category\n↑ / ↓ Select setting\nSpace Toggle setting\nEnter Edit or apply\nEsc / q Back or quit\nF1 Close this help", + ) + .block(Block::bordered().title(" Help ")), + area, + ); +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + #[test] + fn renders_current_configuration() { + let mut config = crate::config::Config::default(); + config.ui.search_key = "ctrl-t".to_string(); + let mut app = App::new(config); + app.category = 0; + app.selected = 0; + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| draw(frame, &mut app)).unwrap(); + + let text = terminal.backend().to_string(); + assert!(text.contains("Keybinding")); + assert!(text.contains("Search shortcut")); + assert!(text.contains("ctrl-t")); + assert!(text.contains("Tab / Shift+Tab")); + } + + #[test] + fn keeps_controls_visible_while_showing_status() { + let mut app = App::new(crate::config::Config::default()); + app.status = Some("failed to save setting".to_string()); + app.status_is_error = true; + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| draw(frame, &mut app)).unwrap(); + + let text = terminal.backend().to_string(); + assert!(text.contains("Tab / Shift+Tab")); + assert!(text.contains("failed to save setting")); + } + + #[test] + fn renders_small_terminal_message() { + let mut app = App::new(crate::config::Config::default()); + let backend = TestBackend::new(60, 15); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| draw(frame, &mut app)).unwrap(); + assert!(terminal.backend().to_string().contains("at least 72x20")); + } + + #[test] + fn keeps_a_long_capture_selection_visible() { + let mut config = crate::config::Config::default(); + for index in 0..20 { + config.proxy.exclude.push(format!("^custom-{index}")); + } + let mut app = App::new(config); + app.category = 1; + app.selected = app.row_count() - 1; + let backend = TestBackend::new(100, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| draw(frame, &mut app)).unwrap(); + + assert!(terminal.backend().to_string().contains("^custom-19")); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index b536356..d18935c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,28 +1,19 @@ mod app; +pub(crate) mod config; +mod runtime; mod ui; -use std::io; use std::sync::mpsc::{Receiver, TryRecvError}; use std::time::Duration; use anyhow::Result; use crossterm::event::{self, Event, KeyEventKind}; -#[cfg(not(windows))] -use crossterm::event::{ - KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, -}; -use crossterm::execute; -use crossterm::terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, -}; use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; use crate::cli::SearchArgs; use crate::config::Config; use app::{Action, App}; - -type Backend = CrosstermBackend; +use runtime::{Backend, TerminalGuard}; /// Launch the interactive TUI. /// @@ -53,7 +44,7 @@ pub fn run( if let Some(command) = app.selected_command { println!("{command}"); use std::io::Write; - let _ = io::stdout().flush(); + let _ = std::io::stdout().flush(); if app.action == Action::Rerun { code = 2; } @@ -88,50 +79,3 @@ fn event_loop( } Ok(()) } - -/// Owns the terminal state and restores it on drop, so a panic never leaves the -/// user's terminal in raw mode. -struct TerminalGuard { - terminal: Terminal, -} - -impl TerminalGuard { - fn enter() -> Result { - enable_raw_mode()?; - let mut stderr = io::stderr(); - execute!(stderr, EnterAlternateScreen)?; - // Ask the terminal (kitty et al.) to report modifier keys such as - // Ctrl+Enter distinctly. Not used on Windows: Windows Terminal's - // kitty-protocol support conflicts with ConPTY; Ctrl+E is the fallback. - #[cfg(not(windows))] - let _ = execute!( - stderr, - PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) - ); - let terminal = Terminal::new(CrosstermBackend::new(stderr))?; - Ok(Self { terminal }) - } - - fn terminal(&mut self) -> &mut Terminal { - &mut self.terminal - } - - fn leave(&mut self) { - let _ = disable_raw_mode(); - #[cfg(not(windows))] - let _ = execute!( - self.terminal.backend_mut(), - PopKeyboardEnhancementFlags, - LeaveAlternateScreen - ); - #[cfg(windows)] - let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); - let _ = self.terminal.show_cursor(); - } -} - -impl Drop for TerminalGuard { - fn drop(&mut self) { - self.leave(); - } -} diff --git a/src/tui/runtime.rs b/src/tui/runtime.rs new file mode 100644 index 0000000..66146c6 --- /dev/null +++ b/src/tui/runtime.rs @@ -0,0 +1,69 @@ +use std::io; + +use anyhow::Result; +#[cfg(not(windows))] +use crossterm::event::{ + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; + +pub(crate) type Backend = CrosstermBackend; + +/// Owns the terminal state and restores it on drop, so a panic never leaves the +/// user's terminal in raw mode. +pub(crate) struct TerminalGuard { + terminal: Terminal, +} + +impl TerminalGuard { + pub(crate) fn enter() -> Result { + enable_raw_mode()?; + let mut stderr = io::stderr(); + if let Err(err) = execute!(stderr, EnterAlternateScreen) { + let _ = disable_raw_mode(); + return Err(err.into()); + } + #[cfg(not(windows))] + let _ = execute!( + stderr, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); + let terminal = match Terminal::new(CrosstermBackend::new(stderr)) { + Ok(terminal) => terminal, + Err(err) => { + let _ = disable_raw_mode(); + let _ = execute!(io::stderr(), LeaveAlternateScreen); + return Err(err.into()); + } + }; + Ok(Self { terminal }) + } + + pub(crate) fn terminal(&mut self) -> &mut Terminal { + &mut self.terminal + } + + pub(crate) fn leave(&mut self) { + let _ = disable_raw_mode(); + #[cfg(not(windows))] + let _ = execute!( + self.terminal.backend_mut(), + PopKeyboardEnhancementFlags, + LeaveAlternateScreen + ); + #[cfg(windows)] + let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + let _ = self.terminal.show_cursor(); + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + self.leave(); + } +} diff --git a/tests/config_tui.rs b/tests/config_tui.rs new file mode 100644 index 0000000..492e046 --- /dev/null +++ b/tests/config_tui.rs @@ -0,0 +1,124 @@ +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; + +#[test] +fn config_without_a_terminal_explains_the_alternatives() { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_recall")) + .arg("config") + .output() + .unwrap(); + + assert!(!output.status.success()); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains("interactive configuration requires a terminal")); + assert!(error.contains("recall config show")); +} + +#[test] +fn config_tui_records_a_key_and_saves_a_toggle() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let test_dir = + std::env::temp_dir().join(format!("recall-config-tui-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&test_dir).unwrap(); + let config_path = test_dir.join("config.toml"); + std::fs::write(&config_path, "# keep\n[ui]\nsearch_key = \"alt-r\"\n").unwrap(); + + let pair = native_pty_system() + .openpty(PtySize { + rows: 30, + cols: 100, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_recall")); + command.arg("config"); + command.env("HOME", &test_dir); + command.env("SHELL", "bash"); + command.env("TERM", "xterm-256color"); + command.env("RECALL_CONFIG", &config_path); + + let mut child = pair.slave.spawn_command(command).unwrap(); + drop(pair.slave); + let mut reader = pair.master.try_clone_reader().unwrap(); + let writer = Arc::new(Mutex::new(pair.master.take_writer().unwrap())); + let master = pair.master; + let output = Arc::new(Mutex::new(Vec::new())); + let reader_output = output.clone(); + let reader_thread = thread::spawn(move || { + let mut chunk = [0u8; 4096]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(count) => reader_output + .lock() + .unwrap() + .extend_from_slice(&chunk[..count]), + Err(_) => break, + } + } + }); + + require_output(&output, "Categories", &mut child); + write_input(&writer, b"\t\r\x1bt\r\t q"); + + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + match child.try_wait().unwrap() { + Some(status) => break status, + None if Instant::now() < deadline => thread::sleep(Duration::from_millis(25)), + None => { + let snapshot = output_text(&output); + let _ = child.kill(); + panic!("config TUI did not exit:\n{snapshot}"); + } + } + }; + assert_eq!(status.exit_code(), 0, "{}", output_text(&output)); + drop(writer); + drop(master); + reader_thread.join().unwrap(); + + let saved = std::fs::read_to_string(&config_path).unwrap(); + let config: toml::Value = toml::from_str(&saved).unwrap(); + assert_eq!(config["ui"]["search_key"].as_str(), Some("alt-t")); + assert_eq!(config["proxy"]["secrets_filter"].as_bool(), Some(false)); + assert!(saved.contains("# keep")); + let _ = std::fs::remove_dir_all(test_dir); +} + +fn write_input(writer: &Arc>>, bytes: &[u8]) { + let mut writer = writer.lock().unwrap(); + writer.write_all(bytes).unwrap(); + writer.flush().unwrap(); +} + +fn require_output( + output: &Arc>>, + needle: &str, + child: &mut Box, +) { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if output_text(output).contains(needle) { + return; + } + thread::sleep(Duration::from_millis(25)); + } + let snapshot = output_text(output); + let _ = child.kill(); + panic!("expected {needle} in config TUI output:\n{snapshot}"); +} + +fn output_text(output: &Arc>>) -> String { + String::from_utf8_lossy(&output.lock().unwrap()).into_owned() +} diff --git a/tests/windows_proxy.rs b/tests/windows_proxy.rs index 92d54dd..ea46779 100644 --- a/tests/windows_proxy.rs +++ b/tests/windows_proxy.rs @@ -115,6 +115,94 @@ fn conpty_forwards_special_keys_and_exits_cleanly() { let _ = std::fs::remove_dir_all(test_dir); } +#[test] +fn config_tui_accepts_input_and_exits_cleanly_in_conpty() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let test_dir = std::env::temp_dir().join(format!( + "recall-config-conpty-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&test_dir).unwrap(); + let config_path = test_dir.join("config.toml"); + std::fs::write(&config_path, "[ui]\nsearch_key = \"alt-r\"\n").unwrap(); + + let pair = native_pty_system() + .openpty(PtySize { + rows: 30, + cols: 100, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_recall")); + command.arg("config"); + command.env("RECALL_CONFIG", &config_path); + + let mut child = pair.slave.spawn_command(command).unwrap(); + drop(pair.slave); + let mut reader = pair.master.try_clone_reader().unwrap(); + let writer = Arc::new(Mutex::new(pair.master.take_writer().unwrap())); + let master = pair.master; + let output = Arc::new(Mutex::new(Vec::new())); + let reader_output = output.clone(); + let terminal_writer = writer.clone(); + let reader_thread = thread::spawn(move || { + let mut chunk = [0u8; 4096]; + let mut cursor_queries_answered = 0; + loop { + let count = match reader.read(&mut chunk) { + Ok(0) => break, + Ok(count) => count, + Err(_) => break, + }; + let cursor_queries = { + let mut output = reader_output.lock().unwrap(); + output.extend_from_slice(&chunk[..count]); + output + .windows(b"\x1b[6n".len()) + .filter(|window| *window == b"\x1b[6n") + .count() + }; + while cursor_queries_answered < cursor_queries { + let mut writer = terminal_writer.lock().unwrap(); + if writer.write_all(b"\x1b[1;1R").is_err() || writer.flush().is_err() { + return; + } + cursor_queries_answered += 1; + } + } + }); + + require_occurrences(&output, "Categories", 1, &mut child); + write_input(&writer, b"q"); + + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + match child.try_wait().unwrap() { + Some(status) => break status, + None if Instant::now() < deadline => thread::sleep(Duration::from_millis(25)), + None => { + let snapshot = output_text(&output); + let _ = child.kill(); + panic!("config TUI did not exit after q:\n{snapshot}"); + } + } + }; + + assert_eq!(status.exit_code(), 0, "{}", output_text(&output)); + drop(writer); + drop(master); + reader_thread.join().unwrap(); + assert_eq!( + std::fs::read_to_string(&config_path).unwrap(), + "[ui]\nsearch_key = \"alt-r\"\n" + ); + let _ = std::fs::remove_dir_all(test_dir); +} + fn write_input(writer: &Arc>>, bytes: &[u8]) { let mut writer = writer.lock().unwrap(); writer.write_all(bytes).unwrap();