bitp 1.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bitbake_project/__init__.py +88 -0
- bitbake_project/__main__.py +14 -0
- bitbake_project/cli.py +1580 -0
- bitbake_project/commands/__init__.py +60 -0
- bitbake_project/commands/branch.py +889 -0
- bitbake_project/commands/common.py +2372 -0
- bitbake_project/commands/config.py +1515 -0
- bitbake_project/commands/deps.py +903 -0
- bitbake_project/commands/explore.py +2269 -0
- bitbake_project/commands/export.py +1030 -0
- bitbake_project/commands/fragment.py +884 -0
- bitbake_project/commands/init.py +515 -0
- bitbake_project/commands/projects.py +1505 -0
- bitbake_project/commands/recipe.py +1374 -0
- bitbake_project/commands/repos.py +154 -0
- bitbake_project/commands/search.py +313 -0
- bitbake_project/commands/update.py +181 -0
- bitbake_project/core.py +1811 -0
- bitp-1.0.6.dist-info/METADATA +401 -0
- bitp-1.0.6.dist-info/RECORD +24 -0
- bitp-1.0.6.dist-info/WHEEL +5 -0
- bitp-1.0.6.dist-info/entry_points.txt +3 -0
- bitp-1.0.6.dist-info/licenses/COPYING +338 -0
- bitp-1.0.6.dist-info/top_level.txt +1 -0
bitbake_project/core.py
ADDED
|
@@ -0,0 +1,1811 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
5
|
+
#
|
|
6
|
+
"""
|
|
7
|
+
Core abstractions for bit.
|
|
8
|
+
|
|
9
|
+
This module provides:
|
|
10
|
+
- Colors: ANSI color formatting
|
|
11
|
+
- GitRepo: Git repository operations wrapper
|
|
12
|
+
- FzfMenu: fzf menu builder and runner
|
|
13
|
+
- State management: defaults, prep state, export state
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import Callable, Dict, List, Optional, Set, Tuple, Union
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Colors:
|
|
25
|
+
"""ANSI color codes for terminal output."""
|
|
26
|
+
GREEN = "\033[32m"
|
|
27
|
+
YELLOW = "\033[33m"
|
|
28
|
+
RED = "\033[31m"
|
|
29
|
+
CYAN = "\033[36m"
|
|
30
|
+
MAGENTA = "\033[35m"
|
|
31
|
+
BOLD = "\033[1m"
|
|
32
|
+
DIM = "\033[2m"
|
|
33
|
+
RESET = "\033[0m"
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def green(cls, text: str) -> str:
|
|
37
|
+
return f"{cls.GREEN}{text}{cls.RESET}"
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def yellow(cls, text: str) -> str:
|
|
41
|
+
return f"{cls.YELLOW}{text}{cls.RESET}"
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def red(cls, text: str) -> str:
|
|
45
|
+
return f"{cls.RED}{text}{cls.RESET}"
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def cyan(cls, text: str) -> str:
|
|
49
|
+
return f"{cls.CYAN}{text}{cls.RESET}"
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def magenta(cls, text: str) -> str:
|
|
53
|
+
return f"{cls.MAGENTA}{text}{cls.RESET}"
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def dim(cls, text: str) -> str:
|
|
57
|
+
return f"{cls.DIM}{text}{cls.RESET}"
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def bold(cls, text: str) -> str:
|
|
61
|
+
return f"{cls.BOLD}{text}{cls.RESET}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# =============================================================================
|
|
65
|
+
# FZF Themes
|
|
66
|
+
# =============================================================================
|
|
67
|
+
|
|
68
|
+
# Theme format: fzf --color option string
|
|
69
|
+
# Themes control: fg+/bg+ (current line), hl/hl+ (match highlight),
|
|
70
|
+
# pointer, marker, prompt, header, info
|
|
71
|
+
# Note: fg (default text) is a separate setting to allow independent control
|
|
72
|
+
FZF_THEMES: Dict[str, Tuple[str, str]] = {
|
|
73
|
+
# (color_string, description)
|
|
74
|
+
"default": (
|
|
75
|
+
"",
|
|
76
|
+
"Terminal default colors"
|
|
77
|
+
),
|
|
78
|
+
"dark": (
|
|
79
|
+
"fg+:white,bg+:236,hl:yellow,hl+:yellow,pointer:cyan,marker:cyan,prompt:cyan,header:blue",
|
|
80
|
+
"Dark background optimized"
|
|
81
|
+
),
|
|
82
|
+
"light": (
|
|
83
|
+
"fg+:black,bg+:254,hl:blue,hl+:blue,pointer:magenta,marker:magenta,prompt:magenta,header:blue",
|
|
84
|
+
"Light background optimized"
|
|
85
|
+
),
|
|
86
|
+
"high-contrast": (
|
|
87
|
+
"fg+:white:bold,bg+:black,hl:yellow:bold,hl+:yellow:bold,pointer:red:bold,marker:red:bold,prompt:green:bold,header:cyan:bold",
|
|
88
|
+
"Maximum visibility"
|
|
89
|
+
),
|
|
90
|
+
"ocean": (
|
|
91
|
+
"fg+:white,bg+:24,hl:cyan,hl+:cyan:bold,pointer:white,marker:white,prompt:cyan,header:39",
|
|
92
|
+
"Blue ocean tones"
|
|
93
|
+
),
|
|
94
|
+
"forest": (
|
|
95
|
+
"fg+:white,bg+:22,hl:yellow,hl+:yellow:bold,pointer:white,marker:white,prompt:green,header:34",
|
|
96
|
+
"Green forest tones"
|
|
97
|
+
),
|
|
98
|
+
"nord": (
|
|
99
|
+
"fg+:white,bg+:60,hl:cyan,hl+:cyan:bold,pointer:cyan,marker:cyan,prompt:blue,header:blue",
|
|
100
|
+
"Nord color scheme"
|
|
101
|
+
),
|
|
102
|
+
"dracula": (
|
|
103
|
+
"fg+:white,bg+:61,hl:212,hl+:212:bold,pointer:212,marker:212,prompt:141,header:139",
|
|
104
|
+
"Dracula color scheme"
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Text color presets (fg) - separate from themes for independent control
|
|
109
|
+
# Use these if your terminal's default text color doesn't work well
|
|
110
|
+
FZF_TEXT_COLORS: Dict[str, Tuple[str, str]] = {
|
|
111
|
+
# (fg_color_value, description)
|
|
112
|
+
"auto": ("", "Use terminal default"),
|
|
113
|
+
"white": ("white", "White text (for dark backgrounds)"),
|
|
114
|
+
"light-gray": ("252", "Light gray (for dark backgrounds)"),
|
|
115
|
+
"dark-gray": ("235", "Dark gray (for light backgrounds)"),
|
|
116
|
+
"black": ("black", "Black text (for light backgrounds)"),
|
|
117
|
+
"green": ("green", "Green text"),
|
|
118
|
+
"cyan": ("cyan", "Cyan text"),
|
|
119
|
+
"yellow": ("yellow", "Yellow text"),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Individual color elements that can be customized
|
|
123
|
+
# These override theme settings for specific elements
|
|
124
|
+
FZF_COLOR_ELEMENTS: Dict[str, str] = {
|
|
125
|
+
# element_name: description
|
|
126
|
+
"fg": "Default text",
|
|
127
|
+
"fg+": "Current line text",
|
|
128
|
+
"bg+": "Current line background",
|
|
129
|
+
"hl": "Match highlight",
|
|
130
|
+
"hl+": "Match highlight (current line)",
|
|
131
|
+
"pointer": "Pointer arrow",
|
|
132
|
+
"marker": "Multi-select marker",
|
|
133
|
+
"prompt": "Prompt text",
|
|
134
|
+
"header": "Header text",
|
|
135
|
+
"info": "Info line",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Color values available for individual customization
|
|
139
|
+
COLOR_VALUES: Dict[str, str] = {
|
|
140
|
+
# name: fzf color value
|
|
141
|
+
"(default)": "",
|
|
142
|
+
"white": "white",
|
|
143
|
+
"black": "black",
|
|
144
|
+
"red": "red",
|
|
145
|
+
"green": "green",
|
|
146
|
+
"yellow": "yellow",
|
|
147
|
+
"blue": "blue",
|
|
148
|
+
"magenta": "magenta",
|
|
149
|
+
"cyan": "cyan",
|
|
150
|
+
# 256-color values
|
|
151
|
+
"light-gray": "252",
|
|
152
|
+
"dark-gray": "236",
|
|
153
|
+
"light-red": "203",
|
|
154
|
+
"light-green": "114",
|
|
155
|
+
"light-yellow": "228",
|
|
156
|
+
"light-blue": "75",
|
|
157
|
+
"light-magenta": "213",
|
|
158
|
+
"light-cyan": "123",
|
|
159
|
+
"orange": "208",
|
|
160
|
+
"pink": "212",
|
|
161
|
+
"purple": "141",
|
|
162
|
+
"teal": "37",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# =============================================================================
|
|
166
|
+
# Terminal Output Colors (configurable)
|
|
167
|
+
# =============================================================================
|
|
168
|
+
|
|
169
|
+
# Elements in terminal output that can have their color customized
|
|
170
|
+
TERMINAL_COLOR_ELEMENTS: Dict[str, Tuple[str, str]] = {
|
|
171
|
+
# element_name: (default_color, description)
|
|
172
|
+
"upstream": ("yellow", "Upstream commit indicator (↓ N to pull)"),
|
|
173
|
+
"local": ("green", "Local commit count"),
|
|
174
|
+
"dirty": ("red", "Dirty working tree indicator"),
|
|
175
|
+
"clean": ("green", "Clean working tree indicator"),
|
|
176
|
+
"repo": ("green", "Repo name (configured layers)"),
|
|
177
|
+
"repo_discovered": ("magenta", "Repo name (discovered layers)"),
|
|
178
|
+
"repo_external": ("cyan", "Repo name (external repos)"),
|
|
179
|
+
"fragment_enabled": ("green", "Enabled fragment in browser"),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# ANSI codes for terminal colors
|
|
183
|
+
ANSI_COLORS: Dict[str, str] = {
|
|
184
|
+
"white": "\033[37m",
|
|
185
|
+
"black": "\033[30m",
|
|
186
|
+
"red": "\033[31m",
|
|
187
|
+
"green": "\033[32m",
|
|
188
|
+
"yellow": "\033[33m",
|
|
189
|
+
"blue": "\033[34m",
|
|
190
|
+
"magenta": "\033[35m",
|
|
191
|
+
"cyan": "\033[36m",
|
|
192
|
+
# Bright/light variants (using 256-color codes)
|
|
193
|
+
"light-gray": "\033[38;5;252m",
|
|
194
|
+
"dark-gray": "\033[38;5;236m",
|
|
195
|
+
"light-red": "\033[38;5;203m",
|
|
196
|
+
"light-green": "\033[38;5;114m",
|
|
197
|
+
"light-yellow": "\033[38;5;228m",
|
|
198
|
+
"light-blue": "\033[38;5;75m",
|
|
199
|
+
"light-magenta": "\033[38;5;213m",
|
|
200
|
+
"light-cyan": "\033[38;5;123m",
|
|
201
|
+
"orange": "\033[38;5;208m",
|
|
202
|
+
"pink": "\033[38;5;212m",
|
|
203
|
+
"purple": "\033[38;5;141m",
|
|
204
|
+
"teal": "\033[38;5;37m",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_terminal_color(element: str, defaults_file: str = ".bit.defaults") -> str:
|
|
209
|
+
"""
|
|
210
|
+
Get configured color for a terminal output element.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
element: Element name from TERMINAL_COLOR_ELEMENTS
|
|
214
|
+
defaults_file: Path to defaults file
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Color name (e.g., "yellow", "cyan")
|
|
218
|
+
"""
|
|
219
|
+
# Get default for this element
|
|
220
|
+
default_color = TERMINAL_COLOR_ELEMENTS.get(element, ("yellow", ""))[0]
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
if os.path.exists(defaults_file):
|
|
224
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
225
|
+
data = json.load(f)
|
|
226
|
+
terminal_colors = data.get("terminal_colors", {})
|
|
227
|
+
return terminal_colors.get(element, default_color)
|
|
228
|
+
except (json.JSONDecodeError, OSError):
|
|
229
|
+
pass
|
|
230
|
+
return default_color
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def set_terminal_color(element: str, color_name: str, defaults_file: str = ".bit.defaults") -> bool:
|
|
234
|
+
"""
|
|
235
|
+
Set color for a terminal output element.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
element: Element name from TERMINAL_COLOR_ELEMENTS
|
|
239
|
+
color_name: Color name from ANSI_COLORS
|
|
240
|
+
defaults_file: Path to defaults file
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
True if successful
|
|
244
|
+
"""
|
|
245
|
+
if element not in TERMINAL_COLOR_ELEMENTS:
|
|
246
|
+
return False
|
|
247
|
+
if color_name not in ANSI_COLORS and color_name != "(default)":
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
data = {}
|
|
252
|
+
if os.path.exists(defaults_file):
|
|
253
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
254
|
+
data = json.load(f)
|
|
255
|
+
|
|
256
|
+
if "terminal_colors" not in data:
|
|
257
|
+
data["terminal_colors"] = {}
|
|
258
|
+
|
|
259
|
+
default_color = TERMINAL_COLOR_ELEMENTS[element][0]
|
|
260
|
+
if color_name == "(default)" or color_name == default_color:
|
|
261
|
+
# Remove custom setting, use default
|
|
262
|
+
data["terminal_colors"].pop(element, None)
|
|
263
|
+
else:
|
|
264
|
+
data["terminal_colors"][element] = color_name
|
|
265
|
+
|
|
266
|
+
# Clean up empty dict
|
|
267
|
+
if not data["terminal_colors"]:
|
|
268
|
+
del data["terminal_colors"]
|
|
269
|
+
|
|
270
|
+
with open(defaults_file, "w", encoding="utf-8") as f:
|
|
271
|
+
json.dump(data, f, indent=2)
|
|
272
|
+
return True
|
|
273
|
+
except (OSError, json.JSONDecodeError):
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def terminal_color(element: str, text: str, defaults_file: str = ".bit.defaults") -> str:
|
|
278
|
+
"""
|
|
279
|
+
Apply configured terminal color to text.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
element: Element name from TERMINAL_COLOR_ELEMENTS
|
|
283
|
+
text: Text to colorize
|
|
284
|
+
defaults_file: Path to defaults file
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Colorized text string with ANSI codes
|
|
288
|
+
"""
|
|
289
|
+
color_name = get_terminal_color(element, defaults_file)
|
|
290
|
+
ansi_code = ANSI_COLORS.get(color_name, ANSI_COLORS.get("yellow", "\033[33m"))
|
|
291
|
+
return f"{ansi_code}{text}\033[0m"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_fzf_theme(defaults_file: str = ".bit.defaults") -> str:
|
|
295
|
+
"""
|
|
296
|
+
Get the current fzf theme color string from defaults.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
fzf --color option string, or empty string for default.
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
if os.path.exists(defaults_file):
|
|
303
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
304
|
+
data = json.load(f)
|
|
305
|
+
theme_name = data.get("fzf_theme", "default")
|
|
306
|
+
if theme_name in FZF_THEMES:
|
|
307
|
+
return FZF_THEMES[theme_name][0]
|
|
308
|
+
except (json.JSONDecodeError, OSError):
|
|
309
|
+
pass
|
|
310
|
+
return ""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def get_fzf_text_color(defaults_file: str = ".bit.defaults") -> str:
|
|
314
|
+
"""
|
|
315
|
+
Get the current fzf text color (fg) setting from defaults.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
fg color value (e.g., "white", "252") or empty string for auto/default.
|
|
319
|
+
"""
|
|
320
|
+
try:
|
|
321
|
+
if os.path.exists(defaults_file):
|
|
322
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
323
|
+
data = json.load(f)
|
|
324
|
+
color_name = data.get("fzf_text_color", "auto")
|
|
325
|
+
if color_name in FZF_TEXT_COLORS:
|
|
326
|
+
return FZF_TEXT_COLORS[color_name][0]
|
|
327
|
+
except (json.JSONDecodeError, OSError):
|
|
328
|
+
pass
|
|
329
|
+
return ""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def set_fzf_text_color(color_name: str, defaults_file: str = ".bit.defaults") -> bool:
|
|
333
|
+
"""
|
|
334
|
+
Save fzf text color setting to defaults file.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
True if successful, False otherwise.
|
|
338
|
+
"""
|
|
339
|
+
if color_name not in FZF_TEXT_COLORS:
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
data = {}
|
|
344
|
+
if os.path.exists(defaults_file):
|
|
345
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
346
|
+
data = json.load(f)
|
|
347
|
+
|
|
348
|
+
data["fzf_text_color"] = color_name
|
|
349
|
+
|
|
350
|
+
with open(defaults_file, "w", encoding="utf-8") as f:
|
|
351
|
+
json.dump(data, f, indent=2)
|
|
352
|
+
return True
|
|
353
|
+
except (json.JSONDecodeError, OSError):
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def get_current_text_color_name(defaults_file: str = ".bit.defaults") -> str:
|
|
358
|
+
"""Get the current text color setting name (not the value)."""
|
|
359
|
+
try:
|
|
360
|
+
if os.path.exists(defaults_file):
|
|
361
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
362
|
+
data = json.load(f)
|
|
363
|
+
return data.get("fzf_text_color", "auto")
|
|
364
|
+
except (json.JSONDecodeError, OSError):
|
|
365
|
+
pass
|
|
366
|
+
return "auto"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def get_custom_colors(defaults_file: str = ".bit.defaults") -> Dict[str, str]:
|
|
370
|
+
"""
|
|
371
|
+
Get individual color overrides from defaults.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Dict mapping element names to color values, e.g. {"pointer": "green", "header": "white"}
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
if os.path.exists(defaults_file):
|
|
378
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
379
|
+
data = json.load(f)
|
|
380
|
+
return data.get("fzf_custom_colors", {})
|
|
381
|
+
except (json.JSONDecodeError, OSError):
|
|
382
|
+
pass
|
|
383
|
+
return {}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def set_custom_color(element: str, color_name: str, defaults_file: str = ".bit.defaults") -> bool:
|
|
387
|
+
"""
|
|
388
|
+
Set an individual color override.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
element: Color element (e.g., "pointer", "header")
|
|
392
|
+
color_name: Color name from COLOR_VALUES (e.g., "green", "(default)")
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
True if successful
|
|
396
|
+
"""
|
|
397
|
+
if element not in FZF_COLOR_ELEMENTS:
|
|
398
|
+
return False
|
|
399
|
+
if color_name not in COLOR_VALUES:
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
data = {}
|
|
404
|
+
if os.path.exists(defaults_file):
|
|
405
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
406
|
+
data = json.load(f)
|
|
407
|
+
|
|
408
|
+
if "fzf_custom_colors" not in data:
|
|
409
|
+
data["fzf_custom_colors"] = {}
|
|
410
|
+
|
|
411
|
+
if color_name == "(default)":
|
|
412
|
+
# Remove the override
|
|
413
|
+
data["fzf_custom_colors"].pop(element, None)
|
|
414
|
+
else:
|
|
415
|
+
data["fzf_custom_colors"][element] = color_name
|
|
416
|
+
|
|
417
|
+
# Clean up empty dict
|
|
418
|
+
if not data["fzf_custom_colors"]:
|
|
419
|
+
del data["fzf_custom_colors"]
|
|
420
|
+
|
|
421
|
+
with open(defaults_file, "w", encoding="utf-8") as f:
|
|
422
|
+
json.dump(data, f, indent=2)
|
|
423
|
+
return True
|
|
424
|
+
except (json.JSONDecodeError, OSError):
|
|
425
|
+
return False
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def clear_custom_colors(defaults_file: str = ".bit.defaults") -> bool:
|
|
429
|
+
"""Clear all individual color overrides."""
|
|
430
|
+
try:
|
|
431
|
+
data = {}
|
|
432
|
+
if os.path.exists(defaults_file):
|
|
433
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
434
|
+
data = json.load(f)
|
|
435
|
+
|
|
436
|
+
data.pop("fzf_custom_colors", None)
|
|
437
|
+
|
|
438
|
+
with open(defaults_file, "w", encoding="utf-8") as f:
|
|
439
|
+
json.dump(data, f, indent=2)
|
|
440
|
+
return True
|
|
441
|
+
except (json.JSONDecodeError, OSError):
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def get_fzf_color_args(defaults_file: str = ".bit.defaults") -> List[str]:
|
|
446
|
+
"""
|
|
447
|
+
Get fzf --color arguments as a list for subprocess calls.
|
|
448
|
+
|
|
449
|
+
Combines theme colors with individual overrides (custom colors take precedence).
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
List like ["--color", "fg:white,fg+:white,bg+:blue,..."] or empty list for default.
|
|
453
|
+
"""
|
|
454
|
+
theme_colors = get_fzf_theme(defaults_file)
|
|
455
|
+
text_color = get_fzf_text_color(defaults_file)
|
|
456
|
+
custom_colors = get_custom_colors(defaults_file)
|
|
457
|
+
|
|
458
|
+
# Build combined color string
|
|
459
|
+
color_parts = []
|
|
460
|
+
|
|
461
|
+
# 1. Theme colors (base)
|
|
462
|
+
if theme_colors:
|
|
463
|
+
color_parts.append(theme_colors)
|
|
464
|
+
|
|
465
|
+
# 2. Text color override (fg from separate setting)
|
|
466
|
+
if text_color:
|
|
467
|
+
color_parts.append(f"fg:{text_color}")
|
|
468
|
+
|
|
469
|
+
# 3. Individual custom color overrides (highest precedence)
|
|
470
|
+
for element, color_name in custom_colors.items():
|
|
471
|
+
if color_name in COLOR_VALUES and COLOR_VALUES[color_name]:
|
|
472
|
+
color_parts.append(f"{element}:{COLOR_VALUES[color_name]}")
|
|
473
|
+
|
|
474
|
+
if color_parts:
|
|
475
|
+
return ["--color", ",".join(color_parts)]
|
|
476
|
+
return []
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def get_fzf_preview_resize_bindings(start_size: int = 50) -> List[str]:
|
|
480
|
+
"""
|
|
481
|
+
Generate fzf keybindings for preview window resize with coordinated state.
|
|
482
|
+
|
|
483
|
+
Uses fzf's --listen HTTP API to send resize commands. State is tracked
|
|
484
|
+
in a temp file so alt-right and alt-left coordinate properly.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
start_size: Initial preview window percentage (default 50)
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
List of fzf arguments including --listen and resize bindings
|
|
491
|
+
"""
|
|
492
|
+
# Check if curl is available for HTTP API approach
|
|
493
|
+
if not shutil.which("curl"):
|
|
494
|
+
# Fallback to simple cycling if no curl
|
|
495
|
+
return [
|
|
496
|
+
"--bind", "alt-p:change-preview-window(50%|40%|30%|20%|10%|hidden|90%|80%|70%|60%)",
|
|
497
|
+
]
|
|
498
|
+
|
|
499
|
+
# Generate a unique port based on PID + random to avoid collisions
|
|
500
|
+
import random
|
|
501
|
+
port = 10000 + (os.getpid() % 10000) + random.randint(0, 999)
|
|
502
|
+
|
|
503
|
+
# State file unique to this fzf instance
|
|
504
|
+
state_file = f"/tmp/fzf-pw-{port}"
|
|
505
|
+
|
|
506
|
+
# Initialize state file with starting size
|
|
507
|
+
try:
|
|
508
|
+
with open(state_file, "w") as f:
|
|
509
|
+
f.write(str(start_size))
|
|
510
|
+
except OSError:
|
|
511
|
+
# Fall back to simple cycling if we can't write state
|
|
512
|
+
return [
|
|
513
|
+
"--bind", "alt-p:change-preview-window(50%|40%|30%|20%|10%|hidden|90%|80%|70%|60%)",
|
|
514
|
+
]
|
|
515
|
+
|
|
516
|
+
# Inline bash commands for resize (no external script needed)
|
|
517
|
+
# Reads state, adjusts, writes back, posts to fzf HTTP API
|
|
518
|
+
shrink_cmd = (
|
|
519
|
+
f"s=$(cat {state_file} 2>/dev/null||echo 50);"
|
|
520
|
+
f"s=$((s-10));[ $s -lt 10 ]&&s=10;"
|
|
521
|
+
f"echo $s>{state_file};"
|
|
522
|
+
f"curl -s -XPOST localhost:{port} -d change-preview-window:$s% >/dev/null 2>&1"
|
|
523
|
+
)
|
|
524
|
+
grow_cmd = (
|
|
525
|
+
f"s=$(cat {state_file} 2>/dev/null||echo 50);"
|
|
526
|
+
f"s=$((s+10));[ $s -gt 90 ]&&s=90;"
|
|
527
|
+
f"echo $s>{state_file};"
|
|
528
|
+
f"curl -s -XPOST localhost:{port} -d change-preview-window:$s% >/dev/null 2>&1"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
return [
|
|
532
|
+
"--listen", str(port),
|
|
533
|
+
"--bind", f"alt-right:execute-silent({shrink_cmd})",
|
|
534
|
+
"--bind", f"alt-left:execute-silent({grow_cmd})",
|
|
535
|
+
]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def run_fzf(
|
|
539
|
+
args: List[str],
|
|
540
|
+
input_text: Optional[str] = None,
|
|
541
|
+
defaults_file: str = ".bit.defaults",
|
|
542
|
+
) -> subprocess.CompletedProcess:
|
|
543
|
+
"""
|
|
544
|
+
Run fzf with automatic theme color support.
|
|
545
|
+
|
|
546
|
+
This is the preferred way to run fzf - it automatically applies
|
|
547
|
+
the user's configured theme colors.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
args: fzf arguments (should NOT include "fzf" as first element,
|
|
551
|
+
but if it does, it will be handled)
|
|
552
|
+
input_text: Optional text to pass to fzf's stdin
|
|
553
|
+
defaults_file: Path to defaults file for theme lookup
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
subprocess.CompletedProcess with stdout captured as text.
|
|
557
|
+
|
|
558
|
+
Raises:
|
|
559
|
+
FileNotFoundError: If fzf is not installed.
|
|
560
|
+
|
|
561
|
+
Example:
|
|
562
|
+
result = run_fzf(
|
|
563
|
+
["--no-multi", "--height", "50%", "--header", "Pick one"],
|
|
564
|
+
input_text="option1\\noption2\\noption3"
|
|
565
|
+
)
|
|
566
|
+
if result.returncode == 0:
|
|
567
|
+
selected = result.stdout.strip()
|
|
568
|
+
"""
|
|
569
|
+
# Normalize args - ensure "fzf" is first
|
|
570
|
+
if args and args[0] != "fzf":
|
|
571
|
+
args = ["fzf"] + args
|
|
572
|
+
elif not args:
|
|
573
|
+
args = ["fzf"]
|
|
574
|
+
|
|
575
|
+
# Add theme colors
|
|
576
|
+
color_args = get_fzf_color_args(defaults_file)
|
|
577
|
+
if color_args:
|
|
578
|
+
args = args + color_args
|
|
579
|
+
|
|
580
|
+
return subprocess.run(
|
|
581
|
+
args,
|
|
582
|
+
input=input_text,
|
|
583
|
+
stdout=subprocess.PIPE,
|
|
584
|
+
text=True,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def set_fzf_theme(theme_name: str, defaults_file: str = ".bit.defaults") -> bool:
|
|
589
|
+
"""
|
|
590
|
+
Save fzf theme to defaults file.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
theme_name: Name of theme from FZF_THEMES
|
|
594
|
+
defaults_file: Path to defaults file
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
True if saved successfully
|
|
598
|
+
"""
|
|
599
|
+
if theme_name not in FZF_THEMES:
|
|
600
|
+
return False
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
data = {}
|
|
604
|
+
if os.path.exists(defaults_file):
|
|
605
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
606
|
+
data = json.load(f)
|
|
607
|
+
|
|
608
|
+
data["fzf_theme"] = theme_name
|
|
609
|
+
|
|
610
|
+
with open(defaults_file, "w", encoding="utf-8") as f:
|
|
611
|
+
json.dump(data, f, indent=2)
|
|
612
|
+
return True
|
|
613
|
+
except (json.JSONDecodeError, OSError):
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def get_current_theme_name(defaults_file: str = ".bit.defaults") -> str:
|
|
618
|
+
"""Get the name of the currently selected theme."""
|
|
619
|
+
try:
|
|
620
|
+
if os.path.exists(defaults_file):
|
|
621
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
622
|
+
data = json.load(f)
|
|
623
|
+
theme_name = data.get("fzf_theme", "default")
|
|
624
|
+
if theme_name in FZF_THEMES:
|
|
625
|
+
return theme_name
|
|
626
|
+
except (json.JSONDecodeError, OSError):
|
|
627
|
+
pass
|
|
628
|
+
return "default"
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def fzf_theme_picker(defaults_file: str = ".bit.defaults") -> Optional[str]:
|
|
632
|
+
"""
|
|
633
|
+
Show fzf menu to select a color theme.
|
|
634
|
+
|
|
635
|
+
The menu shows a live preview of each theme's colors and loops
|
|
636
|
+
so the user can see the effect before exiting.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
defaults_file: Path to defaults file for saving selection
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
Selected theme name, or None if cancelled.
|
|
643
|
+
"""
|
|
644
|
+
if not fzf_available():
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
last_selected = None
|
|
648
|
+
|
|
649
|
+
while True:
|
|
650
|
+
current_theme = get_current_theme_name(defaults_file)
|
|
651
|
+
|
|
652
|
+
# Build menu with theme previews
|
|
653
|
+
menu_lines = []
|
|
654
|
+
for name, (colors, description) in FZF_THEMES.items():
|
|
655
|
+
marker = "● " if name == current_theme else " "
|
|
656
|
+
# Color the theme name based on what it would look like
|
|
657
|
+
colored_name = f"\033[36m{name:<15}\033[0m"
|
|
658
|
+
menu_lines.append(f"{name}\t{marker}{colored_name} {description}")
|
|
659
|
+
|
|
660
|
+
menu_input = "\n".join(menu_lines)
|
|
661
|
+
|
|
662
|
+
# Build preview content showing all elements with current colors
|
|
663
|
+
preview_content = _build_color_preview(defaults_file)
|
|
664
|
+
preview_escaped = preview_content.replace("'", "'\\''")
|
|
665
|
+
preview_cmd = f"echo '{preview_escaped}'"
|
|
666
|
+
|
|
667
|
+
# Build fzf command
|
|
668
|
+
fzf_args = [
|
|
669
|
+
"fzf",
|
|
670
|
+
"--no-multi",
|
|
671
|
+
"--no-sort",
|
|
672
|
+
"--no-info",
|
|
673
|
+
"--ansi",
|
|
674
|
+
"--height", "20",
|
|
675
|
+
"--layout", "reverse",
|
|
676
|
+
"--header", "Select theme (Enter to apply, ←/q=back)",
|
|
677
|
+
"--prompt", "Theme: ",
|
|
678
|
+
"--with-nth", "2..",
|
|
679
|
+
"--delimiter", "\t",
|
|
680
|
+
"--bind", "q:abort",
|
|
681
|
+
"--bind", "left:abort",
|
|
682
|
+
"--preview", preview_cmd,
|
|
683
|
+
"--preview-window", "right:60%:noborder",
|
|
684
|
+
]
|
|
685
|
+
|
|
686
|
+
# Apply preview resize and theme colors
|
|
687
|
+
fzf_args.extend(get_fzf_preview_resize_bindings())
|
|
688
|
+
fzf_args.extend(get_fzf_color_args(defaults_file))
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
result = subprocess.run(
|
|
692
|
+
fzf_args,
|
|
693
|
+
input=menu_input,
|
|
694
|
+
stdout=subprocess.PIPE,
|
|
695
|
+
text=True,
|
|
696
|
+
)
|
|
697
|
+
except FileNotFoundError:
|
|
698
|
+
return last_selected
|
|
699
|
+
|
|
700
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
701
|
+
return last_selected
|
|
702
|
+
|
|
703
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
704
|
+
|
|
705
|
+
if selected in FZF_THEMES:
|
|
706
|
+
set_fzf_theme(selected, defaults_file)
|
|
707
|
+
last_selected = selected
|
|
708
|
+
# Loop to show updated preview
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def fzf_text_color_picker(defaults_file: str = ".bit.defaults") -> Optional[str]:
|
|
712
|
+
"""
|
|
713
|
+
Show fzf menu to select text color (fg).
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
defaults_file: Path to defaults file for saving selection
|
|
717
|
+
|
|
718
|
+
Returns:
|
|
719
|
+
Selected color name, or None if cancelled.
|
|
720
|
+
"""
|
|
721
|
+
if not fzf_available():
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
current_color = get_current_text_color_name(defaults_file)
|
|
725
|
+
|
|
726
|
+
# Build menu
|
|
727
|
+
menu_lines = []
|
|
728
|
+
for name, (color_value, description) in FZF_TEXT_COLORS.items():
|
|
729
|
+
marker = "● " if name == current_color else " "
|
|
730
|
+
# Show a sample of the color if it's set
|
|
731
|
+
if color_value:
|
|
732
|
+
sample = f"\033[38;5;{color_value}m■■■\033[0m" if color_value.isdigit() else f"\033[{'37' if color_value == 'white' else '30' if color_value == 'black' else '32' if color_value == 'green' else '36' if color_value == 'cyan' else '33' if color_value == 'yellow' else '37'}m■■■\033[0m"
|
|
733
|
+
else:
|
|
734
|
+
sample = " "
|
|
735
|
+
menu_lines.append(f"{name}\t{marker}{name:<12} {sample} {description}")
|
|
736
|
+
|
|
737
|
+
menu_input = "\n".join(menu_lines)
|
|
738
|
+
|
|
739
|
+
fzf_args = [
|
|
740
|
+
"fzf",
|
|
741
|
+
"--no-multi",
|
|
742
|
+
"--no-sort",
|
|
743
|
+
"--no-info",
|
|
744
|
+
"--ansi",
|
|
745
|
+
"--height", "~50%",
|
|
746
|
+
"--header", "Select text color (Enter to apply, q to cancel)",
|
|
747
|
+
"--prompt", "Color: ",
|
|
748
|
+
"--with-nth", "2..",
|
|
749
|
+
"--delimiter", "\t",
|
|
750
|
+
"--bind", "q:abort",
|
|
751
|
+
]
|
|
752
|
+
|
|
753
|
+
# Apply current colors
|
|
754
|
+
fzf_args.extend(get_fzf_color_args(defaults_file))
|
|
755
|
+
|
|
756
|
+
try:
|
|
757
|
+
result = subprocess.run(
|
|
758
|
+
fzf_args,
|
|
759
|
+
input=menu_input,
|
|
760
|
+
stdout=subprocess.PIPE,
|
|
761
|
+
text=True,
|
|
762
|
+
)
|
|
763
|
+
except FileNotFoundError:
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
767
|
+
return None
|
|
768
|
+
|
|
769
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
770
|
+
|
|
771
|
+
if selected in FZF_TEXT_COLORS:
|
|
772
|
+
set_fzf_text_color(selected, defaults_file)
|
|
773
|
+
return selected
|
|
774
|
+
|
|
775
|
+
return None
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _build_color_preview(defaults_file: str) -> str:
|
|
779
|
+
"""Build a preview showing all color elements with current settings."""
|
|
780
|
+
# Get current effective colors (theme + custom overrides)
|
|
781
|
+
theme_colors = get_fzf_theme(defaults_file)
|
|
782
|
+
custom_colors = get_custom_colors(defaults_file)
|
|
783
|
+
|
|
784
|
+
# Parse theme colors into a dict
|
|
785
|
+
effective = {}
|
|
786
|
+
if theme_colors:
|
|
787
|
+
for part in theme_colors.split(","):
|
|
788
|
+
if ":" in part:
|
|
789
|
+
key, val = part.split(":", 1)
|
|
790
|
+
effective[key] = val
|
|
791
|
+
|
|
792
|
+
# Apply custom overrides
|
|
793
|
+
for element, color_name in custom_colors.items():
|
|
794
|
+
if color_name in COLOR_VALUES and COLOR_VALUES[color_name]:
|
|
795
|
+
effective[element] = COLOR_VALUES[color_name]
|
|
796
|
+
|
|
797
|
+
def ansi(color_val: str, text: str) -> str:
|
|
798
|
+
"""Wrap text in ANSI color codes."""
|
|
799
|
+
if not color_val:
|
|
800
|
+
return text
|
|
801
|
+
fg_codes = {"white": "37", "black": "30", "red": "31", "green": "32",
|
|
802
|
+
"yellow": "33", "blue": "34", "magenta": "35", "cyan": "36"}
|
|
803
|
+
if color_val in fg_codes:
|
|
804
|
+
return f"\033[{fg_codes[color_val]}m{text}\033[0m"
|
|
805
|
+
elif color_val.isdigit():
|
|
806
|
+
return f"\033[38;5;{color_val}m{text}\033[0m"
|
|
807
|
+
return text
|
|
808
|
+
|
|
809
|
+
def combo(fg_val: str, bg_val: str, text: str) -> str:
|
|
810
|
+
"""Combine fg and bg colors."""
|
|
811
|
+
fg_codes = {"white": "37", "black": "30", "red": "31", "green": "32",
|
|
812
|
+
"yellow": "33", "blue": "34", "magenta": "35", "cyan": "36"}
|
|
813
|
+
bg_codes = {"white": "47", "black": "40", "red": "41", "green": "42",
|
|
814
|
+
"yellow": "43", "blue": "44", "magenta": "45", "cyan": "46"}
|
|
815
|
+
codes = []
|
|
816
|
+
if fg_val:
|
|
817
|
+
if fg_val in fg_codes:
|
|
818
|
+
codes.append(fg_codes[fg_val])
|
|
819
|
+
elif fg_val.isdigit():
|
|
820
|
+
codes.append(f"38;5;{fg_val}")
|
|
821
|
+
if bg_val:
|
|
822
|
+
if bg_val in bg_codes:
|
|
823
|
+
codes.append(bg_codes[bg_val])
|
|
824
|
+
elif bg_val.isdigit():
|
|
825
|
+
codes.append(f"48;5;{bg_val}")
|
|
826
|
+
if codes:
|
|
827
|
+
return f"\033[{';'.join(codes)}m{text}\033[0m"
|
|
828
|
+
return text
|
|
829
|
+
|
|
830
|
+
W = 28 # Inner content width
|
|
831
|
+
|
|
832
|
+
def pad(text: str, visible_len: int) -> str:
|
|
833
|
+
"""Pad text to fill the box width."""
|
|
834
|
+
return text + " " * (W - visible_len)
|
|
835
|
+
|
|
836
|
+
def row(content: str, visible_len: int) -> str:
|
|
837
|
+
"""Create a box row."""
|
|
838
|
+
return f"│ {pad(content, visible_len)} │"
|
|
839
|
+
|
|
840
|
+
# Build preview lines
|
|
841
|
+
lines = ["┌" + "─" * W + "──┐"]
|
|
842
|
+
|
|
843
|
+
# Pointer and marker
|
|
844
|
+
ptr = effective.get("pointer", "")
|
|
845
|
+
mkr = effective.get("marker", "")
|
|
846
|
+
lines.append(row(f"{ansi(ptr, '>')} item one", 10))
|
|
847
|
+
lines.append(row(f"{ansi(mkr, '●')} item two (marked)", 19))
|
|
848
|
+
lines.append(row("", 0))
|
|
849
|
+
|
|
850
|
+
# Prompt
|
|
851
|
+
pmt = effective.get("prompt", "")
|
|
852
|
+
lines.append(row(f"{ansi(pmt, 'prompt:')} type here", 17))
|
|
853
|
+
|
|
854
|
+
# Header
|
|
855
|
+
hdr = effective.get("header", "")
|
|
856
|
+
lines.append(row(ansi(hdr, "Header: ? for help"), 18))
|
|
857
|
+
lines.append(row("", 0))
|
|
858
|
+
|
|
859
|
+
# Normal text (fg)
|
|
860
|
+
fg = effective.get("fg", "")
|
|
861
|
+
lines.append(row(ansi(fg, "Normal text (fg)"), 16))
|
|
862
|
+
|
|
863
|
+
# Highlight
|
|
864
|
+
hl = effective.get("hl", "")
|
|
865
|
+
lines.append(row(f"Search {ansi(hl, 'match')} here", 17))
|
|
866
|
+
lines.append(row("", 0))
|
|
867
|
+
|
|
868
|
+
# Selected line (fg+ on bg+)
|
|
869
|
+
fg_plus = effective.get("fg+", "")
|
|
870
|
+
bg_plus = effective.get("bg+", "")
|
|
871
|
+
hl_plus = effective.get("hl+", "")
|
|
872
|
+
sel_text = f"Selected {ansi(hl_plus, 'match')} line"
|
|
873
|
+
sel_padded = pad(sel_text, 19)
|
|
874
|
+
lines.append(f"│ {combo(fg_plus, bg_plus, sel_padded)} │")
|
|
875
|
+
|
|
876
|
+
# Info
|
|
877
|
+
inf = effective.get("info", "")
|
|
878
|
+
lines.append(row(ansi(inf, "info: 5/10"), 10))
|
|
879
|
+
|
|
880
|
+
lines.append("└" + "─" * W + "──┘")
|
|
881
|
+
|
|
882
|
+
return "\n".join(lines)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def fzf_custom_color_menu(defaults_file: str = ".bit.defaults") -> None:
|
|
886
|
+
"""
|
|
887
|
+
Show fzf menu to customize individual color elements.
|
|
888
|
+
|
|
889
|
+
Presents a list of color elements (pointer, header, etc.) and lets
|
|
890
|
+
user pick which one to customize.
|
|
891
|
+
"""
|
|
892
|
+
if not fzf_available():
|
|
893
|
+
return
|
|
894
|
+
|
|
895
|
+
while True:
|
|
896
|
+
custom_colors = get_custom_colors(defaults_file)
|
|
897
|
+
override_count = len(custom_colors)
|
|
898
|
+
|
|
899
|
+
# Build menu of elements
|
|
900
|
+
menu_lines = []
|
|
901
|
+
for element, description in FZF_COLOR_ELEMENTS.items():
|
|
902
|
+
current = custom_colors.get(element, "(default)")
|
|
903
|
+
# Show color sample
|
|
904
|
+
if current != "(default)" and current in COLOR_VALUES:
|
|
905
|
+
color_val = COLOR_VALUES[current]
|
|
906
|
+
if color_val:
|
|
907
|
+
sample = _color_sample(color_val)
|
|
908
|
+
else:
|
|
909
|
+
sample = " "
|
|
910
|
+
else:
|
|
911
|
+
sample = " "
|
|
912
|
+
menu_lines.append(f"{element}\t{element:<10} {sample} {current:<14} {description}")
|
|
913
|
+
|
|
914
|
+
# Add separator and reset option
|
|
915
|
+
menu_lines.append(f"---\t{'─' * 50}")
|
|
916
|
+
if override_count > 0:
|
|
917
|
+
menu_lines.append(f"CLEAR\t✖ Reset all ({override_count} override{'s' if override_count != 1 else ''}) — restore theme defaults")
|
|
918
|
+
else:
|
|
919
|
+
menu_lines.append(f"CLEAR\t (no overrides)")
|
|
920
|
+
|
|
921
|
+
menu_input = "\n".join(menu_lines)
|
|
922
|
+
|
|
923
|
+
# Build preview content showing all elements with current colors
|
|
924
|
+
preview_content = _build_color_preview(defaults_file)
|
|
925
|
+
# Escape for shell
|
|
926
|
+
preview_escaped = preview_content.replace("'", "'\\''")
|
|
927
|
+
preview_cmd = f"echo '{preview_escaped}'"
|
|
928
|
+
|
|
929
|
+
fzf_args = [
|
|
930
|
+
"fzf",
|
|
931
|
+
"--no-multi",
|
|
932
|
+
"--no-sort",
|
|
933
|
+
"--no-info",
|
|
934
|
+
"--ansi",
|
|
935
|
+
"--height", "20",
|
|
936
|
+
"--layout", "reverse",
|
|
937
|
+
"--header", "Individual Colors (Enter=edit, ←/q=back)",
|
|
938
|
+
"--prompt", "Element: ",
|
|
939
|
+
"--with-nth", "2..",
|
|
940
|
+
"--delimiter", "\t",
|
|
941
|
+
"--bind", "q:abort",
|
|
942
|
+
"--bind", "left:abort",
|
|
943
|
+
"--preview", preview_cmd,
|
|
944
|
+
"--preview-window", "right:60%:noborder",
|
|
945
|
+
]
|
|
946
|
+
|
|
947
|
+
fzf_args.extend(get_fzf_preview_resize_bindings())
|
|
948
|
+
fzf_args.extend(get_fzf_color_args(defaults_file))
|
|
949
|
+
|
|
950
|
+
try:
|
|
951
|
+
result = subprocess.run(
|
|
952
|
+
fzf_args,
|
|
953
|
+
input=menu_input,
|
|
954
|
+
stdout=subprocess.PIPE,
|
|
955
|
+
text=True,
|
|
956
|
+
)
|
|
957
|
+
except FileNotFoundError:
|
|
958
|
+
return
|
|
959
|
+
|
|
960
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
961
|
+
return
|
|
962
|
+
|
|
963
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
964
|
+
|
|
965
|
+
if selected == "CLEAR":
|
|
966
|
+
clear_custom_colors(defaults_file)
|
|
967
|
+
elif selected == "---":
|
|
968
|
+
pass # Separator, do nothing
|
|
969
|
+
elif selected in FZF_COLOR_ELEMENTS:
|
|
970
|
+
_pick_color_for_element(selected, defaults_file)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _color_sample(color_val: str) -> str:
|
|
974
|
+
"""Generate ANSI color sample for a color value."""
|
|
975
|
+
if not color_val:
|
|
976
|
+
return " "
|
|
977
|
+
# Map named colors to ANSI codes
|
|
978
|
+
named_colors = {
|
|
979
|
+
"white": "37", "black": "30", "red": "31", "green": "32",
|
|
980
|
+
"yellow": "33", "blue": "34", "magenta": "35", "cyan": "36",
|
|
981
|
+
}
|
|
982
|
+
if color_val in named_colors:
|
|
983
|
+
return f"\033[{named_colors[color_val]}m■■■\033[0m"
|
|
984
|
+
elif color_val.isdigit():
|
|
985
|
+
return f"\033[38;5;{color_val}m■■■\033[0m"
|
|
986
|
+
return "■■■"
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _pick_color_for_element(element: str, defaults_file: str) -> Optional[str]:
|
|
990
|
+
"""Show color picker for a specific element."""
|
|
991
|
+
if not fzf_available():
|
|
992
|
+
return None
|
|
993
|
+
|
|
994
|
+
custom_colors = get_custom_colors(defaults_file)
|
|
995
|
+
current = custom_colors.get(element, "(default)")
|
|
996
|
+
|
|
997
|
+
# Build menu of colors
|
|
998
|
+
menu_lines = []
|
|
999
|
+
for name, value in COLOR_VALUES.items():
|
|
1000
|
+
marker = "● " if name == current else " "
|
|
1001
|
+
sample = _color_sample(value) if value else " "
|
|
1002
|
+
menu_lines.append(f"{name}\t{marker}{name:<14} {sample}")
|
|
1003
|
+
|
|
1004
|
+
menu_input = "\n".join(menu_lines)
|
|
1005
|
+
|
|
1006
|
+
# Build preview showing what changing this element would look like
|
|
1007
|
+
preview_content = _build_color_preview(defaults_file)
|
|
1008
|
+
preview_escaped = preview_content.replace("'", "'\\''")
|
|
1009
|
+
preview_cmd = f"echo '{preview_escaped}'"
|
|
1010
|
+
|
|
1011
|
+
desc = FZF_COLOR_ELEMENTS.get(element, element)
|
|
1012
|
+
fzf_args = [
|
|
1013
|
+
"fzf",
|
|
1014
|
+
"--no-multi",
|
|
1015
|
+
"--no-sort",
|
|
1016
|
+
"--no-info",
|
|
1017
|
+
"--ansi",
|
|
1018
|
+
"--height", "20",
|
|
1019
|
+
"--layout", "reverse",
|
|
1020
|
+
"--header", f"Select color for {element} ({desc}) ←/q=back",
|
|
1021
|
+
"--prompt", "Color: ",
|
|
1022
|
+
"--with-nth", "2..",
|
|
1023
|
+
"--delimiter", "\t",
|
|
1024
|
+
"--bind", "q:abort",
|
|
1025
|
+
"--bind", "left:abort",
|
|
1026
|
+
"--preview", preview_cmd,
|
|
1027
|
+
"--preview-window", "right:60%:noborder",
|
|
1028
|
+
]
|
|
1029
|
+
|
|
1030
|
+
fzf_args.extend(get_fzf_preview_resize_bindings())
|
|
1031
|
+
fzf_args.extend(get_fzf_color_args(defaults_file))
|
|
1032
|
+
|
|
1033
|
+
try:
|
|
1034
|
+
result = subprocess.run(
|
|
1035
|
+
fzf_args,
|
|
1036
|
+
input=menu_input,
|
|
1037
|
+
stdout=subprocess.PIPE,
|
|
1038
|
+
text=True,
|
|
1039
|
+
)
|
|
1040
|
+
except FileNotFoundError:
|
|
1041
|
+
return None
|
|
1042
|
+
|
|
1043
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1044
|
+
return None
|
|
1045
|
+
|
|
1046
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
1047
|
+
|
|
1048
|
+
if selected in COLOR_VALUES:
|
|
1049
|
+
set_custom_color(element, selected, defaults_file)
|
|
1050
|
+
return selected
|
|
1051
|
+
|
|
1052
|
+
return None
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
class GitRepo:
|
|
1056
|
+
"""
|
|
1057
|
+
Git repository wrapper providing consistent interface for git operations.
|
|
1058
|
+
|
|
1059
|
+
Usage:
|
|
1060
|
+
repo = GitRepo("/path/to/repo")
|
|
1061
|
+
branch = repo.current_branch()
|
|
1062
|
+
if repo.is_clean():
|
|
1063
|
+
repo.run(["pull", "--rebase"])
|
|
1064
|
+
"""
|
|
1065
|
+
|
|
1066
|
+
def __init__(self, path: str):
|
|
1067
|
+
"""Initialize with repository path."""
|
|
1068
|
+
self.path = path
|
|
1069
|
+
|
|
1070
|
+
def check_output(self, cmd: List[str], **kwargs) -> str:
|
|
1071
|
+
"""
|
|
1072
|
+
Run git command and return output.
|
|
1073
|
+
|
|
1074
|
+
Args:
|
|
1075
|
+
cmd: Git command arguments (without 'git' and '-C path')
|
|
1076
|
+
**kwargs: Additional arguments for subprocess.check_output
|
|
1077
|
+
|
|
1078
|
+
Returns:
|
|
1079
|
+
Command output as string (stripped)
|
|
1080
|
+
"""
|
|
1081
|
+
kwargs.setdefault('text', True)
|
|
1082
|
+
kwargs.setdefault('stderr', subprocess.DEVNULL)
|
|
1083
|
+
result = subprocess.check_output(
|
|
1084
|
+
["git", "-C", self.path] + cmd,
|
|
1085
|
+
**kwargs
|
|
1086
|
+
)
|
|
1087
|
+
return result.strip() if isinstance(result, str) else result
|
|
1088
|
+
|
|
1089
|
+
def run(self, cmd: List[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
|
|
1090
|
+
"""
|
|
1091
|
+
Run git command and return CompletedProcess.
|
|
1092
|
+
|
|
1093
|
+
Args:
|
|
1094
|
+
cmd: Git command arguments (without 'git' and '-C path')
|
|
1095
|
+
check: Raise on non-zero exit (default True)
|
|
1096
|
+
**kwargs: Additional arguments for subprocess.run
|
|
1097
|
+
|
|
1098
|
+
Returns:
|
|
1099
|
+
CompletedProcess instance
|
|
1100
|
+
"""
|
|
1101
|
+
kwargs.setdefault('text', True)
|
|
1102
|
+
return subprocess.run(
|
|
1103
|
+
["git", "-C", self.path] + cmd,
|
|
1104
|
+
check=check,
|
|
1105
|
+
**kwargs
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
def toplevel(self) -> Optional[str]:
|
|
1109
|
+
"""Get repository root path, resolved to canonical form."""
|
|
1110
|
+
try:
|
|
1111
|
+
out = self.check_output(["rev-parse", "--show-toplevel"])
|
|
1112
|
+
return os.path.realpath(out)
|
|
1113
|
+
except subprocess.CalledProcessError:
|
|
1114
|
+
return None
|
|
1115
|
+
|
|
1116
|
+
def current_branch(self) -> Optional[str]:
|
|
1117
|
+
"""Get current branch name, or None if detached."""
|
|
1118
|
+
try:
|
|
1119
|
+
return self.check_output(["symbolic-ref", "--short", "HEAD"])
|
|
1120
|
+
except subprocess.CalledProcessError:
|
|
1121
|
+
return None
|
|
1122
|
+
|
|
1123
|
+
def current_head(self) -> Optional[str]:
|
|
1124
|
+
"""Get current HEAD commit SHA."""
|
|
1125
|
+
try:
|
|
1126
|
+
return self.check_output(["rev-parse", "HEAD"])
|
|
1127
|
+
except subprocess.CalledProcessError:
|
|
1128
|
+
return None
|
|
1129
|
+
|
|
1130
|
+
def is_clean(self) -> bool:
|
|
1131
|
+
"""Check if repo has no uncommitted changes to tracked files."""
|
|
1132
|
+
try:
|
|
1133
|
+
result = self.run(
|
|
1134
|
+
["status", "--porcelain", "-uno"],
|
|
1135
|
+
check=False,
|
|
1136
|
+
capture_output=True
|
|
1137
|
+
)
|
|
1138
|
+
return result.returncode == 0 and not result.stdout.strip()
|
|
1139
|
+
except subprocess.CalledProcessError:
|
|
1140
|
+
return False
|
|
1141
|
+
|
|
1142
|
+
def origin_url(self) -> Optional[str]:
|
|
1143
|
+
"""Get origin remote URL."""
|
|
1144
|
+
try:
|
|
1145
|
+
return self.check_output(["remote", "get-url", "origin"])
|
|
1146
|
+
except subprocess.CalledProcessError:
|
|
1147
|
+
return None
|
|
1148
|
+
|
|
1149
|
+
def config_get(self, key: str) -> Optional[str]:
|
|
1150
|
+
"""Get git config value."""
|
|
1151
|
+
try:
|
|
1152
|
+
return self.check_output(["config", "--get", key])
|
|
1153
|
+
except subprocess.CalledProcessError:
|
|
1154
|
+
return None
|
|
1155
|
+
|
|
1156
|
+
def config_set(self, key: str, value: str) -> bool:
|
|
1157
|
+
"""Set git config value. Returns True on success."""
|
|
1158
|
+
try:
|
|
1159
|
+
self.run(["config", key, value], capture_output=True)
|
|
1160
|
+
return True
|
|
1161
|
+
except subprocess.CalledProcessError:
|
|
1162
|
+
return False
|
|
1163
|
+
|
|
1164
|
+
def rev_list_count(self, range_spec: str) -> int:
|
|
1165
|
+
"""Count commits in range."""
|
|
1166
|
+
try:
|
|
1167
|
+
out = self.check_output(["rev-list", "--count", range_spec])
|
|
1168
|
+
return int(out)
|
|
1169
|
+
except (subprocess.CalledProcessError, ValueError):
|
|
1170
|
+
return 0
|
|
1171
|
+
|
|
1172
|
+
def log(self, args: List[str]) -> str:
|
|
1173
|
+
"""Run git log with given arguments."""
|
|
1174
|
+
try:
|
|
1175
|
+
return self.check_output(["log"] + args)
|
|
1176
|
+
except subprocess.CalledProcessError:
|
|
1177
|
+
return ""
|
|
1178
|
+
|
|
1179
|
+
def diff_stat(self, range_spec: str) -> str:
|
|
1180
|
+
"""Get diff --stat for range."""
|
|
1181
|
+
try:
|
|
1182
|
+
return self.check_output(["diff", "--stat", range_spec])
|
|
1183
|
+
except subprocess.CalledProcessError:
|
|
1184
|
+
return ""
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
class FzfMenu:
|
|
1188
|
+
"""
|
|
1189
|
+
Builder for fzf menus.
|
|
1190
|
+
|
|
1191
|
+
Usage:
|
|
1192
|
+
menu = FzfMenu(header="Select an option", prompt="Choice: ")
|
|
1193
|
+
menu.add_option("opt1", "Option One", "Description of option one")
|
|
1194
|
+
menu.add_option("opt2", "Option Two", "Description of option two")
|
|
1195
|
+
menu.add_bind("ctrl-a", "select-all")
|
|
1196
|
+
selected = menu.show()
|
|
1197
|
+
"""
|
|
1198
|
+
|
|
1199
|
+
def __init__(
|
|
1200
|
+
self,
|
|
1201
|
+
header: str = "",
|
|
1202
|
+
prompt: str = "> ",
|
|
1203
|
+
multi: bool = False,
|
|
1204
|
+
height: str = "~50%",
|
|
1205
|
+
ansi: bool = True,
|
|
1206
|
+
no_sort: bool = False,
|
|
1207
|
+
):
|
|
1208
|
+
"""
|
|
1209
|
+
Initialize menu builder.
|
|
1210
|
+
|
|
1211
|
+
Args:
|
|
1212
|
+
header: Header text shown above menu
|
|
1213
|
+
prompt: Prompt text
|
|
1214
|
+
multi: Allow multiple selections
|
|
1215
|
+
height: fzf height (e.g., "~50%", "20")
|
|
1216
|
+
ansi: Enable ANSI color codes
|
|
1217
|
+
no_sort: Disable sorting (preserve order)
|
|
1218
|
+
"""
|
|
1219
|
+
self.header = header
|
|
1220
|
+
self.prompt = prompt
|
|
1221
|
+
self.multi = multi
|
|
1222
|
+
self.height = height
|
|
1223
|
+
self.ansi = ansi
|
|
1224
|
+
self.no_sort = no_sort
|
|
1225
|
+
self.options: List[Tuple[str, str]] = [] # (value, display_line)
|
|
1226
|
+
self.binds: List[Tuple[str, str]] = [] # (key, action)
|
|
1227
|
+
self.extra_args: List[str] = []
|
|
1228
|
+
|
|
1229
|
+
def add_option(self, value: str, display: str = "", description: str = "") -> "FzfMenu":
|
|
1230
|
+
"""
|
|
1231
|
+
Add menu option.
|
|
1232
|
+
|
|
1233
|
+
Args:
|
|
1234
|
+
value: Value returned when selected (first field before tab)
|
|
1235
|
+
display: Display text (if empty, uses value)
|
|
1236
|
+
description: Optional description appended after display
|
|
1237
|
+
|
|
1238
|
+
Returns:
|
|
1239
|
+
self for chaining
|
|
1240
|
+
"""
|
|
1241
|
+
if display:
|
|
1242
|
+
if description:
|
|
1243
|
+
line = f"{value}\t{display}\t{description}"
|
|
1244
|
+
else:
|
|
1245
|
+
line = f"{value}\t{display}"
|
|
1246
|
+
else:
|
|
1247
|
+
line = value
|
|
1248
|
+
self.options.append((value, line))
|
|
1249
|
+
return self
|
|
1250
|
+
|
|
1251
|
+
def add_line(self, line: str) -> "FzfMenu":
|
|
1252
|
+
"""Add raw line to menu (for separators, headers, etc.)."""
|
|
1253
|
+
self.options.append(("", line))
|
|
1254
|
+
return self
|
|
1255
|
+
|
|
1256
|
+
def add_bind(self, key: str, action: str) -> "FzfMenu":
|
|
1257
|
+
"""Add key binding."""
|
|
1258
|
+
self.binds.append((key, action))
|
|
1259
|
+
return self
|
|
1260
|
+
|
|
1261
|
+
def add_arg(self, *args: str) -> "FzfMenu":
|
|
1262
|
+
"""Add extra fzf arguments."""
|
|
1263
|
+
self.extra_args.extend(args)
|
|
1264
|
+
return self
|
|
1265
|
+
|
|
1266
|
+
def show(self) -> Optional[Union[str, List[str]]]:
|
|
1267
|
+
"""
|
|
1268
|
+
Show the menu and return selection.
|
|
1269
|
+
|
|
1270
|
+
Returns:
|
|
1271
|
+
- Single string if multi=False
|
|
1272
|
+
- List of strings if multi=True
|
|
1273
|
+
- None if cancelled or fzf not available
|
|
1274
|
+
"""
|
|
1275
|
+
if not fzf_available():
|
|
1276
|
+
return None
|
|
1277
|
+
|
|
1278
|
+
cmd = ["fzf"]
|
|
1279
|
+
|
|
1280
|
+
if self.multi:
|
|
1281
|
+
cmd.append("--multi")
|
|
1282
|
+
else:
|
|
1283
|
+
cmd.append("--no-multi")
|
|
1284
|
+
|
|
1285
|
+
if self.header:
|
|
1286
|
+
cmd.extend(["--header", self.header])
|
|
1287
|
+
|
|
1288
|
+
if self.prompt:
|
|
1289
|
+
cmd.extend(["--prompt", self.prompt])
|
|
1290
|
+
|
|
1291
|
+
if self.height:
|
|
1292
|
+
cmd.extend(["--height", self.height])
|
|
1293
|
+
|
|
1294
|
+
if self.ansi:
|
|
1295
|
+
cmd.append("--ansi")
|
|
1296
|
+
|
|
1297
|
+
if self.no_sort:
|
|
1298
|
+
cmd.append("--no-sort")
|
|
1299
|
+
|
|
1300
|
+
# Use first field as selection value
|
|
1301
|
+
cmd.extend(["--with-nth", "2.."])
|
|
1302
|
+
cmd.extend(["--delimiter", "\t"])
|
|
1303
|
+
|
|
1304
|
+
for key, action in self.binds:
|
|
1305
|
+
cmd.extend(["--bind", f"{key}:{action}"])
|
|
1306
|
+
|
|
1307
|
+
cmd.extend(self.extra_args)
|
|
1308
|
+
|
|
1309
|
+
# Add theme colors
|
|
1310
|
+
cmd.extend(get_fzf_color_args())
|
|
1311
|
+
|
|
1312
|
+
menu_input = "\n".join(line for _, line in self.options)
|
|
1313
|
+
|
|
1314
|
+
try:
|
|
1315
|
+
result = subprocess.run(
|
|
1316
|
+
cmd,
|
|
1317
|
+
input=menu_input,
|
|
1318
|
+
capture_output=True,
|
|
1319
|
+
text=True,
|
|
1320
|
+
)
|
|
1321
|
+
except FileNotFoundError:
|
|
1322
|
+
return None
|
|
1323
|
+
|
|
1324
|
+
if result.returncode != 0:
|
|
1325
|
+
return None
|
|
1326
|
+
|
|
1327
|
+
output = result.stdout.strip()
|
|
1328
|
+
if not output:
|
|
1329
|
+
return None
|
|
1330
|
+
|
|
1331
|
+
if self.multi:
|
|
1332
|
+
# Return list of first fields (values)
|
|
1333
|
+
selections = []
|
|
1334
|
+
for line in output.split("\n"):
|
|
1335
|
+
parts = line.split("\t")
|
|
1336
|
+
selections.append(parts[0] if parts else line)
|
|
1337
|
+
return selections
|
|
1338
|
+
else:
|
|
1339
|
+
# Return first field (value)
|
|
1340
|
+
parts = output.split("\t")
|
|
1341
|
+
return parts[0] if parts else output
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def fzf_available() -> bool:
|
|
1345
|
+
"""Check if fzf is available."""
|
|
1346
|
+
return shutil.which("fzf") is not None
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def parse_help_options(script_path: str, cmd: str) -> List[Tuple[str, str]]:
|
|
1350
|
+
"""
|
|
1351
|
+
Parse options from a command's --help output.
|
|
1352
|
+
|
|
1353
|
+
Args:
|
|
1354
|
+
script_path: Path to the script executable
|
|
1355
|
+
cmd: Command name (e.g., "status", "init clone")
|
|
1356
|
+
|
|
1357
|
+
Returns:
|
|
1358
|
+
List of (option_flag, description) tuples.
|
|
1359
|
+
e.g., [("--verbose", "Show verbose output"), ("-v", "Verbose")]
|
|
1360
|
+
"""
|
|
1361
|
+
try:
|
|
1362
|
+
# Build command: script_path cmd --help
|
|
1363
|
+
cmd_parts = [script_path] + cmd.split() + ["--help"]
|
|
1364
|
+
result = subprocess.run(
|
|
1365
|
+
cmd_parts,
|
|
1366
|
+
capture_output=True,
|
|
1367
|
+
text=True,
|
|
1368
|
+
timeout=5,
|
|
1369
|
+
)
|
|
1370
|
+
help_text = result.stdout + result.stderr
|
|
1371
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
1372
|
+
return []
|
|
1373
|
+
|
|
1374
|
+
options: List[Tuple[str, str]] = []
|
|
1375
|
+
in_options_section = False
|
|
1376
|
+
current_option = None
|
|
1377
|
+
current_desc_lines: List[str] = []
|
|
1378
|
+
|
|
1379
|
+
for line in help_text.split("\n"):
|
|
1380
|
+
# Detect start of options section
|
|
1381
|
+
if line.strip().lower() in ("options:", "optional arguments:"):
|
|
1382
|
+
in_options_section = True
|
|
1383
|
+
continue
|
|
1384
|
+
|
|
1385
|
+
# Detect end of options section (new section or end)
|
|
1386
|
+
if in_options_section and line and not line.startswith(" ") and not line.startswith("\t"):
|
|
1387
|
+
# Save any pending option
|
|
1388
|
+
if current_option and current_option not in ("-h", "--help", "-h, --help"):
|
|
1389
|
+
desc = " ".join(current_desc_lines).strip()
|
|
1390
|
+
# Remove default value info for cleaner display
|
|
1391
|
+
if "(default:" in desc:
|
|
1392
|
+
desc = desc[:desc.rfind("(default:")].strip()
|
|
1393
|
+
options.append((current_option, desc))
|
|
1394
|
+
in_options_section = False
|
|
1395
|
+
current_option = None
|
|
1396
|
+
current_desc_lines = []
|
|
1397
|
+
continue
|
|
1398
|
+
|
|
1399
|
+
if not in_options_section:
|
|
1400
|
+
continue
|
|
1401
|
+
|
|
1402
|
+
# Parse option lines
|
|
1403
|
+
stripped = line.strip()
|
|
1404
|
+
if not stripped:
|
|
1405
|
+
continue
|
|
1406
|
+
|
|
1407
|
+
# Check if this is a new option line (starts with -)
|
|
1408
|
+
if stripped.startswith("-"):
|
|
1409
|
+
# Save previous option if exists
|
|
1410
|
+
if current_option and current_option not in ("-h", "--help", "-h, --help"):
|
|
1411
|
+
desc = " ".join(current_desc_lines).strip()
|
|
1412
|
+
if "(default:" in desc:
|
|
1413
|
+
desc = desc[:desc.rfind("(default:")].strip()
|
|
1414
|
+
options.append((current_option, desc))
|
|
1415
|
+
|
|
1416
|
+
# Parse new option
|
|
1417
|
+
# Format: "-v, --verbose Description" or "--path PATH Description"
|
|
1418
|
+
parts = stripped.split()
|
|
1419
|
+
opt_parts = []
|
|
1420
|
+
desc_start = 0
|
|
1421
|
+
for i, part in enumerate(parts):
|
|
1422
|
+
if part.startswith("-"):
|
|
1423
|
+
opt_parts.append(part.rstrip(","))
|
|
1424
|
+
elif part.isupper() and i > 0:
|
|
1425
|
+
# This is likely an argument placeholder like PATH
|
|
1426
|
+
continue
|
|
1427
|
+
else:
|
|
1428
|
+
desc_start = i
|
|
1429
|
+
break
|
|
1430
|
+
|
|
1431
|
+
# Prefer long option if available
|
|
1432
|
+
current_option = opt_parts[-1] if opt_parts else None
|
|
1433
|
+
# For combined short/long like "-v, --verbose", show both
|
|
1434
|
+
if len(opt_parts) > 1:
|
|
1435
|
+
current_option = ", ".join(opt_parts)
|
|
1436
|
+
|
|
1437
|
+
current_desc_lines = [" ".join(parts[desc_start:])] if desc_start > 0 else []
|
|
1438
|
+
else:
|
|
1439
|
+
# Continuation of description
|
|
1440
|
+
current_desc_lines.append(stripped)
|
|
1441
|
+
|
|
1442
|
+
# Don't forget the last option
|
|
1443
|
+
if current_option and current_option not in ("-h", "--help", "-h, --help"):
|
|
1444
|
+
desc = " ".join(current_desc_lines).strip()
|
|
1445
|
+
if "(default:" in desc:
|
|
1446
|
+
desc = desc[:desc.rfind("(default:")].strip()
|
|
1447
|
+
options.append((current_option, desc))
|
|
1448
|
+
|
|
1449
|
+
return options
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def fzf_expandable_menu(
|
|
1453
|
+
commands: List[Tuple[str, str, List[Tuple[str, str]]]],
|
|
1454
|
+
header: str = "Enter=select | \\=expand/collapse | q=quit",
|
|
1455
|
+
prompt: str = "> ",
|
|
1456
|
+
height: str = "~80%",
|
|
1457
|
+
preview_cmd: Optional[str] = None,
|
|
1458
|
+
preview_window: str = "right,60%,wrap",
|
|
1459
|
+
options_provider: Optional[Callable[[str], List[Tuple[str, str]]]] = None,
|
|
1460
|
+
categories: Optional[Dict[str, str]] = None,
|
|
1461
|
+
sort_key: Optional[str] = None,
|
|
1462
|
+
initial_selection: Optional[str] = None,
|
|
1463
|
+
) -> Optional[Union[str, Tuple[str, str]]]:
|
|
1464
|
+
"""
|
|
1465
|
+
Show an fzf menu with expandable subcommands.
|
|
1466
|
+
|
|
1467
|
+
Args:
|
|
1468
|
+
commands: List of (cmd, description, [(subcmd, subdesc), ...]) tuples.
|
|
1469
|
+
Commands should be in display order (alphabetical).
|
|
1470
|
+
header: Header text shown in fzf
|
|
1471
|
+
prompt: Prompt text
|
|
1472
|
+
height: fzf height setting
|
|
1473
|
+
preview_cmd: Optional shell command for preview pane ({1} = selected value)
|
|
1474
|
+
preview_window: Preview window position/size if preview_cmd is set
|
|
1475
|
+
options_provider: Optional function that takes a command name and returns
|
|
1476
|
+
a list of (option, description) tuples for that command.
|
|
1477
|
+
Used for verbose mode (v key) to show command options.
|
|
1478
|
+
categories: Optional dict mapping command names to category header strings.
|
|
1479
|
+
If a command has a category, a separator header is shown before it.
|
|
1480
|
+
Example: {"explore": "Git/Repository", "config": "Configuration"}
|
|
1481
|
+
sort_key: Optional key binding that triggers sort mode cycling.
|
|
1482
|
+
When pressed, returns ("SORT", current_selection) tuple.
|
|
1483
|
+
initial_selection: Optional command name to position cursor on initially.
|
|
1484
|
+
|
|
1485
|
+
Returns:
|
|
1486
|
+
- Selected command string on Enter
|
|
1487
|
+
- ("SORT", current_selection) tuple when sort_key is pressed
|
|
1488
|
+
- None if cancelled
|
|
1489
|
+
"""
|
|
1490
|
+
if not fzf_available():
|
|
1491
|
+
return None
|
|
1492
|
+
|
|
1493
|
+
expanded_cmds: Set[str] = set()
|
|
1494
|
+
expanded_opts: Set[str] = set() # Commands with options expanded
|
|
1495
|
+
options_cache: Dict[str, List[Tuple[str, str]]] = {} # Cache parsed options
|
|
1496
|
+
current_selection: Optional[str] = initial_selection
|
|
1497
|
+
preview_hidden: bool = False
|
|
1498
|
+
|
|
1499
|
+
while True:
|
|
1500
|
+
# Build menu lines
|
|
1501
|
+
menu_lines: List[str] = []
|
|
1502
|
+
|
|
1503
|
+
# Calculate max length including subcommands and options
|
|
1504
|
+
all_cmds = []
|
|
1505
|
+
for cmd, desc, subs in commands:
|
|
1506
|
+
all_cmds.append(cmd)
|
|
1507
|
+
if cmd in expanded_cmds or cmd in expanded_opts:
|
|
1508
|
+
for subcmd, _ in subs:
|
|
1509
|
+
all_cmds.append(subcmd)
|
|
1510
|
+
if cmd in expanded_opts and options_provider:
|
|
1511
|
+
if cmd not in options_cache:
|
|
1512
|
+
options_cache[cmd] = options_provider(cmd)
|
|
1513
|
+
for opt, _ in options_cache.get(cmd, []):
|
|
1514
|
+
all_cmds.append(opt)
|
|
1515
|
+
# Also include subcommand options in length calculation
|
|
1516
|
+
for subcmd, _ in subs:
|
|
1517
|
+
if subcmd not in options_cache:
|
|
1518
|
+
options_cache[subcmd] = options_provider(subcmd)
|
|
1519
|
+
for opt, _ in options_cache.get(subcmd, []):
|
|
1520
|
+
all_cmds.append(opt)
|
|
1521
|
+
max_cmd_len = max(len(c) for c in all_cmds) if all_cmds else 12
|
|
1522
|
+
|
|
1523
|
+
for cmd, desc, subs in commands:
|
|
1524
|
+
# Check if this command starts a new category
|
|
1525
|
+
if categories and cmd in categories:
|
|
1526
|
+
cat_label = categories[cmd]
|
|
1527
|
+
# Add category separator (non-selectable via SEPARATOR key)
|
|
1528
|
+
separator_line = f"SEPARATOR\t\033[1;34m── {cat_label} ──\033[0m"
|
|
1529
|
+
menu_lines.append(separator_line)
|
|
1530
|
+
has_subs = len(subs) > 0
|
|
1531
|
+
is_expanded = cmd in expanded_cmds
|
|
1532
|
+
has_opts_expanded = cmd in expanded_opts
|
|
1533
|
+
|
|
1534
|
+
# Prefix: + for expandable, - for expanded, space otherwise
|
|
1535
|
+
if has_subs:
|
|
1536
|
+
prefix = "- " if (is_expanded or has_opts_expanded) else "+ "
|
|
1537
|
+
else:
|
|
1538
|
+
prefix = " "
|
|
1539
|
+
|
|
1540
|
+
colored_cmd = f"\033[36m{cmd:<{max_cmd_len}}\033[0m"
|
|
1541
|
+
menu_lines.append(f"{cmd}\t{prefix}{colored_cmd} {desc}")
|
|
1542
|
+
|
|
1543
|
+
# Build tree structure for sub-items
|
|
1544
|
+
# Level 1: parent's options + subcommands (direct children)
|
|
1545
|
+
# Level 2: subcommand's options (children of subcommands)
|
|
1546
|
+
|
|
1547
|
+
level1_items: List[Tuple[str, str, List[Tuple[str, str]]]] = []
|
|
1548
|
+
# (key, display, children) where children are level2 items
|
|
1549
|
+
|
|
1550
|
+
# Add parent's options first (if v was pressed) - no children
|
|
1551
|
+
if has_opts_expanded and options_provider:
|
|
1552
|
+
if cmd not in options_cache:
|
|
1553
|
+
options_cache[cmd] = options_provider(cmd)
|
|
1554
|
+
for opt, optdesc in options_cache.get(cmd, []):
|
|
1555
|
+
colored_opt = f"\033[33m{opt:<{max_cmd_len}}\033[0m" # Yellow
|
|
1556
|
+
level1_items.append((opt, f"{colored_opt} {optdesc}", []))
|
|
1557
|
+
|
|
1558
|
+
# Add subcommands (if \ or v was pressed)
|
|
1559
|
+
if is_expanded or has_opts_expanded:
|
|
1560
|
+
for subcmd, subdesc in subs:
|
|
1561
|
+
colored_subcmd = f"\033[36m{subcmd:<{max_cmd_len}}\033[0m"
|
|
1562
|
+
children: List[Tuple[str, str]] = []
|
|
1563
|
+
|
|
1564
|
+
# If verbose mode, collect subcommand's options as children
|
|
1565
|
+
if has_opts_expanded and options_provider:
|
|
1566
|
+
if subcmd not in options_cache:
|
|
1567
|
+
options_cache[subcmd] = options_provider(subcmd)
|
|
1568
|
+
for opt, optdesc in options_cache.get(subcmd, []):
|
|
1569
|
+
colored_opt = f"\033[33m{opt:<{max_cmd_len}}\033[0m"
|
|
1570
|
+
children.append((opt, f"{colored_opt} {optdesc}"))
|
|
1571
|
+
|
|
1572
|
+
level1_items.append((subcmd, f"{colored_subcmd} {subdesc}", children))
|
|
1573
|
+
|
|
1574
|
+
# Render tree with proper connectors
|
|
1575
|
+
for i, (key, display, children) in enumerate(level1_items):
|
|
1576
|
+
is_last_l1 = (i == len(level1_items) - 1)
|
|
1577
|
+
tree_char = "└─" if is_last_l1 else "├─"
|
|
1578
|
+
menu_lines.append(f"{key}\t {tree_char} {display}")
|
|
1579
|
+
|
|
1580
|
+
# Render children (level 2)
|
|
1581
|
+
for j, (child_key, child_display) in enumerate(children):
|
|
1582
|
+
is_last_l2 = (j == len(children) - 1)
|
|
1583
|
+
# Continuation line: │ if parent wasn't last, space if it was
|
|
1584
|
+
continuation = " " if is_last_l1 else "│ "
|
|
1585
|
+
child_tree = "└─" if is_last_l2 else "├─"
|
|
1586
|
+
menu_lines.append(f"{child_key}\t {continuation} {child_tree} {child_display}")
|
|
1587
|
+
|
|
1588
|
+
menu_input = "\n".join(menu_lines)
|
|
1589
|
+
|
|
1590
|
+
# Build fzf command
|
|
1591
|
+
fzf_args = [
|
|
1592
|
+
"fzf",
|
|
1593
|
+
"--no-multi",
|
|
1594
|
+
"--no-sort",
|
|
1595
|
+
"--no-info",
|
|
1596
|
+
"--ansi",
|
|
1597
|
+
"--sync", # Ensure input is fully loaded before start event fires
|
|
1598
|
+
"--layout=reverse-list",
|
|
1599
|
+
"--height", height,
|
|
1600
|
+
"--header", header,
|
|
1601
|
+
"--prompt", prompt,
|
|
1602
|
+
"--with-nth", "2..",
|
|
1603
|
+
"--delimiter", "\t",
|
|
1604
|
+
"--bind", "q:abort",
|
|
1605
|
+
]
|
|
1606
|
+
|
|
1607
|
+
# Apply theme colors
|
|
1608
|
+
theme_colors = get_fzf_theme()
|
|
1609
|
+
if theme_colors:
|
|
1610
|
+
fzf_args.extend(["--color", theme_colors])
|
|
1611
|
+
|
|
1612
|
+
# Build expected keys list
|
|
1613
|
+
expect_keys = ["\\"] # backslash for subcommand expand/collapse
|
|
1614
|
+
if options_provider:
|
|
1615
|
+
expect_keys.append("v") # v for options expand/collapse
|
|
1616
|
+
if preview_cmd:
|
|
1617
|
+
expect_keys.append("?") # ? for preview toggle
|
|
1618
|
+
if sort_key:
|
|
1619
|
+
expect_keys.append(sort_key) # for sort mode cycling
|
|
1620
|
+
|
|
1621
|
+
# Add preview if specified
|
|
1622
|
+
if preview_cmd:
|
|
1623
|
+
window = f"{preview_window},hidden" if preview_hidden else preview_window
|
|
1624
|
+
fzf_args.extend([
|
|
1625
|
+
"--preview", preview_cmd,
|
|
1626
|
+
"--preview-window", window,
|
|
1627
|
+
"--bind", "ctrl-d:preview-half-page-down",
|
|
1628
|
+
"--bind", "ctrl-u:preview-half-page-up",
|
|
1629
|
+
"--bind", "page-down:preview-page-down",
|
|
1630
|
+
"--bind", "page-up:preview-page-up",
|
|
1631
|
+
])
|
|
1632
|
+
fzf_args.extend(get_fzf_preview_resize_bindings())
|
|
1633
|
+
|
|
1634
|
+
fzf_args.extend(["--expect", ",".join(expect_keys)])
|
|
1635
|
+
|
|
1636
|
+
# Position cursor on previously selected item after expand/collapse
|
|
1637
|
+
if current_selection:
|
|
1638
|
+
for i, line in enumerate(menu_lines):
|
|
1639
|
+
if line.startswith(current_selection + '\t'):
|
|
1640
|
+
fzf_args.extend(["--bind", f"start:pos({i + 1})"])
|
|
1641
|
+
break
|
|
1642
|
+
|
|
1643
|
+
try:
|
|
1644
|
+
result = subprocess.run(
|
|
1645
|
+
fzf_args,
|
|
1646
|
+
input=menu_input,
|
|
1647
|
+
stdout=subprocess.PIPE,
|
|
1648
|
+
text=True,
|
|
1649
|
+
)
|
|
1650
|
+
except FileNotFoundError:
|
|
1651
|
+
return None
|
|
1652
|
+
|
|
1653
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1654
|
+
return None
|
|
1655
|
+
|
|
1656
|
+
# Don't strip() before split - first line is empty when Enter pressed
|
|
1657
|
+
lines = result.stdout.split("\n")
|
|
1658
|
+
key_pressed = lines[0] if lines else ""
|
|
1659
|
+
selection = lines[1].strip() if len(lines) > 1 else ""
|
|
1660
|
+
|
|
1661
|
+
if not selection:
|
|
1662
|
+
return None
|
|
1663
|
+
|
|
1664
|
+
selected_cmd = selection.split("\t")[0]
|
|
1665
|
+
|
|
1666
|
+
# Handle sort mode cycling first (works on any line including separators)
|
|
1667
|
+
if sort_key and key_pressed == sort_key:
|
|
1668
|
+
# Return special tuple to signal sort mode change request
|
|
1669
|
+
# Use a valid command for cursor positioning, not SEPARATOR
|
|
1670
|
+
cursor_cmd = selected_cmd if selected_cmd != "SEPARATOR" else None
|
|
1671
|
+
return ("SORT", cursor_cmd)
|
|
1672
|
+
|
|
1673
|
+
# Handle preview toggle with ? (works on any line)
|
|
1674
|
+
if preview_cmd and key_pressed == "?":
|
|
1675
|
+
preview_hidden = not preview_hidden
|
|
1676
|
+
if selected_cmd != "SEPARATOR":
|
|
1677
|
+
current_selection = selected_cmd
|
|
1678
|
+
continue
|
|
1679
|
+
|
|
1680
|
+
# Skip category separator lines - they're not selectable for other actions
|
|
1681
|
+
if selected_cmd == "SEPARATOR":
|
|
1682
|
+
continue
|
|
1683
|
+
|
|
1684
|
+
# Handle expand/collapse subcommands with backslash
|
|
1685
|
+
if key_pressed == "\\":
|
|
1686
|
+
for cmd, _, subs in commands:
|
|
1687
|
+
if cmd == selected_cmd and subs:
|
|
1688
|
+
if cmd in expanded_cmds:
|
|
1689
|
+
expanded_cmds.discard(cmd)
|
|
1690
|
+
else:
|
|
1691
|
+
expanded_cmds.add(cmd)
|
|
1692
|
+
current_selection = cmd
|
|
1693
|
+
break
|
|
1694
|
+
continue
|
|
1695
|
+
|
|
1696
|
+
# Handle expand/collapse options with v (verbose)
|
|
1697
|
+
if key_pressed == "v" and options_provider:
|
|
1698
|
+
# Only allow options on top-level commands
|
|
1699
|
+
for cmd, _, _ in commands:
|
|
1700
|
+
if cmd == selected_cmd:
|
|
1701
|
+
if cmd in expanded_opts:
|
|
1702
|
+
expanded_opts.discard(cmd)
|
|
1703
|
+
else:
|
|
1704
|
+
expanded_opts.add(cmd)
|
|
1705
|
+
current_selection = cmd
|
|
1706
|
+
break
|
|
1707
|
+
continue
|
|
1708
|
+
|
|
1709
|
+
# Regular Enter - return the selected command
|
|
1710
|
+
return selected_cmd
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
# =============================================================================
|
|
1714
|
+
# State Management
|
|
1715
|
+
# =============================================================================
|
|
1716
|
+
|
|
1717
|
+
def load_defaults(defaults_file: str) -> dict:
|
|
1718
|
+
"""Load defaults from JSON file."""
|
|
1719
|
+
if not os.path.exists(defaults_file):
|
|
1720
|
+
return {}
|
|
1721
|
+
try:
|
|
1722
|
+
with open(defaults_file, encoding="utf-8") as f:
|
|
1723
|
+
data = json.load(f)
|
|
1724
|
+
if isinstance(data, dict):
|
|
1725
|
+
# Convert repo defaults to strings, but preserve special keys
|
|
1726
|
+
result = {}
|
|
1727
|
+
for k, v in data.items():
|
|
1728
|
+
if k in ("__extra_repos__", "__hidden_repos__"):
|
|
1729
|
+
result[k] = v if isinstance(v, list) else []
|
|
1730
|
+
elif k == "__push_targets__":
|
|
1731
|
+
result[k] = v if isinstance(v, dict) else {}
|
|
1732
|
+
else:
|
|
1733
|
+
result[str(k)] = str(v)
|
|
1734
|
+
return result
|
|
1735
|
+
except Exception:
|
|
1736
|
+
pass
|
|
1737
|
+
return {}
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def save_defaults(defaults_file: str, defaults: dict) -> None:
|
|
1741
|
+
"""Save defaults to JSON file."""
|
|
1742
|
+
with open(defaults_file, "w", encoding="utf-8") as f:
|
|
1743
|
+
json.dump(defaults, f, indent=2, sort_keys=True)
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def load_prep_state(path: str) -> Optional[Dict]:
|
|
1747
|
+
"""Load prep state from JSON file. Returns None if not found."""
|
|
1748
|
+
if not os.path.exists(path):
|
|
1749
|
+
return None
|
|
1750
|
+
try:
|
|
1751
|
+
with open(path, encoding="utf-8") as f:
|
|
1752
|
+
data = json.load(f)
|
|
1753
|
+
if isinstance(data, dict) and "repos" in data:
|
|
1754
|
+
return data
|
|
1755
|
+
except Exception:
|
|
1756
|
+
pass
|
|
1757
|
+
return None
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def save_prep_state(path: str, repos_data: Dict) -> None:
|
|
1761
|
+
"""Save prep state to JSON file."""
|
|
1762
|
+
state = {
|
|
1763
|
+
"timestamp": datetime.now().isoformat(),
|
|
1764
|
+
"repos": repos_data
|
|
1765
|
+
}
|
|
1766
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1767
|
+
json.dump(state, f, indent=2)
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def load_export_state(path: str) -> Dict[str, Dict[str, str]]:
|
|
1771
|
+
"""Load export state from JSON file."""
|
|
1772
|
+
if not os.path.exists(path):
|
|
1773
|
+
return {}
|
|
1774
|
+
try:
|
|
1775
|
+
with open(path, encoding="utf-8") as f:
|
|
1776
|
+
data = json.load(f)
|
|
1777
|
+
if isinstance(data, dict):
|
|
1778
|
+
return data
|
|
1779
|
+
except Exception:
|
|
1780
|
+
pass
|
|
1781
|
+
return {}
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def save_export_state(path: str, state: Dict[str, Dict[str, str]]) -> None:
|
|
1785
|
+
"""Save export state to JSON file."""
|
|
1786
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1787
|
+
json.dump(state, f, indent=2, sort_keys=True)
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
# =============================================================================
|
|
1791
|
+
# Standalone helper functions (for backward compatibility)
|
|
1792
|
+
# =============================================================================
|
|
1793
|
+
|
|
1794
|
+
def git_toplevel(path: str) -> Optional[str]:
|
|
1795
|
+
"""Get the git repository root for a path, resolved to canonical form."""
|
|
1796
|
+
return GitRepo(path).toplevel()
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def current_branch(repo: str) -> Optional[str]:
|
|
1800
|
+
"""Get current branch name for repo."""
|
|
1801
|
+
return GitRepo(repo).current_branch()
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def current_head(repo: str) -> Optional[str]:
|
|
1805
|
+
"""Get current HEAD commit SHA for repo."""
|
|
1806
|
+
return GitRepo(repo).current_head()
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
def repo_is_clean(repo: str) -> bool:
|
|
1810
|
+
"""Check if repo has no uncommitted changes to tracked files."""
|
|
1811
|
+
return GitRepo(repo).is_clean()
|