claudeup 4.17.0 → 4.18.0

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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/alias-parser.test.ts +317 -0
  3. package/src/__tests__/alias-shell-writer.test.ts +661 -0
  4. package/src/__tests__/alias-store.test.ts +86 -0
  5. package/src/__tests__/gitignore-fixer.test.ts +64 -1
  6. package/src/__tests__/gitignore-prerun.test.ts +2 -2
  7. package/src/__tests__/gitignore-service.test.ts +42 -0
  8. package/src/__tests__/marketplaces.test.ts +40 -0
  9. package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
  10. package/src/__tests__/useGitignoreModal.test.ts +2 -2
  11. package/src/data/alias-flags.js +196 -0
  12. package/src/data/alias-flags.ts +291 -0
  13. package/src/data/gitignore-reasons.js +97 -0
  14. package/src/data/gitignore-reasons.ts +103 -0
  15. package/src/data/marketplaces.js +5 -3
  16. package/src/data/marketplaces.ts +5 -4
  17. package/src/services/alias-settings.js +51 -0
  18. package/src/services/alias-settings.ts +63 -0
  19. package/src/services/alias-shell-writer.js +764 -0
  20. package/src/services/alias-shell-writer.ts +873 -0
  21. package/src/services/alias-store.js +77 -0
  22. package/src/services/alias-store.ts +112 -0
  23. package/src/services/gitignore-fixer.js +70 -10
  24. package/src/services/gitignore-fixer.ts +76 -9
  25. package/src/services/gitignore-prerun.js +3 -3
  26. package/src/services/gitignore-prerun.ts +3 -3
  27. package/src/services/gitignore-service.js +20 -2
  28. package/src/services/gitignore-service.ts +23 -1
  29. package/src/services/marketplace-fetcher.js +96 -0
  30. package/src/services/marketplace-fetcher.ts +137 -0
  31. package/src/services/plugin-manager.js +6 -59
  32. package/src/services/plugin-manager.ts +16 -91
  33. package/src/services/skillsmp-client.js +29 -9
  34. package/src/services/skillsmp-client.ts +38 -8
  35. package/src/types/gitignore.ts +1 -1
  36. package/src/ui/App.js +10 -4
  37. package/src/ui/App.tsx +9 -3
  38. package/src/ui/components/TabBar.js +2 -1
  39. package/src/ui/components/TabBar.tsx +2 -1
  40. package/src/ui/components/layout/FooterHints.js +29 -0
  41. package/src/ui/components/layout/FooterHints.tsx +52 -0
  42. package/src/ui/components/layout/ScreenLayout.js +2 -1
  43. package/src/ui/components/layout/ScreenLayout.tsx +12 -3
  44. package/src/ui/components/layout/index.js +1 -0
  45. package/src/ui/components/layout/index.ts +5 -0
  46. package/src/ui/components/modals/SelectModal.js +8 -1
  47. package/src/ui/components/modals/SelectModal.tsx +12 -1
  48. package/src/ui/hooks/useGitignoreModal.js +7 -8
  49. package/src/ui/hooks/useGitignoreModal.ts +8 -9
  50. package/src/ui/renderers/gitignoreRenderers.js +36 -23
  51. package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
  52. package/src/ui/screens/AliasScreen.js +1008 -0
  53. package/src/ui/screens/AliasScreen.tsx +1402 -0
  54. package/src/ui/screens/CliToolsScreen.js +6 -1
  55. package/src/ui/screens/CliToolsScreen.tsx +6 -1
  56. package/src/ui/screens/EnvVarsScreen.js +6 -1
  57. package/src/ui/screens/EnvVarsScreen.tsx +6 -1
  58. package/src/ui/screens/GitignoreScreen.js +189 -88
  59. package/src/ui/screens/GitignoreScreen.tsx +312 -132
  60. package/src/ui/screens/McpRegistryScreen.js +13 -2
  61. package/src/ui/screens/McpRegistryScreen.tsx +13 -2
  62. package/src/ui/screens/McpScreen.js +6 -1
  63. package/src/ui/screens/McpScreen.tsx +6 -1
  64. package/src/ui/screens/ModelSelectorScreen.js +8 -2
  65. package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
  66. package/src/ui/screens/PluginsScreen.js +13 -2
  67. package/src/ui/screens/PluginsScreen.tsx +13 -2
  68. package/src/ui/screens/ProfilesScreen.js +8 -1
  69. package/src/ui/screens/ProfilesScreen.tsx +8 -1
  70. package/src/ui/screens/SkillsScreen.js +21 -4
  71. package/src/ui/screens/SkillsScreen.tsx +39 -5
  72. package/src/ui/screens/StatusLineScreen.js +7 -1
  73. package/src/ui/screens/StatusLineScreen.tsx +7 -1
  74. package/src/ui/screens/index.js +1 -0
  75. package/src/ui/screens/index.ts +1 -0
  76. package/src/ui/state/types.ts +4 -2
@@ -328,6 +328,11 @@ export function CliToolsScreen() {
328
328
  const installedCount = toolStatuses.filter((s) => s.installed).length;
329
329
  const updateCount = toolStatuses.filter((s) => s.hasUpdate).length;
330
330
  const statusContent = (_jsxs("text", { children: [_jsx("span", { fg: "gray", children: "Installed: " }), _jsxs("span", { fg: "cyan", children: [installedCount, "/", toolStatuses.length] }), updateCount > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { fg: "gray", children: " \u2502 Updates: " }), _jsx("span", { fg: "yellow", children: updateCount })] }))] }));
331
- return (_jsx(ScreenLayout, { title: "claudeup CLI Tools", currentScreen: "cli-tools", statusLine: statusContent, footerHints: "Enter:install \u2502 a:update all \u2502 c:fix conflict \u2502 r:refresh", listPanel: _jsx(ScrollableList, { items: toolStatuses, selectedIndex: cliToolsState.selectedIndex, renderItem: renderCliToolRow, maxHeight: dimensions.listPanelHeight, getKey: (status) => status.tool.name }), detailPanel: renderCliToolDetail(selectedStatus) }));
331
+ return (_jsx(ScreenLayout, { title: "claudeup CLI Tools", currentScreen: "cli-tools", statusLine: statusContent, footerHints: [
332
+ { keys: ["Enter"], label: "install" },
333
+ { keys: ["a"], label: "update all" },
334
+ { keys: ["c"], label: "fix conflict" },
335
+ { keys: ["r"], label: "refresh" },
336
+ ], listPanel: _jsx(ScrollableList, { items: toolStatuses, selectedIndex: cliToolsState.selectedIndex, renderItem: renderCliToolRow, maxHeight: dimensions.listPanelHeight, getKey: (status) => status.tool.name }), detailPanel: renderCliToolDetail(selectedStatus) }));
332
337
  }
333
338
  export default CliToolsScreen;
@@ -417,7 +417,12 @@ export function CliToolsScreen() {
417
417
  title="claudeup CLI Tools"
418
418
  currentScreen="cli-tools"
419
419
  statusLine={statusContent}
420
- footerHints="Enter:install │ a:update all │ c:fix conflict │ r:refresh"
420
+ footerHints={[
421
+ { keys: ["Enter"], label: "install" },
422
+ { keys: ["a"], label: "update all" },
423
+ { keys: ["c"], label: "fix conflict" },
424
+ { keys: ["r"], label: "refresh" },
425
+ ]}
421
426
  listPanel={
422
427
  <ScrollableList
423
428
  items={toolStatuses}
@@ -140,7 +140,12 @@ export function SettingsScreen() {
140
140
  ? Array.from(settings.values.data.values()).filter((v) => v.user !== undefined || v.project !== undefined).length
141
141
  : 0;
142
142
  const statusContent = (_jsxs("text", { children: [_jsx("span", { fg: "gray", children: "Settings: " }), _jsxs("span", { fg: "cyan", children: [totalSet, " configured"] }), _jsx("span", { fg: "gray", children: " \u2502 u:user p:project" })] }));
143
- return (_jsx(ScreenLayout, { title: "claudeup Settings", currentScreen: "settings", statusLine: statusContent, footerHints: "\u2191\u2193:nav \u2502 u:user scope \u2502 p:project scope \u2502 Enter:project", listPanel: settings.values.status !== "success" ? (_jsx("text", { fg: "gray", children: settings.values.status === "loading"
143
+ return (_jsx(ScreenLayout, { title: "claudeup Settings", currentScreen: "settings", statusLine: statusContent, footerHints: [
144
+ { keys: ["↑", "↓"], label: "nav" },
145
+ { keys: ["u"], label: "user scope" },
146
+ { keys: ["p"], label: "project scope" },
147
+ { keys: ["Enter"], label: "project" },
148
+ ], listPanel: settings.values.status !== "success" ? (_jsx("text", { fg: "gray", children: settings.values.status === "loading"
144
149
  ? "Loading..."
145
150
  : "Error loading settings" })) : (_jsx(ScrollableList, { items: listItems, selectedIndex: settings.selectedIndex, renderItem: renderSettingRow, maxHeight: dimensions.listPanelHeight })), detailPanel: renderDetail() }));
146
151
  }
@@ -185,7 +185,12 @@ export function SettingsScreen() {
185
185
  title="claudeup Settings"
186
186
  currentScreen="settings"
187
187
  statusLine={statusContent}
188
- footerHints="↑↓:nav │ u:user scope │ p:project scope │ Enter:project"
188
+ footerHints={[
189
+ { keys: ["↑", "↓"], label: "nav" },
190
+ { keys: ["u"], label: "user scope" },
191
+ { keys: ["p"], label: "project scope" },
192
+ { keys: ["Enter"], label: "project" },
193
+ ]}
189
194
  listPanel={
190
195
  settings.values.status !== "success" ? (
191
196
  <text fg="gray">
@@ -1,15 +1,29 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
2
  import { useEffect, useState, useCallback, useMemo } from "react";
3
3
  import { spawn } from "node:child_process";
4
+ import { appendFileSync } from "node:fs";
5
+ import { useRenderer } from "@opentui/react";
4
6
  import { useApp, useModal } from "../state/AppContext.js";
5
7
  import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
6
8
  import { ScreenLayout } from "../components/layout/index.js";
7
- import { loadGitignoreState, applyAllSafeFixes, applyDestructiveFix, applyTemplate, } from "../../services/gitignore-service.js";
9
+ import { loadGitignoreState, applyDestructiveFix, ensureProjectManifestForEdit, applyTemplate, } from "../../services/gitignore-service.js";
8
10
  import { BUILTIN_TEMPLATES } from "../../data/gitignore-templates.js";
9
- import { renderRuleRow } from "../renderers/gitignoreRenderers.js";
11
+ import { getGitignoreRuleReason } from "../../data/gitignore-reasons.js";
12
+ import { renderRuleRow, } from "../renderers/gitignoreRenderers.js";
13
+ // Clean rows are grouped by the manifest that defined them. Order = display
14
+ // order, top to bottom. "Default" is the user-facing name for the built-in
15
+ // rules. Each label names where you'd go to change that group's rules.
16
+ const SOURCE_ORDER = ["project", "global", "profile", "builtin"];
17
+ const SOURCE_LABELS = {
18
+ project: "Project · .claude/gitignore.json",
19
+ global: "Global · ~/.claude/gitignore.json",
20
+ profile: "Profile",
21
+ builtin: "Default · built into claudeup",
22
+ };
10
23
  export function GitignoreScreen() {
11
24
  const { state } = useApp();
12
25
  const modal = useModal();
26
+ const renderer = useRenderer();
13
27
  const cwd = state.projectPath;
14
28
  const [data, setData] = useState(null);
15
29
  const [loading, setLoading] = useState(true);
@@ -32,30 +46,37 @@ export function GitignoreScreen() {
32
46
  useEffect(() => {
33
47
  void reload();
34
48
  }, [reload]);
35
- // Build the unified row list: every rule, sorted with violations first so
36
- // problems are visible at the top of the list without scrolling.
49
+ // Build the managed list: every desired gitignore rule, with all matching
50
+ // violations grouped under the rule that owns them.
37
51
  const rows = useMemo(() => {
38
52
  if (!data)
39
53
  return [];
40
- const violationByPattern = new Map();
41
- for (const v of data.violations) {
42
- // A violation's `path` is the rule's pattern for ignored-but-should-track
43
- // and missing-from-gitignore; for tracked-but-should-ignore it's the
44
- // actual tracked file under a directory rule. Index by rule pattern by
45
- // matching either path or its prefix.
46
- // Simplest: index by path; lookup will try exact then prefix.
47
- violationByPattern.set(v.path, v);
48
- }
49
54
  const annotated = data.resolved.rules.map((rule) => {
50
- const v = violationByPattern.get(rule.pattern) ??
51
- findPrefixMatch(rule, data.violations);
52
- return { rule, violation: v };
55
+ const violations = data.violations.filter((v) => violationMatchesRule(rule, v));
56
+ return { rule, violations };
53
57
  });
58
+ // Display order = flat index order (the keyboard handler walks this array),
59
+ // so the on-screen layout must match this sort exactly:
60
+ // 1. violations first (most violations first)
61
+ // 2. then clean rows grouped by source manifest (SOURCE_ORDER)
62
+ // 3. alphabetical within each group
63
+ const sourceRank = (tier) => {
64
+ const i = SOURCE_ORDER.indexOf(tier);
65
+ return i === -1 ? SOURCE_ORDER.length : i;
66
+ };
54
67
  return annotated.sort((a, b) => {
55
- const aBad = a.violation ? 0 : 1;
56
- const bBad = b.violation ? 0 : 1;
57
- if (aBad !== bBad)
58
- return aBad - bBad;
68
+ const aViol = a.violations.length > 0;
69
+ const bViol = b.violations.length > 0;
70
+ if (aViol !== bViol)
71
+ return aViol ? -1 : 1;
72
+ if (aViol && bViol && a.violations.length !== b.violations.length) {
73
+ return b.violations.length - a.violations.length;
74
+ }
75
+ if (!aViol) {
76
+ const sr = sourceRank(a.rule.source) - sourceRank(b.rule.source);
77
+ if (sr !== 0)
78
+ return sr;
79
+ }
59
80
  return a.rule.pattern.localeCompare(b.rule.pattern);
60
81
  });
61
82
  }, [data]);
@@ -66,41 +87,76 @@ export function GitignoreScreen() {
66
87
  }
67
88
  }, [rows.length, selectedIdx]);
68
89
  const selectedRow = rows[selectedIdx];
69
- const violationCount = data?.violations.length ?? 0;
70
- const safeCount = data?.violations.filter((v) => v.severity === "safe").length ?? 0;
71
- const destructiveCount = violationCount - safeCount;
90
+ const ignoreRuleCount = data?.resolved.rules.filter((rule) => rule.action === "ignore").length ?? 0;
91
+ const trackRuleCount = data?.resolved.rules.filter((rule) => rule.action === "track").length ?? 0;
92
+ const toFixCount = data?.violations.length ?? 0;
93
+ // Group rows for display while preserving each row's flat index in `rows`
94
+ // (the keyboard handler walks `rows` by index, so the highlight must track
95
+ // the original index, not the position within a rendered group).
96
+ const indexed = rows.map((row, index) => ({ row, index }));
97
+ const toFixRows = indexed.filter((r) => r.row.violations.length > 0);
98
+ // Clean rows grouped by which manifest defined them. Sections render only
99
+ // when non-empty, so e.g. the Profile group stays hidden until profiles
100
+ // actually contribute rules.
101
+ const cleanIndexed = indexed.filter((r) => r.row.violations.length === 0);
102
+ const sourceGroups = SOURCE_ORDER.map((tier) => ({
103
+ tier,
104
+ label: SOURCE_LABELS[tier],
105
+ items: cleanIndexed.filter((r) => r.row.rule.source === tier),
106
+ })).filter((g) => g.items.length > 0);
107
+ const cleanCount = cleanIndexed.length;
72
108
  // ── Action handlers ───────────────────────────────────────────────────
73
- const handleApplySafe = useCallback(async () => {
74
- if (!data || safeCount === 0)
75
- return;
76
- const r = await applyAllSafeFixes(cwd, data.violations);
77
- await modal.message("Applied safe fixes", `Appended ${r.appended.length} pattern(s) to .gitignore.${r.alreadyPresent.length > 0
78
- ? `\n\n${r.alreadyPresent.length} pattern(s) were already present.`
79
- : ""}`, "success");
80
- await reload();
81
- }, [cwd, data, safeCount, modal, reload]);
82
- const handleApplySelected = useCallback(async () => {
83
- if (!selectedRow?.violation)
109
+ // Fix every out-of-sync row. Iterates the grouped rows (not the flat
110
+ // violation list) so each .gitignore entry uses the rule's pattern/glob,
111
+ // not one specific path under it — same association handleApplyOne relies on.
112
+ const handleFixAll = useCallback(async () => {
113
+ if (!data || toFixRows.length === 0)
84
114
  return;
85
- const v = selectedRow.violation;
86
- if (v.severity === "safe") {
87
- await handleApplySafe();
115
+ try {
116
+ const messages = [];
117
+ const failures = [];
118
+ for (const { row } of toFixRows) {
119
+ for (const v of row.violations) {
120
+ const r = await applyDestructiveFix(cwd, v, row.rule.pattern);
121
+ (r.applied ? messages : failures).push(r.message);
122
+ }
123
+ }
124
+ await reload();
125
+ if (failures.length > 0) {
126
+ await modal.message("Some fixes didn't apply", [...failures, ...messages].join("\n"), "error");
127
+ }
128
+ }
129
+ catch (err) {
130
+ await modal.message("Fix failed", err instanceof Error ? err.message : String(err), "error");
131
+ }
132
+ }, [cwd, data, toFixRows, reload, modal]);
133
+ // Fix just the highlighted row.
134
+ const handleFixOne = useCallback(async () => {
135
+ if (!selectedRow) {
136
+ await modal.message("No row selected", "Use ↑↓ to highlight a row that needs fixing, then press f.", "info");
88
137
  return;
89
138
  }
90
- const confirmed = await modal.confirm("Apply destructive fix?", `${v.kind} on ${v.path}\n\n` +
91
- `This mutates the git index (e.g. \`git rm --cached\` or \`git add\`).\n` +
92
- `You can review the change with \`git status\` afterwards.`);
93
- if (!confirmed)
139
+ if (selectedRow.violations.length === 0) {
140
+ await modal.message("Nothing to fix", `${selectedRow.rule.pattern} already matches. f only applies to rows that need fixing.`, "info");
94
141
  return;
142
+ }
95
143
  try {
96
- const result = await applyDestructiveFix(cwd, v);
97
- await modal.message(result.applied ? "Fix applied" : "Fix failed", result.message, result.applied ? "success" : "error");
144
+ const results = [];
145
+ for (const v of selectedRow.violations) {
146
+ results.push(await applyDestructiveFix(cwd, v, selectedRow.rule.pattern));
147
+ }
148
+ const failed = results.filter((r) => !r.applied);
149
+ await reload();
150
+ // Success is silent — the reload removes the fixed row. Only surface a
151
+ // modal when something didn't apply.
152
+ if (failed.length > 0) {
153
+ await modal.message("Some fixes didn't apply", results.map((r) => r.message).join("\n"), "error");
154
+ }
98
155
  }
99
156
  catch (err) {
100
157
  await modal.message("Fix failed", err instanceof Error ? err.message : String(err), "error");
101
158
  }
102
- await reload();
103
- }, [selectedRow, handleApplySafe, modal, cwd, reload]);
159
+ }, [selectedRow, modal, cwd, reload]);
104
160
  const handleApplyTemplate = useCallback(async () => {
105
161
  const names = Object.keys(BUILTIN_TEMPLATES);
106
162
  if (names.length === 0) {
@@ -125,16 +181,32 @@ export function GitignoreScreen() {
125
181
  await reload();
126
182
  }, [cwd, modal, reload]);
127
183
  const handleEditManifest = useCallback(async () => {
128
- const projectManifest = `${cwd}/.claude/gitignore.json`;
184
+ const current = data ?? await loadGitignoreState(cwd);
185
+ const projectManifest = await ensureProjectManifestForEdit(cwd, current.resolved);
129
186
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
130
- await new Promise((resolveEdit) => {
131
- const child = spawn(editor, [projectManifest], { stdio: "inherit" });
132
- child.on("exit", () => resolveEdit());
133
- child.on("error", () => resolveEdit());
134
- });
187
+ renderer.suspend();
188
+ try {
189
+ await new Promise((resolveEdit) => {
190
+ const child = spawn(editor, [projectManifest], { stdio: "inherit" });
191
+ child.on("exit", () => resolveEdit());
192
+ child.on("error", () => resolveEdit());
193
+ });
194
+ }
195
+ finally {
196
+ renderer.resume();
197
+ }
135
198
  await reload();
136
- }, [cwd, reload]);
199
+ renderer.requestRender();
200
+ }, [cwd, data, reload, renderer]);
137
201
  useKeyboardHandler((input, key) => {
202
+ if (process.env.CLAUDEUP_DEBUG_KEYS === "1") {
203
+ try {
204
+ appendFileSync("/tmp/claudeup-keys.log", `${new Date().toISOString()} screen=gitignore input=${JSON.stringify(input)} name=${JSON.stringify(key.name)} shift=${key.shift} ctrl=${key.ctrl} meta=${key.meta}\n`);
205
+ }
206
+ catch {
207
+ // best-effort logging — never break the UI
208
+ }
209
+ }
138
210
  if (state.modal)
139
211
  return;
140
212
  if (input === "j" || key.downArrow) {
@@ -145,12 +217,13 @@ export function GitignoreScreen() {
145
217
  setSelectedIdx((i) => Math.max(i - 1, 0));
146
218
  return;
147
219
  }
148
- if (input === "f") {
149
- void handleApplySafe();
220
+ // F = fix everything out of sync; f = fix just the selected row.
221
+ if (input === "F" || (input === "f" && key.shift)) {
222
+ void handleFixAll();
150
223
  return;
151
224
  }
152
- if (input === "F") {
153
- void handleApplySelected();
225
+ if (input === "f") {
226
+ void handleFixOne();
154
227
  return;
155
228
  }
156
229
  if (input === "t") {
@@ -166,62 +239,90 @@ export function GitignoreScreen() {
166
239
  }
167
240
  });
168
241
  // ── Rendering ─────────────────────────────────────────────────────────
169
- const summary = data ? renderSummary(violationCount, safeCount, destructiveCount) : null;
170
- const listBody = loading ? (_jsx("text", { fg: "gray", children: "Loading..." })) : error ? (_jsxs("text", { fg: "red", children: ["Error: ", error] })) : rows.length === 0 ? (_jsx("text", { fg: "gray", children: "No rules resolved." })) : (_jsx("box", { flexDirection: "column", children: rows.slice(0, 200).map((row, i) => renderRuleRow(row, i === selectedIdx)) }));
242
+ const summary = data
243
+ ? renderSummary(rows.length, ignoreRuleCount, trackRuleCount, toFixCount)
244
+ : null;
245
+ const listBody = loading ? (_jsx("text", { fg: "gray", children: "Loading..." })) : error ? (_jsxs("text", { fg: "red", children: ["Error: ", error] })) : rows.length === 0 ? (_jsx("text", { fg: "gray", children: "No rules resolved." })) : (_jsxs("box", { flexDirection: "column", children: [toFixRows.length > 0 && (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "red", children: `Needs fixing (${toFixRows.length})` }), toFixRows
246
+ .slice(0, 100)
247
+ .map(({ row, index }) => renderRuleRow(row, index === selectedIdx)), _jsx("text", { children: " " })] })), _jsx("text", { fg: "gray", children: `Managed items (${cleanCount})` }), sourceGroups.map((group, gi) => (_jsxs("box", { flexDirection: "column", children: [gi > 0 && _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: ` ${group.label}` }), group.items.map(({ row, index }) => renderRuleRow(row, index === selectedIdx))] }, group.tier)))] }));
171
248
  const listPanel = (_jsxs("box", { flexDirection: "column", children: [summary, _jsx("text", { children: " " }), listBody] }));
172
249
  const detailPanel = renderDetail(selectedRow, data?.warnings ?? []);
173
250
  const subtitle = data
174
- ? violationCount === 0
175
- ? "all clean"
176
- : `${violationCount} violation${violationCount === 1 ? "" : "s"}`
251
+ ? toFixCount === 0
252
+ ? `${rows.length} managed`
253
+ : `${toFixCount} to fix`
177
254
  : "";
178
- return (_jsx(ScreenLayout, { title: "Gitignore Manager", subtitle: subtitle, currentScreen: "gitignore", footerHints: "\u2191\u2193 move f fix-all-safe F fix-this t tpl e edit r reload", listPanel: listPanel, detailPanel: detailPanel }));
255
+ return (_jsx(ScreenLayout, { title: "Managed Git State", subtitle: subtitle, currentScreen: "gitignore", footerHints: [
256
+ { keys: ["↑", "↓"], label: "move" },
257
+ { keys: ["f"], label: "fix selected" },
258
+ { keys: ["F"], label: "fix all" },
259
+ { keys: ["t"], label: "template" },
260
+ { keys: ["e"], label: "edit" },
261
+ { keys: ["r"], label: "reload" },
262
+ ], listPanel: listPanel, detailPanel: detailPanel }));
179
263
  }
180
- function findPrefixMatch(rule, violations) {
181
- // A directory rule like "ai-docs/sessions/" can have a violation on a child
182
- // path like "ai-docs/sessions/leak.md". Match by path-startsWith-pattern.
264
+ function violationMatchesRule(rule, violation) {
265
+ if (violation.path === rule.pattern)
266
+ return true;
183
267
  if (!rule.pattern.endsWith("/") && !rule.pattern.includes("*"))
184
- return undefined;
268
+ return false;
185
269
  const prefix = rule.pattern.replace(/\*+$/, "");
186
- return violations.find((v) => v.path.startsWith(prefix));
270
+ return violation.path.startsWith(prefix);
187
271
  }
188
- function renderSummary(violations, safe, destructive) {
189
- if (violations === 0) {
190
- return _jsx("text", { fg: "green", children: "\u2713 All rules satisfied \u2014 nothing to fix." });
191
- }
192
- return (_jsxs("text", { children: [_jsx("span", { fg: "white", children: `${violations} violation${violations === 1 ? "" : "s"} ` }), _jsx("span", { fg: "gray", children: `(` }), safe > 0 && _jsx("span", { fg: "yellow", children: `${safe} safe` }), safe > 0 && destructive > 0 && _jsx("span", { fg: "gray", children: `, ` }), destructive > 0 && _jsx("span", { fg: "red", children: `${destructive} destructive` }), _jsx("span", { fg: "gray", children: `)` })] }));
272
+ function renderSummary(rules, ignoreRules, trackRules, toFix) {
273
+ return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { children: _jsx("span", { fg: "white", children: `${rules} managed item${rules === 1 ? "" : "s"}` }) }), _jsx("text", { children: _jsx("span", { fg: "gray", children: `${ignoreRules} ignored ${trackRules} tracked` }) }), toFix === 0 ? (_jsx("text", { fg: "green", children: "Everything matches \u2014 nothing to fix." })) : (_jsx("text", { fg: "yellow", children: `${toFix} need${toFix === 1 ? "s" : ""} fixing — f one, F all` }))] }));
193
274
  }
194
275
  function renderDetail(row, warnings) {
195
276
  if (!row) {
196
277
  return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "gray", children: "No rule selected." }), warnings.length > 0 && (_jsxs("box", { flexDirection: "column", marginTop: 1, children: [_jsx("text", { fg: "yellow", children: "Warnings:" }), warnings.map((w, i) => (_jsx("text", { fg: "gray", children: w }, i)))] }))] }));
197
278
  }
198
- const { rule, violation } = row;
199
- if (!violation) {
200
- // Rule is satisfied — show what it enforces and where it came from
201
- return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "white", children: _jsx("strong", { children: rule.pattern }) }), _jsx("text", { children: " " }), _jsx("text", { fg: "green", children: `✓ ${rule.action === "ignore" ? "Ignored" : "Tracked"} as required.` }), _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: `Rule from ${rule.source} tier.` })] }));
279
+ const { rule, violations } = row;
280
+ const reason = getGitignoreRuleReason(rule.action, rule.pattern);
281
+ const setIn = sourceName(rule.source);
282
+ if (violations.length === 0) {
283
+ return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "white", children: _jsx("strong", { children: rule.pattern }) }), _jsx("text", { children: " " }), _jsx("text", { fg: "green", children: `✓ Already ${rule.action === "ignore" ? "ignored" : "tracked"} — nothing to do.` }), _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: reason }), _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: `Set in: ${setIn}` }), renderWarnings(warnings)] }));
284
+ }
285
+ return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "white", children: _jsx("strong", { children: rule.pattern }) }), _jsx("text", { children: " " }), _jsxs("text", { children: [_jsx("span", { fg: "gray", children: "Should be: " }), _jsx("span", { fg: "white", children: rule.action === "ignore" ? "ignored by git" : "tracked by git" })] }), _jsx("text", { fg: "gray", children: reason }), _jsx("text", { fg: "gray", children: `Set in: ${setIn}` }), _jsx("text", { children: " " }), _jsx("text", { fg: "yellow", children: `Needs fixing (${violations.length}):` }), violations.map((v, i) => (_jsxs("box", { flexDirection: "column", marginTop: i === 0 ? 0 : 1, children: [_jsx("text", { fg: "white", children: v.path }), _jsx("text", { fg: "gray", children: describeKind(v.kind) }), _jsx("text", { fg: v.severity === "safe" ? "cyan" : "red", children: actionHint(v.kind) })] }, `${v.kind}:${v.path}:${i}`))), renderWarnings(warnings)] }));
286
+ }
287
+ /** Friendly, where-to-change-it name for a rule's source manifest. */
288
+ function sourceName(source) {
289
+ switch (source) {
290
+ case "project":
291
+ return "Project (.claude/gitignore.json)";
292
+ case "global":
293
+ return "Global (~/.claude/gitignore.json)";
294
+ case "profile":
295
+ return "Profile";
296
+ case "builtin":
297
+ return "Default (built into claudeup)";
202
298
  }
203
- // Rule is violated — explain and show the action
204
- return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "white", children: _jsx("strong", { children: violation.path }) }), _jsx("text", { children: " " }), _jsx("text", { fg: "white", children: describeKind(violation.kind) }), _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: `Rule from ${rule.source} tier.` }), _jsx("text", { fg: violation.severity === "safe" ? "yellow" : "red", children: violation.severity === "safe"
205
- ? "Safe — only appends to .gitignore."
206
- : "Destructive — mutates the git index." }), _jsx("text", { children: " " }), _jsx("text", { fg: "cyan", children: actionHint(violation.kind) })] }));
207
299
  }
208
300
  function describeKind(kind) {
209
301
  switch (kind) {
210
- case "tracked-but-should-ignore": return "This file is in the git index but the manifest says it should be ignored.";
211
- case "ignored-but-should-track": return "Your .gitignore excludes this path but the manifest says it should be tracked.";
212
- case "untracked-and-should-track": return "This path exists on disk but git doesn't track it; the manifest says it should.";
213
- case "missing-from-gitignore": return "The manifest says ignore this path, but it's not in any .gitignore line.";
302
+ case "tracked-but-should-ignore":
303
+ return "Git is tracking it, but it should be ignored.";
304
+ case "ignored-but-should-track":
305
+ return "A .gitignore rule hides it, but it should be in git.";
306
+ case "untracked-and-should-track":
307
+ return "It's on disk but git isn't tracking it yet.";
308
+ case "missing-from-gitignore":
309
+ return "It should be ignored, but nothing in .gitignore covers it.";
214
310
  }
215
311
  }
216
312
  function actionHint(kind) {
217
313
  switch (kind) {
218
314
  case "tracked-but-should-ignore":
219
- return "[F] runs `git rm --cached` and appends a .gitignore entry.";
315
+ return "Press f to stop tracking it and add it to .gitignore.";
220
316
  case "ignored-but-should-track":
221
- return "[F] removes the matching .gitignore line and runs `git add`.";
317
+ return "Press f to remove the .gitignore line and add it to git.";
222
318
  case "untracked-and-should-track":
223
- return "[F] runs `git add` to stage the path.";
319
+ return "Press f to add it to git.";
224
320
  case "missing-from-gitignore":
225
- return "[f] appends this and every other safe entry to .gitignore.";
321
+ return "Press f to add it to .gitignore (F to fix all).";
226
322
  }
227
323
  }
324
+ function renderWarnings(warnings) {
325
+ if (warnings.length === 0)
326
+ return null;
327
+ return (_jsxs("box", { flexDirection: "column", marginTop: 1, children: [_jsx("text", { fg: "yellow", children: "Warnings:" }), warnings.map((w, i) => (_jsx("text", { fg: "gray", children: w }, i)))] }));
328
+ }