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
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.
<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
- 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 <group>` - add config to specific group
* `: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?
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

273
main.py Executable file → Normal file
View File

@ -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