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.
@@ -0,0 +1,1515 @@
1
+ #
2
+ # Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
3
+ #
4
+ # SPDX-License-Identifier: GPL-2.0-only
5
+ #
6
+ """Config command - view and configure repo/layer settings."""
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ from typing import Dict, List, Optional, Set, Tuple
16
+
17
+ from ..core import (
18
+ Colors,
19
+ current_branch,
20
+ current_head,
21
+ fzf_available,
22
+ get_fzf_preview_resize_bindings,
23
+ git_toplevel,
24
+ load_defaults,
25
+ repo_is_clean,
26
+ save_defaults,
27
+ FZF_THEMES,
28
+ FZF_TEXT_COLORS,
29
+ get_current_theme_name,
30
+ get_current_text_color_name,
31
+ get_fzf_color_args,
32
+ get_custom_colors,
33
+ fzf_theme_picker,
34
+ fzf_text_color_picker,
35
+ fzf_custom_color_menu,
36
+ TERMINAL_COLOR_ELEMENTS,
37
+ ANSI_COLORS,
38
+ get_terminal_color,
39
+ set_terminal_color,
40
+ )
41
+ from .projects import (
42
+ get_directory_browser,
43
+ _pick_directory_browser,
44
+ get_git_viewer,
45
+ _pick_git_viewer,
46
+ get_preview_layout,
47
+ _pick_preview_layout,
48
+ get_recipe_use_bitbake_layers,
49
+ _pick_recipe_use_bitbake_layers,
50
+ )
51
+ from .common import (
52
+ resolve_bblayers_path,
53
+ resolve_base_and_layers,
54
+ extract_layer_paths,
55
+ collect_repos,
56
+ repo_display_name,
57
+ layer_display_name,
58
+ get_push_target,
59
+ set_push_target,
60
+ remove_push_target,
61
+ add_extra_repo,
62
+ add_hidden_repo,
63
+ remove_hidden_repo,
64
+ get_hidden_repos,
65
+ discover_layers,
66
+ discover_git_repos,
67
+ add_layer_to_bblayers,
68
+ remove_layer_from_bblayers,
69
+ build_layer_collection_map,
70
+ get_upstream_count_ls_remote,
71
+ )
72
+
73
+ def run_config_edit(args) -> int:
74
+ """Edit a layer's layer.conf file in $EDITOR."""
75
+ pairs, _repo_sets = resolve_base_and_layers(args.bblayers)
76
+ layers = [layer for layer, repo in pairs]
77
+
78
+ # Find the target layer by index, name, or path
79
+ target_layer = None
80
+ try:
81
+ idx = int(args.layer)
82
+ if 1 <= idx <= len(layers):
83
+ target_layer = layers[idx - 1]
84
+ else:
85
+ print(f"Invalid index {idx}. Valid range: 1-{len(layers)}")
86
+ return 1
87
+ except ValueError:
88
+ # Try matching by layer name (directory name)
89
+ for layer in layers:
90
+ if layer_display_name(layer).lower() == args.layer.lower():
91
+ target_layer = layer
92
+ break
93
+ # Then try as path
94
+ if not target_layer and os.path.isdir(args.layer):
95
+ target_layer = os.path.abspath(args.layer)
96
+ # Finally try partial path match
97
+ if not target_layer:
98
+ for layer in layers:
99
+ if args.layer in layer or layer.endswith(args.layer):
100
+ target_layer = layer
101
+ break
102
+
103
+ if not target_layer:
104
+ print(f"Layer not found: {args.layer}")
105
+ print("\nAvailable layers:")
106
+ for idx, layer in enumerate(layers, start=1):
107
+ print(f" {idx}. {layer_display_name(layer)}: {layer}")
108
+ return 1
109
+
110
+ # Find layer.conf
111
+ layer_conf = os.path.join(target_layer, "conf", "layer.conf")
112
+ if not os.path.isfile(layer_conf):
113
+ print(f"layer.conf not found: {layer_conf}")
114
+ return 1
115
+
116
+ # Get editor
117
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
118
+
119
+ print(f"Editing: {layer_conf}")
120
+ try:
121
+ subprocess.run([editor, layer_conf], check=True)
122
+ return 0
123
+ except subprocess.CalledProcessError as e:
124
+ print(f"Editor exited with code {e.returncode}")
125
+ return e.returncode
126
+ except FileNotFoundError:
127
+ print(f"Editor not found: {editor}")
128
+ print("Set $EDITOR environment variable to your preferred editor")
129
+ return 1
130
+
131
+
132
+
133
+ def run_config(args) -> int:
134
+ # Handle 'edit' command: config edit <layer>
135
+ if args.repo == "edit":
136
+ if not args.extra_arg:
137
+ print("Usage: bit config edit <layer>")
138
+ print(" layer: index, name, or path")
139
+ return 1
140
+ # Create a mock args object with layer attribute
141
+ class EditArgs:
142
+ pass
143
+ edit_args = EditArgs()
144
+ edit_args.bblayers = args.bblayers
145
+ edit_args.layer = args.extra_arg
146
+ return run_config_edit(edit_args)
147
+
148
+ defaults = load_defaults(args.defaults_file)
149
+ repos, _repo_sets = collect_repos(args.bblayers, defaults)
150
+
151
+ # If no repo specified, use fzf interactive interface
152
+ if args.repo is None:
153
+ return fzf_config_repos(repos, defaults, args.bblayers, args.defaults_file)
154
+
155
+ # Find the target repo by index, display name, or path
156
+ target_repo = None
157
+ try:
158
+ idx = int(args.repo)
159
+ if 1 <= idx <= len(repos):
160
+ target_repo = repos[idx - 1]
161
+ else:
162
+ print(f"Invalid index {idx}. Valid range: 1-{len(repos)}")
163
+ return 1
164
+ except ValueError:
165
+ # Try matching by display name first
166
+ for repo in repos:
167
+ if repo_display_name(repo).lower() == args.repo.lower():
168
+ target_repo = repo
169
+ break
170
+ # Then try as path
171
+ if not target_repo and os.path.isdir(args.repo):
172
+ target_repo = os.path.abspath(args.repo)
173
+ # Finally try partial path match
174
+ if not target_repo:
175
+ for repo in repos:
176
+ if args.repo in repo or repo.endswith(args.repo):
177
+ target_repo = repo
178
+ break
179
+
180
+ if not target_repo:
181
+ print(f"Repo not found: {args.repo}")
182
+ return 1
183
+
184
+ # If -e/--edit specified, open interactive submenu for this repo
185
+ if getattr(args, 'edit', False):
186
+ fzf_repo_config(target_repo, defaults, args.defaults_file)
187
+ return 0
188
+
189
+ # If no options specified, show current config for this repo
190
+ if args.display_name is None and args.update_default is None:
191
+ display = repo_display_name(target_repo)
192
+ update_default = defaults.get(target_repo, "rebase")
193
+ push_target = get_push_target(defaults, target_repo)
194
+ print(f"Repo: {target_repo}")
195
+ print(f"Display name: {display}")
196
+ try:
197
+ custom = subprocess.check_output(
198
+ ["git", "-C", target_repo, "config", "--get", "bit.display-name"],
199
+ stderr=subprocess.DEVNULL,
200
+ text=True,
201
+ ).strip()
202
+ if custom:
203
+ print(" (custom override)")
204
+ else:
205
+ print(" (auto-detected)")
206
+ except subprocess.CalledProcessError:
207
+ print(" (auto-detected)")
208
+ print(f"Update default: {update_default}")
209
+ if push_target:
210
+ print(f"Push target: {push_target.get('push_url', '')}")
211
+ if push_target.get('branch_prefix'):
212
+ print(f" Branch prefix: {push_target['branch_prefix']}")
213
+ return 0
214
+
215
+ # Handle display name changes
216
+ if args.display_name is not None:
217
+ if args.display_name == "":
218
+ # Clear custom name
219
+ try:
220
+ subprocess.run(
221
+ ["git", "-C", target_repo, "config", "--unset", "bit.display-name"],
222
+ check=True,
223
+ )
224
+ print(f"Cleared custom display name for {target_repo}")
225
+ print(f"Now using: {repo_display_name(target_repo)}")
226
+ except subprocess.CalledProcessError:
227
+ print(f"No custom display name was set for {target_repo}")
228
+ else:
229
+ subprocess.run(
230
+ ["git", "-C", target_repo, "config", "bit.display-name", args.display_name],
231
+ check=True,
232
+ )
233
+ print(f"Set display name for {target_repo}")
234
+ print(f" {args.display_name}")
235
+
236
+ # Handle update default changes
237
+ if args.update_default is not None:
238
+ old_default = defaults.get(target_repo, "rebase")
239
+ defaults[target_repo] = args.update_default
240
+ save_defaults(args.defaults_file, defaults)
241
+ print(f"Set update default for {target_repo}")
242
+ print(f" {old_default} -> {args.update_default}")
243
+
244
+ return 0
245
+
246
+
247
+
248
+ def show_repo_status_detail(repo: str, branch: str) -> None:
249
+ """Show detailed status for a repo (local and upstream commits)."""
250
+ if not branch:
251
+ print(" (detached HEAD)")
252
+ return
253
+
254
+ # Local commits
255
+ try:
256
+ local_log = subprocess.check_output(
257
+ ["git", "-C", repo, "log", "--oneline", f"origin/{branch}..HEAD"],
258
+ text=True,
259
+ stderr=subprocess.DEVNULL,
260
+ ).strip()
261
+ if local_log:
262
+ lines = local_log.splitlines()
263
+ print(f" {len(lines)} local commit(s):")
264
+ for line in lines[:10]:
265
+ print(f" {line}")
266
+ if len(lines) > 10:
267
+ print(f" ... and {len(lines) - 10} more")
268
+ else:
269
+ print(" No local commits")
270
+ except subprocess.CalledProcessError:
271
+ print(" (could not get local commits)")
272
+
273
+ # Upstream commits
274
+ try:
275
+ upstream_log = subprocess.check_output(
276
+ ["git", "-C", repo, "log", "--oneline", f"HEAD..origin/{branch}"],
277
+ text=True,
278
+ stderr=subprocess.DEVNULL,
279
+ ).strip()
280
+ if upstream_log:
281
+ lines = upstream_log.splitlines()
282
+ print(f" {len(lines)} upstream commit(s) to pull:")
283
+ for line in lines[:5]:
284
+ print(f" {Colors.YELLOW}{line}{Colors.RESET}")
285
+ if len(lines) > 5:
286
+ print(f" ... and {len(lines) - 5} more")
287
+ else:
288
+ print(" Up-to-date with upstream")
289
+ except subprocess.CalledProcessError:
290
+ print(" (could not get upstream commits)")
291
+
292
+ # Working tree status
293
+ is_clean = repo_is_clean(repo)
294
+ if is_clean:
295
+ print(f" Working tree: {Colors.green('clean')}")
296
+ else:
297
+ print(f" Working tree: {Colors.red('DIRTY')}")
298
+
299
+
300
+
301
+ def parse_config_variables(conf_path: str) -> List[Tuple[str, int, str]]:
302
+ """
303
+ Parse BitBake config file for variable assignments.
304
+ Handles multi-line continuations (lines ending with backslash).
305
+ Returns: [(var_name, line_number, raw_value), ...]
306
+ """
307
+ if not os.path.isfile(conf_path):
308
+ return []
309
+
310
+ results = []
311
+ seen_vars = set() # Track seen variable names to deduplicate
312
+ # Match variable assignments: VAR = "...", VAR += "...", VAR:append = "...", etc.
313
+ var_pattern = re.compile(r'^([A-Z_][A-Z0-9_]*(?::[a-z_]+)?)\s*[\?\+\.]*=')
314
+
315
+ try:
316
+ with open(conf_path, "r") as f:
317
+ lines = f.readlines()
318
+
319
+ i = 0
320
+ while i < len(lines):
321
+ line = lines[i]
322
+ stripped = line.strip()
323
+ if not stripped or stripped.startswith("#"):
324
+ i += 1
325
+ continue
326
+
327
+ match = var_pattern.match(stripped)
328
+ if match:
329
+ var_name = match.group(1)
330
+ start_line = i + 1 # 1-indexed
331
+
332
+ # Extract value (everything after the = sign)
333
+ eq_pos = stripped.find("=")
334
+ value_parts = [stripped[eq_pos + 1:].strip()]
335
+
336
+ # Handle line continuations (ending with \)
337
+ while value_parts[-1].endswith("\\") and i + 1 < len(lines):
338
+ i += 1
339
+ cont_line = lines[i].strip()
340
+ # Remove trailing \ from previous part
341
+ value_parts[-1] = value_parts[-1][:-1].strip()
342
+ value_parts.append(cont_line)
343
+
344
+ # Combine all parts
345
+ full_value = " ".join(value_parts)
346
+ # Clean up quotes and whitespace
347
+ full_value = full_value.strip().strip('"').strip("'").strip()
348
+
349
+ # Only add if we haven't seen this variable (deduplicate)
350
+ if var_name not in seen_vars:
351
+ seen_vars.add(var_name)
352
+ # Truncate for display
353
+ display_value = full_value
354
+ if len(display_value) > 50:
355
+ display_value = display_value[:47] + "..."
356
+ results.append((var_name, start_line, display_value))
357
+
358
+ i += 1
359
+ except (IOError, OSError):
360
+ pass
361
+
362
+ return results
363
+
364
+
365
+
366
+ def fzf_build_config(bblayers_path: Optional[str] = None) -> None:
367
+ """Show config menu for project conf files (local.conf, bblayers.conf)."""
368
+ # Find conf directory
369
+ bblayers_conf = resolve_bblayers_path(bblayers_path)
370
+ if not bblayers_conf:
371
+ print("\nCould not find bblayers.conf")
372
+ input("Press Enter to continue...")
373
+ return
374
+
375
+ conf_dir = os.path.dirname(bblayers_conf)
376
+ local_conf = os.path.join(conf_dir, "local.conf")
377
+
378
+ # Track which files are expanded
379
+ expanded_files: Set[str] = set()
380
+ # Cache parsed variables
381
+ var_cache: Dict[str, List[Tuple[str, int, str]]] = {}
382
+ # Cache for bitbake-getvar results
383
+ getvar_cache: Dict[str, str] = {}
384
+
385
+ def get_variables(conf_path: str) -> List[Tuple[str, int, str]]:
386
+ """Get cached variables for a config file."""
387
+ if conf_path not in var_cache:
388
+ var_cache[conf_path] = parse_config_variables(conf_path)
389
+ return var_cache[conf_path]
390
+
391
+ # Calculate max variable name length for alignment
392
+ def get_max_var_len() -> int:
393
+ max_len = 0
394
+ for conf_path in [bblayers_conf, local_conf]:
395
+ if conf_path in expanded_files:
396
+ for var_name, _, _ in get_variables(conf_path):
397
+ max_len = max(max_len, len(var_name))
398
+ return max_len
399
+
400
+ # Create temp files for preview script and getvar cache
401
+ getvar_cache_file = os.path.join(tempfile.gettempdir(), f"bit-getvar-{os.getpid()}.txt")
402
+ preview_script_file = os.path.join(tempfile.gettempdir(), f"bit-preview-{os.getpid()}.sh")
403
+
404
+ # Write preview script once (content doesn't change)
405
+ with open(preview_script_file, "w") as f:
406
+ f.write(f'''#!/bin/bash
407
+ item="$1"
408
+ cache_file="{getvar_cache_file}"
409
+ if [[ "$item" == VAR:* ]]; then
410
+ var_name=$(echo "$item" | cut -d: -f2)
411
+ file_path=$(echo "$item" | cut -d: -f3-)
412
+ line_num="${{file_path##*:}}"
413
+ file_path="${{file_path%:*}}"
414
+ # Check if we have cached getvar output for this variable
415
+ if [ -f "$cache_file" ]; then
416
+ cached_var=$(head -1 "$cache_file" 2>/dev/null)
417
+ if [ "$cached_var" = "$var_name" ]; then
418
+ echo -e "\\033[1;36mbitbake-getvar $var_name\\033[0m"
419
+ echo
420
+ tail -n +2 "$cache_file"
421
+ exit 0
422
+ fi
423
+ fi
424
+ # Show value from file (15 lines to capture multi-line values)
425
+ echo -e "\\033[1m$var_name\\033[0m (line $line_num)"
426
+ echo
427
+ [ -f "$file_path" ] && sed -n "${{line_num}},$((line_num + 14))p" "$file_path"
428
+ else
429
+ file_path="${{item#FILE:}}"
430
+ [ -f "$file_path" ] && head -40 "$file_path"
431
+ fi
432
+ ''')
433
+ os.chmod(preview_script_file, 0o755)
434
+ preview_cmd = f"{preview_script_file} {{1}}"
435
+
436
+ next_selection = None # Track item to select after operations
437
+ try:
438
+ while True:
439
+ menu_lines = []
440
+ max_var_len = get_max_var_len()
441
+
442
+ # bblayers.conf entry
443
+ bblayers_vars = get_variables(bblayers_conf)
444
+ expand_marker = "+ " if bblayers_vars and bblayers_conf not in expanded_files else " "
445
+ if bblayers_conf in expanded_files:
446
+ expand_marker = "- "
447
+ menu_lines.append(f"FILE:{bblayers_conf}\t{expand_marker}Edit bblayers.conf conf/bblayers.conf")
448
+
449
+ # Expanded variables for bblayers.conf
450
+ if bblayers_conf in expanded_files:
451
+ for i, (var_name, line_num, raw_value) in enumerate(bblayers_vars):
452
+ is_last = (i == len(bblayers_vars) - 1)
453
+ prefix = " └─ " if is_last else " ├─ "
454
+ # Show VAR = value aligned with colors
455
+ padded_name = f"{var_name:<{max_var_len}}"
456
+ display_value = raw_value if raw_value else '""'
457
+ if len(display_value) > 40:
458
+ display_value = display_value[:37] + "..."
459
+ colored_name = Colors.cyan(padded_name)
460
+ colored_value = Colors.green(display_value)
461
+ menu_lines.append(f"VAR:{var_name}:{bblayers_conf}:{line_num}\t{prefix}{colored_name} = {colored_value}")
462
+
463
+ # local.conf entry
464
+ if os.path.isfile(local_conf):
465
+ local_vars = get_variables(local_conf)
466
+ expand_marker = "+ " if local_vars and local_conf not in expanded_files else " "
467
+ if local_conf in expanded_files:
468
+ expand_marker = "- "
469
+ menu_lines.append(f"FILE:{local_conf}\t{expand_marker}Edit local.conf conf/local.conf")
470
+
471
+ # Expanded variables for local.conf
472
+ if local_conf in expanded_files:
473
+ for i, (var_name, line_num, raw_value) in enumerate(local_vars):
474
+ is_last = (i == len(local_vars) - 1)
475
+ prefix = " └─ " if is_last else " ├─ "
476
+ padded_name = f"{var_name:<{max_var_len}}"
477
+ display_value = raw_value if raw_value else '""'
478
+ if len(display_value) > 40:
479
+ display_value = display_value[:37] + "..."
480
+ colored_name = Colors.cyan(padded_name)
481
+ colored_value = Colors.green(display_value)
482
+ menu_lines.append(f"VAR:{var_name}:{local_conf}:{line_num}\t{prefix}{colored_name} = {colored_value}")
483
+ else:
484
+ menu_lines.append(f"FILE:{local_conf}\t Edit local.conf (not found)")
485
+
486
+ header = "Project config | Enter=edit | \\=expand | r=bitbake-getvar | q/←=back"
487
+
488
+ try:
489
+ fzf_cmd = [
490
+ "fzf",
491
+ "--ansi",
492
+ "--no-multi",
493
+ "--no-sort",
494
+ "--layout=reverse-list",
495
+ "--height", "50%",
496
+ "--header", header,
497
+ "--prompt", "Select: ",
498
+ "--with-nth", "2..",
499
+ "--delimiter", "\t",
500
+ "--preview", preview_cmd,
501
+ "--preview-window", "right:60%:wrap",
502
+ "--bind", "pgdn:preview-page-down",
503
+ "--bind", "pgup:preview-page-up",
504
+ "--bind", "esc:become(echo BACK)",
505
+ "--bind", "left:become(echo BACK)",
506
+ "--bind", "q:become(echo BACK)",
507
+ "--bind", "b:become(echo FILE:" + bblayers_conf + ")",
508
+ "--bind", "l:become(echo FILE:" + local_conf + ")",
509
+ "--bind", "right:accept",
510
+ "--bind", "\\:become(echo EXPAND {1})",
511
+ "--bind", "r:become(echo GETVAR {1})",
512
+ ] + get_fzf_preview_resize_bindings() + get_fzf_color_args()
513
+
514
+ # Jump to selected item's position if we have one
515
+ if next_selection:
516
+ for i, line in enumerate(menu_lines):
517
+ if line.startswith(next_selection + "\t"):
518
+ fzf_cmd.extend(["--sync", "--bind", f"load:pos({i + 1})"])
519
+ break
520
+ next_selection = None
521
+
522
+ result = subprocess.run(
523
+ fzf_cmd,
524
+ input="\n".join(menu_lines),
525
+ stdout=subprocess.PIPE,
526
+ text=True,
527
+ )
528
+ except FileNotFoundError:
529
+ print("fzf not found")
530
+ return
531
+
532
+ if result.returncode != 0 or not result.stdout.strip():
533
+ break
534
+
535
+ output = result.stdout.strip()
536
+
537
+ if output == "BACK":
538
+ break
539
+ elif output.startswith("EXPAND "):
540
+ # Toggle expansion of a file
541
+ item = output[7:].strip()
542
+ if item.startswith("FILE:"):
543
+ file_path = item[5:]
544
+ if file_path in expanded_files:
545
+ expanded_files.discard(file_path)
546
+ else:
547
+ expanded_files.add(file_path)
548
+ elif item.startswith("VAR:"):
549
+ # Expanding on a variable - expand its parent file
550
+ parts = item.split(":")
551
+ if len(parts) >= 3:
552
+ file_path = ":".join(parts[2:-1]) # Handle paths with colons
553
+ if file_path in expanded_files:
554
+ expanded_files.discard(file_path)
555
+ else:
556
+ expanded_files.add(file_path)
557
+ continue
558
+ elif output.startswith("GETVAR "):
559
+ # Run bitbake-getvar for a variable and cache result for preview
560
+ item = output[7:].strip()
561
+ if item.startswith("VAR:"):
562
+ parts = item.split(":")
563
+ if len(parts) >= 2:
564
+ var_name = parts[1]
565
+ # Check if bitbake-getvar is available
566
+ if not shutil.which("bitbake-getvar"):
567
+ print(f"\nbitbake-getvar not found in PATH.")
568
+ print("Source oe-init-build-env first to enable this feature.")
569
+ input("Press Enter to continue...")
570
+ continue
571
+ print(f"\nRunning: bitbake-getvar {var_name}...")
572
+ # Write result to cache file for preview
573
+ with open(getvar_cache_file, "w") as f:
574
+ f.write(var_name + "\n")
575
+ try:
576
+ getvar_result = subprocess.run(
577
+ ["bitbake-getvar", var_name],
578
+ stdout=subprocess.PIPE,
579
+ stderr=subprocess.STDOUT,
580
+ text=True,
581
+ )
582
+ with open(getvar_cache_file, "a") as f:
583
+ f.write(getvar_result.stdout)
584
+ # Jump back to this item so preview shows the result
585
+ next_selection = item
586
+ if getvar_result.returncode != 0:
587
+ print(f"Error: {getvar_result.stdout}")
588
+ input("Press Enter to continue...")
589
+ except FileNotFoundError:
590
+ print(f"\nbitbake-getvar not found.")
591
+ input("Press Enter to continue...")
592
+ continue
593
+ elif output.startswith("FILE:"):
594
+ # Extract first field (before tab) then strip FILE: prefix
595
+ file_path = output.split("\t")[0][5:]
596
+ if os.path.isfile(file_path):
597
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
598
+ subprocess.run([editor, file_path])
599
+ # Clear cache after editing
600
+ var_cache.pop(file_path, None)
601
+ else:
602
+ print(f"\nFile not found: {file_path}")
603
+ input("Press Enter to continue...")
604
+ elif output.startswith("VAR:"):
605
+ # Open editor at specific line - extract first field before tab
606
+ first_field = output.split("\t")[0]
607
+ parts = first_field.split(":")
608
+ if len(parts) >= 4:
609
+ var_name = parts[1]
610
+ line_num = parts[-1]
611
+ file_path = ":".join(parts[2:-1])
612
+ if os.path.isfile(file_path):
613
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
614
+ # Most editors support +line syntax
615
+ subprocess.run([editor, f"+{line_num}", file_path])
616
+ # Clear cache after editing
617
+ var_cache.pop(file_path, None)
618
+ finally:
619
+ # Cleanup temp files
620
+ for tmp_file in [getvar_cache_file, preview_script_file]:
621
+ if os.path.exists(tmp_file):
622
+ try:
623
+ os.unlink(tmp_file)
624
+ except OSError:
625
+ pass
626
+
627
+
628
+
629
+ def fzf_repo_config(
630
+ repo: str,
631
+ defaults: Dict[str, str],
632
+ defaults_file: str,
633
+ ) -> None:
634
+ """Show config submenu for a single repo (standalone version for explore)."""
635
+
636
+ def get_display_name() -> Tuple[str, bool]:
637
+ """Get display name and whether it's custom."""
638
+ display = repo_display_name(repo)
639
+ is_custom = False
640
+ try:
641
+ with open(defaults_file, "r") as f:
642
+ for line in f:
643
+ if line.startswith(f"display:{repo}="):
644
+ is_custom = True
645
+ break
646
+ except FileNotFoundError:
647
+ pass
648
+ return display, is_custom
649
+
650
+ def set_display_name() -> None:
651
+ """Prompt to set custom display name."""
652
+ display, _ = get_display_name()
653
+ print(f"\nCurrent display name: {display}")
654
+ print(f"Repo path: {repo}")
655
+ new_name = input("Enter new display name (empty to reset to auto): ").strip()
656
+
657
+ key = f"display:{repo}"
658
+ if new_name:
659
+ save_default(defaults_file, key, new_name)
660
+ print(f"Set display name to: {new_name}")
661
+ else:
662
+ # Remove custom name
663
+ remove_default(defaults_file, key)
664
+ print(f"Reset to auto name: {repo_display_name(repo)}")
665
+ input("Press Enter to continue...")
666
+
667
+ def pick_update_default() -> None:
668
+ """Show submenu to pick update default."""
669
+ display, _ = get_display_name()
670
+ current = defaults.get(repo, "rebase")
671
+
672
+ options = [
673
+ ("rebase", "Rebase local commits on top of upstream"),
674
+ ("merge", "Merge upstream into local branch"),
675
+ ("skip", "Skip this repo during updates"),
676
+ ]
677
+
678
+ menu_lines = []
679
+ for opt, desc in options:
680
+ marker = "●" if opt == current else "○"
681
+ menu_lines.append(f"{opt}\t{marker} {opt:<8} {desc}")
682
+
683
+ header = f"Update default for {display}"
684
+
685
+ try:
686
+ result = subprocess.run(
687
+ [
688
+ "fzf",
689
+ "--no-multi",
690
+ "--no-sort",
691
+ "--height", "~8",
692
+ "--header", header,
693
+ "--prompt", "Select: ",
694
+ "--with-nth", "2..",
695
+ "--delimiter", "\t",
696
+ "--bind", "esc:become(echo BACK)",
697
+ "--bind", "q:become(echo BACK)",
698
+ ] + get_fzf_color_args(),
699
+ input="\n".join(menu_lines),
700
+ stdout=subprocess.PIPE,
701
+ text=True,
702
+ )
703
+ except FileNotFoundError:
704
+ return
705
+
706
+ if result.returncode != 0 or not result.stdout.strip():
707
+ return
708
+
709
+ output = result.stdout.strip()
710
+ if output == "BACK":
711
+ return
712
+
713
+ # Extract option from first field
714
+ selected = output.split("\t")[0]
715
+ if selected in ("rebase", "merge", "skip"):
716
+ save_default(defaults_file, repo, selected)
717
+ defaults[repo] = selected
718
+
719
+ def configure_push_target() -> None:
720
+ """Configure push target for this repo."""
721
+ push_target = get_push_target(defaults, repo)
722
+ current_url = push_target.get("push_url", "") if push_target else ""
723
+ current_prefix = push_target.get("branch_prefix", "") if push_target else ""
724
+
725
+ print(f"\nPush target for {repo_display_name(repo)}")
726
+ print(f"Current push URL: {current_url or '(not configured)'}")
727
+ print(f"Current branch prefix: {current_prefix or '(none)'}")
728
+ print()
729
+
730
+ new_url = input("Push URL (empty to clear, - to keep): ").strip()
731
+ if new_url == "-":
732
+ new_url = current_url
733
+ elif not new_url:
734
+ # Clear the push target
735
+ remove_push_target(defaults_file, defaults, repo)
736
+ print("Push target cleared.")
737
+ input("Press Enter to continue...")
738
+ return
739
+
740
+ new_prefix = input("Branch prefix (e.g. 'yourname/', empty for none, - to keep): ").strip()
741
+ if new_prefix == "-":
742
+ new_prefix = current_prefix
743
+
744
+ set_push_target(defaults_file, defaults, repo, new_url, new_prefix)
745
+ print(f"Push target set: {new_url}")
746
+ if new_prefix:
747
+ print(f"Branch prefix: {new_prefix}")
748
+ input("Press Enter to continue...")
749
+
750
+ def edit_layer_conf() -> None:
751
+ """Edit layer.conf for this repo."""
752
+ # Find layer.conf files in this repo
753
+ layer_confs = []
754
+ for root, dirs, files in os.walk(repo):
755
+ # Don't descend into .git
756
+ if ".git" in dirs:
757
+ dirs.remove(".git")
758
+ if "layer.conf" in files:
759
+ conf_path = os.path.join(root, "conf", "layer.conf")
760
+ if os.path.isfile(conf_path):
761
+ layer_confs.append(conf_path)
762
+ else:
763
+ # Check if it's directly in conf/
764
+ parent = os.path.dirname(root)
765
+ if os.path.basename(root) == "conf":
766
+ layer_confs.append(os.path.join(root, "layer.conf"))
767
+
768
+ if not layer_confs:
769
+ # Try standard location
770
+ for item in os.listdir(repo):
771
+ conf_path = os.path.join(repo, item, "conf", "layer.conf")
772
+ if os.path.isfile(conf_path):
773
+ layer_confs.append(conf_path)
774
+
775
+ if not layer_confs:
776
+ print(f"\nNo layer.conf found in {repo}")
777
+ input("Press Enter to continue...")
778
+ return
779
+
780
+ if len(layer_confs) == 1:
781
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
782
+ subprocess.run([editor, layer_confs[0]])
783
+ else:
784
+ # Multiple - let user pick
785
+ menu = "\n".join(layer_confs)
786
+ try:
787
+ result = subprocess.run(
788
+ ["fzf", "--height", "~10", "--header", "Select layer.conf to edit"] + get_fzf_color_args(),
789
+ input=menu,
790
+ stdout=subprocess.PIPE,
791
+ text=True,
792
+ )
793
+ if result.returncode == 0 and result.stdout.strip():
794
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
795
+ subprocess.run([editor, result.stdout.strip()])
796
+ except FileNotFoundError:
797
+ pass
798
+
799
+ while True:
800
+ display, is_custom = get_display_name()
801
+ update_default = defaults.get(repo, "rebase")
802
+
803
+ display_suffix = " (custom)" if is_custom else " (auto)"
804
+ push_target = get_push_target(defaults, repo)
805
+ push_status = push_target.get("push_url", "")[:40] if push_target else "(not configured)"
806
+ menu_lines = [
807
+ f"DISPLAY\tDisplay name {display}{display_suffix}",
808
+ f"DEFAULT\tUpdate default {update_default}",
809
+ f"PUSH\tPush target {push_status}",
810
+ f"EDIT\tEdit layer.conf →",
811
+ ]
812
+
813
+ header = f"Configure {display} ({repo})"
814
+
815
+ try:
816
+ result = subprocess.run(
817
+ [
818
+ "fzf",
819
+ "--no-multi",
820
+ "--no-sort",
821
+ "--height", "~8",
822
+ "--header", header,
823
+ "--prompt", "Select: ",
824
+ "--with-nth", "2..",
825
+ "--delimiter", "\t",
826
+ "--bind", "esc:become(echo BACK)",
827
+ "--bind", "left:become(echo BACK)",
828
+ "--bind", "q:become(echo BACK)",
829
+ "--bind", "right:accept",
830
+ ] + get_fzf_color_args(),
831
+ input="\n".join(menu_lines),
832
+ stdout=subprocess.PIPE,
833
+ text=True,
834
+ )
835
+ except FileNotFoundError:
836
+ return
837
+
838
+ if result.returncode != 0 or not result.stdout.strip():
839
+ return
840
+
841
+ output = result.stdout.strip()
842
+
843
+ if output == "BACK":
844
+ return
845
+ elif output.startswith("DISPLAY\t"):
846
+ set_display_name()
847
+ elif output.startswith("DEFAULT\t"):
848
+ pick_update_default()
849
+ elif output.startswith("PUSH\t"):
850
+ configure_push_target()
851
+ elif output.startswith("EDIT\t"):
852
+ edit_layer_conf()
853
+
854
+
855
+
856
+ def fzf_config_repos(
857
+ repos: List[str],
858
+ defaults: Dict[str, str],
859
+ bblayers_path: str,
860
+ defaults_file: str,
861
+ ) -> int:
862
+ """
863
+ Interactive fzf-based config interface.
864
+ Returns exit code.
865
+ """
866
+ if not repos:
867
+ print("No repos found.")
868
+ return 1
869
+
870
+ def get_repo_info(repo: str) -> Tuple[str, bool]:
871
+ """Get display name and whether it's custom."""
872
+ display = repo_display_name(repo)
873
+ is_custom = False
874
+ try:
875
+ custom = subprocess.check_output(
876
+ ["git", "-C", repo, "config", "--get", "bit.display-name"],
877
+ stderr=subprocess.DEVNULL,
878
+ text=True,
879
+ ).strip()
880
+ if custom:
881
+ is_custom = True
882
+ except subprocess.CalledProcessError:
883
+ pass
884
+ return display, is_custom
885
+
886
+ def build_menu_lines() -> str:
887
+ """Build fzf menu input with header as last line (appears at top in fzf)."""
888
+ menu_lines = []
889
+ max_name_len = 20
890
+
891
+ # First pass to get max name length
892
+ for repo in repos:
893
+ display, _ = get_repo_info(repo)
894
+ if len(display) + 1 > max_name_len: # +1 for potential *
895
+ max_name_len = len(display) + 1
896
+
897
+ # Data lines in reverse order (so they display 1,2,3... from top in fzf)
898
+ data_lines = []
899
+ for idx, repo in enumerate(repos, start=1):
900
+ display, is_custom = get_repo_info(repo)
901
+ if is_custom:
902
+ display = f"{display}*"
903
+ update_default = defaults.get(repo, "rebase")
904
+ line = f"{repo}\t{idx:<4} {display:<{max_name_len}} {update_default:<10} {repo}"
905
+ data_lines.append(line)
906
+
907
+ # Add special "project" entry for project config (with separator)
908
+ bblayers_conf = resolve_bblayers_path(bblayers_path)
909
+ if bblayers_conf:
910
+ conf_dir = os.path.dirname(bblayers_conf)
911
+ # Add separator before project
912
+ data_lines.append(f"SEPARATOR\t") # Empty separator line
913
+ # Pad plain text first, then apply color (ANSI codes don't take visual space)
914
+ project_name = f"{'project':<{max_name_len}}"
915
+ build_line = f"PROJECT\t{'P':<4} {Colors.cyan(project_name)} {'—':<10} {conf_dir}"
916
+ data_lines.append(build_line)
917
+
918
+ # Add "bit" entry for tool settings
919
+ settings_name = f"{'bit':<{max_name_len}}"
920
+ settings_line = f"SETTINGS\t{'S':<4} {Colors.magenta(settings_name)} {'—':<10} configure options"
921
+ data_lines.append(settings_line)
922
+
923
+ # Reverse so item N is first in input (appears at bottom), item 1 is near end
924
+ menu_lines = list(reversed(data_lines))
925
+
926
+ # Column header as LAST line (appears at TOP in default fzf layout)
927
+ col_header = f"HEADER\t{'#':<4} {'Name':<{max_name_len}} {'Default':<10} Path"
928
+ # Separator line under header (second-to-last, appears just below header)
929
+ sep_len = 4 + 1 + max_name_len + 1 + 10 + 1 + 20 # rough width matching columns
930
+ separator = f"SEPARATOR\t{'─' * sep_len}"
931
+ menu_lines.append(separator)
932
+ menu_lines.append(col_header)
933
+
934
+ return "\n".join(menu_lines)
935
+
936
+ def set_display_name(repo: str) -> None:
937
+ """Prompt and set display name for a repo."""
938
+ current, is_custom = get_repo_info(repo)
939
+ try:
940
+ if is_custom:
941
+ new_name = input(f"\nDisplay name [{current}] (empty to clear): ").strip()
942
+ else:
943
+ new_name = input(f"\nDisplay name [{current}]: ").strip()
944
+ except (EOFError, KeyboardInterrupt):
945
+ print("\n Cancelled.")
946
+ return
947
+
948
+ if new_name == "" and is_custom:
949
+ # Clear custom name
950
+ try:
951
+ subprocess.run(
952
+ ["git", "-C", repo, "config", "--unset", "bit.display-name"],
953
+ check=True,
954
+ )
955
+ print(f" Cleared. Now using: {repo_display_name(repo)}")
956
+ except subprocess.CalledProcessError:
957
+ pass
958
+ elif new_name:
959
+ subprocess.run(
960
+ ["git", "-C", repo, "config", "bit.display-name", new_name],
961
+ check=True,
962
+ )
963
+ print(f" Set to: {new_name}")
964
+ else:
965
+ print(" Unchanged.")
966
+
967
+ def set_update_default(repo: str, new_default: str) -> None:
968
+ """Set update default for a repo."""
969
+ old_default = defaults.get(repo, "rebase")
970
+ if old_default == new_default:
971
+ display, _ = get_repo_info(repo)
972
+ print(f"\n {display}: already set to {new_default}")
973
+ return
974
+ defaults[repo] = new_default
975
+ save_defaults(defaults_file, defaults)
976
+ display, _ = get_repo_info(repo)
977
+ print(f"\n {display}: {old_default} → {new_default}")
978
+
979
+ def edit_layer_conf(repo: str) -> None:
980
+ """Edit layer.conf for a repo."""
981
+ # Find layers in this repo
982
+ pairs, _repo_sets = resolve_base_and_layers(bblayers_path)
983
+ repo_layers = [layer for layer, r in pairs if r == repo]
984
+
985
+ if not repo_layers:
986
+ print(f"\n No layers found in {repo}")
987
+ return
988
+
989
+ if len(repo_layers) == 1:
990
+ layer = repo_layers[0]
991
+ else:
992
+ # Multiple layers - use fzf to pick
993
+ display_name, _ = get_repo_info(repo)
994
+ menu_lines = []
995
+ bindings = []
996
+ for i, lyr in enumerate(repo_layers, start=1):
997
+ # Format: "layer_path\t# layer_name"
998
+ menu_lines.append(f"{lyr}\t{i} {layer_display_name(lyr)}")
999
+ # Add number key binding (1-9)
1000
+ if i <= 9:
1001
+ bindings.extend(["--bind", f"{i}:become(echo {lyr})"])
1002
+
1003
+ try:
1004
+ result = subprocess.run(
1005
+ [
1006
+ "fzf",
1007
+ "--no-multi",
1008
+ "--no-sort",
1009
+ "--height", "~10",
1010
+ "--header", f"Select layer in {display_name} (←=back, 1-{min(len(repo_layers), 9)} or Enter)",
1011
+ "--prompt", "Layer: ",
1012
+ "--with-nth", "2..",
1013
+ "--delimiter", "\t",
1014
+ "--bind", "esc:become(echo BACK)",
1015
+ "--bind", "left:become(echo BACK)",
1016
+ "--bind", "q:become(echo BACK)",
1017
+ ] + bindings + get_fzf_color_args(),
1018
+ input="\n".join(menu_lines),
1019
+ stdout=subprocess.PIPE,
1020
+ text=True,
1021
+ )
1022
+ except FileNotFoundError:
1023
+ print(" fzf not found")
1024
+ return
1025
+
1026
+ if result.returncode != 0 or not result.stdout.strip():
1027
+ return
1028
+
1029
+ output = result.stdout.strip()
1030
+ if output == "BACK":
1031
+ return
1032
+ # Extract layer path (either raw path from number key, or first field from selection)
1033
+ if "\t" in output:
1034
+ layer = output.split("\t")[0]
1035
+ else:
1036
+ layer = output
1037
+
1038
+ layer_conf = os.path.join(layer, "conf", "layer.conf")
1039
+ if not os.path.isfile(layer_conf):
1040
+ print(f"\n layer.conf not found: {layer_conf}")
1041
+ return
1042
+
1043
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
1044
+ print(f"\n Editing: {layer_conf}")
1045
+ try:
1046
+ subprocess.run([editor, layer_conf], check=True)
1047
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
1048
+ print(f" Error: {e}")
1049
+
1050
+ def show_repo_submenu(repo: str) -> None:
1051
+ """Show config submenu for a single repo."""
1052
+ while True:
1053
+ display, is_custom = get_repo_info(repo)
1054
+ update_default = defaults.get(repo, "rebase")
1055
+
1056
+ # Build menu showing current values
1057
+ display_suffix = " (custom)" if is_custom else " (auto)"
1058
+ push_target = get_push_target(defaults, repo)
1059
+ push_status = push_target.get("push_url", "")[:40] if push_target else "(not configured)"
1060
+ menu_lines = [
1061
+ f"DISPLAY\tDisplay name {display}{display_suffix}",
1062
+ f"DEFAULT\tUpdate default {update_default}",
1063
+ f"PUSH\tPush target {push_status}",
1064
+ f"EDIT\tEdit layer.conf →",
1065
+ ]
1066
+
1067
+ header = f"Configure {display} ({repo})"
1068
+
1069
+ try:
1070
+ result = subprocess.run(
1071
+ [
1072
+ "fzf",
1073
+ "--no-multi",
1074
+ "--no-sort",
1075
+ "--height", "~8",
1076
+ "--header", header,
1077
+ "--prompt", "Select: ",
1078
+ "--with-nth", "2..",
1079
+ "--delimiter", "\t",
1080
+ "--bind", "esc:become(echo BACK)",
1081
+ "--bind", "left:become(echo BACK)",
1082
+ "--bind", "q:become(echo BACK)",
1083
+ "--bind", "right:accept",
1084
+ ] + get_fzf_color_args(),
1085
+ input="\n".join(menu_lines),
1086
+ stdout=subprocess.PIPE,
1087
+ text=True,
1088
+ )
1089
+ except FileNotFoundError:
1090
+ return
1091
+
1092
+ if result.returncode != 0 or not result.stdout.strip():
1093
+ return
1094
+
1095
+ output = result.stdout.strip()
1096
+
1097
+ if output == "BACK":
1098
+ return
1099
+ elif output.startswith("DISPLAY\t"):
1100
+ set_display_name(repo)
1101
+ elif output.startswith("DEFAULT\t"):
1102
+ # Show default picker submenu
1103
+ pick_update_default(repo)
1104
+ elif output.startswith("EDIT\t"):
1105
+ edit_layer_conf(repo)
1106
+ elif output.startswith("PUSH\t"):
1107
+ configure_push_target(repo)
1108
+
1109
+ def configure_push_target(repo: str) -> None:
1110
+ """Configure push target for a repo."""
1111
+ display, _ = get_repo_info(repo)
1112
+ push_target = get_push_target(defaults, repo)
1113
+ current_url = push_target.get("push_url", "") if push_target else ""
1114
+ current_prefix = push_target.get("branch_prefix", "") if push_target else ""
1115
+
1116
+ print(f"\nPush target for {display}")
1117
+ print(f"Current push URL: {current_url or '(not configured)'}")
1118
+ print(f"Current branch prefix: {current_prefix or '(none)'}")
1119
+ print()
1120
+
1121
+ new_url = input("Push URL (empty to clear, - to keep): ").strip()
1122
+ if new_url == "-":
1123
+ new_url = current_url
1124
+ elif not new_url:
1125
+ # Clear the push target
1126
+ remove_push_target(defaults_file, defaults, repo)
1127
+ print("Push target cleared.")
1128
+ input("Press Enter to continue...")
1129
+ return
1130
+
1131
+ new_prefix = input("Branch prefix (e.g. 'yourname/', empty for none, - to keep): ").strip()
1132
+ if new_prefix == "-":
1133
+ new_prefix = current_prefix
1134
+
1135
+ set_push_target(defaults_file, defaults, repo, new_url, new_prefix)
1136
+ print(f"Push target set: {new_url}")
1137
+ if new_prefix:
1138
+ print(f"Branch prefix: {new_prefix}")
1139
+ input("Press Enter to continue...")
1140
+
1141
+ def show_build_config() -> None:
1142
+ """Show config submenu for project conf files."""
1143
+ fzf_build_config(bblayers_path)
1144
+
1145
+ def show_settings_submenu() -> None:
1146
+ """Show settings submenu for bit tool configuration."""
1147
+ while True:
1148
+ # Theme summary
1149
+ current_theme = get_current_theme_name(defaults_file)
1150
+ custom_colors = get_custom_colors(defaults_file)
1151
+ custom_count = len(custom_colors)
1152
+ theme_summary = current_theme
1153
+ if custom_count:
1154
+ theme_summary += f" +{custom_count} custom"
1155
+
1156
+ # Directory browser info
1157
+ browser = get_directory_browser()
1158
+ browser_desc = {
1159
+ "auto": "auto-detect",
1160
+ "broot": "broot",
1161
+ "ranger": "ranger",
1162
+ "nnn": "nnn",
1163
+ "fzf": "fzf built-in"
1164
+ }.get(browser, browser)
1165
+
1166
+ # Git viewer info
1167
+ git_viewer = get_git_viewer()
1168
+ git_viewer_desc = {
1169
+ "auto": "auto-detect",
1170
+ "tig": "tig",
1171
+ "lazygit": "lazygit",
1172
+ "gitk": "gitk",
1173
+ }.get(git_viewer, git_viewer)
1174
+
1175
+ # Preview layout info
1176
+ preview_layout = get_preview_layout()
1177
+ preview_layout_desc = {
1178
+ "down": "bottom",
1179
+ "right": "side-by-side",
1180
+ "up": "top",
1181
+ }.get(preview_layout, preview_layout)
1182
+
1183
+ # Recipe scan method info
1184
+ use_bitbake_layers = get_recipe_use_bitbake_layers()
1185
+ recipe_scan_desc = "bitbake-layers" if use_bitbake_layers else "file scan"
1186
+
1187
+ menu_lines = [
1188
+ f"COLORS\tColors {theme_summary}",
1189
+ f"BROWSER\tDir Browser {browser_desc}",
1190
+ f"GITVIEWER\tGit Viewer {git_viewer_desc}",
1191
+ f"PREVIEW\tPreview Layout {preview_layout_desc}",
1192
+ f"RECIPE\tRecipe Scan {recipe_scan_desc}",
1193
+ ]
1194
+
1195
+ try:
1196
+ result = subprocess.run(
1197
+ [
1198
+ "fzf",
1199
+ "--no-multi",
1200
+ "--no-sort",
1201
+ "--height", "~10",
1202
+ "--header", "bit Settings (←/q=back)",
1203
+ "--prompt", "Setting: ",
1204
+ "--with-nth", "2..",
1205
+ "--delimiter", "\t",
1206
+ "--bind", "esc:become(echo BACK)",
1207
+ "--bind", "left:become(echo BACK)",
1208
+ "--bind", "q:become(echo BACK)",
1209
+ "--bind", "right:accept",
1210
+ ] + get_fzf_color_args(),
1211
+ input="\n".join(menu_lines),
1212
+ stdout=subprocess.PIPE,
1213
+ text=True,
1214
+ )
1215
+ except FileNotFoundError:
1216
+ return
1217
+
1218
+ if result.returncode != 0 or not result.stdout.strip():
1219
+ return
1220
+
1221
+ output = result.stdout.strip()
1222
+
1223
+ if output == "BACK":
1224
+ return
1225
+ elif output.startswith("COLORS\t"):
1226
+ show_colors_submenu()
1227
+ elif output.startswith("BROWSER\t"):
1228
+ _pick_directory_browser()
1229
+ elif output.startswith("GITVIEWER\t"):
1230
+ _pick_git_viewer()
1231
+ elif output.startswith("PREVIEW\t"):
1232
+ _pick_preview_layout()
1233
+ elif output.startswith("RECIPE\t"):
1234
+ _pick_recipe_use_bitbake_layers()
1235
+
1236
+ def show_colors_submenu() -> None:
1237
+ """Show colors/theme submenu."""
1238
+ while True:
1239
+ current_theme = get_current_theme_name(defaults_file)
1240
+ theme_desc = FZF_THEMES.get(current_theme, ("", ""))[1]
1241
+ custom_colors = get_custom_colors(defaults_file)
1242
+ custom_count = len(custom_colors)
1243
+ custom_desc = f"{custom_count} override{'s' if custom_count != 1 else ''}" if custom_count else "none"
1244
+
1245
+ # Terminal colors summary
1246
+ terminal_overrides = sum(
1247
+ 1 for elem in TERMINAL_COLOR_ELEMENTS
1248
+ if get_terminal_color(elem, defaults_file) != TERMINAL_COLOR_ELEMENTS[elem][0]
1249
+ )
1250
+ terminal_desc = f"{terminal_overrides} override{'s' if terminal_overrides != 1 else ''}" if terminal_overrides else "defaults"
1251
+
1252
+ menu_lines = [
1253
+ f"THEME\tTheme {current_theme} - {theme_desc}",
1254
+ f"CUSTOM\tIndividual {custom_desc}",
1255
+ f"TERMINAL\tTerminal {terminal_desc}",
1256
+ ]
1257
+
1258
+ try:
1259
+ result = subprocess.run(
1260
+ [
1261
+ "fzf",
1262
+ "--no-multi",
1263
+ "--no-sort",
1264
+ "--height", "~10",
1265
+ "--header", "Colors (←/q=back)",
1266
+ "--prompt", "Option: ",
1267
+ "--with-nth", "2..",
1268
+ "--delimiter", "\t",
1269
+ "--bind", "esc:become(echo BACK)",
1270
+ "--bind", "left:become(echo BACK)",
1271
+ "--bind", "q:become(echo BACK)",
1272
+ "--bind", "right:accept",
1273
+ ] + get_fzf_color_args(),
1274
+ input="\n".join(menu_lines),
1275
+ stdout=subprocess.PIPE,
1276
+ text=True,
1277
+ )
1278
+ except FileNotFoundError:
1279
+ return
1280
+
1281
+ if result.returncode != 0 or not result.stdout.strip():
1282
+ return
1283
+
1284
+ output = result.stdout.strip()
1285
+
1286
+ if output == "BACK":
1287
+ return
1288
+ elif output.startswith("THEME\t"):
1289
+ fzf_theme_picker(defaults_file)
1290
+ elif output.startswith("CUSTOM\t"):
1291
+ fzf_custom_color_menu(defaults_file)
1292
+ elif output.startswith("TERMINAL\t"):
1293
+ show_terminal_colors_submenu()
1294
+
1295
+ def show_terminal_colors_submenu() -> None:
1296
+ """Show terminal output colors submenu."""
1297
+ while True:
1298
+ menu_lines = []
1299
+ for elem, (default_color, desc) in TERMINAL_COLOR_ELEMENTS.items():
1300
+ current = get_terminal_color(elem, defaults_file)
1301
+ is_default = (current == default_color)
1302
+ marker = " " if is_default else "● "
1303
+ # Show color sample
1304
+ ansi_code = ANSI_COLORS.get(current, "\033[33m")
1305
+ sample = f"{ansi_code}■■■\033[0m"
1306
+ status = f"{current}" if not is_default else f"{current} (default)"
1307
+ menu_lines.append(f"{elem}\t{marker}{sample} {desc:<35} {status}")
1308
+
1309
+ try:
1310
+ result = subprocess.run(
1311
+ [
1312
+ "fzf",
1313
+ "--no-multi",
1314
+ "--no-sort",
1315
+ "--ansi",
1316
+ "--height", "~15",
1317
+ "--header", "Terminal Colors (←/q=back)\nThese colors are used in status output, not fzf menus",
1318
+ "--prompt", "Element: ",
1319
+ "--with-nth", "2..",
1320
+ "--delimiter", "\t",
1321
+ "--bind", "esc:become(echo BACK)",
1322
+ "--bind", "left:become(echo BACK)",
1323
+ "--bind", "q:become(echo BACK)",
1324
+ "--bind", "right:accept",
1325
+ ] + get_fzf_color_args(),
1326
+ input="\n".join(menu_lines),
1327
+ stdout=subprocess.PIPE,
1328
+ text=True,
1329
+ )
1330
+ except FileNotFoundError:
1331
+ return
1332
+
1333
+ if result.returncode != 0 or not result.stdout.strip():
1334
+ return
1335
+
1336
+ output = result.stdout.strip()
1337
+
1338
+ if output == "BACK":
1339
+ return
1340
+
1341
+ # Extract element name
1342
+ elem = output.split("\t")[0] if "\t" in output else output
1343
+ if elem in TERMINAL_COLOR_ELEMENTS:
1344
+ pick_terminal_color(elem)
1345
+
1346
+ def pick_terminal_color(element: str) -> None:
1347
+ """Pick a color for a terminal output element."""
1348
+ default_color = TERMINAL_COLOR_ELEMENTS[element][0]
1349
+ current = get_terminal_color(element, defaults_file)
1350
+ desc = TERMINAL_COLOR_ELEMENTS[element][1]
1351
+
1352
+ # Build color options
1353
+ menu_lines = []
1354
+ # Add default option first
1355
+ default_marker = "● " if current == default_color else " "
1356
+ default_ansi = ANSI_COLORS.get(default_color, "\033[33m")
1357
+ menu_lines.append(f"(default)\t{default_marker}{default_ansi}■■■\033[0m (default: {default_color})")
1358
+
1359
+ for name, ansi in sorted(ANSI_COLORS.items()):
1360
+ if name == default_color:
1361
+ continue # Already shown as default
1362
+ marker = "● " if name == current else " "
1363
+ menu_lines.append(f"{name}\t{marker}{ansi}■■■\033[0m {name}")
1364
+
1365
+ try:
1366
+ result = subprocess.run(
1367
+ [
1368
+ "fzf",
1369
+ "--no-multi",
1370
+ "--no-sort",
1371
+ "--ansi",
1372
+ "--height", "~20",
1373
+ "--header", f"Pick color for: {desc} (←/q=back)",
1374
+ "--prompt", "Color: ",
1375
+ "--with-nth", "2..",
1376
+ "--delimiter", "\t",
1377
+ "--bind", "esc:become(echo BACK)",
1378
+ "--bind", "left:become(echo BACK)",
1379
+ "--bind", "q:become(echo BACK)",
1380
+ ] + get_fzf_color_args(),
1381
+ input="\n".join(menu_lines),
1382
+ stdout=subprocess.PIPE,
1383
+ text=True,
1384
+ )
1385
+ except FileNotFoundError:
1386
+ return
1387
+
1388
+ if result.returncode != 0 or not result.stdout.strip():
1389
+ return
1390
+
1391
+ output = result.stdout.strip()
1392
+ if output == "BACK":
1393
+ return
1394
+
1395
+ selected = output.split("\t")[0] if "\t" in output else output
1396
+ if selected == "(default)":
1397
+ set_terminal_color(element, "(default)", defaults_file)
1398
+ elif selected in ANSI_COLORS:
1399
+ set_terminal_color(element, selected, defaults_file)
1400
+
1401
+ def pick_update_default(repo: str) -> None:
1402
+ """Show submenu to pick update default."""
1403
+ display, _ = get_repo_info(repo)
1404
+ current = defaults.get(repo, "rebase")
1405
+
1406
+ menu_lines = []
1407
+ for opt in ["rebase", "merge", "skip"]:
1408
+ marker = "●" if opt == current else "○"
1409
+ menu_lines.append(f"{opt}\t{marker} {opt}")
1410
+
1411
+ try:
1412
+ result = subprocess.run(
1413
+ [
1414
+ "fzf",
1415
+ "--no-multi",
1416
+ "--no-sort",
1417
+ "--height", "~6",
1418
+ "--header", f"Update default for {display}",
1419
+ "--prompt", "Default: ",
1420
+ "--with-nth", "2..",
1421
+ "--delimiter", "\t",
1422
+ "--bind", "r:become(echo rebase)",
1423
+ "--bind", "m:become(echo merge)",
1424
+ "--bind", "s:become(echo skip)",
1425
+ ] + get_fzf_color_args(),
1426
+ input="\n".join(menu_lines),
1427
+ stdout=subprocess.PIPE,
1428
+ text=True,
1429
+ )
1430
+ except FileNotFoundError:
1431
+ return
1432
+
1433
+ if result.returncode != 0 or not result.stdout.strip():
1434
+ return
1435
+
1436
+ output = result.stdout.strip()
1437
+ # Handle both direct key (rebase) and selection (rebase\t● rebase)
1438
+ new_default = output.split("\t")[0]
1439
+ if new_default in ("rebase", "merge", "skip"):
1440
+ set_update_default(repo, new_default)
1441
+
1442
+ header = "Enter/→=configure | d=display | r=rebase | m=merge | s=skip | e=edit | q=quit"
1443
+
1444
+ while True:
1445
+ menu_input = build_menu_lines()
1446
+
1447
+ try:
1448
+ result = subprocess.run(
1449
+ [
1450
+ "fzf",
1451
+ "--no-multi",
1452
+ "--no-sort",
1453
+ "--no-info",
1454
+ "--ansi",
1455
+ "--height", "~50%",
1456
+ "--header", header,
1457
+ "--prompt", "Config: ",
1458
+ "--with-nth", "2..", # Hide repo path / HEADER marker (field 1)
1459
+ "--delimiter", "\t",
1460
+ "--bind", "q:become(echo QUIT)",
1461
+ "--bind", "d:become(echo DISPLAY {1})",
1462
+ "--bind", "r:become(echo REBASE {1})",
1463
+ "--bind", "m:become(echo MERGE {1})",
1464
+ "--bind", "s:become(echo SKIP {1})",
1465
+ "--bind", "e:become(echo EDIT {1})",
1466
+ "--bind", "right:accept",
1467
+ ] + get_fzf_color_args(),
1468
+ input=menu_input,
1469
+ stdout=subprocess.PIPE,
1470
+ text=True,
1471
+ )
1472
+ except FileNotFoundError:
1473
+ print("fzf not found. Use CLI: bit config <repo> --display-name/--update-default")
1474
+ return 1
1475
+
1476
+ if result.returncode != 0 or not result.stdout.strip():
1477
+ break
1478
+
1479
+ output = result.stdout.strip()
1480
+
1481
+ if output == "QUIT":
1482
+ break
1483
+ elif output.startswith("DISPLAY "):
1484
+ repo_path = output[8:].strip()
1485
+ if repo_path not in ("HEADER", "SEPARATOR"):
1486
+ set_display_name(repo_path)
1487
+ elif output.startswith("REBASE "):
1488
+ repo_path = output[7:].strip()
1489
+ if repo_path not in ("HEADER", "SEPARATOR"):
1490
+ set_update_default(repo_path, "rebase")
1491
+ elif output.startswith("MERGE "):
1492
+ repo_path = output[6:].strip()
1493
+ if repo_path not in ("HEADER", "SEPARATOR"):
1494
+ set_update_default(repo_path, "merge")
1495
+ elif output.startswith("SKIP "):
1496
+ repo_path = output[5:].strip()
1497
+ if repo_path not in ("HEADER", "SEPARATOR"):
1498
+ set_update_default(repo_path, "skip")
1499
+ elif output.startswith("EDIT "):
1500
+ repo_path = output[5:].strip()
1501
+ if repo_path not in ("HEADER", "SEPARATOR"):
1502
+ edit_layer_conf(repo_path)
1503
+ elif "\t" in output:
1504
+ # Enter was pressed - drill into submenu (ignore header/separator lines)
1505
+ repo_path = output.split("\t")[0]
1506
+ if repo_path == "PROJECT":
1507
+ show_build_config()
1508
+ elif repo_path == "SETTINGS":
1509
+ show_settings_submenu()
1510
+ elif repo_path not in ("HEADER", "SEPARATOR"):
1511
+ show_repo_submenu(repo_path)
1512
+
1513
+ return 0
1514
+
1515
+