diff --git a/README.md b/README.md index 10888b8..b4b87e6 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,165 @@ # confy -a config manager for linux/unix based systems including macos (unix). +a config manager for linux/unix based systems including macos (unix) and windows. simple tui for keeping track of all your config files in one place. no more hunting through ~/.config. -image - +![screenshot](https://private-user-images.githubusercontent.com/185956030/511882149-a6736759-d430-433f-b93a-cd319dc61277.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Njc3MzMwNDgsIm5iZiI6MTc2NzczMjc0OCwicGF0aCI6Ii8xODU5NTYwMzAvNTExODgyMTQ5LWE2NzM2NzU5LWQ0MzAtNDMzZi1iOTNhLWNkMzE5ZGM2MTI3Ny5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjYwMTA2JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI2MDEwNlQyMDUyMjhaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1iMTY4ODk3ZDk2MTI2MzUyZmRjYzZjY2IzMDg4MGFiNTEzZDMxNmNjMmE2ZDgzNDAwMGQ1MGQ3OGY4OWEyMmVmJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.PIc291MvLnwf1w5XQhI8H6jiQL1pUsre-ZHYsuirbFs) ## features -- track config files from anywhere -- open in $EDITOR with one keypress -- remembers last edited file -- vim-style keybinds -- lightweight and fast -- works on linux, macos, bsd, whatever +* **organize with groups** - create folders to organize your configs (hyprland/, nvim/, etc) +* **collapsible groups** - expand/collapse groups to keep your view clean +* **search** - real-time fuzzy search through all your configs +* **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 +* **vim-style keybinds** - j/k navigation, command mode +* **lightweight and fast** - pure python with curses +* **cross-platform** - works on linux, macos, bsd, windows ## installation ### from AUR (arch linux) + ```bash yay -S confy-tui ``` ### manual install + ```bash git clone https://github.com/Phluxjr23/confy.git +cd confy +chmod +x main.py +# optionally symlink to PATH +sudo ln -s $(pwd)/main.py /usr/local/bin/confy ``` ## dependencies -- python3 -- ranger (for file picker) -- curses (usually included with python) +* python3 +* ranger (for file picker) +* curses (usually included with python) ## usage just run `confy` in your terminal -### keybinds +### navigation -- `j/k` or `arrow keys` - navigate -- `enter` - open selected file in $EDITOR -- `:` - enter command mode -- `q` - quit +* `j/k` or `arrow keys` - move up/down +* `enter` - open file in $EDITOR (or toggle group) +* `space` - toggle group expand/collapse +* `/` - search mode +* `:` - command mode +* `q` - quit ### commands -- `:ac` - add config (opens ranger file picker) -- `:rm` - remove selected file -- `:l` - open last edited file -- `:q` - quit +#### file management +* `:ac` - add config to ungrouped +* `:ac ` - add config to specific group +* `:rm` - remove selected file +* `:l` - open last edited file + +#### group management +* `:ag ` - add new group +* `:mg ` - move selected file to group +* `:rg ` - remove group (moves files to ungrouped) + +#### sorting & filtering +* `:sort name` - sort alphabetically +* `:sort date` - sort by last modified +* `:sort size` - sort by file size +* `:reverse` - toggle ascending/descending order +* `/` then type - search files and groups in real-time + +#### configuration +* `:cd` - change config directory (opens ranger) +* `:cd reset` - reset to ~/.config (or default) +* `:q` - quit + +### search mode + +press `/` to enter search mode, then start typing: +- filters both files and groups in real-time +- case-insensitive fuzzy 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 collapsible - press `space` or `enter` on a group header to toggle. ## 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. + simple, fast, does one thing well. +## examples + +```bash +# start confy +confy + +# create some groups +:ag hyprland +:ag nvim +:ag shell + +# add configs to groups +:ac hyprland # opens ranger, pick hyprland.conf +:ac nvim # opens ranger, pick init.lua + +# move existing files between groups +# (select file first, then) +:mg shell + +# search for configs +/hypr # shows only hyprland-related files + +# sort by recently modified +:sort date +:reverse # newest first + +# change where ranger starts +:cd # pick new directory +:cd reset # back to default +``` + +## tips + +* set `export EDITOR=nvim` in your shell rc for your preferred editor +* use groups to organize by application (hyprland/, nvim/, kitty/) +* 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 + +## windows support + +on windows, change the config directory to where you keep your configs: +``` +:cd +# 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 mit +## contributing + +prs welcome! this is a simple tool but if you have ideas for improvements, open an issue or submit a pr. + ## btw i use arch btw diff --git a/main.py b/main.py old mode 100755 new mode 100644 index fa97fda..7104b56 --- a/main.py +++ b/main.py @@ -12,31 +12,85 @@ TRACKED_FILE = CONFIG_DIR / "tracked.json" class Confy: def __init__(self): - self.files = [] + self.groups = {"ungrouped": []} self.selected = 0 self.page = 0 self.items_per_page = 10 self.command_mode = False + self.search_mode = False self.command_buffer = "" + self.search_buffer = "" self.last_opened = None 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.load_data() + self.rebuild_flat_view() def load_data(self): if TRACKED_FILE.exists(): with open(TRACKED_FILE, 'r') as f: data = json.load(f) - self.files = data.get('files', []) + if 'files' in data: + self.groups = {"ungrouped": data['files']} + else: + self.groups = data.get('groups', {"ungrouped": []}) self.last_opened = data.get('last_opened') + self.collapsed_groups = set(data.get('collapsed_groups', [])) + self.sort_mode = data.get('sort_mode', 'name') + self.sort_order = data.get('sort_order', 'asc') def save_data(self): CONFIG_DIR.mkdir(parents=True, exist_ok=True) with open(TRACKED_FILE, 'w') as f: json.dump({ - 'files': self.files, - 'last_opened': self.last_opened + 'groups': self.groups, + 'last_opened': self.last_opened, + 'collapsed_groups': list(self.collapsed_groups), + 'sort_mode': self.sort_mode, + 'sort_order': self.sort_order }, f, indent=2) + 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": + sorted_files = sorted(files, key=lambda f: os.path.getmtime(f) if os.path.exists(f) else 0) + elif self.sort_mode == "size": + 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()] + if matching_files or query in group_name.lower(): + self.flat_view.append(('group', group_name)) + if group_name not in self.collapsed_groups: + for filepath in self.sort_files(matching_files): + self.flat_view.append(('file', filepath, group_name)) + else: + for group_name in sorted(self.groups.keys()): + self.flat_view.append(('group', group_name)) + if group_name not in self.collapsed_groups: + for filepath in self.sort_files(self.groups[group_name]): + self.flat_view.append(('file', filepath, group_name)) + def get_file_info(self, filepath): try: stat = os.stat(filepath) @@ -53,27 +107,105 @@ class Confy: size /= 1024 return f"{size:.1f}TB" - def add_config(self): + 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 and filepath not in self.files: - self.files.append(filepath) + 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() def remove_config(self): - if self.files and 0 <= self.selected < len(self.files): - del self.files[self.selected] - if self.selected >= len(self.files) and self.files: - self.selected = len(self.files) - 1 + 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] + group_name = item[2] + if filepath in self.groups[group_name]: + self.groups[group_name].remove(filepath) + self.save_data() + self.rebuild_flat_view() + if self.selected >= len(self.flat_view) and self.flat_view: + self.selected = len(self.flat_view) - 1 + + def add_group(self, group_name): + if group_name and group_name not in self.groups: + self.groups[group_name] = [] self.save_data() + self.rebuild_flat_view() + + def remove_group(self, group_name): + if group_name in self.groups and group_name != "ungrouped": + self.groups["ungrouped"].extend(self.groups[group_name]) + del self.groups[group_name] + self.save_data() + self.rebuild_flat_view() + + 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] + if group_name in self.collapsed_groups: + self.collapsed_groups.remove(group_name) + else: + self.collapsed_groups.add(group_name) + self.save_data() + self.rebuild_flat_view() def open_file(self, filepath): editor = os.environ.get('EDITOR', 'nano') @@ -96,37 +228,66 @@ class Confy: stdscr.addstr(0, 1, "confy") last_text = f"previous: {{{Path(self.last_opened).name if self.last_opened else 'none'}}}" stdscr.addstr(1, 1, last_text) - config_text = f"config dir is {self.config_dir}" + + # show sort mode and config dir + 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) - # file list + # file list with groups start_idx = self.page * self.items_per_page - end_idx = min(start_idx + self.items_per_page, len(self.files)) + 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) - filepath = self.files[i] - filename = Path(filepath).name - directory = str(Path(filepath).parent) - mtime, size = self.get_file_info(filepath) + item = self.flat_view[i] - line = f"{filename:<20} | {directory:<30} | {mtime:<16} | {size:<10}" - 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) + 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)" + + 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) + + 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 + 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.files) + self.items_per_page - 1) // self.items_per_page + total_pages = (len(self.flat_view) + self.items_per_page - 1) // self.items_per_page if total_pages == 0: total_pages = 1 bottom_y = height - 2 stdscr.addstr(bottom_y, 0, "═" * width) + 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]) @@ -135,15 +296,39 @@ class Confy: def handle_command(self): cmd = self.command_buffer.strip() + parts = cmd.split(maxsplit=1) + if cmd == "q": return False elif cmd == "ac": self.add_config() + elif parts[0] == "ac" and len(parts) == 2: + self.add_config(parts[1]) elif cmd == "rm": self.remove_config() + elif parts[0] == "ag" and len(parts) == 2: + self.add_group(parts[1]) + elif parts[0] == "rg" and len(parts) == 2: + self.remove_group(parts[1]) + elif parts[0] == "mg" and len(parts) == 2: + self.move_to_group(parts[1]) elif cmd == "l": if self.last_opened and os.path.exists(self.last_opened): self.open_file(self.last_opened) + elif cmd == "cd": + self.change_config_dir() + elif cmd == "cd reset": + self.config_dir = str(Path.home() / ".config") + elif parts[0] == "sort" and len(parts) == 2: + if parts[1] in ["name", "date", "size"]: + self.sort_mode = parts[1] + self.save_data() + self.rebuild_flat_view() + elif cmd == "reverse": + self.sort_order = "desc" if self.sort_order == "asc" else "asc" + self.save_data() + self.rebuild_flat_view() + self.command_buffer = "" self.command_mode = False return True @@ -171,12 +356,36 @@ class Confy: 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 + self.search_mode = False + self.search_buffer = "" + self.rebuild_flat_view() + self.selected = 0 + self.page = 0 + elif key in (curses.KEY_BACKSPACE, 127, 8): + self.search_buffer = self.search_buffer[:-1] + self.rebuild_flat_view() + self.selected = 0 + self.page = 0 + elif 32 <= key <= 126: + self.search_buffer += chr(key) + self.rebuild_flat_view() + self.selected = 0 + self.page = 0 else: if key == ord(':'): self.command_mode = True self.command_buffer = "" + elif key == ord('/'): + self.search_mode = True + self.search_buffer = "" elif key in (ord('j'), curses.KEY_DOWN): - if self.selected < len(self.files) - 1: + if self.selected < len(self.flat_view) - 1: self.selected += 1 if self.selected >= (self.page + 1) * self.items_per_page: self.page += 1 @@ -186,8 +395,14 @@ class Confy: if self.selected < self.page * self.items_per_page: self.page -= 1 elif key == ord('\n'): - if self.files and 0 <= self.selected < len(self.files): - self.open_file(self.files[self.selected]) + if self.flat_view and self.selected < len(self.flat_view): + item = self.flat_view[self.selected] + if item[0] == 'file': + self.open_file(item[1]) + elif item[0] == 'group': + self.toggle_group() + elif key == ord(' '): + self.toggle_group() elif key == ord('q'): break