bitp 1.0.7__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.7.dist-info/METADATA +401 -0
- bitp-1.0.7.dist-info/RECORD +24 -0
- bitp-1.0.7.dist-info/WHEEL +5 -0
- bitp-1.0.7.dist-info/entry_points.txt +3 -0
- bitp-1.0.7.dist-info/licenses/COPYING +338 -0
- bitp-1.0.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1505 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
5
|
+
#
|
|
6
|
+
"""Projects command - manage multiple bit working directories."""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from ..core import Colors, get_fzf_color_args, fzf_available
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_projects_file() -> str:
|
|
19
|
+
"""Get path to global projects config file."""
|
|
20
|
+
config_dir = os.path.expanduser("~/.config/bit")
|
|
21
|
+
os.makedirs(config_dir, exist_ok=True)
|
|
22
|
+
return os.path.join(config_dir, "projects.json")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_projects() -> Dict[str, dict]:
|
|
26
|
+
"""
|
|
27
|
+
Load projects from config file.
|
|
28
|
+
|
|
29
|
+
Returns dict mapping path -> {name, description, last_used}.
|
|
30
|
+
Excludes internal keys like __current_project__, __directory_browser__, etc.
|
|
31
|
+
"""
|
|
32
|
+
projects_file = _get_projects_file()
|
|
33
|
+
if not os.path.isfile(projects_file):
|
|
34
|
+
return {}
|
|
35
|
+
try:
|
|
36
|
+
with open(projects_file, "r") as f:
|
|
37
|
+
data = json.load(f)
|
|
38
|
+
# Filter out internal keys (start with __)
|
|
39
|
+
return {k: v for k, v in data.items() if not k.startswith("__")}
|
|
40
|
+
except (json.JSONDecodeError, OSError):
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_current_project() -> Optional[str]:
|
|
45
|
+
"""Get the currently active project path, or None if not set."""
|
|
46
|
+
config_file = _get_projects_file()
|
|
47
|
+
if not os.path.isfile(config_file):
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
with open(config_file, "r") as f:
|
|
51
|
+
data = json.load(f)
|
|
52
|
+
path = data.get("__current_project__")
|
|
53
|
+
# Verify it still exists
|
|
54
|
+
if path and os.path.isdir(path):
|
|
55
|
+
return path
|
|
56
|
+
return None
|
|
57
|
+
except (json.JSONDecodeError, OSError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_current_project(path: Optional[str]) -> None:
|
|
62
|
+
"""Set the currently active project path (or None to clear)."""
|
|
63
|
+
config_file = _get_projects_file()
|
|
64
|
+
data = {}
|
|
65
|
+
if os.path.isfile(config_file):
|
|
66
|
+
try:
|
|
67
|
+
with open(config_file, "r") as f:
|
|
68
|
+
data = json.load(f)
|
|
69
|
+
except (json.JSONDecodeError, OSError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
if path:
|
|
73
|
+
data["__current_project__"] = os.path.abspath(path)
|
|
74
|
+
elif "__current_project__" in data:
|
|
75
|
+
del data["__current_project__"]
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
with open(config_file, "w") as f:
|
|
79
|
+
json.dump(data, f, indent=2)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_projects(projects: Dict[str, dict]) -> None:
|
|
85
|
+
"""Save projects to config file."""
|
|
86
|
+
projects_file = _get_projects_file()
|
|
87
|
+
try:
|
|
88
|
+
with open(projects_file, "w") as f:
|
|
89
|
+
json.dump(projects, f, indent=2)
|
|
90
|
+
except OSError as e:
|
|
91
|
+
print(f"Warning: Could not save projects: {e}", file=sys.stderr)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def add_project(path: str, name: Optional[str] = None, description: str = "") -> bool:
|
|
95
|
+
"""
|
|
96
|
+
Add a project to the list.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
path: Absolute path to the project directory
|
|
100
|
+
name: Display name (defaults to directory basename)
|
|
101
|
+
description: Optional description
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if added, False if already exists or invalid
|
|
105
|
+
"""
|
|
106
|
+
path = os.path.abspath(path)
|
|
107
|
+
if not os.path.isdir(path):
|
|
108
|
+
print(f"Error: Not a directory: {path}", file=sys.stderr)
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
projects = load_projects()
|
|
112
|
+
if path in projects:
|
|
113
|
+
print(f"Project already registered: {path}")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
if not name:
|
|
117
|
+
name = os.path.basename(path)
|
|
118
|
+
|
|
119
|
+
projects[path] = {
|
|
120
|
+
"name": name,
|
|
121
|
+
"description": description,
|
|
122
|
+
"last_used": None,
|
|
123
|
+
}
|
|
124
|
+
save_projects(projects)
|
|
125
|
+
print(f"Added project: {name} ({path})")
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def remove_project(path: str) -> bool:
|
|
130
|
+
"""Remove a project from the list."""
|
|
131
|
+
path = os.path.abspath(path)
|
|
132
|
+
projects = load_projects()
|
|
133
|
+
if path not in projects:
|
|
134
|
+
print(f"Project not found: {path}", file=sys.stderr)
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
name = projects[path].get("name", path)
|
|
138
|
+
del projects[path]
|
|
139
|
+
save_projects(projects)
|
|
140
|
+
print(f"Removed project: {name}")
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _detect_project_info(path: str) -> dict:
|
|
145
|
+
"""Detect info about a potential project directory."""
|
|
146
|
+
info = {
|
|
147
|
+
"has_bblayers": False,
|
|
148
|
+
"has_defaults": False,
|
|
149
|
+
"layer_count": 0,
|
|
150
|
+
"branch": None,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Check for bblayers.conf in build/conf or any conf directory
|
|
154
|
+
for root, dirs, files in os.walk(path):
|
|
155
|
+
depth = root[len(path):].count(os.sep)
|
|
156
|
+
if depth > 3:
|
|
157
|
+
dirs[:] = []
|
|
158
|
+
continue
|
|
159
|
+
if "bblayers.conf" in files:
|
|
160
|
+
info["has_bblayers"] = True
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
# Check for defaults file
|
|
164
|
+
if os.path.isfile(os.path.join(path, ".bit.defaults")):
|
|
165
|
+
info["has_defaults"] = True
|
|
166
|
+
|
|
167
|
+
# Count layers
|
|
168
|
+
for root, dirs, files in os.walk(path):
|
|
169
|
+
depth = root[len(path):].count(os.sep)
|
|
170
|
+
if depth > 4:
|
|
171
|
+
dirs[:] = []
|
|
172
|
+
continue
|
|
173
|
+
if "layer.conf" in files and "conf" in root:
|
|
174
|
+
info["layer_count"] += 1
|
|
175
|
+
|
|
176
|
+
# Get git branch if in a git repo
|
|
177
|
+
try:
|
|
178
|
+
result = subprocess.run(
|
|
179
|
+
["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
180
|
+
capture_output=True,
|
|
181
|
+
text=True,
|
|
182
|
+
)
|
|
183
|
+
if result.returncode == 0:
|
|
184
|
+
info["branch"] = result.stdout.strip()
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
return info
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _remove_project_interactive() -> bool:
|
|
192
|
+
"""Show picker to select a project to stop tracking."""
|
|
193
|
+
if not fzf_available():
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
projects = load_projects()
|
|
197
|
+
if not projects:
|
|
198
|
+
print("No projects to remove.")
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
menu_lines = []
|
|
202
|
+
for path, info in sorted(projects.items(), key=lambda x: x[1].get("name", x[0]).lower()):
|
|
203
|
+
name = info.get("name", os.path.basename(path))
|
|
204
|
+
exists = " " if os.path.isdir(path) else Colors.red("! ")
|
|
205
|
+
menu_lines.append(f"{path}\t{exists}{name:<20} {Colors.dim(path)}")
|
|
206
|
+
|
|
207
|
+
menu_input = "\n".join(menu_lines)
|
|
208
|
+
|
|
209
|
+
fzf_args = [
|
|
210
|
+
"fzf",
|
|
211
|
+
"--no-multi",
|
|
212
|
+
"--no-sort",
|
|
213
|
+
"--ansi",
|
|
214
|
+
"--height", "~40%",
|
|
215
|
+
"--layout", "reverse",
|
|
216
|
+
"--header", "Select project to stop tracking (Enter=remove, ←/q=cancel)",
|
|
217
|
+
"--prompt", "Remove: ",
|
|
218
|
+
"--with-nth", "2..",
|
|
219
|
+
"--delimiter", "\t",
|
|
220
|
+
"--bind", "q:abort",
|
|
221
|
+
"--bind", "left:abort",
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
fzf_args.extend(get_fzf_color_args())
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
result = subprocess.run(
|
|
228
|
+
fzf_args,
|
|
229
|
+
input=menu_input,
|
|
230
|
+
stdout=subprocess.PIPE,
|
|
231
|
+
text=True,
|
|
232
|
+
)
|
|
233
|
+
except FileNotFoundError:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
240
|
+
if selected in projects:
|
|
241
|
+
return remove_project(selected)
|
|
242
|
+
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def fzf_project_picker(include_browse: bool = True, show_exit_hint: bool = False) -> Optional[str]:
|
|
247
|
+
"""
|
|
248
|
+
Show fzf menu to select a project.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
include_browse: Include option to browse for new project
|
|
252
|
+
show_exit_hint: Show hint about Enter exiting to command menu
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Selected project path, or special commands like:
|
|
256
|
+
- "SELECT:<path>" - Space was pressed, set as active but stay in picker
|
|
257
|
+
- "EXIT:<path>" - Enter was pressed, exit and optionally chain to command menu
|
|
258
|
+
- Other special values for menu options
|
|
259
|
+
- None if cancelled
|
|
260
|
+
"""
|
|
261
|
+
if not fzf_available():
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
projects = load_projects()
|
|
265
|
+
current_project = get_current_project()
|
|
266
|
+
|
|
267
|
+
if not projects and not include_browse:
|
|
268
|
+
print("No projects registered. Use 'bit projects add' to add one.")
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
# Build menu
|
|
272
|
+
menu_lines = []
|
|
273
|
+
|
|
274
|
+
for path, info in sorted(projects.items(), key=lambda x: x[1].get("name", x[0]).lower()):
|
|
275
|
+
name = info.get("name", os.path.basename(path))
|
|
276
|
+
desc = info.get("description", "")
|
|
277
|
+
|
|
278
|
+
# Check if path still exists and if it's current
|
|
279
|
+
is_current = (path == current_project)
|
|
280
|
+
if os.path.isdir(path):
|
|
281
|
+
if is_current:
|
|
282
|
+
status = Colors.cyan("●") # Current project
|
|
283
|
+
else:
|
|
284
|
+
status = Colors.green("○")
|
|
285
|
+
else:
|
|
286
|
+
status = Colors.red("!")
|
|
287
|
+
|
|
288
|
+
# Detect current state
|
|
289
|
+
proj_info = _detect_project_info(path) if os.path.isdir(path) else {}
|
|
290
|
+
layers = proj_info.get("layer_count", 0)
|
|
291
|
+
branch = proj_info.get("branch", "")
|
|
292
|
+
|
|
293
|
+
extra = []
|
|
294
|
+
if is_current:
|
|
295
|
+
extra.append("ACTIVE")
|
|
296
|
+
if layers:
|
|
297
|
+
extra.append(f"{layers} layers")
|
|
298
|
+
if branch:
|
|
299
|
+
extra.append(branch)
|
|
300
|
+
extra_str = f" ({', '.join(extra)})" if extra else ""
|
|
301
|
+
|
|
302
|
+
display = f"{status} {name:<20} {Colors.dim(path)}{Colors.cyan(extra_str)}"
|
|
303
|
+
if desc:
|
|
304
|
+
display += f" - {desc}"
|
|
305
|
+
|
|
306
|
+
menu_lines.append(f"{path}\t{display}")
|
|
307
|
+
|
|
308
|
+
# Add browse/add/remove options
|
|
309
|
+
if include_browse:
|
|
310
|
+
menu_lines.append("---\t" + "─" * 50)
|
|
311
|
+
menu_lines.append("ADD_CWD\t+ Add current directory")
|
|
312
|
+
menu_lines.append("ADD_PATH\t+ Enter path manually...")
|
|
313
|
+
menu_lines.append("BROWSE\t+ Browse for directory...")
|
|
314
|
+
if projects:
|
|
315
|
+
menu_lines.append("REMOVE\t- Remove project tracking...")
|
|
316
|
+
if current_project:
|
|
317
|
+
menu_lines.append("CLEAR\t✖ Clear active project")
|
|
318
|
+
# Settings
|
|
319
|
+
browser = get_directory_browser()
|
|
320
|
+
browser_desc = {"auto": "auto (broot>ranger>nnn>fzf)", "nnn": "nnn", "fzf": "fzf", "broot": "broot", "ranger": "ranger"}.get(browser, browser)
|
|
321
|
+
menu_lines.append(f"SETTINGS\t⚙ Settings: browser={browser_desc}")
|
|
322
|
+
|
|
323
|
+
menu_input = "\n".join(menu_lines)
|
|
324
|
+
|
|
325
|
+
# Build header based on context
|
|
326
|
+
if show_exit_hint:
|
|
327
|
+
header = "Space=activate Enter=done (→menu) +=browse -=remove c=clear s=settings q=quit"
|
|
328
|
+
else:
|
|
329
|
+
header = "Space=activate Enter=done +=browse -=remove c=clear s=settings q=quit"
|
|
330
|
+
|
|
331
|
+
fzf_args = [
|
|
332
|
+
"fzf",
|
|
333
|
+
"--no-multi",
|
|
334
|
+
"--no-sort",
|
|
335
|
+
"--ansi",
|
|
336
|
+
"--height", "~50%",
|
|
337
|
+
"--header", header,
|
|
338
|
+
"--prompt", "Project: ",
|
|
339
|
+
"--with-nth", "2..",
|
|
340
|
+
"--delimiter", "\t",
|
|
341
|
+
"--bind", "q:abort",
|
|
342
|
+
"--bind", "left:abort",
|
|
343
|
+
"--expect", "space,+,-,c,s",
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
fzf_args.extend(get_fzf_color_args())
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
result = subprocess.run(
|
|
350
|
+
fzf_args,
|
|
351
|
+
input=menu_input,
|
|
352
|
+
stdout=subprocess.PIPE,
|
|
353
|
+
text=True,
|
|
354
|
+
)
|
|
355
|
+
except FileNotFoundError:
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
# Parse output - with --expect, first line is key (or empty if Enter), second is selection
|
|
362
|
+
# Don't strip() first as it removes the leading newline when Enter is pressed
|
|
363
|
+
lines = result.stdout.split("\n")
|
|
364
|
+
key = lines[0].strip() if lines else ""
|
|
365
|
+
selected = lines[1].split("\t")[0].strip() if len(lines) > 1 else ""
|
|
366
|
+
|
|
367
|
+
# Handle key shortcuts
|
|
368
|
+
if key == "+":
|
|
369
|
+
return "BROWSE"
|
|
370
|
+
elif key == "space" and selected and selected not in ("---", "ADD_CWD", "ADD_PATH", "BROWSE", "REMOVE", "CLEAR", "SETTINGS"):
|
|
371
|
+
# Space = select/activate the project but stay in picker
|
|
372
|
+
return f"SELECT:{selected}"
|
|
373
|
+
elif key == "-" and selected and selected not in ("---", "ADD_CWD", "ADD_PATH", "BROWSE", "REMOVE", "CLEAR", "SETTINGS"):
|
|
374
|
+
# Remove the highlighted project directly
|
|
375
|
+
return f"REMOVE:{selected}"
|
|
376
|
+
elif key == "c":
|
|
377
|
+
return "CLEAR"
|
|
378
|
+
elif key == "s":
|
|
379
|
+
return "SETTINGS"
|
|
380
|
+
|
|
381
|
+
# No special key (Enter) - return the selected item
|
|
382
|
+
if not selected or selected == "---":
|
|
383
|
+
return fzf_project_picker(include_browse, show_exit_hint)
|
|
384
|
+
|
|
385
|
+
# Enter on a project - signal to exit
|
|
386
|
+
if selected not in ("ADD_CWD", "ADD_PATH", "BROWSE", "REMOVE", "CLEAR", "SETTINGS"):
|
|
387
|
+
return f"EXIT:{selected}"
|
|
388
|
+
|
|
389
|
+
return selected
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def get_directory_browser() -> str:
|
|
393
|
+
"""Get configured directory browser preference."""
|
|
394
|
+
config_file = _get_projects_file()
|
|
395
|
+
if os.path.isfile(config_file):
|
|
396
|
+
try:
|
|
397
|
+
with open(config_file, "r") as f:
|
|
398
|
+
data = json.load(f)
|
|
399
|
+
return data.get("__directory_browser__", "auto")
|
|
400
|
+
except (json.JSONDecodeError, OSError):
|
|
401
|
+
pass
|
|
402
|
+
return "auto"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def set_directory_browser(browser: str) -> None:
|
|
406
|
+
"""Set directory browser preference (auto, nnn, fzf)."""
|
|
407
|
+
config_file = _get_projects_file()
|
|
408
|
+
data = {}
|
|
409
|
+
if os.path.isfile(config_file):
|
|
410
|
+
try:
|
|
411
|
+
with open(config_file, "r") as f:
|
|
412
|
+
data = json.load(f)
|
|
413
|
+
except (json.JSONDecodeError, OSError):
|
|
414
|
+
pass
|
|
415
|
+
data["__directory_browser__"] = browser
|
|
416
|
+
try:
|
|
417
|
+
with open(config_file, "w") as f:
|
|
418
|
+
json.dump(data, f, indent=2)
|
|
419
|
+
except OSError:
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def get_git_viewer() -> str:
|
|
424
|
+
"""Get configured git history viewer preference."""
|
|
425
|
+
config_file = _get_projects_file()
|
|
426
|
+
if os.path.isfile(config_file):
|
|
427
|
+
try:
|
|
428
|
+
with open(config_file, "r") as f:
|
|
429
|
+
data = json.load(f)
|
|
430
|
+
return data.get("__git_viewer__", "auto")
|
|
431
|
+
except (json.JSONDecodeError, OSError):
|
|
432
|
+
pass
|
|
433
|
+
return "auto"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def set_git_viewer(viewer: str) -> None:
|
|
437
|
+
"""Set git history viewer preference."""
|
|
438
|
+
config_file = _get_projects_file()
|
|
439
|
+
data = {}
|
|
440
|
+
if os.path.isfile(config_file):
|
|
441
|
+
try:
|
|
442
|
+
with open(config_file, "r") as f:
|
|
443
|
+
data = json.load(f)
|
|
444
|
+
except (json.JSONDecodeError, OSError):
|
|
445
|
+
pass
|
|
446
|
+
data["__git_viewer__"] = viewer
|
|
447
|
+
try:
|
|
448
|
+
with open(config_file, "w") as f:
|
|
449
|
+
json.dump(data, f, indent=2)
|
|
450
|
+
except OSError:
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def get_graph_renderer() -> str:
|
|
455
|
+
"""Get configured graph renderer preference for dependency visualization."""
|
|
456
|
+
config_file = _get_projects_file()
|
|
457
|
+
if os.path.isfile(config_file):
|
|
458
|
+
try:
|
|
459
|
+
with open(config_file, "r") as f:
|
|
460
|
+
data = json.load(f)
|
|
461
|
+
return data.get("__graph_renderer__", "auto")
|
|
462
|
+
except (json.JSONDecodeError, OSError):
|
|
463
|
+
pass
|
|
464
|
+
return "auto"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def set_graph_renderer(renderer: str) -> None:
|
|
468
|
+
"""Set graph renderer preference (auto, graph-easy, ascii)."""
|
|
469
|
+
config_file = _get_projects_file()
|
|
470
|
+
data = {}
|
|
471
|
+
if os.path.isfile(config_file):
|
|
472
|
+
try:
|
|
473
|
+
with open(config_file, "r") as f:
|
|
474
|
+
data = json.load(f)
|
|
475
|
+
except (json.JSONDecodeError, OSError):
|
|
476
|
+
pass
|
|
477
|
+
data["__graph_renderer__"] = renderer
|
|
478
|
+
try:
|
|
479
|
+
with open(config_file, "w") as f:
|
|
480
|
+
json.dump(data, f, indent=2)
|
|
481
|
+
except OSError:
|
|
482
|
+
pass
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def get_preferred_graph_renderer() -> str:
|
|
486
|
+
"""
|
|
487
|
+
Get the preferred graph renderer that's actually available.
|
|
488
|
+
|
|
489
|
+
Returns: 'graph-easy', 'ascii', or the configured preference.
|
|
490
|
+
"""
|
|
491
|
+
preference = get_graph_renderer()
|
|
492
|
+
|
|
493
|
+
if preference == "auto":
|
|
494
|
+
# Prefer graph-easy if available
|
|
495
|
+
if shutil.which("graph-easy"):
|
|
496
|
+
return "graph-easy"
|
|
497
|
+
return "ascii"
|
|
498
|
+
elif preference == "graph-easy":
|
|
499
|
+
if shutil.which("graph-easy"):
|
|
500
|
+
return "graph-easy"
|
|
501
|
+
return "ascii" # Fall back if not available
|
|
502
|
+
else:
|
|
503
|
+
return preference
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def get_preferred_git_viewer() -> Optional[str]:
|
|
507
|
+
"""
|
|
508
|
+
Get the preferred git viewer that's actually available.
|
|
509
|
+
|
|
510
|
+
Returns the command name (tig, lazygit, gitk) or None if none available.
|
|
511
|
+
"""
|
|
512
|
+
preference = get_git_viewer()
|
|
513
|
+
|
|
514
|
+
# Define viewer priority
|
|
515
|
+
viewers = ["tig", "lazygit", "gitk"]
|
|
516
|
+
|
|
517
|
+
if preference == "auto":
|
|
518
|
+
# Return first available
|
|
519
|
+
for viewer in viewers:
|
|
520
|
+
if shutil.which(viewer):
|
|
521
|
+
return viewer
|
|
522
|
+
return None
|
|
523
|
+
elif preference in viewers and shutil.which(preference):
|
|
524
|
+
return preference
|
|
525
|
+
else:
|
|
526
|
+
# Configured viewer not available, fall back to auto
|
|
527
|
+
for viewer in viewers:
|
|
528
|
+
if shutil.which(viewer):
|
|
529
|
+
return viewer
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _pick_git_viewer() -> None:
|
|
534
|
+
"""Pick git history viewer."""
|
|
535
|
+
if not fzf_available():
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
current = get_git_viewer()
|
|
539
|
+
|
|
540
|
+
options = [
|
|
541
|
+
("auto", "Auto-detect (tig > lazygit > gitk)"),
|
|
542
|
+
("tig", "tig - ncurses git interface"),
|
|
543
|
+
("lazygit", "lazygit - terminal UI for git"),
|
|
544
|
+
("gitk", "gitk - graphical git browser"),
|
|
545
|
+
]
|
|
546
|
+
|
|
547
|
+
menu_lines = []
|
|
548
|
+
for value, desc in options:
|
|
549
|
+
marker = "● " if value == current else " "
|
|
550
|
+
available = ""
|
|
551
|
+
if value in ("tig", "lazygit", "gitk") and not shutil.which(value):
|
|
552
|
+
available = Colors.dim(" (not installed)")
|
|
553
|
+
menu_lines.append(f"{value}\t{marker}{desc}{available}")
|
|
554
|
+
|
|
555
|
+
menu_input = "\n".join(menu_lines)
|
|
556
|
+
|
|
557
|
+
fzf_args = [
|
|
558
|
+
"fzf",
|
|
559
|
+
"--no-multi",
|
|
560
|
+
"--no-sort",
|
|
561
|
+
"--ansi",
|
|
562
|
+
"--height", "~20%",
|
|
563
|
+
"--layout", "reverse",
|
|
564
|
+
"--header", "Select git history viewer (Enter=select, ←/q=back)",
|
|
565
|
+
"--prompt", "Viewer: ",
|
|
566
|
+
"--with-nth", "2..",
|
|
567
|
+
"--delimiter", "\t",
|
|
568
|
+
"--bind", "q:abort",
|
|
569
|
+
"--bind", "left:abort",
|
|
570
|
+
]
|
|
571
|
+
|
|
572
|
+
fzf_args.extend(get_fzf_color_args())
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
result = subprocess.run(
|
|
576
|
+
fzf_args,
|
|
577
|
+
input=menu_input,
|
|
578
|
+
stdout=subprocess.PIPE,
|
|
579
|
+
text=True,
|
|
580
|
+
)
|
|
581
|
+
except FileNotFoundError:
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
588
|
+
if selected in ("auto", "tig", "lazygit", "gitk"):
|
|
589
|
+
set_git_viewer(selected)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def get_preview_layout() -> str:
|
|
593
|
+
"""Get configured preview pane layout preference."""
|
|
594
|
+
config_file = _get_projects_file()
|
|
595
|
+
if os.path.isfile(config_file):
|
|
596
|
+
try:
|
|
597
|
+
with open(config_file, "r") as f:
|
|
598
|
+
data = json.load(f)
|
|
599
|
+
return data.get("__preview_layout__", "down")
|
|
600
|
+
except (json.JSONDecodeError, OSError):
|
|
601
|
+
pass
|
|
602
|
+
return "down"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def set_preview_layout(layout: str) -> None:
|
|
606
|
+
"""Set preview pane layout preference."""
|
|
607
|
+
config_file = _get_projects_file()
|
|
608
|
+
data = {}
|
|
609
|
+
if os.path.isfile(config_file):
|
|
610
|
+
try:
|
|
611
|
+
with open(config_file, "r") as f:
|
|
612
|
+
data = json.load(f)
|
|
613
|
+
except (json.JSONDecodeError, OSError):
|
|
614
|
+
pass
|
|
615
|
+
data["__preview_layout__"] = layout
|
|
616
|
+
try:
|
|
617
|
+
with open(config_file, "w") as f:
|
|
618
|
+
json.dump(data, f, indent=2)
|
|
619
|
+
except OSError:
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def get_preview_window_arg(size: str = "50%") -> str:
|
|
624
|
+
"""Get the fzf --preview-window argument based on configured layout."""
|
|
625
|
+
layout = get_preview_layout()
|
|
626
|
+
if layout == "right":
|
|
627
|
+
return f"right:{size}:wrap"
|
|
628
|
+
elif layout == "up":
|
|
629
|
+
return f"up,{size},wrap"
|
|
630
|
+
else: # down (default)
|
|
631
|
+
return f"down,{size},wrap"
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _pick_preview_layout() -> None:
|
|
635
|
+
"""Pick preview pane layout."""
|
|
636
|
+
if not fzf_available():
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
current = get_preview_layout()
|
|
640
|
+
|
|
641
|
+
options = [
|
|
642
|
+
("down", "Bottom - preview below commit list"),
|
|
643
|
+
("right", "Right - preview beside commit list (side-by-side)"),
|
|
644
|
+
("up", "Top - preview above commit list"),
|
|
645
|
+
]
|
|
646
|
+
|
|
647
|
+
menu_lines = []
|
|
648
|
+
for value, desc in options:
|
|
649
|
+
marker = "● " if value == current else " "
|
|
650
|
+
menu_lines.append(f"{value}\t{marker}{desc}")
|
|
651
|
+
|
|
652
|
+
menu_input = "\n".join(menu_lines)
|
|
653
|
+
|
|
654
|
+
fzf_args = [
|
|
655
|
+
"fzf",
|
|
656
|
+
"--no-multi",
|
|
657
|
+
"--no-sort",
|
|
658
|
+
"--ansi",
|
|
659
|
+
"--height", "~20%",
|
|
660
|
+
"--layout", "reverse",
|
|
661
|
+
"--header", "Select preview pane layout (Enter=select, ←/q=back)",
|
|
662
|
+
"--prompt", "Layout: ",
|
|
663
|
+
"--with-nth", "2..",
|
|
664
|
+
"--delimiter", "\t",
|
|
665
|
+
"--bind", "q:abort",
|
|
666
|
+
"--bind", "left:abort",
|
|
667
|
+
]
|
|
668
|
+
|
|
669
|
+
fzf_args.extend(get_fzf_color_args())
|
|
670
|
+
|
|
671
|
+
try:
|
|
672
|
+
result = subprocess.run(
|
|
673
|
+
fzf_args,
|
|
674
|
+
input=menu_input,
|
|
675
|
+
stdout=subprocess.PIPE,
|
|
676
|
+
text=True,
|
|
677
|
+
)
|
|
678
|
+
except FileNotFoundError:
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
685
|
+
if selected in ("down", "right", "up"):
|
|
686
|
+
set_preview_layout(selected)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def get_recipe_use_bitbake_layers() -> bool:
|
|
690
|
+
"""Get whether to use bitbake-layers for recipe scanning (default: True)."""
|
|
691
|
+
config_file = _get_projects_file()
|
|
692
|
+
if os.path.isfile(config_file):
|
|
693
|
+
try:
|
|
694
|
+
with open(config_file, "r") as f:
|
|
695
|
+
data = json.load(f)
|
|
696
|
+
value = data.get("__recipe_use_bitbake_layers__", True)
|
|
697
|
+
# Handle string values from config
|
|
698
|
+
if isinstance(value, str):
|
|
699
|
+
return value.lower() not in ("false", "no", "0")
|
|
700
|
+
return bool(value)
|
|
701
|
+
except (json.JSONDecodeError, OSError):
|
|
702
|
+
pass
|
|
703
|
+
return True
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def set_recipe_use_bitbake_layers(value: bool) -> None:
|
|
707
|
+
"""Set whether to use bitbake-layers for recipe scanning."""
|
|
708
|
+
config_file = _get_projects_file()
|
|
709
|
+
data = {}
|
|
710
|
+
if os.path.isfile(config_file):
|
|
711
|
+
try:
|
|
712
|
+
with open(config_file, "r") as f:
|
|
713
|
+
data = json.load(f)
|
|
714
|
+
except (json.JSONDecodeError, OSError):
|
|
715
|
+
pass
|
|
716
|
+
data["__recipe_use_bitbake_layers__"] = value
|
|
717
|
+
try:
|
|
718
|
+
with open(config_file, "w") as f:
|
|
719
|
+
json.dump(data, f, indent=2)
|
|
720
|
+
except OSError:
|
|
721
|
+
pass
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _pick_recipe_use_bitbake_layers() -> None:
|
|
725
|
+
"""Pick whether to use bitbake-layers for recipe scanning."""
|
|
726
|
+
if not fzf_available():
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
current = get_recipe_use_bitbake_layers()
|
|
730
|
+
|
|
731
|
+
options = [
|
|
732
|
+
(True, "bitbake-layers - Use bitbake-layers show-recipes (more accurate, slower)"),
|
|
733
|
+
(False, "File scan - Scan .bb files directly (faster, no bitbake env needed)"),
|
|
734
|
+
]
|
|
735
|
+
|
|
736
|
+
menu_lines = []
|
|
737
|
+
for value, desc in options:
|
|
738
|
+
marker = "● " if value == current else " "
|
|
739
|
+
key = "true" if value else "false"
|
|
740
|
+
menu_lines.append(f"{key}\t{marker}{desc}")
|
|
741
|
+
|
|
742
|
+
menu_input = "\n".join(menu_lines)
|
|
743
|
+
|
|
744
|
+
fzf_args = [
|
|
745
|
+
"fzf",
|
|
746
|
+
"--no-multi",
|
|
747
|
+
"--no-sort",
|
|
748
|
+
"--ansi",
|
|
749
|
+
"--height", "~20%",
|
|
750
|
+
"--layout", "reverse",
|
|
751
|
+
"--header", "Recipe scan method (Enter=select, ←/q=back)",
|
|
752
|
+
"--prompt", "Method: ",
|
|
753
|
+
"--with-nth", "2..",
|
|
754
|
+
"--delimiter", "\t",
|
|
755
|
+
"--bind", "q:abort",
|
|
756
|
+
"--bind", "left:abort",
|
|
757
|
+
]
|
|
758
|
+
|
|
759
|
+
fzf_args.extend(get_fzf_color_args())
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
result = subprocess.run(
|
|
763
|
+
fzf_args,
|
|
764
|
+
input=menu_input,
|
|
765
|
+
stdout=subprocess.PIPE,
|
|
766
|
+
text=True,
|
|
767
|
+
)
|
|
768
|
+
except FileNotFoundError:
|
|
769
|
+
return
|
|
770
|
+
|
|
771
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
772
|
+
return
|
|
773
|
+
|
|
774
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
775
|
+
if selected == "true":
|
|
776
|
+
set_recipe_use_bitbake_layers(True)
|
|
777
|
+
elif selected == "false":
|
|
778
|
+
set_recipe_use_bitbake_layers(False)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _pick_directory_browser() -> None:
|
|
782
|
+
"""Show settings menu for directory browser preferences."""
|
|
783
|
+
if not fzf_available():
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
while True:
|
|
787
|
+
current_browser = get_directory_browser()
|
|
788
|
+
current_colors = get_nnn_colors()
|
|
789
|
+
|
|
790
|
+
# Color descriptions
|
|
791
|
+
color_names = {
|
|
792
|
+
"0": "black", "1": "red", "2": "green", "3": "yellow",
|
|
793
|
+
"4": "blue", "5": "magenta", "6": "cyan", "7": "white"
|
|
794
|
+
}
|
|
795
|
+
color_desc = f"dirs={color_names.get(current_colors[0:1], '?')}" if current_colors else "default"
|
|
796
|
+
|
|
797
|
+
menu_lines = [
|
|
798
|
+
f"BROWSER\tBrowser: {current_browser}",
|
|
799
|
+
f"NNN_COLORS\tnnn colors: {color_desc} ({current_colors})",
|
|
800
|
+
]
|
|
801
|
+
|
|
802
|
+
menu_input = "\n".join(menu_lines)
|
|
803
|
+
|
|
804
|
+
fzf_args = [
|
|
805
|
+
"fzf",
|
|
806
|
+
"--no-multi",
|
|
807
|
+
"--no-sort",
|
|
808
|
+
"--ansi",
|
|
809
|
+
"--height", "~20%",
|
|
810
|
+
"--layout", "reverse",
|
|
811
|
+
"--header", "Projects settings (Enter=edit, ←/q=back)",
|
|
812
|
+
"--prompt", "Setting: ",
|
|
813
|
+
"--with-nth", "2..",
|
|
814
|
+
"--delimiter", "\t",
|
|
815
|
+
"--bind", "q:abort",
|
|
816
|
+
"--bind", "left:abort",
|
|
817
|
+
]
|
|
818
|
+
|
|
819
|
+
fzf_args.extend(get_fzf_color_args())
|
|
820
|
+
|
|
821
|
+
try:
|
|
822
|
+
result = subprocess.run(
|
|
823
|
+
fzf_args,
|
|
824
|
+
input=menu_input,
|
|
825
|
+
stdout=subprocess.PIPE,
|
|
826
|
+
text=True,
|
|
827
|
+
)
|
|
828
|
+
except FileNotFoundError:
|
|
829
|
+
return
|
|
830
|
+
|
|
831
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
835
|
+
|
|
836
|
+
if selected == "BROWSER":
|
|
837
|
+
_pick_browser_option()
|
|
838
|
+
elif selected == "NNN_COLORS":
|
|
839
|
+
_pick_nnn_colors()
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _pick_browser_option() -> None:
|
|
843
|
+
"""Pick directory browser."""
|
|
844
|
+
current = get_directory_browser()
|
|
845
|
+
|
|
846
|
+
options = [
|
|
847
|
+
("auto", "Auto-detect (broot > ranger > nnn > fzf)"),
|
|
848
|
+
("broot", "broot - fuzzy search, type paths directly"),
|
|
849
|
+
("ranger", "ranger - vim-like file manager"),
|
|
850
|
+
("nnn", "nnn - fast, minimal file manager"),
|
|
851
|
+
("fzf", "fzf built-in browser"),
|
|
852
|
+
]
|
|
853
|
+
|
|
854
|
+
menu_lines = []
|
|
855
|
+
for value, desc in options:
|
|
856
|
+
marker = "● " if value == current else " "
|
|
857
|
+
available = ""
|
|
858
|
+
if value in ("broot", "ranger", "nnn") and not shutil.which(value):
|
|
859
|
+
available = Colors.dim(" (not installed)")
|
|
860
|
+
menu_lines.append(f"{value}\t{marker}{desc}{available}")
|
|
861
|
+
|
|
862
|
+
menu_input = "\n".join(menu_lines)
|
|
863
|
+
|
|
864
|
+
fzf_args = [
|
|
865
|
+
"fzf",
|
|
866
|
+
"--no-multi",
|
|
867
|
+
"--no-sort",
|
|
868
|
+
"--ansi",
|
|
869
|
+
"--height", "~20%",
|
|
870
|
+
"--layout", "reverse",
|
|
871
|
+
"--header", "Select directory browser (Enter=select, ←/q=back)",
|
|
872
|
+
"--prompt", "Browser: ",
|
|
873
|
+
"--with-nth", "2..",
|
|
874
|
+
"--delimiter", "\t",
|
|
875
|
+
"--bind", "q:abort",
|
|
876
|
+
"--bind", "left:abort",
|
|
877
|
+
]
|
|
878
|
+
|
|
879
|
+
fzf_args.extend(get_fzf_color_args())
|
|
880
|
+
|
|
881
|
+
try:
|
|
882
|
+
result = subprocess.run(
|
|
883
|
+
fzf_args,
|
|
884
|
+
input=menu_input,
|
|
885
|
+
stdout=subprocess.PIPE,
|
|
886
|
+
text=True,
|
|
887
|
+
)
|
|
888
|
+
except FileNotFoundError:
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
892
|
+
return
|
|
893
|
+
|
|
894
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
895
|
+
if selected in ("auto", "fzf", "nnn", "broot", "ranger"):
|
|
896
|
+
set_directory_browser(selected)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _pick_nnn_colors() -> None:
|
|
900
|
+
"""Pick nnn color scheme."""
|
|
901
|
+
# NNN_COLORS format: up to 8 chars, each is a color 0-7
|
|
902
|
+
# We'll focus on the directory color (3rd char) which is usually the issue
|
|
903
|
+
current = get_nnn_colors()
|
|
904
|
+
|
|
905
|
+
presets = [
|
|
906
|
+
("6234", "cyan dirs (default)"),
|
|
907
|
+
("2234", "green dirs"),
|
|
908
|
+
("3234", "yellow dirs"),
|
|
909
|
+
("7234", "white dirs"),
|
|
910
|
+
("5234", "magenta dirs"),
|
|
911
|
+
("1234", "red dirs"),
|
|
912
|
+
("4234", "blue dirs (nnn default)"),
|
|
913
|
+
]
|
|
914
|
+
|
|
915
|
+
menu_lines = []
|
|
916
|
+
for value, desc in presets:
|
|
917
|
+
marker = "● " if value == current else " "
|
|
918
|
+
# Show color sample
|
|
919
|
+
color_code = {"1": "31", "2": "32", "3": "33", "4": "34", "5": "35", "6": "36", "7": "37"}.get(value[0], "0")
|
|
920
|
+
sample = f"\033[{color_code}m■■■\033[0m"
|
|
921
|
+
menu_lines.append(f"{value}\t{marker}{sample} {desc}")
|
|
922
|
+
|
|
923
|
+
menu_input = "\n".join(menu_lines)
|
|
924
|
+
|
|
925
|
+
fzf_args = [
|
|
926
|
+
"fzf",
|
|
927
|
+
"--no-multi",
|
|
928
|
+
"--no-sort",
|
|
929
|
+
"--ansi",
|
|
930
|
+
"--height", "~25%",
|
|
931
|
+
"--layout", "reverse",
|
|
932
|
+
"--header", "Select nnn directory color (Enter=select, ←/q=back)",
|
|
933
|
+
"--prompt", "Color: ",
|
|
934
|
+
"--with-nth", "2..",
|
|
935
|
+
"--delimiter", "\t",
|
|
936
|
+
"--bind", "q:abort",
|
|
937
|
+
"--bind", "left:abort",
|
|
938
|
+
]
|
|
939
|
+
|
|
940
|
+
fzf_args.extend(get_fzf_color_args())
|
|
941
|
+
|
|
942
|
+
try:
|
|
943
|
+
result = subprocess.run(
|
|
944
|
+
fzf_args,
|
|
945
|
+
input=menu_input,
|
|
946
|
+
stdout=subprocess.PIPE,
|
|
947
|
+
text=True,
|
|
948
|
+
)
|
|
949
|
+
except FileNotFoundError:
|
|
950
|
+
return
|
|
951
|
+
|
|
952
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
953
|
+
return
|
|
954
|
+
|
|
955
|
+
selected = result.stdout.strip().split("\t")[0]
|
|
956
|
+
if selected:
|
|
957
|
+
set_nnn_colors(selected)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def browse_for_directory() -> Optional[str]:
|
|
961
|
+
"""
|
|
962
|
+
Browse filesystem to select a directory.
|
|
963
|
+
|
|
964
|
+
Uses configured browser preference, or auto-detects.
|
|
965
|
+
Priority: broot > ranger > nnn > fzf
|
|
966
|
+
"""
|
|
967
|
+
browser = get_directory_browser()
|
|
968
|
+
|
|
969
|
+
if browser == "auto":
|
|
970
|
+
# Auto-detect best available
|
|
971
|
+
if shutil.which("broot"):
|
|
972
|
+
return _browse_with_broot()
|
|
973
|
+
elif shutil.which("ranger"):
|
|
974
|
+
return _browse_with_ranger()
|
|
975
|
+
elif shutil.which("nnn"):
|
|
976
|
+
return _browse_with_nnn()
|
|
977
|
+
elif browser == "broot" and shutil.which("broot"):
|
|
978
|
+
return _browse_with_broot()
|
|
979
|
+
elif browser == "ranger" and shutil.which("ranger"):
|
|
980
|
+
return _browse_with_ranger()
|
|
981
|
+
elif browser == "nnn" and shutil.which("nnn"):
|
|
982
|
+
return _browse_with_nnn()
|
|
983
|
+
|
|
984
|
+
# Fall back to fzf
|
|
985
|
+
if fzf_available():
|
|
986
|
+
return _browse_with_fzf_walker()
|
|
987
|
+
|
|
988
|
+
return None
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _browse_with_broot() -> Optional[str]:
|
|
992
|
+
"""Browse for directory using broot."""
|
|
993
|
+
import tempfile
|
|
994
|
+
|
|
995
|
+
start_dir = os.path.expanduser("~")
|
|
996
|
+
|
|
997
|
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.broot') as f:
|
|
998
|
+
out_file = f.name
|
|
999
|
+
|
|
1000
|
+
try:
|
|
1001
|
+
print()
|
|
1002
|
+
print(f"{Colors.bold('broot directory browser')}")
|
|
1003
|
+
print(f" Type to fuzzy search /path : jump to absolute path")
|
|
1004
|
+
print(f" Enter : enter dir Alt+Enter : {Colors.cyan('SELECT and quit')}")
|
|
1005
|
+
print(f" Esc/q : cancel ? : help")
|
|
1006
|
+
print()
|
|
1007
|
+
input("Press Enter to open broot...")
|
|
1008
|
+
|
|
1009
|
+
# --only-folders: show only directories
|
|
1010
|
+
# --cmd: initial command (none)
|
|
1011
|
+
# We use :print_path verb bound to alt-enter
|
|
1012
|
+
result = subprocess.run(
|
|
1013
|
+
["broot", "--only-folders", "--print-path", "-o", out_file, start_dir],
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
if os.path.isfile(out_file):
|
|
1017
|
+
with open(out_file, "r") as f:
|
|
1018
|
+
selected = f.read().strip()
|
|
1019
|
+
if selected:
|
|
1020
|
+
# broot might output a file, get its directory
|
|
1021
|
+
if os.path.isfile(selected):
|
|
1022
|
+
return os.path.dirname(selected)
|
|
1023
|
+
elif os.path.isdir(selected):
|
|
1024
|
+
return selected
|
|
1025
|
+
|
|
1026
|
+
return None
|
|
1027
|
+
finally:
|
|
1028
|
+
try:
|
|
1029
|
+
os.unlink(out_file)
|
|
1030
|
+
except OSError:
|
|
1031
|
+
pass
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _browse_with_ranger() -> Optional[str]:
|
|
1035
|
+
"""Browse for directory using ranger with fzf integration."""
|
|
1036
|
+
import tempfile
|
|
1037
|
+
|
|
1038
|
+
start_dir = os.path.expanduser("~")
|
|
1039
|
+
|
|
1040
|
+
# Create temp directory for our ranger config
|
|
1041
|
+
temp_dir = tempfile.mkdtemp(prefix='ranger_bbp_')
|
|
1042
|
+
choosedir_file = os.path.join(temp_dir, 'choosedir')
|
|
1043
|
+
commands_file = os.path.join(temp_dir, 'commands.py')
|
|
1044
|
+
rc_file = os.path.join(temp_dir, 'rc.conf')
|
|
1045
|
+
|
|
1046
|
+
try:
|
|
1047
|
+
# Write custom commands.py with fzf_select
|
|
1048
|
+
commands_content = '''
|
|
1049
|
+
import subprocess
|
|
1050
|
+
import os.path
|
|
1051
|
+
from ranger.api.commands import Command
|
|
1052
|
+
|
|
1053
|
+
class fzf_select(Command):
|
|
1054
|
+
"""Find a file or directory using fzf and jump to it."""
|
|
1055
|
+
def execute(self):
|
|
1056
|
+
# Use fd if available, otherwise find
|
|
1057
|
+
if subprocess.run(["which", "fd"], capture_output=True).returncode == 0:
|
|
1058
|
+
command = "fd --type d --hidden --exclude .git 2>/dev/null | fzf +m"
|
|
1059
|
+
else:
|
|
1060
|
+
command = "find . -type d -not -path '*/.*' 2>/dev/null | fzf +m"
|
|
1061
|
+
fzf = self.fm.execute_command(command, stdout=subprocess.PIPE)
|
|
1062
|
+
stdout, stderr = fzf.communicate()
|
|
1063
|
+
if fzf.returncode == 0:
|
|
1064
|
+
fzf_file = os.path.abspath(stdout.decode('utf-8').strip())
|
|
1065
|
+
if os.path.isdir(fzf_file):
|
|
1066
|
+
self.fm.cd(fzf_file)
|
|
1067
|
+
else:
|
|
1068
|
+
self.fm.select_file(fzf_file)
|
|
1069
|
+
|
|
1070
|
+
class fzf_cd(Command):
|
|
1071
|
+
"""fzf to any directory from root."""
|
|
1072
|
+
def execute(self):
|
|
1073
|
+
start = self.arg(1) or os.path.expanduser("~")
|
|
1074
|
+
if subprocess.run(["which", "fd"], capture_output=True).returncode == 0:
|
|
1075
|
+
command = f"fd --type d --hidden --exclude .git . {start} 2>/dev/null | fzf +m"
|
|
1076
|
+
else:
|
|
1077
|
+
command = f"find {start} -type d -not -path '*/.*' 2>/dev/null | fzf +m"
|
|
1078
|
+
fzf = self.fm.execute_command(command, stdout=subprocess.PIPE)
|
|
1079
|
+
stdout, stderr = fzf.communicate()
|
|
1080
|
+
if fzf.returncode == 0:
|
|
1081
|
+
fzf_file = os.path.abspath(stdout.decode('utf-8').strip())
|
|
1082
|
+
if os.path.isdir(fzf_file):
|
|
1083
|
+
self.fm.cd(fzf_file)
|
|
1084
|
+
'''
|
|
1085
|
+
with open(commands_file, 'w') as f:
|
|
1086
|
+
f.write(commands_content)
|
|
1087
|
+
|
|
1088
|
+
# Write rc.conf with our mappings
|
|
1089
|
+
# Source user's rc.conf first if it exists
|
|
1090
|
+
user_rc = os.path.expanduser("~/.config/ranger/rc.conf")
|
|
1091
|
+
rc_content = f'''
|
|
1092
|
+
# Source user config if exists
|
|
1093
|
+
{"source " + user_rc if os.path.isfile(user_rc) else "# no user rc.conf"}
|
|
1094
|
+
|
|
1095
|
+
# bit mappings
|
|
1096
|
+
map <C-f> fzf_select
|
|
1097
|
+
map <C-g> fzf_cd ~
|
|
1098
|
+
map g console cd%space
|
|
1099
|
+
'''
|
|
1100
|
+
with open(rc_file, 'w') as f:
|
|
1101
|
+
f.write(rc_content)
|
|
1102
|
+
|
|
1103
|
+
print()
|
|
1104
|
+
print(f"{Colors.bold('ranger directory browser')} (with fzf)")
|
|
1105
|
+
print(f" {Colors.bold('Ctrl+f')} : fzf search from current dir")
|
|
1106
|
+
print(f" {Colors.bold('Ctrl+g')} : fzf search from home")
|
|
1107
|
+
print(f" {Colors.bold('g')} : type path directly (tab completes)")
|
|
1108
|
+
print(f" :cd /path : jump to path")
|
|
1109
|
+
print(f" q : {Colors.cyan('SELECT current dir and quit')}")
|
|
1110
|
+
print()
|
|
1111
|
+
input("Press Enter to open ranger...")
|
|
1112
|
+
|
|
1113
|
+
env = os.environ.copy()
|
|
1114
|
+
env['RANGER_LOAD_DEFAULT_RC'] = 'FALSE'
|
|
1115
|
+
|
|
1116
|
+
result = subprocess.run(
|
|
1117
|
+
["ranger",
|
|
1118
|
+
f"--choosedir={choosedir_file}",
|
|
1119
|
+
f"--cmd=source {rc_file}",
|
|
1120
|
+
f"--cmd=source {commands_file}",
|
|
1121
|
+
start_dir],
|
|
1122
|
+
env=env,
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
if os.path.isfile(choosedir_file):
|
|
1126
|
+
with open(choosedir_file, "r") as f:
|
|
1127
|
+
selected = f.read().strip()
|
|
1128
|
+
if selected and os.path.isdir(selected):
|
|
1129
|
+
return selected
|
|
1130
|
+
|
|
1131
|
+
return None
|
|
1132
|
+
finally:
|
|
1133
|
+
# Cleanup temp files
|
|
1134
|
+
try:
|
|
1135
|
+
os.unlink(choosedir_file)
|
|
1136
|
+
except OSError:
|
|
1137
|
+
pass
|
|
1138
|
+
try:
|
|
1139
|
+
os.unlink(commands_file)
|
|
1140
|
+
except OSError:
|
|
1141
|
+
pass
|
|
1142
|
+
try:
|
|
1143
|
+
os.unlink(rc_file)
|
|
1144
|
+
except OSError:
|
|
1145
|
+
pass
|
|
1146
|
+
try:
|
|
1147
|
+
os.rmdir(temp_dir)
|
|
1148
|
+
except OSError:
|
|
1149
|
+
pass
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def get_nnn_colors() -> str:
|
|
1153
|
+
"""Get configured nnn color scheme."""
|
|
1154
|
+
config_file = _get_projects_file()
|
|
1155
|
+
if os.path.isfile(config_file):
|
|
1156
|
+
try:
|
|
1157
|
+
with open(config_file, "r") as f:
|
|
1158
|
+
data = json.load(f)
|
|
1159
|
+
return data.get("__nnn_colors__", "6234")
|
|
1160
|
+
except (json.JSONDecodeError, OSError):
|
|
1161
|
+
pass
|
|
1162
|
+
# Default: cyan dirs (6), green sel (2), yellow ctx (3), blue files (4)
|
|
1163
|
+
return "6234"
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def set_nnn_colors(colors: str) -> None:
|
|
1167
|
+
"""Set nnn color scheme."""
|
|
1168
|
+
config_file = _get_projects_file()
|
|
1169
|
+
data = {}
|
|
1170
|
+
if os.path.isfile(config_file):
|
|
1171
|
+
try:
|
|
1172
|
+
with open(config_file, "r") as f:
|
|
1173
|
+
data = json.load(f)
|
|
1174
|
+
except (json.JSONDecodeError, OSError):
|
|
1175
|
+
pass
|
|
1176
|
+
data["__nnn_colors__"] = colors
|
|
1177
|
+
try:
|
|
1178
|
+
with open(config_file, "w") as f:
|
|
1179
|
+
json.dump(data, f, indent=2)
|
|
1180
|
+
except OSError:
|
|
1181
|
+
pass
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _browse_with_nnn() -> Optional[str]:
|
|
1185
|
+
"""Browse for directory using nnn file manager."""
|
|
1186
|
+
import tempfile
|
|
1187
|
+
|
|
1188
|
+
start_dir = os.path.expanduser("~")
|
|
1189
|
+
|
|
1190
|
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.nnn') as f:
|
|
1191
|
+
picker_file = f.name
|
|
1192
|
+
|
|
1193
|
+
try:
|
|
1194
|
+
env = os.environ.copy()
|
|
1195
|
+
# Set colors - default avoids hard-to-read blue for directories
|
|
1196
|
+
# Format: 4 chars for contexts (cursor line, selection, dir, file)
|
|
1197
|
+
# 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 7=white
|
|
1198
|
+
env["NNN_COLORS"] = get_nnn_colors()
|
|
1199
|
+
|
|
1200
|
+
print()
|
|
1201
|
+
print(f"{Colors.bold('nnn directory browser')} (type-to-nav enabled)")
|
|
1202
|
+
print(f" hjkl/arrows : navigate {Colors.bold('/')} : filter current dir")
|
|
1203
|
+
print(f" Enter/l : enter dir {Colors.bold('~')} : jump to home")
|
|
1204
|
+
print(f" Backspace/h : go up Just type: jump to path (e.g. /opt)")
|
|
1205
|
+
print(f" {Colors.bold('p')} : {Colors.cyan('SELECT and quit')}")
|
|
1206
|
+
print(f" q : cancel {Colors.bold('?')} : full help")
|
|
1207
|
+
print()
|
|
1208
|
+
input("Press Enter to open nnn...")
|
|
1209
|
+
|
|
1210
|
+
result = subprocess.run(
|
|
1211
|
+
["nnn", "-n", "-p", picker_file, start_dir],
|
|
1212
|
+
env=env,
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
# Read selected path (written when user presses 'p')
|
|
1216
|
+
if os.path.isfile(picker_file):
|
|
1217
|
+
with open(picker_file, "r") as f:
|
|
1218
|
+
selected = f.read().strip()
|
|
1219
|
+
if selected and os.path.isdir(selected):
|
|
1220
|
+
return selected
|
|
1221
|
+
elif selected and os.path.isfile(selected):
|
|
1222
|
+
# If a file was selected, use its directory
|
|
1223
|
+
return os.path.dirname(selected)
|
|
1224
|
+
|
|
1225
|
+
return None
|
|
1226
|
+
finally:
|
|
1227
|
+
try:
|
|
1228
|
+
os.unlink(picker_file)
|
|
1229
|
+
except OSError:
|
|
1230
|
+
pass
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _browse_with_fzf_walker() -> Optional[str]:
|
|
1234
|
+
"""Browse for directory using fzf with manual directory navigation."""
|
|
1235
|
+
start_dir = os.path.expanduser("~")
|
|
1236
|
+
current_dir = start_dir
|
|
1237
|
+
|
|
1238
|
+
while True:
|
|
1239
|
+
# List directories in current location
|
|
1240
|
+
try:
|
|
1241
|
+
entries = sorted(os.listdir(current_dir))
|
|
1242
|
+
except OSError:
|
|
1243
|
+
entries = []
|
|
1244
|
+
|
|
1245
|
+
dirs = [d for d in entries if os.path.isdir(os.path.join(current_dir, d)) and not d.startswith(".")]
|
|
1246
|
+
|
|
1247
|
+
menu_lines = []
|
|
1248
|
+
menu_lines.append(f".\t{Colors.green('● Select this directory')}: {current_dir}")
|
|
1249
|
+
if current_dir != "/":
|
|
1250
|
+
menu_lines.append(f"..\t{Colors.cyan('↑ Go up to')}: {os.path.dirname(current_dir)}")
|
|
1251
|
+
|
|
1252
|
+
for d in dirs:
|
|
1253
|
+
full_path = os.path.join(current_dir, d)
|
|
1254
|
+
# Check for indicators this might be a Yocto project
|
|
1255
|
+
has_layers = os.path.isdir(os.path.join(full_path, "layers"))
|
|
1256
|
+
has_build = os.path.isdir(os.path.join(full_path, "build"))
|
|
1257
|
+
has_poky = os.path.isdir(os.path.join(full_path, "poky"))
|
|
1258
|
+
has_bblayers = any(
|
|
1259
|
+
os.path.isfile(os.path.join(full_path, sub, "conf", "bblayers.conf"))
|
|
1260
|
+
for sub in ["", "build"]
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
indicator = ""
|
|
1264
|
+
if has_layers or has_build or has_poky or has_bblayers:
|
|
1265
|
+
indicator = Colors.green(" [yocto?]")
|
|
1266
|
+
|
|
1267
|
+
menu_lines.append(f"{d}\t {d}/{indicator}")
|
|
1268
|
+
|
|
1269
|
+
menu_input = "\n".join(menu_lines)
|
|
1270
|
+
|
|
1271
|
+
fzf_args = [
|
|
1272
|
+
"fzf",
|
|
1273
|
+
"--no-multi",
|
|
1274
|
+
"--no-sort",
|
|
1275
|
+
"--ansi",
|
|
1276
|
+
"--height", "~60%",
|
|
1277
|
+
"--layout", "reverse",
|
|
1278
|
+
"--header", f"Browse: {current_dir}\nEnter=enter dir, Tab=select, ←/q=cancel",
|
|
1279
|
+
"--prompt", "Dir: ",
|
|
1280
|
+
"--with-nth", "2..",
|
|
1281
|
+
"--delimiter", "\t",
|
|
1282
|
+
"--bind", "q:abort",
|
|
1283
|
+
"--bind", "left:abort",
|
|
1284
|
+
"--expect", "tab",
|
|
1285
|
+
]
|
|
1286
|
+
|
|
1287
|
+
fzf_args.extend(get_fzf_color_args())
|
|
1288
|
+
|
|
1289
|
+
try:
|
|
1290
|
+
result = subprocess.run(
|
|
1291
|
+
fzf_args,
|
|
1292
|
+
input=menu_input,
|
|
1293
|
+
stdout=subprocess.PIPE,
|
|
1294
|
+
text=True,
|
|
1295
|
+
)
|
|
1296
|
+
except FileNotFoundError:
|
|
1297
|
+
return None
|
|
1298
|
+
|
|
1299
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1300
|
+
return None
|
|
1301
|
+
|
|
1302
|
+
lines = result.stdout.strip().split("\n")
|
|
1303
|
+
key = lines[0] if lines else ""
|
|
1304
|
+
selected = lines[1].split("\t")[0] if len(lines) > 1 else ""
|
|
1305
|
+
|
|
1306
|
+
# Tab means select current highlighted item as the project
|
|
1307
|
+
if key == "tab" and selected and selected not in (".", ".."):
|
|
1308
|
+
return os.path.join(current_dir, selected)
|
|
1309
|
+
|
|
1310
|
+
if selected == ".":
|
|
1311
|
+
return current_dir
|
|
1312
|
+
elif selected == "..":
|
|
1313
|
+
current_dir = os.path.dirname(current_dir)
|
|
1314
|
+
elif selected:
|
|
1315
|
+
new_dir = os.path.join(current_dir, selected)
|
|
1316
|
+
if os.path.isdir(new_dir):
|
|
1317
|
+
current_dir = new_dir
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def run_projects(args, from_auto_prompt: bool = False) -> int:
|
|
1321
|
+
"""
|
|
1322
|
+
Main entry point for projects command.
|
|
1323
|
+
|
|
1324
|
+
Args:
|
|
1325
|
+
args: Parsed command line arguments
|
|
1326
|
+
from_auto_prompt: True if invoked automatically because no project context was found.
|
|
1327
|
+
In this case, after selecting a project with Enter, show command menu.
|
|
1328
|
+
|
|
1329
|
+
Returns:
|
|
1330
|
+
0 for success, 1 for error, 2 to signal "chain to command menu"
|
|
1331
|
+
"""
|
|
1332
|
+
subcommand = getattr(args, "projects_command", None)
|
|
1333
|
+
|
|
1334
|
+
if subcommand == "add":
|
|
1335
|
+
path = getattr(args, "path", None) or os.getcwd()
|
|
1336
|
+
name = getattr(args, "name", None)
|
|
1337
|
+
desc = getattr(args, "description", "") or ""
|
|
1338
|
+
if add_project(path, name, desc):
|
|
1339
|
+
return 0
|
|
1340
|
+
return 1
|
|
1341
|
+
|
|
1342
|
+
elif subcommand == "remove":
|
|
1343
|
+
path = getattr(args, "path", None)
|
|
1344
|
+
if not path:
|
|
1345
|
+
# Interactive selection
|
|
1346
|
+
selected = fzf_project_picker(include_browse=False)
|
|
1347
|
+
if not selected:
|
|
1348
|
+
return 1
|
|
1349
|
+
path = selected
|
|
1350
|
+
if remove_project(path):
|
|
1351
|
+
return 0
|
|
1352
|
+
return 1
|
|
1353
|
+
|
|
1354
|
+
elif subcommand == "shell":
|
|
1355
|
+
# Alias for 'init shell' - change to current project first
|
|
1356
|
+
current_project = get_current_project()
|
|
1357
|
+
if current_project and os.path.isdir(current_project):
|
|
1358
|
+
os.chdir(current_project)
|
|
1359
|
+
from .init import run_init_shell
|
|
1360
|
+
return run_init_shell(args)
|
|
1361
|
+
|
|
1362
|
+
elif subcommand == "list":
|
|
1363
|
+
projects = load_projects()
|
|
1364
|
+
current = get_current_project()
|
|
1365
|
+
|
|
1366
|
+
if not projects:
|
|
1367
|
+
print("No projects registered.")
|
|
1368
|
+
print("Use 'bit projects add' to add one.")
|
|
1369
|
+
return 0
|
|
1370
|
+
|
|
1371
|
+
print(f"\n{Colors.bold('Registered projects:')}\n")
|
|
1372
|
+
for path, info in sorted(projects.items(), key=lambda x: x[1].get("name", x[0]).lower()):
|
|
1373
|
+
name = info.get("name", os.path.basename(path))
|
|
1374
|
+
desc = info.get("description", "")
|
|
1375
|
+
exists = os.path.isdir(path)
|
|
1376
|
+
is_current = (path == current)
|
|
1377
|
+
|
|
1378
|
+
if is_current:
|
|
1379
|
+
status = Colors.cyan("●")
|
|
1380
|
+
label = f" {Colors.cyan('(ACTIVE)')}"
|
|
1381
|
+
elif exists:
|
|
1382
|
+
status = Colors.green("○")
|
|
1383
|
+
label = ""
|
|
1384
|
+
else:
|
|
1385
|
+
status = Colors.red("!")
|
|
1386
|
+
label = ""
|
|
1387
|
+
|
|
1388
|
+
print(f" {status} {Colors.cyan(name)}{label}")
|
|
1389
|
+
print(f" {path}")
|
|
1390
|
+
if desc:
|
|
1391
|
+
print(f" {Colors.dim(desc)}")
|
|
1392
|
+
if not exists:
|
|
1393
|
+
print(f" {Colors.red('(directory not found)')}")
|
|
1394
|
+
print()
|
|
1395
|
+
return 0
|
|
1396
|
+
|
|
1397
|
+
else:
|
|
1398
|
+
# Default: interactive picker
|
|
1399
|
+
selected = fzf_project_picker(include_browse=True, show_exit_hint=from_auto_prompt)
|
|
1400
|
+
|
|
1401
|
+
if not selected:
|
|
1402
|
+
return 0
|
|
1403
|
+
|
|
1404
|
+
if selected == "ADD_CWD":
|
|
1405
|
+
cwd = os.getcwd()
|
|
1406
|
+
name = input(f"Name for {cwd} [{os.path.basename(cwd)}]: ").strip()
|
|
1407
|
+
if not name:
|
|
1408
|
+
name = os.path.basename(cwd)
|
|
1409
|
+
add_project(cwd, name)
|
|
1410
|
+
return run_projects(args, from_auto_prompt) # Show picker again
|
|
1411
|
+
|
|
1412
|
+
elif selected == "ADD_PATH":
|
|
1413
|
+
try:
|
|
1414
|
+
path = input("Enter project path: ").strip()
|
|
1415
|
+
if path:
|
|
1416
|
+
path = os.path.expanduser(path)
|
|
1417
|
+
path = os.path.abspath(path)
|
|
1418
|
+
if os.path.isdir(path):
|
|
1419
|
+
name = input(f"Name for {path} [{os.path.basename(path)}]: ").strip()
|
|
1420
|
+
if not name:
|
|
1421
|
+
name = os.path.basename(path)
|
|
1422
|
+
add_project(path, name)
|
|
1423
|
+
else:
|
|
1424
|
+
print(f"Not a directory: {path}")
|
|
1425
|
+
except (EOFError, KeyboardInterrupt):
|
|
1426
|
+
print()
|
|
1427
|
+
return run_projects(args, from_auto_prompt) # Show picker again
|
|
1428
|
+
|
|
1429
|
+
elif selected == "BROWSE":
|
|
1430
|
+
path = browse_for_directory()
|
|
1431
|
+
if path:
|
|
1432
|
+
name = input(f"Name for {path} [{os.path.basename(path)}]: ").strip()
|
|
1433
|
+
if not name:
|
|
1434
|
+
name = os.path.basename(path)
|
|
1435
|
+
add_project(path, name)
|
|
1436
|
+
return run_projects(args, from_auto_prompt) # Show picker again
|
|
1437
|
+
|
|
1438
|
+
elif selected == "SETTINGS":
|
|
1439
|
+
_pick_directory_browser()
|
|
1440
|
+
return run_projects(args, from_auto_prompt) # Show picker again
|
|
1441
|
+
|
|
1442
|
+
elif selected == "REMOVE":
|
|
1443
|
+
_remove_project_interactive()
|
|
1444
|
+
return run_projects(args, from_auto_prompt) # Show picker again
|
|
1445
|
+
|
|
1446
|
+
elif selected.startswith("REMOVE:"):
|
|
1447
|
+
# Direct removal of highlighted project
|
|
1448
|
+
path = selected[7:] # Strip "REMOVE:" prefix
|
|
1449
|
+
remove_project(path)
|
|
1450
|
+
return run_projects(args, from_auto_prompt) # Show picker again
|
|
1451
|
+
|
|
1452
|
+
elif selected.startswith("SELECT:"):
|
|
1453
|
+
# Space pressed - set as active but stay in picker
|
|
1454
|
+
path = selected[7:] # Strip "SELECT:" prefix
|
|
1455
|
+
if os.path.isdir(path):
|
|
1456
|
+
set_current_project(path)
|
|
1457
|
+
projects = load_projects()
|
|
1458
|
+
name = projects.get(path, {}).get("name", os.path.basename(path))
|
|
1459
|
+
print(f"{Colors.green('✓')} Activated: {Colors.bold(name)}")
|
|
1460
|
+
return run_projects(args, from_auto_prompt) # Show picker again
|
|
1461
|
+
|
|
1462
|
+
elif selected == "CLEAR":
|
|
1463
|
+
set_current_project(None)
|
|
1464
|
+
print(f"{Colors.yellow('Cleared')} active project. Commands will use current directory.")
|
|
1465
|
+
return run_projects(args, from_auto_prompt) # Show picker again
|
|
1466
|
+
|
|
1467
|
+
elif selected.startswith("EXIT:"):
|
|
1468
|
+
# Enter pressed on a project - exit and optionally chain to command menu
|
|
1469
|
+
path = selected[5:] # Strip "EXIT:" prefix
|
|
1470
|
+
if not os.path.isdir(path):
|
|
1471
|
+
print(f"Error: Project directory not found: {path}", file=sys.stderr)
|
|
1472
|
+
return 1
|
|
1473
|
+
|
|
1474
|
+
# Set as active if not already
|
|
1475
|
+
current = get_current_project()
|
|
1476
|
+
if path != current:
|
|
1477
|
+
set_current_project(path)
|
|
1478
|
+
projects = load_projects()
|
|
1479
|
+
name = projects.get(path, {}).get("name", os.path.basename(path))
|
|
1480
|
+
print(f"{Colors.green('✓')} Active project: {Colors.bold(name)}")
|
|
1481
|
+
print(f" {Colors.dim(path)}")
|
|
1482
|
+
|
|
1483
|
+
if from_auto_prompt:
|
|
1484
|
+
# Signal to cli.py to show command menu
|
|
1485
|
+
return 2
|
|
1486
|
+
else:
|
|
1487
|
+
print()
|
|
1488
|
+
print(f"All bit commands will now operate on this project.")
|
|
1489
|
+
# Copy to clipboard if possible
|
|
1490
|
+
try:
|
|
1491
|
+
if shutil.which("xclip"):
|
|
1492
|
+
subprocess.run(["xclip", "-selection", "clipboard"],
|
|
1493
|
+
input=f"cd {path}".encode(), check=True)
|
|
1494
|
+
print(Colors.dim(f"(cd command copied to clipboard)"))
|
|
1495
|
+
elif shutil.which("xsel"):
|
|
1496
|
+
subprocess.run(["xsel", "--clipboard", "--input"],
|
|
1497
|
+
input=f"cd {path}".encode(), check=True)
|
|
1498
|
+
print(Colors.dim(f"(cd command copied to clipboard)"))
|
|
1499
|
+
except Exception:
|
|
1500
|
+
pass
|
|
1501
|
+
return 0
|
|
1502
|
+
|
|
1503
|
+
else:
|
|
1504
|
+
# Fallback for any unhandled menu items (shouldn't happen)
|
|
1505
|
+
return 0
|