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.
-
-
+
## 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