From 6f37bcb08173a9ad1543c127a996b941bd0241f5 Mon Sep 17 00:00:00 2001 From: phluxjr Date: Sat, 7 Mar 2026 01:00:43 -0600 Subject: [PATCH] added a good bit of things :D --- README.md | 103 +++++++++--- confy.1 | 145 +++++++++++++++++ main.py | 461 +++++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 582 insertions(+), 127 deletions(-) create mode 100644 confy.1 diff --git a/README.md b/README.md index 9987b98..89c4a45 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ simple tui for keeping track of all your config files in one place. no more hunt * **multiple sort modes** - sort by name, date modified, or file size * **open in $EDITOR** - edit files with one keypress * **remembers last file** - quick access to recently edited configs -* **customizable config dir** - change base directory for file picker +* **built-in file picker** - no external dependencies, navigate with vim keys +* **rollback** - automatic compressed backups before every edit, restore with `:rb` +* **custom colors** - set colors via config.json, supports hex and named colors * **vim-style keybinds** - j/k navigation, command mode -* **lightweight and fast** - pure python with curses +* **lightweight and fast** - pure python with curses, zero dependencies * **cross-platform** - works on linux, macos, bsd, windows ## installation @@ -30,18 +32,20 @@ yay -S confy-tui ### manual install ```bash -git clone https://github.com/Phluxjr23/confy.git +git clone https://gitlab.com/phluxjr/confy.git cd confy chmod +x main.py -# optionally symlink to PATH sudo ln -s $(pwd)/main.py /usr/local/bin/confy +# optionally install the man page +sudo install -Dm644 confy.1 /usr/share/man/man1/confy.1 ``` ## dependencies * python3 -* ranger (for file picker) -* curses (usually included with python) +* curses (included with python) + +that's it. no ranger, no external tools. ## usage @@ -61,8 +65,9 @@ just run `confy` in your terminal #### file management * `:ac` - add config to ungrouped * `:ac ` - add config to specific group -* `:rm` - remove selected file +* `:rm` - remove selected file from tracking (does not delete the file) * `:l` - open last edited file +* `:rb` - rollback selected file to last backup #### group management * `:ag ` - add new group @@ -77,29 +82,83 @@ just run `confy` in your terminal * `/` then type - search files and groups in real-time #### configuration -* `:cd` - change config directory (opens ranger) -* `:cd reset` - reset to ~/.config (or default) +* `:cd` - change config directory (opens built-in file picker) +* `:cd reset` - reset to ~/.config * `:q` - quit +### rollback + +confy automatically saves a compressed backup of any file to `/tmp/.confbak` before you open it for editing. if you make a mess of your config, select the file and run `:rb` to restore it. + +rollback can be disabled in config.json: +```json +"settings": { + "rollback": false +} +``` + +### colors + +customize colors in `~/.config/confy/config.json` under `settings.colors`. values can be named colors or hex codes: + +```json +"settings": { + "colors": { + "bg": "default", + "fg": "default", + "highlight": "#cba6f7", + "group": "#89b4fa" + } +} +``` + +named colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `default`, `lavender`, `pink`, `purple`, `orange` + +hex colors require a terminal that supports 256 colors (most do). + ### search mode press `/` to enter search mode, then start typing: - filters both files and groups in real-time -- case-insensitive fuzzy matching +- case-insensitive matching - `enter` to accept and keep filtering - `esc` to clear search and show all files ### groups -groups are purely organizational - your actual config files stay in their original locations. groups help you organize your list of tracked configs into logical categories like "hyprland", "nvim", "shell", etc. +groups are purely organizational - your actual config files stay in their original locations. groups help you organize your tracked configs into logical categories like "hyprland", "nvim", "shell", etc. groups are collapsible - press `space` or `enter` on a group header to toggle. +## configuration file + +confy stores everything in `~/.config/confy/config.json`. if you're upgrading from an older version with `tracked.json`, confy will automatically migrate it on first run. + +full example config.json: +```json +{ + "groups": { + "ungrouped": [], + "hyprland": ["/home/user/.config/hypr/hyprland.conf"], + "nvim": ["/home/user/.config/nvim/init.lua"] + }, + "settings": { + "rollback": true, + "colors": { + "bg": "default", + "fg": "default", + "highlight": "#cba6f7", + "group": "#89b4fa" + } + } +} +``` + ## why confy? tired of doing `cd ~/.config/whatever` a million times a day? same. confy keeps all your important configs in one list so you can jump to them instantly. -organize related configs into groups, search through everything, sort however you want, and open files in your editor with a single keypress. +organize related configs into groups, search through everything, sort however you want, and open files in your editor with a single keypress. if you break something, roll it back. simple, fast, does one thing well. @@ -115,8 +174,8 @@ confy :ag shell # add configs to groups -:ac hyprland # opens ranger, pick hyprland.conf -:ac nvim # opens ranger, pick init.lua +:ac hyprland # opens file picker, navigate to hyprland.conf +:ac nvim # opens file picker, navigate to init.lua # move existing files between groups # (select file first, then) @@ -129,9 +188,8 @@ confy :sort date :reverse # newest first -# change where ranger starts -:cd # pick new directory -:cd reset # back to default +# oops, broke your config +:rb # rollback to last backup ``` ## tips @@ -141,6 +199,7 @@ confy * use `:sort date` to quickly find recently edited configs * search with `/` to quickly jump to specific configs * collapse groups you don't use often to keep view clean +* missing files show up in red so you know when a config has moved ## windows support @@ -150,13 +209,17 @@ on windows, change the config directory to where you keep your configs: # navigate to C:\Users\YourName\AppData\Local or wherever ``` -ranger should work on windows via WSL or you can modify the code to use a different file picker. - ## license -GPL-3.0 +GPL-3.0-or-later ## contributing prs welcome! this is a simple tool but if you have ideas for improvements, open an issue or submit a pr. +## man page + +a man page is included. after installing via AUR it's available automatically: +```bash +man confy +``` diff --git a/confy.1 b/confy.1 new file mode 100644 index 0000000..3c5c36c --- /dev/null +++ b/confy.1 @@ -0,0 +1,145 @@ +.TH CONFY 1 "2026-03-06" "2.1.0" "confy manual" +.SH NAME +confy \- a TUI config file manager for linux/unix systems +.SH SYNOPSIS +.B confy +.SH DESCRIPTION +.B confy +is a terminal-based config file manager. track, organize, and edit your config files from a single interface. supports groups, sorting, fuzzy search, rollback, and custom colors. +.SH NAVIGATION +.TP +.B j / DOWN +move down +.TP +.B k / UP +move up +.TP +.B ENTER +open selected file in $EDITOR, or toggle group collapse +.TP +.B SPACE +toggle group collapse +.TP +.B / +enter search mode (fuzzy search by filename or group) +.TP +.B : +enter command mode +.TP +.B q +quit +.SH COMMANDS +commands are entered by pressing +.B : +followed by the command name and ENTER. +.TP +.B :q +quit confy +.TP +.B :ac [group] +add a config file. opens the built-in file picker. optionally specify a group name. +.TP +.B :rm +remove the selected config file from tracking (does not delete the file) +.TP +.B :ag +add a new group +.TP +.B :rg +remove a group (files are moved to ungrouped) +.TP +.B :mg +move selected file to a group +.TP +.B :l +reopen the last opened file +.TP +.B :rb +rollback the selected file to its last backup (saved automatically before each edit) +.TP +.B :sort +change sort mode +.TP +.B :reverse +toggle sort order between ascending and descending +.TP +.B :cd +change the root directory used by the file picker +.TP +.B :cd reset +reset the file picker root to ~/.config +.SH FILE PICKER +the built-in file picker replaces ranger. navigate with +.B j/k +or arrow keys, +.B ENTER +to open a directory or select a file, +.B BACKSPACE +to go up a directory, and +.B q +to cancel. +.SH ROLLBACK +when +.B rollback +is enabled in settings (default: true), confy automatically saves a compressed backup of a file to +.I /tmp/.confbak +before opening it for editing. to restore the backup, select the file and run +.B :rb +\&. a confirmation prompt will appear. +.SH CONFIGURATION +confy stores its data and settings in +.I ~/.config/confy/config.json +\&. if an older +.I tracked.json +is found, it will be automatically migrated. + +the +.B settings +key in config.json accepts the following options: +.TP +.B rollback +boolean. enable/disable automatic backups before editing. default: true +.TP +.B colors +object with keys: +.B bg, fg, highlight, group, border +\&. values can be a named color (e.g. +.I "lavender", "cyan", "default" +) or a hex code (e.g. +.I "#cba6f7" +). + +example config.json settings block: +.PP +.nf +"settings": { + "rollback": true, + "colors": { + "bg": "default", + "fg": "default", + "highlight": "#cba6f7", + "group": "#89b4fa" + } +} +.fi +.SH FILES +.TP +.I ~/.config/confy/config.json +confy data and settings +.TP +.I /tmp/.confbak +rollback backups (gzip compressed) +.SH ENVIRONMENT +.TP +.B EDITOR +the editor used to open config files. defaults to +.B nano +if not set. +.SH AUTHOR +phluxjr +.SH LICENSE +GPL-3.0-or-later +.SH SEE ALSO +.BR nano (1), +.BR vim (1), +.BR nvim (1) diff --git a/main.py b/main.py index ff4b1cf..5b584a1 100644 --- a/main.py +++ b/main.py @@ -5,12 +5,174 @@ import curses import json import os +import gzip +import shutil import subprocess from pathlib import Path from datetime import datetime CONFIG_DIR = Path.home() / ".config" / "confy" -TRACKED_FILE = CONFIG_DIR / "tracked.json" +TRACKED_FILE = CONFIG_DIR / "tracked.json" # legacy +CONFIG_FILE = CONFIG_DIR / "config.json" + +# ── default app config (user can override in config.json under "settings") ──── + +DEFAULT_SETTINGS = { + "rollback": True, + "colors": { + "bg": "default", # terminal default or hex like "#1e1e2e" + "fg": "default", + "highlight": "#cba6f7", # catppuccin mauve as default lol + "group": "#89b4fa", # catppuccin blue + "border": "default", + } +} + +# ── color helpers ───────────────────────────────────────────────────────────── + +# named terminal colors → curses color number +NAMED_COLORS = { + "black": curses.COLOR_BLACK, + "red": curses.COLOR_RED, + "green": curses.COLOR_GREEN, + "yellow": curses.COLOR_YELLOW, + "blue": curses.COLOR_BLUE, + "magenta": curses.COLOR_MAGENTA, + "cyan": curses.COLOR_CYAN, + "white": curses.COLOR_WHITE, + "default": -1, + # some fun extras + "lavender": curses.COLOR_CYAN, # close enough lol + "pink": curses.COLOR_MAGENTA, + "orange": curses.COLOR_YELLOW, + "purple": curses.COLOR_MAGENTA, +} + +def hex_to_curses(hex_color): + """convert #rrggbb to curses 0-1000 rgb values""" + hex_color = hex_color.lstrip('#') + if len(hex_color) == 6: + r = int(hex_color[0:2], 16) * 1000 // 255 + g = int(hex_color[2:4], 16) * 1000 // 255 + b = int(hex_color[4:6], 16) * 1000 // 255 + return r, g, b + return None + +def init_colors(color_settings): + """initialise curses color pairs from settings""" + curses.start_color() + curses.use_default_colors() + + pairs = {} + + def resolve(c): + """returns a curses color number, initialising custom colors if needed""" + if c is None or c == "default": + return -1 + cl = c.lower() + if cl in NAMED_COLORS: + return NAMED_COLORS[cl] + if cl.startswith('#') and curses.can_change_color(): + rgb = hex_to_curses(cl) + if rgb: + # use high color numbers to avoid clobbering defaults + color_num = 16 + len(pairs) + try: + curses.init_color(color_num, *rgb) + return color_num + except: + pass + return -1 + + fg = resolve(color_settings.get("fg", "default")) + bg = resolve(color_settings.get("bg", "default")) + hi = resolve(color_settings.get("highlight", "default")) + grp = resolve(color_settings.get("group", "default")) + + curses.init_pair(1, fg, bg) # normal + curses.init_pair(2, hi, bg) # highlighted/selected + curses.init_pair(3, grp, bg) # group headers + curses.init_pair(4, curses.COLOR_RED, bg) # errors/missing + + return { + "normal": curses.color_pair(1), + "highlight": curses.color_pair(2) | curses.A_REVERSE, + "group": curses.color_pair(3) | curses.A_BOLD, + "error": curses.color_pair(4), + } + +# ── built-in file picker (replaces ranger) ──────────────────────────────────── + +class FilePicker: + def __init__(self, start_dir, colors): + self.cwd = Path(start_dir).expanduser() + self.selected = 0 + self.colors = colors + self.entries = [] + self.scroll = 0 + + def load_entries(self): + try: + entries = sorted(self.cwd.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + self.entries = [self.cwd.parent] + entries # ".." at top + except PermissionError: + self.entries = [self.cwd.parent] + + def run(self, stdscr): + self.load_entries() + curses.curs_set(0) + + while True: + stdscr.clear() + h, w = stdscr.getmaxyx() + + stdscr.addstr(0, 1, f"pick a file: {self.cwd}", self.colors["group"]) + stdscr.addstr(1, 1, "enter=open/select q=cancel backspace=up", self.colors["normal"]) + stdscr.addstr(2, 0, "─" * w, self.colors["normal"]) + + visible = h - 5 + if self.selected < self.scroll: + self.scroll = self.selected + elif self.selected >= self.scroll + visible: + self.scroll = self.selected - visible + 1 + + for i, entry in enumerate(self.entries[self.scroll:self.scroll + visible]): + y = 3 + i + idx = i + self.scroll + is_dir = entry.is_dir() if entry != self.cwd.parent else True + name = "../" if entry == self.cwd.parent else (entry.name + ("/" if is_dir else "")) + attr = self.colors["highlight"] if idx == self.selected else ( + self.colors["group"] if is_dir else self.colors["normal"] + ) + stdscr.addstr(y, 2, name[:w-3], attr) + + stdscr.refresh() + key = stdscr.getch() + + if key in (ord('q'), 27): + return None + elif key in (ord('j'), curses.KEY_DOWN): + if self.selected < len(self.entries) - 1: + self.selected += 1 + elif key in (ord('k'), curses.KEY_UP): + if self.selected > 0: + self.selected -= 1 + elif key in (ord('\n'), curses.KEY_ENTER): + entry = self.entries[self.selected] + if entry == self.cwd.parent or entry.is_dir(): + self.cwd = entry.resolve() + self.selected = 0 + self.scroll = 0 + self.load_entries() + else: + return str(entry) + elif key in (curses.KEY_BACKSPACE, 127, 8): + self.cwd = self.cwd.parent + self.selected = 0 + self.scroll = 0 + self.load_entries() + +# ── main app ────────────────────────────────────────────────────────────────── class Confy: def __init__(self): @@ -26,14 +188,25 @@ class Confy: self.config_dir = str(Path.home() / ".config") self.collapsed_groups = set() self.flat_view = [] - self.sort_mode = "name" # name, date, size - self.sort_order = 'asc' # asc or desc + self.sort_mode = "name" + self.sort_order = "asc" + self.settings = dict(DEFAULT_SETTINGS) + self.popup_message = None + self.colors = {} + self.migrate_if_needed() self.load_data() self.rebuild_flat_view() + def migrate_if_needed(self): + """migrate tracked.json -> config.json if needed""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + if not CONFIG_FILE.exists() and TRACKED_FILE.exists(): + shutil.copy(TRACKED_FILE, CONFIG_FILE) + self.popup_message = "migrated tracked.json → config.json!" + def load_data(self): - if TRACKED_FILE.exists(): - with open(TRACKED_FILE, 'r') as f: + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, 'r') as f: data = json.load(f) if 'files' in data: self.groups = {"ungrouped": data['files']} @@ -43,20 +216,75 @@ class Confy: self.collapsed_groups = set(data.get('collapsed_groups', [])) self.sort_mode = data.get('sort_mode', 'name') self.sort_order = data.get('sort_order', 'asc') + # load user settings, merging with defaults + user_settings = data.get('settings', {}) + self.settings.update(user_settings) + if 'colors' in user_settings: + self.settings['colors'] = {**DEFAULT_SETTINGS['colors'], **user_settings['colors']} def save_data(self): CONFIG_DIR.mkdir(parents=True, exist_ok=True) - with open(TRACKED_FILE, 'w') as f: + with open(CONFIG_FILE, 'w') as f: json.dump({ 'groups': self.groups, 'last_opened': self.last_opened, 'collapsed_groups': list(self.collapsed_groups), 'sort_mode': self.sort_mode, - 'sort_order': self.sort_order + 'sort_order': self.sort_order, + 'settings': self.settings, }, f, indent=2) + # ── rollback ────────────────────────────────────────────────────────────── + + def save_backup(self, filepath): + """save compressed backup of filepath to /tmp""" + if not self.settings.get('rollback', True): + return + try: + bak_name = Path(filepath).name + ".confbak" + bak_path = Path("/tmp") / bak_name + with open(filepath, 'rb') as f_in: + with gzip.open(bak_path, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + except Exception: + pass + + def rollback(self, filepath): + """restore backup for filepath, returns (success, message)""" + bak_name = Path(filepath).name + ".confbak" + bak_path = Path("/tmp") / bak_name + if not bak_path.exists(): + return False, "no backup found in /tmp" + try: + with gzip.open(bak_path, 'rb') as f_in: + with open(filepath, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + return True, f"rolled back {Path(filepath).name}!" + except Exception as e: + return False, f"rollback failed: {e}" + + def confirm_popup(self, stdscr, message): + """show a confirmation popup, returns True if confirmed""" + h, w = stdscr.getmaxyx() + pw, ph = 50, 5 + py, px = h // 2 - ph // 2, w // 2 - pw // 2 + + win = curses.newwin(ph, pw, py, px) + win.box() + win.addstr(1, 2, message[:pw-4], self.colors.get("normal", 0)) + win.addstr(3, 2, "y = confirm n / esc = cancel", self.colors.get("group", 0)) + win.refresh() + + while True: + key = stdscr.getch() + if key == ord('y'): + return True + elif key in (ord('n'), 27, ord('q')): + return False + + # ── sorting / view ──────────────────────────────────────────────────────── + def sort_files(self, files): - """sort files based on current sort mode""" if self.sort_mode == "name": sorted_files = sorted(files, key=lambda f: Path(f).name.lower()) elif self.sort_mode == "date": @@ -65,22 +293,17 @@ class Confy: sorted_files = sorted(files, key=lambda f: os.path.getsize(f) if os.path.exists(f) else 0) else: sorted_files = files - if self.sort_order == "desc": sorted_files = sorted_files[::-1] - return sorted_files def rebuild_flat_view(self): - """rebuild flattened view for navigation""" self.flat_view = [] - - # filter by search if active if self.search_buffer: query = self.search_buffer.lower() for group_name in sorted(self.groups.keys()): - matching_files = [f for f in self.groups[group_name] - if query in Path(f).name.lower() or query in group_name.lower()] + matching_files = [f for f in self.groups[group_name] + if query in Path(f).name.lower() or query in group_name.lower()] if matching_files or query in group_name.lower(): self.flat_view.append(('group', group_name)) if group_name not in self.collapsed_groups: @@ -109,33 +332,25 @@ class Confy: size /= 1024 return f"{size:.1f}TB" - def add_config(self, group_name="ungrouped"): - curses.endwin() - try: - result = subprocess.run(['ranger', '--choosefile=/tmp/confy_pick', self.config_dir]) - if os.path.exists('/tmp/confy_pick'): - with open('/tmp/confy_pick', 'r') as f: - filepath = f.read().strip() - if filepath: - for grp_files in self.groups.values(): - if filepath in grp_files: - os.remove('/tmp/confy_pick') - curses.doupdate() - return - if group_name not in self.groups: - self.groups[group_name] = [] - self.groups[group_name].append(filepath) - self.save_data() - self.rebuild_flat_view() - os.remove('/tmp/confy_pick') - except Exception as e: - pass - curses.doupdate() + # ── file operations ─────────────────────────────────────────────────────── + + def add_config(self, stdscr, group_name="ungrouped"): + picker = FilePicker(self.config_dir, self.colors) + filepath = picker.run(stdscr) + if filepath: + for grp_files in self.groups.values(): + if filepath in grp_files: + self.popup_message = "already tracked!" + return + if group_name not in self.groups: + self.groups[group_name] = [] + self.groups[group_name].append(filepath) + self.save_data() + self.rebuild_flat_view() def remove_config(self): if not self.flat_view or self.selected >= len(self.flat_view): return - item = self.flat_view[self.selected] if item[0] == 'file': filepath = item[1] @@ -163,42 +378,22 @@ class Confy: def move_to_group(self, group_name): if not self.flat_view or self.selected >= len(self.flat_view): return - item = self.flat_view[self.selected] if item[0] == 'file': filepath = item[1] old_group = item[2] - if group_name not in self.groups: self.groups[group_name] = [] - if filepath in self.groups[old_group]: self.groups[old_group].remove(filepath) - if filepath not in self.groups[group_name]: self.groups[group_name].append(filepath) - self.save_data() self.rebuild_flat_view() - def change_config_dir(self): - curses.endwin() - try: - result = subprocess.run(['ranger', '--choosedir=/tmp/confy_dir']) - if os.path.exists('/tmp/confy_dir'): - with open('/tmp/confy_dir', 'r') as f: - new_dir = f.read().strip() - if new_dir and os.path.isdir(new_dir): - self.config_dir = new_dir - os.remove('/tmp/confy_dir') - except Exception as e: - pass - curses.doupdate() - def toggle_group(self): if not self.flat_view or self.selected >= len(self.flat_view): return - item = self.flat_view[self.selected] if item[0] == 'group': group_name = item[1] @@ -210,102 +405,125 @@ class Confy: self.rebuild_flat_view() def open_file(self, filepath): + self.save_backup(filepath) editor = os.environ.get('EDITOR', 'nano') curses.endwin() try: subprocess.run([editor, filepath]) except FileNotFoundError: - input(f"error: editor '{editor}' not found. press enter to continue...") + input(f"error: editor '{editor}' not found. press enter...") except Exception as e: - input(f"error opening file: {e}. press enter to continue...") + input(f"error opening file: {e}. press enter...") curses.doupdate() self.last_opened = filepath self.save_data() + # ── drawing ─────────────────────────────────────────────────────────────── + def draw(self, stdscr): height, width = stdscr.getmaxyx() stdscr.clear() + n = self.colors.get("normal", 0) + g = self.colors.get("group", 0) - # top bar - stdscr.addstr(0, 1, "confy") + stdscr.addstr(0, 1, "confy", g) last_text = f"previous: {{{Path(self.last_opened).name if self.last_opened else 'none'}}}" - stdscr.addstr(1, 1, last_text) - - # show sort mode and config dir + stdscr.addstr(1, 1, last_text, n) + sort_text = f"sort: {self.sort_mode} ({self.sort_order})" config_text = f"config dir: {self.config_dir}" - stdscr.addstr(1, width - len(config_text) - len(sort_text) - 5, sort_text) - stdscr.addstr(1, width - len(config_text) - 2, config_text) - stdscr.addstr(2, 0, "═" * width) + try: + stdscr.addstr(1, width - len(config_text) - len(sort_text) - 5, sort_text, n) + stdscr.addstr(1, width - len(config_text) - 2, config_text, n) + except: + pass + stdscr.addstr(2, 0, "═" * (width - 1), n) - # file list with groups start_idx = self.page * self.items_per_page end_idx = min(start_idx + self.items_per_page, len(self.flat_view)) - + for i in range(start_idx, end_idx): y = 4 + (i - start_idx) item = self.flat_view[i] - + if item[0] == 'group': group_name = item[1] collapsed = "▶" if group_name in self.collapsed_groups else "▼" file_count = len(self.groups[group_name]) line = f"{collapsed} {group_name}/ ({file_count} files)" - + attr = self.colors.get("highlight", curses.A_REVERSE) if i == self.selected else g if i == self.selected: - line = f"{line} <" - stdscr.attron(curses.A_REVERSE | curses.A_BOLD) - else: - stdscr.attron(curses.A_BOLD) - - stdscr.addstr(y, 1, line[:width-2]) - stdscr.attroff(curses.A_BOLD) - if i == self.selected: - stdscr.attroff(curses.A_REVERSE) - + line += " <" + try: + stdscr.addstr(y, 1, line[:width-2], attr) + except: + pass + elif item[0] == 'file': filepath = item[1] filename = Path(filepath).name directory = str(Path(filepath).parent) mtime, size = self.get_file_info(filepath) - - # dynamically size columns based on terminal width + exists = os.path.exists(filepath) + col_width = max(10, (width - 60) // 2) line = f" {filename[:col_width]:<{col_width}} | {directory[:col_width]:<{col_width}} | {mtime} | {size}" - if i == self.selected: - line = f"{line} <" - stdscr.attron(curses.A_REVERSE) - stdscr.addstr(y, 1, line[:width-2]) - if i == self.selected: - stdscr.attroff(curses.A_REVERSE) - # bottom bar - total_pages = (len(self.flat_view) + self.items_per_page - 1) // self.items_per_page - if total_pages == 0: - total_pages = 1 + if i == self.selected: + line += " <" + attr = self.colors.get("highlight", curses.A_REVERSE) + elif not exists: + attr = self.colors.get("error", 0) + else: + attr = n + + try: + stdscr.addstr(y, 1, line[:width-2], attr) + except: + pass + + total_pages = max(1, (len(self.flat_view) + self.items_per_page - 1) // self.items_per_page) bottom_y = height - 2 - stdscr.addstr(bottom_y, 0, "═" * width) - + try: + stdscr.addstr(bottom_y, 0, "═" * (width - 1), n) + except: + pass + if self.command_mode: page_text = f"page {self.page + 1}/{total_pages} ▌ :{self.command_buffer}" elif self.search_mode: page_text = f"page {self.page + 1}/{total_pages} ▌ /{self.search_buffer}" else: page_text = f"page {self.page + 1}/{total_pages} ▌" - stdscr.addstr(bottom_y + 1, 1, page_text[:width-2]) + + try: + stdscr.addstr(bottom_y + 1, 1, page_text[:width-2], n) + except: + pass + + # popup message + if self.popup_message: + msg = f" {self.popup_message} " + px = max(0, width // 2 - len(msg) // 2) + try: + stdscr.addstr(height // 2, px, msg, self.colors.get("highlight", curses.A_REVERSE)) + except: + pass stdscr.refresh() - def handle_command(self): + # ── command handling ────────────────────────────────────────────────────── + + def handle_command(self, stdscr): cmd = self.command_buffer.strip() parts = cmd.split(maxsplit=1) - + if cmd == "q": return False elif cmd == "ac": - self.add_config() + self.add_config(stdscr) elif parts[0] == "ac" and len(parts) == 2: - self.add_config(parts[1]) + self.add_config(stdscr, parts[1]) elif cmd == "rm": self.remove_config() elif parts[0] == "ag" and len(parts) == 2: @@ -318,7 +536,9 @@ class Confy: if self.last_opened and os.path.exists(self.last_opened): self.open_file(self.last_opened) elif cmd == "cd": - self.change_config_dir() + picker = FilePicker(self.config_dir, self.colors) + # pick a dir: just navigate until they quit, use cwd as result + picker.run(stdscr) # returns file, but cwd changes as they browse elif cmd == "cd reset": self.config_dir = str(Path.home() / ".config") elif parts[0] == "sort" and len(parts) == 2: @@ -330,18 +550,42 @@ class Confy: self.sort_order = "desc" if self.sort_order == "asc" else "asc" self.save_data() self.rebuild_flat_view() - + elif cmd == "rb": + # rollback selected file + if self.flat_view and self.selected < len(self.flat_view): + item = self.flat_view[self.selected] + if item[0] == 'file': + filepath = item[1] + if self.confirm_popup(stdscr, f"rollback {Path(filepath).name}?"): + ok, msg = self.rollback(filepath) + self.popup_message = msg + else: + self.popup_message = "rollback cancelled" + else: + self.popup_message = "select a file first" + else: + self.popup_message = "nothing selected" + self.command_buffer = "" self.command_mode = False return True + # ── main loop ───────────────────────────────────────────────────────────── + def run(self, stdscr): + self.colors = init_colors(self.settings.get('colors', DEFAULT_SETTINGS['colors'])) curses.curs_set(0) stdscr.timeout(100) while True: self.draw(stdscr) - + + # clear popup after one frame + if self.popup_message: + stdscr.getch() + self.popup_message = None + continue + try: key = stdscr.getch() except: @@ -349,21 +593,22 @@ class Confy: if self.command_mode: if key == ord('\n'): - if not self.handle_command(): + if not self.handle_command(stdscr): break - elif key == 27: # ESC + elif key == 27: self.command_mode = False self.command_buffer = "" elif key in (curses.KEY_BACKSPACE, 127, 8): self.command_buffer = self.command_buffer[:-1] elif 32 <= key <= 126: self.command_buffer += chr(key) + elif self.search_mode: if key == ord('\n'): self.search_mode = False self.selected = 0 self.page = 0 - elif key == 27: # ESC + elif key == 27: self.search_mode = False self.search_buffer = "" self.rebuild_flat_view() @@ -379,6 +624,7 @@ class Confy: self.rebuild_flat_view() self.selected = 0 self.page = 0 + else: if key == ord(':'): self.command_mode = True @@ -408,6 +654,7 @@ class Confy: elif key == ord('q'): break + def main(): app = Confy() curses.wrapper(app.run)