17 Commits
v1.0.0 ... main

Author SHA1 Message Date
0768f4674e Never gonna give you up 2026-03-07 23:49:07 -06:00
7141abfa52 readme change 2026-03-07 18:32:58 -06:00
d2af2531d0 One does not simply merge into master 2026-03-07 15:17:29 -06:00
f4b45b4c10 fix duplicate upload step 2026-03-07 15:10:35 -06:00
9af491064e fix duplicate upload step 2026-03-07 15:07:57 -06:00
48b1d0be03 add github actions builds 2026-03-07 14:55:11 -06:00
6f37bcb081 added a good bit of things :D 2026-03-07 01:00:43 -06:00
1aa02ff142 chance license 2026-02-16 13:33:01 -06:00
308644b0c5 merge gitlab repo 2026-02-14 23:04:02 -06:00
3f18fa4c5a Initial commit 2026-02-15 04:41:38 +00:00
2c1f759270 nice gif now
much wow
2026-01-06 18:22:13 -06:00
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
6 changed files with 969 additions and 84 deletions

59
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: build
on:
release:
types: [created]
permissions:
contents: write
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
output: confy-linux
- os: macos-latest
output: confy-macos
- os: windows-latest
output: confy-windows.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: set up python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: install pyinstaller
run: pip install pyinstaller
- name: build
run: pyinstaller --onefile main.py --name ${{ matrix.output }}
- name: get release upload url
id: get_release
uses: actions/github-script@v7
with:
script: |
const release = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag: context.ref.replace('refs/tags/', '')
});
return release.data.upload_url;
result-encoding: string
- name: upload binary
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.get_release.outputs.result }}
asset_path: dist/${{ matrix.output }}
asset_name: ${{ matrix.output }}
asset_content_type: application/octet-stream

30
LICENSE
View File

@ -1,21 +1,15 @@
MIT License
confy - a config manager for linux/unix systems
Copyright (C) 2025-2026 phluxjr
Copyright (c) 2025 Phluxjr23
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

227
README.md
View File

@ -1,2 +1,225 @@
# confy
A config manager for linux/unix based systems including MacOS (Unix).
<p align="center">
<img src="confy-logo.png" alt="confy logo" width="256">
</p>
<h1 align="center">confy</h1>
<p align="center">a config manager for linux/unix based systems including macos (unix) and windows.</p>
<p align="center">simple tui for keeping track of all your config files in one place. no more hunting through ~/.config.</p>
---
## 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
* **built-in file picker** - no external dependencies, navigate with vim keys
* **rollback** - automatic compressed backups before every edit, restore with `:rb`
* **custom colors** - set colors via config.json, supports hex and named colors
* **vim-style keybinds** - j/k navigation, command mode
* **lightweight and fast** - pure python with curses, zero dependencies
* **cross-platform** - works on linux, macos, bsd, windows
## installation
### from AUR (arch linux)
```bash
yay -S confy-tui
```
### manual install
```bash
git clone https://gitlab.com/phluxjr/confy.git
cd confy
chmod +x main.py
sudo ln -s $(pwd)/main.py /usr/local/bin/confy
# optionally install the man page
sudo install -Dm644 confy.1 /usr/share/man/man1/confy.1
```
## dependencies
* python3
* curses (included with python)
that's it. no ranger, no external tools.
## 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 from tracking (does not delete the file)
* `:l` - open last edited file
* `:rb` - rollback selected file to last backup
#### 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 built-in file picker)
* `:cd reset` - reset to ~/.config
* `:q` - quit
### rollback
confy automatically saves a compressed backup of any file to `/tmp/<filename>.confbak` before you open it for editing. if you make a mess of your config, select the file and run `:rb` to restore it.
rollback can be disabled in config.json:
```json
"settings": {
"rollback": false
}
```
### colors
customize colors in `~/.config/confy/config.json` under `settings.colors`. values can be named colors or hex codes:
```json
"settings": {
"colors": {
"bg": "default",
"fg": "default",
"highlight": "#cba6f7",
"group": "#89b4fa"
}
}
```
named colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `default`, `lavender`, `pink`, `purple`, `orange`
hex colors require a terminal that supports 256 colors (most do).
### search mode
press `/` to enter search mode, then start typing:
- filters both files and groups in real-time
- case-insensitive 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 tracked configs into logical categories like "hyprland", "nvim", "shell", etc.
groups are collapsible - press `space` or `enter` on a group header to toggle.
## configuration file
confy stores everything in `~/.config/confy/config.json`. if you're upgrading from an older version with `tracked.json`, confy will automatically migrate it on first run.
full example config.json:
```json
{
"groups": {
"ungrouped": [],
"hyprland": ["/home/user/.config/hypr/hyprland.conf"],
"nvim": ["/home/user/.config/nvim/init.lua"]
},
"settings": {
"rollback": true,
"colors": {
"bg": "default",
"fg": "default",
"highlight": "#cba6f7",
"group": "#89b4fa"
}
}
}
```
## 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. if you break something, roll it back.
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 file picker, navigate to hyprland.conf
:ac nvim # opens file picker, navigate to 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
# oops, broke your config
:rb # rollback to last backup
```
## 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
* missing files show up in red so you know when a config has moved
## windows support
on windows, change the config directory to where you keep your configs:
```
:cd
# navigate to C:\Users\YourName\AppData\Local or wherever
```
---
<p align="center">
<strong>copyright © 2025-2026 phluxjr</strong><br>
GPL-3.0-or-later
</p>
<p align="center">
prs welcome! this is a simple tool but if you have ideas for improvements, open an issue or submit a pr.
</p>
<p align="center">
<em>man page included - <code>man confy</code> after install</em>
</p>

BIN
confy-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

145
confy.1 Normal file
View File

@ -0,0 +1,145 @@
.TH CONFY 1 "2026-03-06" "2.1.0" "confy manual"
.SH NAME
confy \- a TUI config file manager for linux/unix systems
.SH SYNOPSIS
.B confy
.SH DESCRIPTION
.B confy
is a terminal-based config file manager. track, organize, and edit your config files from a single interface. supports groups, sorting, fuzzy search, rollback, and custom colors.
.SH NAVIGATION
.TP
.B j / DOWN
move down
.TP
.B k / UP
move up
.TP
.B ENTER
open selected file in $EDITOR, or toggle group collapse
.TP
.B SPACE
toggle group collapse
.TP
.B /
enter search mode (fuzzy search by filename or group)
.TP
.B :
enter command mode
.TP
.B q
quit
.SH COMMANDS
commands are entered by pressing
.B :
followed by the command name and ENTER.
.TP
.B :q
quit confy
.TP
.B :ac [group]
add a config file. opens the built-in file picker. optionally specify a group name.
.TP
.B :rm
remove the selected config file from tracking (does not delete the file)
.TP
.B :ag <name>
add a new group
.TP
.B :rg <name>
remove a group (files are moved to ungrouped)
.TP
.B :mg <name>
move selected file to a group
.TP
.B :l
reopen the last opened file
.TP
.B :rb
rollback the selected file to its last backup (saved automatically before each edit)
.TP
.B :sort <name|date|size>
change sort mode
.TP
.B :reverse
toggle sort order between ascending and descending
.TP
.B :cd
change the root directory used by the file picker
.TP
.B :cd reset
reset the file picker root to ~/.config
.SH FILE PICKER
the built-in file picker replaces ranger. navigate with
.B j/k
or arrow keys,
.B ENTER
to open a directory or select a file,
.B BACKSPACE
to go up a directory, and
.B q
to cancel.
.SH ROLLBACK
when
.B rollback
is enabled in settings (default: true), confy automatically saves a compressed backup of a file to
.I /tmp/<filename>.confbak
before opening it for editing. to restore the backup, select the file and run
.B :rb
\&. a confirmation prompt will appear.
.SH CONFIGURATION
confy stores its data and settings in
.I ~/.config/confy/config.json
\&. if an older
.I tracked.json
is found, it will be automatically migrated.
the
.B settings
key in config.json accepts the following options:
.TP
.B rollback
boolean. enable/disable automatic backups before editing. default: true
.TP
.B colors
object with keys:
.B bg, fg, highlight, group, border
\&. values can be a named color (e.g.
.I "lavender", "cyan", "default"
) or a hex code (e.g.
.I "#cba6f7"
).
example config.json settings block:
.PP
.nf
"settings": {
"rollback": true,
"colors": {
"bg": "default",
"fg": "default",
"highlight": "#cba6f7",
"group": "#89b4fa"
}
}
.fi
.SH FILES
.TP
.I ~/.config/confy/config.json
confy data and settings
.TP
.I /tmp/<filename>.confbak
rollback backups (gzip compressed)
.SH ENVIRONMENT
.TP
.B EDITOR
the editor used to open config files. defaults to
.B nano
if not set.
.SH AUTHOR
phluxjr <phluxjr@phluxjr.net>
.SH LICENSE
GPL-3.0-or-later
.SH SEE ALSO
.BR nano (1),
.BR vim (1),
.BR nvim (1)

592
main.py Executable file → Normal file
View File

@ -1,42 +1,321 @@
#!/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"
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.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"
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 TRACKED_FILE.exists():
with open(TRACKED_FILE, 'r') as f:
if CONFIG_FILE.exists():
with open(CONFIG_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')
# 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(TRACKED_FILE, 'w') as f:
with open(CONFIG_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,
'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)
@ -53,108 +332,260 @@ class Confy:
size /= 1024
return f"{size:.1f}TB"
def add_config(self):
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)
self.save_data()
os.remove('/tmp/confy_pick')
except Exception as e:
pass
curses.doupdate()
# ── 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 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 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 to continue...")
input(f"error: editor '{editor}' not found. press enter...")
except Exception as e:
input(f"error opening file: {e}. press enter to continue...")
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)
# top bar
stdscr.addstr(0, 1, "confy")
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)
config_text = f"config dir is {self.config_dir}"
stdscr.addstr(1, width - len(config_text) - 2, config_text)
stdscr.addstr(2, 0, "" * width)
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)
# file list
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)
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)
item = self.flat_view[i]
# bottom bar
total_pages = (len(self.files) + self.items_per_page - 1) // self.items_per_page
if total_pages == 0:
total_pages = 1
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
stdscr.addstr(bottom_y, 0, "" * width)
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}"
stdscr.addstr(bottom_y + 1, 1, page_text[:width-2])
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()
def handle_command(self):
# ── 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()
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:
@ -162,21 +593,47 @@ class Confy:
if self.command_mode:
if key == ord('\n'):
if not self.handle_command():
if not self.handle_command(stdscr):
break
elif key == 27: # ESC
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.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,11 +643,18 @@ 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
def main():
app = Confy()
curses.wrapper(app.run)