6 Commits

Author SHA1 Message Date
f3b86af05b new year, new update 2026-01-06 15:29:17 -06:00
ab92ccc6f4 remove flatpak manifest 2025-11-11 01:11:14 -06:00
4b89ec8025 Create net.phluxjr.confy-tui.yml 2025-11-11 00:59:51 -06:00
7d936beb2a fix formatting in manual install section of readme 2025-11-09 21:04:18 -06:00
57550692c1 aur is added to readme 2025-11-09 21:03:20 -06:00
9db392e8ae readme
readme is now readable
2025-11-09 18:35:00 -06:00
2 changed files with 408 additions and 30 deletions

165
README.md
View File

@ -1,2 +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.
![screenshot](https://private-user-images.githubusercontent.com/185956030/511882149-a6736759-d430-433f-b93a-cd319dc61277.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Njc3MzMwNDgsIm5iZiI6MTc2NzczMjc0OCwicGF0aCI6Ii8xODU5NTYwMzAvNTExODgyMTQ5LWE2NzM2NzU5LWQ0MzAtNDMzZi1iOTNhLWNkMzE5ZGM2MTI3Ny5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjYwMTA2JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI2MDEwNlQyMDUyMjhaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1iMTY4ODk3ZDk2MTI2MzUyZmRjYzZjY2IzMDg4MGFiNTEzZDMxNmNjMmE2ZDgzNDAwMGQ1MGQ3OGY4OWEyMmVmJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.PIc291MvLnwf1w5XQhI8H6jiQL1pUsre-ZHYsuirbFs)
## features
* **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)
## usage
just run `confy` in your terminal
### navigation
* `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
#### 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: 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.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.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,37 +228,66 @@ 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]
filename = Path(filepath).name
directory = str(Path(filepath).parent)
mtime, size = self.get_file_info(filepath)
line = f"{filename:<20} | {directory:<30} | {mtime:<16} | {size:<10}" if item[0] == 'group':
if i == self.selected: group_name = item[1]
line = f"{line} <" collapsed = "" if group_name in self.collapsed_groups else ""
stdscr.attron(curses.A_REVERSE) file_count = len(self.groups[group_name])
stdscr.addstr(y, 1, line[:width-2]) line = f"{collapsed} {group_name}/ ({file_count} files)"
if i == self.selected:
stdscr.attroff(curses.A_REVERSE) 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 # 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