new year, new update

This commit is contained in:
2026-01-06 15:29:17 -06:00
parent ab92ccc6f4
commit f3b86af05b
2 changed files with 363 additions and 50 deletions

140
README.md
View File

@ -1,67 +1,165 @@
# confy # 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. simple tui for keeping track of all your config files in one place. no more hunting through ~/.config.
<img width="1918" height="1081" alt="image" src="https://github.com/user-attachments/assets/a6736759-d430-433f-b93a-cd319dc61277" /> ![screenshot](https://private-user-images.githubusercontent.com/185956030/511882149-a6736759-d430-433f-b93a-cd319dc61277.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Njc3MzMwNDgsIm5iZiI6MTc2NzczMjc0OCwicGF0aCI6Ii8xODU5NTYwMzAvNTExODgyMTQ5LWE2NzM2NzU5LWQ0MzAtNDMzZi1iOTNhLWNkMzE5ZGM2MTI3Ny5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjYwMTA2JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI2MDEwNlQyMDUyMjhaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1iMTY4ODk3ZDk2MTI2MzUyZmRjYzZjY2IzMDg4MGFiNTEzZDMxNmNjMmE2ZDgzNDAwMGQ1MGQ3OGY4OWEyMmVmJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.PIc291MvLnwf1w5XQhI8H6jiQL1pUsre-ZHYsuirbFs)
## features ## features
- track config files from anywhere * **organize with groups** - create folders to organize your configs (hyprland/, nvim/, etc)
- open in $EDITOR with one keypress * **collapsible groups** - expand/collapse groups to keep your view clean
- remembers last edited file * **search** - real-time fuzzy search through all your configs
- vim-style keybinds * **multiple sort modes** - sort by name, date modified, or file size
- lightweight and fast * **open in $EDITOR** - edit files with one keypress
- works on linux, macos, bsd, whatever * **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 ## installation
### from AUR (arch linux) ### from AUR (arch linux)
```bash ```bash
yay -S confy-tui yay -S confy-tui
``` ```
### manual install ### manual install
```bash ```bash
git clone https://github.com/Phluxjr23/confy.git 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 ## dependencies
- python3 * python3
- ranger (for file picker) * ranger (for file picker)
- curses (usually included with python) * curses (usually included with python)
## usage ## usage
just run `confy` in your terminal just run `confy` in your terminal
### keybinds ### navigation
- `j/k` or `arrow keys` - navigate * `j/k` or `arrow keys` - move up/down
- `enter` - open selected file in $EDITOR * `enter` - open file in $EDITOR (or toggle group)
- `:` - enter command mode * `space` - toggle group expand/collapse
- `q` - quit * `/` - search mode
* `:` - command mode
* `q` - quit
### commands ### commands
- `:ac` - add config (opens ranger file picker) #### file management
- `:rm` - remove selected file * `:ac` - add config to ungrouped
- `:l` - open last edited file * `:ac <group>` - add config to specific group
- `:q` - quit * `:rm` - remove selected file
* `:l` - open last edited file
#### group management
* `:ag <group>` - add new group
* `:mg <group>` - move selected file to group
* `:rg <group>` - 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? ## 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. 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. 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 ## license
mit mit
## contributing
prs welcome! this is a simple tool but if you have ideas for improvements, open an issue or submit a pr.
## btw ## btw
i use arch btw i use arch btw

255
main.py Executable file → Normal file
View File

@ -12,31 +12,85 @@ TRACKED_FILE = CONFIG_DIR / "tracked.json"
class Confy: class Confy:
def __init__(self): def __init__(self):
self.files = [] self.groups = {"ungrouped": []}
self.selected = 0 self.selected = 0
self.page = 0 self.page = 0
self.items_per_page = 10 self.items_per_page = 10
self.command_mode = False self.command_mode = False
self.search_mode = False
self.command_buffer = "" self.command_buffer = ""
self.search_buffer = ""
self.last_opened = None self.last_opened = None
self.config_dir = str(Path.home() / ".config") 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.load_data()
self.rebuild_flat_view()
def load_data(self): def load_data(self):
if TRACKED_FILE.exists(): if TRACKED_FILE.exists():
with open(TRACKED_FILE, 'r') as f: with open(TRACKED_FILE, 'r') as f:
data = json.load(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.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): def save_data(self):
CONFIG_DIR.mkdir(parents=True, exist_ok=True) CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(TRACKED_FILE, 'w') as f: with open(TRACKED_FILE, 'w') as f:
json.dump({ json.dump({
'files': self.files, 'groups': self.groups,
'last_opened': self.last_opened 'last_opened': self.last_opened,
'collapsed_groups': list(self.collapsed_groups),
'sort_mode': self.sort_mode,
'sort_order': self.sort_order
}, f, indent=2) }, 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): def get_file_info(self, filepath):
try: try:
stat = os.stat(filepath) stat = os.stat(filepath)
@ -53,27 +107,105 @@ class Confy:
size /= 1024 size /= 1024
return f"{size:.1f}TB" return f"{size:.1f}TB"
def add_config(self): def add_config(self, group_name="ungrouped"):
curses.endwin() curses.endwin()
try: try:
result = subprocess.run(['ranger', '--choosefile=/tmp/confy_pick', self.config_dir]) result = subprocess.run(['ranger', '--choosefile=/tmp/confy_pick', self.config_dir])
if os.path.exists('/tmp/confy_pick'): if os.path.exists('/tmp/confy_pick'):
with open('/tmp/confy_pick', 'r') as f: with open('/tmp/confy_pick', 'r') as f:
filepath = f.read().strip() filepath = f.read().strip()
if filepath and filepath not in self.files: if filepath:
self.files.append(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.save_data()
self.rebuild_flat_view()
os.remove('/tmp/confy_pick') os.remove('/tmp/confy_pick')
except Exception as e: except Exception as e:
pass pass
curses.doupdate() curses.doupdate()
def remove_config(self): def remove_config(self):
if self.files and 0 <= self.selected < len(self.files): if not self.flat_view or self.selected >= len(self.flat_view):
del self.files[self.selected] return
if self.selected >= len(self.files) and self.files:
self.selected = len(self.files) - 1 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.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): def open_file(self, filepath):
editor = os.environ.get('EDITOR', 'nano') editor = os.environ.get('EDITOR', 'nano')
@ -96,22 +228,48 @@ class Confy:
stdscr.addstr(0, 1, "confy") stdscr.addstr(0, 1, "confy")
last_text = f"previous: {{{Path(self.last_opened).name if self.last_opened else 'none'}}}" last_text = f"previous: {{{Path(self.last_opened).name if self.last_opened else 'none'}}}"
stdscr.addstr(1, 1, last_text) 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(1, width - len(config_text) - 2, config_text)
stdscr.addstr(2, 0, "" * width) stdscr.addstr(2, 0, "" * width)
# file list # file list with groups
start_idx = self.page * self.items_per_page 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): for i in range(start_idx, end_idx):
y = 4 + (i - start_idx) y = 4 + (i - start_idx)
filepath = self.files[i] 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)"
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 filename = Path(filepath).name
directory = str(Path(filepath).parent) directory = str(Path(filepath).parent)
mtime, size = self.get_file_info(filepath) mtime, size = self.get_file_info(filepath)
line = f"{filename:<20} | {directory:<30} | {mtime:<16} | {size:<10}" # 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: if i == self.selected:
line = f"{line} <" line = f"{line} <"
stdscr.attron(curses.A_REVERSE) stdscr.attron(curses.A_REVERSE)
@ -120,13 +278,16 @@ class Confy:
stdscr.attroff(curses.A_REVERSE) stdscr.attroff(curses.A_REVERSE)
# bottom bar # 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: if total_pages == 0:
total_pages = 1 total_pages = 1
bottom_y = height - 2 bottom_y = height - 2
stdscr.addstr(bottom_y, 0, "" * width) stdscr.addstr(bottom_y, 0, "" * width)
if self.command_mode: if self.command_mode:
page_text = f"page {self.page + 1}/{total_pages} ▌ :{self.command_buffer}" 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: else:
page_text = f"page {self.page + 1}/{total_pages}" page_text = f"page {self.page + 1}/{total_pages}"
stdscr.addstr(bottom_y + 1, 1, page_text[:width-2]) stdscr.addstr(bottom_y + 1, 1, page_text[:width-2])
@ -135,15 +296,39 @@ class Confy:
def handle_command(self): def handle_command(self):
cmd = self.command_buffer.strip() cmd = self.command_buffer.strip()
parts = cmd.split(maxsplit=1)
if cmd == "q": if cmd == "q":
return False return False
elif cmd == "ac": elif cmd == "ac":
self.add_config() self.add_config()
elif parts[0] == "ac" and len(parts) == 2:
self.add_config(parts[1])
elif cmd == "rm": elif cmd == "rm":
self.remove_config() 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": elif cmd == "l":
if self.last_opened and os.path.exists(self.last_opened): if self.last_opened and os.path.exists(self.last_opened):
self.open_file(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_buffer = ""
self.command_mode = False self.command_mode = False
return True return True
@ -171,12 +356,36 @@ class Confy:
self.command_buffer = self.command_buffer[:-1] self.command_buffer = self.command_buffer[:-1]
elif 32 <= key <= 126: elif 32 <= key <= 126:
self.command_buffer += chr(key) 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: else:
if key == ord(':'): if key == ord(':'):
self.command_mode = True self.command_mode = True
self.command_buffer = "" self.command_buffer = ""
elif key == ord('/'):
self.search_mode = True
self.search_buffer = ""
elif key in (ord('j'), curses.KEY_DOWN): 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 self.selected += 1
if self.selected >= (self.page + 1) * self.items_per_page: if self.selected >= (self.page + 1) * self.items_per_page:
self.page += 1 self.page += 1
@ -186,8 +395,14 @@ class Confy:
if self.selected < self.page * self.items_per_page: if self.selected < self.page * self.items_per_page:
self.page -= 1 self.page -= 1
elif key == ord('\n'): elif key == ord('\n'):
if self.files and 0 <= self.selected < len(self.files): if self.flat_view and self.selected < len(self.flat_view):
self.open_file(self.files[self.selected]) 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'): elif key == ord('q'):
break break