Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0768f4674e | |||
| 7141abfa52 | |||
| d2af2531d0 | |||
| f4b45b4c10 | |||
| 9af491064e | |||
| 48b1d0be03 | |||
| 6f37bcb081 | |||
| 1aa02ff142 | |||
| 308644b0c5 | |||
| 3f18fa4c5a | |||
| 2c1f759270 |
59
.github/workflows/build.yml
vendored
Normal file
59
.github/workflows/build.yml
vendored
Normal 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
30
LICENSE
@ -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/>.
|
||||
|
||||
128
README.md
128
README.md
@ -1,10 +1,14 @@
|
||||
# confy
|
||||
<p align="center">
|
||||
<img src="confy-logo.png" alt="confy logo" width="256">
|
||||
</p>
|
||||
|
||||
a config manager for linux/unix based systems including macos (unix) and windows.
|
||||
<h1 align="center">confy</h1>
|
||||
|
||||
simple tui for keeping track of all your config files in one place. no more hunting through ~/.config.
|
||||
<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
|
||||
|
||||
@ -14,34 +18,36 @@ simple tui for keeping track of all your config files in one place. no more hunt
|
||||
* **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
|
||||
* **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
|
||||
* **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://github.com/Phluxjr23/confy.git
|
||||
git clone https://gitlab.com/phluxjr/confy.git
|
||||
cd confy
|
||||
chmod +x main.py
|
||||
# optionally symlink to PATH
|
||||
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
|
||||
* ranger (for file picker)
|
||||
* curses (usually included with python)
|
||||
* curses (included with python)
|
||||
|
||||
that's it. no ranger, no external tools.
|
||||
|
||||
## usage
|
||||
|
||||
@ -61,8 +67,9 @@ just run `confy` in your terminal
|
||||
#### file management
|
||||
* `:ac` - add config to ungrouped
|
||||
* `:ac <group>` - add config to specific group
|
||||
* `:rm` - remove selected file
|
||||
* `: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
|
||||
@ -77,34 +84,86 @@ just run `confy` in your terminal
|
||||
* `/` then type - search files and groups in real-time
|
||||
|
||||
#### configuration
|
||||
* `:cd` - change config directory (opens ranger)
|
||||
* `:cd reset` - reset to ~/.config (or default)
|
||||
* `: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 fuzzy matching
|
||||
- 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 list of tracked configs into logical categories like "hyprland", "nvim", "shell", etc.
|
||||
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.
|
||||
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
|
||||
@ -115,8 +174,8 @@ confy
|
||||
:ag shell
|
||||
|
||||
# add configs to groups
|
||||
:ac hyprland # opens ranger, pick hyprland.conf
|
||||
:ac nvim # opens ranger, pick init.lua
|
||||
: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)
|
||||
@ -129,9 +188,8 @@ confy
|
||||
:sort date
|
||||
:reverse # newest first
|
||||
|
||||
# change where ranger starts
|
||||
:cd # pick new directory
|
||||
:cd reset # back to default
|
||||
# oops, broke your config
|
||||
:rb # rollback to last backup
|
||||
```
|
||||
|
||||
## tips
|
||||
@ -141,6 +199,7 @@ confy
|
||||
* 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
|
||||
|
||||
@ -150,16 +209,17 @@ on windows, change the config directory to where you keep your configs:
|
||||
# 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
|
||||
<p align="center">
|
||||
<strong>copyright © 2025-2026 phluxjr</strong><br>
|
||||
GPL-3.0-or-later
|
||||
</p>
|
||||
|
||||
mit
|
||||
<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>
|
||||
|
||||
## 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
|
||||
<p align="center">
|
||||
<em>man page included - <code>man confy</code> after install</em>
|
||||
</p>
|
||||
|
||||
BIN
confy-logo.png
Normal file
BIN
confy-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
145
confy.1
Normal file
145
confy.1
Normal 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)
|
||||
465
main.py
465
main.py
@ -1,14 +1,178 @@
|
||||
#!/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):
|
||||
@ -24,14 +188,25 @@ class Confy:
|
||||
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.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)
|
||||
if 'files' in data:
|
||||
self.groups = {"ungrouped": data['files']}
|
||||
@ -41,20 +216,75 @@ class Confy:
|
||||
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({
|
||||
'groups': self.groups,
|
||||
'last_opened': self.last_opened,
|
||||
'collapsed_groups': list(self.collapsed_groups),
|
||||
'sort_mode': self.sort_mode,
|
||||
'sort_order': self.sort_order
|
||||
'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):
|
||||
"""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":
|
||||
@ -63,22 +293,17 @@ class Confy:
|
||||
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()]
|
||||
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:
|
||||
@ -107,33 +332,25 @@ class Confy:
|
||||
size /= 1024
|
||||
return f"{size:.1f}TB"
|
||||
|
||||
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:
|
||||
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()
|
||||
# ── 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]
|
||||
@ -161,42 +378,22 @@ class Confy:
|
||||
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]
|
||||
@ -208,102 +405,125 @@ class Confy:
|
||||
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)
|
||||
|
||||
# show sort mode and config dir
|
||||
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}"
|
||||
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)
|
||||
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 with groups
|
||||
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 = 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)
|
||||
|
||||
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)
|
||||
|
||||
# dynamically size columns based on terminal width
|
||||
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 = 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.flat_view) + self.items_per_page - 1) // self.items_per_page
|
||||
if total_pages == 0:
|
||||
total_pages = 1
|
||||
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(parts[1])
|
||||
self.add_config(stdscr, parts[1])
|
||||
elif cmd == "rm":
|
||||
self.remove_config()
|
||||
elif parts[0] == "ag" and len(parts) == 2:
|
||||
@ -316,7 +536,9 @@ class Confy:
|
||||
if self.last_opened and os.path.exists(self.last_opened):
|
||||
self.open_file(self.last_opened)
|
||||
elif cmd == "cd":
|
||||
self.change_config_dir()
|
||||
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:
|
||||
@ -328,18 +550,42 @@ class Confy:
|
||||
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:
|
||||
@ -347,21 +593,22 @@ 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: # ESC
|
||||
elif key == 27:
|
||||
self.search_mode = False
|
||||
self.search_buffer = ""
|
||||
self.rebuild_flat_view()
|
||||
@ -377,6 +624,7 @@ class Confy:
|
||||
self.rebuild_flat_view()
|
||||
self.selected = 0
|
||||
self.page = 0
|
||||
|
||||
else:
|
||||
if key == ord(':'):
|
||||
self.command_mode = True
|
||||
@ -406,6 +654,7 @@ class Confy:
|
||||
elif key == ord('q'):
|
||||
break
|
||||
|
||||
|
||||
def main():
|
||||
app = Confy()
|
||||
curses.wrapper(app.run)
|
||||
|
||||
Reference in New Issue
Block a user