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,1374 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
5
|
+
#
|
|
6
|
+
"""Recipe command - search and browse BitBake recipes."""
|
|
7
|
+
|
|
8
|
+
import glob
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
from typing import Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from ..core import Colors, fzf_available, get_fzf_color_args, get_fzf_preview_resize_bindings, load_defaults
|
|
23
|
+
from .common import (
|
|
24
|
+
resolve_bblayers_path,
|
|
25
|
+
resolve_base_and_layers,
|
|
26
|
+
dedupe_preserve_order,
|
|
27
|
+
layer_display_name,
|
|
28
|
+
extract_layer_paths,
|
|
29
|
+
)
|
|
30
|
+
from .projects import get_preview_window_arg, get_recipe_use_bitbake_layers
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# =============================================================================
|
|
34
|
+
# Cache Management
|
|
35
|
+
# =============================================================================
|
|
36
|
+
|
|
37
|
+
def _get_recipe_cache_dir() -> str:
|
|
38
|
+
"""Get path to recipe cache directory."""
|
|
39
|
+
cache_dir = os.path.expanduser("~/.cache/bit")
|
|
40
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
41
|
+
return cache_dir
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_cache_hash(layer_paths: List[str]) -> str:
|
|
45
|
+
"""Generate cache hash from sorted layer paths."""
|
|
46
|
+
sorted_paths = sorted(layer_paths)
|
|
47
|
+
content = "\n".join(sorted_paths)
|
|
48
|
+
return hashlib.md5(content.encode()).hexdigest()[:12]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_recipe_cache(layer_paths: List[str]) -> Optional[List[dict]]:
|
|
52
|
+
"""Load recipe cache if valid (< 5 minutes old)."""
|
|
53
|
+
cache_dir = _get_recipe_cache_dir()
|
|
54
|
+
cache_hash = _get_cache_hash(layer_paths)
|
|
55
|
+
cache_path = os.path.join(cache_dir, f"recipe-index-{cache_hash}.json")
|
|
56
|
+
|
|
57
|
+
if not os.path.isfile(cache_path):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
cache_age = time.time() - os.path.getmtime(cache_path)
|
|
62
|
+
if cache_age > 300: # 5 minutes TTL
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
with open(cache_path, "r") as f:
|
|
66
|
+
data = json.load(f)
|
|
67
|
+
return data.get("recipes")
|
|
68
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _save_recipe_cache(layer_paths: List[str], recipes: List[dict]) -> None:
|
|
73
|
+
"""Save recipe cache."""
|
|
74
|
+
cache_dir = _get_recipe_cache_dir()
|
|
75
|
+
cache_hash = _get_cache_hash(layer_paths)
|
|
76
|
+
cache_path = os.path.join(cache_dir, f"recipe-index-{cache_hash}.json")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with open(cache_path, "w") as f:
|
|
80
|
+
json.dump({
|
|
81
|
+
"timestamp": time.time(),
|
|
82
|
+
"recipes": recipes,
|
|
83
|
+
}, f)
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# =============================================================================
|
|
89
|
+
# Recipe Metadata Parsing
|
|
90
|
+
# =============================================================================
|
|
91
|
+
|
|
92
|
+
def _parse_recipe_metadata(filepath: str) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Extract metadata from a .bb recipe file using regex.
|
|
95
|
+
|
|
96
|
+
Returns dict with:
|
|
97
|
+
- SUMMARY, DESCRIPTION, LICENSE, HOMEPAGE, SECTION, DEPENDS
|
|
98
|
+
"""
|
|
99
|
+
metadata = {
|
|
100
|
+
"SUMMARY": "",
|
|
101
|
+
"DESCRIPTION": "",
|
|
102
|
+
"LICENSE": "",
|
|
103
|
+
"HOMEPAGE": "",
|
|
104
|
+
"SECTION": "",
|
|
105
|
+
"DEPENDS": "",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
|
110
|
+
content = f.read()
|
|
111
|
+
except (OSError, IOError):
|
|
112
|
+
return metadata
|
|
113
|
+
|
|
114
|
+
# Parse each variable
|
|
115
|
+
for var in metadata.keys():
|
|
116
|
+
# Match VAR = "value" or VAR = 'value'
|
|
117
|
+
# Also handle ?= and ??= but prefer =
|
|
118
|
+
patterns = [
|
|
119
|
+
rf'^{var}\s*=\s*"([^"]*)"',
|
|
120
|
+
rf"^{var}\s*=\s*'([^']*)'",
|
|
121
|
+
rf'^{var}\s*\?=\s*"([^"]*)"',
|
|
122
|
+
rf"^{var}\s*\?=\s*'([^']*)'",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
for pattern in patterns:
|
|
126
|
+
match = re.search(pattern, content, re.MULTILINE)
|
|
127
|
+
if match:
|
|
128
|
+
metadata[var] = match.group(1).strip()
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
return metadata
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _extract_pn_pv(filename: str) -> Tuple[str, str]:
|
|
135
|
+
"""
|
|
136
|
+
Extract PN (recipe name) and PV (version) from filename.
|
|
137
|
+
|
|
138
|
+
Handles:
|
|
139
|
+
- recipe_version.bb -> (recipe, version)
|
|
140
|
+
- recipe.bb -> (recipe, "")
|
|
141
|
+
- recipe-name_1.2.3.bb -> (recipe-name, 1.2.3)
|
|
142
|
+
- recipe_%.bbappend -> (recipe, %) for version-wildcard appends
|
|
143
|
+
- recipe.bbappend -> (recipe, "")
|
|
144
|
+
"""
|
|
145
|
+
# Remove .bb or .bbappend extension
|
|
146
|
+
if filename.endswith(".bbappend"):
|
|
147
|
+
basename = filename[:-9]
|
|
148
|
+
elif filename.endswith(".bb"):
|
|
149
|
+
basename = filename[:-3]
|
|
150
|
+
else:
|
|
151
|
+
basename = filename
|
|
152
|
+
|
|
153
|
+
# Split on underscore for version
|
|
154
|
+
if "_" in basename:
|
|
155
|
+
parts = basename.rsplit("_", 1)
|
|
156
|
+
pn = parts[0]
|
|
157
|
+
pv = parts[1] if len(parts) > 1 else ""
|
|
158
|
+
else:
|
|
159
|
+
pn = basename
|
|
160
|
+
pv = ""
|
|
161
|
+
|
|
162
|
+
return pn, pv
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# =============================================================================
|
|
166
|
+
# Recipe Scanning
|
|
167
|
+
# =============================================================================
|
|
168
|
+
|
|
169
|
+
def _scan_recipes_from_layers(
|
|
170
|
+
layer_paths: List[str],
|
|
171
|
+
force: bool = False,
|
|
172
|
+
progress: bool = True,
|
|
173
|
+
) -> List[dict]:
|
|
174
|
+
"""
|
|
175
|
+
Scan layers for recipes using file glob.
|
|
176
|
+
|
|
177
|
+
Returns list of recipe dicts with:
|
|
178
|
+
- pn, pv, path, layer, layer_name
|
|
179
|
+
- SUMMARY, DESCRIPTION, LICENSE, HOMEPAGE, SECTION, DEPENDS
|
|
180
|
+
"""
|
|
181
|
+
if not force:
|
|
182
|
+
cached = _load_recipe_cache(layer_paths)
|
|
183
|
+
if cached is not None:
|
|
184
|
+
return cached
|
|
185
|
+
|
|
186
|
+
recipes = []
|
|
187
|
+
seen_recipes = set() # Track (pn, layer) to avoid duplicates
|
|
188
|
+
|
|
189
|
+
if progress:
|
|
190
|
+
print(Colors.dim("Scanning recipes..."), end=" ", flush=True)
|
|
191
|
+
|
|
192
|
+
total_layers = len(layer_paths)
|
|
193
|
+
|
|
194
|
+
for idx, layer_path in enumerate(layer_paths):
|
|
195
|
+
layer_name = layer_display_name(layer_path)
|
|
196
|
+
|
|
197
|
+
# Glob for recipes: recipes-*/*/*.bb and recipes-*/*/*.bbappend
|
|
198
|
+
pattern = os.path.join(layer_path, "recipes-*", "*", "*.bb")
|
|
199
|
+
bb_files = glob.glob(pattern)
|
|
200
|
+
|
|
201
|
+
# Also check for recipes directly in recipes-*/*.bb (less common)
|
|
202
|
+
pattern2 = os.path.join(layer_path, "recipes-*", "*.bb")
|
|
203
|
+
bb_files.extend(glob.glob(pattern2))
|
|
204
|
+
|
|
205
|
+
# Also glob for bbappend files
|
|
206
|
+
pattern_append = os.path.join(layer_path, "recipes-*", "*", "*.bbappend")
|
|
207
|
+
bb_files.extend(glob.glob(pattern_append))
|
|
208
|
+
pattern_append2 = os.path.join(layer_path, "recipes-*", "*.bbappend")
|
|
209
|
+
bb_files.extend(glob.glob(pattern_append2))
|
|
210
|
+
|
|
211
|
+
for bb_path in bb_files:
|
|
212
|
+
filename = os.path.basename(bb_path)
|
|
213
|
+
|
|
214
|
+
# Determine if this is a recipe or append
|
|
215
|
+
is_append = filename.endswith(".bbappend")
|
|
216
|
+
|
|
217
|
+
pn, pv = _extract_pn_pv(filename)
|
|
218
|
+
|
|
219
|
+
# Track unique entries per layer (separate tracking for recipes vs appends)
|
|
220
|
+
entry_type = "append" if is_append else "recipe"
|
|
221
|
+
key = (pn, layer_path, entry_type)
|
|
222
|
+
if key in seen_recipes:
|
|
223
|
+
continue
|
|
224
|
+
seen_recipes.add(key)
|
|
225
|
+
|
|
226
|
+
# Parse metadata
|
|
227
|
+
metadata = _parse_recipe_metadata(bb_path)
|
|
228
|
+
|
|
229
|
+
recipes.append({
|
|
230
|
+
"pn": pn,
|
|
231
|
+
"pv": pv,
|
|
232
|
+
"path": bb_path,
|
|
233
|
+
"layer": layer_path,
|
|
234
|
+
"layer_name": layer_name,
|
|
235
|
+
"type": entry_type,
|
|
236
|
+
**metadata,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
if progress:
|
|
240
|
+
print(Colors.dim(f"{len(recipes)} recipes found"))
|
|
241
|
+
|
|
242
|
+
# Save to cache
|
|
243
|
+
_save_recipe_cache(layer_paths, recipes)
|
|
244
|
+
|
|
245
|
+
return recipes
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _scan_configured_recipes(
|
|
249
|
+
bblayers_path: str,
|
|
250
|
+
force: bool = False,
|
|
251
|
+
use_bitbake_layers: bool = True,
|
|
252
|
+
) -> Tuple[List[dict], List[str]]:
|
|
253
|
+
"""
|
|
254
|
+
Get recipes from configured layers.
|
|
255
|
+
|
|
256
|
+
First tries bitbake-layers show-recipes if available and use_bitbake_layers is True,
|
|
257
|
+
then falls back to file scan.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
bblayers_path: Path to bblayers.conf
|
|
261
|
+
force: Force cache rebuild
|
|
262
|
+
use_bitbake_layers: If True, try bitbake-layers first (default True)
|
|
263
|
+
|
|
264
|
+
Returns (recipes, layer_paths).
|
|
265
|
+
"""
|
|
266
|
+
# Get configured layer paths
|
|
267
|
+
try:
|
|
268
|
+
from .common import extract_layer_paths
|
|
269
|
+
layer_paths = extract_layer_paths(bblayers_path)
|
|
270
|
+
except SystemExit:
|
|
271
|
+
return [], []
|
|
272
|
+
|
|
273
|
+
# Try bitbake-layers if available and enabled (more accurate, includes bbappends)
|
|
274
|
+
if use_bitbake_layers and shutil.which("bitbake-layers") and not force:
|
|
275
|
+
recipes = _try_bitbake_layers_recipes()
|
|
276
|
+
if recipes:
|
|
277
|
+
return recipes, layer_paths
|
|
278
|
+
|
|
279
|
+
# Fall back to file scan
|
|
280
|
+
recipes = _scan_recipes_from_layers(layer_paths, force=force)
|
|
281
|
+
return recipes, layer_paths
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _try_bitbake_layers_recipes() -> Optional[List[dict]]:
|
|
285
|
+
"""
|
|
286
|
+
Try to get recipes via bitbake-layers show-recipes.
|
|
287
|
+
|
|
288
|
+
Returns list of recipe dicts or None if unavailable/failed.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
result = subprocess.run(
|
|
292
|
+
["bitbake-layers", "show-recipes", "-f"],
|
|
293
|
+
capture_output=True,
|
|
294
|
+
text=True,
|
|
295
|
+
timeout=60,
|
|
296
|
+
)
|
|
297
|
+
if result.returncode != 0:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
# Parse output - format is:
|
|
301
|
+
# recipe-name:
|
|
302
|
+
# layer-name version
|
|
303
|
+
recipes = []
|
|
304
|
+
current_pn = None
|
|
305
|
+
for line in result.stdout.splitlines():
|
|
306
|
+
line = line.rstrip()
|
|
307
|
+
if not line:
|
|
308
|
+
continue
|
|
309
|
+
if line.endswith(":") and not line.startswith(" "):
|
|
310
|
+
current_pn = line[:-1].strip()
|
|
311
|
+
elif line.startswith(" ") and current_pn:
|
|
312
|
+
# Parse " layer-name version"
|
|
313
|
+
parts = line.strip().split()
|
|
314
|
+
if len(parts) >= 2:
|
|
315
|
+
layer_name = parts[0]
|
|
316
|
+
pv = parts[1]
|
|
317
|
+
recipes.append({
|
|
318
|
+
"pn": current_pn,
|
|
319
|
+
"pv": pv,
|
|
320
|
+
"path": "", # bitbake-layers doesn't give path
|
|
321
|
+
"layer": "",
|
|
322
|
+
"layer_name": layer_name,
|
|
323
|
+
"SUMMARY": "",
|
|
324
|
+
"DESCRIPTION": "",
|
|
325
|
+
"LICENSE": "",
|
|
326
|
+
"HOMEPAGE": "",
|
|
327
|
+
"SECTION": "",
|
|
328
|
+
"DEPENDS": "",
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
return recipes if recipes else None
|
|
332
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# =============================================================================
|
|
337
|
+
# Layer Index API
|
|
338
|
+
# =============================================================================
|
|
339
|
+
|
|
340
|
+
def _get_recipe_index_cache_path(branch: str) -> str:
|
|
341
|
+
"""Get path to recipe index API cache file."""
|
|
342
|
+
cache_dir = _get_recipe_cache_dir()
|
|
343
|
+
return os.path.join(cache_dir, f"recipe-api-{branch}.json")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _fetch_recipe_index(branch: str = "master", force: bool = False, search: str = "") -> Optional[List[dict]]:
|
|
347
|
+
"""
|
|
348
|
+
Fetch recipes from layers.openembedded.org API.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
branch: Branch to filter by (default: master)
|
|
352
|
+
force: Force refresh, ignore cache
|
|
353
|
+
search: Optional search query (sent to API for server-side filtering)
|
|
354
|
+
|
|
355
|
+
Returns list of recipe dicts or None on error.
|
|
356
|
+
"""
|
|
357
|
+
# Only use cache for full fetches (no search query)
|
|
358
|
+
cache_path = _get_recipe_index_cache_path(branch)
|
|
359
|
+
cache_max_age = 2 * 60 * 60 # 2 hours
|
|
360
|
+
|
|
361
|
+
if not search and not force and os.path.isfile(cache_path):
|
|
362
|
+
try:
|
|
363
|
+
cache_age = time.time() - os.path.getmtime(cache_path)
|
|
364
|
+
if cache_age < cache_max_age:
|
|
365
|
+
with open(cache_path, "r") as f:
|
|
366
|
+
data = json.load(f)
|
|
367
|
+
return data.get("recipes")
|
|
368
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
# Fetch from API
|
|
372
|
+
# The recipes endpoint is paginated, we need to fetch all pages
|
|
373
|
+
api_base = "https://layers.openembedded.org/layerindex/api/recipes/"
|
|
374
|
+
all_recipes = []
|
|
375
|
+
|
|
376
|
+
# Build URL with optional search parameter
|
|
377
|
+
if search:
|
|
378
|
+
encoded_search = urllib.parse.quote(search)
|
|
379
|
+
next_url = f"{api_base}?format=json&search={encoded_search}"
|
|
380
|
+
print(Colors.dim(f"Searching index for '{search}'..."), end=" ", flush=True)
|
|
381
|
+
else:
|
|
382
|
+
next_url = f"{api_base}?format=json"
|
|
383
|
+
print(Colors.dim("Fetching recipe index..."), end=" ", flush=True)
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
page = 0
|
|
387
|
+
while next_url and page < 50: # Safety limit
|
|
388
|
+
page += 1
|
|
389
|
+
with urllib.request.urlopen(next_url, timeout=30) as resp:
|
|
390
|
+
data = json.loads(resp.read().decode())
|
|
391
|
+
|
|
392
|
+
# API may return paginated results
|
|
393
|
+
if isinstance(data, list):
|
|
394
|
+
all_recipes.extend(data)
|
|
395
|
+
next_url = None
|
|
396
|
+
elif isinstance(data, dict):
|
|
397
|
+
results = data.get("results", data.get("objects", []))
|
|
398
|
+
all_recipes.extend(results)
|
|
399
|
+
next_url = data.get("next")
|
|
400
|
+
else:
|
|
401
|
+
break
|
|
402
|
+
|
|
403
|
+
print(Colors.dim(f"{len(all_recipes)} recipes"))
|
|
404
|
+
|
|
405
|
+
# Transform to our format
|
|
406
|
+
recipes = []
|
|
407
|
+
for entry in all_recipes:
|
|
408
|
+
# Get branch info - filter by branch
|
|
409
|
+
lb = entry.get("layerbranch", {})
|
|
410
|
+
branch_info = lb.get("branch", {}) if isinstance(lb, dict) else {}
|
|
411
|
+
entry_branch = branch_info.get("name", "") if isinstance(branch_info, dict) else ""
|
|
412
|
+
|
|
413
|
+
# The API structure varies - adapt as needed
|
|
414
|
+
pn = entry.get("pn", entry.get("name", ""))
|
|
415
|
+
pv = entry.get("pv", "")
|
|
416
|
+
summary = entry.get("summary", "")
|
|
417
|
+
description = entry.get("description", "")
|
|
418
|
+
section = entry.get("section", "")
|
|
419
|
+
license_val = entry.get("license", "")
|
|
420
|
+
homepage = entry.get("homepage", "")
|
|
421
|
+
|
|
422
|
+
# Get layer name from layerbranch
|
|
423
|
+
layer_name = ""
|
|
424
|
+
if isinstance(lb, dict):
|
|
425
|
+
layer_info = lb.get("layer", {})
|
|
426
|
+
if isinstance(layer_info, dict):
|
|
427
|
+
layer_name = layer_info.get("name", "")
|
|
428
|
+
|
|
429
|
+
if pn:
|
|
430
|
+
recipes.append({
|
|
431
|
+
"pn": pn,
|
|
432
|
+
"pv": pv,
|
|
433
|
+
"path": "", # Remote, no local path
|
|
434
|
+
"layer": "",
|
|
435
|
+
"layer_name": layer_name,
|
|
436
|
+
"SUMMARY": summary,
|
|
437
|
+
"DESCRIPTION": description,
|
|
438
|
+
"LICENSE": license_val,
|
|
439
|
+
"HOMEPAGE": homepage,
|
|
440
|
+
"SECTION": section,
|
|
441
|
+
"DEPENDS": "",
|
|
442
|
+
"source": "index",
|
|
443
|
+
"branch": entry_branch,
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
# Save to cache
|
|
447
|
+
try:
|
|
448
|
+
with open(cache_path, "w") as f:
|
|
449
|
+
json.dump({"timestamp": time.time(), "recipes": recipes}, f)
|
|
450
|
+
except OSError:
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
return recipes
|
|
454
|
+
|
|
455
|
+
except urllib.error.URLError as e:
|
|
456
|
+
print(Colors.dim("failed"))
|
|
457
|
+
# Try stale cache
|
|
458
|
+
if os.path.isfile(cache_path):
|
|
459
|
+
try:
|
|
460
|
+
with open(cache_path, "r") as f:
|
|
461
|
+
data = json.load(f)
|
|
462
|
+
if data.get("recipes"):
|
|
463
|
+
print(Colors.yellow(f"Using cached data (network error)"))
|
|
464
|
+
return data["recipes"]
|
|
465
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
466
|
+
pass
|
|
467
|
+
print(f"Error fetching recipe index: {e}")
|
|
468
|
+
return None
|
|
469
|
+
except json.JSONDecodeError as e:
|
|
470
|
+
print(Colors.dim("failed"))
|
|
471
|
+
print(f"Error parsing response: {e}")
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# =============================================================================
|
|
476
|
+
# FZF Browser
|
|
477
|
+
# =============================================================================
|
|
478
|
+
|
|
479
|
+
def _build_recipe_menu(
|
|
480
|
+
recipes: List[dict],
|
|
481
|
+
source_name: str,
|
|
482
|
+
) -> str:
|
|
483
|
+
"""Build fzf menu input for recipe list."""
|
|
484
|
+
if not recipes:
|
|
485
|
+
return ""
|
|
486
|
+
|
|
487
|
+
# Separate recipes from appends
|
|
488
|
+
regular_recipes = [r for r in recipes if r.get("type") != "append"]
|
|
489
|
+
append_recipes = [r for r in recipes if r.get("type") == "append"]
|
|
490
|
+
|
|
491
|
+
# Calculate column widths across all entries
|
|
492
|
+
all_recipes = regular_recipes + append_recipes
|
|
493
|
+
max_pn_len = max(len(r["pn"]) for r in all_recipes) if all_recipes else 20
|
|
494
|
+
max_pn_len = min(max_pn_len, 30)
|
|
495
|
+
max_pv_len = max(len(r.get("pv", "")) for r in all_recipes) if all_recipes else 10
|
|
496
|
+
max_pv_len = min(max_pv_len, 15)
|
|
497
|
+
max_layer_len = max(len(r.get("layer_name", "")) for r in all_recipes) if all_recipes else 15
|
|
498
|
+
max_layer_len = min(max_layer_len, 20)
|
|
499
|
+
|
|
500
|
+
menu_lines = []
|
|
501
|
+
|
|
502
|
+
def format_recipe(recipe, is_append=False):
|
|
503
|
+
pn = recipe["pn"][:30]
|
|
504
|
+
pv = recipe.get("pv", "")[:15]
|
|
505
|
+
layer_name = recipe.get("layer_name", "")[:20]
|
|
506
|
+
summary = recipe.get("SUMMARY", "")[:50]
|
|
507
|
+
path = recipe.get("path", "")
|
|
508
|
+
|
|
509
|
+
# Color based on type and source
|
|
510
|
+
is_remote = recipe.get("source") == "index"
|
|
511
|
+
if is_append:
|
|
512
|
+
# Appends shown in yellow/magenta
|
|
513
|
+
colored_pn = Colors.yellow(f"{pn:<{max_pn_len}}")
|
|
514
|
+
type_marker = Colors.dim("(append)")
|
|
515
|
+
elif is_remote:
|
|
516
|
+
colored_pn = Colors.cyan(f"{pn:<{max_pn_len}}")
|
|
517
|
+
type_marker = ""
|
|
518
|
+
else:
|
|
519
|
+
colored_pn = Colors.green(f"{pn:<{max_pn_len}}")
|
|
520
|
+
type_marker = ""
|
|
521
|
+
|
|
522
|
+
if type_marker:
|
|
523
|
+
line = f"{path}\t{colored_pn} {pv:<{max_pv_len}} {layer_name:<{max_layer_len}} {type_marker} {summary}"
|
|
524
|
+
else:
|
|
525
|
+
line = f"{path}\t{colored_pn} {pv:<{max_pv_len}} {layer_name:<{max_layer_len}} {summary}"
|
|
526
|
+
return line
|
|
527
|
+
|
|
528
|
+
# Add regular recipes first
|
|
529
|
+
for recipe in regular_recipes:
|
|
530
|
+
menu_lines.append(format_recipe(recipe, is_append=False))
|
|
531
|
+
|
|
532
|
+
# Add separator if we have both types
|
|
533
|
+
if regular_recipes and append_recipes:
|
|
534
|
+
# fzf separator line (non-selectable)
|
|
535
|
+
menu_lines.append(f"---\t{Colors.dim('── Appends (' + str(len(append_recipes)) + ') ──')}")
|
|
536
|
+
|
|
537
|
+
# Add appends
|
|
538
|
+
for recipe in append_recipes:
|
|
539
|
+
menu_lines.append(format_recipe(recipe, is_append=True))
|
|
540
|
+
|
|
541
|
+
return "\n".join(menu_lines)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _build_recipe_preview(recipe: dict) -> str:
|
|
545
|
+
"""Build preview content for a recipe."""
|
|
546
|
+
lines = []
|
|
547
|
+
|
|
548
|
+
pn = recipe.get("pn", "")
|
|
549
|
+
pv = recipe.get("pv", "")
|
|
550
|
+
layer_name = recipe.get("layer_name", "")
|
|
551
|
+
path = recipe.get("path", "")
|
|
552
|
+
|
|
553
|
+
lines.append(f"{Colors.bold(pn)}")
|
|
554
|
+
if pv:
|
|
555
|
+
lines.append(f"Version: {pv}")
|
|
556
|
+
lines.append(f"Layer: {layer_name}")
|
|
557
|
+
if path:
|
|
558
|
+
lines.append(f"Path: {path}")
|
|
559
|
+
lines.append("")
|
|
560
|
+
|
|
561
|
+
summary = recipe.get("SUMMARY", "")
|
|
562
|
+
if summary:
|
|
563
|
+
lines.append(f"{Colors.cyan('Summary:')}")
|
|
564
|
+
lines.append(f" {summary}")
|
|
565
|
+
lines.append("")
|
|
566
|
+
|
|
567
|
+
description = recipe.get("DESCRIPTION", "")
|
|
568
|
+
if description:
|
|
569
|
+
lines.append(f"{Colors.cyan('Description:')}")
|
|
570
|
+
# Word wrap
|
|
571
|
+
for line in description.split("\n"):
|
|
572
|
+
lines.append(f" {line}")
|
|
573
|
+
lines.append("")
|
|
574
|
+
|
|
575
|
+
license_val = recipe.get("LICENSE", "")
|
|
576
|
+
if license_val:
|
|
577
|
+
lines.append(f"{Colors.cyan('License:')} {license_val}")
|
|
578
|
+
|
|
579
|
+
homepage = recipe.get("HOMEPAGE", "")
|
|
580
|
+
if homepage:
|
|
581
|
+
lines.append(f"{Colors.cyan('Homepage:')} {homepage}")
|
|
582
|
+
|
|
583
|
+
section = recipe.get("SECTION", "")
|
|
584
|
+
if section:
|
|
585
|
+
lines.append(f"{Colors.cyan('Section:')} {section}")
|
|
586
|
+
|
|
587
|
+
depends = recipe.get("DEPENDS", "")
|
|
588
|
+
if depends:
|
|
589
|
+
lines.append(f"{Colors.cyan('Depends:')} {depends}")
|
|
590
|
+
|
|
591
|
+
return "\n".join(lines)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _recipe_fzf_browser(
|
|
595
|
+
recipes: List[dict],
|
|
596
|
+
source_name: str,
|
|
597
|
+
query: str = "",
|
|
598
|
+
allow_source_switch: bool = True,
|
|
599
|
+
is_remote: bool = False,
|
|
600
|
+
available_sources: List[str] = None,
|
|
601
|
+
):
|
|
602
|
+
"""
|
|
603
|
+
Interactive fzf browser for recipes.
|
|
604
|
+
|
|
605
|
+
Key bindings:
|
|
606
|
+
- Enter: View recipe in $EDITOR or less
|
|
607
|
+
- ctrl-e: Edit recipe in $EDITOR (disabled for remote)
|
|
608
|
+
- alt-c: Copy path to clipboard
|
|
609
|
+
- ctrl-f: Show full file in preview
|
|
610
|
+
- ctrl-i: Show metadata info in preview
|
|
611
|
+
- alt-s: Cycle through sources (Configured → Local → Index)
|
|
612
|
+
- left: Go back to source selection menu
|
|
613
|
+
- pgup/pgdn: Scroll preview
|
|
614
|
+
- ?: Toggle preview
|
|
615
|
+
- esc: Quit
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
- 0: Normal exit
|
|
619
|
+
- 1: Error
|
|
620
|
+
- "menu": Go back to source selection menu
|
|
621
|
+
- ("switch", source): Switch to specified source
|
|
622
|
+
"""
|
|
623
|
+
if not recipes:
|
|
624
|
+
print("No recipes found.")
|
|
625
|
+
return 1
|
|
626
|
+
|
|
627
|
+
if not fzf_available():
|
|
628
|
+
# Fall back to text list
|
|
629
|
+
for recipe in recipes:
|
|
630
|
+
pn = recipe.get("pn", "")
|
|
631
|
+
pv = recipe.get("pv", "")
|
|
632
|
+
layer_name = recipe.get("layer_name", "")
|
|
633
|
+
print(f" {Colors.green(pn):<30} {pv:<15} {layer_name}")
|
|
634
|
+
return 0
|
|
635
|
+
|
|
636
|
+
# Build menu
|
|
637
|
+
menu_input = _build_recipe_menu(recipes, source_name)
|
|
638
|
+
|
|
639
|
+
# Build preview script
|
|
640
|
+
# We'll use temp files - one for data, one for the preview script
|
|
641
|
+
import tempfile
|
|
642
|
+
preview_data = {r.get("path", r.get("pn", "")): r for r in recipes}
|
|
643
|
+
preview_data_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
|
|
644
|
+
json.dump(preview_data, preview_data_file)
|
|
645
|
+
preview_data_file.close()
|
|
646
|
+
|
|
647
|
+
# Create a preview script file to avoid shell escaping issues
|
|
648
|
+
preview_script = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False)
|
|
649
|
+
preview_script.write(f'''#!/usr/bin/env python3
|
|
650
|
+
import json
|
|
651
|
+
import sys
|
|
652
|
+
|
|
653
|
+
key = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
654
|
+
# Strip any surrounding quotes that fzf might add
|
|
655
|
+
key = key.strip("'\\\"")
|
|
656
|
+
with open("{preview_data_file.name}", "r") as f:
|
|
657
|
+
data = json.load(f)
|
|
658
|
+
|
|
659
|
+
recipe = data.get(key, {{}})
|
|
660
|
+
if not recipe:
|
|
661
|
+
print(f"No data for: {{key}}")
|
|
662
|
+
sys.exit(0)
|
|
663
|
+
|
|
664
|
+
pn = recipe.get("pn", "")
|
|
665
|
+
pv = recipe.get("pv", "")
|
|
666
|
+
layer_name = recipe.get("layer_name", "")
|
|
667
|
+
path = recipe.get("path", "")
|
|
668
|
+
summary = recipe.get("SUMMARY", "")
|
|
669
|
+
description = recipe.get("DESCRIPTION", "")
|
|
670
|
+
license_val = recipe.get("LICENSE", "")
|
|
671
|
+
homepage = recipe.get("HOMEPAGE", "")
|
|
672
|
+
section = recipe.get("SECTION", "")
|
|
673
|
+
depends = recipe.get("DEPENDS", "")
|
|
674
|
+
|
|
675
|
+
print(f"\\033[1m{{pn}}\\033[0m")
|
|
676
|
+
if pv:
|
|
677
|
+
print(f"Version: {{pv}}")
|
|
678
|
+
print(f"Layer: {{layer_name}}")
|
|
679
|
+
if path:
|
|
680
|
+
print(f"Path: {{path}}")
|
|
681
|
+
print()
|
|
682
|
+
if summary:
|
|
683
|
+
print("\\033[36mSummary:\\033[0m")
|
|
684
|
+
print(f" {{summary}}")
|
|
685
|
+
print()
|
|
686
|
+
if description:
|
|
687
|
+
print("\\033[36mDescription:\\033[0m")
|
|
688
|
+
for line in description.split("\\n"):
|
|
689
|
+
print(f" {{line}}")
|
|
690
|
+
print()
|
|
691
|
+
if license_val:
|
|
692
|
+
print(f"\\033[36mLicense:\\033[0m {{license_val}}")
|
|
693
|
+
if homepage:
|
|
694
|
+
print(f"\\033[36mHomepage:\\033[0m {{homepage}}")
|
|
695
|
+
if section:
|
|
696
|
+
print(f"\\033[36mSection:\\033[0m {{section}}")
|
|
697
|
+
if depends:
|
|
698
|
+
print(f"\\033[36mDepends:\\033[0m {{depends}}")
|
|
699
|
+
''')
|
|
700
|
+
preview_script.close()
|
|
701
|
+
|
|
702
|
+
# Preview commands - metadata view and full file view
|
|
703
|
+
# Note: fzf's {1} is already shell-quoted, don't add extra quotes
|
|
704
|
+
preview_metadata = f'python3 "{preview_script.name}" {{1}}'
|
|
705
|
+
# For file preview, check if file exists (remote recipes won't have local path)
|
|
706
|
+
preview_file = 'if [ -f {1} ]; then cat {1}; else echo "Remote recipe - no local file available"; echo ""; echo "Path: {1}"; fi'
|
|
707
|
+
|
|
708
|
+
# Build header - three lines to fit all keybindings (use ctrl/alt so typing works)
|
|
709
|
+
header_line1 = f"[{source_name}] Type to filter"
|
|
710
|
+
header_line2 = "Enter=view | "
|
|
711
|
+
if not is_remote:
|
|
712
|
+
header_line2 += "ctrl-e=edit | "
|
|
713
|
+
header_line2 += "alt-c=copy | alt-d=deps | ctrl-f=file | ctrl-i=info"
|
|
714
|
+
header_line3 = "alt-s=switch source | ←=back | pgup/pgdn=scroll | esc=quit"
|
|
715
|
+
header = f"{header_line1}\n{header_line2}\n{header_line3}"
|
|
716
|
+
|
|
717
|
+
# Determine expected keys (use modifiers so typing works)
|
|
718
|
+
expect_keys = ["ctrl-e", "alt-c", "alt-d", "esc", "left", "alt-s"]
|
|
719
|
+
|
|
720
|
+
preview_window = get_preview_window_arg("50%")
|
|
721
|
+
|
|
722
|
+
fzf_args = [
|
|
723
|
+
"fzf",
|
|
724
|
+
"--no-multi",
|
|
725
|
+
"--ansi",
|
|
726
|
+
"--height", "100%",
|
|
727
|
+
"--layout=reverse-list", # Prompt at bottom, A-Z top to bottom, first item selected
|
|
728
|
+
"--no-hscroll", # Keep recipe name visible, don't scroll to match
|
|
729
|
+
"--header", header,
|
|
730
|
+
"--prompt", f"Recipe ({source_name}): ",
|
|
731
|
+
"--with-nth", "2..",
|
|
732
|
+
"--delimiter", "\t",
|
|
733
|
+
"--preview", preview_metadata,
|
|
734
|
+
"--preview-window", preview_window,
|
|
735
|
+
"--bind", "?:toggle-preview",
|
|
736
|
+
"--bind", f"ctrl-f:change-preview({preview_file})",
|
|
737
|
+
"--bind", f"ctrl-i:change-preview({preview_metadata})",
|
|
738
|
+
"--bind", "pgup:preview-page-up",
|
|
739
|
+
"--bind", "pgdn:preview-page-down",
|
|
740
|
+
"--bind", "esc:abort",
|
|
741
|
+
"--expect", ",".join(expect_keys),
|
|
742
|
+
]
|
|
743
|
+
|
|
744
|
+
# Add initial query if provided
|
|
745
|
+
if query:
|
|
746
|
+
fzf_args.extend(["--query", query])
|
|
747
|
+
|
|
748
|
+
fzf_args.extend(get_fzf_preview_resize_bindings())
|
|
749
|
+
fzf_args.extend(get_fzf_color_args())
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
result = subprocess.run(
|
|
753
|
+
fzf_args,
|
|
754
|
+
input=menu_input,
|
|
755
|
+
stdout=subprocess.PIPE,
|
|
756
|
+
text=True,
|
|
757
|
+
)
|
|
758
|
+
except FileNotFoundError:
|
|
759
|
+
return 1
|
|
760
|
+
finally:
|
|
761
|
+
# Clean up temp files
|
|
762
|
+
for f in [preview_data_file.name, preview_script.name]:
|
|
763
|
+
try:
|
|
764
|
+
os.unlink(f)
|
|
765
|
+
except OSError:
|
|
766
|
+
pass
|
|
767
|
+
|
|
768
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
769
|
+
return 0
|
|
770
|
+
|
|
771
|
+
# Parse output
|
|
772
|
+
lines = result.stdout.split("\n")
|
|
773
|
+
key = lines[0].strip() if lines else ""
|
|
774
|
+
selected = lines[1].split("\t")[0].strip() if len(lines) > 1 else ""
|
|
775
|
+
|
|
776
|
+
if not selected:
|
|
777
|
+
return 0
|
|
778
|
+
|
|
779
|
+
# Find the selected recipe
|
|
780
|
+
recipe = None
|
|
781
|
+
for r in recipes:
|
|
782
|
+
if r.get("path") == selected or r.get("pn") == selected:
|
|
783
|
+
recipe = r
|
|
784
|
+
break
|
|
785
|
+
|
|
786
|
+
if not recipe:
|
|
787
|
+
return 0
|
|
788
|
+
|
|
789
|
+
# Handle actions (modifier keys so typing works)
|
|
790
|
+
if key == "alt-c":
|
|
791
|
+
# Copy path to clipboard
|
|
792
|
+
path = recipe.get("path", recipe.get("pn", ""))
|
|
793
|
+
if path:
|
|
794
|
+
_copy_to_clipboard(path)
|
|
795
|
+
print(f"Copied: {path}")
|
|
796
|
+
return _recipe_fzf_browser(recipes, source_name, query, allow_source_switch, is_remote, available_sources)
|
|
797
|
+
|
|
798
|
+
if key == "ctrl-e" and not is_remote:
|
|
799
|
+
# Edit in $EDITOR
|
|
800
|
+
path = recipe.get("path", "")
|
|
801
|
+
if path and os.path.isfile(path):
|
|
802
|
+
editor = os.environ.get("EDITOR", "vi")
|
|
803
|
+
subprocess.run([editor, path])
|
|
804
|
+
return _recipe_fzf_browser(recipes, source_name, query, allow_source_switch, is_remote, available_sources)
|
|
805
|
+
|
|
806
|
+
if key == "alt-d" and not is_remote:
|
|
807
|
+
# Show recipe dependencies
|
|
808
|
+
path = recipe.get("path", "")
|
|
809
|
+
pn = recipe.get("pn", "")
|
|
810
|
+
if path and os.path.isfile(path):
|
|
811
|
+
from .deps import _parse_recipe_depends, _render_recipe_tree_ascii, RecipeInfo
|
|
812
|
+
depends, rdepends = _parse_recipe_depends(path)
|
|
813
|
+
layer_name = recipe.get("layer_name", os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(path)))))
|
|
814
|
+
recipe_info = RecipeInfo(
|
|
815
|
+
name=pn,
|
|
816
|
+
version=recipe.get("pv", ""),
|
|
817
|
+
path=path,
|
|
818
|
+
layer=layer_name,
|
|
819
|
+
depends=depends,
|
|
820
|
+
rdepends=rdepends,
|
|
821
|
+
)
|
|
822
|
+
print(f"\n{_render_recipe_tree_ascii(recipe_info, include_rdepends=True)}\n")
|
|
823
|
+
input("Press Enter to continue...")
|
|
824
|
+
return _recipe_fzf_browser(recipes, source_name, query, allow_source_switch, is_remote, available_sources)
|
|
825
|
+
|
|
826
|
+
if key == "esc":
|
|
827
|
+
return 0
|
|
828
|
+
|
|
829
|
+
if key == "left":
|
|
830
|
+
# Go back to menu
|
|
831
|
+
return "menu"
|
|
832
|
+
|
|
833
|
+
if key == "alt-s":
|
|
834
|
+
# Cycle to next source
|
|
835
|
+
if available_sources and len(available_sources) > 1:
|
|
836
|
+
# Map display names to source keys
|
|
837
|
+
source_key_map = {
|
|
838
|
+
"Configured": "configured",
|
|
839
|
+
"Local": "local",
|
|
840
|
+
"Index": "index",
|
|
841
|
+
}
|
|
842
|
+
# Find current source key - handle combined names like "Configured+Local"
|
|
843
|
+
current_key = None
|
|
844
|
+
source_name_lower = source_name.lower()
|
|
845
|
+
for display, key_name in source_key_map.items():
|
|
846
|
+
if source_name == display or source_name_lower == key_name:
|
|
847
|
+
current_key = key_name
|
|
848
|
+
break
|
|
849
|
+
|
|
850
|
+
# If source_name is combined (e.g., "Configured+Local"), use first available as current
|
|
851
|
+
if current_key is None and "+" in source_name:
|
|
852
|
+
# Take the first part
|
|
853
|
+
first_part = source_name.split("+")[0]
|
|
854
|
+
current_key = source_key_map.get(first_part, first_part.lower())
|
|
855
|
+
|
|
856
|
+
if current_key and current_key in available_sources:
|
|
857
|
+
idx = available_sources.index(current_key)
|
|
858
|
+
next_idx = (idx + 1) % len(available_sources)
|
|
859
|
+
next_source = available_sources[next_idx]
|
|
860
|
+
return ("switch", next_source)
|
|
861
|
+
elif available_sources:
|
|
862
|
+
# Just pick the first available source if current not found
|
|
863
|
+
return ("switch", available_sources[0])
|
|
864
|
+
# Fall back to menu if can't cycle
|
|
865
|
+
return "menu"
|
|
866
|
+
|
|
867
|
+
# Enter - view recipe
|
|
868
|
+
path = recipe.get("path", "")
|
|
869
|
+
if path and os.path.isfile(path):
|
|
870
|
+
# Use $EDITOR if set, otherwise less
|
|
871
|
+
pager = os.environ.get("PAGER", "less")
|
|
872
|
+
subprocess.run([pager, path])
|
|
873
|
+
else:
|
|
874
|
+
# Remote recipe - show preview
|
|
875
|
+
preview = _build_recipe_preview(recipe)
|
|
876
|
+
pager = os.environ.get("PAGER", "less")
|
|
877
|
+
subprocess.run([pager], input=preview, text=True)
|
|
878
|
+
|
|
879
|
+
return _recipe_fzf_browser(recipes, source_name, query, allow_source_switch, is_remote, available_sources)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _copy_to_clipboard(text: str) -> bool:
|
|
883
|
+
"""Copy text to clipboard."""
|
|
884
|
+
clipboard_cmds = [
|
|
885
|
+
["xclip", "-selection", "clipboard"],
|
|
886
|
+
["xsel", "--clipboard", "--input"],
|
|
887
|
+
["pbcopy"],
|
|
888
|
+
]
|
|
889
|
+
|
|
890
|
+
for cmd in clipboard_cmds:
|
|
891
|
+
if shutil.which(cmd[0]):
|
|
892
|
+
try:
|
|
893
|
+
subprocess.run(cmd, input=text, text=True, check=True)
|
|
894
|
+
return True
|
|
895
|
+
except subprocess.CalledProcessError:
|
|
896
|
+
continue
|
|
897
|
+
return False
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
# =============================================================================
|
|
901
|
+
# Source Selection Menu
|
|
902
|
+
# =============================================================================
|
|
903
|
+
|
|
904
|
+
def _source_selection_menu(has_local: bool = True) -> Optional[Tuple[str, str, List[str], List[str], str]]:
|
|
905
|
+
"""
|
|
906
|
+
Show unified source selection menu with toggles and browse/search options.
|
|
907
|
+
|
|
908
|
+
Returns:
|
|
909
|
+
- Tuple of (action, query, sources, search_fields, sort_by) where:
|
|
910
|
+
- action: "browse" or "search"
|
|
911
|
+
- query: what was typed in the prompt
|
|
912
|
+
- sources: list of enabled sources ["configured", "local", "index"]
|
|
913
|
+
- search_fields: list of fields to search ["name", "summary", "description"]
|
|
914
|
+
- sort_by: "name" or "layer"
|
|
915
|
+
- None: Cancelled
|
|
916
|
+
"""
|
|
917
|
+
if not fzf_available():
|
|
918
|
+
# Text-based fallback
|
|
919
|
+
print("\nRecipe Search:")
|
|
920
|
+
if has_local:
|
|
921
|
+
print(" Sources: configured layers, local layers")
|
|
922
|
+
source = input("Enter search term (or empty to browse all): ").strip()
|
|
923
|
+
if source:
|
|
924
|
+
return ("search", source, ["configured", "local"], ["name"], "name")
|
|
925
|
+
return ("browse", "", ["configured", "local"], ["name"], "name")
|
|
926
|
+
else:
|
|
927
|
+
print(" Source: layer index (no local project)")
|
|
928
|
+
source = input("Enter search term (or empty to browse all): ").strip()
|
|
929
|
+
if source:
|
|
930
|
+
return ("search", source, ["index"], ["name"], "name")
|
|
931
|
+
return ("browse", "", ["index"], ["name"], "name")
|
|
932
|
+
|
|
933
|
+
# Build menu with toggles and actions
|
|
934
|
+
menu_lines = []
|
|
935
|
+
|
|
936
|
+
# Source toggles (selected by default based on has_local)
|
|
937
|
+
if has_local:
|
|
938
|
+
menu_lines.append("toggle:configured\t [x] Configured (layers in bblayers.conf)")
|
|
939
|
+
menu_lines.append("toggle:local\t [x] Local (configured + discovered)")
|
|
940
|
+
menu_lines.append("toggle:index\t [ ] Layer index (remote API)")
|
|
941
|
+
|
|
942
|
+
# Separator and actions
|
|
943
|
+
menu_lines.append("---\t " + "─" * 45)
|
|
944
|
+
menu_lines.append("browse\t > Browse (show all, type to filter)")
|
|
945
|
+
menu_lines.append("search\t > Search (type query above, Enter to search)")
|
|
946
|
+
|
|
947
|
+
menu_input = "\n".join(menu_lines)
|
|
948
|
+
|
|
949
|
+
# Track toggle states - sources, search fields, and sort
|
|
950
|
+
toggle_states = {
|
|
951
|
+
"configured": has_local,
|
|
952
|
+
"local": has_local,
|
|
953
|
+
"index": not has_local,
|
|
954
|
+
"name": True, # Search recipe name (default on)
|
|
955
|
+
"summary": False, # Search SUMMARY field
|
|
956
|
+
"description": False, # Search DESCRIPTION field
|
|
957
|
+
"sort_by_layer": False, # Sort by layer instead of name
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
# Create temp files for dynamic menu reload
|
|
961
|
+
import tempfile
|
|
962
|
+
|
|
963
|
+
state_file = tempfile.NamedTemporaryFile(mode='w', suffix='.state', delete=False)
|
|
964
|
+
menu_script = tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False)
|
|
965
|
+
|
|
966
|
+
# Write initial state
|
|
967
|
+
json.dump(toggle_states, state_file)
|
|
968
|
+
state_file.close()
|
|
969
|
+
|
|
970
|
+
# Write menu generation script
|
|
971
|
+
# Order: first items appear at bottom (near prompt) in default fzf layout
|
|
972
|
+
menu_script.write(f'''#!/bin/bash
|
|
973
|
+
state=$(cat "{state_file.name}")
|
|
974
|
+
configured=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('configured') else '[ ]')")
|
|
975
|
+
local=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('local') else '[ ]')")
|
|
976
|
+
index=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('index') else '[ ]')")
|
|
977
|
+
name=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('name') else '[ ]')")
|
|
978
|
+
summary=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('summary') else '[ ]')")
|
|
979
|
+
description=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('description') else '[ ]')")
|
|
980
|
+
sort_layer=$(echo "$state" | python3 -c "import sys,json; d=json.load(sys.stdin); print('layer' if d.get('sort_by_layer') else 'name')")
|
|
981
|
+
# Actions first (at bottom, near prompt)
|
|
982
|
+
echo "search > Search (type query below, Enter)"
|
|
983
|
+
echo "browse > Browse (show all, filter in browser)"
|
|
984
|
+
echo "--- ── Actions ─────────────────────────────────"
|
|
985
|
+
# Sort option
|
|
986
|
+
echo "toggle:sort_by_layer Sort: $sort_layer"
|
|
987
|
+
echo "--- ── Options ─────────────────────────────────"
|
|
988
|
+
# Source toggles (middle section)
|
|
989
|
+
echo "toggle:index $index Index (remote API)"
|
|
990
|
+
''')
|
|
991
|
+
if has_local:
|
|
992
|
+
menu_script.write('''echo "toggle:local $local Local (configured + discovered)"
|
|
993
|
+
echo "toggle:configured $configured Config (layers in bblayers.conf)"
|
|
994
|
+
''')
|
|
995
|
+
menu_script.write('''echo "--- ── Sources ─────────────────────────────────"
|
|
996
|
+
# Search field toggles (at top)
|
|
997
|
+
echo "toggle:description $description Description"
|
|
998
|
+
echo "toggle:summary $summary Summary"
|
|
999
|
+
echo "toggle:name $name Name"
|
|
1000
|
+
echo "--- ── Search In ───────────────────────────────"
|
|
1001
|
+
''')
|
|
1002
|
+
menu_script.close()
|
|
1003
|
+
os.chmod(menu_script.name, 0o755)
|
|
1004
|
+
|
|
1005
|
+
# Write toggle script
|
|
1006
|
+
toggle_script = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False)
|
|
1007
|
+
toggle_script.write(f'''#!/usr/bin/env python3
|
|
1008
|
+
import sys, json
|
|
1009
|
+
key = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
1010
|
+
if key.startswith("toggle:"):
|
|
1011
|
+
source = key.replace("toggle:", "")
|
|
1012
|
+
with open("{state_file.name}", "r") as f:
|
|
1013
|
+
state = json.load(f)
|
|
1014
|
+
state[source] = not state.get(source, False)
|
|
1015
|
+
with open("{state_file.name}", "w") as f:
|
|
1016
|
+
json.dump(state, f)
|
|
1017
|
+
''')
|
|
1018
|
+
toggle_script.close()
|
|
1019
|
+
os.chmod(toggle_script.name, 0o755)
|
|
1020
|
+
|
|
1021
|
+
try:
|
|
1022
|
+
# Generate initial menu
|
|
1023
|
+
initial_menu = subprocess.check_output(["bash", menu_script.name], text=True)
|
|
1024
|
+
|
|
1025
|
+
fzf_args = [
|
|
1026
|
+
"fzf",
|
|
1027
|
+
"--no-multi",
|
|
1028
|
+
"--no-sort",
|
|
1029
|
+
"--ansi",
|
|
1030
|
+
"--disabled", # Disable filtering - query is just captured, not used to filter
|
|
1031
|
+
"--height", "~20",
|
|
1032
|
+
"--header", "Space=toggle | Enter=select | Type query for Search | Esc=quit",
|
|
1033
|
+
"--prompt", "Query: ",
|
|
1034
|
+
"--print-query",
|
|
1035
|
+
"--with-nth", "2..",
|
|
1036
|
+
"--delimiter", "\t",
|
|
1037
|
+
"--bind", f"space:execute-silent(python3 {toggle_script.name} {{1}})+reload(bash {menu_script.name})",
|
|
1038
|
+
"--bind", "esc:abort",
|
|
1039
|
+
]
|
|
1040
|
+
fzf_args.extend(get_fzf_color_args())
|
|
1041
|
+
|
|
1042
|
+
result = subprocess.run(
|
|
1043
|
+
fzf_args,
|
|
1044
|
+
input=initial_menu,
|
|
1045
|
+
stdout=subprocess.PIPE,
|
|
1046
|
+
text=True,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
if result.returncode != 0:
|
|
1050
|
+
return None
|
|
1051
|
+
|
|
1052
|
+
# Read final toggle state
|
|
1053
|
+
with open(state_file.name, "r") as f:
|
|
1054
|
+
toggle_states = json.load(f)
|
|
1055
|
+
|
|
1056
|
+
# Parse output: query on first line, selection on second
|
|
1057
|
+
lines = result.stdout.split("\n")
|
|
1058
|
+
query = lines[0].strip() if len(lines) > 0 else ""
|
|
1059
|
+
selected = lines[1].split("\t")[0].strip() if len(lines) > 1 else ""
|
|
1060
|
+
|
|
1061
|
+
# Handle separator - user somehow selected it, ignore
|
|
1062
|
+
if selected == "---" or selected.startswith("toggle:"):
|
|
1063
|
+
return None
|
|
1064
|
+
|
|
1065
|
+
# Handle browse/search actions
|
|
1066
|
+
sort_by = "layer" if toggle_states.get("sort_by_layer") else "name"
|
|
1067
|
+
if selected == "browse":
|
|
1068
|
+
sources = [s for s, enabled in toggle_states.items() if enabled and s in ("configured", "local", "index")]
|
|
1069
|
+
search_fields = [s for s, enabled in toggle_states.items() if enabled and s in ("name", "summary", "description")]
|
|
1070
|
+
if not sources:
|
|
1071
|
+
print("No sources selected. Enable at least one source.")
|
|
1072
|
+
return None
|
|
1073
|
+
return ("browse", query, sources, search_fields or ["name"], sort_by)
|
|
1074
|
+
elif selected == "search":
|
|
1075
|
+
sources = [s for s, enabled in toggle_states.items() if enabled and s in ("configured", "local", "index")]
|
|
1076
|
+
search_fields = [s for s, enabled in toggle_states.items() if enabled and s in ("name", "summary", "description")]
|
|
1077
|
+
if not sources:
|
|
1078
|
+
print("No sources selected. Enable at least one source.")
|
|
1079
|
+
return None
|
|
1080
|
+
if not query:
|
|
1081
|
+
print("No query entered. Type a search term first.")
|
|
1082
|
+
return None
|
|
1083
|
+
return ("search", query, sources, search_fields or ["name"], sort_by)
|
|
1084
|
+
|
|
1085
|
+
return None
|
|
1086
|
+
|
|
1087
|
+
finally:
|
|
1088
|
+
# Clean up temp files
|
|
1089
|
+
for f in [state_file.name, menu_script.name, toggle_script.name]:
|
|
1090
|
+
try:
|
|
1091
|
+
os.unlink(f)
|
|
1092
|
+
except OSError:
|
|
1093
|
+
pass
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
# =============================================================================
|
|
1097
|
+
# Main Entry Point
|
|
1098
|
+
# =============================================================================
|
|
1099
|
+
|
|
1100
|
+
def run_recipe(args) -> int:
|
|
1101
|
+
"""
|
|
1102
|
+
Main entry point for recipe command.
|
|
1103
|
+
|
|
1104
|
+
Args:
|
|
1105
|
+
args: Parsed command line arguments with:
|
|
1106
|
+
- query: Optional search query
|
|
1107
|
+
- browse: Browse all local recipes
|
|
1108
|
+
- configured: Search configured layers only
|
|
1109
|
+
- local: Search all local layers
|
|
1110
|
+
- index: Search layer index API
|
|
1111
|
+
- layer: Filter to specific layer
|
|
1112
|
+
- section: Filter by SECTION
|
|
1113
|
+
- list: Text output (no fzf)
|
|
1114
|
+
- force: Rebuild cache
|
|
1115
|
+
- branch: Branch for layer index (default: master)
|
|
1116
|
+
"""
|
|
1117
|
+
query = getattr(args, "query", None) or ""
|
|
1118
|
+
browse_mode = getattr(args, "browse", False)
|
|
1119
|
+
configured_mode = getattr(args, "configured", False)
|
|
1120
|
+
local_mode = getattr(args, "local", False)
|
|
1121
|
+
index_mode = getattr(args, "index", False)
|
|
1122
|
+
layer_filter = getattr(args, "layer", None)
|
|
1123
|
+
section_filter = getattr(args, "section", None)
|
|
1124
|
+
list_mode = getattr(args, "list", False)
|
|
1125
|
+
force = getattr(args, "force", False)
|
|
1126
|
+
branch = getattr(args, "branch", "master")
|
|
1127
|
+
bblayers = getattr(args, "bblayers", None)
|
|
1128
|
+
|
|
1129
|
+
# Load defaults for configuration options
|
|
1130
|
+
defaults_file = getattr(args, "defaults_file", ".bit.defaults")
|
|
1131
|
+
defaults = load_defaults(defaults_file)
|
|
1132
|
+
|
|
1133
|
+
# Configuration: use bitbake-layers for configured recipes (default: True)
|
|
1134
|
+
# Configurable via 'bit config' -> Settings -> Recipe Scan
|
|
1135
|
+
use_bitbake_layers = get_recipe_use_bitbake_layers()
|
|
1136
|
+
|
|
1137
|
+
# Search field options from CLI
|
|
1138
|
+
search_name = getattr(args, "name", False)
|
|
1139
|
+
search_summary = getattr(args, "summary", False)
|
|
1140
|
+
search_description = getattr(args, "description", False)
|
|
1141
|
+
|
|
1142
|
+
# Sort option from CLI
|
|
1143
|
+
sort_by = getattr(args, "sort", None) or "name" # default: sort by name
|
|
1144
|
+
|
|
1145
|
+
# Check if we have a local project context
|
|
1146
|
+
bblayers_path = resolve_bblayers_path(bblayers)
|
|
1147
|
+
has_local = bblayers_path is not None
|
|
1148
|
+
|
|
1149
|
+
# Determine mode from CLI flags or show unified menu
|
|
1150
|
+
action = "browse" # default
|
|
1151
|
+
sources = []
|
|
1152
|
+
search_fields = ["name"] # default: search name only
|
|
1153
|
+
|
|
1154
|
+
# Build search_fields from CLI options (if any specified)
|
|
1155
|
+
if search_name or search_summary or search_description:
|
|
1156
|
+
search_fields = []
|
|
1157
|
+
if search_name:
|
|
1158
|
+
search_fields.append("name")
|
|
1159
|
+
if search_summary:
|
|
1160
|
+
search_fields.append("summary")
|
|
1161
|
+
if search_description:
|
|
1162
|
+
search_fields.append("description")
|
|
1163
|
+
|
|
1164
|
+
# Track if we're using interactive menu (allows looping back)
|
|
1165
|
+
use_interactive_menu = False
|
|
1166
|
+
|
|
1167
|
+
if browse_mode:
|
|
1168
|
+
action = "browse"
|
|
1169
|
+
sources = ["configured", "local"] if has_local else ["index"]
|
|
1170
|
+
elif configured_mode:
|
|
1171
|
+
# If query provided, use search action to pre-filter
|
|
1172
|
+
action = "search" if query else "browse"
|
|
1173
|
+
sources = ["configured"]
|
|
1174
|
+
elif local_mode:
|
|
1175
|
+
action = "search" if query else "browse"
|
|
1176
|
+
sources = ["local"]
|
|
1177
|
+
elif index_mode:
|
|
1178
|
+
action = "search" if query else "browse"
|
|
1179
|
+
sources = ["index"]
|
|
1180
|
+
elif not has_local:
|
|
1181
|
+
# No local context - auto-select index
|
|
1182
|
+
action = "browse"
|
|
1183
|
+
sources = ["index"]
|
|
1184
|
+
print(Colors.dim("No local project found, using layer index."))
|
|
1185
|
+
else:
|
|
1186
|
+
use_interactive_menu = True
|
|
1187
|
+
|
|
1188
|
+
# Main loop - allows returning to menu from browser
|
|
1189
|
+
while True:
|
|
1190
|
+
if use_interactive_menu:
|
|
1191
|
+
# Show unified source selection menu
|
|
1192
|
+
result = _source_selection_menu(has_local)
|
|
1193
|
+
if not result:
|
|
1194
|
+
return 0
|
|
1195
|
+
action, menu_query, sources, menu_search_fields, menu_sort_by = result
|
|
1196
|
+
# Use menu query
|
|
1197
|
+
query = menu_query
|
|
1198
|
+
# Use menu search fields if no CLI options specified
|
|
1199
|
+
if not (search_name or search_summary or search_description):
|
|
1200
|
+
search_fields = menu_search_fields
|
|
1201
|
+
# Use menu sort_by if no CLI --sort specified
|
|
1202
|
+
if not getattr(args, "sort", None):
|
|
1203
|
+
sort_by = menu_sort_by
|
|
1204
|
+
|
|
1205
|
+
# Fetch recipes from all enabled sources
|
|
1206
|
+
all_recipes = []
|
|
1207
|
+
source_names = []
|
|
1208
|
+
is_remote = False
|
|
1209
|
+
|
|
1210
|
+
# Helper to detect if query looks like a regex pattern
|
|
1211
|
+
def looks_like_regex(q: str) -> bool:
|
|
1212
|
+
regex_chars = set('^$.*+?[](){}|\\')
|
|
1213
|
+
return any(c in regex_chars for c in q)
|
|
1214
|
+
|
|
1215
|
+
def fetch_from_source(src: str) -> List[dict]:
|
|
1216
|
+
nonlocal is_remote
|
|
1217
|
+
if src == "index":
|
|
1218
|
+
is_remote = True
|
|
1219
|
+
# Pass query for server-side search (only for search action)
|
|
1220
|
+
api_search = query if action == "search" else ""
|
|
1221
|
+
# Warn if query looks like regex (index API doesn't support regex)
|
|
1222
|
+
if api_search and looks_like_regex(api_search):
|
|
1223
|
+
print(Colors.yellow(f"Warning: Index API doesn't support regex. '{api_search}' will be treated as literal text."))
|
|
1224
|
+
return _fetch_recipe_index(branch, force, search=api_search) or []
|
|
1225
|
+
elif src == "local":
|
|
1226
|
+
if has_local:
|
|
1227
|
+
pairs, _ = resolve_base_and_layers(bblayers_path, defaults, discover_all=True)
|
|
1228
|
+
layer_paths = dedupe_preserve_order(layer for layer, _ in pairs)
|
|
1229
|
+
return _scan_recipes_from_layers(layer_paths, force=force)
|
|
1230
|
+
return []
|
|
1231
|
+
elif src == "configured":
|
|
1232
|
+
if has_local:
|
|
1233
|
+
recipes, _ = _scan_configured_recipes(bblayers_path, force=force, use_bitbake_layers=use_bitbake_layers)
|
|
1234
|
+
return recipes
|
|
1235
|
+
return []
|
|
1236
|
+
return []
|
|
1237
|
+
|
|
1238
|
+
for src in sources:
|
|
1239
|
+
recipes = fetch_from_source(src)
|
|
1240
|
+
all_recipes.extend(recipes)
|
|
1241
|
+
source_names.append(src.capitalize())
|
|
1242
|
+
|
|
1243
|
+
# Dedupe by (pn, layer_name)
|
|
1244
|
+
seen = set()
|
|
1245
|
+
unique_recipes = []
|
|
1246
|
+
for r in all_recipes:
|
|
1247
|
+
key = (r.get("pn", ""), r.get("layer_name", ""))
|
|
1248
|
+
if key not in seen:
|
|
1249
|
+
seen.add(key)
|
|
1250
|
+
unique_recipes.append(r)
|
|
1251
|
+
recipes = unique_recipes
|
|
1252
|
+
|
|
1253
|
+
source_name = "+".join(source_names) if source_names else "Local"
|
|
1254
|
+
|
|
1255
|
+
# Apply filters
|
|
1256
|
+
if layer_filter:
|
|
1257
|
+
recipes = [r for r in recipes if layer_filter.lower() in r.get("layer_name", "").lower()]
|
|
1258
|
+
|
|
1259
|
+
if section_filter:
|
|
1260
|
+
recipes = [r for r in recipes if section_filter.lower() in r.get("SECTION", "").lower()]
|
|
1261
|
+
|
|
1262
|
+
# Helper to check if recipe matches query based on search_fields (supports regex)
|
|
1263
|
+
def recipe_matches(r: dict, q: str) -> bool:
|
|
1264
|
+
try:
|
|
1265
|
+
pattern = re.compile(q, re.IGNORECASE)
|
|
1266
|
+
except re.error:
|
|
1267
|
+
# Fall back to literal match if invalid regex
|
|
1268
|
+
q_lower = q.lower()
|
|
1269
|
+
if "name" in search_fields and q_lower in r.get("pn", "").lower():
|
|
1270
|
+
return True
|
|
1271
|
+
if "summary" in search_fields and q_lower in r.get("SUMMARY", "").lower():
|
|
1272
|
+
return True
|
|
1273
|
+
if "description" in search_fields and q_lower in r.get("DESCRIPTION", "").lower():
|
|
1274
|
+
return True
|
|
1275
|
+
return False
|
|
1276
|
+
|
|
1277
|
+
if "name" in search_fields and pattern.search(r.get("pn", "")):
|
|
1278
|
+
return True
|
|
1279
|
+
if "summary" in search_fields and pattern.search(r.get("SUMMARY", "")):
|
|
1280
|
+
return True
|
|
1281
|
+
if "description" in search_fields and pattern.search(r.get("DESCRIPTION", "")):
|
|
1282
|
+
return True
|
|
1283
|
+
return False
|
|
1284
|
+
|
|
1285
|
+
# For "search" action, filter by query before showing
|
|
1286
|
+
if action == "search" and query:
|
|
1287
|
+
pre_filter_count = len(recipes)
|
|
1288
|
+
recipes = [r for r in recipes if recipe_matches(r, query)]
|
|
1289
|
+
fields_str = "+".join(search_fields)
|
|
1290
|
+
print(f"Search '{query}' in [{fields_str}]: {len(recipes)} matches (from {pre_filter_count} recipes)")
|
|
1291
|
+
initial_query = "" # Don't re-filter in browser
|
|
1292
|
+
elif list_mode and query:
|
|
1293
|
+
# For --list mode, also filter
|
|
1294
|
+
recipes = [r for r in recipes if recipe_matches(r, query)]
|
|
1295
|
+
initial_query = ""
|
|
1296
|
+
else:
|
|
1297
|
+
initial_query = query
|
|
1298
|
+
|
|
1299
|
+
# Sort recipes
|
|
1300
|
+
if sort_by == "layer":
|
|
1301
|
+
# Sort by layer name, then by recipe name within each layer
|
|
1302
|
+
recipes = sorted(recipes, key=lambda r: (r.get("layer_name", "").lower(), r.get("pn", "").lower()))
|
|
1303
|
+
else:
|
|
1304
|
+
# Sort by recipe name A-Z (default)
|
|
1305
|
+
recipes = sorted(recipes, key=lambda r: r.get("pn", "").lower())
|
|
1306
|
+
|
|
1307
|
+
# Output
|
|
1308
|
+
if list_mode:
|
|
1309
|
+
# Text output
|
|
1310
|
+
if not recipes:
|
|
1311
|
+
print("No recipes found.")
|
|
1312
|
+
return 1
|
|
1313
|
+
|
|
1314
|
+
for recipe in recipes:
|
|
1315
|
+
pn = recipe.get("pn", "")
|
|
1316
|
+
pv = recipe.get("pv", "")
|
|
1317
|
+
layer_name = recipe.get("layer_name", "")
|
|
1318
|
+
path = recipe.get("path", "")
|
|
1319
|
+
print(f"{pn:<30} {pv:<15} {layer_name:<20} {path}")
|
|
1320
|
+
return 0
|
|
1321
|
+
|
|
1322
|
+
# Determine available sources for cycling
|
|
1323
|
+
available_sources = []
|
|
1324
|
+
if has_local:
|
|
1325
|
+
available_sources.extend(["configured", "local"])
|
|
1326
|
+
available_sources.append("index")
|
|
1327
|
+
|
|
1328
|
+
# FZF browser with source cycling
|
|
1329
|
+
current_source_name = source_name
|
|
1330
|
+
current_recipes = recipes
|
|
1331
|
+
current_is_remote = is_remote
|
|
1332
|
+
|
|
1333
|
+
while True:
|
|
1334
|
+
browser_result = _recipe_fzf_browser(
|
|
1335
|
+
current_recipes,
|
|
1336
|
+
current_source_name,
|
|
1337
|
+
query=initial_query,
|
|
1338
|
+
allow_source_switch=has_local,
|
|
1339
|
+
is_remote=current_is_remote,
|
|
1340
|
+
available_sources=available_sources,
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
# Check if user wants to switch source inline
|
|
1344
|
+
if isinstance(browser_result, tuple) and browser_result[0] == "switch":
|
|
1345
|
+
next_source = browser_result[1]
|
|
1346
|
+
print(f"Switching to {next_source.capitalize()}...")
|
|
1347
|
+
|
|
1348
|
+
# Fetch from the new source
|
|
1349
|
+
current_is_remote = (next_source == "index")
|
|
1350
|
+
current_recipes = fetch_from_source(next_source)
|
|
1351
|
+
|
|
1352
|
+
# Apply filters
|
|
1353
|
+
if layer_filter:
|
|
1354
|
+
current_recipes = [r for r in current_recipes if layer_filter.lower() in r.get("layer_name", "").lower()]
|
|
1355
|
+
if section_filter:
|
|
1356
|
+
current_recipes = [r for r in current_recipes if section_filter.lower() in r.get("SECTION", "").lower()]
|
|
1357
|
+
|
|
1358
|
+
# Sort
|
|
1359
|
+
if sort_by == "layer":
|
|
1360
|
+
current_recipes = sorted(current_recipes, key=lambda r: (r.get("layer_name", "").lower(), r.get("pn", "").lower()))
|
|
1361
|
+
else:
|
|
1362
|
+
current_recipes = sorted(current_recipes, key=lambda r: r.get("pn", "").lower())
|
|
1363
|
+
|
|
1364
|
+
current_source_name = next_source.capitalize()
|
|
1365
|
+
continue # Re-show browser with new source
|
|
1366
|
+
|
|
1367
|
+
# Check if user wants to go back to menu
|
|
1368
|
+
if browser_result == "menu" and use_interactive_menu:
|
|
1369
|
+
break # Break inner loop to go back to source selection menu
|
|
1370
|
+
|
|
1371
|
+
# Normal exit
|
|
1372
|
+
return browser_result if isinstance(browser_result, int) else 0
|
|
1373
|
+
|
|
1374
|
+
continue # Continue outer while loop (back to menu)
|