Files
confy/main.py
2026-03-07 01:00:43 -06:00

664 lines
26 KiB
Python

#!/usr/bin/env python3
# confy - a config manager for linux/unix systems
# Copyright (C) 2025-2026 phluxjr
# Licensed under GPL-3.0-or-later
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" # 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):
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"
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 CONFIG_FILE.exists():
with open(CONFIG_FILE, 'r') as f:
data = json.load(f)
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')
# 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(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,
'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):
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):
self.flat_view = []
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)
mtime = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M')
size = self.format_size(stat.st_size)
return mtime, size
except:
return "unknown", "unknown"
def format_size(self, size):
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f"{size:.1f}{unit}"
size /= 1024
return f"{size:.1f}TB"
# ── 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]
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 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):
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...")
except Exception as e:
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)
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, n)
sort_text = f"sort: {self.sort_mode} ({self.sort_order})"
config_text = f"config dir: {self.config_dir}"
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)
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 += " <"
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)
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 += " <"
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
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}"
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()
# ── 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(stdscr)
elif parts[0] == "ac" and len(parts) == 2:
self.add_config(stdscr, 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":
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:
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()
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:
continue
if self.command_mode:
if key == ord('\n'):
if not self.handle_command(stdscr):
break
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:
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.flat_view) - 1:
self.selected += 1
if self.selected >= (self.page + 1) * self.items_per_page:
self.page += 1
elif key in (ord('k'), curses.KEY_UP):
if self.selected > 0:
self.selected -= 1
if self.selected < self.page * self.items_per_page:
self.page -= 1
elif key == ord('\n'):
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
def main():
app = Confy()
curses.wrapper(app.run)
if __name__ == "__main__":
main()