claudeup 4.15.1 → 4.16.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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/gitignore-detector.test.ts +156 -0
  3. package/src/__tests__/gitignore-fixer.test.ts +197 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +75 -0
  5. package/src/__tests__/gitignore-resolver.test.ts +166 -0
  6. package/src/__tests__/gitignore-service.test.ts +93 -0
  7. package/src/__tests__/useGitignoreModal.test.ts +71 -0
  8. package/src/data/gitignore-defaults.js +24 -0
  9. package/src/data/gitignore-defaults.ts +26 -0
  10. package/src/data/gitignore-templates.js +21 -0
  11. package/src/data/gitignore-templates.ts +25 -0
  12. package/src/prerunner/index.js +17 -0
  13. package/src/prerunner/index.ts +20 -0
  14. package/src/services/gitignore-detector.js +155 -0
  15. package/src/services/gitignore-detector.ts +157 -0
  16. package/src/services/gitignore-fixer.js +171 -0
  17. package/src/services/gitignore-fixer.ts +198 -0
  18. package/src/services/gitignore-prerun.js +46 -0
  19. package/src/services/gitignore-prerun.ts +59 -0
  20. package/src/services/gitignore-resolver.js +143 -0
  21. package/src/services/gitignore-resolver.ts +190 -0
  22. package/src/services/gitignore-service.js +99 -0
  23. package/src/services/gitignore-service.ts +142 -0
  24. package/src/types/gitignore.js +6 -0
  25. package/src/types/gitignore.ts +68 -0
  26. package/src/ui/App.js +47 -3
  27. package/src/ui/App.tsx +51 -2
  28. package/src/ui/components/TabBar.js +1 -0
  29. package/src/ui/components/TabBar.tsx +1 -0
  30. package/src/ui/hooks/useGitignoreModal.js +75 -0
  31. package/src/ui/hooks/useGitignoreModal.ts +97 -0
  32. package/src/ui/renderers/gitignoreRenderers.js +33 -0
  33. package/src/ui/renderers/gitignoreRenderers.tsx +70 -0
  34. package/src/ui/screens/GitignoreScreen.js +227 -0
  35. package/src/ui/screens/GitignoreScreen.tsx +364 -0
  36. package/src/ui/screens/index.js +1 -0
  37. package/src/ui/screens/index.ts +1 -0
  38. package/src/ui/state/types.ts +4 -2
package/src/ui/App.tsx CHANGED
@@ -21,8 +21,15 @@ import {
21
21
  ModelSelectorScreen,
22
22
  ProfilesScreen,
23
23
  SkillsScreen,
24
+ GitignoreScreen,
24
25
  } from "./screens/index.js";
25
26
  import type { Screen } from "./state/types.js";
27
+ import { checkGitignore } from "../services/gitignore-prerun.js";
28
+ import {
29
+ loadGitignoreState,
30
+ applyAllSafeFixes,
31
+ } from "../services/gitignore-service.js";
32
+ import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
26
33
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
27
34
  import {
28
35
  migrateMarketplaceRename,
@@ -70,6 +77,8 @@ function Router() {
70
77
  return <ProfilesScreen />;
71
78
  case "skills":
72
79
  return <SkillsScreen />;
80
+ case "gitignore":
81
+ return <GitignoreScreen />;
73
82
  default:
74
83
  return <PluginsScreen />;
75
84
  }
@@ -120,7 +129,7 @@ function GlobalKeyHandler({
120
129
  // Don't handle keys when modal is open or searching
121
130
  if (state.modal || state.isSearching) return;
122
131
 
123
- // Global navigation shortcuts (1-5) - include mcp-registry as it's a sub-screen of mcp
132
+ // Global navigation shortcuts (1-7) - include mcp-registry as it's a sub-screen of mcp
124
133
  const isTopLevel = [
125
134
  "plugins",
126
135
  "mcp",
@@ -130,6 +139,7 @@ function GlobalKeyHandler({
130
139
  "model-selector",
131
140
  "profiles",
132
141
  "skills",
142
+ "gitignore",
133
143
  ].includes(state.currentRoute.screen);
134
144
 
135
145
  if (isTopLevel) {
@@ -139,6 +149,7 @@ function GlobalKeyHandler({
139
149
  else if (input === "4") navigateToScreen("settings");
140
150
  else if (input === "5") navigateToScreen("profiles");
141
151
  else if (input === "6") navigateToScreen("cli-tools");
152
+ else if (input === "7") navigateToScreen("gitignore");
142
153
 
143
154
  // Tab navigation cycling
144
155
  if (key.tab) {
@@ -149,6 +160,7 @@ function GlobalKeyHandler({
149
160
  "settings",
150
161
  "profiles",
151
162
  "cli-tools",
163
+ "gitignore",
152
164
  ];
153
165
  const currentIndex = screens.indexOf(
154
166
  state.currentRoute.screen as Screen,
@@ -187,7 +199,7 @@ function GlobalKeyHandler({
187
199
  ? This help
188
200
 
189
201
  Quick Navigation
190
- 1 Plugins 4 Settings
202
+ 1 Plugins 4 Settings 7 Gitignore
191
203
  2 Skills 5 Profiles
192
204
  3 MCP Servers 6 CLI Tools
193
205
 
@@ -319,6 +331,11 @@ function AppContent({ onExit }: AppContentProps) {
319
331
  const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
320
332
  const [mismatchData, setMismatchData] = useState<VersionMismatchInfo[] | null>(null);
321
333
  const mismatchModal = useMismatchModal();
334
+ const [gitignoreData, setGitignoreData] = useState<{
335
+ violationCount: number;
336
+ safeCount: number;
337
+ } | null>(null);
338
+ const gitignoreModal = useGitignoreModal();
322
339
 
323
340
  // Check for updates on startup (non-blocking)
324
341
  useEffect(() => {
@@ -341,6 +358,22 @@ function AppContent({ onExit }: AppContentProps) {
341
358
  setMismatchData(null);
342
359
  }, [mismatchData, mismatchModal]);
343
360
 
361
+ // Show gitignore modal — wait for the modal slot to be free so we don't
362
+ // stomp on the mismatch modal that may have just been shown.
363
+ useEffect(() => {
364
+ if (!gitignoreData) return;
365
+ if (state.modal) return;
366
+ gitignoreModal.show({
367
+ violationCount: gitignoreData.violationCount,
368
+ safeCount: gitignoreData.safeCount,
369
+ onFixSafe: async () => {
370
+ const s = await loadGitignoreState(state.projectPath);
371
+ await applyAllSafeFixes(state.projectPath, s.violations);
372
+ },
373
+ });
374
+ setGitignoreData(null);
375
+ }, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
376
+
344
377
  // Auto-refresh marketplaces on startup
345
378
  useEffect(() => {
346
379
  const noRefresh = process.argv.includes("--no-refresh");
@@ -409,6 +442,22 @@ function AppContent({ onExit }: AppContentProps) {
409
442
  })
410
443
  .catch(() => {}); // non-fatal
411
444
 
445
+ // Check for gitignore manifest violations and surface a popup if any.
446
+ // Loads twice (once for count, once when user picks "fix safe") because
447
+ // the second load happens after edits — keeps detection cheap on startup.
448
+ (async () => {
449
+ try {
450
+ const result = await checkGitignore(process.cwd());
451
+ if (result.violationCount > 0) {
452
+ const s = await loadGitignoreState(process.cwd());
453
+ const safeCount = s.violations.filter((v) => v.severity === "safe").length;
454
+ setGitignoreData({ violationCount: result.violationCount, safeCount });
455
+ }
456
+ } catch {
457
+ // non-fatal
458
+ }
459
+ })();
460
+
412
461
  repairAllMarketplaces()
413
462
  .then(async () => {
414
463
  dispatch({ type: "HIDE_PROGRESS" });
@@ -6,6 +6,7 @@ const TABS = [
6
6
  { key: "4", label: "Settings", screen: "settings" },
7
7
  { key: "5", label: "Profiles", screen: "profiles" },
8
8
  { key: "6", label: "CLI", screen: "cli-tools" },
9
+ { key: "7", label: "Gitignore", screen: "gitignore" },
9
10
  ];
10
11
  export function TabBar({ currentScreen }) {
11
12
  return (_jsx("box", { flexDirection: "row", gap: 0, children: TABS.map((tab, index) => {
@@ -14,6 +14,7 @@ const TABS: Tab[] = [
14
14
  { key: "4", label: "Settings", screen: "settings" },
15
15
  { key: "5", label: "Profiles", screen: "profiles" },
16
16
  { key: "6", label: "CLI", screen: "cli-tools" },
17
+ { key: "7", label: "Gitignore", screen: "gitignore" },
17
18
  ];
18
19
 
19
20
  interface TabBarProps {
@@ -0,0 +1,75 @@
1
+ import { useCallback } from "react";
2
+ import { useApp, useNavigation } from "../state/AppContext.js";
3
+ export function buildGitignoreModal(args) {
4
+ const { violationCount, safeCount, onFixSafe, onOpenTab, onDismiss } = args;
5
+ const options = [
6
+ {
7
+ label: safeCount > 0
8
+ ? `Append ${safeCount} safe entr${safeCount === 1 ? "y" : "ies"} to .gitignore`
9
+ : "(no safe-fixable entries)",
10
+ value: "fix-safe",
11
+ },
12
+ {
13
+ label: "Open Gitignore tab to review",
14
+ value: "open-tab",
15
+ },
16
+ {
17
+ label: "Dismiss",
18
+ value: "dismiss",
19
+ },
20
+ ];
21
+ return {
22
+ type: "select",
23
+ title: "Gitignore violations detected",
24
+ message: `Found ${violationCount} issue(s) where the resolved gitignore manifest ` +
25
+ `disagrees with your working tree.\n\n` +
26
+ `Safe fixes append to .gitignore (no index mutation). Destructive fixes ` +
27
+ `(\`git rm --cached\`, removing .gitignore lines) must be applied one at ` +
28
+ `a time from the Gitignore tab.`,
29
+ options,
30
+ onSelect: (value) => {
31
+ if (value === "fix-safe")
32
+ onFixSafe();
33
+ else if (value === "open-tab")
34
+ onOpenTab();
35
+ else
36
+ onDismiss();
37
+ },
38
+ onCancel: onDismiss,
39
+ };
40
+ }
41
+ export function useGitignoreModal() {
42
+ const { dispatch } = useApp();
43
+ const { navigateToScreen } = useNavigation();
44
+ const show = useCallback((args) => {
45
+ if (args.violationCount === 0)
46
+ return;
47
+ const dismiss = () => dispatch({ type: "HIDE_MODAL" });
48
+ const modal = buildGitignoreModal({
49
+ violationCount: args.violationCount,
50
+ safeCount: args.safeCount,
51
+ onFixSafe: () => {
52
+ dispatch({ type: "HIDE_MODAL" });
53
+ void Promise.resolve(args.onFixSafe()).catch((err) => {
54
+ dispatch({
55
+ type: "SHOW_MODAL",
56
+ modal: {
57
+ type: "message",
58
+ title: "Safe fixes failed",
59
+ message: err instanceof Error ? err.message : String(err),
60
+ variant: "error",
61
+ onDismiss: dismiss,
62
+ },
63
+ });
64
+ });
65
+ },
66
+ onOpenTab: () => {
67
+ dispatch({ type: "HIDE_MODAL" });
68
+ navigateToScreen("gitignore");
69
+ },
70
+ onDismiss: dismiss,
71
+ });
72
+ dispatch({ type: "SHOW_MODAL", modal });
73
+ }, [dispatch, navigateToScreen]);
74
+ return { show };
75
+ }
@@ -0,0 +1,97 @@
1
+ import { useCallback } from "react";
2
+ import { useApp, useNavigation } from "../state/AppContext.js";
3
+ import type { ModalState } from "../state/types.js";
4
+
5
+ /**
6
+ * Startup gitignore-violation modal. Mirrors `useMismatchModal` so the
7
+ * two flows feel identical to the user — claudeup launches, detects
8
+ * a problem, asks "fix safe entries / open the tab / dismiss".
9
+ *
10
+ * Pure builder is exported separately for tests.
11
+ */
12
+
13
+ export interface BuildModalArgs {
14
+ violationCount: number;
15
+ safeCount: number;
16
+ onFixSafe: () => void;
17
+ onOpenTab: () => void;
18
+ onDismiss: () => void;
19
+ }
20
+
21
+ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
22
+ const { violationCount, safeCount, onFixSafe, onOpenTab, onDismiss } = args;
23
+ const options = [
24
+ {
25
+ label:
26
+ safeCount > 0
27
+ ? `Append ${safeCount} safe entr${safeCount === 1 ? "y" : "ies"} to .gitignore`
28
+ : "(no safe-fixable entries)",
29
+ value: "fix-safe",
30
+ },
31
+ {
32
+ label: "Open Gitignore tab to review",
33
+ value: "open-tab",
34
+ },
35
+ {
36
+ label: "Dismiss",
37
+ value: "dismiss",
38
+ },
39
+ ];
40
+ return {
41
+ type: "select",
42
+ title: "Gitignore violations detected",
43
+ message:
44
+ `Found ${violationCount} issue(s) where the resolved gitignore manifest ` +
45
+ `disagrees with your working tree.\n\n` +
46
+ `Safe fixes append to .gitignore (no index mutation). Destructive fixes ` +
47
+ `(\`git rm --cached\`, removing .gitignore lines) must be applied one at ` +
48
+ `a time from the Gitignore tab.`,
49
+ options,
50
+ onSelect: (value: string) => {
51
+ if (value === "fix-safe") onFixSafe();
52
+ else if (value === "open-tab") onOpenTab();
53
+ else onDismiss();
54
+ },
55
+ onCancel: onDismiss,
56
+ };
57
+ }
58
+
59
+ export function useGitignoreModal() {
60
+ const { dispatch } = useApp();
61
+ const { navigateToScreen } = useNavigation();
62
+
63
+ const show = useCallback(
64
+ (args: { violationCount: number; safeCount: number; onFixSafe: () => Promise<void> | void }) => {
65
+ if (args.violationCount === 0) return;
66
+ const dismiss = () => dispatch({ type: "HIDE_MODAL" });
67
+ const modal = buildGitignoreModal({
68
+ violationCount: args.violationCount,
69
+ safeCount: args.safeCount,
70
+ onFixSafe: () => {
71
+ dispatch({ type: "HIDE_MODAL" });
72
+ void Promise.resolve(args.onFixSafe()).catch((err) => {
73
+ dispatch({
74
+ type: "SHOW_MODAL",
75
+ modal: {
76
+ type: "message",
77
+ title: "Safe fixes failed",
78
+ message: err instanceof Error ? err.message : String(err),
79
+ variant: "error",
80
+ onDismiss: dismiss,
81
+ },
82
+ });
83
+ });
84
+ },
85
+ onOpenTab: () => {
86
+ dispatch({ type: "HIDE_MODAL" });
87
+ navigateToScreen("gitignore");
88
+ },
89
+ onDismiss: dismiss,
90
+ });
91
+ dispatch({ type: "SHOW_MODAL", modal });
92
+ },
93
+ [dispatch, navigateToScreen],
94
+ );
95
+
96
+ return { show };
97
+ }
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ const SELECTION_BG = "#7e57c2";
3
+ const SELECTION_FG = "white";
4
+ // Layout: " X Verb path" — 2 (gutter) + 2 (status) + 7 (verb) + 4 (gap)
5
+ // = 15 prefix chars. On a 38-col panel inner width that leaves 23 chars for
6
+ // the path; truncate at 22 to keep 1 col safety margin (OpenTUI <text>
7
+ // reserves a second line if the rendered string equals the inner width).
8
+ const MAX_PATH_LEN = 22;
9
+ function truncatePath(p) {
10
+ return p.length > MAX_PATH_LEN ? p.slice(0, MAX_PATH_LEN - 1) + "…" : p;
11
+ }
12
+ export function renderRuleRow(row, isSelected) {
13
+ const { rule, violation } = row;
14
+ const path = truncatePath(rule.pattern);
15
+ const key = `${rule.action}:${rule.pattern}`;
16
+ // Layout: status-glyph (2) + verb (7) + 2 spaces + path
17
+ // OK rule: ✓ Ignore path
18
+ // Violated:! Track path
19
+ const verb = rule.action === "ignore" ? "Ignore " : "Track ";
20
+ if (!violation) {
21
+ // Clean rule — muted, just confirmation
22
+ if (isSelected) {
23
+ return (_jsx("text", { bg: SELECTION_BG, fg: SELECTION_FG, children: `▶ ✓ ${verb} ${path}` }, key));
24
+ }
25
+ return (_jsxs("text", { children: [_jsx("span", { fg: "green", children: ` ✓ ` }), _jsx("span", { fg: "gray", children: `${verb} ${path}` })] }, key));
26
+ }
27
+ // Violated rule — color encodes severity of the fix
28
+ const sevColor = violation.severity === "safe" ? "yellow" : "red";
29
+ if (isSelected) {
30
+ return (_jsx("text", { bg: SELECTION_BG, fg: SELECTION_FG, children: `▶ ! ${verb} ${path}` }, key));
31
+ }
32
+ return (_jsxs("text", { children: [_jsx("span", { fg: sevColor, children: ` ! ` }), _jsx("span", { fg: "white", children: `${verb} ${path}` })] }, key));
33
+ }
@@ -0,0 +1,70 @@
1
+ import React from "react";
2
+ import type { ResolvedRule, Violation } from "../../types/gitignore.js";
3
+
4
+ /**
5
+ * A unified row for the Gitignore screen — represents one resolved rule,
6
+ * optionally annotated with the violation it has against the working tree.
7
+ */
8
+ export interface RuleRow {
9
+ rule: ResolvedRule;
10
+ violation?: Violation;
11
+ }
12
+
13
+ const SELECTION_BG = "#7e57c2";
14
+ const SELECTION_FG = "white";
15
+
16
+ // Layout: " X Verb path" — 2 (gutter) + 2 (status) + 7 (verb) + 4 (gap)
17
+ // = 15 prefix chars. On a 38-col panel inner width that leaves 23 chars for
18
+ // the path; truncate at 22 to keep 1 col safety margin (OpenTUI <text>
19
+ // reserves a second line if the rendered string equals the inner width).
20
+ const MAX_PATH_LEN = 22;
21
+
22
+ function truncatePath(p: string): string {
23
+ return p.length > MAX_PATH_LEN ? p.slice(0, MAX_PATH_LEN - 1) + "…" : p;
24
+ }
25
+
26
+ export function renderRuleRow(row: RuleRow, isSelected: boolean): React.ReactNode {
27
+ const { rule, violation } = row;
28
+ const path = truncatePath(rule.pattern);
29
+ const key = `${rule.action}:${rule.pattern}`;
30
+
31
+ // Layout: status-glyph (2) + verb (7) + 2 spaces + path
32
+ // OK rule: ✓ Ignore path
33
+ // Violated:! Track path
34
+
35
+ const verb = rule.action === "ignore" ? "Ignore " : "Track ";
36
+
37
+ if (!violation) {
38
+ // Clean rule — muted, just confirmation
39
+ if (isSelected) {
40
+ return (
41
+ <text key={key} bg={SELECTION_BG} fg={SELECTION_FG}>
42
+ {`▶ ✓ ${verb} ${path}`}
43
+ </text>
44
+ );
45
+ }
46
+ return (
47
+ <text key={key}>
48
+ <span fg="green">{` ✓ `}</span>
49
+ <span fg="gray">{`${verb} ${path}`}</span>
50
+ </text>
51
+ );
52
+ }
53
+
54
+ // Violated rule — color encodes severity of the fix
55
+ const sevColor = violation.severity === "safe" ? "yellow" : "red";
56
+
57
+ if (isSelected) {
58
+ return (
59
+ <text key={key} bg={SELECTION_BG} fg={SELECTION_FG}>
60
+ {`▶ ! ${verb} ${path}`}
61
+ </text>
62
+ );
63
+ }
64
+ return (
65
+ <text key={key}>
66
+ <span fg={sevColor}>{` ! `}</span>
67
+ <span fg="white">{`${verb} ${path}`}</span>
68
+ </text>
69
+ );
70
+ }
@@ -0,0 +1,227 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useEffect, useState, useCallback, useMemo } from "react";
3
+ import { spawn } from "node:child_process";
4
+ import { useApp, useModal } from "../state/AppContext.js";
5
+ import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
6
+ import { ScreenLayout } from "../components/layout/index.js";
7
+ import { loadGitignoreState, applyAllSafeFixes, applyDestructiveFix, applyTemplate, } from "../../services/gitignore-service.js";
8
+ import { BUILTIN_TEMPLATES } from "../../data/gitignore-templates.js";
9
+ import { renderRuleRow } from "../renderers/gitignoreRenderers.js";
10
+ export function GitignoreScreen() {
11
+ const { state } = useApp();
12
+ const modal = useModal();
13
+ const cwd = state.projectPath;
14
+ const [data, setData] = useState(null);
15
+ const [loading, setLoading] = useState(true);
16
+ const [error, setError] = useState(null);
17
+ const [selectedIdx, setSelectedIdx] = useState(0);
18
+ const reload = useCallback(async () => {
19
+ setLoading(true);
20
+ setError(null);
21
+ try {
22
+ const s = await loadGitignoreState(cwd);
23
+ setData(s);
24
+ }
25
+ catch (err) {
26
+ setError(err instanceof Error ? err.message : String(err));
27
+ }
28
+ finally {
29
+ setLoading(false);
30
+ }
31
+ }, [cwd]);
32
+ useEffect(() => {
33
+ void reload();
34
+ }, [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.
37
+ const rows = useMemo(() => {
38
+ if (!data)
39
+ 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
+ const annotated = data.resolved.rules.map((rule) => {
50
+ const v = violationByPattern.get(rule.pattern) ??
51
+ findPrefixMatch(rule, data.violations);
52
+ return { rule, violation: v };
53
+ });
54
+ 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;
59
+ return a.rule.pattern.localeCompare(b.rule.pattern);
60
+ });
61
+ }, [data]);
62
+ // Clamp selection when rows shrink.
63
+ useEffect(() => {
64
+ if (selectedIdx >= rows.length) {
65
+ setSelectedIdx(Math.max(0, rows.length - 1));
66
+ }
67
+ }, [rows.length, selectedIdx]);
68
+ 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;
72
+ // ── 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)
84
+ return;
85
+ const v = selectedRow.violation;
86
+ if (v.severity === "safe") {
87
+ await handleApplySafe();
88
+ return;
89
+ }
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)
94
+ return;
95
+ try {
96
+ const result = await applyDestructiveFix(cwd, v);
97
+ await modal.message(result.applied ? "Fix applied" : "Fix failed", result.message, result.applied ? "success" : "error");
98
+ }
99
+ catch (err) {
100
+ await modal.message("Fix failed", err instanceof Error ? err.message : String(err), "error");
101
+ }
102
+ await reload();
103
+ }, [selectedRow, handleApplySafe, modal, cwd, reload]);
104
+ const handleApplyTemplate = useCallback(async () => {
105
+ const names = Object.keys(BUILTIN_TEMPLATES);
106
+ if (names.length === 0) {
107
+ await modal.message("No templates", "No built-in templates available.", "info");
108
+ return;
109
+ }
110
+ const selected = await modal.select("Apply template", "Merge the template into the project manifest at .claude/gitignore.json.", names.map((n) => ({ label: n, value: n })));
111
+ if (!selected)
112
+ return;
113
+ try {
114
+ const r = await applyTemplate(cwd, selected);
115
+ const conflictNote = r.conflicts.length > 0
116
+ ? `\n\nSkipped due to conflicts: ${r.conflicts.join(", ")}`
117
+ : "";
118
+ await modal.message("Template applied", `Wrote ${r.appliedTo}\n\n` +
119
+ `Added ${r.added.ignore.length} ignore + ${r.added.track.length} track entries.` +
120
+ conflictNote, "success");
121
+ }
122
+ catch (err) {
123
+ await modal.message("Template failed", err instanceof Error ? err.message : String(err), "error");
124
+ }
125
+ await reload();
126
+ }, [cwd, modal, reload]);
127
+ const handleEditManifest = useCallback(async () => {
128
+ const projectManifest = `${cwd}/.claude/gitignore.json`;
129
+ 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
+ });
135
+ await reload();
136
+ }, [cwd, reload]);
137
+ useKeyboardHandler((input, key) => {
138
+ if (state.modal)
139
+ return;
140
+ if (input === "j" || key.downArrow) {
141
+ setSelectedIdx((i) => Math.min(i + 1, Math.max(0, rows.length - 1)));
142
+ return;
143
+ }
144
+ if (input === "k" || key.upArrow) {
145
+ setSelectedIdx((i) => Math.max(i - 1, 0));
146
+ return;
147
+ }
148
+ if (input === "f") {
149
+ void handleApplySafe();
150
+ return;
151
+ }
152
+ if (input === "F") {
153
+ void handleApplySelected();
154
+ return;
155
+ }
156
+ if (input === "t") {
157
+ void handleApplyTemplate();
158
+ return;
159
+ }
160
+ if (input === "e") {
161
+ void handleEditManifest();
162
+ return;
163
+ }
164
+ if (input === "r") {
165
+ void reload();
166
+ }
167
+ });
168
+ // ── 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)) }));
171
+ const listPanel = (_jsxs("box", { flexDirection: "column", children: [summary, _jsx("text", { children: " " }), listBody] }));
172
+ const detailPanel = renderDetail(selectedRow, data?.warnings ?? []);
173
+ const subtitle = data
174
+ ? violationCount === 0
175
+ ? "all clean"
176
+ : `${violationCount} violation${violationCount === 1 ? "" : "s"}`
177
+ : "";
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 }));
179
+ }
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.
183
+ if (!rule.pattern.endsWith("/") && !rule.pattern.includes("*"))
184
+ return undefined;
185
+ const prefix = rule.pattern.replace(/\*+$/, "");
186
+ return violations.find((v) => v.path.startsWith(prefix));
187
+ }
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: `)` })] }));
193
+ }
194
+ function renderDetail(row, warnings) {
195
+ if (!row) {
196
+ 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
+ }
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.` })] }));
202
+ }
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
+ }
208
+ function describeKind(kind) {
209
+ 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.";
214
+ }
215
+ }
216
+ function actionHint(kind) {
217
+ switch (kind) {
218
+ case "tracked-but-should-ignore":
219
+ return "[F] runs `git rm --cached` and appends a .gitignore entry.";
220
+ case "ignored-but-should-track":
221
+ return "[F] removes the matching .gitignore line and runs `git add`.";
222
+ case "untracked-and-should-track":
223
+ return "[F] runs `git add` to stage the path.";
224
+ case "missing-from-gitignore":
225
+ return "[f] appends this and every other safe entry to .gitignore.";
226
+ }
227
+ }